From 1b8abf70f8e87c596050d7cb338b22d81cd36530 Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Thu, 15 Oct 2026 19:50:17 +0200 Subject: [PATCH] feat(drive): port upstream C# SDK up to 8ffae007 Port af391cbb (require node type for getting an available name); c6b45188 (file-backed transfer buffers) is C# memory plumbing and a no-op here. - get_available_name takes an AvailableNameTarget. A file keeps its extension last (name (1).ext); a folder appends to the whole name (name (1)), so a folder named v1.2 no longer resolves to v1 (1).2. - Split alternate_names into alternate_file_names and alternate_folder_names, mirroring AlternateFileNameGenerator. Breaking: get_available_name gains a parameter. --- UPSTREAM_SYNC.md | 5 +- crates/proton-drive-rs/src/client.rs | 87 ++++++++++++------- crates/proton-drive-rs/src/lib.rs | 5 +- crates/proton-drive-rs/src/node.rs | 12 +++ .../tests/live_transfer_ops.rs | 6 +- 5 files changed, 76 insertions(+), 39 deletions(-) diff --git a/UPSTREAM_SYNC.md b/UPSTREAM_SYNC.md index b8460cd..e8ef5f9 100644 --- a/UPSTREAM_SYNC.md +++ b/UPSTREAM_SYNC.md @@ -7,8 +7,8 @@ gitignored, so this file is the only durable record of what we last reviewed. - **Upstream**: https://github.com/ProtonDriveApps/sdk - **Reconciled subtree**: `client/cs/src` (was `client/cs/sdk/src` before the mid-2026 projects reorg; the script watches both) -- **Pinned**: `df3a717b` -- **Date**: 2026-10-06 +- **Pinned**: `8ffae007` +- **Date**: 2026-10-15 > **Note**: upstream force-pushes — the SHAs in rows before 2026-07-14 no longer > exist in its history. Triage by commit subject/date when a pin goes stale. @@ -41,3 +41,4 @@ gitignored, so this file is the only durable record of what we last reviewed. | 2026-09-19 | `ce000edd` → `6cbf2f44` | `6cbf2f44`, `20822176` | **retriability on block transfers** (`6cbf2f44`): new `ProtonError::is_retriable()` / `ProtonApiError::is_retriable()` (C# `RetryPolicy.IsRetriable` — a 4xx is permanent except 404, 408 and 429; the envelope codes `TooManyRequests` and `DoesNotExist` override the status, as a rate limit can arrive under a 2xx-shaped envelope). `upload_one_block` now ends the transfer on a permanent rejection instead of spending its remaining attempts on it, keeping the existing expired-upload-token re-mint and timeout downshift. The whole-file download path gained the expired-URL recovery `RevisionReader` already had: the block table plus its deduplicated one-shot re-list moved into `revision::BlockTargets`, which `RevisionReader::block_plaintext`, `ProtonDriveClient::write_content_blocks` and the public-link `download_file_to` all fetch through — so a download long enough to outlive its block URLs re-lists the revision once per expiry rather than failing. diverged deliberately: thumbnail blob fetches got no change — upstream passes them `refreshTransferTargetAsync: null` and relies on the same predicate to stop retrying, and our thumbnail fetch is a single `get_storage_blob` whose only retry is `http::send_retrying`, which already retries 408/429/502/503/504 and nothing else. **small-upload interop** (`20822176`): `upload_file` / `upload_new_revision` now fall back to the regular block path when the one-request endpoint reports `AlreadyExists` (2500) — the Proton API reports upstream's `NodeWithSameNameExists` and `RevisionDraftConflict` under that one code, and the block path knows how to delete our own stale draft and retry while the small endpoint is one shot. Everything else still propagates: a small upload creates, uploads and seals in a single non-idempotent request, so replaying it on 429/424/5xx/a dropped connection risks a duplicate file (upstream excludes 429 and 424 explicitly). Not ported: upstream's `SmallUploadNotApplicable` condition has no envelope code we can recognise, and `HttpRequestType.SmallUpload` maps to our telemetry op name rather than a new request type. no-op: `6ce47922` (thumbnail retry leaking upload-queue slots) — we have no `UploadQueue` and no per-file block accounting, only the client-wide in-flight block semaphore, and our thumbnail list is a slice rather than a lazily re-enumerated `IEnumerable`, so the double-reservation it fixes cannot occur. **Ours, not upstream**: `RetryPolicy::max_retry_after` (default 120 s) now bounds a server-supplied `Retry-After`, which was honoured uncapped inside a sleep a daemon thread blocks on — an hour-long one is indistinguishable from a hang. Live validation pending: a multi-block upload, a <128 KiB upload, and a download left running past its block-URL lifetime.| | 2026-09-27 | `6cbf2f44` → `f28a93ce` | — | no behavioral changes. not ported: `0c054f0c` (`DriveClientEnumerateNodes` / `DrivePhotosClientEnumerateNodes` interop messages exposing C# `EnumerateNodesAsync` to the Kotlin bindings — CExports/proto only; `ProtonDriveClient::enumerate_nodes` and `ProtonPhotosClient::enumerate_nodes` already exist). | | 2026-10-06 | `f28a93ce` → `df3a717b` | `0aabea59` | ported: **recently accessed report** (`ProtonDriveClient::report_recently_accessed` → `POST recently-accessed-items`, `ProtonPhotosClient::report_recently_accessed` → `POST photos/recently-accessed-items`; batches of 50, missing `AccessTime` defaults to now, epoch seconds; `RecentlyAccessedReportItem` takes `Option` rather than `DateTimeOffset`). Also ported the DTO half of `f257c20e`: `LinkDto` / `ActiveRevisionDto` read `ThirdParty` and `Sdk` (default `false`). not ported: rest of `f257c20e` (`DecryptionErrorEvent` / `VerificationErrorEvent` with `Recency` and `CreatedBy` — telemetry plumbing for C#'s interop metric callback, we emit no such events; same reasoning as `29e58378`). Live validation pending: the recently-accessed endpoints. | +| 2026-10-15 | `df3a717b` → `8ffae007` | `af391cbb` | ported: **folder-aware available names** (`af391cbb`, breaking upstream and here): `get_available_name` takes an `AvailableNameTarget` (`File` / `Folder`). A file keeps its extension last (`name (1).ext`, as before); a folder appends to the whole name (`name (1)`), so a folder named `v1.2` no longer resolves to `v1 (1).2`. `alternate_names` split into `alternate_file_names` / `alternate_folder_names` (C# `AlternateFileNameGenerator.GetFileNames` / `GetFolderNames`). no-op: `c6b45188` (C# transfer buffers optionally backed by an unlinked `mkstemp` file instead of `RecyclableMemoryStream`, behind a compile-time flag) — memory plumbing only; our blocks are `Vec` bounded by the client-wide in-flight block semaphore, and the verifier prefix it now reads from the stream is the same `min(prefix, len)` bytes our `verification_token` already zero-pads. **Downstream:** no `get_available_name` caller in `proton-drive-linux` or `proton-stream`. | diff --git a/crates/proton-drive-rs/src/client.rs b/crates/proton-drive-rs/src/client.rs index 1617cdb..a399574 100644 --- a/crates/proton-drive-rs/src/client.rs +++ b/crates/proton-drive-rs/src/client.rs @@ -80,8 +80,9 @@ use crate::dtos::{ }; use crate::events::{DriveEvent, DriveEventScopeId}; use crate::node::{ - AlbumProperties, FileThumbnail, Node, NodeKind, NodeMoveItem, PhotoLocation, PhotoProperties, - RecentlyAccessedReportItem, RevisionState, Thumbnail, ThumbnailType, + AlbumProperties, AvailableNameTarget, FileThumbnail, Node, NodeKind, NodeMoveItem, + PhotoLocation, PhotoProperties, RecentlyAccessedReportItem, RevisionState, Thumbnail, + ThumbnailType, }; use crate::photos::{ AlbumItem, PhotoTag, PhotoTagsUpdate, PhotoUploadMetadata, PhotosTimelineItem, @@ -2576,19 +2577,30 @@ impl ProtonDriveClient { /// `name`. /// /// Mirrors C# `NodeOperations.GetAvailableNameAsync`: hash `name` and a - /// stream of alternates (`name`, `name (1)`, `name (2)`, …) under the parent - /// folder's hash key and ask the server which hashes are free - /// (`checkAvailableHashes`), a batch of [`NAME_AVAILABILITY_BATCH`] at a - /// time, returning the first available candidate. Call before create/upload - /// to dodge `NameHashDigest` collisions. Returns `name` unchanged when it is - /// already free. - pub async fn get_available_name(&self, parent_uid: &NodeUid, name: &str) -> Result { + /// stream of alternates under the parent folder's hash key and ask the + /// server which hashes are free (`checkAvailableHashes`), a batch of + /// [`NAME_AVAILABILITY_BATCH`] at a time, returning the first available + /// candidate. `target` picks the alternate spelling: a file keeps its + /// extension last (`name (1).ext`), a folder appends to the whole name + /// (`name (1)`), so `v1.2` does not become `v1 (1).2`. Call before + /// create/upload to dodge `NameHashDigest` collisions. Returns `name` + /// unchanged when it is already free. + pub async fn get_available_name( + &self, + parent_uid: &NodeUid, + name: &str, + target: AvailableNameTarget, + ) -> Result { let parent_key = self.folder_node_key(parent_uid).await?; let parent_hash_key = self.parent_hash_key(parent_uid, &parent_key).await?; let client_uid = self.http.session_id().to_string(); - // Candidate stream: the original name first, then `name (1)`, `name (2)`… - let mut candidates = std::iter::once(name.to_string()).chain(alternate_names(name)); + // Candidate stream: the original name first, then its alternates. + let alternates: Box + Send + '_> = match target { + AvailableNameTarget::File => Box::new(alternate_file_names(name)), + AvailableNameTarget::Folder => Box::new(alternate_folder_names(name)), + }; + let mut candidates = std::iter::once(name.to_string()).chain(alternates); loop { // Build one batch of candidates and their hex name-hash digests. @@ -8127,10 +8139,11 @@ fn to_drive_event(volume_id: &VolumeId, event: &VolumeEventDto) -> Result impl Iterator + '_ { +/// Unbounded stream of alternate names for a taken file name: `name (1).ext`, +/// `name (2).ext`, … (C# `AlternateFileNameGenerator.GetFileNames`). The +/// extension is everything from the last `.`, matching .NET +/// `Path.GetFileNameWithoutExtension`. +fn alternate_file_names(original: &str) -> impl Iterator + Send + '_ { let (stem, ext) = match original.rfind('.') { Some(idx) => original.split_at(idx), None => (original, ""), @@ -8138,9 +8151,13 @@ fn alternate_names(original: &str) -> impl Iterator + '_ { (1..).map(move |i| format!("{stem} ({i}){ext}")) } -/// Fail if any per-link response in a batch aggregate carries a non-success -/// code (the top-level envelope is `MultipleResponses`, so the real status is -/// per link). `op` names the operation for the error message. +/// Unbounded stream of alternate names for a taken folder name: `name (1)`, +/// `name (2)`, … (C# `AlternateFileNameGenerator.GetFolderNames`). A folder has +/// no extension, so a dot in its name is kept where it is. +fn alternate_folder_names(original: &str) -> impl Iterator + Send + '_ { + (1..).map(move |i| format!("{original} ({i})")) +} + /// The re-encrypted material a single node needs to move under a new parent, /// produced by `ProtonDriveClient::build_move_parts`. struct MoveParts { @@ -8683,13 +8700,13 @@ fn drive_items(page: &SharedWithMeResponse) -> Vec { mod tests { use super::{ CONTEXT_SHARE_CACHE_CAP, DriveCache, DriveEvent, FOLDER_KEY_CACHE_CAP, - MAX_NODE_NAME_LENGTH, NodeAction, ParentKeyId, aggregate_outcomes, alternate_names, - assemble_manifest, context_share_path, drive_items, epoch_to_iso8601, - is_expired_upload_token, is_listable_revision_state, is_upload_timeout, join_node_path, - missing_related_links, path_segments, record_timeline_outcome, resolve_parent_keys, - run_context_share_mutation, share_membership_from_dto, small_upload_applicable, - small_upload_should_fall_back, take_block_target, take_thumbnail_target, to_drive_event, - validate_node_name, + MAX_NODE_NAME_LENGTH, NodeAction, ParentKeyId, aggregate_outcomes, alternate_file_names, + alternate_folder_names, assemble_manifest, context_share_path, drive_items, + epoch_to_iso8601, is_expired_upload_token, is_listable_revision_state, is_upload_timeout, + join_node_path, missing_related_links, path_segments, record_timeline_outcome, + resolve_parent_keys, run_context_share_mutation, share_membership_from_dto, + small_upload_applicable, small_upload_should_fall_back, take_block_target, + take_thumbnail_target, to_drive_event, validate_node_name, }; use crate::dtos::{ AggregateLinksResponse, BlockUploadTarget, LinkDetailsDto, LinkDto, LinkIdResponsePair, @@ -9152,24 +9169,30 @@ mod tests { } #[test] - fn alternate_names_keeps_extension() { - let got: Vec = alternate_names("report.txt").take(3).collect(); + fn alternate_file_names_keeps_extension() { + let got: Vec = alternate_file_names("report.txt").take(3).collect(); assert_eq!(got, ["report (1).txt", "report (2).txt", "report (3).txt"]); } #[test] - fn alternate_names_no_extension() { - let got: Vec = alternate_names("folder").take(2).collect(); - assert_eq!(got, ["folder (1)", "folder (2)"]); + fn alternate_file_names_no_extension() { + let got: Vec = alternate_file_names("notes").take(2).collect(); + assert_eq!(got, ["notes (1)", "notes (2)"]); } #[test] - fn alternate_names_uses_last_dot_only() { + fn alternate_file_names_uses_last_dot_only() { // .NET `Path.GetFileNameWithoutExtension` strips only the final segment. - let first = alternate_names("archive.tar.gz").next().unwrap(); + let first = alternate_file_names("archive.tar.gz").next().unwrap(); assert_eq!(first, "archive.tar (1).gz"); } + #[test] + fn alternate_folder_names_keep_dots_in_place() { + let got: Vec = alternate_folder_names("v1.2").take(2).collect(); + assert_eq!(got, ["v1.2 (1)", "v1.2 (2)"]); + } + #[test] fn photos_attributes_content_hash_and_tags() { use super::{BlockWriteResult, build_photos_attributes, hmac_sha256}; diff --git a/crates/proton-drive-rs/src/lib.rs b/crates/proton-drive-rs/src/lib.rs index 55c782f..aa43290 100644 --- a/crates/proton-drive-rs/src/lib.rs +++ b/crates/proton-drive-rs/src/lib.rs @@ -56,8 +56,9 @@ pub use event_manager::{ }; pub use events::{DriveEvent, DriveEventScopeId}; pub use node::{ - AlbumProperties, FileThumbnail, Node, NodeKind, NodeMoveItem, PhotoLocation, PhotoProperties, - RecentlyAccessedReportItem, RevisionState, Thumbnail, ThumbnailType, + AlbumProperties, AvailableNameTarget, FileThumbnail, Node, NodeKind, NodeMoveItem, + PhotoLocation, PhotoProperties, RecentlyAccessedReportItem, RevisionState, Thumbnail, + ThumbnailType, }; pub use photos::{ AlbumItem, PhotoTag, PhotoTagsUpdate, PhotoUploadMetadata, PhotosTimelineItem, diff --git a/crates/proton-drive-rs/src/node.rs b/crates/proton-drive-rs/src/node.rs index 0c3662e..de362fe 100644 --- a/crates/proton-drive-rs/src/node.rs +++ b/crates/proton-drive-rs/src/node.rs @@ -20,6 +20,18 @@ pub struct NodeMoveItem { pub target_name: Option, } +/// What [`get_available_name`](crate::ProtonDriveClient::get_available_name) +/// resolves a name for, which decides how alternates are spelled. +/// +/// Mirrors C# `Proton.Drive.Sdk.Nodes.AvailableNameTarget`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AvailableNameTarget { + /// Alternates keep the extension last: `report (1).txt`. + File, + /// Alternates append to the whole name: `v1.2 (1)`. + Folder, +} + /// One node for [`report_recently_accessed`](crate::ProtonDriveClient::report_recently_accessed). /// /// Mirrors upstream `RecentlyAccessedReportItem`. diff --git a/crates/proton-drive-rs/tests/live_transfer_ops.rs b/crates/proton-drive-rs/tests/live_transfer_ops.rs index ffe278d..ff3bcbb 100644 --- a/crates/proton-drive-rs/tests/live_transfer_ops.rs +++ b/crates/proton-drive-rs/tests/live_transfer_ops.rs @@ -12,7 +12,7 @@ mod common; use std::io::Cursor; -use proton_drive_rs::{NodeKind, Thumbnail, ThumbnailType}; +use proton_drive_rs::{AvailableNameTarget, NodeKind, Thumbnail, ThumbnailType}; use proton_sdk::ids::NodeUid; /// Trash then permanently delete the given nodes; best-effort, logs on failure. @@ -342,7 +342,7 @@ async fn get_available_name_resolves_collision() { // Free name resolves to itself. let free = client - .get_available_name(&root.uid, &base) + .get_available_name(&root.uid, &base, AvailableNameTarget::File) .await .expect("get_available_name (free)"); assert_eq!(free, base, "an unused name must resolve to itself"); @@ -354,7 +354,7 @@ async fn get_available_name_resolves_collision() { .expect("create_folder to occupy the name"); let alt = client - .get_available_name(&root.uid, &base) + .get_available_name(&root.uid, &base, AvailableNameTarget::File) .await .expect("get_available_name (taken)"); assert_ne!(alt, base, "a taken name must resolve to an alternate");