mirror of
https://github.com/narrrl/proton-sdk-rs.git
synced 2026-11-03 16:41:26 +01:00
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:
1 parent
3da18b9309
commit
781301a77b
8 files changed
+632
-99
No files matched your search
@@ -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.
|
||||||
@@ -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). |
|
||||||
|
|||||||
@@ -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. |
|
||||||
@@ -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();
|
||||||
bookmarks.push(Bookmark {
|
let (url_password, custom_password) = split_bookmark_password(&password);
|
||||||
url: format!(
|
let url = format!(
|
||||||
"https://drive.proton.me/urls/{}#{url_password}",
|
"https://drive.proton.me/urls/{}#{url_password}",
|
||||||
dto.token.token
|
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 {
|
||||||
|
url,
|
||||||
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 {
|
||||||
|
|||||||
@@ -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")]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in new issue
Block a user