Files
proton-sdk-rs/UPSTREAM_SYNC.md
nvrl 781301a77b feat(sharing,public-link): update public links, name bookmarks, walk shared trees
- update_public_link sets the role, custom password and expiry of a node's
  public link and keeps its URL (TS updateSharedLink). Create and update
  share the sealing of the share key and SRP verifier.
- list_bookmarks decrypts the bookmarked item's name with the share key the
  stored password unlocks, and returns the custom password in the new
  Bookmark::custom_password. The URL fragment is now only the generated
  password.
- ProtonDrivePublicLinkClient::enumerate_subtree walks a shared folder
  breadth first, several folders of a level at a time.
- Add CHANGELOG.md, update the README feature table for this and the
  previous unreleased commits, and record the TS ports in UPSTREAM_SYNC.
2026-10-25 21:27:05 +01:00

48 lines
29 KiB
Markdown

# Upstream sync
Tracks reconciliation of this pure-Rust port against the canonical **C#** SDK
(`ProtonDriveApps/sdk`, subtree `client/cs/sdk/src`). The `sdk/` checkout is
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**: `b68d64bd`
- **Date**: 2026-10-25
> **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.
## Workflow
1. `./scripts/upstream-sync.sh` — fetches upstream, lists cs-relevant commits
since the pinned SHA, drops noise (chore/docs/test/ci/build), prints diffs.
2. Triage each surviving commit: behavioral change to port, or structural/cosmetic.
3. Port the behavioral diffs into `crates/`.
4. Bump **Pinned** (and **Date**) above to the new upstream HEAD; commit.
## Log
| date | from → to | ported | notes |
|------|-----------|--------|-------|
| 2026-06-25 | initial pin `fb4173ab` | — | baseline; delta reviewed, 0 behavioral changes outstanding |
| 2026-06-26 | `fb4173ab` → `36430318` | `36430318` | ported: enumeration returns `NodeUid`s (`enumerate_folder_children_node_uids` / `enumerate_trash_node_uids`), caller materializes via `enumerate_nodes`. noise dropped: BOM/deps/kt-enum-order/cs-account-refactor |
| 2026-07-10 | `36430318` → `f2496161` | `9a1c39b3` | ported: validation check in `Thumbnail::new` returning `Result` instead of panicking. noise dropped: projects reorg / C# Account client move to incubating. deferred: device support (`32a8eed0`). |
| 2026-07-14 | `f2496161` → `2219a42f` | `d15fa9b9`, `bead8f65`, `f6b12911`, `c0940f52`, `b88b8041`, `2eb69c46` | upstream force-pushed + moved the cs subtree, so the old pin was dangling and the script silently reported "up to date" (both fixed). ported: **devices** (`devices.rs` + `enumerate_devices` / `create_device` / `rename_device` / `delete_device`; volume-free `DeviceUid`), **node sharing flags** (`Node::is_shared` / `is_shared_publicly` from the link `Sharing` block), **revision state** (`NodeKind::File::active_revision_state`), **shared-with-me** (`enumerate_shared_with_me_node_uids`, `GET v2/sharedwithme`), **leave shared node** (`leave_shared_node`, `DELETE v2/shares/{sid}/members/{mid}`). no-op: `15fa845e` (non-JSON error bodies — `http.rs` already falls back to a status-only error when the envelope won't parse). diverged deliberately: `9a7f1d02` + `b4dadf02` removed the C# entity cache and its tags; ours stays. cosmetic, not ported: `30ab32c1` / `388d3900` (C# field renames), `1a781ade` (buffer lengths). |
| 2026-07-24 | `1b6ff835` → `e0ad37e3` | `d2ffde20`, `724b6771` | both features were already ported from the TypeScript SDK, so this closed the remaining parity deltas. ported: **shared-by-me on the photos volume** (`SharingOperations.EnumerateSharedNodeUidsAsync` now takes a volume resolver — `page_shared_by_me(volume_id)` + `ProtonPhotosClient::enumerate_shared_node_uids`, empty when the account has no photos volume) and `SharedByMeLinkDto.ContextShareID` (kept `Option` — recent upstream addition, older backends omit it); **per-node move outcomes** (`move_nodes` now returns `Vec<(NodeUid, Result<()>)>` in input order, mirroring the breaking `MoveNodesAsync` result map; per-link envelope codes via the new `aggregate_outcomes`, which `check_aggregate` now sits on). cosmetic, not ported: `AnchorID` retyped `string` → `LinkId` (ours is `String`). diverged deliberately: we keep the batch endpoint `PUT volumes/{vid}/links/move-multiple` — upstream still loops per node behind a `// FIXME: … use the batch move endpoint`; and the drive-side listing keeps the `_by_me` suffix (upstream renamed both to `EnumerateSharedNodeUids`) because `proton-drive-linux` already consumes that name. |
| 2026-07-22 | `2219a42f` → `1b6ff835` | `ded1b716`, `071f976f` | ported: **atomic small uploads** for buffered new files and revisions (`DriveSmallFileUpload` is an explicit opt-in because this crate has no remote feature-flag provider), and **photo duplicate detection** using name/content HMAC matching. already covered/no-op: `ee0b3fba` (Rust rename accepts `Option<&str>`), `193c26f1` (Rust in-memory cache already has bounded LRU eviction), `29d359e0` (digest representation refactor retains hex JSON), `5c370289` (C# disposal lifetime only), `95752055` (Photos cache construction only). deliberately not ported: `2bf49aae` / `2590d227` cache storage-format changes; Rust exposes a distinct string repository contract and ships no persistent backend to migrate/reset. |
| 2026-07-31 | `e0ad37e3` → `941aafa5` | `42526703`, `f2fa871b`, `814ae781`, `c528a376` | ported: **node-name validation** (`validate_node_name`: empty or >255 chars rejected before create/rename; counted in `char`s — C# counts UTF-16 units, and neither is the server's own accounting); **album read ops** (`AlbumDto` + `enumerate_album_node_uids` / `enumerate_album`, `GET photos/volumes/{vid}/albums` and `…/albums/{lid}/children?Sort=Captured&Desc=1`, both paged on `AnchorID`; `LinkDetailsDto::folder_properties` reads an album's hash key out of the `Album` block, which also makes `parent_hash_key` work for album children); **photo + album node metadata** (`Node::photo` / `Node::album` — capture time, content hash, main/related photos, tags, album inclusions; photo count, cover photo, last activity — added as optional `Node` fields rather than `NodeKind` variants so persisted node blobs keep deserializing, mirroring how `membership` landed); **photo tag updates** (`update_photos`, `POST`/`DELETE photos/volumes/{vid}/links/{lid}/tags` via the new `ApiHttpClient::delete_with_body`, `POST …/favorite` for photos on our own photos volume, one outcome per input update like `move_nodes`); **shared-with-me photos/albums** (`ShareTargetType::is_photos_item` over `v2/sharedwithme` + the dedicated `GET photos/albums/shared-with-me`, whose rows carry their own `VolumeID`). deferred: `03b1cb7f` **favoriting shared photos** — needs `PhotosCrypto` + `PhotoTransferPayloadBuilder` (name/passphrase session keys rewrapped to the timeline root, related-photo fan-out, batches of 20); our `rewrap_message_to` / `recover_message_session_key` make it feasible, but until it lands `update_photos` fails that one update explicitly instead of silently no-oping. not ported: `4c1dd783` (`IsSharedPublicly` → `IsSharedByUrl` rename — `proton-drive-linux` consumes `is_shared_publicly`), `1477ea45` (C# user-facing error strings; our error text is our own), the second half of `42526703` (server-supplied names validated into a per-node `InvalidNameError` — we have no node error list to put it in). no-op: `82d605b7` (C# `FifoFlexibleSemaphore` cancel race — tokio's `Semaphore` is cancel-safe and drops queued waiters), `8bdd9420` (throw on resume-after-dispose; Rust ownership makes it unreachable), `ca11470f` + `c947fce7` (CExports/interop only). Album *writes* (create, add/remove photos) remain unported — upstream has no C# public API for them either. Live validation pending: every photos endpoint here needs a photos-volume account. |
| 2026-08-01 | — (no upstream delta) | — | **not an upstream port.** Extended the TypeScript-sourced public-link consume side with a streaming surface that has **no TS or C# counterpart** and is ours: `open_revision` / `open_revision_at` / `download_range` / `download_thumbnail` / `enumerate_thumbnails` / `refresh_session` / `with_max_inflight_blocks` on `ProtonDrivePublicLinkClient`. Enabling change: `RevisionReader` was welded to `ProtonDriveClient`, so it now takes a `RevisionTransport` (new `transport.rs` — block permits + a route-prefixed `ApiHttpClient` + a `BlockSession` for renewal); `fetch_revision_blocks` and the block-size ranking moved there out of `client.rs`. Also `ApiHttpClient::without_token_refresh` (proton-sdk), because the anonymous session's refresh token is empty and the built-in 401 refresh masked the real 401. Perf on the visitor path: LRU node-key cache + iterative ancestor walk behind `SingleFlight` (was re-deriving the whole chain, at an S2K each, per child), `enumerate_nodes` chunked at 150 and fanned out (it did not chunk at all), `download_file_to` paged (a single un-paged GET truncated anything past 50 blocks) and concurrent. Live-verified against the test account, including the one uncertain fact the design rested on: the `drive/unauth/` revision response **does** carry `XAttr`, so a visitor resolves true per-block sizes and can seek. |
| 2026-08-10 | `941aafa5` → `df6b6bfd` | `f493c5ee`, `62895702` | ported: **per-node trash/restore/delete outcomes, streamed**. Upstream turned `TrashNodesAsync` / `RestoreNodesAsync` / `DeleteNodesAsync` from `ValueTask<IReadOnlyDictionary<NodeUid, Result<Exception>>>` into `IAsyncEnumerable<NodeActionResult>`, and `UpdatePhotosAsync` into `IAsyncEnumerable<PhotoUpdateResult>`. Rust equivalent: `trash_nodes` / `restore_nodes` / `delete_nodes` now return `Vec<(NodeUid, Result<()>)>` (**breaking** — they returned `Result<()>` and failed the whole call through `check_aggregate`, which is now gone), plus `*_streaming` siblings returning `impl Stream<Item = Result<(NodeUid, Result<()>)>>` that yield each batch's outcomes as the batch lands; `update_photos_streaming` does the same for photo tag updates. Kept the `(NodeUid, Result<()>)` tuple used by `move_nodes` rather than adding `NodeActionResult` / `PhotoUpdateResult` record types — C# needs a named type for its iterators, Rust does not. Outcome mapping mirrors upstream exactly: only the links the aggregate envelope reports on produce an outcome, and the stream's outer `Err` (a request that never produced an envelope) ends it. diverged deliberately: upstream's rewrite also dropped its per-volume `Task.WhenAll`, so both sides are now sequential over volume groups — ours already was. not ported: `9dd63423` (explicit stream disposal through interop — CExports/proto only; Rust ownership drops readers/writers at scope end). |
| 2026-08-10 | — (no upstream delta) | — | **not an upstream port.** `ProtonDriveClient::with_entity_repository` — the chainable form of `with_entity_cache`, added because that one is a *constructor* and so could not be combined with `with_key_salts`; a client that resumes a persisted session needs the salts and wants a persistent entity cache, and upstream C# has no equivalent tension (its cache is set on the client options object). Additive only: `with_entity_cache` and the in-memory default are unchanged. Consumed by `proton-drive-linux`, which now backs the entity cache with an encrypted SQLite store. |
| 2026-08-18 | — (no upstream delta) | — | **not an upstream port of C#** — ported from the **TypeScript** SDK, which is the only upstream with an album write API. Ported: **album creation** (`ProtonPhotosClient::create_album` — `POST photos/volumes/{vid}/albums`, from `AlbumsManager.createAlbum` + `AlbumsCryptoService.createAlbum`; an album is a folder node parented to the photos root, so the crypto is the folder create: generated node key, passphrase encrypted to the photos root key and signed by the photos share's membership address, plus the album's own child-name hash key, and TS's `Locked: false`) and **adding photos to an album** (`add_photos_to_album` — `POST photos/volumes/{vid}/albums/{lid}/add-multiple`, from `AddToAlbumProcess.processSameVolumeQueue` + `PhotoTransferPayloadBuilder`; per photo the name is re-encrypted to the album key, name/content hashes recomputed under the album hash key, and the node passphrase **rewrapped** via `rewrap_message_to` rather than re-encrypted from plaintext — the ciphertext and therefore the original `NodePassphraseSignature` survive, which is why the entry carries no signature of its own; related photos are flattened in beside their main photo, batches of 10 like TS's `ADD_PHOTOS_BATCH_SIZE`, one outcome per input photo in input order like `move_nodes`). Not ported: TS `copyPhoto` (adding a photo that lives on **another** volume, i.e. from an album shared with us — needs the cross-volume copy path; those inputs fail their own outcome), `updateAlbum` (rename / cover photo) and `deleteAlbum`. Also **ours, with no upstream counterpart**: `find_duplicates_many` and `name_collisions` — the duplicates endpoint already takes a `NameHashes` array but both SDKs only ever send one, and a Google Takeout import asks the question tens of thousands of times; `name_collisions` answers on the name hash alone so the caller only has to read and SHA-1 the files whose names actually collide. Live validation pending: everything here needs a photos-volume account. |
| 2026-08-27 | `df6b6bfd` → `c8d03244` | `e0e198e2`, `c8d03244` | ported: **save photos to the timeline** (`ProtonPhotosClient::save_photos_to_timeline` — C# `PhotoOperations.SavePhotosToTimelineAsync`, itself the JS `saveToTimeline`). Photos already on our photos volume are *moved* into the timeline root through `PUT photos/volumes/{vid}/links/transfer-multiple`, batched so no request carries more than ten links (main photo + related photos counted together, never split across requests); photos on another volume — from a shared-with-me album — are *copied* through `POST volumes/{vid}/links/{lid}/copy`, one request each, with their related photos nested in `Photos`. This closes the **cross-volume copy** hole logged in the 2026-08-18 row (TS `copyPhoto`), so photos from albums shared with us can now be kept. The re-encryption is what `add_photos_to_album` already did — name re-encrypted to the target key and signed by the photos membership address, name/content hashes recomputed under the target hash key, node passphrase **rewrapped** via `rewrap_message_to` so the original `NodePassphraseSignature` survives — so it was lifted into a shared `photo_target_payload` (C# `PhotosCrypto.EncryptPhotoForTarget` + `PhotoTransferPayloadBuilder`) that both paths build on. Retry semantics mirror upstream exactly: a photo the server reports an incomplete group for is re-queued **once** with the related photos it named, and only its second answer becomes an outcome; a photo already parented to the timeline root fails its own outcome. Also ported: `ProtonPhotosClient::enumerate_events` (`c8d03244`'s only non-interop hunk — a passthrough to the Drive client's, which we already had). diverged deliberately: upstream returns `IAsyncEnumerable<NodeActionResult>`; ours collects `Vec<(NodeUid, Result<()>)>` like `move_nodes` and the other batch ops — and, because of the retry queue, outcome order follows completion, not input, which the API docs state. not ported: `29e58378` **per-file upload performance metrics** (`UploadPerformanceEvent`, size/route/block-count classification, active-vs-paused attempt timing on `UploadController`) — it is telemetry plumbing for C#'s interop metric callback; our `proton-sdk::telemetry` has its own `TelemetryEvent`/`OpTimer` shape and we have no pause/resume upload controller, and nothing about the upload itself changes. structural, not ported: `0537d906` (C# merges its batch loaders and `DtoToMetadataConverter` into one `NodeProvider`; the only behavior-adjacent bit is dropping the cache's claim/acquisition dance, which our single-flight already covers). Live validation pending: the whole path needs a photos-volume account, and the cross-volume half needs a second account sharing an album with it. |
| 2026-09-15 | `c8d03244` → `ce000edd` | `cdc0f260`, `4b1129aa`, `63a7fc1e`, `5cbbb0ad`, `e33712b9` | ported: **trash time field name** (`cdc0f260` — C# dropped a `[JsonPropertyName("Trashed")]` that never matched the API's `TrashTime`; ours had copied the same wrong name, so `LinkDto` now reads `TrashTime` and keeps `Trashed` as a separate legacy field rather than a serde alias, which would fail a link carrying both). **direct role + membership detail** (`4b1129aa`): `Node::direct_role` — `Admin` on our own main or photos volume (`is_own_volume`, C# `VolumeOperations.IsOwnVolumeAsync`), otherwise the membership's permissions or `Inherited`; `ShareMembership` gains `invite_time`, `inviter_email` and `inviter_verification`, the last from checking the inviter's `drive.share-member.inviter` signature over our share-passphrase key packet against the claimed inviter's keys (C# `NodeCrypto.VerifyMembershipInviter`; new `proton_sdk::crypto::verify_detached_with_context`, which requires the `context@proton.ch` notation to match and accepts armored or base64-binary signatures). Non-fatal like every other verification. Kept optional (`Option<MemberRole>` / `Option<VerificationStatus>`, `skip_serializing_if` on the membership additions) so persisted `Node` blobs keep deserializing and read back as "unknown" rather than as a wrong role; C# models `SharedBy` as `Result<Author, SignatureVerificationError>` — ours is the claimed email plus the status. **share id** (`63a7fc1e`): `Node::share_id`, the `Sharing` block's `ShareID` (C# `DeprecatedShareId`, internal there; public here as the JS SDK has it). **batched move with rename, streamed** (`5cbbb0ad` + `e33712b9` — upstream finally replaced its per-node loop with the `move-multiple` endpoint we already used): new `move_nodes_streaming(Vec<NodeMoveItem>, NodeUid)` yielding `(NodeUid, Result<()>)` per node as each batch lands, with `NodeMoveItem::target_name` for rename-on-move; batches of 150 that never mix anonymously signed nodes with signed ones; an anonymously signed node now gets its passphrase signed by the mover plus `SignatureEmail` — **also fixed in `move_node`**, which never did this (C# `MoveSingleAsync` always had); a link rejected `InvalidRequirements` (2000) is retried once with the server's `NameHash` provided parent and decrypted name are unchanged, else fails out of sync (C# `PrepareItemWithRemoteNameDigestAsync`). `move_nodes` keeps its signature and input-order contract and now collects the stream. diverged deliberately: `NodeMoveItem` carries no `CurrentParentUid`/`CurrentName` — we read both off the link rather than trust the caller, so upstream's retry mostly exists for a case we only hit through a stale cached `OriginalHash`; per-code exception types (`NodeWithSameNameExistsException`, `FolderCapacityExceededException`, `FolderNestingTooDeepException`, `CannotMoveFavoritePhotoFromShareException`, `NodeOutOfSyncException`, `ParentFolderNotFoundException`) are not ported — the per-link `ProtonError::Api` keeps the `ResponseCode` to match on; `RenameNodeAsync` removal not mirrored (`rename_node` stays, `proton-drive-linux` uses it). no-op: `e3d602b9` (C# `HashingReadStream`/`HashingWriteStream` hashed the whole buffer instead of `offset..count`; our upload hashes `buf.truncate(n)`-ed blocks and has no stream wrapper). noise: `df87c3aa` (AEAD feature-flag removal, chore). **Downstream:** adding `Node::{direct_role, share_id}` and `ShareMembership::{invite_time, inviter_email, inviter_verification}` breaks struct literals in `proton-drive-linux` tests (`pdfs-fuse/src/state.rs`) at the next SDK bump. Live validation pending for all of it — membership verification and anonymous-node moves need a second account.|
| 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`. |
| 2026-10-25 | — (no C# delta) | — | **not an upstream port of C#** — two ports from the **TypeScript** SDK, plus one change of ours. **Folder size** (TS `getFolderSize`, `243f11f4`): `ProtonDriveClient::get_folder_size` → `GET volumes/{vid}/folders/{lid}/calculate-descendents-size`, returning `FolderSize { size, descendants }`. Live-verified, which settled what the TS docs leave open: folders count as descendants, and the size is encrypted bytes on storage (two single-block files of 301 000 plaintext bytes report 301 112). **Copy** (TS `NodesManagement.copyNode`): `ProtonDriveClient::copy_node(uid, new_parent, name)` → `POST volumes/{vid}/links/{lid}/copy`, a server-side copy of a file with its content or a folder with its subtree. The crypto is the move's (`build_move_parts`: passphrase rewrapped to the new parent, name encrypted and signed by the My Files address, signature fields only for an anonymously signed node); `CopyPhotoResponse` became the shared `CopyLinkResponse`. Diverged deliberately: the returned uid takes the **target** parent's volume, where TS uses the source's, which only holds within one volume; no `copyNodes` batch, which in TS is a sequential loop over `copyNode`. Live-verified: file content, rename-on-copy, folder subtree, and `AlreadyExists` for a taken name. **Ours**: reads are retried after a dropped connection — `send_retrying` reads the body inside its loop and takes a `Replay` mode; a `GET`, a block download and the new `ApiHttpClient::post_query` (link details, thumbnail URLs, photo duplicates, `checkAvailableHashes`) are sent again when the connection drops mid-request or mid-body, while writes keep the old rule. |
| 2026-10-25 | `8ffae007` → `b68d64bd` | `a36cc423` | ported: **album capabilities** (`a36cc423`), the C# album write API, against the TS-sourced album writes we already had. `add_photos_to_album` now goes through the timeline transfer driver, renamed `transfer_photos` over a `PhotoTransferTarget` with a `PhotoTransferKind` (C# `TransferPhotosToTargetAsync` / `PhotoTransferTarget`): same-volume photos through `add-multiple` (the per-link answers share `TransferPhotosResponse`, so `AddPhotosToAlbumResponse` is gone), photos on **another volume** copied into the album through `POST volumes/{vid}/links/{lid}/copy` instead of failing, and a photo whose group the server reports incomplete re-sent once with the related photos it named; a related photo that cannot be re-encrypted is skipped rather than failing its main photo. The call still answers in input order. `add-multiple` entries never carry a passphrase signature (C# `ToAlbumPhotoLinkItem`). New `ProtonPhotosClient::set_album_cover` (the cover half of `UpdateAlbumAsync`; same volume only, refused before sending), and `AlbumUpdateRequest` now omits a `None` cover or link like C#. New `delete_album_saving_photos` (`DeleteAlbumAsync` with `saveToTimeline`): on `ResponseCode::AlbumContainsPhotosNotInTimeline` (200302, new) it saves the photos `Details.ChildLinkIDs` names, then deletes again; the first photo that cannot be saved fails the call. `remove_photos_from_album` fails a photo on another volume without sending it. Kept additive: `delete_album(uid, delete_photos)` and `rename_album` keep their signatures (`proton-drive-linux` calls both) rather than taking C#'s `(force, saveToTimeline)` and `(name?, cover?)`. Live-verified on the test account: add, adding twice (`Photo already exists in the album.`), cover, remove, delete keeping photos. Not live-verified: the cross-volume copy and the 200302 save, which need a second account sharing an album. **not ported: experimental search** (`e861ef99`): the engine is a Rust crate under `incubating/search` whose source is not in the public tree — only its C# bindings' wiring (`Search.targets`, `ProtonDriveSearchClient`) and a `BinaryKeyValueStore` for its index are. The rest of that commit is cosmetic (`await foreach` over a local, `SqliteConnectionStringBuilder`). |
| 2026-10-25 | — (no C# delta) | — | **Not an upstream port from C#**: three sharing gaps closed from the **TypeScript** SDK. **Public link update** (TS `SharingManagement.updateSharedLink` / `apiService.updatePublicLink`): `ProtonDriveClient::update_public_link(uid, role, custom_password, expiration_time)` → `PUT shares/{sid}/urls/{urlID}`. The generated password is taken from the recovered URL fragment and must be 12 characters (TS refuses legacy links the same way); the share key and SRP verifier are sealed again under it plus the new custom password. Create and update now share `seal_public_link_settings` and a flattened `PublicLinkSettingsDto`; update sends `ExpirationTime: null` to clear an expiry, as TS does. **Bookmark names** (TS `SharingCryptoService.decryptBookmark`): the whole stored password, bcrypt-derived with `SharePasswordSalt`, unlocks the share key, which decrypts `Token.Name`; signatures are not checked, as in TS. `Bookmark` gains `custom_password` (TS `splitGeneratedAndCustomPassword`), and the URL fragment is now only the generated 12 characters. **Ours**: `ProtonDrivePublicLinkClient::enumerate_subtree` walks a shared folder breadth first, 6 folders per level at once (lifted from proton-stream's crawl; neither SDK has it). Live-verified on the test account: update to Editor + custom password + expiry, then clear both; bookmark on our own link lists its name and custom password; a nested subtree walks in level order. |