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.
This commit is contained in:
nvrl committed 2026-10-25 21:27:05 +01:00
1 parent 3da18b9309
commit 781301a77b
8 files changed
+632 -99

No files matched your search

+65
View File
@@ -0,0 +1,65 @@
# Changelog
Notable changes to `proton-sdk` and `proton-drive-rs`. The format follows
[Keep a Changelog](https://keepachangelog.com/1.1.0/); versions follow semver,
and both crates are released together under the workspace version. Upstream
commits a change ports are recorded in [UPSTREAM_SYNC.md](UPSTREAM_SYNC.md).
This file starts after 0.9.2; earlier changes are in the git history.
## [Unreleased]
### Added
- **Server-side copy.** `ProtonDriveClient::copy_node(uid, new_parent, name)`
copies a file, or a folder with everything in it, without downloading and
uploading it. The copy may get a new name and may land on another volume; the
returned uid is on the target volume. Ported from the TypeScript SDK
(`copyNode`).
- **Folder size.** `ProtonDriveClient::get_folder_size(uid)` returns a
`FolderSize` with the total size and the number of descendants, trashed nodes
included. Folders count as descendants, and the size is in encrypted bytes.
Ported from the TypeScript SDK (`getFolderSize`).
- **Changing a public link.** `ProtonDriveClient::update_public_link(uid, role,
custom_password, expiration_time)` sets the role, the custom password and the
expiry of the link on a node. The URL does not change. `None` removes a custom
password or an expiry. A legacy link cannot be updated. Ported from the
TypeScript SDK (`updateSharedLink`).
- **Walking a public link.** `ProtonDrivePublicLinkClient::enumerate_subtree`
and `enumerate_subtree_reporting` return every node below a shared folder,
breadth first, listing several folders of a level at once.
- **Bookmark names.** `list_bookmarks` now decrypts the bookmarked item's name
into `Bookmark::node_name`, and returns the link's custom password in the new
`Bookmark::custom_password` field.
- **Album capabilities from the C# SDK** (`a36cc423`):
- `ProtonPhotosClient::set_album_cover`;
- `ProtonPhotosClient::delete_album_saving_photos`, which on
`AlbumContainsPhotosNotInTimeline` (code 200302) saves the photos that are
only in the album to the timeline and deletes the album again;
- `add_photos_to_album` adds photos from another volume by copying them in,
instead of refusing them;
- a photo group the server reports as incomplete is retried once with the
missing related photos.
- `ResponseCode::AlbumContainsPhotosNotInTimeline`.
- `ApiHttpClient::post_query`, a POST that only reads and may be sent again.
### Changed
- **A read is sent again when its connection drops.** A GET, a block download or
a `post_query` that loses its connection mid-request or while reading the body
is retried under the client's retry policy. A write still is not: it may have
been applied. Read-only POSTs in `proton-drive-rs` (link details, thumbnails,
photo duplicates, `checkAvailableHashes`) now use `post_query`.
- `remove_photos_from_album` refuses a photo on another volume per photo, in
the input order, without sending it.
### Fixed
- A bookmark's URL fragment is now only the generated password, as in the URL
the web client shows. It used to include the custom password as well.
### Breaking
- `Bookmark` has a new public field, `custom_password`. Code that builds a
`Bookmark` with a struct literal must set it.
- `ResponseCode` has a new variant. An exhaustive `match` on it needs an arm.
+7 -4
View File
@@ -265,6 +265,7 @@ neither upstream SDK, original to `proton-sdk-rs` · ❌ not implemented.
| | Key-salt Injection | ⭐ | `with_key_salts` lets a client hand in cached salts and chain an entity repository onto it, so a warm start skips the salt round-trip. | | | Key-salt Injection | ⭐ | `with_key_salts` lets a client hand in cached salts and chain an entity repository onto it, so a warm start skips the salt round-trip. |
| | Quota | ✅ | `quota()` — used/total space with `available()` / `used_fraction()` helpers. | | | Quota | ✅ | `quota()` — used/total space with `available()` / `used_fraction()` helpers. |
| **HTTP Client** | Retries & Jitter | ✅ | Exponential backoff for 408, 429, and 5xx, respecting `Retry-After`. | | **HTTP Client** | Retries & Jitter | ✅ | Exponential backoff for 408, 429, and 5xx, respecting `Retry-After`. |
| | Read Replay | ⭐ | A read (GET, block download, `post_query`) whose connection drops mid-request or mid-body is sent again; a write is not, because it may have been applied. |
| | Telemetry | ✅ | Pluggable request tracking (`ITelemetry` analogue). | | | Telemetry | ✅ | Pluggable request tracking (`ITelemetry` analogue). |
| **Drive / Volume** | Volume Resolution | ✅ | Auto-resolves volume lists; auto-creates the `my-files` root volume on first login. | | **Drive / Volume** | Volume Resolution | ✅ | Auto-resolves volume lists; auto-creates the `my-files` root volume on first login. |
| | Node Listing | ✅ | `get_node`, `enumerate_folder_children`, `enumerate_trash` with key resolution. | | | Node Listing | ✅ | `get_node`, `enumerate_folder_children`, `enumerate_trash` with key resolution. |
@@ -272,6 +273,8 @@ neither upstream SDK, original to `proton-sdk-rs` · ❌ not implemented.
| | Path Helpers | ⭐ | `get_node_by_path`, `get_node_path`, `get_node_hierarchy`, `create_folder_path`, `get_available_name` — a filesystem front-end wants paths, not UID walks. | | | Path Helpers | ⭐ | `get_node_by_path`, `get_node_path`, `get_node_hierarchy`, `create_folder_path`, `get_available_name` — a filesystem front-end wants paths, not UID walks. |
| | File Operations | ✅ | Rename, trash, restore, delete, empty trash — batch calls report one outcome per node (`trash_nodes`), or stream them as each batch lands (`trash_nodes_streaming`). | | | File Operations | ✅ | Rename, trash, restore, delete, empty trash — batch calls report one outcome per node (`trash_nodes`), or stream them as each batch lands (`trash_nodes_streaming`). |
| | Move Operations | ✅ | Same-volume move. Batch moves are chunked automatically, one outcome per node. | | | Move Operations | ✅ | Same-volume move. Batch moves are chunked automatically, one outcome per node. |
| | Server-side Copy | 🟦 | `copy_node` copies a file or a whole folder on the server, optionally under a new name and onto another volume; no bytes pass through the client. |
| | Folder Size | 🟦 | `get_folder_size` asks the server for a folder's total size (encrypted bytes) and descendant count, trashed nodes included. |
| | Cross-volume Move | ❌ | Not supported here, and not by `proton-drive-linux` either — the C# public API throws `NotImplementedException`, and a cross-volume move means re-uploading content under the destination volume's keys. | | | Cross-volume Move | ❌ | Not supported here, and not by `proton-drive-linux` either — the C# public API throws `NotImplementedException`, and a cross-volume move means re-uploading content under the destination volume's keys. |
| **Events / Sync** | Event Enumeration | ✅ | `enumerate_events` + `latest_event_id` per volume scope; `invalidate_caches_for_event` keeps the entity cache coherent. | | **Events / Sync** | Event Enumeration | ✅ | `enumerate_events` + `latest_event_id` per volume scope; `invalidate_caches_for_event` keeps the entity cache coherent. |
| | Event Manager | 🟦 | Background poll loop (`EventManager`) with a broadcast channel and a foreground/background interval switch, modelled on the TS `eventScheduler`. | | | Event Manager | 🟦 | Background poll loop (`EventManager`) with a broadcast channel and a foreground/background interval switch, modelled on the TS `eventScheduler`. |
@@ -294,12 +297,12 @@ neither upstream SDK, original to `proton-sdk-rs` · ❌ not implemented.
| | Device Operations | ✅ | Rename and delete registered devices. | | | Device Operations | ✅ | Rename and delete registered devices. |
| **Sharing & Links** | Share Creation | 🟦 | `share_node` bootstraps a share on a node (no C# public API for it). | | **Sharing & Links** | Share Creation | 🟦 | `share_node` bootstraps a share on a node (no C# public API for it). |
| | Member Management | ✅ | Invite users (`invite_users`), list members, update roles, and remove members. | | | Member Management | ✅ | Invite users (`invite_users`), list members, update roles, and remove members. |
| | Public Share Links | 🟦 | Create public shareable links (`create_public_link`) with password protection and expiry; `get_public_link`, `remove_public_link`. | | | Public Share Links | 🟦 | Create public shareable links (`create_public_link`) with password protection and expiry; `get_public_link`, `update_public_link` (role, custom password, expiry; the URL is kept), `remove_public_link`. |
| | External Invites | ✅ | Invite non-Proton users (`invite_external_users`) and track registration status. | | | External Invites | ✅ | Invite non-Proton users (`invite_external_users`) and track registration status. |
| | Bookmarks | ✅ | Save, list, and delete public link bookmarks (`create_bookmark`, `list_bookmarks`, `delete_bookmark`). | | | Bookmarks | ✅ | Save, list, and delete public link bookmarks (`create_bookmark`, `list_bookmarks`, `delete_bookmark`). A listed bookmark carries its URL, its custom password and the bookmarked item's name. |
| | Incoming Invites | ✅ | List, accept, or reject invitations shared *with* the current user; `leave_shared_node`. | | | Incoming Invites | ✅ | List, accept, or reject invitations shared *with* the current user; `leave_shared_node`. |
| | Shared Listings | ✅ | `enumerate_shared_by_me_node_uids`, `enumerate_shared_with_me` (drive and photos volumes). | | | Shared Listings | ✅ | `enumerate_shared_by_me_node_uids`, `enumerate_shared_with_me` (drive and photos volumes). |
| | Public Link (consume) | 🟦 | Open someone else's link with no Proton account (`ProtonDrivePublicLinkClient`): SRP handshake, custom passwords, browse and download the shared subtree. | | | Public Link (consume) | 🟦 | Open someone else's link with no Proton account (`ProtonDrivePublicLinkClient`): SRP handshake, custom passwords, browse and download the shared subtree, or walk all of it at once (`enumerate_subtree`). |
| | Public Link streaming | ⭐ | Seekable range reads over a shared file (`open_revision` → `RevisionReader::read_at`), plus thumbnails, batch enumeration and in-place session renewal — a visitor streams a large file without downloading it. No TS or C# counterpart. | | | Public Link streaming | ⭐ | Seekable range reads over a shared file (`open_revision` → `RevisionReader::read_at`), plus thumbnails, batch enumeration and in-place session renewal — a visitor streams a large file without downloading it. No TS or C# counterpart. |
| **Photos** | Photos Timeline | ✅ | `ProtonPhotosClient` maps photostream, timeline enumeration, and photo downloads. | | **Photos** | Photos Timeline | ✅ | `ProtonPhotosClient` maps photostream, timeline enumeration, and photo downloads. |
| | Photo Uploads | ✅ | Uploading photos with `PhotoUploadMetadata` (capture time, tags, grouping). | | | Photo Uploads | ✅ | Uploading photos with `PhotoUploadMetadata` (capture time, tags, grouping). |
@@ -309,7 +312,7 @@ neither upstream SDK, original to `proton-sdk-rs` · ❌ not implemented.
| | Duplicate Detection | ✅ | `find_duplicates` matches candidates on name/content HMAC before re-uploading. | | | Duplicate Detection | ✅ | `find_duplicates` matches candidates on name/content HMAC before re-uploading. |
| | Favorite Shared Photos | ❌ | Needs the photo re-encrypted for our timeline root; not ported (upstream `03b1cb7f`). | | | Favorite Shared Photos | ❌ | Needs the photo re-encrypted for our timeline root; not ported (upstream `03b1cb7f`). |
| | Save to Timeline | ✅ | `save_photos_to_timeline` moves photos already on our photos volume into the timeline root and copies cross-volume ones (from albums shared with us). | | | Save to Timeline | ✅ | `save_photos_to_timeline` moves photos already on our photos volume into the timeline root and copies cross-volume ones (from albums shared with us). |
| | Album Writes | ⚠️ | `create_album` and `add_photos_to_album` are ported from the TypeScript SDK (C# has no public album write API); album rename, cover photo and delete are not. | | | Album Writes | ✅ | `create_album`, `rename_album`, `set_album_cover`, `add_photos_to_album` (photos from another volume are copied in), `remove_photos_from_album`, `delete_album`, and `delete_album_saving_photos`, which first saves photos that are only in the album to the timeline. |
| | Photos Volume Create| ❌ | Volume creation is not yet ported. | | | Photos Volume Create| ❌ | Volume creation is not yet ported. |
| **Caching** | Pluggable Cache | ✅ | Pluggable entity cache (`with_entity_cache`, or `with_entity_repository` to chain one onto a `with_key_salts` client); keys/secrets remain strictly in memory. Retained deliberately — upstream C# removed its entity cache. | | **Caching** | Pluggable Cache | ✅ | Pluggable entity cache (`with_entity_cache`, or `with_entity_repository` to chain one onto a `with_key_salts` client); keys/secrets remain strictly in memory. Retained deliberately — upstream C# removed its entity cache. |
| **Validation** | Node Names | ✅ | Empty or >255-char names are rejected client-side before create/rename (counted in `char`s; C# counts UTF-16 units). | | **Validation** | Node Names | ✅ | Empty or >255-char names are rejected client-side before create/rename (counted in `char`s; C# counts UTF-16 units). |
+1
View File
@@ -44,3 +44,4 @@ gitignored, so this file is the only durable record of what we last reviewed.
| 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-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 | — (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 | `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. |
+242 -93
View File
@@ -22,9 +22,10 @@ use proton_sdk::crypto::PrivateKey;
use proton_sdk::crypto::{ use proton_sdk::crypto::{
ContentKey, DEFAULT_BIT_LENGTH, SHARING_INVITER_CONTEXT, VerificationKeyRing, ContentKey, DEFAULT_BIT_LENGTH, SHARING_INVITER_CONTEXT, VerificationKeyRing,
VerificationStatus, accept_invitation, build_standard_share_material, VerificationStatus, accept_invitation, build_standard_share_material,
build_volume_creation_material, decrypt_armored_with_keys, derive_key_passphrase, build_volume_creation_material, decrypt_armored_with_keys, decrypt_armored_with_password,
encrypt_external_invitation, encrypt_invitation, generate_node_hash_key, generate_node_key, derive_key_passphrase, encrypt_external_invitation, encrypt_invitation, generate_node_hash_key,
generate_node_key_aead, generate_verifier, verify_detached, verify_detached_with_context, generate_node_key, generate_node_key_aead, generate_verifier, verify_detached,
verify_detached_with_context,
}; };
use proton_sdk::error::{ProtonApiError, ProtonError, Result}; use proton_sdk::error::{ProtonApiError, ProtonError, Result};
use proton_sdk::http::ApiHttpClient; use proton_sdk::http::ApiHttpClient;
@@ -49,13 +50,13 @@ use crate::dtos::{
AlbumCreationLink, AlbumCreationRequest, AlbumCreationResponse, AlbumItemListResponse, AlbumCreationLink, AlbumCreationRequest, AlbumCreationResponse, AlbumItemListResponse,
AlbumListResponse, AlbumUpdateLink, AlbumUpdateRequest, BlockCreationRequest, BlockDto, AlbumListResponse, AlbumUpdateLink, AlbumUpdateRequest, BlockCreationRequest, BlockDto,
BlockUploadPreparationRequest, BlockUploadPreparationResponse, BlockUploadTarget, BlockUploadPreparationRequest, BlockUploadPreparationResponse, BlockUploadTarget,
BlockVerificationInputResponse, BlockVerifier, BookmarkShareUrlDto, BookmarksResponse, BlockVerificationInputResponse, BlockVerifier, BookmarkShareUrlDto, BookmarkTokenDto,
CommonExtendedAttributes, ContextShareResponse, CopyLinkRequest, CopyLinkResponse, BookmarksResponse, CommonExtendedAttributes, ContextShareResponse, CopyLinkRequest,
CopyPhotoContent, CopyPhotoRelatedItem, CopyPhotoRequest, CreateBookmarkRequest, CopyLinkResponse, CopyPhotoContent, CopyPhotoRelatedItem, CopyPhotoRequest,
CreatePublicLinkRequest, CreatePublicLinkResponse, CreateShareRequest, CreateShareResponse, CreateBookmarkRequest, CreatePublicLinkRequest, CreatePublicLinkResponse, CreateShareRequest,
DeviceCreationDeviceDto, DeviceCreationLinkDto, DeviceCreationRequest, DeviceCreationResponse, CreateShareResponse, DeviceCreationDeviceDto, DeviceCreationLinkDto, DeviceCreationRequest,
DeviceCreationShareDto, DeviceListResponse, DeviceUpdateRequest, DeviceUpdateShareDto, DeviceCreationResponse, DeviceCreationShareDto, DeviceListResponse, DeviceUpdateRequest,
ExtendedAttributes, ExternalInvitationDto, ExternalInvitationResponseDto, DeviceUpdateShareDto, ExtendedAttributes, ExternalInvitationDto, ExternalInvitationResponseDto,
ExternalInvitationsResponse, FileContentDigests, FileCreationRequest, FileCreationResponse, ExternalInvitationsResponse, FileContentDigests, FileCreationRequest, FileCreationResponse,
FindPhotoDuplicatesRequest, FindPhotoDuplicatesResponse, FolderChildrenResponse, FindPhotoDuplicatesRequest, FindPhotoDuplicatesResponse, FolderChildrenResponse,
FolderCreationRequest, FolderCreationResponse, FolderSizeResponse, InvitationDetailsResponse, FolderCreationRequest, FolderCreationResponse, FolderSizeResponse, InvitationDetailsResponse,
@@ -65,7 +66,7 @@ use crate::dtos::{
LinkDetailsResponse, LinkDto, LinkType, ModulusResponse, MoveLinkRequest, LinkDetailsResponse, LinkDto, LinkType, ModulusResponse, MoveLinkRequest,
MoveMultipleLinksItem, MoveMultipleLinksRequest, MultipleLinksRequest, MyFilesShareResponse, MoveMultipleLinksItem, MoveMultipleLinksRequest, MultipleLinksRequest, MyFilesShareResponse,
NodeNameAvailabilityRequest, NodeNameAvailabilityResponse, PhotoTagsRequest, NodeNameAvailabilityRequest, NodeNameAvailabilityResponse, PhotoTagsRequest,
PhotosAttributesDto, RemovePhotosFromAlbumRequest, RenameLinkRequest, PhotosAttributesDto, PublicLinkSettingsDto, RemovePhotosFromAlbumRequest, RenameLinkRequest,
ReportRecentlyAccessedItem, ReportRecentlyAccessedRequest, RevisionConflict, ReportRecentlyAccessedItem, ReportRecentlyAccessedRequest, RevisionConflict,
RevisionCreationRequest, RevisionCreationResponse, RevisionDto, RevisionListItemDto, RevisionCreationRequest, RevisionCreationResponse, RevisionDto, RevisionListItemDto,
RevisionListResponse, RevisionMetadataResponse, RevisionUpdateRequest, ShareInvitationDto, RevisionListResponse, RevisionMetadataResponse, RevisionUpdateRequest, ShareInvitationDto,
@@ -74,8 +75,9 @@ use crate::dtos::{
SharedWithMeResponse, SmallFileUploadMetadataRequest, SmallRevisionUploadMetadataRequest, SharedWithMeResponse, SmallFileUploadMetadataRequest, SmallRevisionUploadMetadataRequest,
SmallUploadResponse, ThumbnailBlockListRequest, ThumbnailBlockListResponse, SmallUploadResponse, ThumbnailBlockListRequest, ThumbnailBlockListResponse,
ThumbnailCreationRequest, ThumbnailDto, TimelinePhotoListResponse, TransferPhotoLinkItem, ThumbnailCreationRequest, ThumbnailDto, TimelinePhotoListResponse, TransferPhotoLinkItem,
TransferPhotosRequest, TransferPhotosResponse, UpdatePermissionsRequest, VolumeCreationRequest, TransferPhotosRequest, TransferPhotosResponse, UpdatePermissionsRequest,
VolumeEventDto, VolumeEventListResponse, VolumeListResponse, VolumeTrashResponse, UpdatePublicLinkRequest, VolumeCreationRequest, VolumeEventDto, VolumeEventListResponse,
VolumeListResponse, VolumeTrashResponse,
}; };
use crate::events::{DriveEvent, DriveEventScopeId}; use crate::events::{DriveEvent, DriveEventScopeId};
use crate::node::{ use crate::node::{
@@ -3755,15 +3757,7 @@ impl ProtonDriveClient {
expiration_time: Option<i64>, expiration_time: Option<i64>,
) -> Result<PublicLink> { ) -> Result<PublicLink> {
let mut timer = self.telemetry.start("create_public_link"); let mut timer = self.telemetry.start("create_public_link");
let permissions = public_link_permissions(role)?;
let permissions = match role {
MemberRole::Viewer | MemberRole::Editor => role.to_permissions().unwrap(),
_ => {
return Err(ProtonError::invalid_operation(
"a public link can only grant the Viewer or Editor role",
));
}
};
let (share_id, share_session_key, creator_email, address_key) = let (share_id, share_session_key, creator_email, address_key) =
self.ensure_node_share(uid).await?; self.ensure_node_share(uid).await?;
@@ -3771,65 +3765,21 @@ impl ProtonDriveClient {
// The URL fragment is the generated password; the encryption password is // The URL fragment is the generated password; the encryption password is
// the generated password with any custom password appended. // the generated password with any custom password appended.
let generated = generate_public_link_password(); let generated = generate_public_link_password();
let full_password = match custom_password { let settings = self
Some(custom) if !custom.is_empty() => format!("{generated}{custom}"), .seal_public_link_settings(
_ => generated.clone(), permissions,
}; &generated,
custom_password,
// The modulus is fetched first so that everything CPU-bound below can go share_session_key,
// to the blocking pool in one hop. address_key,
let modulus = self.fetch_srp_modulus().await?; )
.await?;
// Wrap the share session key with the SRP-salted passphrase, store the let has_custom_password = settings.flags == 3;
// password encrypted to the owner's address key, and build the SRP
// verifier over the link password.
//
// All of that is pure CPU and none of it is cheap: `derive_key_passphrase`
// is a deliberately slow bcrypt, and `generate_verifier` a 2048-bit modular
// exponentiation. Inline, they stalled every other task on the reactor
// thread for the duration — so, like the rest of the crate's PGP work, they
// run on the blocking pool.
let key_salt = generate_key_salt();
let signed_modulus = modulus.modulus.clone();
let password_bytes = full_password.clone();
let salt_for_task = key_salt;
let (share_passphrase_key_packet, armored_password, verifier) =
tokio::task::spawn_blocking(move || {
let salted_passphrase =
derive_key_passphrase(password_bytes.as_bytes(), &salt_for_task)?;
let key_packet =
base64_encode(share_session_key.encrypt_with_password(&salted_passphrase)?);
let armored_password = address_key.encrypt(password_bytes.as_bytes())?;
let verifier = generate_verifier(
password_bytes.as_bytes(),
&signed_modulus,
DEFAULT_BIT_LENGTH,
)?;
Ok::<_, ProtonError>((key_packet, armored_password, verifier))
})
.await
.map_err(|e| {
ProtonError::invalid_operation(format!("public link crypto task failed: {e}"))
})??;
let flags = if custom_password.map(|p| !p.is_empty()).unwrap_or(false) {
3 // random + custom password
} else {
2 // random password only
};
let request = CreatePublicLinkRequest { let request = CreatePublicLinkRequest {
creator_email, creator_email,
permissions,
flags,
expiration_time, expiration_time,
share_password_salt: base64_encode(key_salt), settings,
share_passphrase_key_packet,
password: armored_password,
url_password_salt: verifier.salt,
srp_verifier: verifier.verifier,
srp_modulus_id: modulus.modulus_id,
max_accesses: 0,
}; };
let path = format!("shares/{share_id}/urls"); let path = format!("shares/{share_id}/urls");
@@ -3843,7 +3793,134 @@ impl ProtonDriveClient {
role, role,
creation_time: now_epoch_seconds(), creation_time: now_epoch_seconds(),
expiration_time, expiration_time,
has_custom_password: flags == 3, has_custom_password,
})
}
/// Change the options of the public link on a node: its role, its custom
/// password and its expiry (`PUT shares/{sid}/urls/{urlID}`). Each call sets
/// all three: `None` removes a custom password or an expiry the link had.
///
/// Ported from the TypeScript SDK (`SharingManagement.updateSharedLink`). The
/// URL does not change: the generated password in its fragment is kept, and
/// the share key and SRP verifier are sealed again under it plus the new
/// custom password. A legacy link, whose fragment is not a generated
/// 12-character password, cannot be updated; remove it and create a new one.
pub async fn update_public_link(
&self,
uid: &NodeUid,
role: MemberRole,
custom_password: Option<&str>,
expiration_time: Option<i64>,
) -> Result<PublicLink> {
let mut timer = self.telemetry.start("update_public_link");
let permissions = public_link_permissions(role)?;
let link = self
.get_public_link(uid)
.await?
.ok_or_else(|| ProtonError::invalid_operation("the node has no public link"))?;
let generated = link
.url
.as_deref()
.and_then(|url| url.split_once('#'))
.map(|(_, fragment)| fragment.to_owned())
.filter(|fragment| fragment.chars().count() == PUBLIC_LINK_PASSWORD_LEN)
.ok_or_else(|| {
ProtonError::invalid_operation(
"a legacy public link cannot be updated; remove it and create a new one",
)
})?;
let (_, share_session_key, _, address_key) = self.ensure_node_share(uid).await?;
let settings = self
.seal_public_link_settings(
permissions,
&generated,
custom_password,
share_session_key,
address_key,
)
.await?;
let has_custom_password = settings.flags == 3;
let request = UpdatePublicLinkRequest {
expiration_time,
settings,
};
let path = format!("shares/{}/urls/{}", link.share_id, link.public_link_id);
let _: proton_sdk::api::ApiResponse = self.http.put(&path, &request).await?;
timer.success();
Ok(PublicLink {
role,
expiration_time,
has_custom_password,
..link
})
}
/// Seal a public link's share key and SRP verifier under its password: the
/// generated password, with `custom_password` appended when there is one.
///
/// The share session key is wrapped with the bcrypt-salted passphrase
/// (`SharePassphraseKeyPacket`), the password is stored encrypted to the
/// owner's address key, and the SRP verifier lets the server authenticate
/// visitors without learning the password.
async fn seal_public_link_settings(
&self,
permissions: i32,
generated: &str,
custom_password: Option<&str>,
share_session_key: ContentKey,
address_key: PrivateKey,
) -> Result<PublicLinkSettingsDto> {
let custom_password = custom_password.filter(|p| !p.is_empty());
let full_password = format!("{generated}{}", custom_password.unwrap_or(""));
// The modulus is fetched first so that everything CPU-bound below can go
// to the blocking pool in one hop.
let modulus = self.fetch_srp_modulus().await?;
// All of that is pure CPU and none of it is cheap: `derive_key_passphrase`
// is a deliberately slow bcrypt, and `generate_verifier` a 2048-bit modular
// exponentiation. Inline, they stalled every other task on the reactor
// thread for the duration — so, like the rest of the crate's PGP work, they
// run on the blocking pool.
let key_salt = generate_key_salt();
let signed_modulus = modulus.modulus;
let (share_passphrase_key_packet, armored_password, verifier) =
tokio::task::spawn_blocking(move || {
let salted_passphrase = derive_key_passphrase(full_password.as_bytes(), &key_salt)?;
let key_packet =
base64_encode(share_session_key.encrypt_with_password(&salted_passphrase)?);
let armored_password = address_key.encrypt(full_password.as_bytes())?;
let verifier = generate_verifier(
full_password.as_bytes(),
&signed_modulus,
DEFAULT_BIT_LENGTH,
)?;
Ok::<_, ProtonError>((key_packet, armored_password, verifier))
})
.await
.map_err(|e| {
ProtonError::invalid_operation(format!("public link crypto task failed: {e}"))
})??;
Ok(PublicLinkSettingsDto {
permissions,
flags: if custom_password.is_some() {
3 // random + custom password
} else {
2 // random password only
},
share_password_salt: base64_encode(key_salt),
share_passphrase_key_packet,
password: armored_password,
url_password_salt: verifier.salt,
srp_verifier: verifier.verifier,
srp_modulus_id: modulus.modulus_id,
max_accesses: 0,
}) })
} }
@@ -4120,8 +4197,9 @@ impl ProtonDriveClient {
// ---- bookmarks --------------------------------------------------------- // ---- bookmarks ---------------------------------------------------------
/// List the public links the user has saved to their account /// List the public links the user has saved to their account
/// (`GET v2/shared-bookmarks`). The URL is always recovered; the item name is /// (`GET v2/shared-bookmarks`). A bookmark whose stored password cannot be
/// only present when the SRP-derived share key could be decrypted (not yet). /// decrypted is skipped; the item name is `None` when the share key it is
/// encrypted to cannot be unlocked or the name is not valid.
pub async fn list_bookmarks(&self) -> Result<Vec<Bookmark>> { pub async fn list_bookmarks(&self) -> Result<Vec<Bookmark>> {
let mut timer = self.telemetry.start("list_bookmarks"); let mut timer = self.telemetry.start("list_bookmarks");
let response: BookmarksResponse = self.http.get("v2/shared-bookmarks").await?; let response: BookmarksResponse = self.http.get("v2/shared-bookmarks").await?;
@@ -4138,20 +4216,24 @@ impl ProtonDriveClient {
let Ok(bytes) = decrypt_armored_with_keys(enc, &address_keys) else { let Ok(bytes) = decrypt_armored_with_keys(enc, &address_keys) else {
continue; continue;
}; };
// The stored secret is the URL password with any custom password // The stored secret is the generated URL password with any custom
// appended; the URL only needs the leading generated portion, but the // password appended (TS `splitGeneratedAndCustomPassword`). The URL
// API does not tell us the split, so the fragment is the whole string. // fragment is the generated part; the share key is locked with the
let url_password = String::from_utf8_lossy(&bytes).into_owned(); // whole string.
let password = String::from_utf8_lossy(&bytes).into_owned();
let (url_password, custom_password) = split_bookmark_password(&password);
let url = format!(
"https://drive.proton.me/urls/{}#{url_password}",
dto.token.token
);
let custom_password = custom_password.map(str::to_owned);
let node_name = decrypt_bookmark_name(password, &dto.token).await;
bookmarks.push(Bookmark { bookmarks.push(Bookmark {
url: format!( url,
"https://drive.proton.me/urls/{}#{url_password}",
dto.token.token
),
token: dto.token.token, token: dto.token.token,
// Name decryption needs the SRP-derived share key, not yet ported; custom_password,
// the URL is enough to open the link. node_name,
node_name: None,
is_folder: dto.token.link_type == 1, is_folder: dto.token.link_type == 1,
creation_time: dto.create_time, creation_time: dto.create_time,
}); });
@@ -7987,6 +8069,53 @@ const PUBLIC_LINK_PASSWORD_CHARSET: &[u8] =
/// Generated public-link password length (JS `PUBLIC_LINK_GENERATED_PASSWORD_LENGTH`). /// Generated public-link password length (JS `PUBLIC_LINK_GENERATED_PASSWORD_LENGTH`).
const PUBLIC_LINK_PASSWORD_LEN: usize = 12; const PUBLIC_LINK_PASSWORD_LEN: usize = 12;
/// The permission mask a public link may grant: Viewer or Editor, never Admin.
fn public_link_permissions(role: MemberRole) -> Result<i32> {
role.to_permissions()
.filter(|_| matches!(role, MemberRole::Viewer | MemberRole::Editor))
.ok_or_else(|| {
ProtonError::invalid_operation("a public link can only grant the Viewer or Editor role")
})
}
/// Split a bookmark's stored secret into the generated URL password and the
/// custom password appended to it, if any (TS `splitGeneratedAndCustomPassword`).
fn split_bookmark_password(password: &str) -> (&str, Option<&str>) {
let split = password
.char_indices()
.nth(PUBLIC_LINK_PASSWORD_LEN)
.map_or(password.len(), |(i, _)| i);
let (generated, custom) = password.split_at(split);
(generated, (!custom.is_empty()).then_some(custom))
}
/// Decrypt a bookmarked item's name (TS `SharingCryptoService.decryptBookmark`).
///
/// The share key is locked the way a visitor unlocks it: the whole stored
/// password, bcrypt-derived with `SharePasswordSalt`, opens `SharePassphrase`.
/// The name is encrypted to the share key. Signatures are not checked, as in
/// TS. Any failure, or a name that would not pass validation, yields `None`:
/// the URL still opens the link.
///
/// Runs on the blocking pool: the bcrypt derivation and the share key's S2K
/// are each tens of milliseconds of CPU, once per bookmark.
async fn decrypt_bookmark_name(password: String, token: &BookmarkTokenDto) -> Option<String> {
let salt = BASE64.decode(&token.share_password_salt).ok()?;
let armored_passphrase = token.share_passphrase.clone();
let armored_share_key = token.share_key.clone();
let armored_name = token.name.clone();
tokio::task::spawn_blocking(move || {
let key_password = derive_key_passphrase(password.as_bytes(), &salt).ok()?;
let passphrase = decrypt_armored_with_password(&armored_passphrase, &key_password).ok()?;
let share_key = PrivateKey::from_armored(&armored_share_key, &passphrase).ok()?;
let name_bytes = share_key.decrypt_armored_message(&armored_name).ok()?;
let name = String::from_utf8(name_bytes).ok()?;
validate_node_name(&name).ok().map(|()| name)
})
.await
.ok()?
}
/// Generate a random 12-character alphanumeric public-link password. /// Generate a random 12-character alphanumeric public-link password.
fn generate_public_link_password() -> String { fn generate_public_link_password() -> String {
let mut raw = [0u8; PUBLIC_LINK_PASSWORD_LEN]; let mut raw = [0u8; PUBLIC_LINK_PASSWORD_LEN];
@@ -8855,8 +8984,8 @@ mod tests {
is_listable_revision_state, is_upload_timeout, join_node_path, missing_related_links, is_listable_revision_state, is_upload_timeout, join_node_path, missing_related_links,
path_segments, photos_only_in_album, record_transfer_outcome, resolve_parent_keys, path_segments, photos_only_in_album, record_transfer_outcome, resolve_parent_keys,
run_context_share_mutation, share_membership_from_dto, small_upload_applicable, 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, small_upload_should_fall_back, split_bookmark_password, take_block_target,
validate_node_name, take_thumbnail_target, to_drive_event, validate_node_name,
}; };
use crate::dtos::{ use crate::dtos::{
AggregateLinksResponse, BlockUploadTarget, LinkDetailsDto, LinkDto, LinkIdResponsePair, AggregateLinksResponse, BlockUploadTarget, LinkDetailsDto, LinkDto, LinkIdResponsePair,
@@ -8873,6 +9002,26 @@ mod tests {
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
use tokio::sync::{Mutex, Notify, RwLock}; use tokio::sync::{Mutex, Notify, RwLock};
#[test]
fn a_bookmark_password_splits_into_the_generated_part_and_the_custom_one() {
assert_eq!(
split_bookmark_password("AbCdEf123456"),
("AbCdEf123456", None)
);
assert_eq!(
split_bookmark_password("AbCdEf123456s3cr3t!"),
("AbCdEf123456", Some("s3cr3t!"))
);
// A custom password may hold any character; the split counts chars,
// not bytes, and never cuts one in half.
assert_eq!(
split_bookmark_password("AbCdEf123456pässwört"),
("AbCdEf123456", Some("pässwört"))
);
// A legacy password shorter than a generated one is all URL password.
assert_eq!(split_bookmark_password("short"), ("short", None));
}
/// A batch envelope with one successful link and one that failed with `code`. /// A batch envelope with one successful link and one that failed with `code`.
fn mixed_aggregate(code: ResponseCode) -> AggregateLinksResponse { fn mixed_aggregate(code: ResponseCode) -> AggregateLinksResponse {
AggregateLinksResponse { AggregateLinksResponse {
+20 -2
View File
@@ -2389,12 +2389,30 @@ pub struct ModulusResponse {
pub struct CreatePublicLinkRequest { pub struct CreatePublicLinkRequest {
#[serde(rename = "CreatorEmail")] #[serde(rename = "CreatorEmail")]
pub creator_email: String, pub creator_email: String,
#[serde(rename = "ExpirationTime", skip_serializing_if = "Option::is_none")]
pub expiration_time: Option<i64>,
#[serde(flatten)]
pub settings: PublicLinkSettingsDto,
}
/// `PUT shares/{shareID}/urls/{urlID}`. Unlike create, an absent expiry is
/// sent as `null`, which clears an expiry the link had (TS `updatePublicLink`).
#[derive(Debug, Serialize)]
pub struct UpdatePublicLinkRequest {
#[serde(rename = "ExpirationTime")]
pub expiration_time: Option<i64>,
#[serde(flatten)]
pub settings: PublicLinkSettingsDto,
}
/// The fields create and update share: the role, the password flags, and the
/// share key + SRP verifier sealed under the link password.
#[derive(Debug, Serialize)]
pub struct PublicLinkSettingsDto {
#[serde(rename = "Permissions")] #[serde(rename = "Permissions")]
pub permissions: i32, pub permissions: i32,
#[serde(rename = "Flags")] #[serde(rename = "Flags")]
pub flags: i32, pub flags: i32,
#[serde(rename = "ExpirationTime", skip_serializing_if = "Option::is_none")]
pub expiration_time: Option<i64>,
#[serde(rename = "SharePasswordSalt")] #[serde(rename = "SharePasswordSalt")]
pub share_password_salt: String, pub share_password_salt: String,
#[serde(rename = "SharePassphraseKeyPacket")] #[serde(rename = "SharePassphraseKeyPacket")]
+67
View File
@@ -66,6 +66,12 @@ use crate::sharing::MemberRole;
use crate::single_flight::SingleFlight; use crate::single_flight::SingleFlight;
use crate::transport::{BlockSession, RevisionTransport, rank_block_sizes}; use crate::transport::{BlockSession, RevisionTransport, rank_block_sizes};
/// How many folders of one level [`ProtonDrivePublicLinkClient::enumerate_subtree`]
/// lists at once. Each listing fans out again into up to
/// [`MAX_CONCURRENT_DETAIL_FETCHES`] detail batches, so this multiplies the
/// requests in flight; six keeps them in the low tens.
const MAX_CONCURRENT_FOLDER_LISTINGS: usize = 6;
/// Link metadata readable before authenticating, from `GET urls/{token}/info`. /// Link metadata readable before authenticating, from `GET urls/{token}/info`.
/// ///
/// Fetch it with [`ProtonDrivePublicLinkClient::info`] to learn whether the user /// Fetch it with [`ProtonDrivePublicLinkClient::info`] to learn whether the user
@@ -522,6 +528,67 @@ impl ProtonDrivePublicLinkClient {
Ok(uids) Ok(uids)
} }
/// Every node below a shared folder, breadth first: the folder's children,
/// then their children, and so on. The folder itself is not included.
///
/// The authenticated client walks a tree with `get_node_hierarchy`; a
/// visitor has no such endpoint, so this lists one folder per request. A
/// whole level is listed at once, six folders at a time, because a shared
/// library is wide: hundreds of sibling folders, each one listing plus a
/// detail fetch, which one at a time is minutes of round trips. Nodes come back in a stable order: by level, then in the
/// order their folders were listed.
///
/// A failed listing fails the walk. A caller that walks large shares over a
/// flaky network may want to wrap [`enumerate_folder_children_node_uids`]
/// with its own retry instead.
///
/// [`enumerate_folder_children_node_uids`]: Self::enumerate_folder_children_node_uids
pub async fn enumerate_subtree(&self, folder_uid: &NodeUid) -> Result<Vec<Node>> {
self.enumerate_subtree_reporting(folder_uid, |_| {}).await
}
/// As [`enumerate_subtree`](Self::enumerate_subtree), calling `progress`
/// with the number of nodes found so far each time a folder's listing
/// comes back. A walk of a large share takes minutes; a rising count shows
/// it is not stuck.
pub async fn enumerate_subtree_reporting(
&self,
folder_uid: &NodeUid,
progress: impl Fn(usize),
) -> Result<Vec<Node>> {
let mut level = vec![folder_uid.clone()];
let mut found = Vec::new();
while !level.is_empty() {
let listings = level.into_iter().map(|folder| {
let client = self.clone();
async move { client.list_folder(&folder).await }
});
let mut listings = stream::iter(listings).buffered(MAX_CONCURRENT_FOLDER_LISTINGS);
let mut next = Vec::new();
while let Some(children) = listings.try_next().await? {
next.extend(
children
.iter()
.filter(|child| child.is_folder())
.map(|child| child.uid.clone()),
);
found.extend(children);
progress(found.len());
}
level = next;
}
Ok(found)
}
/// One folder's children, decrypted.
async fn list_folder(&self, folder_uid: &NodeUid) -> Result<Vec<Node>> {
let child_uids = self.enumerate_folder_children_node_uids(folder_uid).await?;
self.enumerate_nodes(&child_uids).await
}
/// Fetch decrypted metadata for many nodes in the shared subtree. /// Fetch decrypted metadata for many nodes in the shared subtree.
/// ///
/// A node that does not exist is omitted; one that fails to decrypt is /// A node that does not exist is omitted; one that fails to decrypt is
+5
View File
@@ -257,6 +257,11 @@ pub struct Bookmark {
pub token: String, pub token: String,
/// The full public URL, including the `#password` fragment. /// The full public URL, including the `#password` fragment.
pub url: String, pub url: String,
/// The link's custom password, when it has one. The URL alone does not
/// open such a link; pass this to
/// [`ProtonDrivePublicLinkClient::open`](crate::ProtonDrivePublicLinkClient::open)
/// as well.
pub custom_password: Option<String>,
/// The bookmarked item's decrypted name, when it could be decrypted. /// The bookmarked item's decrypted name, when it could be decrypted.
pub node_name: Option<String>, pub node_name: Option<String>,
/// Whether the bookmarked item is a folder. /// Whether the bookmarked item is a folder.
@@ -787,3 +787,228 @@ async fn a_visitor_can_download_thumbnails_of_shared_files() {
cleanup(client, &folder).await; cleanup(client, &folder).await;
} }
// ---------------------------------------------------------------------------
// Public link — changing options, walking the subtree, bookmarks
// ---------------------------------------------------------------------------
/// Updating a link keeps its URL and applies the new role, custom password and
/// expiry; a visitor then needs the new password, and a second update clears
/// both the password and the expiry again.
#[tokio::test]
#[ignore = "live: needs test-account credentials"]
async fn update_public_link_changes_role_password_and_expiry() {
let Some(live) = common::live_client().await else {
return;
};
let client = &live.client;
let folder = scratch_folder(client, "public-update").await;
let link = client
.create_public_link(&folder, MemberRole::Viewer, None, None)
.await
.expect("create_public_link");
let url = link.url.clone().expect("a created link must carry its URL");
let custom = "n3w-s3cr3t";
let expiry = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs() as i64
+ 7 * 24 * 3600;
let updated = client
.update_public_link(&folder, MemberRole::Editor, Some(custom), Some(expiry))
.await
.expect("update_public_link");
assert_eq!(updated.public_link_id, link.public_link_id);
assert_eq!(
updated.url.as_deref(),
Some(url.as_str()),
"the URL is kept"
);
assert_eq!(updated.role, MemberRole::Editor);
assert!(updated.has_custom_password);
assert_eq!(updated.expiration_time, Some(expiry));
let fetched = client
.get_public_link(&folder)
.await
.expect("get_public_link")
.expect("link must still exist");
assert_eq!(fetched.role, MemberRole::Editor, "the role must be stored");
assert!(
fetched.has_custom_password,
"the password flag must be stored"
);
assert_eq!(
fetched.expiration_time,
Some(expiry),
"the expiry must be stored"
);
assert_eq!(fetched.url.as_deref(), Some(url.as_str()));
assert!(
ProtonDrivePublicLinkClient::open(common::config(), &url, None)
.await
.is_err(),
"the link must now need the custom password"
);
let visitor = ProtonDrivePublicLinkClient::open(common::config(), &url, Some(custom))
.await
.expect("open with the new custom password");
assert_eq!(visitor.public_role(), MemberRole::Editor);
let cleared = client
.update_public_link(&folder, MemberRole::Viewer, None, None)
.await
.expect("update_public_link (clear)");
assert!(!cleared.has_custom_password);
let fetched = client
.get_public_link(&folder)
.await
.expect("get_public_link")
.expect("link must still exist");
assert!(
!fetched.has_custom_password,
"the custom password is removed"
);
assert_eq!(fetched.expiration_time, None, "the expiry is cleared");
ProtonDrivePublicLinkClient::open(common::config(), &url, None)
.await
.expect("the link opens with the URL alone again");
assert!(
client
.update_public_link(&folder, MemberRole::Admin, None, None)
.await
.is_err(),
"a public link cannot grant Admin"
);
client
.remove_public_link(&link)
.await
.expect("remove_public_link");
cleanup(client, &folder).await;
}
/// A visitor walks the whole shared tree, nested folders included, without
/// the start folder itself.
#[tokio::test]
#[ignore = "live: needs test-account credentials"]
async fn a_visitor_enumerates_the_whole_shared_subtree() {
let Some(live) = common::live_client().await else {
return;
};
let client = &live.client;
let folder = scratch_folder(client, "public-walk").await;
let a = client
.create_folder(&folder, "a", None)
.await
.expect("create a");
let b = client.create_folder(&a, "b", None).await.expect("create b");
let top = client
.upload_file(&folder, "top.txt", "text/plain", b"top")
.await
.expect("upload top");
let deep = client
.upload_file(&b, "deep.txt", "text/plain", b"deep")
.await
.expect("upload deep");
let link = client
.create_public_link(&folder, MemberRole::Viewer, None, None)
.await
.expect("create_public_link");
let url = link.url.clone().expect("a created link must carry its URL");
let visitor = ProtonDrivePublicLinkClient::open(common::config(), &url, None)
.await
.expect("open public link");
let progress = std::sync::atomic::AtomicUsize::new(0);
let nodes = visitor
.enumerate_subtree_reporting(&folder, |n| {
progress.store(n, std::sync::atomic::Ordering::Relaxed)
})
.await
.expect("enumerate_subtree");
let mut uids: Vec<_> = nodes.iter().map(|n| n.uid.clone()).collect();
uids.sort_by_key(|uid| uid.link_id.to_string());
let mut want = vec![a.clone(), b.clone(), top.clone(), deep.clone()];
want.sort_by_key(|uid| uid.link_id.to_string());
assert_eq!(uids, want, "every descendant, and not the start folder");
assert_eq!(progress.into_inner(), 4, "the last report is the total");
// Breadth first: the first level comes before anything below it.
let position = |uid: &NodeUid| nodes.iter().position(|n| &n.uid == uid).unwrap();
assert!(position(&a) < position(&b));
assert!(position(&top) < position(&b));
assert!(position(&b) < position(&deep));
client
.remove_public_link(&link)
.await
.expect("remove_public_link");
cleanup(client, &folder).await;
}
/// A bookmark lists with its item's name, the URL fragment without the custom
/// password, and the custom password on its own, and is gone once deleted.
#[tokio::test]
#[ignore = "live: needs test-account credentials"]
async fn bookmarks_list_with_their_name_and_custom_password() {
let Some(live) = common::live_client().await else {
return;
};
let client = &live.client;
let folder = scratch_folder(client, "public-bookmark").await;
let folder_name = client
.get_node(&folder)
.await
.expect("get_node")
.expect("folder exists")
.name;
let custom = "b00kmark";
let link = client
.create_public_link(&folder, MemberRole::Viewer, Some(custom), None)
.await
.expect("create_public_link");
let url = link.url.clone().expect("a created link must carry its URL");
client
.create_bookmark(&url, Some(custom))
.await
.expect("create_bookmark");
let bookmarks = client.list_bookmarks().await.expect("list_bookmarks");
let bookmark = bookmarks
.iter()
.find(|b| url.contains(&b.token))
.expect("the new bookmark must be listed");
assert_eq!(
bookmark.url, url,
"the URL carries the generated password only"
);
assert_eq!(bookmark.custom_password.as_deref(), Some(custom));
assert_eq!(bookmark.node_name.as_deref(), Some(folder_name.as_str()));
assert!(bookmark.is_folder);
client
.delete_bookmark(&bookmark.token)
.await
.expect("delete_bookmark");
let bookmarks = client.list_bookmarks().await.expect("list_bookmarks");
assert!(
bookmarks.iter().all(|b| !url.contains(&b.token)),
"a deleted bookmark must not be listed"
);
client
.remove_public_link(&link)
.await
.expect("remove_public_link");
cleanup(client, &folder).await;
}