Compare commits

..
5 Commits
Author SHA1 Message Date
nvrl f4d6580cd3 chore(version) bumped to 1.10.1 2026-09-15 15:13:22 +02:00
nvrl b2f62f70e1 chore(deps,core,gui): upgrade dependencies to latest
- keyring 3 -> keyring-core 1 + dbus-secret-service-keyring-store 1
  (crypto-rust). Same libdbus Secret Service backend; with no target the
  store matches items keyring 3 wrote, so stored sessions survive.
- ksni 0.2 -> 0.3 via the blocking API, with assume_sni_available so a
  tray that starts before the SNI watcher waits instead of failing. Drops
  the clap 2 / atty chain (Dependabot atty advisory).
- gtk4 0.11, libadwaita 0.9, webkit6 0.6, rusqlite 0.40, fuser 0.18,
  directories 6, sha1/sha2 0.11, base64 0.23.
2026-09-15 15:07:00 +02:00
nvrl dcb2ab7fac chore(version) bumped to 1.10.0 2026-09-15 13:59:24 +02:00
nvrl eb56f4a1e0 feat(fuse,core,gui,cli): one-request move+rename drain, show who shared an item
- A queued rename that also changes folder now lands as one `move-multiple`
  request carrying the target name instead of rename-then-move, so there is
  no half-applied state and no second call for stale name requirements to
  hit (B46 follow-up). A destination name collision moves under a conflict
  name in one step; same-folder renames still use the rename endpoint.
- Shared with me shows the inviter and invite date (GUI subtitle, `pdfs
  shared-with-me`), with a warning when the invitation signature does not
  verify against the claimed inviter's keys. `DirEntry` gains `shared_by`,
  `shared_at` and `shared_by_unverified`, all serde-defaulted.
- Bump `proton-sdk` / `proton-drive-rs` to 0.6.3 (anonymous-node moves,
  `TrashTime` trashed detection).
2026-09-15 13:59:13 +02:00
nvrl c28803b037 chore(changelog) updated changelog for 1.9.2 and 1.9.3 2026-09-08 14:58:48 +02:00
24 changed files with 918 additions and 869 deletions

No files matched your search

