diff --git a/Cargo.lock b/Cargo.lock index 1fc1518..e8d9518 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3858,9 +3858,9 @@ dependencies = [ [[package]] name = "proton-drive-rs" -version = "0.6.2" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d919c52e0ffcb94459ec14daf46d13e7590b81ade0ff757665c62c310faac0b" +checksum = "11faa95cf40bd87d4d48435cdf9b16fff5f14caf45b3a565a941fd4a6e18cefe" dependencies = [ "async-trait", "base64", @@ -3885,9 +3885,9 @@ dependencies = [ [[package]] name = "proton-sdk" -version = "0.6.2" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b531dbbaa8133c0ef63453ed75dbc0a416dfb12f24f39a6e4f0549caa95dcc0" +checksum = "7de99acbd776f7bad5e1e1f3c40d7df3e4ef7411984d02370008ba1ff270a047" dependencies = [ "aes-gcm 0.11.0", "async-trait", diff --git a/Cargo.toml b/Cargo.toml index c5372b6..410d2da 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,8 +15,8 @@ keywords = ["proton", "drive", "fuse", "sync", "linux"] categories = ["filesystem", "command-line-utilities"] [workspace.dependencies] -proton-sdk = { version = "0.6.2" } -proton-drive-rs = { version = "0.6.2" } +proton-sdk = { version = "0.6.3" } +proton-drive-rs = { version = "0.6.3" } # Internal crates. pdfs-core = { path = "crates/pdfs-core" } diff --git a/crates/pdfs-cli/src/main.rs b/crates/pdfs-cli/src/main.rs index 3ac39f6..d4b52ba 100644 --- a/crates/pdfs-cli/src/main.rs +++ b/crates/pdfs-cli/src/main.rs @@ -1129,6 +1129,9 @@ fn cmd_shared_with_me(uid: Option) -> 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) -> 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 { + 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")); + } } diff --git a/crates/pdfs-core/src/control.rs b/crates/pdfs-core/src/control.rs index 1cfabd8..d1ad1d1 100644 --- a/crates/pdfs-core/src/control.rs +++ b/crates/pdfs-core/src/control.rs @@ -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 diff --git a/crates/pdfs-core/src/db/tests.rs b/crates/pdfs-core/src/db/tests.rs index c44d4ff..9f03a6c 100644 --- a/crates/pdfs-core/src/db/tests.rs +++ b/crates/pdfs-core/src/db/tests.rs @@ -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(); diff --git a/crates/pdfs-fuse/src/background.rs b/crates/pdfs-fuse/src/background.rs index 437b66f..57328f0 100644 --- a/crates/pdfs-fuse/src/background.rs +++ b/crates/pdfs-fuse/src/background.rs @@ -541,6 +541,8 @@ mod tests { photo: None, album: None, verification: Default::default(), + direct_role: None, + share_id: None, } } diff --git a/crates/pdfs-fuse/src/drain.rs b/crates/pdfs-fuse/src/drain.rs index 3c44698..82f459f 100644 --- a/crates/pdfs-fuse/src/drain.rs +++ b/crates/pdfs-fuse/src/drain.rs @@ -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 { + 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, } } diff --git a/crates/pdfs-fuse/src/lib.rs b/crates/pdfs-fuse/src/lib.rs index 6d1eda6..86433a8 100644 --- a/crates/pdfs-fuse/src/lib.rs +++ b/crates/pdfs-fuse/src/lib.rs @@ -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, } } } diff --git a/crates/pdfs-fuse/src/sharing.rs b/crates/pdfs-fuse/src/sharing.rs index 37b9307..adc87dd 100644 --- a/crates/pdfs-fuse/src/sharing.rs +++ b/crates/pdfs-fuse/src/sharing.rs @@ -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, } } diff --git a/crates/pdfs-fuse/src/state.rs b/crates/pdfs-fuse/src/state.rs index 2226f08..101961f 100644 --- a/crates/pdfs-fuse/src/state.rs +++ b/crates/pdfs-fuse/src/state.rs @@ -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, } } diff --git a/crates/pdfs-fuse/src/sweep.rs b/crates/pdfs-fuse/src/sweep.rs index 989a2cd..f3962b3 100644 --- a/crates/pdfs-fuse/src/sweep.rs +++ b/crates/pdfs-fuse/src/sweep.rs @@ -368,6 +368,8 @@ mod tests { photo: None, album: None, verification: Default::default(), + direct_role: None, + share_id: None, } } diff --git a/crates/pdfs-fuse/src/virtual.rs b/crates/pdfs-fuse/src/virtual.rs index 7cb00a0..19cd063 100644 --- a/crates/pdfs-fuse/src/virtual.rs +++ b/crates/pdfs-fuse/src/virtual.rs @@ -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, } } diff --git a/crates/pdfs-gui/src/app/pages/browser.rs b/crates/pdfs-gui/src/app/pages/browser.rs index b819a16..83c3a41 100644 --- a/crates/pdfs-gui/src/app/pages/browser.rs +++ b/crates/pdfs-gui/src/app/pages/browser.rs @@ -2345,6 +2345,9 @@ pub(crate) fn repaint_search(ui: &Rc, 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| { diff --git a/crates/pdfs-gui/src/app/pages/shared.rs b/crates/pdfs-gui/src/app/pages/shared.rs index 7f13d65..55d288c 100644 --- a/crates/pdfs-gui/src/app/pages/shared.rs +++ b/crates/pdfs-gui/src/app/pages/shared.rs @@ -309,6 +309,17 @@ fn shared_entry_row(ui: &Rc, 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, 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 = 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 diff --git a/crates/pdfs-gui/src/app/pages/shared_by_me.rs b/crates/pdfs-gui/src/app/pages/shared_by_me.rs index 317010e..28efd37 100644 --- a/crates/pdfs-gui/src/app/pages/shared_by_me.rs +++ b/crates/pdfs-gui/src/app/pages/shared_by_me.rs @@ -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, } } diff --git a/docs/BUGS.md b/docs/BUGS.md index ac58a8a..53705cb 100644 --- a/docs/BUGS.md +++ b/docs/BUGS.md @@ -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 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 diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index bba2cfe..2906596 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -11,7 +11,28 @@ scratch (user data in `staging/` and `recovery/` is never touched by this). ## [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