mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-04 16:56:59 +01:00
implemented shared with me + verification
This commit is contained in:
1 parent
551e14b644
commit
a8210ce8fd
16 files changed
+1830
-224
No files matched your search
@@ -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 = [
|
||||
|
||||
@@ -77,6 +77,7 @@ const REPORTED_TABLES: &[&str] = &[
|
||||
"trash",
|
||||
"device",
|
||||
"sync_folder",
|
||||
"mount",
|
||||
"sync_entry",
|
||||
"pending_op",
|
||||
"activity",
|
||||
|
||||
@@ -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;
|
||||
";
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user