Generated
+512 -793
View File
File diff suppressed because it is too large. Load diff
+14 -10
View File
@@ -3,7 +3,7 @@ resolver = "2"
members = ["crates/pdfs-core", "crates/pdfs-fuse", "crates/pdfs-cli", "crates/pdfs-gui"] members = ["crates/pdfs-core", "crates/pdfs-fuse", "crates/pdfs-cli", "crates/pdfs-gui"]
[workspace.package] [workspace.package]
version = "1.9.3" version = "1.10.1"
edition = "2024" edition = "2024"
license = "MIT" license = "MIT"
authors = ["Nils Pukropp <contact@narl.io>"] authors = ["Nils Pukropp <contact@narl.io>"]
@@ -15,8 +15,8 @@ keywords = ["proton", "drive", "fuse", "sync", "linux"]
categories = ["filesystem", "command-line-utilities"] categories = ["filesystem", "command-line-utilities"]
[workspace.dependencies] [workspace.dependencies]
proton-sdk = { version = "0.6.2" } proton-sdk = { version = "0.6.4" }
proton-drive-rs = { version = "0.6.2" } proton-drive-rs = { version = "0.6.4" }
# Internal crates. # Internal crates.
pdfs-core = { path = "crates/pdfs-core" } pdfs-core = { path = "crates/pdfs-core" }
@@ -25,7 +25,7 @@ pdfs-fuse = { path = "crates/pdfs-fuse" }
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "fs", "io-util", "time", "signal"] } tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "fs", "io-util", "time", "signal"] }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
base64 = "0.22" base64 = "0.23"
thiserror = "2" thiserror = "2"
anyhow = "1" anyhow = "1"
# Non-poisoning mutexes. A handler that panics while holding a lock must not # Non-poisoning mutexes. A handler that panics while holding a lock must not
@@ -35,24 +35,28 @@ anyhow = "1"
parking_lot = "0.12" parking_lot = "0.12"
tracing = "0.1" tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] } tracing-subscriber = { version = "0.3", features = ["env-filter"] }
directories = "5" directories = "6"
ignore = "0.4" ignore = "0.4"
sha2 = "0.10" sha2 = "0.11"
# Google Takeout exports are zip sets; the importer reads their central # Google Takeout exports are zip sets; the importer reads their central
# directories and streams entries out without extracting to disk first. # directories and streams entries out without extracting to disk first.
zip = { version = "8", default-features = false, features = ["deflate"] } zip = { version = "8", default-features = false, features = ["deflate"] }
# Proton's photo duplicate detection is keyed on the plaintext SHA-1, so an # Proton's photo duplicate detection is keyed on the plaintext SHA-1, so an
# import has to speak SHA-1 whatever its age. # import has to speak SHA-1 whatever its age.
sha1 = "0.10" sha1 = "0.11"
# Thumbnail generation for photos the server has none for. Decoders only for the # Thumbnail generation for photos the server has none for. Decoders only for the
# formats a camera or phone actually produces — the rest of the crate's codecs # formats a camera or phone actually produces — the rest of the crate's codecs
# would be dead weight in the daemon. # would be dead weight in the daemon.
image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp", "gif", "tiff", "bmp", "rayon"] } image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp", "gif", "tiff", "bmp", "rayon"] }
rusqlite = { version = "0.32", features = ["bundled"] } rusqlite = { version = "0.40", features = ["bundled"] }
keyring = { version = "3", features = ["sync-secret-service", "crypto-rust"] } # keyring 4 split into `keyring-core` plus per-platform stores. The libdbus
# Secret Service store is the one keyring 3's `sync-secret-service` wrapped, and
# with no `target` it matches items v3 wrote, so existing logins survive.
keyring-core = "1"
dbus-secret-service-keyring-store = { version = "1", features = ["crypto-rust"] }
clap = { version = "4", features = ["derive"] } clap = { version = "4", features = ["derive"] }
rpassword = "7" rpassword = "7"
async-trait = "0.1" async-trait = "0.1"
futures = "0.3" futures = "0.3"
fuser = "0.17" fuser = "0.18"
libc = "0.2" libc = "0.2"
+71
View File
@@ -1129,6 +1129,9 @@ fn cmd_shared_with_me(uid: Option<String>) -> Result<()> {
format!(" ({})", e.role) format!(" ({})", e.role)
}; };
println!("{kind} {:>12} {}{role} [{}]", e.size, e.name, e.uid); println!("{kind} {:>12} {}{role} [{}]", e.size, e.name, e.uid);
if let Some(line) = shared_by_line(&e) {
println!("{:>15}{line}", "");
}
} }
} }
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)), CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
@@ -1137,6 +1140,39 @@ fn cmd_shared_with_me(uid: Option<String>) -> Result<()> {
Ok(()) Ok(())
} }
/// Who shared an entry with me, and when, as a line printed under it — `None`
/// when the invitation said nothing (owned content, or a node inside a shared
/// folder rather than its root). An inviter whose signature did not verify is
/// said to be claimed, not stated: the name may be forged.
fn shared_by_line(entry: &pdfs_core::control::DirEntry) -> Option<String> {
if entry.shared_by.is_empty() {
return None;
}
let mut line = format!("shared by {}", entry.shared_by);
if entry.shared_at > 0 {
line.push_str(&format!(" at {}", format_epoch_utc(entry.shared_at)));
}
if entry.shared_by_unverified {
line.push_str(" WARNING: invitation signature did not verify; the sender may be forged");
}
Some(line)
}
/// `YYYY-MM-DD` (UTC) for epoch seconds, without pulling a date crate into the
/// CLI for one column. Civil-from-days per Howard Hinnant's algorithm.
fn format_epoch_utc(secs: i64) -> String {
let z = secs.div_euclid(86_400) + 719_468;
let era = z.div_euclid(146_097);
let doe = z - era * 146_097;
let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let day = doy - (153 * mp + 2) / 5 + 1;
let month = if mp < 10 { mp + 3 } else { mp - 9 };
let year = yoe + era * 400 + i64::from(month <= 2);
format!("{year:04}-{month:02}-{day:02}")
}
/// Download a file shared with me. The daemon puts the plaintext in its content /// Download a file shared with me. The daemon puts the plaintext in its content
/// cache and replies with that path; with a `dest` we copy it out, so the caller /// cache and replies with that path; with a `dest` we copy it out, so the caller
/// gets a file it owns rather than one the cache may later evict. /// gets a file it owns rather than one the cache may later evict.
@@ -2805,4 +2841,39 @@ mod login_wait_tests {
wait_for_session(|| Ok(()), |_| slept += 1).unwrap(); wait_for_session(|| Ok(()), |_| slept += 1).unwrap();
assert_eq!(slept, 0); assert_eq!(slept, 0);
} }
#[test]
fn epoch_seconds_format_as_utc_calendar_dates() {
assert_eq!(format_epoch_utc(0), "1970-01-01");
assert_eq!(format_epoch_utc(951_782_400), "2000-02-29");
assert_eq!(format_epoch_utc(1_700_000_000), "2023-11-14");
assert_eq!(format_epoch_utc(-86_400), "1969-12-31");
}
#[test]
fn a_forged_looking_invitation_is_flagged_not_stated() {
let mut entry = pdfs_core::control::DirEntry {
name: "Budget.ods".into(),
is_dir: false,
size: 1,
modified: 0,
pinned: false,
cached: false,
uid: "vol~link".into(),
path: String::new(),
role: "viewer".into(),
shared_by: String::new(),
shared_at: 0,
shared_by_unverified: false,
};
assert_eq!(shared_by_line(&entry), None, "no invitation, no line");
entry.shared_by = "alice@proton.me".into();
entry.shared_at = 1_700_000_000;
assert_eq!(
shared_by_line(&entry).as_deref(),
Some("shared by alice@proton.me at 2023-11-14")
);
entry.shared_by_unverified = true;
assert!(shared_by_line(&entry).unwrap().contains("WARNING"));
}
} }
+2 -1
View File
@@ -23,7 +23,8 @@ sha2.workspace = true
sha1.workspace = true sha1.workspace = true
zip.workspace = true zip.workspace = true
rusqlite.workspace = true rusqlite.workspace = true
keyring.workspace = true keyring-core.workspace = true
dbus-secret-service-keyring-store.workspace = true
parking_lot.workspace = true parking_lot.workspace = true
async-trait.workspace = true async-trait.workspace = true
libc.workspace = true libc.workspace = true
+8 -3
View File
@@ -6,7 +6,7 @@
//! interactive step; the refresh token auto-renews via the HTTP client's 401 //! interactive step; the refresh token auto-renews via the HTTP client's 401
//! path, so no fresh 2FA is required until the refresh token itself expires. //! path, so no fresh 2FA is required until the refresh token itself expires.
use keyring::Entry; use keyring_core::Entry;
use proton_drive_rs::{KeySalt, ProtonDriveClient}; use proton_drive_rs::{KeySalt, ProtonDriveClient};
use proton_sdk::api::{HumanVerification, HumanVerificationCredential}; use proton_sdk::api::{HumanVerification, HumanVerificationCredential};
use proton_sdk::cache::EncryptedCacheRepository; use proton_sdk::cache::EncryptedCacheRepository;
@@ -81,6 +81,11 @@ fn client_config() -> ProtonClientConfiguration {
} }
fn keyring_entry() -> Result<Entry> { fn keyring_entry() -> Result<Entry> {
// Installed lazily rather than once at startup so a session bus that was not
// up yet on the first attempt is retried on the next one.
if keyring_core::get_default_store().is_none() {
keyring_core::set_default_store(dbus_secret_service_keyring_store::Store::new()?);
}
Ok(Entry::new(KEYRING_SERVICE, KEYRING_USER)?) Ok(Entry::new(KEYRING_SERVICE, KEYRING_USER)?)
} }
@@ -215,7 +220,7 @@ pub fn load() -> Result<StoredSession> {
let entry = keyring_entry()?; let entry = keyring_entry()?;
match entry.get_password() { match entry.get_password() {
Ok(json) => Ok(serde_json::from_str(&json)?), Ok(json) => Ok(serde_json::from_str(&json)?),
Err(keyring::Error::NoEntry) => Err(Error::NotLoggedIn), Err(keyring_core::Error::NoEntry) => Err(Error::NotLoggedIn),
Err(e) => Err(e.into()), Err(e) => Err(e.into()),
} }
} }
@@ -238,7 +243,7 @@ pub fn logout() -> Result<()> {
} }
} }
match keyring_entry()?.delete_credential() { match keyring_entry()?.delete_credential() {
Ok(()) | Err(keyring::Error::NoEntry) => Ok(()), Ok(()) | Err(keyring_core::Error::NoEntry) => Ok(()),
Err(e) => Err(e.into()), Err(e) => Err(e.into()),
} }
} }
+16
View File
@@ -906,6 +906,22 @@ pub struct DirEntry {
/// report. Defaulted for wire-compat with clients/daemons predating it. /// report. Defaulted for wire-compat with clients/daemons predating it.
#[serde(default)] #[serde(default)]
pub role: String, pub role: String,
/// Who shared this node with me, as the invitation claims it — empty for
/// content I own, for a node reached *inside* a shared folder (only the share
/// root carries the invitation), and when the API did not say. Trust it only
/// as far as [`shared_by_unverified`](Self::shared_by_unverified) allows.
/// Defaulted for wire-compat with clients/daemons predating it.
#[serde(default)]
pub shared_by: String,
/// When it was shared with me, epoch seconds; 0 when unknown.
#[serde(default)]
pub shared_at: i64,
/// The invitation's signature did not check out against `shared_by`'s keys,
/// so the name may be forged. False when it verified *and* when it could not
/// be checked at all (no signature, no keys): only an actual mismatch is a
/// warning worth showing.
#[serde(default)]
pub shared_by_unverified: bool,
} }
/// One hit in a [`Request::Search`] result. Like [`DirEntry`] but carries the /// One hit in a [`Request::Search`] result. Like [`DirEntry`] but carries the
+4
View File
@@ -3201,6 +3201,8 @@ fn test_db_has_children_and_trashed_filtering() {
photo: None, photo: None,
album: None, album: None,
verification: Default::default(), verification: Default::default(),
direct_role: None,
share_id: None,
}; };
db.upsert_node(&parent_node).unwrap(); db.upsert_node(&parent_node).unwrap();
@@ -3227,6 +3229,8 @@ fn test_db_has_children_and_trashed_filtering() {
photo: None, photo: None,
album: None, album: None,
verification: Default::default(), verification: Default::default(),
direct_role: None,
share_id: None,
}; };
db.upsert_node(&child_node).unwrap(); db.upsert_node(&child_node).unwrap();
+1 -1
View File
@@ -11,7 +11,7 @@ pub enum Error {
Proton(#[from] proton_sdk::ProtonError), Proton(#[from] proton_sdk::ProtonError),
#[error("keyring: {0}")] #[error("keyring: {0}")]
Keyring(#[from] keyring::Error), Keyring(#[from] keyring_core::Error),
#[error("serialization: {0}")] #[error("serialization: {0}")]
Serde(#[from] serde_json::Error), Serde(#[from] serde_json::Error),
+2
View File
@@ -541,6 +541,8 @@ mod tests {
photo: None, photo: None,
album: None, album: None,
verification: Default::default(), verification: Default::default(),
direct_role: None,
share_id: None,
} }
} }
+90 -42
View File
@@ -29,12 +29,14 @@ use std::time::{Duration, Instant};
use parking_lot::Mutex; use parking_lot::Mutex;
use futures::StreamExt;
use pdfs_core::batch; use pdfs_core::batch;
use pdfs_core::cache::{Baseline, StagedWrite}; use pdfs_core::cache::{Baseline, StagedWrite};
use pdfs_core::control::{ActivityKind, TransferDirection}; use pdfs_core::control::{ActivityKind, TransferDirection};
use pdfs_core::db::{OP_CREATE, OP_MKDIR, OP_RENAME, OP_REVISION, OP_TRASH, PendingOp, RenameMeta}; use pdfs_core::db::{OP_CREATE, OP_MKDIR, OP_RENAME, OP_REVISION, OP_TRASH, PendingOp, RenameMeta};
use proton_drive_rs::Node; use proton_drive_rs::proton_sdk::error::ProtonError;
use proton_drive_rs::proton_sdk::ids::NodeUid; use proton_drive_rs::proton_sdk::ids::NodeUid;
use proton_drive_rs::{Node, NodeMoveItem};
use tracing::{debug, error, info, warn}; use tracing::{debug, error, info, warn};
use super::state::Intervals; use super::state::Intervals;
@@ -642,61 +644,46 @@ impl Core {
return Ok(()); return Ok(());
} }
}; };
// The name half goes first, so that a collision on the move half below is let landed = if node.parent_uid.as_ref() == Some(&parent) {
// about the name the node will actually land under rather than the one it self.drain_rename_in_place(&uid, &node.name, &name)?
// is about to lose. } else {
let mut landed = node.name.clone(); // Move and rename land as one request (`move-multiple` takes a
if landed != name { // target name), so there is no half-applied state between them and
match self.rt.block_on(self.client.rename_node(&uid, &name, None)) { // no window for the requirements to go stale between two calls —
Ok(()) => landed = name.clone(), // the race B46 queued this op to get away from.
// Someone took the name while we were offline. Renaming to a let target = (node.name != name).then_some(name.as_str());
match self.move_renaming(&uid, &parent, target) {
Ok(()) => name.clone(),
// The destination holds that name already. Landing under a
// *different* name is the non-destructive resolution: it neither // *different* name is the non-destructive resolution: it neither
// clobbers their file nor drops ours, and it is visible. // clobbers their file nor drops ours, and it is visible.
Err(e) if is_already_exists(&e) => { Err(e) if is_already_exists(&e) => {
let alt = conflict_name(&name, now_secs()); let alt = conflict_name(&name, now_secs());
warn!(%uid, name, alt, "rename target name is taken; using a conflict name"); warn!(%uid, name, alt, "destination already holds that name; using a conflict name");
self.rt self.move_renaming(&uid, &parent, Some(&alt))?;
.block_on(self.client.rename_node(&uid, &alt, None))?;
landed = alt.clone();
self.adopt_drained_name(&uid, &alt); self.adopt_drained_name(&uid, &alt);
self.log_activity( self.log_activity(
ActivityKind::Rename, ActivityKind::Rename,
&name, &name,
format!("name was taken remotely; renamed to {alt}"),
false,
);
}
Err(e) => return Err(e.into()),
}
}
if node.parent_uid.as_ref() != Some(&parent) {
match self.rt.block_on(self.client.move_node(&uid, &parent)) {
Ok(()) => {}
// The destination holds a `landed` of its own. Same resolution as
// a name collision, and it has to happen before the move: the API
// has no move-and-rename, so the node is renamed out of the way
// here and moved second.
Err(e) if is_already_exists(&e) => {
let alt = conflict_name(&landed, now_secs());
warn!(%uid, name = %landed, alt, "destination already holds that name; using a conflict name");
self.rt
.block_on(self.client.rename_node(&uid, &alt, None))?;
self.rt.block_on(self.client.move_node(&uid, &parent))?;
self.adopt_drained_name(&uid, &alt);
self.log_activity(
ActivityKind::Rename,
&landed,
format!("destination already had that name; moved as {alt}"), format!("destination already had that name; moved as {alt}"),
false, false,
); );
alt
} }
// The destination folder is gone. Leaving the node in its current // The destination folder is gone. Leaving the node in its current
// parent is the honest outcome: it is not where the user asked for // parent is the honest outcome: it is not where the user asked for
// it, but it exists, it is where it has always been, and the // it, but it exists and it is where it has always been. The name
// rename half above still applied. Retrying could only fail again // the user chose still applies there. Retrying the move could
// — the folder is not coming back — and would wedge the queue. // only fail again — the folder is not coming back — and would
// wedge the queue.
Err(e) if is_gone(&e) => { Err(e) if is_gone(&e) => {
warn!(%uid, name = %landed, %parent, "move destination is gone; leaving the node where it is"); warn!(%uid, name, %parent, "move destination is gone; leaving the node where it is");
let landed = match self.drain_rename_in_place(&uid, &node.name, &name) {
Ok(landed) => landed,
// The node went with it; nothing is left to rename.
Err(e) if is_gone(&e) => node.name.clone(),
Err(e) => return Err(e.into()),
};
self.log_activity( self.log_activity(
ActivityKind::Rename, ActivityKind::Rename,
&landed, &landed,
@@ -704,16 +691,73 @@ impl Core {
.to_string(), .to_string(),
false, false,
); );
landed
} }
Err(e) => return Err(e.into()), Err(e) => return Err(e.into()),
} }
} };
self.db.delete_op(op.id)?; self.db.delete_op(op.id)?;
self.note_self_change(&uid); self.note_self_change(&uid);
info!(%uid, name = %landed, "pending rename landed"); info!(%uid, name = %landed, "pending rename landed");
Ok(()) Ok(())
} }
/// Rename `uid` without moving it, from `current` to `name`, and return the
/// name it landed under. Someone may have taken the name while we were
/// offline; the node then lands under a conflict name instead.
fn drain_rename_in_place(
&self,
uid: &NodeUid,
current: &str,
name: &str,
) -> Result<String, ProtonError> {
if current == name {
return Ok(current.to_string());
}
match self.rt.block_on(self.client.rename_node(uid, name, None)) {
Ok(()) => Ok(name.to_string()),
Err(e) if is_already_exists(&e) => {
let alt = conflict_name(name, now_secs());
warn!(%uid, name, alt, "rename target name is taken; using a conflict name");
self.rt.block_on(self.client.rename_node(uid, &alt, None))?;
self.adopt_drained_name(uid, &alt);
self.log_activity(
ActivityKind::Rename,
name,
format!("name was taken remotely; renamed to {alt}"),
false,
);
Ok(alt)
}
Err(e) => Err(e),
}
}
/// Move `uid` under `parent` in one request, renaming it to `target_name` on
/// the way when one is given.
fn move_renaming(
&self,
uid: &NodeUid,
parent: &NodeUid,
target_name: Option<&str>,
) -> Result<(), ProtonError> {
let item = NodeMoveItem {
uid: uid.clone(),
target_name: target_name.map(str::to_string),
};
self.rt.block_on(async {
let mut outcomes =
std::pin::pin!(self.client.move_nodes_streaming(vec![item], parent.clone()));
match outcomes.next().await {
Some(Ok((_, outcome))) => outcome,
Some(Err(e)) => Err(e),
None => Err(ProtonError::invalid_operation(format!(
"move of {uid} reported no outcome"
))),
}
})
}
/// Apply a queued trash to the remote. /// Apply a queued trash to the remote.
/// ///
/// A node that is already gone is a success, not a failure: the outcome the /// A node that is already gone is a success, not a failure: the outcome the
@@ -1587,6 +1631,8 @@ mod tests {
photo: None, photo: None,
album: None, album: None,
verification: Default::default(), verification: Default::default(),
direct_role: None,
share_id: None,
} }
} }
@@ -1879,6 +1925,8 @@ mod tests {
photo: None, photo: None,
album: None, album: None,
verification: Default::default(), verification: Default::default(),
direct_role: None,
share_id: None,
} }
} }
+15
View File
@@ -3738,6 +3738,9 @@ impl Core {
// My own tree: a role only means something under a share, and // My own tree: a role only means something under a share, and
// this listing never crosses into one. // this listing never crosses into one.
role: String::new(), role: String::new(),
shared_by: String::new(),
shared_at: 0,
shared_by_unverified: false,
}) })
.collect()) .collect())
} }
@@ -4319,6 +4322,9 @@ impl Core {
uid: item.uid, uid: item.uid,
path: String::new(), path: String::new(),
role: String::new(), role: String::new(),
shared_by: String::new(),
shared_at: 0,
shared_by_unverified: false,
}) })
.collect()) .collect())
} }
@@ -4994,6 +5000,8 @@ fn local_node(uid: NodeUid, parent_uid: NodeUid, name: String, is_dir: bool) ->
album: None, album: None,
// Nothing signed it: it has never been near the crypto layer. // Nothing signed it: it has never been near the crypto layer.
verification: Default::default(), verification: Default::default(),
direct_role: None,
share_id: None,
} }
} }
@@ -5918,6 +5926,8 @@ mod pending_size_tests {
photo: None, photo: None,
album: None, album: None,
verification: Default::default(), verification: Default::default(),
direct_role: None,
share_id: None,
} }
} }
@@ -7128,6 +7138,9 @@ mod tests {
share_id, share_id,
membership_id: ShareMembershipId::from("membership"), membership_id: ShareMembershipId::from("membership"),
permissions, permissions,
invite_time: None,
inviter_email: None,
inviter_verification: None,
}); });
node node
}; };
@@ -7588,6 +7601,8 @@ mod tests {
photo: None, photo: None,
album: None, album: None,
verification: Default::default(), verification: Default::default(),
direct_role: None,
share_id: None,
} }
} }
} }
+16
View File
@@ -15,6 +15,7 @@ use pdfs_core::{CoreError, CoreResult};
use tracing::debug; use tracing::debug;
use crate::QUOTA_TTL; use crate::QUOTA_TTL;
use proton_drive_rs::proton_sdk::crypto::VerificationStatus;
use proton_drive_rs::proton_sdk::ids::NodeUid; use proton_drive_rs::proton_sdk::ids::NodeUid;
use proton_drive_rs::{MemberRole, NodeKind}; use proton_drive_rs::{MemberRole, NodeKind};
@@ -47,6 +48,18 @@ fn shared_entry_of(core: &Core, n: Node) -> DirEntry {
.map(role_to_str) .map(role_to_str)
.unwrap_or_default() .unwrap_or_default()
.to_string(); .to_string();
let invitation = n.membership.as_ref();
let shared_by = invitation
.and_then(|m| m.inviter_email.clone())
.unwrap_or_default();
let shared_at = invitation.and_then(|m| m.invite_time).unwrap_or(0);
// Only a signature that is present and wrong is a warning. An invitation
// that could not be checked — unsigned, or no keys for the claimed inviter —
// is not evidence of forgery, and flagging every such share would teach the
// user to ignore the flag.
let shared_by_unverified = invitation
.and_then(|m| m.inviter_verification)
.is_some_and(|status| status == VerificationStatus::Failed);
DirEntry { DirEntry {
name: n.name, name: n.name,
is_dir, is_dir,
@@ -57,6 +70,9 @@ fn shared_entry_of(core: &Core, n: Node) -> DirEntry {
path: core.rel_path_for_uid(&n.uid).unwrap_or_default(), path: core.rel_path_for_uid(&n.uid).unwrap_or_default(),
uid: n.uid.to_string(), uid: n.uid.to_string(),
role, role,
shared_by,
shared_at,
shared_by_unverified,
} }
} }
+5
View File
@@ -1120,6 +1120,8 @@ mod tests {
photo: None, photo: None,
album: None, album: None,
verification: Default::default(), verification: Default::default(),
direct_role: None,
share_id: None,
} }
} }
@@ -1128,6 +1130,9 @@ mod tests {
share_id: ShareId::from("share"), share_id: ShareId::from("share"),
membership_id: ShareMembershipId::from("membership"), membership_id: ShareMembershipId::from("membership"),
permissions, permissions,
invite_time: None,
inviter_email: None,
inviter_verification: None,
} }
} }
+2
View File
@@ -368,6 +368,8 @@ mod tests {
photo: None, photo: None,
album: None, album: None,
verification: Default::default(), verification: Default::default(),
direct_role: None,
share_id: None,
} }
} }
+2
View File
@@ -51,6 +51,8 @@ pub(crate) fn virtual_node(parent_uid: NodeUid, name: String, timestamp: i64) ->
photo: None, photo: None,
album: None, album: None,
verification: Default::default(), verification: Default::default(),
direct_role: None,
share_id: None,
} }
} }
+4 -4
View File
@@ -29,9 +29,9 @@ anyhow.workspace = true
tokio.workspace = true tokio.workspace = true
serde.workspace = true serde.workspace = true
serde_json.workspace = true serde_json.workspace = true
ksni = "0.2" ksni = { version = "0.3", features = ["blocking"] }
gtk4 = { version = "0.9", features = ["v4_12"] } gtk4 = { version = "0.11", features = ["v4_12"] }
adw = { package = "libadwaita", version = "0.7", features = ["v1_5"] } adw = { package = "libadwaita", version = "0.9", features = ["v1_5"] }
async-channel = "2" async-channel = "2"
exif = { version = "0.6", package = "kamadak-exif" } exif = { version = "0.6", package = "kamadak-exif" }
webkit6 = "0.4" webkit6 = "0.6"
+3
View File
@@ -2345,6 +2345,9 @@ pub(crate) fn repaint_search(ui: &Rc<Ui>, hits: &[SearchHit]) {
uid: h.uid.clone(), uid: h.uid.clone(),
path: h.path.clone(), path: h.path.clone(),
role: String::new(), role: String::new(),
shared_by: String::new(),
shared_at: 0,
shared_by_unverified: false,
}) })
.collect(); .collect();
entries.sort_by(|a, b| { entries.sort_by(|a, b| {
+48 -4
View File
@@ -309,6 +309,17 @@ fn shared_entry_row(ui: &Rc<Ui>, entry: &DirEntry) -> adw::ActionRow {
if let Some(badge) = role_badge(&entry.role) { if let Some(badge) = role_badge(&entry.role) {
row.add_suffix(&badge); row.add_suffix(&badge);
} }
// The "shared by" in the subtitle is what the invitation claims. When its
// signature does not check out against that person's keys, the claim must
// not read as fact — someone may be impersonating a contact to get a file
// opened.
if entry.shared_by_unverified {
let warning = gtk4::Image::from_icon_name("dialog-warning-symbolic");
warning.add_css_class("warning");
warning.set_valign(gtk4::Align::Center);
warning.set_tooltip_text(Some(UNVERIFIED_INVITER_TOOLTIP));
row.add_suffix(&warning);
}
row.add_prefix(&file_thumbnail(ui, entry, 40, 24, true)); row.add_prefix(&file_thumbnail(ui, entry, 40, 24, true));
let ui_act = ui.clone(); let ui_act = ui.clone();
let uid = entry.uid.clone(); let uid = entry.uid.clone();
@@ -329,12 +340,29 @@ fn shared_entry_row(ui: &Rc<Ui>, entry: &DirEntry) -> adw::ActionRow {
row row
} }
/// A shared row's subtitle: its size (files only), and where it can be reached /// Tooltip on the warning shown next to a share whose invitation did not verify.
/// in the local mount once the synthetic `Shared with me/` directory has interned const UNVERIFIED_INVITER_TOOLTIP: &str = "The invitation's signature doesn't match the sender's keys. \
/// it. An unlisted share has no local path yet, and says nothing rather than It may not really be from them.";
/// pointing at a directory that is not there.
/// A shared row's subtitle: who shared it and when (share roots only — that is
/// where the invitation lives), its size (files only), and where it can be
/// reached in the local mount once the synthetic `Shared with me/` directory has
/// interned it. An unlisted share has no local path yet, and says nothing rather
/// than pointing at a directory that is not there.
pub(crate) fn shared_row_subtitle(entry: &DirEntry) -> String { pub(crate) fn shared_row_subtitle(entry: &DirEntry) -> String {
let mut parts: Vec<String> = Vec::new(); let mut parts: Vec<String> = Vec::new();
if !entry.shared_by.is_empty() {
let date = if entry.shared_at > 0 {
format_modified(entry.shared_at)
} else {
String::new()
};
parts.push(if date.is_empty() {
format!("Shared by {}", entry.shared_by)
} else {
format!("Shared by {} on {date}", entry.shared_by)
});
}
if !entry.is_dir { if !entry.is_dir {
parts.push(human_bytes(entry.size)); parts.push(human_bytes(entry.size));
} }
@@ -723,9 +751,25 @@ mod tests {
uid: "vol~link".into(), uid: "vol~link".into(),
path: path.into(), path: path.into(),
role: role.into(), role: role.into(),
shared_by: String::new(),
shared_at: 0,
shared_by_unverified: false,
} }
} }
#[test]
fn a_share_root_says_who_shared_it() {
let mut root = entry(true, "", "viewer");
root.shared_by = "alice@proton.me".into();
assert_eq!(shared_row_subtitle(&root), "Shared by alice@proton.me");
root.shared_at = 1_700_000_000;
let subtitle = shared_row_subtitle(&root);
assert!(
subtitle.starts_with("Shared by alice@proton.me on "),
"{subtitle}"
);
}
#[test] #[test]
fn a_share_with_no_local_path_says_nothing_about_one() { fn a_share_with_no_local_path_says_nothing_about_one() {
// Before the synthetic directory has been listed, the daemon cannot // Before the synthetic directory has been listed, the daemon cannot
@@ -293,5 +293,8 @@ pub(crate) fn shared_item_as_entry(item: &SharedItem) -> DirEntry {
path: item.path.clone(), path: item.path.clone(),
// Shared *by* me: I own it, so there is no role of mine to report. // Shared *by* me: I own it, so there is no role of mine to report.
role: String::new(), role: String::new(),
shared_by: String::new(),
shared_at: 0,
shared_by_unverified: false,
} }
} }
+9 -8
View File
@@ -14,8 +14,9 @@ use std::process::Command;
use std::time::Duration; use std::time::Duration;
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use ksni::blocking::TrayMethods;
use ksni::menu::StandardItem; use ksni::menu::StandardItem;
use ksni::{MenuItem, Tray, TrayService}; use ksni::{MenuItem, Tray};
use pdfs_core::auth; use pdfs_core::auth;
use pdfs_core::config::AppDirs; use pdfs_core::config::AppDirs;
use pdfs_core::control::{ use pdfs_core::control::{
@@ -282,13 +283,13 @@ fn main() -> Result<()> {
state: poll_state(&socket, &default_mountpoint), state: poll_state(&socket, &default_mountpoint),
}; };
let service = TrayService::new(tray); // The tray autostarts with the session and can beat the desktop's SNI
let handle = service.handle(); // watcher onto the bus. Without `assume_sni_available` that race is a hard
std::thread::spawn(move || { // spawn error; with it the item waits and registers once the watcher shows.
if let Err(e) = service.run() { let handle = tray
tracing::error!("tray service stopped: {e:?}"); .assume_sni_available(true)
} .spawn()
}); .context("start tray service")?;
// Poll the daemon forever, pushing each fresh snapshot into the tray so the // Poll the daemon forever, pushing each fresh snapshot into the tray so the
// menu reflects mount/login changes made elsewhere (e.g. via the CLI). // menu reflects mount/login changes made elsewhere (e.g. via the CLI).
+1 -1
View File
@@ -1364,7 +1364,7 @@ fn icon_for(name: &str, is_dir: bool) -> gio::Icon {
if is_dir { if is_dir {
return gio::ThemedIcon::new("folder").upcast(); return gio::ThemedIcon::new("folder").upcast();
} }
let (content_type, _uncertain) = gio::functions::content_type_guess(Some(name), &[]); let (content_type, _uncertain) = gio::functions::content_type_guess(Some(name), None);
gio::functions::content_type_get_icon(&content_type) gio::functions::content_type_get_icon(&content_type)
} }
+10
View File
@@ -2650,6 +2650,16 @@ collisions without losing the source. Simple rename-only and move-only calls
remain synchronous. The managed live suite subsequently passed the replacement remain synchronous. The managed live suite subsequently passed the replacement
and combined move/rename cases. and combined move/rename cases.
**Follow-up (2026-09-15, unverified):** With SDK 0.6.3 the drain sends the
move and the rename as one `move-multiple` request carrying the target name,
instead of renaming in the source folder and then moving. That removes the
second call the stale requirements could hit, and the SDK retries a remaining
`InvalidRequirements` once with the server's current name hash. A destination
name collision now moves under a conflict name in one step. Same-folder renames
are unchanged. The synchronous FUSE `rename` path (`serve_rename`) still renames
then moves with its bounded retry and has not been converted. Needs a managed
live run of the combined move/rename cases.
--- ---
## B47 — FUSE Accepts Path Components Longer Than NAME_MAX ## B47 — FUSE Accepts Path Components Longer Than NAME_MAX
+79 -1
View File
@@ -11,7 +11,83 @@ scratch (user data in `staging/` and `recovery/` is never touched by this).
## [Unreleased] ## [Unreleased]
No schema change. Nothing yet.
## [1.10.1] — 2026-09-15
Dependency upgrade release, clearing both open Dependabot advisories. No schema change.
Schema: **28**; SDK bumped to `proton-sdk` / `proton-drive-rs` **0.6.4**.
### Security
- **`lru` IterMut Stacked Borrows advisory.** The SDK now depends on `lru` 0.18.
- **`atty` unaligned read advisory.** The tray moved to `ksni` 0.3, which no longer pulls
`dbus-codegen` → `clap` 2 → `atty`.
### Changed
- **Keyring backend is `keyring-core` + `dbus-secret-service-keyring-store`** (keyring 4's split).
Same libdbus Secret Service as before; stored sessions written by earlier releases are still
found, so no re-login is needed.
- **The tray waits for a late StatusNotifierWatcher** instead of failing to start when it beats
the desktop's tray host onto the session bus.
- Upgraded `gtk4` 0.11, `libadwaita` 0.9, `webkit6` 0.6, `rusqlite` 0.40 (bundled SQLite),
`fuser` 0.18, `directories` 6, `sha1`/`sha2` 0.11, `base64` 0.23.
## [1.10.0] — 2026-09-15
One-request move-and-rename in the drain, and who shared an item with you. No schema change.
Schema: **28**; SDK bumped to `proton-sdk` / `proton-drive-rs` **0.6.3**.
### Added
- **Shared with me says who shared an item, and when.** The GUI row subtitle and `pdfs
shared-with-me` show the inviter and invite date for a share root. The invitation's signature is
checked against the claimed inviter's keys; when it does not verify, the GUI shows a warning
and the CLI prints one, since the sender may be forged. `DirEntry` gains `shared_by`,
`shared_at` and `shared_by_unverified` (defaulted, so older front-ends and daemons interoperate).
### Changed
- **A queued move-and-rename lands as one request.** `drain_rename` used to rename in the source
folder and then move, with a rename-out-of-the-way step when the destination held the name. It
now calls `move-multiple` with a target name, so there is no half-applied state between the two
halves and no second call for the encrypted-name requirements to go stale against (B46). A
rename that stays in the same folder still uses the rename endpoint.
### Fixed
- **Moving a node with no signature email no longer fails.** Such nodes (created anonymously, e.g.
through a public link) need the passphrase signed by the mover; SDK 0.6.3 sends that signature.
- **A node trashed through the newer API field is recognised as trashed.** The SDK now reads
`TrashTime` as well as the legacy `Trashed` field, so the drain no longer tries to rename a node
that is already in the trash.
## [1.9.3] — 2026-09-01
The daemon's wait-for-login loop, and the latency that fixing it introduced. No schema change.
Schema: **28**; SDK unchanged at `proton-sdk` / `proton-drive-rs` **0.6.2**.
### Fixed
- **An enabled service with no saved session no longer polls the keyring 28,800 times a day.**
`wait_for_session` slept a flat 3 s between checks, and a check is not just a sleep — `auth::load`
builds a fresh `keyring::Entry`, so every iteration opened a new D-Bus connection to the Secret
Service and negotiated a session on it. The wait now backs off, and "not logged in" is said once
at `info` instead of on every pass, which also stops the journal filling with tens of thousands
of identical lines (`#21`).
- **Signing in wakes the parked daemon instead of leaving the mount up to an interval late.** The
backoff had no wake-up path: a unit started at boot reaches the ceiling within ~90 s, and
`enable --now` is a no-op on an already-active unit, so the GUI login and the tray's Connect left
it sleeping. `service::enable_start` now runs `enable` + `restart`, which interrupts the sleep and
still starts a unit that is down, and `pdfs login` restarts the unit when it is already active —
deliberately without touching `enable`, so a headless setup driving `pdfs daemon` by hand is left
alone. The ceiling also drops from 60 s to 15 s, still an 80% cut in keyring/D-Bus wakeups
(28,800/day → 5,760/day), bounding the worst case for paths that never reach systemd.
### Changed
- The wait is extracted into `wait_for_session()`, taking the load and sleep calls as parameters,
so the schedule is testable without a keyring and without real time passing.
## [1.9.2] — 2026-08-28
Repair for photos a Takeout import filed under the day they were imported, an `fzf` launcher front
end, and a gallery that survives 1,600 photos on one date. No schema change. Schema: **28**; SDK
`proton-sdk` / `proton-drive-rs` **0.6.2**.
### Added ### Added
- **`pdfs redate-photos`: repair photos filed under the day they were imported.** A Takeout whose - **`pdfs redate-photos`: repair photos filed under the day they were imported.** A Takeout whose
@@ -621,6 +697,8 @@ First stable release: FUSE files-on-demand mount, sync daemon under `proton-driv
- The outstanding FUSE defects tracked in `docs/BUGS.md`, plus a truncate defect, validated - The outstanding FUSE defects tracked in `docs/BUGS.md`, plus a truncate defect, validated
by a new POSIX compliance suite for the filesystem. by a new POSIX compliance suite for the filesystem.
[1.9.3]: https://github.com/narrrl/proton-drive-linux/releases/tag/v1.9.3
[1.9.2]: https://github.com/narrrl/proton-drive-linux/releases/tag/v1.9.2
[1.9.1]: https://github.com/narrrl/proton-drive-linux/releases/tag/v1.9.1 [1.9.1]: https://github.com/narrrl/proton-drive-linux/releases/tag/v1.9.1
[1.9.0]: https://github.com/narrrl/proton-drive-linux/releases/tag/v1.9.0 [1.9.0]: https://github.com/narrrl/proton-drive-linux/releases/tag/v1.9.0
[1.8.2]: https://github.com/narrrl/proton-drive-linux/releases/tag/v1.8.2 [1.8.2]: https://github.com/narrrl/proton-drive-linux/releases/tag/v1.8.2
+1 -1
View File
@@ -9,7 +9,7 @@
# Build + install: cd packaging && makepkg -fi # Build + install: cd packaging && makepkg -fi
pkgname=proton-drive-linux pkgname=proton-drive-linux
pkgver=1.9.3 pkgver=1.10.1
pkgrel=1 pkgrel=1
pkgdesc="Unofficial Proton Drive client for Linux: files-on-demand FUSE mount, CLI, GTK4 app + tray" pkgdesc="Unofficial Proton Drive client for Linux: files-on-demand FUSE mount, CLI, GTK4 app + tray"
arch=('x86_64') arch=('x86_64')