implemented shared with me + verification

This commit is contained in:
nvrl committed 2026-07-29 21:29:07 +02:00
1 parent 551e14b644
commit a8210ce8fd
16 files changed
+1830 -224

No files matched your search

+110
View File
@@ -14,6 +14,7 @@ use serde::{Deserialize, Serialize};
use crate::cache::Pin;
use crate::error::Result;
pub use crate::mounts::{MountAccess, MountKind, MountMode, MountSpec};
/// Cap on the *write* half of a round-trip. A crashed daemon can leave its
/// listening socket in the kernel (e.g. as a `<defunct>` zombie): `connect`
@@ -194,6 +195,11 @@ pub enum Request {
/// it invalidates, it does not fetch.
Refresh { scope: RefreshScope },
// ---- locations --------------------------------------------------------
/// List every local Proton Drive location. Replies with
/// [`Response::Locations`].
ListLocations,
// ---- devices ----------------------------------------------------------
/// List the account's registered devices. Replies with [`Response::Devices`].
ListDevices,
@@ -1127,6 +1133,8 @@ pub enum Response {
},
/// The account's devices (reply to [`Request::ListDevices`]).
Devices { items: Vec<DeviceInfo> },
/// Every local Proton Drive location (reply to [`Request::ListLocations`]).
Locations { items: Vec<MountSpec> },
/// This device's synced folders (reply to [`Request::ListSyncFolders`]).
SyncFolders { items: Vec<SyncFolderInfo> },
/// Folders offered for restore (reply to [`Request::ListRestorableFolders`]).
@@ -1574,6 +1582,7 @@ mod tests {
#[test]
fn sharing_requests_roundtrip() {
let reqs = [
Request::ListLocations,
Request::ListDevices,
Request::RenameDevice {
uid: "dev-1".into(),
@@ -1698,6 +1707,107 @@ mod tests {
}
}
#[test]
fn locations_response_roundtrips_typed_mounts() {
let response = Response::Locations {
items: vec![
MountSpec {
id: 1,
kind: MountKind::MyFiles,
local_path: "/home/me/ProtonDrive".into(),
root_uid: "vol~root".into(),
root_share_id: "share-main".into(),
mode: MountMode::OnDemand,
access: MountAccess::Rw,
state: "idle".into(),
last_sync: 0,
pending_mode: None,
mounted: true,
progress: None,
},
MountSpec {
id: 2,
kind: MountKind::Device { sync_folder_id: 7 },
local_path: "/home/me/Work".into(),
root_uid: "device-vol~work".into(),
root_share_id: "device-share".into(),
mode: MountMode::Mirror,
access: MountAccess::Rw,
state: "syncing".into(),
last_sync: 42,
pending_mode: Some(MountMode::OnDemand),
mounted: false,
progress: None,
},
MountSpec {
id: 3,
kind: MountKind::Shared {
share_root_uid: "shared-vol~root".into(),
},
local_path: "/home/me/Shared".into(),
root_uid: "shared-vol~root".into(),
root_share_id: "shared-share".into(),
mode: MountMode::Unknown,
access: MountAccess::Ro,
state: "error".into(),
last_sync: 0,
pending_mode: Some(MountMode::Unknown),
mounted: false,
progress: Some(SyncProgress {
phase: SyncPhase::Applying,
done: 2,
total: 5,
current: "report.pdf".into(),
}),
},
],
};
let wire = serde_json::to_string(&response).unwrap();
assert!(!wire.contains('\n'), "wire form must be a single line");
assert!(wire.contains(r#""mode":"ondemand""#));
assert!(wire.contains(r#""kind":"shared""#));
assert!(wire.contains(r#""access":"ro""#));
assert!(wire.contains(r#""mode":"unknown""#));
let back: Response = serde_json::from_str(&wire).unwrap();
match back {
Response::Locations { items } => {
assert_eq!(items.len(), 3);
assert!(matches!(
items[1].kind,
MountKind::Device { sync_folder_id: 7 }
));
assert_eq!(items[1].pending_mode, Some(MountMode::OnDemand));
assert!(matches!(
&items[2].kind,
MountKind::Shared { share_root_uid }
if share_root_uid == "shared-vol~root"
));
assert_eq!(items[2].access, MountAccess::Ro);
assert_eq!(items[2].mode, MountMode::Unknown);
assert_eq!(items[2].pending_mode, Some(MountMode::Unknown));
let progress = items[2].progress.as_ref().unwrap();
assert_eq!(progress.phase, SyncPhase::Applying);
assert_eq!((progress.done, progress.total), (2, 5));
}
other => panic!("unexpected response: {other:?}"),
}
}
#[test]
fn legacy_daemons_reject_list_locations_cleanly() {
#[allow(dead_code)]
#[derive(serde::Deserialize)]
enum LegacyLocationRequest {
ListSyncFolders,
}
let wire = serde_json::to_string(&Request::ListLocations).unwrap();
assert!(
serde_json::from_str::<LegacyLocationRequest>(&wire).is_err(),
"an old daemon must reject ListLocations instead of interpreting another request"
);
}
#[test]
fn path_share_requests_keep_their_existing_wire_shape() {
let cases = [
+1
View File
@@ -77,6 +77,7 @@ const REPORTED_TABLES: &[&str] = &[
"trash",
"device",
"sync_folder",
"mount",
"sync_entry",
"pending_op",
"activity",
+48 -1
View File
@@ -9,7 +9,7 @@ use super::Db;
use crate::Result;
/// Current schema version. Bump on every forward migration added below.
pub(super) const SCHEMA_VERSION: i64 = 17;
pub(super) const SCHEMA_VERSION: i64 = 18;
impl Db {
pub(super) fn migrate(&self) -> Result<()> {
@@ -92,6 +92,9 @@ impl Db {
if current < 17 {
tx.execute_batch(MIGRATION_V17)?;
}
if current < 18 {
tx.execute_batch(MIGRATION_V18)?;
}
tx.execute(
"INSERT INTO sync_state (key, value) VALUES ('schema_version', ?1)
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
@@ -425,3 +428,47 @@ CREATE TABLE share_access (
CHECK (access IN ('owner', 'editor', 'viewer', 'unknown'))
);
";
/// Schema v18: unified location presentation. Device rows carry only their
/// `sync_folder` identity; reads join all device state from that authoritative
/// table. The trigger projects future folders, while the foreign key removes a
/// presentation row with its sync folder.
const MIGRATION_V18: &str = "
CREATE TABLE mount (
id INTEGER PRIMARY KEY,
kind TEXT NOT NULL
CHECK (kind IN ('myfiles', 'device', 'shared')),
sync_folder_id INTEGER REFERENCES sync_folder(id) ON DELETE CASCADE,
share_root_uid TEXT,
local_path TEXT NOT NULL DEFAULT '',
root_uid TEXT NOT NULL DEFAULT '',
root_share_id TEXT NOT NULL DEFAULT '',
mode TEXT NOT NULL DEFAULT 'unknown',
access TEXT NOT NULL DEFAULT 'rw'
CHECK (access IN ('rw', 'ro')),
CHECK (
(kind = 'myfiles' AND sync_folder_id IS NULL AND share_root_uid IS NULL)
OR
(kind = 'device' AND sync_folder_id IS NOT NULL AND share_root_uid IS NULL)
OR
(kind = 'shared' AND sync_folder_id IS NULL AND share_root_uid IS NOT NULL)
)
);
CREATE UNIQUE INDEX mount_myfiles
ON mount(kind) WHERE kind = 'myfiles';
CREATE UNIQUE INDEX mount_device
ON mount(sync_folder_id) WHERE kind = 'device';
CREATE UNIQUE INDEX mount_shared
ON mount(share_root_uid) WHERE kind = 'shared';
INSERT INTO mount (kind, sync_folder_id)
SELECT 'device', id FROM sync_folder;
CREATE TRIGGER mount_sync_folder_insert
AFTER INSERT ON sync_folder
BEGIN
INSERT OR IGNORE INTO mount (kind, sync_folder_id)
VALUES ('device', NEW.id);
END;
";
+5
View File
@@ -27,6 +27,7 @@ mod devices;
mod local;
mod maintenance;
mod migrations;
mod mounts;
mod nodes;
mod ops;
mod photos;
@@ -110,6 +111,10 @@ impl Db {
#[cfg(test)]
pub fn open_in_memory() -> Result<Self> {
let conn = Connection::open_in_memory()?;
// Keep relational behavior identical to `open`: V18 relies on the
// `mount.sync_folder_id` cascade, and SQLite disables foreign keys per
// connection unless explicitly enabled.
conn.pragma_update(None, "foreign_keys", "ON")?;
let db = Self {
conn: Mutex::new(conn),
};
+178
View File
@@ -0,0 +1,178 @@
//! Unified mount/location presentation rows.
//!
//! A device row stores only its `sync_folder` identity. Its path, remote root,
//! mode, state, pending mode, and last-sync timestamp are selected from
//! `sync_folder`, which remains the authoritative sync-engine table.
use rusqlite::params;
use super::Db;
use crate::mounts::{MountAccess, MountKind, MountMode, MountSpec};
use crate::{Error, Result};
impl Db {
/// Project the configured primary mount into the presentation table.
///
/// `root_share_id` is optional because an offline startup may only have the
/// cached root node. A previously learned share id is retained only when it
/// belongs to the same root uid.
pub fn mount_upsert_my_files(
&self,
local_path: &str,
root_uid: &str,
root_share_id: Option<&str>,
) -> Result<i64> {
let mut conn = self.conn.lock();
let tx = conn.transaction()?;
let updated = tx.execute(
"UPDATE mount
SET local_path = ?1,
root_share_id = CASE
WHEN root_uid = ?2
THEN COALESCE(NULLIF(?3, ''), root_share_id)
ELSE COALESCE(?3, '')
END,
root_uid = ?2,
mode = 'ondemand',
access = 'rw'
WHERE kind = 'myfiles'",
params![local_path, root_uid, root_share_id],
)?;
let id = if updated == 0 {
tx.execute(
"INSERT INTO mount
(kind, local_path, root_uid, root_share_id, mode, access)
VALUES ('myfiles', ?1, ?2, COALESCE(?3, ''), 'ondemand', 'rw')",
params![local_path, root_uid, root_share_id],
)?;
tx.last_insert_rowid()
} else {
tx.query_row("SELECT id FROM mount WHERE kind = 'myfiles'", [], |row| {
row.get(0)
})?
};
tx.commit()?;
Ok(id)
}
/// Store a share id learned asynchronously, but only if the projection still
/// names the root it was resolved for.
pub fn mount_repair_my_files_share_id(
&self,
root_uid: &str,
root_share_id: &str,
) -> Result<bool> {
let conn = self.conn.lock();
Ok(conn.execute(
"UPDATE mount
SET root_share_id = ?2
WHERE kind = 'myfiles'
AND root_uid = ?1",
params![root_uid, root_share_id],
)? > 0)
}
/// Every local location, with device fields joined from `sync_folder`.
pub fn mount_list(&self) -> Result<Vec<MountSpec>> {
let conn = self.conn.lock();
let mut stmt = conn.prepare(
"SELECT
m.id,
m.kind,
m.sync_folder_id,
m.share_root_uid,
CASE WHEN m.kind = 'device' THEN sf.local_path ELSE m.local_path END,
CASE WHEN m.kind = 'device' THEN sf.remote_uid ELSE m.root_uid END,
CASE WHEN m.kind = 'device' THEN sf.remote_share_id ELSE m.root_share_id END,
CASE WHEN m.kind = 'device' THEN sf.mode ELSE m.mode END,
m.access,
CASE WHEN m.kind = 'device' THEN sf.state ELSE 'idle' END,
CASE WHEN m.kind = 'device' THEN sf.last_sync ELSE 0 END,
CASE WHEN m.kind = 'device' THEN sf.pending_mode ELSE NULL END
FROM mount AS m
LEFT JOIN sync_folder AS sf ON sf.id = m.sync_folder_id
ORDER BY CASE m.kind
WHEN 'myfiles' THEN 0
WHEN 'device' THEN 1
ELSE 2
END,
m.id",
)?;
let rows = stmt.query_map([], |row| {
Ok((
row.get::<_, i64>(0)?,
row.get::<_, String>(1)?,
row.get::<_, Option<i64>>(2)?,
row.get::<_, Option<String>>(3)?,
row.get::<_, String>(4)?,
row.get::<_, String>(5)?,
row.get::<_, String>(6)?,
row.get::<_, String>(7)?,
row.get::<_, String>(8)?,
row.get::<_, String>(9)?,
row.get::<_, i64>(10)?,
row.get::<_, Option<String>>(11)?,
))
})?;
let mut mounts = Vec::new();
for row in rows {
let (
id,
kind,
sync_folder_id,
share_root_uid,
local_path,
root_uid,
root_share_id,
mode,
access,
state,
last_sync,
pending_mode,
) = row?;
let kind = match kind.as_str() {
"myfiles" => MountKind::MyFiles,
"device" => MountKind::Device {
sync_folder_id: sync_folder_id.ok_or_else(|| {
Error::Other(format!("mount row {id} has no sync_folder_id"))
})?,
},
"shared" => MountKind::Shared {
share_root_uid: share_root_uid.ok_or_else(|| {
Error::Other(format!("mount row {id} has no share_root_uid"))
})?,
},
other => {
return Err(Error::Other(format!(
"mount row {id} has unknown kind {other:?}"
)));
}
};
let access = match access.as_str() {
"rw" => MountAccess::Rw,
"ro" => MountAccess::Ro,
other => {
return Err(Error::Other(format!(
"mount row {id} has unknown access {other:?}"
)));
}
};
mounts.push(MountSpec {
id,
kind,
local_path,
root_uid,
root_share_id,
mode: MountMode::from(mode.as_str()),
access,
state,
last_sync,
pending_mode: pending_mode.as_deref().map(MountMode::from),
mounted: false,
progress: None,
});
}
Ok(mounts)
}
}
+20 -5
View File
@@ -115,14 +115,17 @@ impl Db {
Ok(n > 0)
}
/// Update a synced folder's mode (`mirror`/`ondemand`). The folder has
/// reached the mode it was asked for, so any queued request is satisfied and
/// cleared in the same write — a `pending_mode` outliving the switch it asked
/// for would have the engine try to apply it again on the next pass.
/// Update a synced folder's mode (`mirror`/`ondemand`). A queued request for
/// that mode is satisfied and cleared in the same write. An opposite pending
/// mode arrived after this transition started, so preserve it as the newer
/// last-request intent.
pub fn sync_folder_set_mode(&self, id: i64, mode: &str) -> Result<()> {
let conn = self.conn.lock();
conn.execute(
"UPDATE sync_folder SET mode = ?2, pending_mode = NULL WHERE id = ?1",
"UPDATE sync_folder
SET mode = ?2,
pending_mode = CASE WHEN pending_mode = ?2 THEN NULL ELSE pending_mode END
WHERE id = ?1",
params![id, mode],
)?;
Ok(())
@@ -139,6 +142,18 @@ impl Db {
Ok(())
}
/// Withdraw a queued mode only if it is still the intent the caller
/// inspected. A newer request must survive an older settlement attempt.
pub fn sync_folder_clear_pending_mode_if(&self, id: i64, expected: &str) -> Result<bool> {
let conn = self.conn.lock();
let changed = conn.execute(
"UPDATE sync_folder SET pending_mode = NULL
WHERE id = ?1 AND pending_mode = ?2",
params![id, expected],
)?;
Ok(changed > 0)
}
/// Update a synced folder's state and stamp `last_sync` to now.
pub fn sync_folder_set_state(&self, id: i64, state: &str, last_sync: i64) -> Result<()> {
let conn = self.conn.lock();
+254 -4
View File
@@ -1323,15 +1323,17 @@ fn migration_v17_adds_share_access_to_a_v16_fixture() {
.as_nanos()
));
{
// V17 only adds `share_access`, so removing it and restoring the version
// stamp produces the exact schema a released V16 database had.
// Remove the later tables too, so restoring the version stamp produces
// the exact schema a released V16 database had.
let db = Db::open(&path).unwrap();
db.set_state_str("fixture_value", "survives").unwrap();
}
{
let conn = rusqlite::Connection::open(&path).unwrap();
conn.execute_batch(
"DROP TABLE share_access;
"DROP TRIGGER mount_sync_folder_insert;
DROP TABLE mount;
DROP TABLE share_access;
UPDATE sync_state SET value = '16' WHERE key = 'schema_version';",
)
.unwrap();
@@ -1347,13 +1349,211 @@ fn migration_v17_adds_share_access_to_a_v16_fixture() {
assert_eq!(db.share_access(&root).unwrap(), Some(crate::Access::Viewer));
assert_eq!(
db.state_str("schema_version").unwrap().as_deref(),
Some("17")
Some("18")
);
drop(db);
let _ = std::fs::remove_file(path);
}
#[test]
fn migration_v18_projects_v17_sync_folders_and_cascades_deletes() {
let path = std::env::temp_dir().join(format!(
"pdfs-db-v17-fixture-{}-{}.db",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
{
// Pin the released V17 shape that V18 reads instead of deriving a
// fixture from today's head schema and dropping newer objects.
let conn = rusqlite::Connection::open(&path).unwrap();
conn.execute_batch(
"CREATE TABLE sync_state (key TEXT PRIMARY KEY, value TEXT);
INSERT INTO sync_state VALUES ('schema_version', '17');
INSERT INTO sync_state VALUES ('fixture_value', 'survives');
CREATE TABLE sync_folder (
id INTEGER PRIMARY KEY,
local_path TEXT NOT NULL UNIQUE,
remote_uid TEXT NOT NULL,
remote_share_id TEXT NOT NULL,
mode TEXT NOT NULL DEFAULT 'mirror',
state TEXT NOT NULL DEFAULT 'idle',
last_sync INTEGER NOT NULL DEFAULT 0,
pending_mode TEXT
);
CREATE TABLE sync_entry (
folder_id INTEGER NOT NULL,
rel_path TEXT NOT NULL,
remote_uid TEXT,
local_mtime INTEGER NOT NULL DEFAULT 0,
local_size INTEGER NOT NULL DEFAULT 0,
remote_hash TEXT,
remote_rev TEXT,
PRIMARY KEY (folder_id, rel_path)
);
INSERT INTO sync_folder
(id, local_path, remote_uid, remote_share_id, mode, state, last_sync)
VALUES
(41, '/home/me/Existing', 'vol~existing', 'share-existing',
'ondemand', 'syncing', 123);
CREATE TABLE share_access (
root_uid TEXT PRIMARY KEY,
access TEXT NOT NULL
CHECK (access IN ('owner', 'editor', 'viewer', 'unknown'))
);",
)
.unwrap();
}
let db = Db::open(&path).unwrap();
let existing = 41;
let locations = db.mount_list().unwrap();
assert_eq!(locations.len(), 1);
let projected = &locations[0];
assert!(matches!(
projected.kind,
crate::mounts::MountKind::Device {
sync_folder_id
} if sync_folder_id == existing
));
assert_eq!(projected.local_path, "/home/me/Existing");
assert_eq!(projected.mode, crate::mounts::MountMode::OnDemand);
assert_eq!(projected.state, "syncing");
assert_eq!(projected.last_sync, 123);
assert_eq!(
db.state_str("fixture_value").unwrap().as_deref(),
Some("survives")
);
let added = db
.sync_folder_add("/home/me/New", "vol~new", "share-new")
.unwrap();
assert_eq!(db.mount_list().unwrap().len(), 2);
assert!(db.sync_folder_remove(added).unwrap());
assert_eq!(
db.mount_list().unwrap().len(),
1,
"the V18 foreign key must remove the device projection"
);
assert_eq!(
db.state_str("schema_version").unwrap().as_deref(),
Some("18")
);
drop(db);
let _ = std::fs::remove_file(path);
}
#[test]
fn my_files_projection_retains_share_id_only_for_the_same_root() {
let db = Db::open_in_memory().unwrap();
let id = db
.mount_upsert_my_files("/home/me/ProtonDrive", "vol~root", Some("share-main"))
.unwrap();
let same_id = db
.mount_upsert_my_files("/mnt/proton", "vol~root", None)
.unwrap();
assert_eq!(same_id, id);
assert_eq!(db.mount_list().unwrap()[0].root_share_id, "share-main");
db.mount_upsert_my_files("/mnt/proton", "vol~root-new", None)
.unwrap();
let locations = db.mount_list().unwrap();
assert_eq!(locations.len(), 1);
assert!(matches!(
locations[0].kind,
crate::mounts::MountKind::MyFiles
));
assert_eq!(locations[0].local_path, "/mnt/proton");
assert_eq!(locations[0].root_uid, "vol~root-new");
assert_eq!(
locations[0].root_share_id, "",
"a share id must never be paired with a different root uid"
);
}
#[test]
fn my_files_share_id_repair_is_conditional_on_root_uid() {
let db = Db::open_in_memory().unwrap();
db.mount_upsert_my_files("/mnt/proton", "vol~root", None)
.unwrap();
assert!(
!db.mount_repair_my_files_share_id("vol~stale", "share-stale")
.unwrap()
);
assert_eq!(db.mount_list().unwrap()[0].root_share_id, "");
assert!(
db.mount_repair_my_files_share_id("vol~root", "share-current")
.unwrap()
);
assert_eq!(db.mount_list().unwrap()[0].root_share_id, "share-current");
}
#[test]
fn mount_constraints_reject_malformed_kinds_and_missing_sync_folders() {
let db = Db::open_in_memory().unwrap();
let conn = db.conn.lock();
assert!(
conn.execute(
"INSERT INTO mount (kind, local_path, root_uid)
VALUES ('device', '/tmp/device', 'vol~device')",
[],
)
.is_err(),
"a device projection without sync_folder_id must fail its CHECK"
);
assert!(
conn.execute(
"INSERT INTO mount (kind, sync_folder_id)
VALUES ('device', 999999)",
[],
)
.is_err(),
"a device projection cannot reference a missing sync folder"
);
assert!(
conn.execute(
"INSERT INTO mount (kind, share_root_uid, local_path, root_uid)
VALUES ('myfiles', 'vol~shared', '/tmp/root', 'vol~root')",
[],
)
.is_err(),
"kind-specific columns must not be mixed"
);
}
#[test]
fn deleting_a_device_projection_does_not_delete_its_sync_folder() {
let db = Db::open_in_memory().unwrap();
let folder_id = db
.sync_folder_add("/home/me/Keep", "vol~keep", "share-keep")
.unwrap();
{
let conn = db.conn.lock();
assert_eq!(
conn.execute(
"DELETE FROM mount WHERE kind = 'device' AND sync_folder_id = ?1",
[folder_id],
)
.unwrap(),
1
);
}
assert!(db.sync_folder_get(folder_id).unwrap().is_some());
assert!(
db.mount_list().unwrap().is_empty(),
"the foreign key direction must not recreate or delete sync authority"
);
}
#[test]
fn refuses_a_schema_newer_than_the_running_build() {
let path = std::env::temp_dir().join(format!(
@@ -1953,6 +2153,56 @@ fn pending_mode_is_queued_until_the_mode_is_reached() {
assert_eq!(folder.pending_mode, None);
}
#[test]
fn mode_commit_clears_only_the_intent_it_satisfies() {
let db = Db::open_in_memory().unwrap();
let id = db
.sync_folder_add("/tmp/pdfs-mode-intent", "vol~folder", "share")
.unwrap();
db.sync_folder_set_pending_mode(id, Some("ondemand"))
.unwrap();
db.sync_folder_set_mode(id, "ondemand").unwrap();
assert_eq!(db.sync_folder_get(id).unwrap().unwrap().pending_mode, None);
db.sync_folder_set_pending_mode(id, Some("mirror")).unwrap();
db.sync_folder_set_mode(id, "ondemand").unwrap();
assert_eq!(
db.sync_folder_get(id)
.unwrap()
.unwrap()
.pending_mode
.as_deref(),
Some("mirror")
);
}
#[test]
fn clearing_a_stale_pending_mode_preserves_the_newer_intent() {
let db = Db::open_in_memory().unwrap();
let id = db
.sync_folder_add("/tmp/pdfs-pending", "vol~folder", "share")
.unwrap();
db.sync_folder_set_pending_mode(id, Some("ondemand"))
.unwrap();
db.sync_folder_set_pending_mode(id, Some("mirror")).unwrap();
assert!(
!db.sync_folder_clear_pending_mode_if(id, "ondemand")
.unwrap()
);
assert_eq!(
db.sync_folder_get(id)
.unwrap()
.unwrap()
.pending_mode
.as_deref(),
Some("mirror")
);
assert!(db.sync_folder_clear_pending_mode_if(id, "mirror").unwrap());
assert_eq!(db.sync_folder_get(id).unwrap().unwrap().pending_mode, None);
}
#[test]
fn mirror_uid_lookup_covers_the_root_and_synced_descendants_only() {
let db = Db::open_in_memory().unwrap();
+1
View File
@@ -10,6 +10,7 @@ pub mod control;
pub mod db;
pub mod error;
pub mod localindex;
pub mod mounts;
pub mod profile;
pub mod search;
pub mod service;
+146
View File
@@ -0,0 +1,146 @@
//! Unified presentation model for every local Proton Drive location.
//!
//! Device sync state remains owned by `sync_folder`; these types are the stable
//! shape exposed to front-ends after that state is joined with the `mount`
//! presentation table.
use std::fmt;
use serde::{Deserialize, Serialize};
use crate::control::SyncProgress;
/// How a location makes remote content available locally.
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Default)]
#[serde(rename_all = "lowercase")]
pub enum MountMode {
/// A full local copy reconciled in both directions.
Mirror,
/// A FUSE session that fetches content as it is accessed.
OnDemand,
/// A mode written by a newer client.
#[default]
#[serde(other)]
Unknown,
}
impl MountMode {
pub const fn as_str(self) -> &'static str {
match self {
Self::Mirror => "mirror",
Self::OnDemand => "ondemand",
Self::Unknown => "unknown",
}
}
}
impl From<&str> for MountMode {
fn from(value: &str) -> Self {
match value {
"mirror" => Self::Mirror,
"ondemand" => Self::OnDemand,
_ => Self::Unknown,
}
}
}
impl fmt::Display for MountMode {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
/// Whether the location itself was declared writable.
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum MountAccess {
Rw,
Ro,
}
impl MountAccess {
pub const fn as_str(self) -> &'static str {
match self {
Self::Rw => "rw",
Self::Ro => "ro",
}
}
}
impl fmt::Display for MountAccess {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
/// What remote root a local location presents.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
#[serde(tag = "kind", rename_all = "lowercase")]
pub enum MountKind {
/// The account's primary My Files mount.
MyFiles,
/// A folder owned by this machine's Proton Drive device.
Device { sync_folder_id: i64 },
/// A future standalone mount rooted at a node shared with this account.
Shared { share_root_uid: String },
}
/// One local Proton Drive location as exposed over the control protocol.
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct MountSpec {
/// Presentation-row id. Device actions use the `sync_folder_id` carried by
/// [`MountKind::Device`], not this id.
pub id: i64,
pub kind: MountKind,
pub local_path: String,
pub root_uid: String,
pub root_share_id: String,
pub mode: MountMode,
pub access: MountAccess,
/// `idle` | `syncing` | `error` | `conflict` for device locations.
pub state: String,
/// Last successful device sync, epoch seconds; `0` when not applicable.
pub last_sync: i64,
/// A device mode switch accepted but not yet applied.
#[serde(default)]
pub pending_mode: Option<MountMode>,
/// Whether a FUSE session currently owns `local_path`.
#[serde(default)]
pub mounted: bool,
/// Live device sync progress, absent when no pass is running.
#[serde(default)]
pub progress: Option<SyncProgress>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn mount_mode_keeps_legacy_wire_strings() {
assert_eq!(
serde_json::to_string(&MountMode::Mirror).unwrap(),
r#""mirror""#
);
assert_eq!(
serde_json::to_string(&MountMode::OnDemand).unwrap(),
r#""ondemand""#
);
assert_eq!(
serde_json::from_str::<MountMode>(r#""mirror""#).unwrap(),
MountMode::Mirror
);
assert_eq!(
serde_json::from_str::<MountMode>(r#""ondemand""#).unwrap(),
MountMode::OnDemand
);
}
#[test]
fn mount_mode_tolerates_unknown_values() {
assert_eq!(
serde_json::from_str::<MountMode>(r#""from-the-future""#).unwrap(),
MountMode::Unknown
);
}
}
+52 -3
View File
@@ -17,6 +17,8 @@
use serde::{Deserialize, Serialize};
use crate::mounts::MountMode;
/// File name of the profile inside the device root folder.
pub const PROFILE_FILE_NAME: &str = "profile.json";
@@ -39,8 +41,9 @@ pub struct ProfileFolder {
/// Absolute local path it was synced to. A *suggestion* on restore: another
/// machine may have a different username, home, or mount layout.
pub local_path: String,
/// `mirror` or `ondemand`.
pub mode: String,
/// `mirror` or `ondemand`. Unknown values written by a newer client do not
/// invalidate the rest of the profile.
pub mode: MountMode,
}
/// One pinned node (P5 pins), as `pins` stores it.
@@ -114,7 +117,7 @@ mod tests {
folders: vec![ProfileFolder {
remote_uid: "vol~link".to_string(),
local_path: "/home/nils/Documents".to_string(),
mode: "mirror".to_string(),
mode: MountMode::Mirror,
}],
pins: vec![ProfilePin {
uid: "vol~pin".to_string(),
@@ -133,6 +136,52 @@ mod tests {
assert_eq!(Profile::parse(&p.to_bytes().unwrap()).unwrap(), p);
}
#[test]
fn profile_keeps_legacy_mode_bytes_and_parses_old_documents() {
let bytes = sample().to_bytes().unwrap();
let json = String::from_utf8(bytes).unwrap();
assert!(json.contains(r#""mode": "mirror""#));
let old = br#"{
"version": 1,
"device_uid": "dev-1",
"hostname": "old-client",
"saved_at": 1,
"folders": [
{
"remote_uid": "vol~mirror",
"local_path": "/home/me/Mirror",
"mode": "mirror"
},
{
"remote_uid": "vol~ondemand",
"local_path": "/home/me/Cloud",
"mode": "ondemand"
}
]
}"#;
let profile = Profile::parse(old).unwrap();
assert_eq!(profile.folders[0].mode, MountMode::Mirror);
assert_eq!(profile.folders[1].mode, MountMode::OnDemand);
}
#[test]
fn unknown_folder_mode_does_not_reject_profile() {
let json = br#"{
"version": 1,
"device_uid": "dev-1",
"hostname": "future-client",
"saved_at": 1,
"folders": [{
"remote_uid": "vol~future",
"local_path": "/home/me/Future",
"mode": "streaming"
}]
}"#;
let profile = Profile::parse(json).unwrap();
assert_eq!(profile.folders[0].mode, MountMode::Unknown);
}
/// A field added by a later client must not break this one's restore: the
/// common subset is still usable, which is the whole reason `version` is
/// reserved for breaking changes only.