mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-04 00:47:00 +01:00
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:
1 parent
c28803b037
commit
eb56f4a1e0
17 files changed
+315
-53
No files matched your search
Generated
+4
-4
@@ -3858,9 +3858,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "proton-drive-rs"
|
name = "proton-drive-rs"
|
||||||
version = "0.6.2"
|
version = "0.6.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9d919c52e0ffcb94459ec14daf46d13e7590b81ade0ff757665c62c310faac0b"
|
checksum = "11faa95cf40bd87d4d48435cdf9b16fff5f14caf45b3a565a941fd4a6e18cefe"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"base64",
|
"base64",
|
||||||
@@ -3885,9 +3885,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "proton-sdk"
|
name = "proton-sdk"
|
||||||
version = "0.6.2"
|
version = "0.6.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6b531dbbaa8133c0ef63453ed75dbc0a416dfb12f24f39a6e4f0549caa95dcc0"
|
checksum = "7de99acbd776f7bad5e1e1f3c40d7df3e4ef7411984d02370008ba1ff270a047"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes-gcm 0.11.0",
|
"aes-gcm 0.11.0",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
|
|||||||
+2
-2
@@ -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.3" }
|
||||||
proton-drive-rs = { version = "0.6.2" }
|
proton-drive-rs = { version = "0.6.3" }
|
||||||
|
|
||||||
# Internal crates.
|
# Internal crates.
|
||||||
pdfs-core = { path = "crates/pdfs-core" }
|
pdfs-core = { path = "crates/pdfs-core" }
|
||||||
|
|||||||
@@ -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"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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| {
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
+22
-1
@@ -11,7 +11,28 @@ scratch (user data in `staging/` and `recovery/` is never touched by this).
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
Nothing yet.
|
SDK bumped to `proton-sdk` / `proton-drive-rs` **0.6.3**. No schema change.
|
||||||
|
|
||||||
|
### 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
|
## [1.9.3] — 2026-09-01
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user