mirror of
https://github.com/narrrl/proton-sdk-rs.git
synced 2026-11-03 16:41:26 +01:00
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.
This commit is contained in:
1 parent
9e4d2fb036
commit
1b8abf70f8
5 files changed
+76
-39
No files matched your search
+3
-2
@@ -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<i64>` 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<u8>` 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`. |
|
||||
@@ -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<String> {
|
||||
/// 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<String> {
|
||||
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<dyn Iterator<Item = String> + 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<DriveE
|
||||
}
|
||||
}
|
||||
|
||||
/// Unbounded stream of alternate names for a taken name: `name (1).ext`,
|
||||
/// `name (2).ext`, … (C# `AlternateFileNameGenerator.GetNames`). The extension
|
||||
/// is everything from the last `.`, matching .NET `Path.GetFileNameWithoutExtension`.
|
||||
fn alternate_names(original: &str) -> impl Iterator<Item = String> + '_ {
|
||||
/// 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<Item = String> + 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<Item = String> + '_ {
|
||||
(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<Item = String> + 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<SharedWithMeItem> {
|
||||
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<String> = alternate_names("report.txt").take(3).collect();
|
||||
fn alternate_file_names_keeps_extension() {
|
||||
let got: Vec<String> = 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<String> = alternate_names("folder").take(2).collect();
|
||||
assert_eq!(got, ["folder (1)", "folder (2)"]);
|
||||
fn alternate_file_names_no_extension() {
|
||||
let got: Vec<String> = 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<String> = 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};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -20,6 +20,18 @@ pub struct NodeMoveItem {
|
||||
pub target_name: Option<String>,
|
||||
}
|
||||
|
||||
/// 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`.
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in new issue
Block a user