mirror of
https://github.com/narrrl/proton-sdk-rs.git
synced 2026-11-03 16:41:26 +01:00
version 0.4.0
This commit is contained in:
1 parent
5e1f7b56e0
commit
4fe7f02ebb
14 files changed
+1838
-291
No files matched your search
Generated
+61
-55
@@ -42,9 +42,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "aes"
|
||||
version = "0.9.1"
|
||||
version = "0.9.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f1fc76eaeac4c9164506c466d4ffdd8ec9d0c5bf57ee97177c4d8eceb3a0e138"
|
||||
checksum = "f8eb277bec05f56a0e0591f155a484cbd0f4f07ff2905051a48c72f004f7ed58"
|
||||
dependencies = [
|
||||
"cipher 0.5.2",
|
||||
"cpubits",
|
||||
@@ -72,7 +72,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fdf011db2e21ce0d575593d749db5554b47fed37aff429e4dc50bc91ac93a028"
|
||||
dependencies = [
|
||||
"aead 0.6.1",
|
||||
"aes 0.9.1",
|
||||
"aes 0.9.2",
|
||||
"cipher 0.5.2",
|
||||
"ctr 0.10.1",
|
||||
"ghash 0.6.0",
|
||||
@@ -118,9 +118,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "async-compression"
|
||||
version = "0.4.42"
|
||||
version = "0.4.43"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e79b3f8a79cccc2898f31920fc69f304859b3bd567490f75ebf51ae1c792a9ac"
|
||||
checksum = "3976abdc8fe7d1133d43d304afd42abdf5bc3e1319d263d223bde07b5efc4be8"
|
||||
dependencies = [
|
||||
"compression-codecs",
|
||||
"compression-core",
|
||||
@@ -136,7 +136,7 @@ checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.0",
|
||||
"syn 3.0.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -186,6 +186,12 @@ version = "0.22.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
version = "0.23.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b25655df2c3cdd83c5e5b293b88acd880332b2ddadd7c30ac43144fdc0033da9"
|
||||
|
||||
[[package]]
|
||||
name = "base64ct"
|
||||
version = "1.8.3"
|
||||
@@ -194,11 +200,11 @@ checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
|
||||
|
||||
[[package]]
|
||||
name = "bcrypt"
|
||||
version = "0.19.2"
|
||||
version = "0.19.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f3c067aa24dd4ed5c79cf222a38f260c8f23d3b82a062fba3f28c6fe563b753"
|
||||
checksum = "a0cd0bd35a28836d528d2b58ad499bc3c5641d59379421b1be9eeb0c2f2b912a"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"base64 0.23.0",
|
||||
"blowfish 0.10.0",
|
||||
"getrandom 0.4.3",
|
||||
"subtle",
|
||||
@@ -357,9 +363,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.3.0"
|
||||
version = "1.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c89588d05638b5b4594a3348a2d6c20277e43a7f5c5202b05cc56888475a47b8"
|
||||
checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9"
|
||||
dependencies = [
|
||||
"find-msvc-tools",
|
||||
"jobserver",
|
||||
@@ -817,13 +823,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "displaydoc"
|
||||
version = "0.2.6"
|
||||
version = "0.2.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f"
|
||||
checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
"syn 3.0.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1215,9 +1221,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "http"
|
||||
version = "1.4.2"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425"
|
||||
checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"itoa",
|
||||
@@ -1254,18 +1260,18 @@ checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
|
||||
|
||||
[[package]]
|
||||
name = "hybrid-array"
|
||||
version = "0.4.13"
|
||||
version = "0.4.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c"
|
||||
checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b"
|
||||
dependencies = [
|
||||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper"
|
||||
version = "1.10.1"
|
||||
version = "1.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498"
|
||||
checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72"
|
||||
dependencies = [
|
||||
"atomic-waker",
|
||||
"bytes",
|
||||
@@ -1302,7 +1308,7 @@ version = "0.1.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"futures-channel",
|
||||
"futures-util",
|
||||
@@ -1596,9 +1602,9 @@ checksum = "34b357333733e8260735ba5894eb928c02ecc69c78715f01a8019e7fa7f2db4c"
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.186"
|
||||
version = "0.2.189"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
|
||||
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
|
||||
|
||||
[[package]]
|
||||
name = "libm"
|
||||
@@ -1877,7 +1883,7 @@ dependencies = [
|
||||
"aes-gcm 0.10.3",
|
||||
"aes-kw",
|
||||
"argon2",
|
||||
"base64",
|
||||
"base64 0.22.1",
|
||||
"bitfields",
|
||||
"block-padding",
|
||||
"blowfish 0.9.1",
|
||||
@@ -2030,10 +2036,10 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proton-drive-rs"
|
||||
version = "0.3.2"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64",
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"futures",
|
||||
"getrandom 0.4.3",
|
||||
@@ -2055,11 +2061,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proton-sdk"
|
||||
version = "0.3.2"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"aes-gcm 0.11.0",
|
||||
"async-trait",
|
||||
"base64",
|
||||
"base64 0.22.1",
|
||||
"bcrypt",
|
||||
"bytes",
|
||||
"chrono",
|
||||
@@ -2230,7 +2236,7 @@ version = "0.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "04e9018c9d814e5f30cc16a0f03271aeab3571e609612d9fe78c1aa8d11c2f62"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
@@ -2336,9 +2342,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustls"
|
||||
version = "0.23.42"
|
||||
version = "0.23.43"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138"
|
||||
checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"once_cell",
|
||||
@@ -2362,9 +2368,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustls-pki-types"
|
||||
version = "1.15.0"
|
||||
version = "1.15.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046"
|
||||
checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
|
||||
dependencies = [
|
||||
"web-time",
|
||||
"zeroize",
|
||||
@@ -2504,14 +2510,14 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.0",
|
||||
"syn 3.0.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.150"
|
||||
version = "1.0.151"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
|
||||
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
@@ -2528,7 +2534,7 @@ checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.0",
|
||||
"syn 3.0.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2652,18 +2658,18 @@ checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
|
||||
|
||||
[[package]]
|
||||
name = "snafu"
|
||||
version = "0.9.1"
|
||||
version = "0.9.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d1a012328be2e3f5d5f6f3218147ca02588cea4cb865e876849ab6debcf36522"
|
||||
checksum = "e45cb604038abb7b926b679887b3226d8d0f23874b66623625a0454be425a4b7"
|
||||
dependencies = [
|
||||
"snafu-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "snafu-derive"
|
||||
version = "0.9.1"
|
||||
version = "0.9.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5f103c50866b8743da9429b8a581d81a27c2d3a9c4ac7df8f8571c1dd7896eda"
|
||||
checksum = "287f59010008f0d7cf5e3b03196d666c1acc46c8d3e9cf34c28a1a7157601e72"
|
||||
dependencies = [
|
||||
"heck",
|
||||
"proc-macro2",
|
||||
@@ -2728,9 +2734,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "3.0.0"
|
||||
version = "3.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2fac314a64dc9a36e61a9eb4261a5e9bbfbc922b27e518af97bc32b926cf967"
|
||||
checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -2800,7 +2806,7 @@ checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.0",
|
||||
"syn 3.0.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2830,9 +2836,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
|
||||
|
||||
[[package]]
|
||||
name = "tokio"
|
||||
version = "1.53.0"
|
||||
version = "1.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d988bcd52dbe076d3d46903332f58c912b87a2c49b1428419a5845154762ffee"
|
||||
checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"libc",
|
||||
@@ -2845,13 +2851,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tokio-macros"
|
||||
version = "2.7.1"
|
||||
version = "2.7.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6328af13490e73a9b4694030fafd93f8c8c6a9dede33e821c3fc63eddf8042ba"
|
||||
checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
"syn 3.0.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2866,9 +2872,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tokio-util"
|
||||
version = "0.7.18"
|
||||
version = "0.7.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098"
|
||||
checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"futures-core",
|
||||
@@ -3452,18 +3458,18 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy"
|
||||
version = "0.8.54"
|
||||
version = "0.8.55"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b7cbbc0a705a0fd05cc3676525980d2bf5a9bc4adac6d6475209a7887cf59d19"
|
||||
checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb"
|
||||
dependencies = [
|
||||
"zerocopy-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy-derive"
|
||||
version = "0.8.54"
|
||||
version = "0.8.55"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5"
|
||||
checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@ resolver = "2"
|
||||
members = ["crates/proton-sdk", "crates/proton-drive-rs"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.3.2"
|
||||
version = "0.4.0"
|
||||
edition = "2024"
|
||||
license = "MIT"
|
||||
repository = "https://github.com/narrrl/proton-sdk-rs"
|
||||
|
||||
@@ -270,6 +270,8 @@ Moving a file or folder within the same volume is an offline cryptographic opera
|
||||
| | External Invites | ✅ | Invite non-Proton users (`invite_external_users`) and track registration status. |
|
||||
| | Bookmarks | ✅ | Save and list public link bookmarks (`create_bookmark`, `list_bookmarks`). |
|
||||
| | Incoming Invites | ✅ | List, accept, or reject invitations shared *with* the current user. |
|
||||
| | 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 streaming | ✅ | Seekable range reads over a shared file (`open_revision` → `RevisionReader::read_at`), plus thumbnails and in-place session renewal — a visitor can stream a large file without downloading it. |
|
||||
| **Photos** | Photos Timeline | ✅ | `ProtonPhotosClient` maps photostream, timeline enumeration, and photo downloads. |
|
||||
| | Photo Uploads | ✅ | Uploading photos with `PhotoUploadMetadata` (capture time, tags, grouping). |
|
||||
| | Albums (read) | ✅ | List albums (`enumerate_album_node_uids`) and their photos (`enumerate_album`); album nodes carry `Node::album`. |
|
||||
|
||||
@@ -32,3 +32,4 @@ gitignored, so this file is the only durable record of what we last reviewed.
|
||||
| 2026-07-24 | `1b6ff835` → `e0ad37e3` | `d2ffde20`, `724b6771` | both features were already ported from the TypeScript SDK, so this closed the remaining parity deltas. ported: **shared-by-me on the photos volume** (`SharingOperations.EnumerateSharedNodeUidsAsync` now takes a volume resolver — `page_shared_by_me(volume_id)` + `ProtonPhotosClient::enumerate_shared_node_uids`, empty when the account has no photos volume) and `SharedByMeLinkDto.ContextShareID` (kept `Option` — recent upstream addition, older backends omit it); **per-node move outcomes** (`move_nodes` now returns `Vec<(NodeUid, Result<()>)>` in input order, mirroring the breaking `MoveNodesAsync` result map; per-link envelope codes via the new `aggregate_outcomes`, which `check_aggregate` now sits on). cosmetic, not ported: `AnchorID` retyped `string` → `LinkId` (ours is `String`). diverged deliberately: we keep the batch endpoint `PUT volumes/{vid}/links/move-multiple` — upstream still loops per node behind a `// FIXME: … use the batch move endpoint`; and the drive-side listing keeps the `_by_me` suffix (upstream renamed both to `EnumerateSharedNodeUids`) because `proton-drive-linux` already consumes that name. |
|
||||
| 2026-07-22 | `2219a42f` → `1b6ff835` | `ded1b716`, `071f976f` | ported: **atomic small uploads** for buffered new files and revisions (`DriveSmallFileUpload` is an explicit opt-in because this crate has no remote feature-flag provider), and **photo duplicate detection** using name/content HMAC matching. already covered/no-op: `ee0b3fba` (Rust rename accepts `Option<&str>`), `193c26f1` (Rust in-memory cache already has bounded LRU eviction), `29d359e0` (digest representation refactor retains hex JSON), `5c370289` (C# disposal lifetime only), `95752055` (Photos cache construction only). deliberately not ported: `2bf49aae` / `2590d227` cache storage-format changes; Rust exposes a distinct string repository contract and ships no persistent backend to migrate/reset. |
|
||||
| 2026-07-31 | `e0ad37e3` → `941aafa5` | `42526703`, `f2fa871b`, `814ae781`, `c528a376` | ported: **node-name validation** (`validate_node_name`: empty or >255 chars rejected before create/rename; counted in `char`s — C# counts UTF-16 units, and neither is the server's own accounting); **album read ops** (`AlbumDto` + `enumerate_album_node_uids` / `enumerate_album`, `GET photos/volumes/{vid}/albums` and `…/albums/{lid}/children?Sort=Captured&Desc=1`, both paged on `AnchorID`; `LinkDetailsDto::folder_properties` reads an album's hash key out of the `Album` block, which also makes `parent_hash_key` work for album children); **photo + album node metadata** (`Node::photo` / `Node::album` — capture time, content hash, main/related photos, tags, album inclusions; photo count, cover photo, last activity — added as optional `Node` fields rather than `NodeKind` variants so persisted node blobs keep deserializing, mirroring how `membership` landed); **photo tag updates** (`update_photos`, `POST`/`DELETE photos/volumes/{vid}/links/{lid}/tags` via the new `ApiHttpClient::delete_with_body`, `POST …/favorite` for photos on our own photos volume, one outcome per input update like `move_nodes`); **shared-with-me photos/albums** (`ShareTargetType::is_photos_item` over `v2/sharedwithme` + the dedicated `GET photos/albums/shared-with-me`, whose rows carry their own `VolumeID`). deferred: `03b1cb7f` **favoriting shared photos** — needs `PhotosCrypto` + `PhotoTransferPayloadBuilder` (name/passphrase session keys rewrapped to the timeline root, related-photo fan-out, batches of 20); our `rewrap_message_to` / `recover_message_session_key` make it feasible, but until it lands `update_photos` fails that one update explicitly instead of silently no-oping. not ported: `4c1dd783` (`IsSharedPublicly` → `IsSharedByUrl` rename — `proton-drive-linux` consumes `is_shared_publicly`), `1477ea45` (C# user-facing error strings; our error text is our own), the second half of `42526703` (server-supplied names validated into a per-node `InvalidNameError` — we have no node error list to put it in). no-op: `82d605b7` (C# `FifoFlexibleSemaphore` cancel race — tokio's `Semaphore` is cancel-safe and drops queued waiters), `8bdd9420` (throw on resume-after-dispose; Rust ownership makes it unreachable), `ca11470f` + `c947fce7` (CExports/interop only). Album *writes* (create, add/remove photos) remain unported — upstream has no C# public API for them either. Live validation pending: every photos endpoint here needs a photos-volume account. |
|
||||
| 2026-08-01 | — (no upstream delta) | — | **not an upstream port.** Extended the TypeScript-sourced public-link consume side with a streaming surface that has **no TS or C# counterpart** and is ours: `open_revision` / `open_revision_at` / `download_range` / `download_thumbnail` / `enumerate_thumbnails` / `refresh_session` / `with_max_inflight_blocks` on `ProtonDrivePublicLinkClient`. Enabling change: `RevisionReader` was welded to `ProtonDriveClient`, so it now takes a `RevisionTransport` (new `transport.rs` — block permits + a route-prefixed `ApiHttpClient` + a `BlockSession` for renewal); `fetch_revision_blocks` and the block-size ranking moved there out of `client.rs`. Also `ApiHttpClient::without_token_refresh` (proton-sdk), because the anonymous session's refresh token is empty and the built-in 401 refresh masked the real 401. Perf on the visitor path: LRU node-key cache + iterative ancestor walk behind `SingleFlight` (was re-deriving the whole chain, at an S2K each, per child), `enumerate_nodes` chunked at 150 and fanned out (it did not chunk at all), `download_file_to` paged (a single un-paged GET truncated anything past 50 blocks) and concurrent. Live-verified against the test account, including the one uncertain fact the design rested on: the `drive/unauth/` revision response **does** carry `XAttr`, so a visitor resolves true per-block sizes and can seek. |
|
||||
@@ -14,7 +14,7 @@ readme = "../../README.md"
|
||||
documentation = "https://docs.rs/proton-drive-rs"
|
||||
|
||||
[dependencies]
|
||||
proton-sdk = { path = "../proton-sdk", version = "0.3.2" }
|
||||
proton-sdk = { path = "../proton-sdk", version = "0.4.0" }
|
||||
tokio.workspace = true
|
||||
reqwest.workspace = true
|
||||
serde.workspace = true
|
||||
|
||||
@@ -63,13 +63,13 @@ use crate::dtos::{
|
||||
NodeNameAvailabilityRequest, NodeNameAvailabilityResponse, PhotoTagsRequest,
|
||||
PhotosAttributesDto, RenameLinkRequest, RevisionConflict, RevisionCreationRequest,
|
||||
RevisionCreationResponse, RevisionDto, RevisionListItemDto, RevisionListResponse,
|
||||
RevisionMetadataResponse, RevisionResponse, RevisionUpdateRequest, ShareInvitationDto,
|
||||
ShareInvitationsResponse, ShareMembersResponse, ShareMembershipSummaryDto, ShareResponse,
|
||||
ShareTargetType, ShareUrlDto, ShareUrlsResponse, SharedAlbumsResponse, SharedByMeResponse,
|
||||
SharedWithMeResponse, SmallFileUploadMetadataRequest, SmallRevisionUploadMetadataRequest,
|
||||
SmallUploadResponse, ThumbnailBlockListRequest, ThumbnailBlockListResponse,
|
||||
ThumbnailCreationRequest, ThumbnailDto, TimelinePhotoListResponse, UpdatePermissionsRequest,
|
||||
VolumeCreationRequest, VolumeEventDto, VolumeEventListResponse, VolumeTrashResponse,
|
||||
RevisionMetadataResponse, RevisionUpdateRequest, ShareInvitationDto, ShareInvitationsResponse,
|
||||
ShareMembersResponse, ShareMembershipSummaryDto, ShareResponse, ShareTargetType, ShareUrlDto,
|
||||
ShareUrlsResponse, SharedAlbumsResponse, SharedByMeResponse, SharedWithMeResponse,
|
||||
SmallFileUploadMetadataRequest, SmallRevisionUploadMetadataRequest, SmallUploadResponse,
|
||||
ThumbnailBlockListRequest, ThumbnailBlockListResponse, ThumbnailCreationRequest, ThumbnailDto,
|
||||
TimelinePhotoListResponse, UpdatePermissionsRequest, VolumeCreationRequest, VolumeEventDto,
|
||||
VolumeEventListResponse, VolumeTrashResponse,
|
||||
};
|
||||
use crate::events::{DriveEvent, DriveEventScopeId};
|
||||
use crate::node::{
|
||||
@@ -87,19 +87,21 @@ use crate::sharing::{
|
||||
PublicLink, ShareInvitation, ShareMember, ShareMembership, SharedWithMeItem,
|
||||
};
|
||||
use crate::single_flight::SingleFlight;
|
||||
|
||||
/// Content blocks are 4 MiB of plaintext each (C# `RevisionWriter.DefaultBlockSize`).
|
||||
const DEFAULT_BLOCK_SIZE: usize = 1 << 22;
|
||||
use crate::transport::{DEFAULT_BLOCK_SIZE, RevisionTransport, rank_block_sizes};
|
||||
|
||||
/// Maximum encrypted multipart size accepted by the atomic upload endpoint.
|
||||
const SMALL_UPLOAD_SIZE_LIMIT: usize = 128 * 1024;
|
||||
|
||||
/// Maximum links per batch trash/restore/delete request (C#
|
||||
/// `NodeOperations.MaximumBatchCount`).
|
||||
const MAX_BATCH_COUNT: usize = 150;
|
||||
pub(crate) const MAX_BATCH_COUNT: usize = 150;
|
||||
|
||||
/// Thumbnail block ids resolved per `POST volumes/{vid}/thumbnails` request
|
||||
/// (C# `FileOperations.MaxThumbnailIdsPerRequest`).
|
||||
pub(crate) const MAX_THUMBNAIL_IDS_PER_REQUEST: usize = 30;
|
||||
|
||||
/// Link-detail batches fetched at once, per volume, when enumerating nodes.
|
||||
const MAX_CONCURRENT_DETAIL_FETCHES: usize = 4;
|
||||
pub(crate) const MAX_CONCURRENT_DETAIL_FETCHES: usize = 4;
|
||||
|
||||
/// Nodes decrypted at once within one batch of link details.
|
||||
///
|
||||
@@ -121,7 +123,7 @@ const MAX_CONCURRENT_NODE_BUILDS: usize = 8;
|
||||
/// Sized a little above one file's pipeline (an upload holds up to
|
||||
/// `MAX_BUFFERED_UPLOAD_BLOCKS + MAX_CONCURRENT_BLOCK_UPLOADS + 1` permits) so a
|
||||
/// lone transfer still saturates it and a second one is not starved.
|
||||
const DEFAULT_MAX_INFLIGHT_BLOCKS: usize = 16;
|
||||
pub(crate) const DEFAULT_MAX_INFLIGHT_BLOCKS: usize = 16;
|
||||
|
||||
/// Content blocks uploaded concurrently within a single file.
|
||||
///
|
||||
@@ -515,13 +517,6 @@ impl ProtonDriveClient {
|
||||
}
|
||||
|
||||
/// The account client backing this Drive client's key chain.
|
||||
/// The underlying HTTP client, for sibling modules that issue their own
|
||||
/// requests against the same session (e.g. [`RevisionReader`] fetching
|
||||
/// block bodies from storage).
|
||||
pub(crate) fn http(&self) -> &ApiHttpClient {
|
||||
&self.http
|
||||
}
|
||||
|
||||
pub fn account(&self) -> &AccountClient {
|
||||
&self.account
|
||||
}
|
||||
@@ -587,6 +582,16 @@ impl ProtonDriveClient {
|
||||
self.block_slots.clone()
|
||||
}
|
||||
|
||||
/// What a [`RevisionReader`] needs from this client, and nothing else.
|
||||
///
|
||||
/// The session is `Static` because an ordinary bearer session refreshes its
|
||||
/// own tokens inside [`ApiHttpClient`] on a 401; by the time an error
|
||||
/// reaches the transport there is nothing left to retry. The visitor path
|
||||
/// supplies a session that can genuinely be renewed.
|
||||
pub(crate) fn revision_transport(&self) -> RevisionTransport {
|
||||
RevisionTransport::authenticated(self.http.clone(), self.block_slots())
|
||||
}
|
||||
|
||||
/// Resolve (and cache) the user's "My Files" root folder.
|
||||
pub async fn get_my_files_folder(&self) -> Result<Node> {
|
||||
self.ensure_my_files().await?;
|
||||
@@ -1361,7 +1366,7 @@ impl ProtonDriveClient {
|
||||
|
||||
timer.success();
|
||||
Ok(RevisionReader::new(
|
||||
self.clone(),
|
||||
self.revision_transport(),
|
||||
uid.clone(),
|
||||
revision_id,
|
||||
content_key,
|
||||
@@ -1639,26 +1644,11 @@ impl ProtonDriveClient {
|
||||
|
||||
/// Plaintext size of each content block, in block order.
|
||||
///
|
||||
/// These sizes are not merely descriptive: [`RevisionReader`] accumulates
|
||||
/// them to derive where each block *starts* in the plaintext. A vector that
|
||||
/// overstates any block but the last therefore shifts every block after it,
|
||||
/// and the reader splices those blocks at the wrong offsets — returning
|
||||
/// full-length reads of the wrong bytes, with no error to notice. See
|
||||
/// `revision.rs::padded_block_sizes_shift_later_blocks`, which pins that
|
||||
/// consequence.
|
||||
///
|
||||
/// So the sources are ranked by how much they can be trusted, and the
|
||||
/// function refuses rather than guesses when none of them holds:
|
||||
///
|
||||
/// 1. `Common.BlockSizes` from the revision's extended attributes — the
|
||||
/// authoritative value, written by the uploading client.
|
||||
/// 2. `Common.Size` — the total, from which all-but-last full blocks and a
|
||||
/// final short block follow by subtraction. Sound for the standard
|
||||
/// layout the uploader produces.
|
||||
/// 3. Nothing usable. A single-block file cannot be misaligned (there is no
|
||||
/// later block to shift), so it is assumed full-size and only its
|
||||
/// reported length can be wrong. A multi-block file is an error: serving
|
||||
/// it would mean serving misplaced bytes.
|
||||
/// Decrypts the revision's extended attributes — *verified*, which is what
|
||||
/// needs an account client and is therefore why this lives here rather than
|
||||
/// in `transport` — and hands them to
|
||||
/// [`rank_block_sizes`](crate::transport::rank_block_sizes), which owns the
|
||||
/// ranking rules and the refusal.
|
||||
async fn resolve_block_sizes(
|
||||
&self,
|
||||
node_key: &PrivateKey,
|
||||
@@ -1687,40 +1677,7 @@ impl ProtonDriveClient {
|
||||
None => None,
|
||||
};
|
||||
|
||||
let block = DEFAULT_BLOCK_SIZE as u64;
|
||||
|
||||
if let Some(sizes) = common.as_ref().and_then(|c| c.block_sizes.as_ref())
|
||||
&& sizes.len() == block_count
|
||||
{
|
||||
// A non-positive entry is as corrupting as a padded one — a
|
||||
// zero-length block in the middle shifts everything after it — so a
|
||||
// malformed vector is rejected outright rather than clamped.
|
||||
if let Some(bad) = sizes.iter().position(|&n| n <= 0) {
|
||||
return Err(ProtonError::invalid_operation(format!(
|
||||
"revision {} records a non-positive size for block {bad}",
|
||||
revision.id
|
||||
)));
|
||||
}
|
||||
return Ok(sizes.iter().map(|&n| n as u64).collect());
|
||||
}
|
||||
|
||||
if let Some(total) = common.and_then(|c| c.size).filter(|&n| n >= 0) {
|
||||
let total = total as u64;
|
||||
return Ok((0..block_count)
|
||||
.map(|i| total.saturating_sub(block * i as u64).min(block))
|
||||
.collect());
|
||||
}
|
||||
|
||||
if block_count <= 1 {
|
||||
return Ok(vec![block; block_count]);
|
||||
}
|
||||
|
||||
Err(ProtonError::invalid_operation(format!(
|
||||
"revision {} has {block_count} blocks but no usable size information \
|
||||
(no BlockSizes, no Size); refusing to guess, since a wrong block \
|
||||
layout would serve misaligned content",
|
||||
revision.id
|
||||
)))
|
||||
rank_block_sizes(common.as_ref(), &revision.id, block_count)
|
||||
}
|
||||
|
||||
/// Download and decrypt a file's thumbnail of the given type, if it has one.
|
||||
@@ -1814,8 +1771,6 @@ impl ProtonDriveClient {
|
||||
thumbnail_type: ThumbnailType,
|
||||
for_photos: bool,
|
||||
) -> Result<Vec<FileThumbnail>> {
|
||||
const MAX_THUMBNAIL_IDS_PER_REQUEST: usize = 30;
|
||||
|
||||
let mut results: Vec<FileThumbnail> = Vec::new();
|
||||
|
||||
// Group link ids by volume, preserving first-seen volume order.
|
||||
@@ -5087,50 +5042,21 @@ impl ProtonDriveClient {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The revision's metadata and its full, ordered block table.
|
||||
///
|
||||
/// The paging, ordering and contiguity rules are identical on the visitor
|
||||
/// path, so they live in
|
||||
/// [`RevisionTransport::list_blocks`](crate::transport::RevisionTransport)
|
||||
/// and this is the authenticated entry point to them.
|
||||
pub(crate) async fn fetch_revision_blocks(
|
||||
&self,
|
||||
volume_id: &VolumeId,
|
||||
link_id: &LinkId,
|
||||
revision_id: &str,
|
||||
) -> Result<(RevisionDto, Vec<BlockDto>)> {
|
||||
const PAGE_SIZE: i32 = 50;
|
||||
|
||||
let mut blocks: Vec<BlockDto> = Vec::new();
|
||||
let mut metadata: Option<RevisionDto> = None;
|
||||
let mut from_index: i32 = 1;
|
||||
|
||||
loop {
|
||||
let path = format!(
|
||||
"v2/volumes/{volume_id}/files/{link_id}/revisions/{revision_id}?FromBlockIndex={from_index}&PageSize={PAGE_SIZE}&NoBlockUrls=0"
|
||||
);
|
||||
let response: RevisionResponse = self.http.get(&path).await?;
|
||||
let mut revision = response.revision;
|
||||
let page = std::mem::take(&mut revision.blocks);
|
||||
let page_len = page.len();
|
||||
|
||||
if metadata.is_none() {
|
||||
metadata = Some(revision);
|
||||
}
|
||||
blocks.extend(page);
|
||||
|
||||
if page_len < PAGE_SIZE as usize {
|
||||
break;
|
||||
}
|
||||
from_index = blocks.iter().map(|b| b.index).max().unwrap_or(from_index) + 1;
|
||||
}
|
||||
|
||||
blocks.sort_by_key(|b| b.index);
|
||||
for (offset, block) in blocks.iter().enumerate() {
|
||||
if block.index != offset as i32 + 1 {
|
||||
return Err(ProtonError::invalid_operation(
|
||||
"file contents are incomplete (non-contiguous blocks)",
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let metadata = metadata
|
||||
.ok_or_else(|| ProtonError::invalid_operation("revision returned no metadata"))?;
|
||||
Ok((metadata, blocks))
|
||||
self.revision_transport()
|
||||
.list_blocks(volume_id, link_id, revision_id)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn ensure_my_files(&self) -> Result<()> {
|
||||
|
||||
@@ -249,19 +249,34 @@ pub async fn decrypt_extended_attributes_verified(
|
||||
Ok((attrs, status))
|
||||
}
|
||||
|
||||
/// Decrypt a revision's extended attributes without verifying their signature.
|
||||
///
|
||||
/// For callers that have the node key but no [`AccountClient`] to resolve the
|
||||
/// author's public key through — namely an anonymous public-link visitor, who
|
||||
/// has no address book and cannot look up a signer at all.
|
||||
///
|
||||
/// Skipping verification loses nothing a visitor could otherwise have used:
|
||||
/// [`decrypt_extended_attributes_verified`] returns the status as *metadata* and
|
||||
/// callers do not gate on it (see the SDK's rule that a failed verification is
|
||||
/// never an error). Decryption still proves the payload was encrypted to this
|
||||
/// node key.
|
||||
pub(crate) fn decrypt_extended_attributes_unverified(
|
||||
node_key: &PrivateKey,
|
||||
armored_xattr: &str,
|
||||
) -> Result<DecryptedExtendedAttributes> {
|
||||
let json = node_key.decrypt_armored_message(armored_xattr)?;
|
||||
serde_json::from_slice(&json).map_err(|e| {
|
||||
ProtonError::invalid_operation(format!("deserialize extended attributes: {e}"))
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use proton_sdk::crypto::generate_node_key;
|
||||
|
||||
/// Decrypt + deserialize XAttr without verification (the production path
|
||||
/// uses [`decrypt_extended_attributes_verified`], which needs an account to
|
||||
/// resolve author keys).
|
||||
fn decrypt_xattr(node_key: &PrivateKey, armored: &str) -> DecryptedExtendedAttributes {
|
||||
let json = node_key
|
||||
.decrypt_armored_message(armored)
|
||||
.expect("decrypt xattr");
|
||||
serde_json::from_slice(&json).expect("deserialize xattr")
|
||||
decrypt_extended_attributes_unverified(node_key, armored).expect("decrypt xattr")
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -45,6 +45,7 @@ mod public_link;
|
||||
mod revision;
|
||||
mod sharing;
|
||||
mod single_flight;
|
||||
mod transport;
|
||||
|
||||
pub use cache::{CachedNodeInfo, DriveEntityCache};
|
||||
pub use client::ProtonDriveClient;
|
||||
@@ -55,7 +56,8 @@ pub use event_manager::{
|
||||
};
|
||||
pub use events::{DriveEvent, DriveEventScopeId};
|
||||
pub use node::{
|
||||
AlbumProperties, Node, NodeKind, PhotoProperties, RevisionState, Thumbnail, ThumbnailType,
|
||||
AlbumProperties, FileThumbnail, Node, NodeKind, PhotoProperties, RevisionState, Thumbnail,
|
||||
ThumbnailType,
|
||||
};
|
||||
pub use photos::{
|
||||
AlbumItem, PhotoTag, PhotoTagsUpdate, PhotoUploadMetadata, PhotosTimelineItem,
|
||||
|
||||
@@ -29,9 +29,15 @@
|
||||
//! claimed, rather than a verification that silently passed.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::num::NonZeroUsize;
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use base64::Engine;
|
||||
use base64::engine::general_purpose::STANDARD as BASE64;
|
||||
use futures::stream::{self, StreamExt, TryStreamExt};
|
||||
use lru::LruCache;
|
||||
use tokio::sync::Semaphore;
|
||||
|
||||
use proton_sdk::config::ProtonClientConfiguration;
|
||||
use proton_sdk::crypto::{
|
||||
@@ -42,15 +48,21 @@ use proton_sdk::error::{ProtonError, Result};
|
||||
use proton_sdk::http::{ApiHttpClient, Tokens, get_unauthenticated};
|
||||
use proton_sdk::ids::{LinkId, NodeUid, SessionId, VolumeId};
|
||||
|
||||
use crate::client::parse_public_link_url;
|
||||
use crate::crypto::decrypt_link;
|
||||
use crate::client::{
|
||||
DEFAULT_MAX_INFLIGHT_BLOCKS, MAX_BATCH_COUNT, MAX_CONCURRENT_DETAIL_FETCHES,
|
||||
MAX_THUMBNAIL_IDS_PER_REQUEST, parse_public_link_url,
|
||||
};
|
||||
use crate::crypto::{decrypt_extended_attributes_unverified, decrypt_link};
|
||||
use crate::dtos::{
|
||||
FileDto, FolderChildrenResponse, LinkDetailsDto, LinkDetailsRequest, LinkDetailsResponse,
|
||||
LinkType, PublicLinkAuthRequest, PublicLinkAuthResponse, PublicLinkInfoResponse,
|
||||
RevisionResponse,
|
||||
ThumbnailBlockListRequest, ThumbnailBlockListResponse,
|
||||
};
|
||||
use crate::node::{Node, NodeKind, RevisionState};
|
||||
use crate::node::{FileThumbnail, Node, NodeKind, RevisionState, ThumbnailType};
|
||||
use crate::revision::{MAX_CONCURRENT_BLOCK_DOWNLOADS, RevisionReader, decrypt_block_blocking};
|
||||
use crate::sharing::MemberRole;
|
||||
use crate::single_flight::SingleFlight;
|
||||
use crate::transport::{BlockSession, RevisionTransport, rank_block_sizes};
|
||||
|
||||
/// Link metadata readable before authenticating, from `GET urls/{token}/info`.
|
||||
///
|
||||
@@ -69,11 +81,13 @@ pub struct PublicLinkInfo {
|
||||
pub vendor_type: i32,
|
||||
}
|
||||
|
||||
/// An authenticated, anonymous session against one public link.
|
||||
/// Everything needed to run the handshake, and nothing that a completed
|
||||
/// handshake produced.
|
||||
///
|
||||
/// Mirrors TS `SharingPublicLinkSession`. Holds the link password so an expired
|
||||
/// session can be re-established by replaying the handshake.
|
||||
struct PublicLinkSession {
|
||||
/// Split out from [`PublicLinkSession`] so a session can be *constructed* around
|
||||
/// an already-authenticated client while still being able to replay the
|
||||
/// handshake later — the credentials outlive any particular session.
|
||||
struct Credentials {
|
||||
config: ProtonClientConfiguration,
|
||||
token: String,
|
||||
/// URL fragment password concatenated with any custom password — the TS SDK
|
||||
@@ -89,7 +103,7 @@ struct AuthOutcome {
|
||||
public_role: MemberRole,
|
||||
}
|
||||
|
||||
impl PublicLinkSession {
|
||||
impl Credentials {
|
||||
async fn info(&self) -> Result<PublicLinkInfoResponse> {
|
||||
get_unauthenticated(&self.config, &format!("drive/urls/{}/info", self.token)).await
|
||||
}
|
||||
@@ -159,7 +173,12 @@ impl PublicLinkSession {
|
||||
// scope. Sending them to `drive/` returns 403 `MissingScopes`.
|
||||
// The session routes (`drive/urls/{token}/…`) are exempt — they run
|
||||
// unauthenticated, before this client exists.
|
||||
.with_base_route("drive/unauth/");
|
||||
.with_base_route("drive/unauth/")
|
||||
// The refresh token above is empty, so `ApiHttpClient`'s built-in 401
|
||||
// handling would POST it to `auth/v4/refresh` and replace the real 401
|
||||
// with an error about refreshing. Opt out, and let the 401 reach
|
||||
// `RevisionTransport`, which recovers by replaying *this* handshake.
|
||||
.without_token_refresh();
|
||||
|
||||
Ok(AuthOutcome {
|
||||
http,
|
||||
@@ -190,19 +209,133 @@ impl PublicLinkSession {
|
||||
}
|
||||
}
|
||||
|
||||
/// The client a public-link session is currently issuing requests through,
|
||||
/// tagged with the handshake that produced it.
|
||||
struct LiveSession {
|
||||
http: ApiHttpClient,
|
||||
generation: u64,
|
||||
}
|
||||
|
||||
/// An authenticated, anonymous session against one public link.
|
||||
///
|
||||
/// Mirrors TS `SharingPublicLinkSession`. Holds the link password so an expired
|
||||
/// session can be re-established by replaying the handshake — which matters more
|
||||
/// here than anywhere else in the SDK, because the anonymous session is minted
|
||||
/// with an **empty refresh token** and so cannot renew itself the ordinary way.
|
||||
/// A long read (a film, say) will outlive it.
|
||||
///
|
||||
/// The live client is swapped behind a lock rather than handed back to the
|
||||
/// caller as a new object, so every [`RevisionReader`](crate::RevisionReader)
|
||||
/// already opened over this session keeps working across the swap. Swapping the
|
||||
/// *whole* client is required, not just its tokens: a re-auth mints a new
|
||||
/// `x-pm-uid` as well as a new bearer, and an [`ApiHttpClient`] binds one
|
||||
/// session id for its lifetime.
|
||||
struct PublicLinkSession {
|
||||
credentials: Credentials,
|
||||
/// Read on every request, written only by a handshake. A `std` lock because
|
||||
/// [`BlockSession::http`] is synchronous; it is never held across an await,
|
||||
/// and never poisoned in practice, so poisoning is recovered from rather
|
||||
/// than propagated.
|
||||
live: std::sync::RwLock<LiveSession>,
|
||||
/// Serializes handshakes, so a burst of expired-session failures replays one
|
||||
/// SRP exchange rather than one per caller.
|
||||
renewing: tokio::sync::Mutex<()>,
|
||||
}
|
||||
|
||||
impl PublicLinkSession {
|
||||
/// Authenticate and wrap the result in a renewable session.
|
||||
///
|
||||
/// Returns the outcome alongside the session because the share key, root uid
|
||||
/// and role belong to the *link*, not to the session, and the client keeps
|
||||
/// them directly.
|
||||
async fn open(credentials: Credentials) -> Result<(Arc<Self>, AuthOutcome)> {
|
||||
let auth = credentials.auth().await?;
|
||||
let session = Arc::new(Self {
|
||||
credentials,
|
||||
live: std::sync::RwLock::new(LiveSession {
|
||||
http: auth.http.clone(),
|
||||
generation: 0,
|
||||
}),
|
||||
renewing: tokio::sync::Mutex::new(()),
|
||||
});
|
||||
Ok((session, auth))
|
||||
}
|
||||
|
||||
fn read_live<T>(&self, f: impl FnOnce(&LiveSession) -> T) -> T {
|
||||
let live = self
|
||||
.live
|
||||
.read()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
f(&live)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl BlockSession for PublicLinkSession {
|
||||
fn http(&self) -> (ApiHttpClient, u64) {
|
||||
self.read_live(|live| (live.http.clone(), live.generation))
|
||||
}
|
||||
|
||||
async fn renew(&self, seen: u64) -> Result<()> {
|
||||
let _guard = self.renewing.lock().await;
|
||||
|
||||
if self.read_live(|live| live.generation) != seen {
|
||||
// Someone else re-authenticated while we waited; their session is at
|
||||
// least as fresh as one we would mint now.
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let auth = self.credentials.auth().await?;
|
||||
|
||||
let mut live = self
|
||||
.live
|
||||
.write()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
live.http = auth.http;
|
||||
live.generation += 1;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Read access to one public share link, as a visitor.
|
||||
///
|
||||
/// Obtained from [`open`](Self::open). The client holds an anonymous session and
|
||||
/// the link's share key; every operation is scoped to the shared subtree.
|
||||
///
|
||||
/// Cloning is cheap and shares the session, so a clone recovers from an expiry
|
||||
/// the original recovered from, and vice versa.
|
||||
#[derive(Clone)]
|
||||
pub struct ProtonDrivePublicLinkClient {
|
||||
session: PublicLinkSession,
|
||||
http: ApiHttpClient,
|
||||
session: Arc<PublicLinkSession>,
|
||||
/// Every request goes through here rather than through a bare
|
||||
/// [`ApiHttpClient`], so an expired anonymous session is replayed once and
|
||||
/// retried instead of surfacing as a 401.
|
||||
transport: RevisionTransport,
|
||||
/// The link's share key — the parent key of the shared root node.
|
||||
share_key: PrivateKey,
|
||||
root_uid: NodeUid,
|
||||
public_role: MemberRole,
|
||||
/// Unlocked node keys, so a folder's children do not each re-derive their
|
||||
/// shared parent's key.
|
||||
///
|
||||
/// Unlocking a key is an S2K derivation — tens of milliseconds. Listing a
|
||||
/// folder of 500 episodes walked the ancestor chain and re-unlocked every
|
||||
/// key on it *per child*, which is the difference between a catalog crawl
|
||||
/// taking seconds and taking minutes.
|
||||
node_keys: Arc<std::sync::Mutex<LruCache<NodeUid, PrivateKey>>>,
|
||||
/// Collapses concurrent misses for the same key onto one load, so the
|
||||
/// fanned-out detail fetches below do not each start the same ancestor walk
|
||||
/// before any of them has populated the cache.
|
||||
key_loads: Arc<SingleFlight<NodeUid, PrivateKey>>,
|
||||
}
|
||||
|
||||
/// Unlocked node keys held per client.
|
||||
///
|
||||
/// A shared subtree's *distinct* keys are its folders, so this only has to
|
||||
/// outlive a crawl of one directory's worth of ancestors; it is a bound on
|
||||
/// pathological trees, not a working-set estimate.
|
||||
const NODE_KEY_CACHE_CAPACITY: usize = 512;
|
||||
|
||||
impl ProtonDrivePublicLinkClient {
|
||||
/// Read a link's public metadata without opening it.
|
||||
///
|
||||
@@ -211,12 +344,12 @@ impl ProtonDrivePublicLinkClient {
|
||||
/// its `#password` fragment.
|
||||
pub async fn info(config: ProtonClientConfiguration, url: &str) -> Result<PublicLinkInfo> {
|
||||
let (token, password) = parse_public_link_url(url)?;
|
||||
let session = PublicLinkSession {
|
||||
let credentials = Credentials {
|
||||
config,
|
||||
token,
|
||||
password,
|
||||
};
|
||||
let raw = session.info().await?;
|
||||
let raw = credentials.info().await?;
|
||||
Ok(PublicLinkInfo {
|
||||
is_custom_password_protected: raw.is_custom_password_protected(),
|
||||
is_legacy: raw.is_legacy(),
|
||||
@@ -242,22 +375,50 @@ impl ProtonDrivePublicLinkClient {
|
||||
_ => url_password,
|
||||
};
|
||||
|
||||
let session = PublicLinkSession {
|
||||
let (session, auth) = PublicLinkSession::open(Credentials {
|
||||
config,
|
||||
token,
|
||||
password,
|
||||
};
|
||||
let auth = session.auth().await?;
|
||||
})
|
||||
.await?;
|
||||
|
||||
let transport = RevisionTransport::new(
|
||||
session.clone(),
|
||||
Arc::new(Semaphore::new(DEFAULT_MAX_INFLIGHT_BLOCKS)),
|
||||
);
|
||||
|
||||
Ok(Self {
|
||||
session,
|
||||
http: auth.http,
|
||||
transport,
|
||||
share_key: auth.share_key,
|
||||
root_uid: auth.root_uid,
|
||||
public_role: auth.public_role,
|
||||
node_keys: Arc::new(std::sync::Mutex::new(LruCache::new(
|
||||
NonZeroUsize::new(NODE_KEY_CACHE_CAPACITY).expect("capacity is non-zero"),
|
||||
))),
|
||||
key_loads: Arc::new(SingleFlight::default()),
|
||||
})
|
||||
}
|
||||
|
||||
/// Cap the content blocks this client keeps in memory at once, across every
|
||||
/// read it is running.
|
||||
///
|
||||
/// Mirrors
|
||||
/// [`ProtonDriveClient::with_max_inflight_blocks`](crate::ProtonDriveClient::with_max_inflight_blocks).
|
||||
/// The default is sized for a background sync; a player that seeks wants it
|
||||
/// higher. Must be non-zero; a zero cap would deadlock every read, so it is
|
||||
/// clamped to 1.
|
||||
///
|
||||
/// Applies to this client and every clone made from it *afterwards*; clones
|
||||
/// already taken keep the previous cap.
|
||||
pub fn with_max_inflight_blocks(mut self, blocks: usize) -> Self {
|
||||
self.transport = RevisionTransport::new(
|
||||
self.session.clone(),
|
||||
Arc::new(Semaphore::new(blocks.max(1))),
|
||||
);
|
||||
self
|
||||
}
|
||||
|
||||
/// The role the link grants its visitors — [`MemberRole::Viewer`] for a
|
||||
/// read-only link, [`MemberRole::Editor`] for one that permits uploads.
|
||||
pub fn public_role(&self) -> MemberRole {
|
||||
@@ -310,7 +471,7 @@ impl ProtonDrivePublicLinkClient {
|
||||
path.push_str(&format!("?AnchorID={anchor_id}"));
|
||||
}
|
||||
|
||||
let page: FolderChildrenResponse = self.http.get(&path).await?;
|
||||
let page: FolderChildrenResponse = self.transport.get(&path).await?;
|
||||
if page.link_ids.is_empty() {
|
||||
break;
|
||||
}
|
||||
@@ -334,6 +495,10 @@ impl ProtonDrivePublicLinkClient {
|
||||
/// A node that does not exist is omitted; one that fails to decrypt is
|
||||
/// logged and skipped, so the result may be shorter than `uids` — the same
|
||||
/// partial-listing behavior as the authenticated client.
|
||||
///
|
||||
/// Link ids are batched at [`MAX_BATCH_COUNT`] per request — the server's
|
||||
/// limit, which an unchunked call silently exceeded on any folder past 150
|
||||
/// children — and the batches are fetched several at a time.
|
||||
pub async fn enumerate_nodes(&self, uids: &[NodeUid]) -> Result<Vec<Node>> {
|
||||
if uids.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
@@ -341,28 +506,39 @@ impl ProtonDrivePublicLinkClient {
|
||||
|
||||
let mut nodes = Vec::with_capacity(uids.len());
|
||||
for (volume_id, link_ids) in group_by_volume(uids) {
|
||||
let response: LinkDetailsResponse = self
|
||||
.http
|
||||
.post(
|
||||
&format!("v2/volumes/{volume_id}/links"),
|
||||
&LinkDetailsRequest {
|
||||
link_ids: &link_ids,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
// The closure takes its batch *by value*. Taking `&[LinkId]` — which
|
||||
// `chunks()` hands out directly — would give the fetch future a
|
||||
// higher-ranked lifetime that `tokio::spawn` rejects in callers
|
||||
// ("implementation of `FnOnce` is not general enough"), an error that
|
||||
// surfaces only downstream. `tests/spawnable.rs` pins it.
|
||||
let chunks: Vec<Vec<LinkId>> = link_ids
|
||||
.chunks(MAX_BATCH_COUNT)
|
||||
.map(<[LinkId]>::to_vec)
|
||||
.collect();
|
||||
let batches = chunks.into_iter().map(|link_ids| {
|
||||
let client = self.clone();
|
||||
let volume_id = volume_id.clone();
|
||||
async move { client.fetch_link_details(&volume_id, &link_ids).await }
|
||||
});
|
||||
|
||||
for details in &response.links {
|
||||
let parent_key = match self.resolve_parent_key(&volume_id, details).await {
|
||||
Ok(key) => key,
|
||||
Err(e) => {
|
||||
tracing::warn!(link_id = %details.link.id, "skipping node: {e}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
match self.build_node(&volume_id, details, &parent_key) {
|
||||
Ok(node) => nodes.push(node),
|
||||
Err(e) => {
|
||||
tracing::warn!(link_id = %details.link.id, "skipping node: {e}")
|
||||
// `buffered` yields in request order, so a listing keeps the order
|
||||
// its uids were given in.
|
||||
let mut responses = stream::iter(batches).buffered(MAX_CONCURRENT_DETAIL_FETCHES);
|
||||
|
||||
while let Some(response) = responses.try_next().await? {
|
||||
for details in &response.links {
|
||||
let parent_key = match self.resolve_parent_key(&volume_id, details).await {
|
||||
Ok(key) => key,
|
||||
Err(e) => {
|
||||
tracing::warn!(link_id = %details.link.id, "skipping node: {e}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
match self.build_node(&volume_id, details, &parent_key) {
|
||||
Ok(node) => nodes.push(node),
|
||||
Err(e) => {
|
||||
tracing::warn!(link_id = %details.link.id, "skipping node: {e}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -371,6 +547,21 @@ impl ProtonDrivePublicLinkClient {
|
||||
Ok(nodes)
|
||||
}
|
||||
|
||||
/// One batch of link details. Callers must keep `link_ids` within
|
||||
/// [`MAX_BATCH_COUNT`].
|
||||
async fn fetch_link_details(
|
||||
&self,
|
||||
volume_id: &VolumeId,
|
||||
link_ids: &[LinkId],
|
||||
) -> Result<LinkDetailsResponse> {
|
||||
self.transport
|
||||
.post(
|
||||
&format!("v2/volumes/{volume_id}/links"),
|
||||
&LinkDetailsRequest { link_ids },
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Download a shared file's active revision.
|
||||
///
|
||||
/// Unlike the authenticated client's
|
||||
@@ -404,24 +595,35 @@ impl ProtonDrivePublicLinkClient {
|
||||
let decrypted = decrypt_link(&parent_key, &details.link)?;
|
||||
let content_key = self.content_key(&details, &decrypted.node_key)?;
|
||||
|
||||
let response: RevisionResponse = self
|
||||
.http
|
||||
.get(&format!(
|
||||
"v2/volumes/{}/files/{}/revisions/{}",
|
||||
uid.volume_id, uid.link_id, revision_id
|
||||
))
|
||||
// Paged: a single un-paged GET returns only the first 50 blocks, which
|
||||
// silently truncated any file past 200 MiB.
|
||||
let (_, blocks) = self
|
||||
.transport
|
||||
.list_blocks(&uid.volume_id, &uid.link_id, &revision_id)
|
||||
.await?;
|
||||
|
||||
// Blocks arrive in index order; decrypt each in turn straight into the
|
||||
// writer so a large file never has to be resident in full.
|
||||
let mut blocks = response.revision.blocks;
|
||||
blocks.sort_by_key(|block| block.index);
|
||||
for block in blocks {
|
||||
let ciphertext = self
|
||||
.http
|
||||
.get_storage_blob(&block.bare_url, &block.token)
|
||||
.await?;
|
||||
let plaintext = content_key.decrypt_block(&ciphertext)?;
|
||||
// Fetched and decrypted several at a time but yielded in block order, so
|
||||
// the writer still sees the file sequentially and no more than
|
||||
// `MAX_CONCURRENT_BLOCK_DOWNLOADS` blocks are ever resident.
|
||||
let mut plaintexts = stream::iter(blocks.into_iter().map(|block| {
|
||||
let transport = self.transport.clone();
|
||||
let content_key = content_key.clone();
|
||||
async move {
|
||||
let permit = transport.block_slots().acquire_owned().await.map_err(|e| {
|
||||
ProtonError::invalid_operation(format!("block slots closed: {e}"))
|
||||
})?;
|
||||
let ciphertext = transport
|
||||
.http()
|
||||
.get_storage_blob(&block.bare_url, &block.token)
|
||||
.await?;
|
||||
let plaintext = decrypt_block_blocking(content_key, ciphertext).await?;
|
||||
Ok::<_, ProtonError>((plaintext, permit))
|
||||
}
|
||||
}))
|
||||
.buffered(MAX_CONCURRENT_BLOCK_DOWNLOADS);
|
||||
|
||||
// `_permit` frees once the block is written, not when it is decrypted.
|
||||
while let Some((plaintext, _permit)) = plaintexts.try_next().await? {
|
||||
writer
|
||||
.write_all(&plaintext)
|
||||
.map_err(|e| ProtonError::invalid_operation(format!("write block: {e}")))?;
|
||||
@@ -430,32 +632,347 @@ impl ProtonDrivePublicLinkClient {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Download and decrypt only the plaintext byte range
|
||||
/// `[offset, offset + length)` of a shared file's active revision.
|
||||
///
|
||||
/// A one-shot convenience over [`open_revision`](Self::open_revision), which
|
||||
/// callers issuing more than one read should hold instead — this repeats the
|
||||
/// whole resolution (link details, ancestor walk, node-key unlock, revision
|
||||
/// listing) every time.
|
||||
pub async fn download_range(&self, uid: &NodeUid, offset: u64, length: u64) -> Result<Vec<u8>> {
|
||||
self.open_revision(uid).await?.read_at(offset, length).await
|
||||
}
|
||||
|
||||
/// Open the active revision of a shared file for seekable reads.
|
||||
///
|
||||
/// This is what makes a public link streamable: every content block is a
|
||||
/// self-contained PGP packet under one session key, so a
|
||||
/// [`RevisionReader`] can serve any byte range by fetching only the blocks
|
||||
/// that overlap it, several at a time — no whole-file download, no
|
||||
/// sequential decrypt.
|
||||
///
|
||||
/// The reader holds this client's session and renews it on expiry, so it
|
||||
/// keeps working past the anonymous session's lifetime.
|
||||
///
|
||||
/// The reader is pinned to the revision that is active now; it does not
|
||||
/// follow later revisions of the same file.
|
||||
pub async fn open_revision(&self, uid: &NodeUid) -> Result<RevisionReader> {
|
||||
self.open_revision_inner(uid, None).await
|
||||
}
|
||||
|
||||
/// As [`open_revision`](Self::open_revision), on an explicit revision.
|
||||
///
|
||||
/// A visitor cannot enumerate a file's revision history, so `revision_id`
|
||||
/// has to come from somewhere that already knew it —
|
||||
/// [`NodeKind::File::active_revision_id`](crate::NodeKind) recorded earlier,
|
||||
/// typically, to reopen the same bytes a previous session was reading.
|
||||
pub async fn open_revision_at(
|
||||
&self,
|
||||
uid: &NodeUid,
|
||||
revision_id: &str,
|
||||
) -> Result<RevisionReader> {
|
||||
self.open_revision_inner(uid, Some(revision_id)).await
|
||||
}
|
||||
|
||||
async fn open_revision_inner(
|
||||
&self,
|
||||
uid: &NodeUid,
|
||||
revision_id: Option<&str>,
|
||||
) -> Result<RevisionReader> {
|
||||
let details = self.link_details(uid).await?;
|
||||
let active = file_properties(&details).and_then(|file| file.active_revision.as_ref());
|
||||
|
||||
let revision_id = match revision_id {
|
||||
Some(id) => id.to_string(),
|
||||
None => active.map(|revision| revision.id.clone()).ok_or_else(|| {
|
||||
ProtonError::invalid_operation("the shared file has no active revision")
|
||||
})?,
|
||||
};
|
||||
|
||||
let parent_key = self.resolve_parent_key(&uid.volume_id, &details).await?;
|
||||
let node_key = decrypt_link(&parent_key, &details.link)?.node_key;
|
||||
let content_key = self.content_key(&details, &node_key)?;
|
||||
|
||||
let (revision, blocks) = self
|
||||
.transport
|
||||
.list_blocks(&uid.volume_id, &uid.link_id, &revision_id)
|
||||
.await?;
|
||||
|
||||
// Prefer the revision's own extended attributes. Fall back to the copy
|
||||
// the link-details response above already delivered for the *active*
|
||||
// revision — the visitor route has a history of stripping fields the
|
||||
// authenticated one returns, and without a usable size the reader cannot
|
||||
// place block boundaries and refuses to open a multi-block file at all.
|
||||
let armored = revision.extended_attributes.as_deref().or_else(|| {
|
||||
active
|
||||
.filter(|candidate| candidate.id == revision_id)
|
||||
.and_then(|candidate| candidate.extended_attributes.as_deref())
|
||||
});
|
||||
|
||||
let common = armored.and_then(|xattr| {
|
||||
match decrypt_extended_attributes_unverified(&node_key, xattr) {
|
||||
Ok(attrs) => attrs.common,
|
||||
Err(e) => {
|
||||
tracing::debug!(error = %e, "revision extended attributes did not decrypt");
|
||||
None
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let block_sizes = rank_block_sizes(common.as_ref(), &revision_id, blocks.len())?;
|
||||
|
||||
Ok(RevisionReader::new(
|
||||
self.transport.clone(),
|
||||
uid.clone(),
|
||||
revision_id,
|
||||
content_key,
|
||||
blocks,
|
||||
block_sizes,
|
||||
))
|
||||
}
|
||||
|
||||
/// Download and decrypt a shared file's thumbnail of the given type, if it
|
||||
/// has one.
|
||||
///
|
||||
/// Ports the authenticated
|
||||
/// [`download_thumbnail`](crate::ProtonDriveClient::download_thumbnail): the
|
||||
/// revision's thumbnail header names a block id, `POST volumes/{vid}/thumbnails`
|
||||
/// resolves it to a download URL, and the bytes decrypt under the same
|
||||
/// content key as the file's content blocks. Only the route prefix differs.
|
||||
/// Returns `Ok(None)` when the file has no thumbnail of that type.
|
||||
pub async fn download_thumbnail(
|
||||
&self,
|
||||
uid: &NodeUid,
|
||||
thumbnail_type: ThumbnailType,
|
||||
) -> Result<Option<Vec<u8>>> {
|
||||
let (content_key, thumbnail_id) = match self.thumbnail_target(uid, thumbnail_type).await? {
|
||||
(content_key, Some(id)) => (content_key, id),
|
||||
(_, None) => return Ok(None),
|
||||
};
|
||||
|
||||
let response: ThumbnailBlockListResponse = self
|
||||
.transport
|
||||
.post(
|
||||
&format!("volumes/{}/thumbnails", uid.volume_id),
|
||||
&ThumbnailBlockListRequest {
|
||||
thumbnail_ids: vec![thumbnail_id.clone()],
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
let block = response
|
||||
.blocks
|
||||
.into_iter()
|
||||
.find(|block| block.thumbnail_id == thumbnail_id)
|
||||
.ok_or_else(|| {
|
||||
let reason = response
|
||||
.errors
|
||||
.iter()
|
||||
.find(|e| e.thumbnail_id == thumbnail_id)
|
||||
.map(|e| e.error.clone())
|
||||
.unwrap_or_else(|| "thumbnail block not returned".to_string());
|
||||
ProtonError::invalid_operation(format!("resolve thumbnail block: {reason}"))
|
||||
})?;
|
||||
|
||||
let ciphertext = self
|
||||
.transport
|
||||
.http()
|
||||
.get_storage_blob(&block.bare_url, &block.token)
|
||||
.await?;
|
||||
Ok(Some(content_key.decrypt_thumbnail(&ciphertext)?))
|
||||
}
|
||||
|
||||
/// Batch-download the thumbnails of `uids`.
|
||||
///
|
||||
/// The shape a poster wall wants: block ids are resolved in batches of up to
|
||||
/// [`MAX_THUMBNAIL_IDS_PER_REQUEST`], and a per-file failure — no thumbnail
|
||||
/// of that type, a decrypt error, a node that is not a file — is reported in
|
||||
/// that file's [`FileThumbnail`] instead of failing the batch. Returned
|
||||
/// order does not track the input order.
|
||||
pub async fn enumerate_thumbnails(
|
||||
&self,
|
||||
uids: &[NodeUid],
|
||||
thumbnail_type: ThumbnailType,
|
||||
) -> Result<Vec<FileThumbnail>> {
|
||||
let mut results: Vec<FileThumbnail> = Vec::new();
|
||||
|
||||
for (volume_id, link_ids) in group_by_volume(uids) {
|
||||
// thumbnail id -> the file it belongs to and the key it decrypts under.
|
||||
let mut targets: HashMap<String, (NodeUid, ContentKey)> = HashMap::new();
|
||||
|
||||
for link_id in link_ids {
|
||||
let uid = NodeUid::new(volume_id.clone(), link_id);
|
||||
match self.thumbnail_target(&uid, thumbnail_type).await {
|
||||
Ok((content_key, Some(id))) => {
|
||||
targets.insert(id, (uid, content_key));
|
||||
}
|
||||
Ok((_, None)) => results.push(FileThumbnail::err(
|
||||
uid.clone(),
|
||||
ProtonError::invalid_operation(format!(
|
||||
"node {uid} has no thumbnail of the requested type"
|
||||
)),
|
||||
)),
|
||||
Err(e) => results.push(FileThumbnail::err(uid, e)),
|
||||
}
|
||||
}
|
||||
|
||||
let thumbnail_ids: Vec<String> = targets.keys().cloned().collect();
|
||||
for chunk in thumbnail_ids.chunks(MAX_THUMBNAIL_IDS_PER_REQUEST) {
|
||||
self.drain_thumbnail_chunk(&volume_id, chunk, &mut targets, &mut results)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
/// Resolve one chunk of thumbnail ids to blocks, download them, and record
|
||||
/// an outcome for every id in the chunk — including the ones the server said
|
||||
/// nothing about at all.
|
||||
async fn drain_thumbnail_chunk(
|
||||
&self,
|
||||
volume_id: &VolumeId,
|
||||
chunk: &[String],
|
||||
targets: &mut HashMap<String, (NodeUid, ContentKey)>,
|
||||
results: &mut Vec<FileThumbnail>,
|
||||
) {
|
||||
let response: ThumbnailBlockListResponse = match self
|
||||
.transport
|
||||
.post(
|
||||
&format!("volumes/{volume_id}/thumbnails"),
|
||||
&ThumbnailBlockListRequest {
|
||||
thumbnail_ids: chunk.to_vec(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(response) => response,
|
||||
// The chunk request itself failed: every file in it gets that error.
|
||||
Err(e) => {
|
||||
for id in chunk {
|
||||
if let Some((uid, _)) = targets.remove(id) {
|
||||
results.push(FileThumbnail::err(
|
||||
uid,
|
||||
ProtonError::invalid_operation(format!(
|
||||
"resolve thumbnail blocks: {e}"
|
||||
)),
|
||||
));
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
for block in response.blocks {
|
||||
let Some((uid, content_key)) = targets.remove(&block.thumbnail_id) else {
|
||||
continue;
|
||||
};
|
||||
let downloaded = match self
|
||||
.transport
|
||||
.http()
|
||||
.get_storage_blob(&block.bare_url, &block.token)
|
||||
.await
|
||||
{
|
||||
Ok(ciphertext) => content_key
|
||||
.decrypt_thumbnail(&ciphertext)
|
||||
.map_err(ProtonError::from),
|
||||
Err(e) => Err(e),
|
||||
};
|
||||
results.push(match downloaded {
|
||||
Ok(bytes) => FileThumbnail::ok(uid, bytes),
|
||||
Err(e) => FileThumbnail::err(uid, e),
|
||||
});
|
||||
}
|
||||
|
||||
for error in response.errors {
|
||||
if let Some((uid, _)) = targets.remove(&error.thumbnail_id) {
|
||||
results.push(FileThumbnail::err(
|
||||
uid,
|
||||
ProtonError::invalid_operation(error.error),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Anything still in `targets` was neither returned nor errored on. Left
|
||||
// unreported it would look like a file that was never asked about.
|
||||
for id in chunk {
|
||||
if let Some((uid, _)) = targets.remove(id) {
|
||||
results.push(FileThumbnail::err(
|
||||
uid,
|
||||
ProtonError::invalid_operation("thumbnail not found".to_string()),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A file's content key and the block id of its thumbnail of
|
||||
/// `thumbnail_type`, if it has one.
|
||||
async fn thumbnail_target(
|
||||
&self,
|
||||
uid: &NodeUid,
|
||||
thumbnail_type: ThumbnailType,
|
||||
) -> Result<(ContentKey, Option<String>)> {
|
||||
let details = self.link_details(uid).await?;
|
||||
let revision_id = file_properties(&details)
|
||||
.and_then(|file| file.active_revision.as_ref())
|
||||
.map(|revision| revision.id.clone())
|
||||
.ok_or_else(|| {
|
||||
ProtonError::invalid_operation("the shared file has no active revision")
|
||||
})?;
|
||||
|
||||
let parent_key = self.resolve_parent_key(&uid.volume_id, &details).await?;
|
||||
let node_key = decrypt_link(&parent_key, &details.link)?.node_key;
|
||||
let content_key = self.content_key(&details, &node_key)?;
|
||||
|
||||
// The thumbnail headers ride along with the revision's block listing.
|
||||
let (revision, _blocks) = self
|
||||
.transport
|
||||
.list_blocks(&uid.volume_id, &uid.link_id, &revision_id)
|
||||
.await?;
|
||||
let wanted = thumbnail_type.as_i32();
|
||||
let thumbnail_id = revision
|
||||
.thumbnails
|
||||
.iter()
|
||||
.find(|thumbnail| thumbnail.thumbnail_type == wanted)
|
||||
.and_then(|thumbnail| thumbnail.id.clone());
|
||||
|
||||
Ok((content_key, thumbnail_id))
|
||||
}
|
||||
|
||||
/// Re-run the handshake, replacing the session behind this client.
|
||||
///
|
||||
/// The anonymous session carries no refresh token, so an expiry is recovered
|
||||
/// from by authenticating again (TS `SharingPublicLinkSession.reauth`).
|
||||
/// Returns a fresh client rather than mutating this one, so an in-flight
|
||||
/// caller never observes a half-swapped session.
|
||||
///
|
||||
/// Rarely needed directly: every request this client makes already replays
|
||||
/// the handshake once on an expired session, and so does every
|
||||
/// [`RevisionReader`](crate::RevisionReader) it hands out. Reach for it to
|
||||
/// renew *ahead* of a long read rather than to repair a failed one.
|
||||
///
|
||||
/// The share key, root uid and role belong to the link and cannot change
|
||||
/// under a successful re-auth, so only the session is replaced.
|
||||
pub async fn refresh_session(&self) -> Result<()> {
|
||||
let (_, generation) = self.session.http();
|
||||
self.session.renew(generation).await
|
||||
}
|
||||
|
||||
/// As [`refresh_session`](Self::refresh_session), returning a client for
|
||||
/// callers written against the older shape.
|
||||
///
|
||||
/// The returned client shares this one's session rather than owning a
|
||||
/// separate one — which is the point of the change: readers opened from
|
||||
/// *either* handle recover from the expiry, where previously the original
|
||||
/// client and everything it had handed out were stranded.
|
||||
pub async fn reauth(&self) -> Result<Self> {
|
||||
let auth = self.session.auth().await?;
|
||||
Ok(Self {
|
||||
session: PublicLinkSession {
|
||||
config: self.session.config.clone(),
|
||||
token: self.session.token.clone(),
|
||||
password: self.session.password.clone(),
|
||||
},
|
||||
http: auth.http,
|
||||
share_key: auth.share_key,
|
||||
root_uid: auth.root_uid,
|
||||
public_role: auth.public_role,
|
||||
})
|
||||
self.refresh_session().await?;
|
||||
Ok(self.clone())
|
||||
}
|
||||
|
||||
/// The key that decrypts `link`: the share key at the shared root, otherwise
|
||||
/// the parent node's key, resolved recursively up to the root.
|
||||
/// the parent node's key.
|
||||
///
|
||||
/// The recursion terminates at the shared root because the visitor cannot
|
||||
/// see past it — a link whose parent chain does not reach the root is not
|
||||
/// The walk terminates at the shared root because the visitor cannot see
|
||||
/// past it — a link whose parent chain does not reach the root is not
|
||||
/// readable with this session's material.
|
||||
async fn resolve_parent_key(
|
||||
&self,
|
||||
@@ -470,24 +987,88 @@ impl ProtonDrivePublicLinkClient {
|
||||
ProtonError::invalid_operation("node has no parent and is not the shared root")
|
||||
})?;
|
||||
|
||||
let parent = self
|
||||
.link_details(&NodeUid::new(volume_id.clone(), parent_id))
|
||||
.await?;
|
||||
// Recursion is boxed: the chain length is the tree depth, which the
|
||||
// compiler cannot bound for an `async fn` that calls itself.
|
||||
let grandparent_key = Box::pin(self.resolve_parent_key(volume_id, &parent)).await?;
|
||||
Ok(decrypt_link(&grandparent_key, &parent.link)?.node_key)
|
||||
self.node_key(&NodeUid::new(volume_id.clone(), parent_id))
|
||||
.await
|
||||
}
|
||||
|
||||
/// A node's own unlocked key, from cache when it is there.
|
||||
///
|
||||
/// Walks *up* to the nearest cached ancestor (or the shared root, whose
|
||||
/// parent key is the share key), then unlocks back *down*, caching every key
|
||||
/// on the way. Written iteratively rather than recursively so it is one
|
||||
/// finite future — an `async fn` that awaited itself would need boxing at
|
||||
/// the recursion point, and its `Send`-ness would depend on its own, which
|
||||
/// the compiler cannot resolve.
|
||||
async fn node_key(&self, uid: &NodeUid) -> Result<PrivateKey> {
|
||||
if let Some(key) = self.cached_node_key(uid) {
|
||||
return Ok(key);
|
||||
}
|
||||
|
||||
let client = self.clone();
|
||||
let target = uid.clone();
|
||||
self.key_loads
|
||||
.run(uid.clone(), async move {
|
||||
// Up: collect the chain from `target` towards the root, stopping
|
||||
// at the first key already unlocked.
|
||||
let mut chain: Vec<LinkDetailsDto> = Vec::new();
|
||||
let mut cursor = target.clone();
|
||||
|
||||
let mut key = loop {
|
||||
if let Some(key) = client.cached_node_key(&cursor) {
|
||||
break key;
|
||||
}
|
||||
|
||||
let details = client.link_details(&cursor).await?;
|
||||
|
||||
if details.link.id == client.root_uid.link_id {
|
||||
let key = decrypt_link(&client.share_key, &details.link)?.node_key;
|
||||
client.cache_node_key(&cursor, &key);
|
||||
break key;
|
||||
}
|
||||
|
||||
let parent_id = details.link.parent_id.clone().ok_or_else(|| {
|
||||
ProtonError::invalid_operation(
|
||||
"node has no parent and is not the shared root",
|
||||
)
|
||||
})?;
|
||||
let parent = NodeUid::new(cursor.volume_id.clone(), parent_id);
|
||||
chain.push(details);
|
||||
cursor = parent;
|
||||
};
|
||||
|
||||
// Down: each node's key unlocks the next, and every one is worth
|
||||
// keeping — the siblings still to be listed will ask for them.
|
||||
while let Some(details) = chain.pop() {
|
||||
key = decrypt_link(&key, &details.link)?.node_key;
|
||||
client.cache_node_key(
|
||||
&NodeUid::new(target.volume_id.clone(), details.link.id.clone()),
|
||||
&key,
|
||||
);
|
||||
}
|
||||
|
||||
Ok(key)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
fn cached_node_key(&self, uid: &NodeUid) -> Option<PrivateKey> {
|
||||
self.node_keys
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.get(uid)
|
||||
.cloned()
|
||||
}
|
||||
|
||||
fn cache_node_key(&self, uid: &NodeUid, key: &PrivateKey) {
|
||||
self.node_keys
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.put(uid.clone(), key.clone());
|
||||
}
|
||||
|
||||
async fn link_details(&self, uid: &NodeUid) -> Result<LinkDetailsDto> {
|
||||
let response: LinkDetailsResponse = self
|
||||
.http
|
||||
.post(
|
||||
&format!("v2/volumes/{}/links", uid.volume_id),
|
||||
&LinkDetailsRequest {
|
||||
link_ids: std::slice::from_ref(&uid.link_id),
|
||||
},
|
||||
)
|
||||
let response = self
|
||||
.fetch_link_details(&uid.volume_id, std::slice::from_ref(&uid.link_id))
|
||||
.await?;
|
||||
response
|
||||
.links
|
||||
@@ -522,20 +1103,42 @@ impl ProtonDrivePublicLinkClient {
|
||||
NodeKind::Folder
|
||||
} else {
|
||||
let file = file_properties(details);
|
||||
let active = file.and_then(|f| f.active_revision.as_ref());
|
||||
|
||||
// The link-details response already carries the active revision's
|
||||
// `XAttr`, so the uploader's claimed size, mtime and digest cost
|
||||
// nothing beyond one decrypt — no extra round-trip. Unverified,
|
||||
// because a visitor has no address book to resolve the signer
|
||||
// through; see the module note.
|
||||
let common = active
|
||||
.and_then(|revision| revision.extended_attributes.as_deref())
|
||||
.and_then(|xattr| {
|
||||
match decrypt_extended_attributes_unverified(&decrypted.node_key, xattr) {
|
||||
Ok(attrs) => attrs.common,
|
||||
// Present but unreadable is worth a line — absent is
|
||||
// ordinary, and says nothing.
|
||||
Err(e) => {
|
||||
tracing::debug!(link_id = %link.id, error = %e,
|
||||
"revision extended attributes did not decrypt");
|
||||
None
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
NodeKind::File {
|
||||
media_type: file.map(|f| f.media_type.clone()).unwrap_or_default(),
|
||||
total_size_on_storage: file.map(|f| f.total_size_on_storage).unwrap_or_default(),
|
||||
active_revision_state: file
|
||||
.and_then(|f| f.active_revision.as_ref())
|
||||
active_revision_state: active
|
||||
.map(|revision| RevisionState::from_raw(revision.state)),
|
||||
active_revision_id: file
|
||||
.and_then(|f| f.active_revision.as_ref())
|
||||
.map(|revision| revision.id.clone()),
|
||||
// Claimed metadata lives in the revision's extended attributes,
|
||||
// which this read surface does not fetch.
|
||||
claimed_size: None,
|
||||
claimed_modification_time: None,
|
||||
content_sha1: None,
|
||||
active_revision_id: active.map(|revision| revision.id.clone()),
|
||||
claimed_size: common.as_ref().and_then(|c| c.size),
|
||||
claimed_modification_time: common
|
||||
.as_ref()
|
||||
.and_then(|c| c.modification_time.clone()),
|
||||
content_sha1: common
|
||||
.as_ref()
|
||||
.and_then(|c| c.digests.as_ref())
|
||||
.and_then(|d| d.sha1.clone()),
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -14,8 +14,8 @@ use crate::node::RevisionState;
|
||||
use sha2::{Digest, Sha256};
|
||||
use tokio::sync::{Mutex, OwnedSemaphorePermit, RwLock};
|
||||
|
||||
use crate::client::ProtonDriveClient;
|
||||
use crate::dtos::BlockDto;
|
||||
use crate::transport::RevisionTransport;
|
||||
|
||||
/// Content blocks fetched concurrently within a single file.
|
||||
///
|
||||
@@ -134,7 +134,11 @@ struct BlockTable {
|
||||
/// file gains a new revision, this reader keeps serving the old one; reopen to
|
||||
/// follow the change.
|
||||
pub struct RevisionReader {
|
||||
client: ProtonDriveClient,
|
||||
/// Everything the read needs from whoever opened it: block permits, an HTTP
|
||||
/// client already carrying the right route prefix, and — for a session that
|
||||
/// can die under the reader — the renewal path. Deliberately *not* a whole
|
||||
/// client, so an anonymous public-link visitor can have one too.
|
||||
transport: RevisionTransport,
|
||||
uid: NodeUid,
|
||||
revision_id: String,
|
||||
content_key: ContentKey,
|
||||
@@ -149,7 +153,7 @@ pub struct RevisionReader {
|
||||
|
||||
impl RevisionReader {
|
||||
pub(crate) fn new(
|
||||
client: ProtonDriveClient,
|
||||
transport: RevisionTransport,
|
||||
uid: NodeUid,
|
||||
revision_id: String,
|
||||
content_key: ContentKey,
|
||||
@@ -158,7 +162,7 @@ impl RevisionReader {
|
||||
) -> Self {
|
||||
let file_size = block_sizes.iter().sum();
|
||||
Self {
|
||||
client,
|
||||
transport,
|
||||
uid,
|
||||
revision_id,
|
||||
content_key,
|
||||
@@ -249,7 +253,7 @@ impl RevisionReader {
|
||||
// Held across fetch *and* decrypt: both halves are resident 4 MiB
|
||||
// buffers, and the plaintext outlives the ciphertext.
|
||||
let permit = self
|
||||
.client
|
||||
.transport
|
||||
.block_slots()
|
||||
.acquire_owned()
|
||||
.await
|
||||
@@ -257,12 +261,14 @@ impl RevisionReader {
|
||||
|
||||
let (url, token, generation) = self.block_location(index).await?;
|
||||
|
||||
let ciphertext = match self.client.http().get_storage_blob(&url, &token).await {
|
||||
// A block fetch carries a `pm-storage-token` and no session credential
|
||||
// at all, so it cannot fail for session reasons — only the URL expires.
|
||||
let ciphertext = match self.transport.http().get_storage_blob(&url, &token).await {
|
||||
Ok(bytes) => bytes,
|
||||
Err(e) if is_expired_block_url(&e) => {
|
||||
self.refresh_blocks(generation).await?;
|
||||
let (url, token, _) = self.block_location(index).await?;
|
||||
self.client.http().get_storage_blob(&url, &token).await?
|
||||
self.transport.http().get_storage_blob(&url, &token).await?
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
@@ -300,8 +306,8 @@ impl RevisionReader {
|
||||
}
|
||||
|
||||
let (_, blocks) = self
|
||||
.client
|
||||
.fetch_revision_blocks(&self.uid.volume_id, &self.uid.link_id, &self.revision_id)
|
||||
.transport
|
||||
.list_blocks(&self.uid.volume_id, &self.uid.link_id, &self.revision_id)
|
||||
.await?;
|
||||
|
||||
if blocks.len() != self.block_sizes.len() {
|
||||
|
||||
@@ -0,0 +1,492 @@
|
||||
//! What a revision read needs from its client, and nothing else.
|
||||
//!
|
||||
//! [`RevisionReader`](crate::RevisionReader) originally held a whole
|
||||
//! [`ProtonDriveClient`](crate::ProtonDriveClient), which is why an anonymous
|
||||
//! public-link visitor could not have one. It only ever used three things: the
|
||||
//! host-wide block permits, an [`ApiHttpClient`] to fetch block bodies through,
|
||||
//! and the paginated revision listing. All three are satisfied by an HTTP client
|
||||
//! plus a semaphore — and `ApiHttpClient` already carries the route prefix
|
||||
//! (`drive/` vs `drive/unauth/`), which is the only thing that differs between
|
||||
//! the authenticated and visitor paths. The endpoints, payloads, paging and
|
||||
//! contiguity rules are identical.
|
||||
//!
|
||||
//! So the seam here is deliberately narrow: [`RevisionTransport`] is *concrete*,
|
||||
//! and the one genuine axis of variation — what to do when the session dies —
|
||||
//! is the two-method [`BlockSession`] trait.
|
||||
//!
|
||||
//! ## The session asymmetry
|
||||
//!
|
||||
//! Worth stating once, because it is easy to re-derive wrongly: block bodies are
|
||||
//! fetched with [`ApiHttpClient::get_storage_blob`], which sends a
|
||||
//! `pm-storage-token` and **no bearer and no `x-pm-uid`**. Block fetches are
|
||||
//! therefore immune to session expiry — a 401 there means an expired *block
|
||||
//! URL*. The revision listing is the only session-authenticated call in a read,
|
||||
//! so it is the only one that can meet a dead session.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use proton_sdk::error::{ProtonError, Result};
|
||||
use proton_sdk::http::ApiHttpClient;
|
||||
use proton_sdk::ids::{LinkId, VolumeId};
|
||||
use serde::Serialize;
|
||||
use serde::de::DeserializeOwned;
|
||||
use tokio::sync::Semaphore;
|
||||
|
||||
use crate::dtos::{BlockDto, DecryptedCommonExtendedAttributes, RevisionDto, RevisionResponse};
|
||||
|
||||
/// Content blocks are 4 MiB of plaintext each (C# `RevisionWriter.DefaultBlockSize`).
|
||||
pub(crate) const DEFAULT_BLOCK_SIZE: usize = 1 << 22;
|
||||
|
||||
/// The live session a revision read issues its authenticated calls through, and
|
||||
/// how to replace it when it dies.
|
||||
///
|
||||
/// Generation-versioned for the same reason
|
||||
/// [`RevisionReader::refresh_blocks`](crate::RevisionReader) versions its block
|
||||
/// table: a burst of failures must replay **one** recovery, not one per caller.
|
||||
/// A loser re-checks the generation and returns.
|
||||
#[async_trait]
|
||||
pub(crate) trait BlockSession: Send + Sync + 'static {
|
||||
/// The client to use right now, paired with the generation it belongs to.
|
||||
///
|
||||
/// Deliberately synchronous — this is on the per-request path, and it is a
|
||||
/// lock read plus two refcount bumps. The lock is never held across an await.
|
||||
fn http(&self) -> (ApiHttpClient, u64);
|
||||
|
||||
/// Replace the session, unless somebody has already replaced `seen`.
|
||||
async fn renew(&self, seen: u64) -> Result<()>;
|
||||
}
|
||||
|
||||
/// A session that renews itself.
|
||||
///
|
||||
/// An ordinary bearer session refreshes its own tokens inside [`ApiHttpClient`]
|
||||
/// on a 401, so by the time an error surfaces here there is nothing left to try:
|
||||
/// `renew` is a no-op and the generation never moves.
|
||||
pub(crate) struct StaticSession(ApiHttpClient);
|
||||
|
||||
impl StaticSession {
|
||||
pub(crate) fn new(http: ApiHttpClient) -> Self {
|
||||
Self(http)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl BlockSession for StaticSession {
|
||||
fn http(&self) -> (ApiHttpClient, u64) {
|
||||
(self.0.clone(), 0)
|
||||
}
|
||||
|
||||
async fn renew(&self, _seen: u64) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Everything a revision read needs that is not the revision itself.
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct RevisionTransport {
|
||||
session: Arc<dyn BlockSession>,
|
||||
/// Host-wide in-flight block permits, shared with the owning client's other
|
||||
/// transfers.
|
||||
block_slots: Arc<Semaphore>,
|
||||
}
|
||||
|
||||
impl RevisionTransport {
|
||||
pub(crate) fn new(session: Arc<dyn BlockSession>, block_slots: Arc<Semaphore>) -> Self {
|
||||
Self {
|
||||
session,
|
||||
block_slots,
|
||||
}
|
||||
}
|
||||
|
||||
/// A transport over a session that manages its own tokens.
|
||||
pub(crate) fn authenticated(http: ApiHttpClient, block_slots: Arc<Semaphore>) -> Self {
|
||||
Self::new(Arc::new(StaticSession::new(http)), block_slots)
|
||||
}
|
||||
|
||||
/// The current HTTP client. Block fetches go straight through this — they
|
||||
/// carry no session credential, so they need no renewal path.
|
||||
pub(crate) fn http(&self) -> ApiHttpClient {
|
||||
self.session.http().0
|
||||
}
|
||||
|
||||
pub(crate) fn block_slots(&self) -> Arc<Semaphore> {
|
||||
self.block_slots.clone()
|
||||
}
|
||||
|
||||
/// `GET path` through the current session, replaying the handshake once if
|
||||
/// the session turns out to be gone.
|
||||
pub(crate) async fn get<T: DeserializeOwned>(&self, path: &str) -> Result<T> {
|
||||
let (http, generation) = self.session.http();
|
||||
match http.get::<T>(path).await {
|
||||
Err(e) if is_session_expired(&e) => {
|
||||
self.session.renew(generation).await?;
|
||||
self.session.http().0.get::<T>(path).await
|
||||
}
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
/// `POST path` through the current session, with the same one-shot renewal.
|
||||
pub(crate) async fn post<B: Serialize + Sync, T: DeserializeOwned>(
|
||||
&self,
|
||||
path: &str,
|
||||
body: &B,
|
||||
) -> Result<T> {
|
||||
let (http, generation) = self.session.http();
|
||||
match http.post::<B, T>(path, body).await {
|
||||
Err(e) if is_session_expired(&e) => {
|
||||
self.session.renew(generation).await?;
|
||||
self.session.http().0.post::<B, T>(path, body).await
|
||||
}
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
/// The revision's metadata and its full, ordered block table.
|
||||
pub(crate) async fn list_blocks(
|
||||
&self,
|
||||
volume_id: &VolumeId,
|
||||
link_id: &LinkId,
|
||||
revision_id: &str,
|
||||
) -> Result<(RevisionDto, Vec<BlockDto>)> {
|
||||
const PAGE_SIZE: i32 = 50;
|
||||
|
||||
let mut blocks: Vec<BlockDto> = Vec::new();
|
||||
let mut metadata: Option<RevisionDto> = None;
|
||||
let mut from_index: i32 = 1;
|
||||
|
||||
loop {
|
||||
let path = format!(
|
||||
"v2/volumes/{volume_id}/files/{link_id}/revisions/{revision_id}?FromBlockIndex={from_index}&PageSize={PAGE_SIZE}&NoBlockUrls=0"
|
||||
);
|
||||
let response: RevisionResponse = self.get(&path).await?;
|
||||
let mut revision = response.revision;
|
||||
let page = std::mem::take(&mut revision.blocks);
|
||||
let page_len = page.len();
|
||||
|
||||
if metadata.is_none() {
|
||||
metadata = Some(revision);
|
||||
}
|
||||
blocks.extend(page);
|
||||
|
||||
if page_len < PAGE_SIZE as usize {
|
||||
break;
|
||||
}
|
||||
from_index = blocks.iter().map(|b| b.index).max().unwrap_or(from_index) + 1;
|
||||
}
|
||||
|
||||
blocks.sort_by_key(|b| b.index);
|
||||
for (offset, block) in blocks.iter().enumerate() {
|
||||
if block.index != offset as i32 + 1 {
|
||||
return Err(ProtonError::invalid_operation(
|
||||
"file contents are incomplete (non-contiguous blocks)",
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let metadata = metadata
|
||||
.ok_or_else(|| ProtonError::invalid_operation("revision returned no metadata"))?;
|
||||
Ok((metadata, blocks))
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a failure means the *session* is gone.
|
||||
///
|
||||
/// Only meaningful for session-authenticated calls. A block fetch carries a
|
||||
/// `pm-storage-token` rather than the bearer, so a 401 there is an expired block
|
||||
/// URL — see `revision::is_expired_block_url`, which deliberately also accepts
|
||||
/// 403 and 404.
|
||||
///
|
||||
/// 403 is excluded here on purpose: on the visitor path it means
|
||||
/// `MissingScopes`, i.e. the request went to `drive/` instead of
|
||||
/// `drive/unauth/`. Re-authenticating would not fix that and would loop.
|
||||
pub(crate) fn is_session_expired(error: &ProtonError) -> bool {
|
||||
matches!(error, ProtonError::Api(e) if e.http_status == 401)
|
||||
}
|
||||
|
||||
/// Rank a revision's block-size sources and produce the per-block plaintext
|
||||
/// sizes.
|
||||
///
|
||||
/// These sizes are not merely descriptive: [`RevisionReader`](crate::RevisionReader)
|
||||
/// accumulates them to derive where each block *starts* in the plaintext. A
|
||||
/// vector that overstates any block but the last therefore shifts every block
|
||||
/// after it, and the reader splices those blocks at the wrong offsets —
|
||||
/// returning full-length reads of the wrong bytes, with no error to notice. See
|
||||
/// `revision.rs::padded_block_sizes_shift_later_blocks`, which pins that
|
||||
/// consequence.
|
||||
///
|
||||
/// So the sources are ranked by how much they can be trusted, and this refuses
|
||||
/// rather than guesses when none of them holds:
|
||||
///
|
||||
/// 1. `Common.BlockSizes` from the revision's extended attributes — the
|
||||
/// authoritative value, written by the uploading client.
|
||||
/// 2. `Common.Size` — the total, from which all-but-last full blocks and a final
|
||||
/// short block follow by subtraction. Sound for the standard layout the
|
||||
/// uploader produces.
|
||||
/// 3. Nothing usable. A single-block file cannot be misaligned (there is no
|
||||
/// later block to shift), so it is assumed full-size and only its reported
|
||||
/// length can be wrong. A multi-block file is an error: serving it would mean
|
||||
/// serving misplaced bytes.
|
||||
///
|
||||
/// **Not a rung:** `FileDto.total_size_on_storage` (wire `TotalEncryptedSize`).
|
||||
/// That is the *ciphertext* size — PGP framing per block, varying with the
|
||||
/// encryption mode — so deriving plaintext block boundaries from it produces
|
||||
/// exactly the shifted-splice failure above. Do not add it.
|
||||
pub(crate) fn rank_block_sizes(
|
||||
common: Option<&DecryptedCommonExtendedAttributes>,
|
||||
revision_id: &str,
|
||||
block_count: usize,
|
||||
) -> Result<Vec<u64>> {
|
||||
let block = DEFAULT_BLOCK_SIZE as u64;
|
||||
|
||||
if let Some(sizes) = common.and_then(|c| c.block_sizes.as_ref())
|
||||
&& sizes.len() == block_count
|
||||
{
|
||||
// A non-positive entry is as corrupting as a padded one — a zero-length
|
||||
// block in the middle shifts everything after it — so a malformed vector
|
||||
// is rejected outright rather than clamped.
|
||||
if let Some(bad) = sizes.iter().position(|&n| n <= 0) {
|
||||
return Err(ProtonError::invalid_operation(format!(
|
||||
"revision {revision_id} records a non-positive size for block {bad}"
|
||||
)));
|
||||
}
|
||||
return Ok(sizes.iter().map(|&n| n as u64).collect());
|
||||
}
|
||||
|
||||
if let Some(total) = common.and_then(|c| c.size).filter(|&n| n >= 0) {
|
||||
let total = total as u64;
|
||||
return Ok((0..block_count)
|
||||
.map(|i| total.saturating_sub(block * i as u64).min(block))
|
||||
.collect());
|
||||
}
|
||||
|
||||
if block_count <= 1 {
|
||||
return Ok(vec![block; block_count]);
|
||||
}
|
||||
|
||||
Err(ProtonError::invalid_operation(format!(
|
||||
"revision {revision_id} has {block_count} blocks but no usable size information \
|
||||
(no block sizes and no total in its extended attributes)"
|
||||
)))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
fn common(
|
||||
size: Option<i64>,
|
||||
block_sizes: Option<Vec<i64>>,
|
||||
) -> DecryptedCommonExtendedAttributes {
|
||||
DecryptedCommonExtendedAttributes {
|
||||
size,
|
||||
block_sizes,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
const MIB: u64 = 1 << 20;
|
||||
|
||||
/// The authoritative rung: whatever the uploader recorded is used as-is,
|
||||
/// including a short final block.
|
||||
#[test]
|
||||
fn block_sizes_from_extended_attributes_are_used_verbatim() {
|
||||
let attrs = common(Some(9 * MIB as i64), Some(vec![4 << 20, 4 << 20, 1 << 20]));
|
||||
let sizes = rank_block_sizes(Some(&attrs), "rev", 3).expect("sizes resolve");
|
||||
assert_eq!(sizes, vec![4 * MIB, 4 * MIB, MIB]);
|
||||
}
|
||||
|
||||
/// A vector that does not describe every block is not partial information,
|
||||
/// it is untrustworthy information — fall through to the total rather than
|
||||
/// pad it out.
|
||||
#[test]
|
||||
fn a_block_sizes_vector_of_the_wrong_length_falls_through_to_the_total() {
|
||||
let attrs = common(Some(9 * MIB as i64), Some(vec![4 << 20, 4 << 20]));
|
||||
let sizes = rank_block_sizes(Some(&attrs), "rev", 3).expect("sizes resolve");
|
||||
assert_eq!(sizes, vec![4 * MIB, 4 * MIB, MIB], "derived from the total");
|
||||
}
|
||||
|
||||
/// A zero-length block in the middle shifts every block after it just as a
|
||||
/// padded one does, so it is refused rather than clamped to something
|
||||
/// plausible.
|
||||
#[test]
|
||||
fn a_non_positive_block_size_is_rejected_rather_than_clamped() {
|
||||
let attrs = common(Some(9 * MIB as i64), Some(vec![4 << 20, 0, 1 << 20]));
|
||||
let error = rank_block_sizes(Some(&attrs), "rev", 3).expect_err("must refuse");
|
||||
assert!(
|
||||
error.to_string().contains("non-positive size for block 1"),
|
||||
"names the offending block: {error}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The total alone is enough, because the uploader's layout is all-but-last
|
||||
/// full blocks plus a remainder.
|
||||
#[test]
|
||||
fn the_total_size_derives_full_blocks_and_a_short_final_one() {
|
||||
let attrs = common(Some(9 * MIB as i64), None);
|
||||
let sizes = rank_block_sizes(Some(&attrs), "rev", 3).expect("sizes resolve");
|
||||
assert_eq!(sizes, vec![4 * MIB, 4 * MIB, MIB]);
|
||||
assert_eq!(sizes.iter().sum::<u64>(), 9 * MIB, "sums to the total");
|
||||
}
|
||||
|
||||
/// A file that happens to be an exact multiple of the block size gets no
|
||||
/// short tail, and no phantom extra block.
|
||||
#[test]
|
||||
fn a_total_that_is_a_whole_number_of_blocks_has_no_short_tail() {
|
||||
let attrs = common(Some(8 * MIB as i64), None);
|
||||
let sizes = rank_block_sizes(Some(&attrs), "rev", 2).expect("sizes resolve");
|
||||
assert_eq!(sizes, vec![4 * MIB, 4 * MIB]);
|
||||
}
|
||||
|
||||
/// With one block there is no later block to displace, so assuming the full
|
||||
/// block size can only misreport the length — never misplace bytes.
|
||||
#[test]
|
||||
fn a_single_block_revision_is_assumed_full_size() {
|
||||
let sizes = rank_block_sizes(None, "rev", 1).expect("sizes resolve");
|
||||
assert_eq!(sizes, vec![4 * MIB]);
|
||||
assert_eq!(
|
||||
rank_block_sizes(None, "rev", 0).expect("empty resolves"),
|
||||
Vec::<u64>::new()
|
||||
);
|
||||
}
|
||||
|
||||
/// The refusal that keeps the reader honest: guessing here would serve
|
||||
/// full-length reads of the wrong bytes.
|
||||
#[test]
|
||||
fn a_multi_block_revision_with_no_size_information_is_an_error() {
|
||||
let error = rank_block_sizes(None, "rev-42", 3).expect_err("must refuse");
|
||||
let message = error.to_string();
|
||||
assert!(message.contains("rev-42"), "names the revision: {message}");
|
||||
assert!(
|
||||
message.contains("no usable size information"),
|
||||
"says why: {message}"
|
||||
);
|
||||
}
|
||||
|
||||
/// A dead session is a 401 and nothing else. 403 on the visitor path means
|
||||
/// `MissingScopes` — a wrong route prefix, which re-authenticating would
|
||||
/// only loop on — and 404 means an expired block URL.
|
||||
#[test]
|
||||
fn only_a_401_reads_as_an_expired_session() {
|
||||
use proton_sdk::api::ResponseCode;
|
||||
use proton_sdk::error::ProtonApiError;
|
||||
|
||||
let api = |status: u16| {
|
||||
ProtonError::Api(ProtonApiError {
|
||||
code: ResponseCode::Unknown,
|
||||
http_status: status,
|
||||
message: "nope".into(),
|
||||
details: None,
|
||||
})
|
||||
};
|
||||
|
||||
assert!(is_session_expired(&api(401)));
|
||||
assert!(
|
||||
!is_session_expired(&api(403)),
|
||||
"MissingScopes is not expiry"
|
||||
);
|
||||
assert!(
|
||||
!is_session_expired(&api(404)),
|
||||
"an expired block url is not"
|
||||
);
|
||||
assert!(!is_session_expired(&ProtonError::invalid_operation("x")));
|
||||
}
|
||||
|
||||
/// A session that counts its handshakes, so renewal can be tested without a
|
||||
/// network.
|
||||
struct CountingSession {
|
||||
http: ApiHttpClient,
|
||||
generation: std::sync::Mutex<u64>,
|
||||
handshakes: AtomicUsize,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl BlockSession for CountingSession {
|
||||
fn http(&self) -> (ApiHttpClient, u64) {
|
||||
(self.http.clone(), *self.generation.lock().unwrap())
|
||||
}
|
||||
|
||||
async fn renew(&self, seen: u64) -> Result<()> {
|
||||
let mut generation = self.generation.lock().unwrap();
|
||||
if *generation != seen {
|
||||
// Somebody else already replaced the session we were told about.
|
||||
return Ok(());
|
||||
}
|
||||
self.handshakes.fetch_add(1, Ordering::SeqCst);
|
||||
*generation += 1;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn counting_session() -> Arc<CountingSession> {
|
||||
use proton_sdk::config::ProtonClientConfiguration;
|
||||
use proton_sdk::http::Tokens;
|
||||
use proton_sdk::ids::SessionId;
|
||||
|
||||
let http = ApiHttpClient::new(
|
||||
ProtonClientConfiguration::new("test@1.0"),
|
||||
SessionId::from("anonymous"),
|
||||
Tokens {
|
||||
access_token: "access".into(),
|
||||
refresh_token: String::new(),
|
||||
},
|
||||
)
|
||||
.expect("build client");
|
||||
|
||||
Arc::new(CountingSession {
|
||||
http,
|
||||
generation: std::sync::Mutex::new(0),
|
||||
handshakes: AtomicUsize::new(0),
|
||||
})
|
||||
}
|
||||
|
||||
/// Concurrent readers that all saw the same dead session replay **one**
|
||||
/// handshake between them — the generation check makes the losers no-ops.
|
||||
/// Without this, a seek that spans ten blocks would run ten SRP handshakes.
|
||||
#[tokio::test]
|
||||
async fn concurrent_renewals_of_one_generation_run_a_single_handshake() {
|
||||
let session = counting_session();
|
||||
|
||||
let mut set = tokio::task::JoinSet::new();
|
||||
for _ in 0..8 {
|
||||
let session = session.clone();
|
||||
set.spawn(async move { session.renew(0).await });
|
||||
}
|
||||
while let Some(result) = set.join_next().await {
|
||||
result.expect("task").expect("renew");
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
session.handshakes.load(Ordering::SeqCst),
|
||||
1,
|
||||
"eight callers, one handshake"
|
||||
);
|
||||
assert_eq!(*session.generation.lock().unwrap(), 1);
|
||||
}
|
||||
|
||||
/// A caller arriving with a stale generation has already been rescued by
|
||||
/// somebody else's handshake, so it must not start another.
|
||||
#[tokio::test]
|
||||
async fn renewing_a_generation_that_was_already_replaced_is_a_no_op() {
|
||||
let session = counting_session();
|
||||
|
||||
session.renew(0).await.expect("first renew");
|
||||
assert_eq!(session.handshakes.load(Ordering::SeqCst), 1);
|
||||
|
||||
session.renew(0).await.expect("stale renew");
|
||||
assert_eq!(
|
||||
session.handshakes.load(Ordering::SeqCst),
|
||||
1,
|
||||
"the stale caller did not re-handshake"
|
||||
);
|
||||
}
|
||||
|
||||
/// The transport is what `RevisionReader` stores, so it has to survive being
|
||||
/// moved into a spawned task alongside the reader.
|
||||
#[test]
|
||||
fn a_revision_transport_is_send_and_sync() {
|
||||
fn assert_send_sync<T: Send + Sync>() {}
|
||||
assert_send_sync::<RevisionTransport>();
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,8 @@
|
||||
mod common;
|
||||
|
||||
use proton_drive_rs::{
|
||||
MemberRole, ProtonDriveClient, ProtonDrivePublicLinkClient, ProtonPhotosClient,
|
||||
MemberRole, ProtonDriveClient, ProtonDrivePublicLinkClient, ProtonPhotosClient, Thumbnail,
|
||||
ThumbnailType,
|
||||
};
|
||||
use proton_sdk::ids::NodeUid;
|
||||
|
||||
@@ -439,3 +440,318 @@ async fn custom_password_link_requires_the_password() {
|
||||
|
||||
cleanup(client, &folder).await;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public link — seekable reads, the streaming path
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A multi-block file, in blocks of `4 MiB`, whose every byte encodes its own
|
||||
/// offset. A range served from the wrong block is then identifiable rather than
|
||||
/// merely unequal — which matters here, because a wrong block-size vector
|
||||
/// returns a full-length read of the wrong bytes with no error at all.
|
||||
fn positional_payload(len: usize) -> Vec<u8> {
|
||||
(0..len).map(|i| (i % 251) as u8).collect()
|
||||
}
|
||||
|
||||
/// **The load-bearing test for streaming over a public link.**
|
||||
///
|
||||
/// Everything `proton-stream` does rests on one uncertain fact: that the
|
||||
/// `drive/unauth/` revision endpoint returns a usable `XAttr` to an anonymous
|
||||
/// visitor. Without it `rank_block_sizes` cannot place block boundaries and
|
||||
/// refuses to open a multi-block file, and seeking is impossible. The visitor
|
||||
/// route has form here — `?NoBlockUrls=true` already strips `BareURL`/`Token` —
|
||||
/// so this is verified, not assumed.
|
||||
///
|
||||
/// Then the reads themselves: a range spanning a block boundary, a range wholly
|
||||
/// inside a later block (the seek case), and the short final block.
|
||||
#[tokio::test]
|
||||
#[ignore = "live: needs test-account credentials"]
|
||||
async fn a_visitor_can_seek_within_a_shared_multi_block_file() {
|
||||
let Some(live) = common::live_client().await else {
|
||||
return;
|
||||
};
|
||||
let client = &live.client;
|
||||
|
||||
let folder = scratch_folder(client, "public-seek").await;
|
||||
|
||||
// 9 MiB: two full 4 MiB blocks and a 1 MiB tail, so both the boundary case
|
||||
// and the short-tail case are reachable.
|
||||
const BLOCK: usize = 4 << 20;
|
||||
let contents = positional_payload(9 << 20);
|
||||
let name = format!("seekable-{}.bin", common::unique_suffix());
|
||||
let file = client
|
||||
.upload_file(&folder, &name, "application/octet-stream", &contents)
|
||||
.await
|
||||
.expect("upload_file");
|
||||
|
||||
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 reader = visitor
|
||||
.open_revision(&file)
|
||||
.await
|
||||
.expect("a visitor must be able to open a revision for reading");
|
||||
|
||||
// If this fails, the unauth revision response carried no usable size and the
|
||||
// whole streaming design needs revisiting — so it is asserted before the
|
||||
// reads, with a message that says which rung was missing.
|
||||
assert_eq!(
|
||||
reader.block_sizes(),
|
||||
&[BLOCK as u64, BLOCK as u64, 1 << 20],
|
||||
"the visitor must resolve true per-block sizes; a padded or empty vector \
|
||||
means the unauth revision response omitted its XAttr"
|
||||
);
|
||||
assert_eq!(
|
||||
reader.size(),
|
||||
contents.len() as u64,
|
||||
"and they must sum to the real file size"
|
||||
);
|
||||
|
||||
// A range straddling the block 0 / block 1 boundary.
|
||||
let straddle = reader
|
||||
.read_at(BLOCK as u64 - 1024, 2048)
|
||||
.await
|
||||
.expect("read across a block boundary");
|
||||
assert_eq!(
|
||||
straddle,
|
||||
&contents[BLOCK - 1024..BLOCK + 1024],
|
||||
"a read spanning two blocks must splice them at the right offset"
|
||||
);
|
||||
|
||||
// The seek case: a range wholly inside the *last* full block, touching
|
||||
// neither of the ones before it.
|
||||
let seeked = reader
|
||||
.read_at(6 << 20, 64 << 10)
|
||||
.await
|
||||
.expect("read from the middle of the file");
|
||||
let want = &contents[(6 << 20)..(6 << 20) + (64 << 10)];
|
||||
assert_eq!(
|
||||
seeked, want,
|
||||
"a mid-file read must return the bytes at that offset, not an earlier block's"
|
||||
);
|
||||
|
||||
// The short tail, and a read that runs past EOF: clamped, never padded.
|
||||
let tail = reader
|
||||
.read_at(8 << 20, 4 << 20)
|
||||
.await
|
||||
.expect("read the short final block");
|
||||
assert_eq!(
|
||||
tail,
|
||||
&contents[8 << 20..],
|
||||
"the final block is 1 MiB, and a read past EOF is clamped to it"
|
||||
);
|
||||
assert!(
|
||||
reader
|
||||
.read_at(contents.len() as u64, 4096)
|
||||
.await
|
||||
.expect("a read at EOF is not an error")
|
||||
.is_empty(),
|
||||
"a read starting at EOF yields nothing"
|
||||
);
|
||||
|
||||
// `download_range` is the one-shot form of the same thing.
|
||||
let ranged = visitor
|
||||
.download_range(&file, 5 << 20, 4096)
|
||||
.await
|
||||
.expect("download_range");
|
||||
assert_eq!(ranged, &contents[(5 << 20)..(5 << 20) + 4096]);
|
||||
|
||||
// And the whole-file path must still agree with all of it — it now drives a
|
||||
// paged, concurrent block pipeline rather than one un-paged sequential loop.
|
||||
let whole = visitor
|
||||
.download_file(&file)
|
||||
.await
|
||||
.expect("visitor download");
|
||||
assert_eq!(
|
||||
whole, contents,
|
||||
"the whole-file download must match the uploaded bytes"
|
||||
);
|
||||
|
||||
cleanup(client, &folder).await;
|
||||
}
|
||||
|
||||
/// A visitor's node listing must carry the uploader's claimed metadata, which it
|
||||
/// previously left as `None` — a catalog has nothing to show without a size.
|
||||
#[tokio::test]
|
||||
#[ignore = "live: needs test-account credentials"]
|
||||
async fn a_visitor_sees_claimed_size_and_digest_on_shared_files() {
|
||||
let Some(live) = common::live_client().await else {
|
||||
return;
|
||||
};
|
||||
let client = &live.client;
|
||||
|
||||
let folder = scratch_folder(client, "public-xattr").await;
|
||||
|
||||
let contents = positional_payload(64 << 10);
|
||||
let name = format!("claimed-{}.bin", common::unique_suffix());
|
||||
let file = client
|
||||
.upload_file(&folder, &name, "application/octet-stream", &contents)
|
||||
.await
|
||||
.expect("upload_file");
|
||||
|
||||
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 node = visitor
|
||||
.get_node(&file)
|
||||
.await
|
||||
.expect("visitor node lookup")
|
||||
.expect("the shared file must exist");
|
||||
|
||||
match &node.kind {
|
||||
proton_drive_rs::NodeKind::File {
|
||||
claimed_size,
|
||||
content_sha1,
|
||||
..
|
||||
} => {
|
||||
assert_eq!(
|
||||
*claimed_size,
|
||||
Some(contents.len() as i64),
|
||||
"the visitor must read the uploader's claimed size from the link details XAttr"
|
||||
);
|
||||
assert!(
|
||||
content_sha1.is_some(),
|
||||
"and the content digest alongside it"
|
||||
);
|
||||
}
|
||||
other => panic!("expected a file, got {other:?}"),
|
||||
}
|
||||
|
||||
cleanup(client, &folder).await;
|
||||
}
|
||||
|
||||
/// Renewing the session must not strand a reader that was already handed out —
|
||||
/// the whole reason the session is swapped in place rather than replaced.
|
||||
///
|
||||
/// Simulates the expiry rather than waiting hours for one: an explicit
|
||||
/// `refresh_session` puts the reader on a session it was not opened with, which
|
||||
/// is exactly the state a real expiry leaves it in once recovery has run.
|
||||
#[tokio::test]
|
||||
#[ignore = "live: needs test-account credentials"]
|
||||
async fn refreshing_the_session_keeps_an_open_reader_working() {
|
||||
let Some(live) = common::live_client().await else {
|
||||
return;
|
||||
};
|
||||
let client = &live.client;
|
||||
|
||||
let folder = scratch_folder(client, "public-renew").await;
|
||||
|
||||
let contents = positional_payload(5 << 20);
|
||||
let name = format!("renew-{}.bin", common::unique_suffix());
|
||||
let file = client
|
||||
.upload_file(&folder, &name, "application/octet-stream", &contents)
|
||||
.await
|
||||
.expect("upload_file");
|
||||
|
||||
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 reader = visitor.open_revision(&file).await.expect("open_revision");
|
||||
|
||||
let before = reader.read_at(0, 4096).await.expect("read before refresh");
|
||||
assert_eq!(before, &contents[..4096]);
|
||||
|
||||
visitor
|
||||
.refresh_session()
|
||||
.await
|
||||
.expect("refresh_session must replay the handshake");
|
||||
|
||||
// Past the first block, so this genuinely fetches under the new session
|
||||
// rather than replaying anything cached.
|
||||
let after = reader
|
||||
.read_at(4 << 20, 4096)
|
||||
.await
|
||||
.expect("a reader opened before the refresh must keep working");
|
||||
assert_eq!(after, &contents[(4 << 20)..(4 << 20) + 4096]);
|
||||
|
||||
cleanup(client, &folder).await;
|
||||
}
|
||||
|
||||
/// Thumbnails on the visitor path: the same block-storage fetch and content-key
|
||||
/// decrypt as the authenticated one, over `drive/unauth/`.
|
||||
#[tokio::test]
|
||||
#[ignore = "live: needs test-account credentials"]
|
||||
async fn a_visitor_can_download_thumbnails_of_shared_files() {
|
||||
let Some(live) = common::live_client().await else {
|
||||
return;
|
||||
};
|
||||
let client = &live.client;
|
||||
|
||||
let folder = scratch_folder(client, "public-thumb").await;
|
||||
|
||||
let contents = positional_payload(32 << 10);
|
||||
let thumbnail = b"not a real jpeg, but it round-trips".to_vec();
|
||||
let name = format!("thumbed-{}.bin", common::unique_suffix());
|
||||
let file = client
|
||||
.upload_file_from(
|
||||
&folder,
|
||||
&name,
|
||||
"application/octet-stream",
|
||||
std::io::Cursor::new(contents.clone()),
|
||||
contents.len() as i64,
|
||||
vec![
|
||||
Thumbnail::new(ThumbnailType::Thumbnail, thumbnail.clone())
|
||||
.expect("small thumbnail"),
|
||||
],
|
||||
None,
|
||||
false,
|
||||
)
|
||||
.await
|
||||
.expect("upload_file_from with a thumbnail");
|
||||
|
||||
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 single = visitor
|
||||
.download_thumbnail(&file, ThumbnailType::Thumbnail)
|
||||
.await
|
||||
.expect("visitor thumbnail download");
|
||||
assert_eq!(
|
||||
single.as_deref(),
|
||||
Some(thumbnail.as_slice()),
|
||||
"the visitor must decrypt the thumbnail it was shown"
|
||||
);
|
||||
|
||||
let batch = visitor
|
||||
.enumerate_thumbnails(std::slice::from_ref(&file), ThumbnailType::Thumbnail)
|
||||
.await
|
||||
.expect("visitor thumbnail batch");
|
||||
assert_eq!(batch.len(), 1, "one file in, one result out");
|
||||
assert_eq!(
|
||||
batch[0]
|
||||
.result
|
||||
.as_deref()
|
||||
.expect("the batch entry succeeded"),
|
||||
thumbnail.as_slice()
|
||||
);
|
||||
|
||||
cleanup(client, &folder).await;
|
||||
}
|
||||
@@ -16,7 +16,7 @@ use std::sync::Arc;
|
||||
|
||||
use tokio::task::JoinHandle;
|
||||
|
||||
use proton_drive_rs::{ProtonDriveClient, RevisionReader};
|
||||
use proton_drive_rs::{ProtonDriveClient, ProtonDrivePublicLinkClient, RevisionReader};
|
||||
use proton_sdk::ids::NodeUid;
|
||||
|
||||
/// `download_file` inside a spawned task.
|
||||
@@ -128,3 +128,49 @@ fn reader_read_at_is_spawnable(
|
||||
Ok::<_, proton_sdk::error::ProtonError>(())
|
||||
})
|
||||
}
|
||||
|
||||
/// The same guard on the visitor path, which is the one that actually matters
|
||||
/// for a streaming player: `open_revision` over a public link, then several
|
||||
/// concurrent `read_at`s off one shared reader inside spawned tasks.
|
||||
///
|
||||
/// The public client reaches this through a different route than the
|
||||
/// authenticated one — its transport wraps a renewable session behind a trait
|
||||
/// object — so `Send`ness has to be re-proven, not assumed.
|
||||
fn public_link_reader_read_at_is_spawnable(
|
||||
client: ProtonDrivePublicLinkClient,
|
||||
uid: NodeUid,
|
||||
) -> JoinHandle<proton_sdk::error::Result<()>> {
|
||||
tokio::spawn(async move {
|
||||
let reader: Arc<RevisionReader> = Arc::new(client.open_revision(&uid).await?);
|
||||
let mut set = tokio::task::JoinSet::new();
|
||||
for i in 0..4u64 {
|
||||
let reader = reader.clone();
|
||||
set.spawn(async move { reader.read_at(i * 4096, 4096).await });
|
||||
}
|
||||
while let Some(joined) = set.join_next().await {
|
||||
joined.expect("task panicked")?;
|
||||
}
|
||||
Ok::<_, proton_sdk::error::ProtonError>(())
|
||||
})
|
||||
}
|
||||
|
||||
/// `download_file_to` on the visitor path, which now drives the same `buffered`
|
||||
/// block pipeline the authenticated one does.
|
||||
fn public_link_download_file_to_is_spawnable(
|
||||
client: ProtonDrivePublicLinkClient,
|
||||
uid: NodeUid,
|
||||
) -> JoinHandle<proton_sdk::error::Result<()>> {
|
||||
tokio::spawn(async move {
|
||||
let mut sink: Vec<u8> = Vec::new();
|
||||
client.download_file_to(&uid, &mut sink).await
|
||||
})
|
||||
}
|
||||
|
||||
/// `enumerate_nodes` on the visitor path: chunked and fanned out with
|
||||
/// `buffered` over cloned clients.
|
||||
fn public_link_enumerate_nodes_is_spawnable(
|
||||
client: ProtonDrivePublicLinkClient,
|
||||
uids: Vec<NodeUid>,
|
||||
) -> JoinHandle<proton_sdk::error::Result<Vec<proton_drive_rs::Node>>> {
|
||||
tokio::spawn(async move { client.enumerate_nodes(&uids).await })
|
||||
}
|
||||
@@ -48,6 +48,16 @@ pub struct ApiHttpClient {
|
||||
/// root. Empty by default. Lives on the outer struct (not `Inner`) so clones
|
||||
/// can carry different prefixes while sharing one token/telemetry store.
|
||||
route_prefix: Arc<str>,
|
||||
/// Whether a 401 should be met with a token refresh and one retry.
|
||||
///
|
||||
/// True for an ordinary bearer session. False for an anonymous public-link
|
||||
/// session, whose refresh token is deliberately empty
|
||||
/// (`public_link.rs::PublicLinkSession::auth`) — there, refreshing posts an
|
||||
/// empty token to `auth/v4/refresh` and the caller sees an error about
|
||||
/// *refresh* instead of the 401 that actually happened. Lives on the outer
|
||||
/// struct alongside `route_prefix`, for the same reason: clones differ
|
||||
/// while sharing one token store.
|
||||
refresh_tokens: bool,
|
||||
}
|
||||
|
||||
/// Callback invoked after a successful token refresh.
|
||||
@@ -98,6 +108,7 @@ impl ApiHttpClient {
|
||||
on_tokens_refreshed: std::sync::Mutex::new(None),
|
||||
}),
|
||||
route_prefix: Arc::from(""),
|
||||
refresh_tokens: true,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -110,6 +121,24 @@ impl ApiHttpClient {
|
||||
Self {
|
||||
inner: Arc::clone(&self.inner),
|
||||
route_prefix: route.into(),
|
||||
refresh_tokens: self.refresh_tokens,
|
||||
}
|
||||
}
|
||||
|
||||
/// Derive a clone that does **not** try to refresh its tokens on a 401,
|
||||
/// sharing this client's token store, telemetry sink and connection pool.
|
||||
///
|
||||
/// For a session that has no refresh token to spend — an anonymous
|
||||
/// public-link session is minted with an empty one — refreshing turns a
|
||||
/// clean "your session is gone" 401 into a confusing failure of
|
||||
/// `auth/v4/refresh` itself. With refresh off, the 401 reaches the caller
|
||||
/// intact as `ProtonError::Api { http_status: 401 }`, which is what a
|
||||
/// re-handshake path needs in order to recognise its cue.
|
||||
pub fn without_token_refresh(&self) -> Self {
|
||||
Self {
|
||||
inner: Arc::clone(&self.inner),
|
||||
route_prefix: Arc::clone(&self.route_prefix),
|
||||
refresh_tokens: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -288,7 +317,7 @@ impl ApiHttpClient {
|
||||
let response = self
|
||||
.send_multipart_with_token(path, &metadata, binary_parts, &rejected_token)
|
||||
.await?;
|
||||
let response = if response.status() == StatusCode::UNAUTHORIZED {
|
||||
let response = if response.status() == StatusCode::UNAUTHORIZED && self.refresh_tokens {
|
||||
let bytes = response.bytes().await?;
|
||||
if let Ok(envelope) = serde_json::from_slice::<ApiResponse>(&bytes)
|
||||
&& matches!(
|
||||
@@ -349,7 +378,7 @@ impl ApiHttpClient {
|
||||
.send_with_token(method.clone(), path, body, &access_token)
|
||||
.await?;
|
||||
|
||||
let response = if response.status() == StatusCode::UNAUTHORIZED {
|
||||
let response = if response.status() == StatusCode::UNAUTHORIZED && self.refresh_tokens {
|
||||
self.handle_unauthorized(method, path, body, response, access_token)
|
||||
.await?
|
||||
} else {
|
||||
@@ -895,4 +924,107 @@ mod tests {
|
||||
.any(|(k, v)| *k == "status" && v == "200")
|
||||
);
|
||||
}
|
||||
|
||||
/// A client built with [`ApiHttpClient::without_token_refresh`] hands the
|
||||
/// 401 straight to the caller instead of spending a refresh token on it.
|
||||
///
|
||||
/// The request count is the real assertion: an ordinary client answers a 401
|
||||
/// by posting to `auth/v4/refresh` and retrying, so a second connection
|
||||
/// would prove the opt-out did not take. A public-link session has no
|
||||
/// refresh token to spend, and needs the bare 401 to know it must re-run the
|
||||
/// SRP handshake.
|
||||
#[tokio::test]
|
||||
async fn a_client_without_token_refresh_surfaces_the_401() {
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let requests = Arc::new(AtomicUsize::new(0));
|
||||
|
||||
// Answers every connection with a 401, so a client that retries after a
|
||||
// refresh gets counted rather than hanging.
|
||||
let seen = requests.clone();
|
||||
let server = tokio::spawn(async move {
|
||||
loop {
|
||||
let (mut sock, _) = listener.accept().await.unwrap();
|
||||
seen.fetch_add(1, Ordering::SeqCst);
|
||||
let mut buf = [0u8; 2048];
|
||||
let _ = sock.read(&mut buf).await.unwrap();
|
||||
let body = br#"{"Code":401,"Error":"Invalid access token"}"#;
|
||||
let head = format!(
|
||||
"HTTP/1.1 401 Unauthorized\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
|
||||
body.len()
|
||||
);
|
||||
sock.write_all(head.as_bytes()).await.unwrap();
|
||||
sock.write_all(body).await.unwrap();
|
||||
sock.flush().await.unwrap();
|
||||
}
|
||||
});
|
||||
|
||||
let config = ProtonClientConfiguration::new("test@1.0")
|
||||
.with_base_url(format!("http://{addr}/"))
|
||||
.with_retry_policy(RetryPolicy::disabled());
|
||||
let client = ApiHttpClient::new(
|
||||
config,
|
||||
SessionId::from("test-session"),
|
||||
// Empty, exactly as a public-link session is minted.
|
||||
Tokens {
|
||||
access_token: "access".into(),
|
||||
refresh_token: String::new(),
|
||||
},
|
||||
)
|
||||
.unwrap()
|
||||
.without_token_refresh();
|
||||
|
||||
let error = client
|
||||
.get::<ApiResponse>("some/path")
|
||||
.await
|
||||
.expect_err("a 401 is an error");
|
||||
|
||||
match error {
|
||||
ProtonError::Api(e) => assert_eq!(e.http_status, 401, "the 401 reaches the caller"),
|
||||
other => panic!("expected an api error, got {other:?}"),
|
||||
}
|
||||
assert_eq!(
|
||||
requests.load(Ordering::SeqCst),
|
||||
1,
|
||||
"no refresh attempt, no retry"
|
||||
);
|
||||
|
||||
server.abort();
|
||||
}
|
||||
|
||||
/// The opt-out rides along on `with_base_route`, so a Drive/public-link
|
||||
/// client derived from a non-refreshing one does not silently regain the
|
||||
/// refresh behaviour.
|
||||
#[test]
|
||||
fn the_token_refresh_opt_out_survives_a_base_route_change() {
|
||||
let client = ApiHttpClient::new(
|
||||
ProtonClientConfiguration::new("test@1.0"),
|
||||
SessionId::from("test-session"),
|
||||
Tokens {
|
||||
access_token: "access".into(),
|
||||
refresh_token: String::new(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(client.refresh_tokens, "an ordinary session refreshes");
|
||||
assert!(
|
||||
!client.without_token_refresh().refresh_tokens,
|
||||
"the opt-out takes"
|
||||
);
|
||||
assert!(
|
||||
!client
|
||||
.without_token_refresh()
|
||||
.with_base_route("drive/unauth/")
|
||||
.refresh_tokens,
|
||||
"and survives a route change"
|
||||
);
|
||||
assert!(
|
||||
client.with_base_route("drive/").refresh_tokens,
|
||||
"an ordinary session keeps refreshing across a route change"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user