mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-04 16:56:59 +01:00
added functionality to open shared files with you + fixed bug where mid transfer the SIGTERM signal can corrupt the mount
This commit is contained in:
1 parent
e0f0c7e0b7
commit
bf625bf8d9
15 files changed
+1621
-136
No files matched your search
@@ -41,6 +41,13 @@ pub struct AppConfig {
|
||||
/// defaults. Defaulted for configs predating the field.
|
||||
#[serde(default)]
|
||||
pub ignore_patterns: Option<Vec<String>>,
|
||||
/// Device uid this machine has been *adopted* into, overriding the hostname
|
||||
/// heuristic in `ensure_device`. Lives here rather than in `cache.db` so it
|
||||
/// survives a fresh install — on a new machine the DB is empty by
|
||||
/// definition, which is exactly when the pin matters. `None` means "resolve
|
||||
/// by hostname". Defaulted for configs predating the field.
|
||||
#[serde(default)]
|
||||
pub device_uid: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for AppConfig {
|
||||
@@ -51,6 +58,7 @@ impl Default for AppConfig {
|
||||
cache_budget: None,
|
||||
mountpoint: None,
|
||||
ignore_patterns: None,
|
||||
device_uid: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -294,6 +302,7 @@ mod tests {
|
||||
cache_budget: Some(1234),
|
||||
mountpoint: Some("/tmp/x".to_string()),
|
||||
ignore_patterns: Some(vec!["build/".to_string()]),
|
||||
device_uid: Some("dev-uid".to_string()),
|
||||
};
|
||||
let json = serde_json::to_string(&config).unwrap();
|
||||
let decoded: AppConfig = serde_json::from_str(&json).unwrap();
|
||||
|
||||
@@ -188,6 +188,11 @@ pub enum Request {
|
||||
RenameDevice { uid: String, name: String },
|
||||
/// Delete (deregister) a device by its uid. Replies with [`Response::Ok`].
|
||||
DeleteDevice { uid: String },
|
||||
/// Adopt an existing device as this machine's, pinning its uid in
|
||||
/// `config.json` so a hostname change or reinstall re-attaches to it instead
|
||||
/// of registering a duplicate (features.md 5.1). `uid: None` clears the pin.
|
||||
/// Replies with [`Response::Ok`].
|
||||
AdoptDevice { uid: Option<String> },
|
||||
|
||||
// ---- device folder sync (devices.md) ----------------------------------
|
||||
/// Add a local folder to this machine's device, uploading its tree and
|
||||
@@ -204,6 +209,13 @@ pub enum Request {
|
||||
/// Force a reconcile pass: one folder by id, or all when `id` is `None`.
|
||||
/// Replies with [`Response::Ok`].
|
||||
SyncNow { id: Option<i64> },
|
||||
/// List the folders under this machine's device that can be synced here,
|
||||
/// each with a proposed local path (features.md 5.2). Replies with
|
||||
/// [`Response::RestorableFolders`].
|
||||
ListRestorableFolders,
|
||||
/// Attach the given remote device folders to local paths and sync them down.
|
||||
/// Replies with [`Response::Ok`].
|
||||
RestoreSyncFolders { items: Vec<RestoreItem> },
|
||||
|
||||
// ---- sharing a node ---------------------------------------------------
|
||||
/// Invite `emails` (Proton and/or external addresses, auto-detected) to the
|
||||
@@ -259,6 +271,16 @@ pub enum Request {
|
||||
/// [`Response::Entries`] (each entry carries its `uid`; `path` is empty since
|
||||
/// the item lives outside the mount tree).
|
||||
ListSharedWithMe,
|
||||
/// List the children of a folder shared with me, addressed by `uid`. Replies
|
||||
/// with [`Response::Entries`]; like [`Request::ListSharedWithMe`] the entries
|
||||
/// carry a `uid` and an empty `path`, since the subtree lives outside the
|
||||
/// mount. Lets a front-end browse into a shared folder.
|
||||
ListSharedFolder { uid: String },
|
||||
/// Download a file shared with me into the content cache, addressed by `uid`.
|
||||
/// Replies with [`Response::FilePath`] so the front-end can open it with the
|
||||
/// default app. The by-uid twin of [`Request::OpenFile`], which can only
|
||||
/// address nodes inside the mount.
|
||||
OpenSharedFile { uid: String },
|
||||
/// Leave a shared node by its `uid`, giving up my access. Replies with
|
||||
/// [`Response::Ok`].
|
||||
LeaveShared { uid: String },
|
||||
@@ -340,6 +362,11 @@ pub struct DeviceInfo {
|
||||
/// front-end must not offer that as casually as removing another computer.
|
||||
#[serde(default)]
|
||||
pub this_device: bool,
|
||||
/// Whether this device is the one *explicitly adopted* in `config.json`
|
||||
/// ([`Request::AdoptDevice`]), as opposed to one matched by hostname. Only
|
||||
/// an adopted device survives a hostname change or a reinstall.
|
||||
#[serde(default)]
|
||||
pub adopted: bool,
|
||||
}
|
||||
|
||||
/// One synced local folder on this machine's device (in [`Response::SyncFolders`]).
|
||||
@@ -369,6 +396,36 @@ pub struct SyncFolderInfo {
|
||||
pub progress: Option<SyncProgress>,
|
||||
}
|
||||
|
||||
/// A folder under this machine's device offered for restore (in
|
||||
/// [`Response::RestorableFolders`]).
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct RestorableFolder {
|
||||
/// Uid of the remote folder — the handle for [`RestoreItem`].
|
||||
pub remote_uid: String,
|
||||
/// Its remote name.
|
||||
pub name: String,
|
||||
/// Where the daemon *proposes* to put it: the path the profile recorded when
|
||||
/// that path makes sense on this machine, else `~/<name>`. A front-end shows
|
||||
/// this as an editable default, never applies it silently.
|
||||
pub local_path: String,
|
||||
/// Proposed mode, from the profile; `mirror` when unknown.
|
||||
pub mode: String,
|
||||
/// Already synced on this machine, so restoring it again would be a no-op.
|
||||
/// Listed anyway so the picker shows the whole device.
|
||||
#[serde(default)]
|
||||
pub already_synced: bool,
|
||||
}
|
||||
|
||||
/// One folder to restore, in [`Request::RestoreSyncFolders`].
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct RestoreItem {
|
||||
pub remote_uid: String,
|
||||
/// Absolute local path to sync it to; created if missing.
|
||||
pub local_path: String,
|
||||
/// `mirror` or `ondemand`.
|
||||
pub mode: String,
|
||||
}
|
||||
|
||||
/// Which stage a running sync pass is in, in a [`SyncProgress`].
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SyncPhase {
|
||||
@@ -898,6 +955,8 @@ pub enum Response {
|
||||
Devices { items: Vec<DeviceInfo> },
|
||||
/// This device's synced folders (reply to [`Request::ListSyncFolders`]).
|
||||
SyncFolders { items: Vec<SyncFolderInfo> },
|
||||
/// Folders offered for restore (reply to [`Request::ListRestorableFolders`]).
|
||||
RestorableFolders { items: Vec<RestorableFolder> },
|
||||
/// A node's share: members + pending invitations, and its public link if any
|
||||
/// (reply to [`Request::ListShare`]).
|
||||
Share {
|
||||
@@ -1161,6 +1220,18 @@ mod tests {
|
||||
mode: "ondemand".into(),
|
||||
},
|
||||
Request::SyncNow { id: Some(3) },
|
||||
Request::AdoptDevice {
|
||||
uid: Some("dev-1".into()),
|
||||
},
|
||||
Request::AdoptDevice { uid: None },
|
||||
Request::ListRestorableFolders,
|
||||
Request::RestoreSyncFolders {
|
||||
items: vec![RestoreItem {
|
||||
remote_uid: "vol~link".into(),
|
||||
local_path: "/home/me/Docs".into(),
|
||||
mode: "mirror".into(),
|
||||
}],
|
||||
},
|
||||
Request::ShareNode {
|
||||
path: "a/b".into(),
|
||||
emails: vec!["x@proton.me".into(), "y@example.com".into()],
|
||||
@@ -1192,6 +1263,12 @@ mod tests {
|
||||
Request::ListSharedByMe,
|
||||
Request::ListActivity { limit: 100 },
|
||||
Request::ListSharedWithMe,
|
||||
Request::ListSharedFolder {
|
||||
uid: "vol~link".into(),
|
||||
},
|
||||
Request::OpenSharedFile {
|
||||
uid: "vol~link".into(),
|
||||
},
|
||||
Request::LeaveShared {
|
||||
uid: "vol~link".into(),
|
||||
},
|
||||
|
||||
@@ -137,9 +137,10 @@ impl Db {
|
||||
if exists.is_none() {
|
||||
continue;
|
||||
}
|
||||
let count: i64 = conn.query_row(&format!("SELECT count(*) FROM \"{name}\""), [], |r| {
|
||||
r.get(0)
|
||||
})?;
|
||||
let count: i64 =
|
||||
conn.query_row(&format!("SELECT count(*) FROM \"{name}\""), [], |r| {
|
||||
r.get(0)
|
||||
})?;
|
||||
tables.push(((*name).to_string(), count));
|
||||
}
|
||||
|
||||
@@ -203,7 +204,6 @@ impl Db {
|
||||
let frames: i64 = conn.query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |r| r.get(2))?;
|
||||
Ok(frames.max(0))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -314,7 +314,8 @@ mod tests {
|
||||
let db = Db::open(&path).unwrap();
|
||||
|
||||
for i in 0..2000 {
|
||||
db.set_state_str(&format!("k{i}"), &"v".repeat(256)).unwrap();
|
||||
db.set_state_str(&format!("k{i}"), &"v".repeat(256))
|
||||
.unwrap();
|
||||
}
|
||||
let full = db.stats().unwrap().total_bytes();
|
||||
for i in 0..2000 {
|
||||
|
||||
@@ -9,6 +9,7 @@ pub mod control;
|
||||
pub mod db;
|
||||
pub mod error;
|
||||
pub mod localindex;
|
||||
pub mod profile;
|
||||
pub mod service;
|
||||
pub mod shell;
|
||||
pub mod syncignore;
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
//! The machine profile that travels with a device (features.md 5.3).
|
||||
//!
|
||||
//! Sync folder mappings, pins, and the settings that shape them live in
|
||||
//! `cache.db` and `config.json` — both on the local disk. When that disk dies,
|
||||
//! the remote data survives but the *arrangement* of it does not: which folders
|
||||
//! were synced, where they lived locally, which were on-demand, what was pinned.
|
||||
//!
|
||||
//! So the daemon mirrors that arrangement into a `profile.json` in its device's
|
||||
//! root folder on Drive. It goes up the normal upload path, which means it is
|
||||
//! end-to-end encrypted with the account keys like any other file — 5.3's
|
||||
//! "encrypt with the user's Proton keys" is satisfied by *not* special-casing
|
||||
//! it. It lives under the device rather than the Drive root because it describes
|
||||
//! that device; adopting the device is what makes it relevant.
|
||||
//!
|
||||
//! This module is pure data. The upload/download and the restore flow live in
|
||||
//! `pdfs-fuse`, next to the client that performs them.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// File name of the profile inside the device root folder.
|
||||
pub const PROFILE_FILE_NAME: &str = "profile.json";
|
||||
|
||||
/// Format version of the profile document.
|
||||
///
|
||||
/// Bumped only for changes a *reader* cannot survive. Adding fields does not
|
||||
/// qualify — unknown fields are ignored on the way in, so an older client reads
|
||||
/// a newer profile's common subset. A newer `version` than this is refused
|
||||
/// outright rather than half-applied: a restore that silently drops folders it
|
||||
/// did not understand is worse than one that tells you to upgrade.
|
||||
pub const PROFILE_VERSION: u32 = 1;
|
||||
|
||||
/// One synced folder, as it was arranged on the machine that wrote the profile.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ProfileFolder {
|
||||
/// Uid of the folder's remote root, under the device root. The stable
|
||||
/// identity — the local path may not exist on the restoring machine, but
|
||||
/// this does.
|
||||
pub remote_uid: String,
|
||||
/// 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,
|
||||
}
|
||||
|
||||
/// One pinned node (P5 pins), as `pins` stores it.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ProfilePin {
|
||||
pub uid: String,
|
||||
/// Mountpoint-relative path, kept for display: a pin whose node has been
|
||||
/// deleted should still be nameable in a restore summary.
|
||||
pub path: String,
|
||||
pub recursive: bool,
|
||||
}
|
||||
|
||||
/// The full arrangement of one machine.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Profile {
|
||||
pub version: u32,
|
||||
/// The device this profile describes.
|
||||
pub device_uid: String,
|
||||
/// Hostname of the machine that wrote it, so a restore prompt can say
|
||||
/// *"restore settings from laptop-xyz?"* rather than quoting a uid.
|
||||
pub hostname: String,
|
||||
/// When it was written, epoch seconds.
|
||||
pub saved_at: i64,
|
||||
#[serde(default)]
|
||||
pub folders: Vec<ProfileFolder>,
|
||||
#[serde(default)]
|
||||
pub pins: Vec<ProfilePin>,
|
||||
/// Global sync ignore patterns. Safe to restore as-is.
|
||||
#[serde(default)]
|
||||
pub ignore_patterns: Option<Vec<String>>,
|
||||
/// Machine-shaped settings. Carried, but never applied without asking: a
|
||||
/// laptop's 5 GB cache budget and `~/ProtonDrive` mountpoint are not
|
||||
/// obviously right on the next machine.
|
||||
#[serde(default)]
|
||||
pub cache_budget: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub mountpoint: Option<String>,
|
||||
}
|
||||
|
||||
impl Profile {
|
||||
/// Parse a profile document, refusing one written by a newer client.
|
||||
pub fn parse(bytes: &[u8]) -> Result<Self, String> {
|
||||
let profile: Profile =
|
||||
serde_json::from_slice(bytes).map_err(|e| format!("malformed profile.json: {e}"))?;
|
||||
if profile.version > PROFILE_VERSION {
|
||||
return Err(format!(
|
||||
"profile.json was written by a newer client (version {}, this client understands {PROFILE_VERSION}); upgrade to restore it",
|
||||
profile.version
|
||||
));
|
||||
}
|
||||
Ok(profile)
|
||||
}
|
||||
|
||||
/// Serialize for upload. Pretty-printed: it is a small file a user may well
|
||||
/// open in the web UI to see what their machine had.
|
||||
pub fn to_bytes(&self) -> Result<Vec<u8>, String> {
|
||||
serde_json::to_vec_pretty(self).map_err(|e| format!("serialize profile: {e}"))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn sample() -> Profile {
|
||||
Profile {
|
||||
version: PROFILE_VERSION,
|
||||
device_uid: "dev-1".to_string(),
|
||||
hostname: "laptop-xyz".to_string(),
|
||||
saved_at: 1_700_000_000,
|
||||
folders: vec![ProfileFolder {
|
||||
remote_uid: "vol~link".to_string(),
|
||||
local_path: "/home/nils/Documents".to_string(),
|
||||
mode: "mirror".to_string(),
|
||||
}],
|
||||
pins: vec![ProfilePin {
|
||||
uid: "vol~pin".to_string(),
|
||||
path: "Photos/2024".to_string(),
|
||||
recursive: true,
|
||||
}],
|
||||
ignore_patterns: Some(vec!["target/".to_string()]),
|
||||
cache_budget: Some(5_000_000_000),
|
||||
mountpoint: Some("/home/nils/ProtonDrive".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trips() {
|
||||
let p = sample();
|
||||
assert_eq!(Profile::parse(&p.to_bytes().unwrap()).unwrap(), p);
|
||||
}
|
||||
|
||||
/// 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.
|
||||
#[test]
|
||||
fn unknown_fields_are_ignored() {
|
||||
let json = br#"{
|
||||
"version": 1,
|
||||
"device_uid": "dev-1",
|
||||
"hostname": "laptop-xyz",
|
||||
"saved_at": 1,
|
||||
"folders": [],
|
||||
"something_from_the_future": {"a": 1}
|
||||
}"#;
|
||||
let p = Profile::parse(json).unwrap();
|
||||
assert_eq!(p.device_uid, "dev-1");
|
||||
assert!(p.folders.is_empty());
|
||||
}
|
||||
|
||||
/// Missing optional sections read as empty rather than failing: a profile
|
||||
/// written before pins existed is still a valid folder mapping.
|
||||
#[test]
|
||||
fn absent_sections_default() {
|
||||
let json = br#"{"version":1,"device_uid":"d","hostname":"h","saved_at":0}"#;
|
||||
let p = Profile::parse(json).unwrap();
|
||||
assert!(p.pins.is_empty());
|
||||
assert!(p.ignore_patterns.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn future_version_is_refused() {
|
||||
let mut p = sample();
|
||||
p.version = PROFILE_VERSION + 1;
|
||||
let err = Profile::parse(&p.to_bytes().unwrap()).unwrap_err();
|
||||
assert!(err.contains("newer client"), "{err}");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user