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).
This commit is contained in:
nvrl committed 2026-09-15 13:59:13 +02:00
1 parent c28803b037
commit eb56f4a1e0
17 files changed
+315 -53

No files matched your search

+71
View File
@@ -1129,6 +1129,9 @@ fn cmd_shared_with_me(uid: Option<String>) -> Result<()> {
format!(" ({})", e.role)
};
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)),
@@ -1137,6 +1140,39 @@ fn cmd_shared_with_me(uid: Option<String>) -> Result<()> {
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
/// 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.
@@ -2805,4 +2841,39 @@ mod login_wait_tests {
wait_for_session(|| Ok(()), |_| slept += 1).unwrap();
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"));
}
}
+16
View File
@@ -906,6 +906,22 @@ pub struct DirEntry {
/// report. Defaulted for wire-compat with clients/daemons predating it.
#[serde(default)]
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
+4
View File
@@ -3201,6 +3201,8 @@ fn test_db_has_children_and_trashed_filtering() {
photo: None,
album: None,
verification: Default::default(),
direct_role: None,
share_id: None,
};
db.upsert_node(&parent_node).unwrap();
@@ -3227,6 +3229,8 @@ fn test_db_has_children_and_trashed_filtering() {
photo: None,
album: None,
verification: Default::default(),
direct_role: None,
share_id: None,
};
db.upsert_node(&child_node).unwrap();
+2
View File
@@ -541,6 +541,8 @@ mod tests {
photo: None,
album: None,
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 futures::StreamExt;
use pdfs_core::batch;
use pdfs_core::cache::{Baseline, StagedWrite};
use pdfs_core::control::{ActivityKind, TransferDirection};
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::{Node, NodeMoveItem};
use tracing::{debug, error, info, warn};
use super::state::Intervals;
@@ -642,61 +644,46 @@ impl Core {
return Ok(());
}
};
// The name half goes first, so that a collision on the move half below is
// about the name the node will actually land under rather than the one it
// is about to lose.
let mut landed = node.name.clone();
if landed != name {
match self.rt.block_on(self.client.rename_node(&uid, &name, None)) {
Ok(()) => landed = name.clone(),
// Someone took the name while we were offline. Renaming to a
let landed = if node.parent_uid.as_ref() == Some(&parent) {
self.drain_rename_in_place(&uid, &node.name, &name)?
} else {
// Move and rename land as one request (`move-multiple` takes a
// target name), so there is no half-applied state between them and
// no window for the requirements to go stale between two calls —
// the race B46 queued this op to get away from.
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
// clobbers their file nor drops ours, and it is visible.
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))?;
landed = alt.clone();
warn!(%uid, name, alt, "destination already holds that name; using a conflict name");
self.move_renaming(&uid, &parent, Some(&alt))?;
self.adopt_drained_name(&uid, &alt);
self.log_activity(
ActivityKind::Rename,
&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}"),
false,
);
alt
}
// The destination folder is gone. Leaving the node in its current
// 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
// rename half above still applied. Retrying could only fail again
// — the folder is not coming back — and would wedge the queue.
// it, but it exists and it is where it has always been. The name
// the user chose still applies there. Retrying the move could
// only fail again — the folder is not coming back — and would
// wedge the queue.
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(
ActivityKind::Rename,
&landed,
@@ -704,16 +691,73 @@ impl Core {
.to_string(),
false,
);
landed
}
Err(e) => return Err(e.into()),
}
}
};
self.db.delete_op(op.id)?;
self.note_self_change(&uid);
info!(%uid, name = %landed, "pending rename landed");
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.
///
/// A node that is already gone is a success, not a failure: the outcome the
@@ -1587,6 +1631,8 @@ mod tests {
photo: None,
album: None,
verification: Default::default(),
direct_role: None,
share_id: None,
}
}
@@ -1879,6 +1925,8 @@ mod tests {
photo: None,
album: None,
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
// this listing never crosses into one.
role: String::new(),
shared_by: String::new(),
shared_at: 0,
shared_by_unverified: false,
})
.collect())
}
@@ -4319,6 +4322,9 @@ impl Core {
uid: item.uid,
path: String::new(),
role: String::new(),
shared_by: String::new(),
shared_at: 0,
shared_by_unverified: false,
})
.collect())
}
@@ -4994,6 +5000,8 @@ fn local_node(uid: NodeUid, parent_uid: NodeUid, name: String, is_dir: bool) ->
album: None,
// Nothing signed it: it has never been near the crypto layer.
verification: Default::default(),
direct_role: None,
share_id: None,
}
}
@@ -5918,6 +5926,8 @@ mod pending_size_tests {
photo: None,
album: None,
verification: Default::default(),
direct_role: None,
share_id: None,
}
}
@@ -7128,6 +7138,9 @@ mod tests {
share_id,
membership_id: ShareMembershipId::from("membership"),
permissions,
invite_time: None,
inviter_email: None,
inviter_verification: None,
});
node
};
@@ -7588,6 +7601,8 @@ mod tests {
photo: None,
album: None,
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 crate::QUOTA_TTL;
use proton_drive_rs::proton_sdk::crypto::VerificationStatus;
use proton_drive_rs::proton_sdk::ids::NodeUid;
use proton_drive_rs::{MemberRole, NodeKind};
@@ -47,6 +48,18 @@ fn shared_entry_of(core: &Core, n: Node) -> DirEntry {
.map(role_to_str)
.unwrap_or_default()
.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 {
name: n.name,
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(),
uid: n.uid.to_string(),
role,
shared_by,
shared_at,
shared_by_unverified,
}
}
+5
View File
@@ -1120,6 +1120,8 @@ mod tests {
photo: None,
album: None,
verification: Default::default(),
direct_role: None,
share_id: None,
}
}
@@ -1128,6 +1130,9 @@ mod tests {
share_id: ShareId::from("share"),
membership_id: ShareMembershipId::from("membership"),
permissions,
invite_time: None,
inviter_email: None,
inviter_verification: None,
}
}
+2
View File
@@ -368,6 +368,8 @@ mod tests {
photo: None,
album: None,
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,
album: None,
verification: Default::default(),
direct_role: None,
share_id: None,
}
}
+3
View File
@@ -2345,6 +2345,9 @@ pub(crate) fn repaint_search(ui: &Rc<Ui>, hits: &[SearchHit]) {
uid: h.uid.clone(),
path: h.path.clone(),
role: String::new(),
shared_by: String::new(),
shared_at: 0,
shared_by_unverified: false,
})
.collect();
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) {
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));
let ui_act = ui.clone();
let uid = entry.uid.clone();
@@ -329,12 +340,29 @@ fn shared_entry_row(ui: &Rc<Ui>, entry: &DirEntry) -> adw::ActionRow {
row
}
/// A shared row's subtitle: 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.
/// Tooltip on the warning shown next to a share whose invitation did not verify.
const UNVERIFIED_INVITER_TOOLTIP: &str = "The invitation's signature doesn't match the sender's keys. \
It may not really be from them.";
/// 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 {
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 {
parts.push(human_bytes(entry.size));
}
@@ -723,9 +751,25 @@ mod tests {
uid: "vol~link".into(),
path: path.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]
fn a_share_with_no_local_path_says_nothing_about_one() {
// 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(),
// Shared *by* me: I own it, so there is no role of mine to report.
role: String::new(),
shared_by: String::new(),
shared_at: 0,
shared_by_unverified: false,
}
}