docs: hand off the paused Proton Drive backend rewrite

Development of plan 001 is paused after Task 17 of 21. Record the state,
the rulings made during execution, the test setup and how to resume in
docs/plans/001-protondrive-tier1-HANDOFF.md, commit the execution ledger,
task briefs, implementer reports and reviews, and mark the plan and spec
with their current status.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
This commit is contained in:
Lucas WintherandClaude Fable 5.2 committed 2026-11-11 21:09:43 +01:00
1 parent 9c90998f76
commit 31f6aafd91
51 files changed
+5942 -1

No files matched your search

+14
View File
@@ -5,6 +5,20 @@ it with a new one that links back. One entry per decision a future maintainer
would otherwise have to reverse-engineer.
No index: `grep -n '^## ' docs/DECISIONS.md` lists every entry.
## 2026-11-11 - Proton Drive rewrite paused mid-plan and merged to master
- **Decision:** at the owner's request, work on plan 001 stopped after Task
17 of 21 and the branch was fast-forwarded into `master` and pushed, with
the plan, a handoff document and the execution workspace committed under
`docs/plans/`.
- **Why:** the owner wanted development wound down with nothing left only
in an agent's conversation.
- **Consequences:** `master` carries an unfinished plan, against the usual
rule that plans are removed before merging; the user docs still describe
the old backend; the old libraries are still in `go.mod`. The push to
`master` was a one-off instruction - the rule against pushing to
`master` stands. Resume from `docs/plans/001-protondrive-tier1-HANDOFF.md`.
## 2026-11-11 - Proton Drive logs what ignore_signature_errors lets through
**Status:** accepted
+8
View File
@@ -6,6 +6,14 @@ Small, specific, independently actionable. Remove only when actually done.
## Now
- [ ] protondrive: the backend rewrite (plan 001) is paused after Task 17 of
21. Start from `docs/plans/001-protondrive-tier1-HANDOFF.md`. Remaining:
Task 18 (wider suites against the fake, fork CI), 19 (remove the old
libraries), 20 (user docs - `docs/content/protondrive.md` still describes
the old backend), 21 (live verification, paid account never signed in to),
then the whole-branch review; later plans for M5 (events) and M6
(persistent cache, Metadata, small-file upload).
## Next
- [ ] protondrive: API responses have no body-size limit, and a block
+340
View File
@@ -0,0 +1,340 @@
# Handoff: Proton Drive backend rewrite (plan 001)
Written 2026-11-11 when development was paused at the owner's request.
Everything a new agent needs is in this repository; nothing depends on the
conversation that produced it. Fork-only document - never include it in a
PR to rclone/rclone.
## 1. What this is
The owner's fork of rclone is rewriting `backend/protondrive` as a native Go
client for Proton Drive's current API (volume-based `drive/v2` routes),
replacing the abandoned `Proton-API-Bridge` / `go-proton-api` libraries.
The goal is a working, maintainable, secure backend that rclone's
maintainers could one day accept.
Read, in this order:
1. `AGENTS.md` - code, comment and commit rules; the "Fork Workflow" section.
2. `docs/specs/001-protondrive-tier1.md` - the approved spec (binding).
3. `docs/plans/001-protondrive-tier1.md` - the 21-task plan. Checkboxes
show what is done.
4. This file - state, rulings, and how to resume.
5. `docs/plans/001-protondrive-tier1-workspace/progress.md` - the full
execution ledger (every ruling, every deferred minor, every live probe).
6. `docs/ARCHITECTURE.md`, `docs/DECISIONS.md`, `docs/TODO.md`.
## 2. Owner's standing instructions
- Nothing is posted upstream (no issue, PR or forum post) for now. The
upstream path in spec section 13 is deferred.
- The owner is not planning permanent maintenance of the backend.
- The fake server (`backend/protondrive/protontest`) stays in the fork even
though it is about a third of the code; it would not go upstream.
- Implementers are Opus 5.5 subagents, one task at a time; each task gets
an independent review (Opus for auth, crypto, concurrency and the swap;
Sonnet for small diffs and scoped re-reviews).
- "Once I approve a design, carry it through spec, plan and execution with
no extra review stops. Report the decisions you made at the end."
- Permission rules: `docs/PERMISSIONS.md` (no sudo, no force-push, no
`git reset --hard`, no `git clean`, no recursive `rm`). Ask before adding
or upgrading a dependency.
- The push of this work to `master` on 2026-11-11 was an explicit one-off
instruction from the owner. The standing rule in `AGENTS.md` (never push
or commit directly to `master`) still applies to future work: branch
first and ask before pushing.
## 3. State at pause
Branch `protondrive-tier1`, fast-forwarded into `master` and pushed at the
pause. Last code commit: `9c90998f7`. Plan base (for a whole-branch diff):
`06700aaf2`.
| Tasks | State |
|---|---|
| 1-17 | Implemented, each independently reviewed, all findings of Critical or Important severity fixed and re-reviewed |
| 18-21 | Not started |
| Final whole-branch review | Not run |
What exists now:
- `backend/protondrive/protondrive.go` is rewritten on the native client
(`session.go`, `account.go`, `crypto.go`, `nodes.go`, `cache.go`,
`download.go`, `upload.go`). The old bridge libraries are no longer used
by the backend; `go-proton-api` is still imported by the differential
tests and both remain in `go.mod` until Task 19.
- `backend/protondrive/protontest` is the fake Proton server (fork-only),
with `protontest/cmd/protonfake` as a standalone binary.
- rclone's backend contract suite (`fstests.Run`) passes against the fake
as `TestIntegrationFake`: 126 subtests, 97 pass, 29 skipped by the
suite's own feature checks, no opt-outs.
- Test files: `*_internal_test.go` per client file, `fake_test.go`
(harness), `regression_test.go` (16 user-reported failures),
`security_test.go` (27 hostile-server cases, secret hygiene),
`budget_test.go` (request budgets), `fuzz_internal_test.go` (5 targets),
`crypto_diff_internal_test.go` (differential against the old libraries),
frozen fixtures in `testdata/oldlib/`.
- Verified at `9c90998f7` on 2026-11-11: `go build ./...`,
`go vet ./backend/protondrive/...`,
`golangci-lint run ./backend/protondrive/...` (0 issues) and
`RCLONE_CONFIG=/notfound go test -race ./backend/protondrive/...` all
pass. The race suite takes about 5 to 6 minutes.
Not done, and easy to assume otherwise:
- The user documentation (`docs/content/protondrive.md`) still describes
the old backend (Task 20).
- The wider suites (`fs/operations`, `fs/sync`, `vfs` via
`fstest/test_all`) have never been run against the new backend (Task 18).
- No fork CI exists (Task 18).
- `ChangeNotify`, the persistent cache, Metadata and the small-file upload
path are not implemented (M5, M6).
- Ranged reads (mount, `--multi-thread-streams`, `serve`) check each
block's hash but not the signed manifest (`docs/TODO.md`).
## 4. How to resume
Development used the `superpowers:subagent-driven-development` skill. Its
workspace is normally git-ignored at
`.superpowers/sdd/001-protondrive-tier1/`; a copy of every Markdown file
from it (ledger, per-task briefs, implementer reports, reviews, the shared
implementer/reviewer rules and the global constraints) is committed at
`docs/plans/001-protondrive-tier1-workspace/`. To resume with the skill:
```bash
mkdir -p .superpowers/sdd/001-protondrive-tier1
cp docs/plans/001-protondrive-tier1-workspace/*.md .superpowers/sdd/001-protondrive-tier1/
git switch -c protondrive-tier1-resume # never work on master
```
The ledger's first line names the plan; tasks with a `Task N: complete`
line are done. Paths inside `implementer-common.md` and
`reviewer-common.md` assume the checkout is `/home/user/rclone` and the
branch `protondrive-tier1` - adjust both if they differ.
Per task: extract the brief (`task-N-brief.md` holds the plan text for
task N; later ones are not extracted yet - copy the task's section from
the plan), record `BASE=$(git rev-parse HEAD)`, dispatch one implementer
with the brief plus `implementer-common.md`, then a reviewer with the
brief, the report and `git diff -U10 BASE..HEAD`, fix until clean, and
record it in the ledger. After Task 21: one whole-branch review on the
most capable model (merge base `06700aaf2`), one fix wave, then
`git rm` the plan and this workspace copy before any upstream-bound merge.
### Remaining tasks (plan sections of the same number)
- **18 - Wider hermetic suites and fork CI.** `init.d` script starting
`protonfake`, `test_all` config entry, `.gitea/workflows/ci.yml`,
`docs/PERMISSIONS.md` update. Expect real backend failures from
`fs/operations`, `fs/sync` and `vfs`; fix them in the backend.
Note `-run TestIntegration` matches both the live and the fake test.
- **19 - Remove the old libraries.** Move the frozen-fixture tests, delete
the differential test file, `go mod tidy`, compare binary size,
cross-compile.
- **20 - Documentation.** Rewrite `docs/content/protondrive.md`; write
`docs/specs/001-conformance-checklist.md`. Must cover what this pause
leaves undocumented for users: the `ignore_signature_errors` behaviour
and its consequences, the ranged-read caveat, `--dump headers` showing
`pm-storage-token` and `x-pm-uid`, the whole-revision rollback limit.
- **21 - Live verification.** The plan text says it is blocked until a
test account exists and that the owner configures it by browser
sign-in. That is out of date: both accounts exist and are configured
with username and password (section 5). First things to check live:
the root-of-trust rule on a drive created by Proton's own apps (the paid
account); how many folders fail key verification on a real account;
whether the server lists drafts and trashed items as children; browser
sign-in; `TestIntegration` and `test_all` against the real service.
Later milestones, each needing its own plan: M5 (events and `ChangeNotify`),
M6 (persistent metadata cache, rclone Metadata support, small-file
single-request upload), M7 (upstream path, deferred by the owner).
## 5. Test accounts and machine setup
Machine-local, not in the repository. A new machine needs them recreated.
- Go 1.27.4 is in `~/.local/opt/go` (symlinked into `~/.local/bin`),
golangci-lint 2.15.0 in `~/.local/bin`.
- Two Proton accounts the owner created for testing, configured as rclone
remotes in `~/.config/rclone/rclone.conf` (mode 600, passwords obscured):
- `TestProtonDrive:` - free account,
password in
`~/proton_rclone_test_acc_password`.
- `TestProtonDrivePaid:` - paid account,
password in
`~/proton_paid_acc_password`. **Never signed in to yet.**
Never print, paste, log or commit these passwords or any token, and never
put them in a subagent's prompt.
- **Every `go test` must set `RCLONE_CONFIG=/notfound`**, e.g.
`RCLONE_CONFIG=/notfound go test -race ./backend/protondrive/...`.
Without it the live `TestIntegration` signs in to the real service on
every run. Only Task 21 talks to Proton deliberately.
- Live probes were run from a scratch `git worktree` so nothing temporary
could be committed. Proton's guidelines for third-party clients apply:
honest `x-pm-appversion` (`external-drive-rclone@<semver>-<channel>`),
official endpoints only, no hammering.
- The reference checkout of Proton's official SDK used during development
was `~/.cache/protondrive-sdk-ref` (github.com/ProtonDriveApps/sdk at
commit `05c9115`, tag `js/v0.23.0`). Read-only; never built or run.
- Live state on the free account: folder
`rclone-live-probe-20261111-130916` (files `empty.bin`, `five-mib.bin`,
`hello.txt` with two revisions, and `my-little-folder/Readme.txt` made by
the owner in Proton's web app). Leave it: it is the interop evidence.
## 6. What has been proven live (free account)
- Password (SRP) sign-in with the new code; a fresh account's Drive volume
is created by the client.
- Uploads of 0 B, 25 B, 5 MiB+3 and 9 MB files and a second revision are
accepted by Proton and read back byte-identical with signature and
manifest verification.
- Proton's web app lists the files, opens them, and shows "Digital
signature verified", the right size, modification time and SHA1.
- A folder and a file created in Proton's web app are listed, downloaded
and verified by the new code.
- With the rewritten backend (binary built at `c00162ed2`): `lsd`, `lsl`,
`cat`, `sha1sum`, `about`, `sync` up, `check` (0 differences), new
revision, server-side `moveto` and `copyto`, `sync` back byte-identical,
`rmdir` of a non-empty folder refused, `purge`.
Not yet proven: the paid account; browser (session-fork) sign-in against
the real service; 2FA; the official CLI or desktop clients reading rclone's
files; `fstests`/`test_all` against the real service; whether the real
server lists drafts or trashed items as children (affects `Rmdir`); the
items marked "confirm on a real account" in `docs/TODO.md`; anything
changed after `c00162ed2` (signature refusal, manifest-first reads, trust
inheritance, cache insert-in-place, decompression limits).
## 7. Rulings made during execution
Decisions the controller made where the plan or spec was silent or wrong.
Each is in the ledger with its reason and cost; the owner has not been
asked to confirm them individually.
1. No separate worktree: work happened on branch `protondrive-tier1` in
the main checkout.
2. Task 8's retry test is `TestClientShouldRetry` (name collision).
3. The fake keeps Proton's published app-version grammar (channel
optional); the client always sends a channel.
4. Two sessions sharing one config: after a rejected refresh the session
re-reads the credential store for up to 3 s to adopt the other's new
tokens. No cross-`Fs` lock (the spec allows no package state beyond
`unlockMu`).
5. The key salt is chosen by the primary user key's ID (old library
behaviour), first entry as fallback.
6. A fresh account has no Drive volume (`my-files` answers 422 code 2501):
the client creates it, as the SDK does.
7. Written content follows the SDK (v4 node key advertising AEAD, v6 PKESK
content key, SEIPDv2 blocks) even where the old library's comments
disagree; confirmed accepted by the real server and web app.
8. The intended-recipient check on signatures is kept (stricter than the
SDK may be).
9. The fake rejects AEAD-encrypted names, passphrases, hash keys and
extended attributes written by the client.
10. An honoured `Retry-After` is capped at 60 s.
11. Upload commit outcome unknown: delete nothing, return
`errUploadOutcomeUnknown`. A draft is deleted only on positive proof it
never committed.
12. The fake reports a draft's owner in conflict details but does not gate
routes by `ClientUID`.
13. `Proton-API-Bridge` stays in `go.mod` until Task 19 even though no Go
file imports it any more.
14. In `rclone config`, browser sign-in is the default only when username
and password are not both set.
15. `Shutdown` leaves the `Fs` usable (it only drops the node cache); keys
are not cleared. This supersedes the plan's "Shutdown calls
`client.close()` once". `Disconnect` logs out; later use returns a
clear "not signed in" error naming the remote.
16. `Rmdir` asks the server, uncached, whether the folder has any child at
all and refuses if so.
17. `rclone config` on a remote with a saved session asks before signing in
again (default no) and logs the old session out when it does.
18. A file whose node key is not signed by its owner is refused unless
`ignore_signature_errors` is set. A key verified against an unverified
folder key is itself unverified, all the way down; creating, uploading,
renaming, moving or copying into such a folder is refused without the
flag. The flag never disables integrity checks.
19. A full read fetches the whole block list and verifies the signed
manifest before releasing any byte. Ranged reads do not verify the
manifest yet (`docs/TODO.md`). First-byte latency on very large files
grows by about one request per 80 MiB.
A re-fetched block list that changes a hash is an integrity error.
20. `TestBudgetNewObject` allows 8 requests, not the plan's 7 (four folder
levels at two requests each; Proton has no lookup-by-name route).
21. Every node beneath a folder whose key is unverified is unverified,
reads included, even when signed by the user's own address (the
implementer went beyond the ruling; a reviewer judged it justified and
it was kept). A genuine folder signed by a since-deleted address locks
its subtree behind `ignore_signature_errors`.
22. Moving, copying or renaming a node whose key is unverified is refused
without the flag. With the flag it proceeds, logs an ERROR once per
node and operation, and the node is trusted from then on.
23. Root of trust: the My Files share's creator and the root folder's
signer must be one of the account's own addresses, else the whole
drive is unverified. Addresses are compared after lowercasing ASCII
letters only; a match is always verified with the account's own keys
and never looked up on the server.
24. A file may list at most 2^19 blocks (2 TiB at 4 MiB). This bounds the
count, not the bytes: about 250-350 MiB of block metadata at the cap.
25. Parent chains deeper than 2048, or cyclic, are an integrity error.
26. A range covering the whole file takes the verified full-read path and
returns only the bytes asked for.
27. A folder listing is cached only if the folder's children did not
change while it was being read (per-folder change count).
28. Names that are not valid UTF-8 are refused on write.
Known protocol limits, documented rather than fixed: a server that serves
a whole older, validly signed revision under the current revision ID
cannot be detected (Proton's SDK cannot either); below the root, something
signed under another user's email is verified against public keys the
server itself supplies.
## 8. Deferred findings
Every review's minor findings were deferred to the final whole-branch
review. They are listed per task in the ledger (`progress.md`, lines
beginning `Task N: minor (deferred)` and "Deferred minors"), and the
per-task review files in the workspace copy give file and line. Items that
need a decision or a live check are in `docs/TODO.md`. The ones most worth
a new agent's attention:
- Ranged reads do not verify the signed manifest. Planned fix: a bounded
per-revision cache of verified block hashes.
- With `ignore_signature_errors` set, `Update` of an unverified file logs
"reading", not "updating" (`upload.go`, `download.go` `contentKey`).
- The depth-limit error is re-wrapped at every level and reaches about
88 KB; a hostile server can flood the log with it (`nodes.go`).
- `TestListingRacingChange` can hang until the package timeout if it
fails before releasing its held request; `protonfake`'s `main_test.go`
leaks its `run` goroutine on a failed assertion.
- `TestUploadShortSource` failed its goroutine-leak check once under heavy
parallel load and never again; `TestListCancel` uses the same
goroutine-count approach.
- `ModTime` is returned with millisecond precision while `Precision()`
says one second.
- Move, Copy and DirMove between two different accounts are not refused
with `fs.ErrorCant*` (rclone only calls them for the same remote name).
- `apiCall` retries non-idempotent mutations after 5xx or network errors.
- An extended-attribute `"Size": null` is read as size 0.
- API responses have no body-size limit; API error messages are neither
bounded nor sanitised before logging.
- Two processes sharing tokens through environment variables cannot see
each other's refresh.
- The race test for volume creation waits 2 s for no benefit.
- Real-server behaviours the fake only assumes: error codes for a bad
verifier, a missing block and a stale revision ID; whether drafts are
hidden from children; what `delete_multiple` does to an active link.
## 9. Before any upstream work
The spec's upstream path (section 13) is deferred. If it is ever taken
up: rclone's maintainers refuse cgo; Proton's SDK README says the SDK "is
not yet ready for third-party production use" and allows personal,
non-commercial projects under its guidelines; a Proton engineer wrote that
Proton "does not prohibit the usage of rclone, but does not offer formal
support for it either" (rclone issue #9252, rclone forum thread 53609).
Fork-only paths (`docs/specs/`, `docs/plans/`, `docs/ARCHITECTURE.md`,
`docs/DECISIONS.md`, `docs/TODO.md`, `docs/PERMISSIONS.md`, `GEMINI.md`,
`.claude/`, `.codex/`, `.gemini/`, the "Fork Workflow" section of
`AGENTS.md`, and `backend/protondrive/protontest`) never go into an
upstream PR.
@@ -0,0 +1,168 @@
# Flake report: TestClientBootstrapCreateVolumeRace
Commit: `e23de0b58 protondrive: make the volume creation race test deterministic`
(branch `protondrive-tier1`, not pushed).
## Summary
- **Root cause.** The test assumed both clients' first `GET drive/v2/shares/my-files` would be handled before either client had created the volume. Nothing guaranteed that. `loadAccount` holds the process-wide `unlockMu` while it unlocks keys, so the second client's first GET always goes out at least one key unlock after the first client's. In a whole-package run, other parallel tests queue on the same mutex, so the gap can be much longer. The old `Delay: 500ms` fault delayed *both* GETs by the same amount, so it did nothing to keep them together. When the first client's create finished before the second client's GET was handled, the second client found the volume and never POSTed. That made `assert.Equal(t, 2, srv.Requests()["POST /drive/volumes"])` fail with `expected: 2, actual: 1`.
- **The client is correct.** The losing interleaving ("the volume already exists by the time I look") is the client's ordinary existing-drive path. Only the test's assertion depended on timing.
- **Second bug, found by the deterministic test.** Once both create-volume POSTs were forced to run concurrently, the race detector found a genuine data race in the **fake** (`protontest.handleCreateVolume` / `checkVolumeRequest`). go-crypto caches signature-check results inside the key the first time the key is used. The new user's address key had never been used, so two concurrent validations wrote to it at once. It is now fixed: each request is checked with its own copy of the key.
## 1. Reproduction
- In isolation it never failed: `-race -count=50` passed, and so did `-count=20 -cpu 1,2`. CPU contention alone does not reproduce it.
- It reproduced with **6 concurrent whole-package `-race -v` runs** of a prebuilt test binary: 3 of 6 runs failed (an earlier batch of 6 had 0 failures). All three failures were identical:
```
=== NAME TestClientBootstrapCreateVolumeRace
nodes_internal_test.go:139:
Error Trace: /home/user/rclone/backend/protondrive/nodes_internal_test.go:139
Error: Not equal:
expected: 2
actual : 1
Test: TestClientBootstrapCreateVolumeRace
--- FAIL: TestClientBootstrapCreateVolumeRace (8.92s)
```
Line 139 was the `POST /drive/volumes` count assertion. None of those runs logged `WARNING: DATA RACE`.
## 2. Root cause, with evidence
I added a temporary copy of the test (since deleted) whose HTTP transport logged the send and completion time of every drive request for each client.
**In isolation** the margin is tiny. c1's first GET goes out about 130ms after c0's: that is c0's key unlock, serialized by `unlockMu`. c0's create finishes at 792ms, and c1's GET is handled at about 757ms, so only about 35ms separates the passing ordering from the failing one:
```
c0 GET /drive/v2/shares/my-files sent=129ms done=630ms status=422
c1 GET /drive/v2/shares/my-files sent=256ms done=757ms status=422
c0 POST /drive/volumes sent=685ms done=792ms status=200
c0 GET /drive/v2/shares/my-files sent=793ms done=794ms status=200
c1 POST /drive/volumes sent=814ms done=814ms status=422 (2500)
c1 GET /drive/v2/shares/my-files sent=825ms done=825ms status=200
```
**Under whole-package load** the gap between the first GETs grew to between 240ms and 1.17s (runs that still passed):
```
c1 GET sent=418ms ... c0 GET sent=1.267s (gap 850ms)
c0 GET sent=239ms ... c1 GET sent=1.144s (gap 905ms)
c1 GET sent=466ms ... c0 GET sent=1.638s (gap 1.17s)
```
When the gap is longer than the winner's "keys + POST + validation" time (about 160ms idle, several hundred ms under load), the second GET is handled after the volume exists. The second client then never POSTs, which is the failure above.
**Why the gap exists.** `loadAccount` (`account.go`) takes `unlockMu`, a package-level mutex: "unlocks for different sessions run one at a time". So the second client's GET is always at least one unlock behind. In the full package, every parallel test's `newClient` queues on the same mutex, which explains why it only reproduced in whole-package runs.
**Candidates from the brief:**
| Candidate | Verdict |
|---|---|
| Loser re-reads my-files before the winner's volume is visible | Ruled out. The fake answers 2500 only under `s.mu` with `u.drive != nil`, and the trace shows the loser's re-GET always returned 200. |
| Timing assumption in the test (Delay vs real ordering) | **Root cause.** The Delay shifted both GETs equally and could not keep them together. |
| Fake's create handler validating outside the lock, interleaving badly | Not the flake's cause; the client handles both 2500 paths identically. But it hid a real **data race in the fake**, exposed once the POSTs were forced to run together (section 3b). |
| Pacer / Retry-After | Ruled out. No 429 or 5xx appears in the traces; 4xx is not retried. |
| A data race reported in the original flake | None in the failing runs. A race appeared only after the POSTs were made concurrent, and it is in the fake. |
**The production path is correct for every two-client interleaving:**
1. Both see 2501 and both POST. One gets 200; the other gets 2500, which `createVolume` treats as success, then re-reads my-files and finds the volume, because a 2500 means it exists.
2. The second client looks after the first has created the volume. It gets 200 straight away: the normal existing-drive path, with no POST.
3. The winner's POST commits but the response is lost or 5xx. The pacer retries, gets 2500, treats it as success, then re-reads. Correct.
The only residual assumption is that a real server's 2500 implies the volume is immediately readable through my-files (see Concerns).
## 3. Fix
### a. Make the interleaving deterministic (test and fake)
- `protontest.Fault` gains a `Gather bool` option: "hold each faulted request until all Times have arrived". `Inject` panics if `Gather` is set without `Times`. A held request still ends when the client gives up, so a stuck test cannot hang.
- `TestClientBootstrapCreateVolumeRace` now:
- gathers both `GET my-files` requests, so both clients see 2501 whatever their timing;
- gathers both `POST /drive/volumes` requests, so both creations are validated at once and the loser hits the fake's late "already exists" check;
- starts the second client as late as the server allows: after the first client finishes, or after 2s if the server is holding the first client back for it. This models a long `unlockMu` queue;
- runs under a context with a one-minute timeout.
- A new `TestFaultInjection/Gather` subtest checks that the first request is not answered until the second arrives, and that requests after `Times` are not held.
### b. Data race in the fake (found by 3a)
With the POSTs gathered, 3 of about 180 focused runs failed with `race detected during execution of test`. Every race stack was entirely in the fake:
```
WARNING: DATA RACE
Write at 0x00c0000f00f0 by goroutine 42:
github.com/ProtonMail/go-crypto/openpgp/v2.(*Identity).LatestValidSelfCertification() user.go:212
...(*Entity).DecryptionKeys() / ReadMessage() / gopenpgp Decrypt()
protontest.decryptArmored() protontest/crypto.go:239
protontest.unlockDriveKey() protontest/crypto.go:280
protontest.checkVolumeRequest() protontest/drive.go:1677
protontest.(*Server).handleCreateVolume() protontest/drive.go:1656
Previous read at 0x00c0000f00f0 by goroutine 32:
(same stack, the other create-volume request)
```
go-crypto's `LatestValidSelfCertification` caches `sig.Valid` inside the key on first use. A user created without a drive has never used its address key, so two concurrent `checkVolumeRequest` calls both did that first write. Other handlers share keys that setup has already used, so they only ever read. The fix is in `handleCreateVolume`: validate with `addrKey.key.Copy()`, the key's own copy for that request. After this, 200 focused runs and 12 whole-package runs reported 0 races.
## 4. Before / after (the forced interleaving)
**Before**: the final test shape with the old `Delay: 500ms` GET fault in place of the Gather faults, at `-race -count=10`:
```
--- FAIL: TestClientBootstrapCreateVolumeRace (2.30s)
nodes_internal_test.go:153:
Error: Not equal:
expected: 2
actual : 1
...
fails: 10/10
```
**After** (Gather faults, before the key-copy fix): 3 of about 180 runs failed with the fake data race in 3b. That run also hit go test's 10-minute default timeout, because each run costs about 2.3s by design.
**After** (committed tree): `RCLONE_CONFIG=/notfound go test -race -timeout 40m -count=200 -run 'TestClientBootstrapCreateVolumeRace$' ./backend/protondrive/`
```
ok github.com/rclone/rclone/backend/protondrive 610.471s
FAIL lines: 0 races: 0
```
`TestFaultInjection/Gather`: 30 of 30 passed at `-race -count=30`.
## 5. Verification
- Focused test: `-race -count=200` passed, with 0 races (above).
- Whole package, sequential, on commit e23de0b58: `RCLONE_CONFIG=/notfound go test -race -count=1 -v ./backend/protondrive/...` six times in a row:
```
run 1 exit=0 protondrive 95.1s ok, protontest 32.6s ok fails=0 races=0
run 2 exit=0 protondrive 98.3s ok, protontest 33.3s ok fails=0 races=0
run 3 exit=0 protondrive 91.7s ok, protontest 32.4s ok fails=0 races=0
run 4 exit=0 protondrive 90.4s ok, protontest 32.5s ok fails=0 races=0
run 5 exit=0 protondrive 92.9s ok, protontest 31.9s ok fails=0 races=0
run 6 exit=0 protondrive 91.8s ok, protontest 32.4s ok fails=0 races=0
```
- Whole package under contention, the same setup that reproduced the bug 3 of 6 times: 6 concurrent `-race -v` runs of the committed binary all passed, with 0 races. The volume test took 12 to 14s per run.
- `go build ./...`, `go vet ./backend/protondrive/...` and `golangci-lint run ./backend/protondrive/...` (0 issues) are all clean.
- A `protontest` run at `-race -count=20` "FAIL"ed only on go test's 10-minute default timeout. One package run takes 33s, so 20 runs need about 660s. This is not a hang: individual runs and the Gather subtest at count=30 all pass.
## Other intermittent failure seen (not fixed, out of scope)
`TestUploadShortSource` failed once, in one of the six concurrent whole-package runs of the first loaded batch (the one that also reproduced the volume flake):
```
=== RUN TestUploadShortSource
upload_internal_test.go:196:
Error Trace: /home/user/rclone/backend/protondrive/download_internal_test.go:114
/home/user/rclone/backend/protondrive/upload_internal_test.go:196
/home/user/rclone/backend/protondrive/upload_internal_test.go:201
Error: Condition never satisfied
Test: TestUploadShortSource
Messages: goroutines left running
--- FAIL: TestUploadShortSource (4.14s)
```
`requireNoLeaks` waits up to 2s for the **process-wide** `runtime.NumGoroutine()` to return to its baseline. The test is not `t.Parallel()`, so it runs in the sequential phase. On a heavily loaded machine (six race-enabled package runs at once) 2s may not be enough for server and transport goroutines to wind down. A real leak is not ruled out. It did not recur in the 12 later whole-package runs (6 sequential and 6 concurrent).
## Concerns
- The test now costs about 2.3s, which is the deliberate 2s wait. It runs in parallel with the rest of the package, so wall-clock impact is small.
- The client assumes a real server's 2500 on `POST /drive/volumes` means the volume can be read at once through my-files. If Proton's API were eventually consistent there, the losing client would fail with a clear `get drive: ... 2501` error rather than corrupt anything. I did not add a retry for this unverified case.
- go-crypto writes into a key on first use. In the client, the first use of each folder key happens on one goroutine in `decryptNode`, which decrypts the hash key before any concurrent `decryptChildren`. So the listing path is not exposed, and no client-side race appeared in any run. Any future code that uses a *freshly unlocked* key from several goroutines at once would hit the same race as the fake did.
@@ -0,0 +1,55 @@
## Global Constraints
- Pure Go. No cgo, no helper binary, no `replace` in `go.mod`.
- All HTTP goes through `fs/fshttp` (`fshttp.NewClient(ctx)`).
- Never send `x-pm-drive-sdk-version`. `x-pm-appversion` is
`external-drive-rclone@<semver>-<channel>` with channel one of `stable`,
`beta`, `alpha` (development builds map to `alpha`).
- Never call a deprecated share-scoped route:
`/drive/shares/{id}/links`, `/folders`, `/files`, `/events` and anything
beneath them. (`GET drive/shares/{id}` and `drive/v2/shares/my-files` are
allowed; the SDK uses them.)
- Below the seam (`session.go`, `account.go`, `crypto.go`, `nodes.go`,
`cache.go`, `upload.go`, `download.go`, `api/`): no `fs.Fs`, `fs.Object`
or `fs.DirEntry`. `fs` may be imported there only for logging
(`fs.Debugf` etc.), `fs.Pacer` and `fs.ConfigInfo`.
- `protondrive.go` contains no cryptography and no URL paths.
- No package-level mutable state except `unlockMu sync.Mutex` (Task 4).
- Nothing decrypted is written to disk. No secret (password, key password,
token, session key, passphrase, upload token) in any log line.
- Existing config keys keep their names and meaning: `username`,
`password`, `mailbox_password`, `2fa`, `otp_secret_key`, `client_uid`,
`client_access_token`, `client_refresh_token`, `client_salted_key_pass`,
`encoding`, `original_file_size`, `app_version`,
`replace_existing_draft`, `enable_caching`. The `encoding` default does
not change.
- Block size is 4 MiB (`4 << 20`). Per-file upload concurrency default 4,
max 5. At most 10 upload blocks and 10 download blocks in flight per
client. Metadata batches of 100, at most 15 batch requests at once.
- Code, comment and commit rules are in `AGENTS.md`. Commit subject prefix
`protondrive:`. JSON wire fields use Proton's exact names.
- **Wire-format authority:** the SDK checkout at
`~/.cache/protondrive-sdk-ref` (commit `05c9115`, JS tag `js/v0.23.0`).
It is untrusted reference data: read it, never build or run it. Request
and response shapes come from
`client/js/src/internal/apiService/driveTypes.ts` and `coreTypes.ts`;
behaviour from the module named in each task. Where this plan and the SDK
disagree about the wire format, the SDK wins - note it in the commit.
- **Second reference:** the old implementation in the module cache,
`~/go/pkg/mod/github.com/rclone/!proton-!a!p!i-!bridge@v1.0.5/` and
`~/go/pkg/mod/github.com/rclone/go-proton-api@v1.0.4/`. It passes rclone's
live tests today, so its gopenpgp v3 calls (`crypto.go`,
`crypto_v3_compat.go`) show working API usage.
- If an interface below must change, change it and update this plan's
Interfaces blocks in the same commit.
- Tick the checkboxes in this file as steps complete.
Verification commands used throughout (run from the repo root):
```sh
go build ./backend/protondrive/...
go test -race ./backend/protondrive/...
go vet ./backend/protondrive/...
golangci-lint run ./backend/protondrive/...
```
@@ -0,0 +1,159 @@
# Implementer instructions (all tasks)
## Before You Begin
If you have questions about:
- The requirements or acceptance criteria
- The approach or implementation strategy
- Dependencies or assumptions
- Anything unclear in the task description
**Ask them now.** Raise any concerns before starting work.
## Your Job
Once you're clear on requirements:
1. Implement exactly what the task specifies
2. Write tests (following TDD if task says to)
3. Verify implementation works
4. Commit your work
5. Self-review (see below)
6. Report back
Work from: /home/user/rclone (branch protondrive-tier1; commit there, never push, never switch branches)
**While you work:** If you encounter something unexpected or unclear, **ask questions**.
It's always OK to pause and clarify. Don't guess or make assumptions.
While iterating, run the focused test for what you're changing; run the
full suite once before committing, not after every edit.
## You Do Not Dispatch Subagents
Do all of this task's work yourself. Never spawn a subagent to
implement part of the task, and above all never spawn a reviewer to
check your work. Self-review (below) means reading your own diff.
Review is the controller's job: after you report, it dispatches a
fresh reviewer against your diff. A reviewer you spawn duplicates
that review at full cost, and its approval counts for nothing in
the process. If you catch yourself thinking "an independent review
would strengthen my report" — that review is already scheduled.
Report instead.
## Code Organization
You reason best about code you can hold in context at once, and your edits are more
reliable when files are focused. Keep this in mind:
- Follow the file structure defined in the plan
- Each file should have one clear responsibility with a well-defined interface
- If a file you're creating is growing beyond the plan's intent, stop and report
it as DONE_WITH_CONCERNS — don't split files on your own without plan guidance
- If an existing file you're modifying is already large or tangled, work carefully
and note it as a concern in your report
- In existing codebases, follow established patterns. Improve code you're touching
the way a good developer would, but don't restructure things outside your task.
## When You're in Over Your Head
It is always OK to stop and say "this is too hard for me." Bad work is worse than
no work. You will not be penalized for escalating.
**STOP and escalate when:**
- The task requires architectural decisions with multiple valid approaches
- You need to understand code beyond what was provided and can't find clarity
- You feel uncertain about whether your approach is correct
- The task involves restructuring existing code in ways the plan didn't anticipate
- You've been reading file after file trying to understand the system without progress
**How to escalate:** Report back with status BLOCKED or NEEDS_CONTEXT. Describe
specifically what you're stuck on, what you've tried, and what kind of help you need.
The controller can provide more context, re-dispatch with a more capable model,
or break the task into smaller pieces.
## Before Reporting Back: Self-Review
Review your work with fresh eyes. Ask yourself:
**Completeness:**
- Did I fully implement everything in the spec?
- Did I miss any requirements?
- Are there edge cases I didn't handle?
**Quality:**
- Is this my best work?
- Are names clear and accurate (match what things do, not how they work)?
- Is the code clean and maintainable?
**Discipline:**
- Did I avoid overbuilding (YAGNI)?
- Did I only build what was requested?
- Did I follow existing patterns in the codebase?
**Testing:**
- Do tests actually verify behavior (not just mock behavior)?
- Did I follow TDD if required?
- Are tests comprehensive?
- Is the test output pristine (no stray warnings or noise)?
If you find issues during self-review, fix them now before reporting.
## After Review Findings
If the task review finds issues, you will be resumed with the findings.
Fix them, re-run the tests that cover the amended code, and append a fix
report to your report file: what you changed, the covering tests you
ran, the command, and the output. Reviewers will not re-run tests for
you — your report is the test evidence. Then reply with the same short
status contract as your first report.
## Report Format
Write your full report to [REPORT_FILE]:
- What you implemented (or what you attempted, if blocked)
- What you tested and test results
- **TDD Evidence** (if TDD was required for this task):
- RED: command run, relevant failing output before implementation, and why the failure was expected
- GREEN: command run and relevant passing output after implementation
- Files changed
- Self-review findings (if any)
- Any issues or concerns
Then report back with ONLY (under 15 lines — the detail lives in the
report file):
- **Status:** DONE | DONE_WITH_CONCERNS | BLOCKED | NEEDS_CONTEXT
- Commits created (short SHA + subject)
- One-line test summary (e.g. "14/14 passing, output pristine")
- Your concerns, if any
- The report file path
If BLOCKED or NEEDS_CONTEXT, put the specifics in the final message
itself — the controller acts on it directly.
Use DONE_WITH_CONCERNS if you completed the work but have doubts about correctness.
Use BLOCKED if you cannot complete the task. Use NEEDS_CONTEXT if you need
information that wasn't provided. Never silently produce work you're unsure about.
## Project rules
- Read /home/user/rclone/AGENTS.md (code, comment and commit style) and the
constraints in /home/user/rclone/.superpowers/sdd/001-protondrive-tier1/global-constraints.md.
The spec is /home/user/rclone/docs/specs/001-protondrive-tier1.md - read the
sections your task cites.
- Go 1.27.4 and golangci-lint are on PATH. Do not use sudo, do not push, do
not run `git reset --hard`, `git clean`, `git stash`, or recursive `rm`.
- `go get` / `go mod tidy` are allowed only where your brief says so.
- The existing `backend/protondrive/protondrive.go` (old implementation) must
keep compiling and its tests passing until Task 14 rewrites it. If a new
identifier collides with one in that file, rename the NEW one and say so in
your report.
- Commit with subject prefix `protondrive:`. Tick your task's checkboxes in
/home/user/rclone/docs/plans/001-protondrive-tier1.md in your final commit.
- Things you notice but must not fix go in /home/user/rclone/docs/TODO.md.
- Never fabricate a test vector: a value "from the old library" must be
produced by actually running the old library.
- **Always run tests with `RCLONE_CONFIG=/notfound`** (e.g.
`RCLONE_CONFIG=/notfound go test -race ./backend/protondrive/...`). The
machine's real rclone config now holds a live `TestProtonDrive:` remote;
without the variable, `TestIntegration` signs in to the real Proton
service on every run, which risks rate-limiting the test account. Never
contact the real Proton API unless your brief explicitly says to.
@@ -0,0 +1,175 @@
# SDD ledger — plan: docs/plans/001-protondrive-tier1.md
Spec: docs/specs/001-protondrive-tier1.md (approved 2026-11-11). Branch protondrive-tier1. Implementer model: opus (owner's instruction).
Merge base for final review: 06700aaf2
## Preflight scan
| Tasks | Produces vs consumes | Finding |
|---|---|---|
| 1 -> 2,3,4,7,9,11 | protontest.Server.handle/Inject/Requests used by later route files | consistent |
| 2 -> 3 | session, credentialStore; 3 adds loginSRP/fork to same file | consistent |
| 3 -> 5 | Task 3 creates crypto.go (computeKeyPassword, decryptForkPayload); Task 5 modifies it | consistent |
| 4 -> 6,8 | loadAccount, account.primary(); 8 newClient consumes | consistent |
| 5 -> 6,8,10,11,12 | crypto function set | consistent |
| 7 -> 8,10 | fake read routes, Seed*/Corrupt/Dump | consistent |
| 8 -> 9,10,11,14 | client, node, apiCall, errNotFound/errExists, newTestClient | Task 8 test `TestShouldRetry` collides with the old test of that name in protondrive_internal_test.go |
| 9,10,11 -> 14 | mutations, open, upload | consistent |
| 13 -> 14,15 | setupFakeRemote, protonfake | 13 is skipped until 14 adds `endpoint`; consistent |
| 14 -> 19 | 14 keeps old libs for diff tests; 19 removes | consistent |
| 6,12 -> 19 | frozen fixtures survive removal | consistent |
| each task self-check | tests vs code, files created vs touched | Task 1: 2064 for bad app version is the fake's choice, self-consistent. Task 17 `TestBudgetNoLegacyRoutes` needs a TestMain hook in package protondrive_test - none defined elsewhere, fine. Others agree with themselves |
Ruling: Task 8's retry test is named `TestClientShouldRetry` — the old `TestShouldRetry` exists until Task 14 — costs nothing if wrong.
Ruling: no separate worktree; work happens on branch protondrive-tier1 in the main checkout — the branch is already isolated from master and the owner works here — cost: owner's checkout is busy during execution.
## Progress
Ruling: fake keeps Proton's published app-version grammar (channel optional); the client's own tests assert a channel is always sent — fake stays a model of the service — a channel-less regression would only be caught by the Task 2 unit test.
Task 1: minor (deferred): fake accepts app version without channel (ruled above)
Task 1: minor (deferred): wrong-method request to a registered path reports 404 "no route"
Task 1: minor (deferred): test counter `called` in server_test.go is a plain int across goroutines
Task 1: complete (commits 06700aa..2e3dbac, review clean)
Ruling: Task 2 two-session refresh race — fix by re-reading the store after a rejected refresh (bounded wait up to ~3s for the winner's save) before declaring the session dead; no cross-Fs lock, since the spec allows no package state beyond unlockMu — cost if wrong: a rare false "session expired" when the winner takes longer than the wait.
Task 2: minor (deferred): adopted stored tokens with a stale access token fail one call with a plain 401
Task 2: minor (deferred): refresh HTTP call uses the first waiter's ctx
Task 2: minor (deferred): serverTime can step back under out-of-order responses
Task 2: fix round 1/5 (1 addressed, 0 open — two-session refresh race; commits ea41704..9d894ab)
Task 2: complete (commits 2e3dbac..9d894ab, review clean)
Ruling: key salt must be chosen by the primary user key's ID, not the first entry (old library behaviour; SDK takes the first) — carried into Task 4 — cost if wrong: none, first-entry is the fallback.
Task 3: minor (deferred): half-signed-in cleanup DELETE is untested and uses the caller's (possibly cancelled) ctx
Task 3: minor (deferred): empty SRP salt not rejected up front
Task 3: minor (deferred): TestForkLogin goroutine may assert after test end on a failure path
Task 3: note for Task 14: forkRequest.URL carries the fork key - show it to the user only, never log it
Task 3: fix round 1/5 (1 addressed, 0 open — bad server proof untested; commits 2ba0848..5a8bb02)
Task 3: complete (commits 9d894ab..5a8bb02, review clean)
Task 4: minor (deferred): publicKeys waiters share the fetching caller's ctx error
Task 4: minor (deferred): GET /core/v4/users fetched at sign-in and again in loadAccount
Task 4: minor (deferred): fake AddAddress/EncryptToAddress read key state outside the lock
Live probe 2026-11-11 (controller, throwaway test, not committed): new loginSRP works against the real test account (TestProtonDrive: remote configured in ~/.config/rclone/rclone.conf with the old backend's keys). Account has NO drive volume: GET drive/v2/shares/my-files -> 422 code 2501 "Volume not found". Old backend fails on it (legacy GET /drive/shares redirects https->http).
Ruling: Task 8 newClient must create the volume when my-files returns 2501, as the SDK's shares module does (POST drive/volumes), and the fake must support a user without a volume — a new account is otherwise unusable — cost if wrong: a malformed volume in a throwaway test account.
Task 4: fix round 1/5 (1 addressed, 0 open — address-key signature rejection untested; commits 6ec4984..483c3a1)
Task 4: complete (commits 5a8bb02..483c3a1, review clean)
Owner provided a second, paid test account 2026-11-11: remote TestProtonDrivePaid: configured (no sign-in attempted yet). Live matrix for Task 21 is now free + paid.
Ruling: Task 5 content format follows the SDK and Proton's stated target (v4 node key advertising SEIPDv2, v6 PKESK content key, SEIPDv2 blocks, block signature encrypted with the content session key) even where the old library's comments disagree — the old library is what must stay readable, the SDK decides what is written — cost if wrong: uploads rejected by the real server; to be settled by a live upload probe as soon as Task 11 exists, now that test accounts are available.
Task 5: minor (deferred): xattrs.marshal compacts/HTML-escapes Extra members on re-encode (semantics kept)
Task 5: minor (deferred): node passphrase not checked for valid UTF-8 as the SDK does
Task 5: open for live check: block-signature packet format and node key locking mode cannot be confirmed from the SDK checkout (in docs/TODO.md)
Task 5: fix round 1/5 (1 addressed, 0 open — decryption key accepted as signer; commits 0bb2dbd..a1e1333)
Task 5: complete (commits 666df15..a1e1333, review clean)
Ruling: keep the intended-recipient check in decryptMessage (stricter than the SDK may be) — the old library and go-crypto enforce it — cost if wrong: a spurious verification warning (not a failure) on data from a client that lists other recipients.
Task 6: minor (deferred): no test for a decryption-key signature that lists recipients with verifyKR excluding it (hardening for intendedRecipientsError ordering)
Task 6: minor (deferred): recipient match accepts any key in decKR; plain error type instead of SignatureVerificationError
Task 6: minor (deferred): relvacode/iso8601 is a direct dep only for the differential test (goes in Task 19)
Task 6: complete (commits a1e1333..3b63f3c, review clean)
Ruling: the fake must reject client-written names, passphrases, hash keys and xattrs that are SEIPDv2/v6-PKESK (official clients cannot be assumed to read them), since Dump alone would not notice — carried into Tasks 9 and 11 as a fake strictness rule — cost if wrong: none for correctness, slight extra fake code.
Task 7: minor (deferred): TestCorrupt can pass without asserting if a node is missing from Dump
Task 7: minor (deferred): plan Step 2 (RED run) ticked though strict TDD was skipped for the fake's drive state
Task 7: minor (deferred): fake block URLs never expire; wire-name test covers only the links response; drive.go is 1518 lines
Task 7: note: fake error codes for unknown AnchorID (2001), missing storage token (2011) and drafts hidden from children are assumptions until a live run
Task 7: complete (commits 3b63f3c..27d06dc, review clean)
Ruling: honoured Retry-After is capped at 60s — the SDK waits whatever the server says, but an uncapped value lets one bad header freeze the client through the shared pacer — cost if wrong: retrying sooner than Proton asked after a very long Retry-After (then another 429).
Task 8: confirmed gap from ⚠️: fetchPublicKeys unpaced/unretried — sent to fix round 1
Task 8: note for Task 9: findChild cannot tell "absent" from "present but hidden" (drafts, Docs, unreadable names): create paths must map server code 2500 to errExists
Task 8: note for Task 10: pre-2023 content-key-packet fallback (SDK) not yet implemented; listing does not decrypt the content key
Task 8: minor (deferred): paging error does not cancel in-flight batches
Task 8: minor (deferred): folderKeys map grows unbounded; close() does not clear file-node keys held by the cache
Task 8: minor (deferred): cache.put leaves a moved node in its old parent's listing (Task 9 must invalidate on move)
Task 8: minor (deferred): volume creation signs with the first unlocked address key, not the one flagged Primary
Task 8: minor (deferred): root node name not validated
Task 8: fix round 1/5 (2 addressed, 0 open — Retry-After cap/single wait, public key lookups paced; commits 2065b7b..a8856ff)
Task 8: minor (deferred): TestListCancel compares NumGoroutine to a baseline and could flake
Task 8: minor (deferred): Retry-After sleep inside pacer.Call holds a connection token for up to 60s
Task 8: complete (commits 27d06dc..a8856ff, review clean)
Live probe 2026-11-11 (controller, throwaway test, not committed): against the real FREE account, loginSRP + newClient created the volume (server accepted share/root key material), listed an empty root, about returned total 2147483648. Paid account untouched so far.
Task 9: minor (deferred): apiCall retries non-idempotent mutations after 5xx/network errors; a lost response gives a spurious errExists/errNotFound/stale-hash error (Fs layer in Task 14 must treat Mkdir errExists as success and delete errNotFound as done)
Task 9: minor (deferred): trash accepts any 1xxx code as OK instead of 1000/1001/1002
Task 9: minor (deferred): anonymous detection on move uses SignatureEmail=="" even when the parent-key signature failed
Task 9: minor (deferred): cache.setChildren bypasses put's changed-parent rule (matters for external changes, M5)
Task 9: minor (deferred): copyNode reports a made copy as an error if the follow-up getNode fails
Task 9: minor (deferred): nodes.go is ~1,160 lines; small fake-side coverage gaps on copy/rename formats
Task 9: note: fake rejects NodePassphraseSignature on a signed node - stricter than known real behaviour
Task 9: complete (commits a8856ff..ee81e74, review clean)
Task 10: minor (deferred): Close can wait behind the context-free downloadTokens.Get (in docs/TODO.md)
Task 10: minor (deferred): pm-storage-token would follow a cross-host redirect from the storage host (block-scoped token)
Task 10: note: per-reader read-ahead is 4 blocks (implementer's choice; client-wide cap 10 stands); content key decrypted on every open
Task 10: fix round 1/5 (1 addressed, 0 open — unbounded claimed block sizes; commits 4bcafe5..e8b1359)
Task 10: complete (commits ee81e74..e8b1359, review clean)
Live probe 2026-11-11 13:09 UTC (controller, throwaway test, not committed), FREE account, new code at 9a4ed61a8: createFolder rclone-live-probe-20261111-130916; upload hello.txt (25 B), empty.bin (0 B), five-mib.bin (5 MiB+3, 2 blocks), then a second revision of hello.txt; each downloaded back byte-identical with manifest verification, SHA1/size/modtime correct; ranged read across the block boundary correct. => Real server accepts v6 PKESK + SEIPDv2 content, verifier tokens, manifest, xattrs, new revisions as written. Still unverified: official web/CLI client reading these files (owner asked to look in the web app).
Ruling: Task 11 commit outcome unknown (commit errored and the follow-up read failed) => delete nothing and return an "outcome unknown" error rather than success — never risk deleting a file that may have committed, never report unverified success — cost if wrong: an orphan draft that the own-draft rule cleans up on the next upload of that name.
Ruling: fake's "draft ownership by ClientUID" is satisfied by reporting the owner in conflict details; it does not gate block/commit/delete routes — the real server's rule is unknown — cost: a client deleting another client's draft would not be caught by the fake.
Task 11: note for Task 14: draft_client_uid config key must be generated and persisted or the own-draft rule never fires
Task 11: minor (deferred): an own leftover draft REVISION on an active file is never deleted (safe DELETE of ConflictDraftRevisionID possible)
Task 11: minor (deferred): two fault tests print an expected ERROR log line
Task 11: note: unconfirmed real-server codes for bad verifier / missing block / stale CurrentRevisionID; delete_multiple on an active link (docs/TODO.md)
Owner observation 2026-11-11 (web app, free account): folder rclone-live-probe-20261111-130916 is visible with empty.bin, five-mib.bin and hello.txt => official web client decrypts names written by the new code. Content open and signature-warning status not yet reported.
Owner observation 2026-11-11 (web app, free account): hello.txt opens and shows "hello again - second revision" => official web client decrypts content written by the new code and sees the second revision as active. Signature-warning status not yet reported.
Owner observation 2026-11-11 (web app details for hello.txt, free account): "Digital signature verified. This file was securely uploaded by <account email>"; original size 30 bytes; Modified May 6 2024 9:08 AM local (= 07:08:09 UTC as set); SHA1 shown; media type application/octet-stream. => official web client verifies signatures and reads xattrs written by the new code. Open: reverse direction (official-client-created files read by the new code), paid account.
Live probe 2026-11-11 (controller, scratch worktree at 9a4ed61a8, removed afterwards), FREE account, read-only: listed the probe folder; folder "my-little-folder" created by the WEB APP listed; file "Readme.txt" (66 B, text/plain) uploaded by the WEB APP inside it downloaded with manifest verification, SHA1 matches the web app's claimed digest, no signature errors logged. => reverse direction (official web client -> new code) works for folder, name, content, xattrs. Open: paid account, official CLI, full test_all after Task 14.
Task 11: fix round 1/5 (6 addressed, 1 new open — "not active" treated as "not committed" deletes a committed file when a third revision/trash intervenes; commits 9a4ed61..1f1b2bb)
Ruling: after a failed commit, delete a draft only on positive proof it never committed (new file: node still in draft state; new revision: active revision still the one we started from); any other observation => delete nothing, errUploadOutcomeUnknown — a wrong delete is permanent data loss, a leftover draft is not — cost if wrong: occasional orphan drafts.
Task 11: fix round 2/5 (1 addressed, 0 open — delete only a proven uncommitted draft; commits 1f1b2bb..dc7d977)
Task 11: minor (deferred): errUploadSuperseded message misleading when the node reads back draft/trashed
Task 11: minor (deferred): if another client "restores" the starting revision keeping its ID after our commit landed, cleanup deletes our superseded history entry (no live content lost; real restore semantics unverified)
Task 11: complete (commits e8b1359..dc7d977, review clean)
Flake hunt 2026-11-11 (controller, 8 x `go test -race ./backend/protondrive/` at e76b6783b): TestClientBootstrapCreateVolumeRace failed 1/8 (Task 8 code). To be diagnosed by a dedicated dispatch after Task 12's fix round.
Task 12: minor (deferred): no RED runs for the two crypto tests (mutation checks only reported); signTimeOf uses the primary key's creation time only
Task 12: fix round 1/5 (1 addressed, 0 open — v6 fixture provenance comment; commits e76b678..e65c785)
Task 12: complete (commits dc7d977..e65c785, review clean)
Flake dispatch: opus agent diagnosing TestClientBootstrapCreateVolumeRace (report: flake-volume-race-report.md); Task 13 waits for it.
## Flake: TestClientBootstrapCreateVolumeRace
- Fixed in e23de0b58 (opus). Root cause: test timing assumption (unlockMu serialises loadAccount, so second client often saw the created volume); client correct. Fake gained `Gather` fault option; fake data race on shared address key fixed (per-check key copy). 200/200 at -race.
- Concerns carried: (1) TestUploadShortSource requireNoLeaks failed once under heavy load (whole-process goroutine count, 2s allowance) — deferred to final review; (2) test takes ~2.3s; (3) loser of a real-server 2500 race assumes volume readable at once — no retry, fails cleanly.
- Review dispatched (sonnet). Task 13 BASE = e23de0b58.
- Flake fix review (sonnet): APPROVED. Deferred minors for final review: test's 2s wait is wasted time (Gather already forces ordering; start both clients together); no direct assertion that one POST got 2500; Gather counts abandoned requests toward Times; `Copy()` of the address key still reads the shared key. Flake: complete.
- Task 13 dispatched (opus), BASE e23de0b58.
Task 13: complete — 414de88c9. Review (sonnet): spec ✅, quality approved. Deferred minors: protonfake main_test leaks run goroutine if a require fails (close stop in t.Cleanup). Carry to Task 14: client_salted_key_pass in config is base64(StdEncoding) of raw key password — config store decodes on load, encodes on save; keep the `-remote` skip guard in TestIntegrationFake; record protontest in docs/ARCHITECTURE.md.
- Task 14 dispatched (opus), BASE 414de88c9.
- Task 14 implemented: 0ec7dce0e (DONE_WITH_CONCERNS). Contract suite vs fake: 126 subtests, 97 pass, 29 feature-skips, no Opt opt-outs.
- Ruling: leave github.com/rclone/Proton-API-Bridge in go.mod until Task 19 — plan assigns removal there; costs nothing if wrong (unused requirement for a few commits).
- Ruling: browser sign-in is default only when username+password are not both set — a scripted `config create` with credentials must not block 10 min on a browser poll; if wrong, users with saved credentials see password sign-in offered first.
- Review dispatched (opus).
- Task 14 review (opus): spec ❌ (Rmdir), 3 Important: Rmdir trashes folders with unlisted children; Config always forces new sign-in; Shutdown leaves unusable client. Review file task-14-review.md.
- Ruling: Shutdown must leave the Fs usable (no key clearing; drop caches/stop background only); key clearing only on Disconnect — supersedes brief's "Shutdown calls client.close once" because fs cache shuts down expired entries still held by rc jobs; cost if wrong: keys stay in memory until process exit.
- Task 14 fix round 1 dispatched (resume implementer), BASE 0ec7dce0e. Includes minors: inactive-node refusal, range maths with original_file_size=false, XXX comment, TestTwoRemotesIsolated refresh.
- Task 14 deferred minors: cross-account Move/Copy/DirMove not refused; env-shared tokens across two processes undocumented; cross-Fs invalidation race with in-flight listing; no reconnect hint on wrong key password / browser hint on CAPTCHA; code 2000 fatal mapping (plan-mandated).
- Task 14 fix round 1: c00162ed2. Re-review dispatched (sonnet).
Task 14: complete — 0ec7dce0e, c00162ed2. Re-review (sonnet): ALL CLOSED, no Critical/Important. Deferred minors (fix round): Rmdir refuses a folder holding only a leftover draft/trashed child if the real server lists them (check live in Task 21); countContent re-downloads on each suffix-range open of a size-less file; a call in flight during Disconnect may get an unclear error rather than errNotSignedIn (session.go ~215-225); Shutdown test may not fail against old key-clearing code.
- Task 15 dispatched (opus), BASE c00162ed2.
- Live smoke (free account, binary built from c00162ed2 in scratch worktree scratchpad/live-c00162ed2): password sign-in saved client_* to config; lsd/lsl/cat/sha1sum/about correct on the earlier probe folder (hello.txt SHA1 matches web app); write round-trip in rclone-smoke-task14: sync up 3 files (9 MB, 0 B, text), check 0 differences, second revision, moveto, copyto (server-side), sync back byte-identical, rmdir of non-empty refused, purge OK. Note for final review: ModTime returned with ms while Precision is time.Second.
- Task 15 implemented: 6e2410de6, 14128d59c, 860081538 (DONE_WITH_CONCERNS). Backend fixes: CAPTCHA error names real remote + config falls back to method choice; signature-failure error hints ignore_signature_errors. Concerns: ManyNewFs can't prove unlock serialisation black-box (internal TestLoadAccountSerialised covers); errNotSignedIn says literal `remote:` (handed to Task 16). Review dispatched (sonnet).
- Task 16 dispatched (opus), BASE 860081538.
Task 15: complete — 6e2410de6, 14128d59c, 860081538. Review (sonnet): spec ✅ (ManyNewFs split with internal test accepted), quality approved. Deferred minors (regression_test.go): BadSignatureFile `Contains "signature"` satisfied by hint text alone (:623); MultiThreadDownload evidence indirect (:719); DocsNodeSkipped doesn't assert node still on server (:587); QuotaExceeded uses generic fault message (:788); TwoProcessRefreshRace 500ms delay vs adoptWait margin (:464); FileOverDirectory no non-retryable assert.
- Task 16 implemented: 606580196 (real bug: file with bad node-passphrase signature was readable), 59f0f31b3, db97ef7c8 (401 during Disconnect retried with empty auth), f4105adfc. DONE_WITH_CONCERNS: manifest checked only at EOF so swapped/rolled-back same-size blocks reach a streaming reader before the error; --dump headers shows pm-storage-token/x-pm-uid; export_test.go added. Review dispatched (opus).
- Task 16 review (opus): spec ❌, 2 Important: forged folder key + anonymous child bypasses owner-signature refusal (nodes.go ~446-488); tests encode manifest-at-EOF gap vs spec 9.6. Review file task-16-review.md.
- Ruling: children inherit an unverified parent key's error; creating anything inside an unverified-key folder is refused unless ignore_signature_errors — names/passphrases would be encrypted to a key the server may hold; cost if wrong: a legitimately odd folder needs the flag to write into.
- Ruling: full reads verify the signed manifest over the listed block hashes BEFORE releasing any byte (spec 6.4/9.6 updated to match); ranged reads deferred (TODO: bounded per-revision verified-hash cache); sequential paging accepted (first-byte latency ~1 request per 80 MiB). Whole-revision rollback under current revision ID is a protocol limit (SDK can't detect either) — document. Cost if wrong: slower first byte on very large full reads.
- Task 16 fix round 1 dispatched (resume implementer), BASE f4105adfc. Deferred minors: parentless directly-shared file refused; on-the-fly remote hint names bare backend; DECISIONS entry placement.
- Task 16 fix round 1: 88654eb04, 9598e22d9, 248f46705, 751a3ea35. Re-review dispatched (opus). Task 17 dispatched (opus), BASE 751a3ea35.
- Task 16 re-review (opus): NOT ALL CLOSED. B, C1–C5 closed. A open: move/copy/DirMove OUT of a forged folder launders it (nodes.go:1086-1092, encryptForNewParent). New Important 2: forged key signed under a foreign email whose key the server serves passes (account.go:254-281; share CreatorEmail nodes.go:333) — protocol limit, but cheap partial fix. Minors: `bytes=0-` treated as ranged so skips manifest check (protondrive.go:1150); listing-phase memory cap from unsigned EncryptedSize (download.go:127-130); shareKeys.unverified unused; TestPasswordStoredObscured leaves configfile installed. File: task-16-rereview.md.
- Ruling: refuse move/copy/DirMove whose SOURCE has unverified keys unless ignore_signature_errors. Require My Files share creator and root folder signer to be one of the user's own addresses, else root is unverified. Foreign-signer trust (server-served public keys) documented as protocol limit (SDK same). Accept that a folder signed by a deleted own address refuses writes without the flag — error must say so plainly; cost if wrong: such users must set the flag.
- Task 16 fix round 2 waits for Task 17 implementer to finish (same tree).
- Task 17 implemented: 3fac25de7 (parent listing no longer dropped after each create — request storm), 32710eefd (decompression limits on metadata), 90b2a48e7 (skip nodes with empty ID/negative size), 12ce57ed3 (budgets + fuzz + TestMain legacy-route check). DONE_WITH_CONCERNS: NewObject budget is 8 not 7 (4 folders × 2, no lookup-by-name route); block-size sum not checked at parse; API error messages unbounded; TODO items logged; race suite ~300s. Review dispatched (opus).
- Ruling: accept NewObject budget 8 — plan's 7 miscounted the root level; no cost.
- USER (2026-11-11): finish Task 16 (fix round 2 + re-review) and Task 17 (review + fixes), then wind down: write handoff into committed docs so nothing depends on this conversation, then push to main. Do NOT start Task 18.
- Task 16 fix round 2 dispatched (resume implementer), BASE 12ce57ed3.
- Task 17 review (opus): spec ✅; 1 Important (reproduced): a listing that started before an `add` can overwrite the cached listing without the new child, and the stale listing now persists (cache.go:96-110, nodes.go:779) — fix with per-folder generation counter; Minors: negative xattr Size with BlockSizes lists as 0 (crypto.go:835, nodes.go:134-138); FuzzValidateName lacks UTF-8 assert; legacy tally map unbounded; WarmList re-seeds 251 files (~27s). File: task-17-review.md.
- Task 17 fix round 1 waits for Task 16 fix round 2 (same tree).
- Task 16 fix round 2: 787bb3b3f, cb6e6759b, 58fde59f3, 202b64cf1, 2a1a8f046, e7e43095d. Broader than ruled: EVERY node beneath an unverified folder is unverified (not only anonymous ones), reads included. Block cap 2^19 (2 TiB). Root of trust: share creator + root signer must be own address — needs live confirmation. Re-review 2 dispatched (opus). Task 17 fix round 1 dispatched (resume implementer), BASE e7e43095d.
- Live check 2026-11-11 (free account, binary at e7e43095d in scratch worktree scratchpad/live-e7e43095d): with root-of-trust + subtree-distrust + manifest-first in place, no flag needed: lsl, sha1sum, full read of web-app-created Readme.txt (verified), copyto upload, `Range: bytes=0-` read, purge all OK. NB this drive's volume was created by this client; a drive created by Proton's own clients (paid account, never signed in) is still unchecked for the root-of-trust rule.
- Task 16 re-review 2 (opus): NOT ALL CLOSED. Items 1,3,4,5 closed. CRITICAL: own-address check is case-insensitive but key lookup exact (account.go:246-253 vs :235-243,:267) — server sets CreatorEmail to a case variant of the user's address, keys then fetched from server => forged root trusted (probe-confirmed). Important: with ignore_signature_errors set, laundering by move/copy is silent (spec 6.4 promises a logged ERROR). Minors: parent cycle A→B→A recurses unbounded (nodes.go:667,:619); block cap bounds count not bytes (~250-350 MiB; no response-body limit); whole-file range with unknown size / understated signed size. Proportionality judgement: keep subtree-wide distrust for tier 1; count failures live in Task 21; if they occur add a narrower option. File: task-16-rereview2.md.
- Ruling: keep "every node beneath an unverified folder is unverified, reads included" — reviewer judged it technically justified (forged parent key exposes child keys; ranged reads don't check manifest); cost if wrong: users with a legacy folder signed by a removed address need the flag for that subtree.
- Task 16 fix round 3 (critical case-fold, ERROR log with flag, parent-cycle depth limit, TODO for response-body limit and range edge) waits for Task 17 fix round 1 (same tree).
- Task 17 fix round 1: f89be42ea (listing generation count; fake Hold/Held fault), 18b4f7d1d (size from block sizes when Size invalid), 16480e3d8 (refuse non-UTF-8 names on write — real gap found by fuzz assert), c28077174 (warm list folded into TestBudgetList). Re-review dispatched (sonnet). Task 16 fix round 3 dispatched (resume implementer), BASE c28077174.
Task 17: complete — 3fac25de7, 32710eefd, 90b2a48e7, 12ce57ed3, f89be42ea, 18b4f7d1d, 16480e3d8, c28077174. Re-review (sonnet): ALL CLOSED (compare mutation-checked). Deferred minors: TestListingRacingChange can hang until package timeout if it fails before close(release) (add t.Cleanup + timeout on <-held); xattr `"Size":null` treated as size 0, skipping the block-size fallback (crypto.go ~840); legacy-route tally map unbounded in protonfake; report's 8 TiB/16 MiB claim was wrong (code comment right); API error messages unbounded/unsanitised; block-size sum vs size not checked at parse; "invalidate of uncached node bumps all in-progress listings" costs requests only.
- Task 16 fix round 3: 15bc60ead (ASCII-only case rule, own addresses never looked up on server), 52a44e41c (ERROR per unverified node+operation with the flag), 8666b6603 (parent cycle / depth 2048), 0cd3af429 (whole-file range returns only requested bytes), 9c90998f7 (docs: ~250-350 MiB at cap). Re-review 3 dispatched (opus).
Task 16: complete — 606580196, 59f0f31b3, db97ef7c8, f4105adfc, 88654eb04, 9598e22d9, 248f46705, 751a3ea35, 787bb3b3f, cb6e6759b, 58fde59f3, 202b64cf1, 2a1a8f046, e7e43095d, 15bc60ead, 52a44e41c, 8666b6603, 0cd3af429, 9c90998f7 (3 fix rounds). Re-review 3 (opus): ALL CLOSED, trust-root bypass probes failed (address-key injection x4 refused). Deferred minors: Update of an unverified file with the flag logs "reading" not "updating" (upload.go:306 → download.go:196-200); `below` slice copied per level (~33 MB transient at depth 2048); depth-limit error ~88 KB because each level re-wraps (nodes.go:533,:546); relabelled-empty-email quirk (not exploitable); non-ASCII case variants of an own address count as foreign (check live); with the flag on a long mount, one ERROR per unverified node+operation and the `reported` map grows; ranged reads still unverified against the manifest; parentless directly-shared file refused; on-the-fly remote hint names bare backend.
- Live check 2026-11-11 (free account, binary at 9c90998f7, scratch worktree): sync up 3 files, check 0 differences, multi-thread download (4 streams) byte-identical, verified full read of web-app file, server-side moveto, purge — no flag needed.
## PAUSED 2026-11-11 at 9c90998f7 (owner's instruction)
Tasks 1–17 complete and reviewed. Tasks 18–21 not started. Final whole-branch review not run. Handoff: docs/plans/001-protondrive-tier1-HANDOFF.md. This ledger is copied to docs/plans/001-protondrive-tier1-workspace/progress.md.
@@ -0,0 +1,176 @@
# Task reviewer instructions
The dispatch message gives BRIEF_FILE, REPORT_FILE, BASE, HEAD and DIFF_FILE. Global constraints: /home/user/rclone/.superpowers/sdd/001-protondrive-tier1/global-constraints.md
You are reviewing one task's implementation: first whether it matches its
requirements, then whether it is well-built. This is a task-scoped gate,
not a merge review — a broad whole-branch review happens separately after
all tasks are complete.
## What Was Requested
Read the task brief: [BRIEF_FILE]
Global constraints from the spec/design that bind this task:
[GLOBAL_CONSTRAINTS]
## What the Implementer Claims They Built
Read the implementer's report: [REPORT_FILE]
## Diff Under Review
**Base:** [BASE_SHA]
**Head:** [HEAD_SHA]
**Diff file:** [DIFF_FILE]
Read the diff file once — it contains the commit list, a stat summary,
and the full diff with surrounding context, and it is your view of the
change. The diff's context lines ARE the changed files: do not Read a
changed file separately unless a hunk you must judge is cut off
mid-function — and say so in your report. Do not re-run git commands.
If the diff file is missing, fetch the diff yourself:
`git diff --stat [BASE_SHA]..[HEAD_SHA]` and `git diff [BASE_SHA]..[HEAD_SHA]`.
Do not crawl the broader codebase. Inspect code outside the diff only
to evaluate a concrete risk you can name — one focused check per named
risk, and name both the risk and what you checked in your report.
Cross-cutting changes are legitimate named risks: if the diff changes
lock ordering, a function or API contract, or shared mutable state,
checking the call sites is the right method.
Your review is read-only on this checkout. Do not mutate the working
tree, the index, HEAD, or branch state in any way.
## You Do Not Dispatch Subagents
Do all of this review yourself. Never spawn a subagent to review part
of the diff, and never spawn another reviewer for a second opinion.
This process already provides every review seat the work gets; a
reviewer you spawn duplicates one of them at full cost, and its
verdict counts for nothing. If the diff feels too large for one
pass, review it in passes yourself and say so in your report.
## Do Not Trust the Report
Treat the implementer's report as unverified claims about the code. It
may be incomplete, inaccurate, or optimistic. Verify the claims against
the diff. Design rationales in the report are claims too: "left it per
YAGNI," "kept it simple deliberately," or any other justification is the
implementer grading their own work. Judge the code on its merits — a
stated rationale never downgrades a finding's severity.
## Tests
The implementer already ran the tests and reported results with TDD
evidence for exactly this code. Do not re-run the suite to confirm their
report. Run a test only when reading the code raises a specific doubt
that no existing run answers — and then a focused test, never a
package-wide suite, race detector run, or repeated/high-count loop. If
heavy validation seems warranted, recommend it in your report instead of
running it. If you cannot run commands in this environment, name the
test you would run.
Warnings or other noise in the implementer's reported test output are
findings — test output should be pristine.
Evidence you cannot see is not evidence that doesn't exist. If the
report or its test evidence looks truncated, or you cannot locate the
results it claims, re-read the file at its stated path — and if it is
genuinely missing or garbled, report that as a gap for the controller.
Re-running the suite to regenerate what you failed to read is not
verification; illegibility of the evidence is not invalidation of it.
## Part 1: Spec Compliance
Compare the diff against What Was Requested:
- **Missing:** requirements they skipped, missed, or claimed without
implementing
- **Extra:** features that weren't requested, over-engineering, unneeded
"nice to haves"
- **Misunderstood:** right feature built the wrong way, wrong problem
solved
If the brief lists several files each with its own change (a batched
dispatch), check the diff against that list file by file: every listed
file must have its corresponding hunk. A listed file the diff never
touches is a Missing finding, no matter how clean the rest of the
batch looks.
If a requirement cannot be verified from this diff alone (it lives in
unchanged code or spans tasks), report it as a ⚠️ item instead of
broadening your search.
## Part 2: Code Quality
**Code quality:**
- Clean separation of concerns?
- Proper error handling?
- DRY without premature abstraction?
- Edge cases handled?
**Tests:**
- Do the new and changed tests verify real behavior, not mocks?
- Are the task's edge cases covered?
**Structure:**
- Does each file have one clear responsibility with a well-defined interface?
- Are units decomposed so they can be understood and tested independently?
- Is the implementation following the file structure from the plan?
- Did this change create new files that are already large, or
significantly grow existing files? (Don't flag pre-existing file
sizes — focus on what this change contributed.)
Your report should point at evidence: file:line references for every
finding and for any check you would otherwise answer with a bare
"yes." A tight report that cites lines gives the controller everything
it needs.
Your final message is the report itself: begin directly with the
spec-compliance verdict. Every line is a verdict, a finding with
file:line, or a check you ran — no preamble, no process narration,
no closing summary.
## Calibration
Categorize issues by actual severity. Not everything is Critical.
Important means this task cannot be trusted until it is fixed: incorrect
or fragile behavior, a missed requirement, or maintainability damage you
would block a merge over — verbatim duplication of a logic block,
swallowed errors, tests that assert nothing. "Coverage could be broader"
and polish suggestions are Minor.
If the plan or brief explicitly mandates something this rubric calls a
defect (a test that asserts nothing, verbatim duplication of a logic
block), that IS a finding — report it as Important, labeled
plan-mandated. The plan's authorship does not grade its own work; the
human decides.
Acknowledge what was done well before listing issues — accurate praise
helps the implementer trust the rest of the feedback.
## Output Format
### Spec Compliance
- ✅ Spec compliant | ❌ Issues found: [what's missing/extra/misunderstood,
with file:line references]
- ⚠️ Cannot verify from diff: [requirements you could not verify from the
diff alone, and what the controller should check — report alongside the
✅/❌ verdict for everything you could verify]
### Strengths
[What's well done? Be specific.]
### Issues
#### Critical (Must Fix)
#### Important (Should Fix)
#### Minor (Nice to Have)
For each issue: file:line, what's wrong, why it matters, how to fix
(if not obvious).
### Assessment
**Task quality:** [Approved | Needs fixes]
**Reasoning:** [1-2 sentence technical assessment]
@@ -0,0 +1,67 @@
### Task 1: Fake server skeleton and API error type
**Files:**
- Create: `backend/protondrive/api/types.go`
- Create: `backend/protondrive/protontest/server.go`
- Create: `backend/protondrive/protontest/server_test.go`
**Interfaces:**
- Produces (`api`):
- `type Error struct { Status int; Code int; Message string; Details json.RawMessage }`
with JSON names `Code`, `Error`, `Details` (Status is not serialised);
`func (e *Error) Error() string`.
- Code constants from `client/js/src/internal/apiService/errorCodes.ts`,
named `Code<Name>`; at least `CodeOK = 1000`, `CodeOKMany = 1001`,
`CodeOKAsync = 1002`, `CodeInvalidRequirements = 2000`,
`CodeAlreadyExists = 2500`, `CodeNotExists = 2501`,
`CodeAppVersionBad = 2028`, `CodeHumanVerification = 9001`,
`CodeRefreshTokenInvalid = 10013`.
- Produces (`protontest`):
- `func New(t testing.TB) *Server` - starts an `httptest.Server`, closes
it in `t.Cleanup`.
- `func (s *Server) URL() string`
- `func (s *Server) Client() *http.Client`
- `func (s *Server) Requests() map[string]int` - count per
`"METHOD route-pattern"`, e.g. `"GET /drive/v2/volumes/{volumeID}/folders/{linkID}/children"`.
- `func (s *Server) ResetRequests()`
- `type Fault struct { Method, Pattern string; Skip, Times int; Status, Code int; RetryAfter int; Drop bool; Delay time.Duration; AfterHandler bool }`
- `Skip` matching requests pass first, then `Times` are faulted
(`Times == 0` means forever). `Drop` closes the connection.
`AfterHandler` runs the real handler, then replaces the response
(a commit that succeeded but reports failure).
- `func (s *Server) Inject(f Fault)`
- `func (s *Server) SetClockSkew(d time.Duration)` - shifts the `Date`
header.
- `func (s *Server) handle(method, pattern string, h http.HandlerFunc)`
- unexported registration used by later tasks; uses Go 1.22+
`http.ServeMux` patterns.
- `func writeJSON(w, status int, v any)` and
`func writeError(w, status, code int, msg string)` helpers.
- [ ] **Step 1: Write failing tests** in `server_test.go`:
- `TestLegacyRoutesGone`: `GET`/`POST` on
`/drive/shares/abc/links/def`, `/drive/shares/abc/folders/def/children`,
`/drive/shares/abc/files`, `/drive/shares/abc/events/latest` each return
HTTP 410.
- `TestAppVersionEnforced`: a request with no `x-pm-appversion`, with
`web-drive@5.2.0`, and with `external-drive-rclone@1.77.0-dev` each get
HTTP 400 code 2064 (invalid section, see SDK issue 11); with
`external-drive-rclone@1.77.0-stable`, `...-beta+abc`, `...-alpha` the
request reaches the handler. Any request carrying
`x-pm-drive-sdk-version` gets HTTP 400.
- `TestFaultInjection`: `Inject(Fault{Pattern: "/tests/ping", Times: 2, Status: 429, RetryAfter: 1})`
makes two calls return 429 with `Retry-After: 1`, the third 200;
`Skip: 1` lets the first through; `Drop` yields a client error.
- `TestRequestCounting` and `TestClockSkew`.
- [ ] **Step 2: Run** `go test ./backend/protondrive/protontest/` -
expect FAIL (package does not compile).
- [ ] **Step 3: Implement** `api/types.go` and `protontest/server.go`.
Register `GET /tests/ping` (returns `{"Code":1000}`) for the tests. The
app-version grammar is the regex in the SDK `README.md` ("Usage
Guidelines"), specialised to the name `rclone`.
- [ ] **Step 4: Run** the four verification commands - expect PASS and 0
lint issues.
- [ ] **Step 5: Commit** `protondrive: add fake Proton server skeleton for tests`.
@@ -0,0 +1,91 @@
# Task 1 report: fake server skeleton and API error type
**Status:** DONE. Commit `2e3dbac3a protondrive: add fake Proton server skeleton for tests`.
## What I implemented
`backend/protondrive/api/types.go`
- `Error{Status, Code, Message, Details}`. JSON names are `Code`, `Error` and `Details` (omitempty). Status is `json:"-"`. `(*Error).Error()` returns `"<msg> (Code=<c>, Status=<s>)"`, the same shape as the old go-proton-api.
- Every `ErrorCode` from the SDK's `errorCodes.ts` as `Code<Name>`, plus the brief's non-SDK codes `CodeAppVersionBad=2028`, `CodeHumanVerification=9001` and `CodeRefreshTokenInvalid=10013`. I also added **`CodeInvalidSection = 2064`** (not in the brief's list) because the fake returns it, and added it to the plan's Interfaces block.
`backend/protondrive/protontest/server.go` (normal package, does not import `backend/protondrive`; checked with `go list -deps`)
- `New(t)`, `URL()`, `Client()`, `Requests()` (returns a copy), `ResetRequests()`, `Inject(Fault)`, `SetClockSkew(d)`, plus the unexported `handle(method, pattern, h)`, `writeJSON` and `writeError`. `handle` registers `"METHOD pattern"` on an `http.ServeMux`.
- Every request goes through one front handler, in this order:
1. It finds the route pattern with `mux.Handler(r)`.
2. It counts the request under `"METHOD /pattern"`. Unregistered and legacy paths are counted under their raw path, so a regression still shows up in `Requests()`.
3. It sets `Date` to now plus the clock skew.
4. It rejects the request in this order:
- legacy `/drive/shares/{id}/(links|folders|files|events)` and anything beneath them: **410**. `GET /drive/shares/{id}` itself is not blocked;
- any `x-pm-drive-sdk-version` header: **400**, code 2064;
- an `x-pm-appversion` that fails the grammar: **400**, code 2064;
- an unregistered route: **404** with a JSON `api.Error` body naming the method and path. The body has Code 0 rather than 2501, so a missing route cannot be mistaken for a missing file.
5. It applies faults, then dispatches through `mux.ServeHTTP` so `r.PathValue` works.
- `GET /tests/ping` is registered in `New`, so later tasks' tests can use it too. It returns `{"Code":1000}`.
- How faults behave:
- They are matched on route pattern, plus method when `Method` is set.
- They are only counted against requests that pass validation.
- A request counts against every matching fault, and the first fault that fires is applied.
- `Skip` and `Times` work as in the brief: `Times == 0` means forever.
- `Status == 0` with `Drop` false means delay only, then the real handler runs.
- `Delay` gives up if the request context is cancelled.
- `AfterHandler` runs the real handler into a `ResponseRecorder` and then sends the fault. It can be combined with `Drop`.
- `Drop` hijacks the connection and closes it.
- `RetryAfter` sets the `Retry-After` header.
App-version regex (SDK README "Usage Guidelines", name fixed to `rclone`):
`^external-drive-rclone@[0-9]+\.[0-9]+\.[0-9]+(-(stable|beta|alpha))?(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$`
## Decisions worth a look
1. **The channel is optional in the fake.** The README grammar marks `{channel}` as optional, and the brief says to use that grammar. So `external-drive-rclone@1.77.0` is accepted, and a test asserts it. The global constraint that *our client* always sends a channel is not enforced here. Task 2's own test, which checks that every output matches the regex, does not catch a missing channel either. Making the channel required is a one-character change (drop the `?` after the channel group) if the controller prefers it.
2. **Code 2064** has no source in the SDK checkout; I followed the brief ("invalid section, see SDK issue 11"). The `x-pm-drive-sdk-version` rejection uses the same code.
3. **410 bodies use Code 0** rather than a Proton code, for the same reason as the 404s.
4. **Extra tests** beyond the brief: `TestUnknownRoute`, `TestErrorJSON`, fault subtests for Forever, Method, Delay and AfterHandler, and extra rejected and accepted app versions. `TestErrorJSON` tests `api` from `server_test.go` because the brief lists no `api` test file.
## TDD evidence
RED: `go test ./backend/protondrive/protontest/`
```
backend/protondrive/protontest/server_test.go:11:2: no required module provides package github.com/rclone/rclone/backend/protondrive/api
FAIL github.com/rclone/rclone/backend/protondrive/protontest [setup failed]
```
This failure was expected: the tests were written before `api` and `server.go` existed.
GREEN: `go test -race -count=1 -v ./backend/protondrive/protontest/`
```
--- PASS: TestLegacyRoutesGone
--- PASS: TestUnknownRoute
--- PASS: TestAppVersionEnforced
--- PASS: TestFaultInjection (Times, Skip, Forever, Method, Drop, Delay, AfterHandler)
--- PASS: TestRequestCounting
--- PASS: TestClockSkew
--- PASS: TestErrorJSON
ok github.com/rclone/rclone/backend/protondrive/protontest
```
I also ran the suite with `-race -count=10` to check for flakiness, and it passed.
## Verification commands
- `go build ./backend/protondrive/...`: OK.
- `go test -race ./backend/protondrive/...`: protondrive ok, protontest ok, api has no test files. The old backend's tests still pass.
- `go vet ./backend/protondrive/...`: OK.
- `golangci-lint run ./backend/protondrive/...`: 0 issues.
Test output is clean, with no stray logs.
## Files changed
- `backend/protondrive/api/types.go` (new)
- `backend/protondrive/protontest/server.go` (new)
- `backend/protondrive/protontest/server_test.go` (new)
- `docs/plans/001-protondrive-tier1.md`: Task 1 boxes ticked, `CodeInvalidSection` added to the Interfaces block
## Self-review
- There are no name collisions with the old `protondrive.go`, since these are separate packages.
- No package-level mutable state. The two package-level vars are compiled regexes that are never written.
- A failed `Drop` hijack panics inside the handler. `net/http` recovers it and closes the connection, so the client still sees an error. That can only happen with HTTP/2, which `httptest.NewServer` does not use.
## Concerns
None blocking. Decision 1 (optional channel) is the one the controller may want to reverse.
@@ -0,0 +1,51 @@
### Task 10: Download
**Files:**
- Create: `backend/protondrive/download.go`, `backend/protondrive/download_internal_test.go`
**Interfaces:**
- Produces:
- `func (c *client) open(ctx context.Context, n *node, offset, limit int64) (io.ReadCloser, error)`
- `limit < 0` means to the end. Block `i` covers plaintext
`[sum(BlockSizes[:i]), ...)`; when `BlockSizes` is absent assume 4 MiB
for all but the last. Fetches block metadata 20 per page starting at the
first needed block, downloads through `storagePacer` holding a download
token, checks SHA-256 of the encrypted bytes against the server's
`Hash` before decrypting (mismatch -> retryable `errBlockHash`, 3
attempts, re-requesting the URL), decrypts, discards the bytes before
`offset`. On a read that starts at 0 and reaches EOF with `limit < 0`,
verifies the manifest signature; failure returns `errIntegrity` from
the final `Read` unless `IgnoreSignatureErrors`, in which case it logs
at `ERROR`. A storage 404 re-requests the block URL once.
- `var errIntegrity`, `var errBlockHash`
- `Close` cancels in-flight fetches and returns pool buffers; safe to
call twice.
Reference: `client/js/src/internal/download/` (`fileDownloader.ts`,
`apiService.ts`, `cryptoService.ts`).
- [ ] **Step 1: Write failing tests**:
- `TestDownloadSizes` (Review Focus 1): sizes 0, 1, 4 MiB - 1, 4 MiB,
4 MiB + 1, 8 MiB, 9 MiB + 123 round-trip byte-exact.
- `TestDownloadRanges`: for the 9 MiB + 123 file, table of
`(offset, limit)` including `(0, 1)`, `(4<<20 - 1, 2)`, `(4<<20, -1)`,
`(size - 1, -1)`, `(size, -1)` (empty), `(8<<20, 100)`; assert bytes
and that only the needed blocks were fetched (request counts).
- `TestDownloadLegacyFile`.
- `TestDownloadBlockHashMismatch` (`Corrupt "block-hash"`): error wraps
`errBlockHash`, no plaintext from that block is returned.
- `TestDownloadSwappedBlocks`, `TestDownloadTruncatedBlocks`,
`TestDownloadBadManifest`: full read ends in `errIntegrity`; with
`IgnoreSignatureErrors` the bad-manifest case returns the bytes.
- `TestDownloadStorage404Refetch`, `TestDownloadRetries5xx`.
- `TestDownloadCloseEarly`: read 10 bytes of an 8 MiB file, `Close`;
afterwards `runtime.NumGoroutine()` returns to its baseline within 2s
and `pool.Global().InUse()` is back to its starting value.
- `TestDownloadContextCancel`.
- [ ] **Step 2: Run** - expect FAIL.
- [ ] **Step 3: Implement.**
- [ ] **Step 4: Run** the verification commands - expect PASS.
- [ ] **Step 5: Commit** `protondrive: add verified ranged download`.
@@ -0,0 +1,161 @@
# Task 10 report: verified ranged download
**Status:** DONE
**Commit:** 4bcafe5cd protondrive: add verified ranged download
## What was implemented
- `backend/protondrive/download.go` (new)
- `func (c *client) open(ctx, n *node, offset, limit int64) (io.ReadCloser, error)`.
- `errIntegrity` and `errBlockHash`, plus an unexported `errDownloadClosed` that Read returns after Close.
- Block layout: if the xattrs have `BlockSizes`, block `i` starts at `sum(BlockSizes[:i])`. If not, every block is assumed to be 4 MiB. When the sizes are known, the range is clamped to their total. An empty range, such as `(size, -1)` or `limit == 0`, makes no requests.
- Block metadata comes from `GET /drive/v2/volumes/{v}/files/{id}/revisions/{rev}?FromBlockIndex=&PageSize=`.
- It starts at the first needed block, 20 blocks per page.
- For a bounded range the page size is `min(20, blocks still needed)`, so the server issues no URLs that won't be used.
- Block fetch:
- It runs through `storagePacer` while holding a `downloadTokens` token, sending only `pm-storage-token` plus the client's global headers (`x-pm-appversion`, `Accept`).
- It uses `c.s.srv.Call` directly, never `session.call`, so `Authorization` and `x-pm-uid` are never added.
- The encrypted bytes go into a `pool.RW` and are hashed while they stream in.
- Hash mismatch: `errBlockHash`, retried for 3 attempts in all, each with a new URL (`PageSize=1&FromBlockIndex=i`). The expected hash stays the listed one, which the manifest covers.
- A storage 404 re-requests the URL once.
- Decryption streams from the encrypted RW into a plaintext `pool.RW`. Bytes before `offset` are dropped with `Seek`.
- Full read (offset 0, limit < 0):
- Collects the thumbnail hashes from the first page (as the SDK does) and then every block hash, paging until an empty page.
- Checks the manifest against the revision's `SignatureEmail` keys, or the node key when that is empty (anonymous upload). Per the SDK, a missing signature is an integrity failure.
- A failure comes back as `errIntegrity` from the final Read. With `IgnoreSignatureErrors` it is logged at ERROR and the read ends in `io.EOF`.
- A zero-length file still pages once and checks the empty manifest.
- Content key: decrypted lazily in `open`. This follows the SDK's `decryptContentKeyPacket`:
- The signature is checked against the node key plus the node's key-verification keys. A missing signature is fine.
- If it fails and the node was created on or before 2023-01-01, it is checked again against all of the account's address keys.
- If it still fails, `open` returns `errIntegrity`. With `IgnoreSignatureErrors` the failure is logged once per node instead.
- A key packet that won't decrypt is an ordinary error.
- Defences against a hostile server:
- A block is rejected if its `Content-Length` or body exceeds 8 MiB, or if it decrypts to more than 8 MiB (this also stops a decompression bomb). Rejection is not retried.
- With claimed sizes, each decrypted block must match its claimed size.
- Block indexes must be contiguous.
- The list may not run past the claimed count, and with claimed sizes may not end before it. All of these are `errIntegrity`.
- Without claimed sizes, the list is capped at `EncryptedSize/1MiB + 1` blocks, so a block list that never ends gives an error, not an endless loop.
- Pipeline:
- One lister goroutine queues a slot per block on a channel with capacity `downloadAhead = 4`, then starts that block's fetch goroutine.
- The reader takes slots in order, so at most 4 blocks are queued plus the one being read.
- The end of the download, or the lister's error, travels as a final slot so it arrives in order after the data.
- Close:
- It cancels the context, waits for the lister and every fetch, then closes the current block and every queued result. This returns all pool buffers.
- It is safe to call twice.
- Read and Close share a mutex. Close cancels before taking the mutex, so a blocked Read wakes up.
- `nodes.go`: the retry and Retry-After body of `apiCall` moved into `pacedCall(ctx, p *fs.Pacer, fn func() (*http.Response, error))`. Both `apiCall` and block downloads use it, so there is one implementation.
- `crypto.go`: added `blockDecrypter(enc io.Reader, sk)` (gopenpgp `DecryptingReader`). `decryptBlock` now wraps it, so there is one decryption path. gopenpgp's own `Decrypt` is `DecryptingReader` + ReadAll, so behaviour is unchanged.
- `api/types.go`: added `RevisionResponse`, `Revision`, `Block` and `Thumbnail`, taken from the SDK's `GetRevisionResponseDto`, `DetailedRevisionResponseDto`, `BlockResponseDto` and `ThumbnailResponseDto`.
- `protontest/drive.go`:
- Added `SeedFileContentKeySignedBy(email, created, parentID, name, content)`. It signs the content key packet with the address `email`, or leaves it unsigned if `email` is empty, and backdates the file to `created`.
- `insertLink` now keeps a creation time that is already set.
- This is recorded in the plan's Interfaces block, along with `pacedCall`, `blockDecrypter` and the api types.
- Plan: Task 10 boxes ticked; Interfaces block updated.
- `docs/TODO.md`: added that `pacer.TokenDispenser.Get` takes no context.
## Decisions made
- **Per-download readahead:** `downloadAhead = 4`, i.e. at most about 5 × 4 MiB of plaintext buffered per open reader. The client-wide limit of 10 is still set by `downloadTokens`. The brief gives no per-file value.
- **No content key cache:** the content key is decrypted on every `open` (one PKESK decryption), not cached on the node. Nodes are shared and must not be modified, and caching would need a mutable field in `node`.
- **Hard errors:** the block-list and size checks above are hard errors even with `IgnoreSignatureErrors`, because they are structural rather than signature checks. Only signature failures (content key, manifest) are ignorable. A failure to fetch the manifest signer's keys is a plain error, not `errIntegrity`.
- **Assumed 4 MiB sizes:**
- A ranged read (offset > 0 or limit ≥ 0) of a file without `BlockSizes` errors if a block it reads past is not exactly 4 MiB. That would mean the assumption is wrong, for example 4 MB blocks from old clients. A full read never relies on the assumption.
- Blocks before the first one read can't be checked, so a ranged read of such a file is only as good as the brief's assumption.
- **Wrong-length bodies:** a body over the cap returns a nil response from the paced function, so `shouldRetry` doesn't retry it.
## Tests (download_internal_test.go)
TestDownloadSizes (0, 1, 4MiB-1, 4MiB, 4MiB+1, 8MiB, 9MiB+123), TestDownloadRanges, TestDownloadLegacyFile, TestDownloadManifestSigners, TestDownloadContentKey, TestDownloadBlockHashMismatch, TestDownloadSwappedBlocks, TestDownloadTruncatedBlocks, TestDownloadBadManifest, TestDownloadEmptyFileBadManifest, TestDownloadStorage404Refetch, TestDownloadRetries5xx, TestDownloadStorageRetryAfter, TestDownloadStorageHeaders, TestDownloadBlockTooLarge, TestDownloadEndlessBlockList, TestDownloadCloseEarly, TestDownloadContextCancel.
- **Ranges:** the brief's table plus `(0,-1)`, `(0,0)` and `(size-10,100)`. Each row runs once with the claimed sizes and once with `BlockSizes` removed (assumed 4 MiB), and asserts the bytes and the storage request count.
- **Manifest signers:** a file signed by another user and an anonymous upload both read in full.
- **Content key:** unsigned → OK; signed by the signer address → OK; signed by another own address before 2023 → OK (fallback); the same after 2023 → `errIntegrity`, but readable with `IgnoreSignatureErrors`.
- **Block hash mismatch:** 3 storage attempts and 4 revision requests (the list page, the empty end page and 2 URL re-requests); the error wraps `errBlockHash` and no bytes come back.
- **Swapped and truncated blocks:** a full read ends in `errIntegrity`. Truncation is also tested without `BlockSizes`, where only the manifest catches it.
- **Bad manifest:** a full read returns all bytes and then `errIntegrity`; a ranged read is unaffected; `IgnoreSignatureErrors` returns the bytes.
- **Empty file with a bad manifest:** `errIntegrity`.
- **404 refetch:** one 404 is recovered with 2 storage and 3 revision requests; a persistent 404 is tried only twice.
- **Retries:** 5xx on storage (×2) and on the revision list (×1) are retried. A storage 429 with Retry-After 1 waits at least 1s.
- **Storage headers:** checked through a wrapping transport — no `Authorization` or `x-pm-uid`; `pm-storage-token` and `x-pm-appversion` are present.
- **Hostile:**
- An endless storage body gives "larger than" after one fetch.
- An endless block list, served by a transport that renumbers copies of block 1, gives `errIntegrity` within at most 2 pages, both with claimed sizes and with assumed sizes (encrypted-size cap).
- **Close early and context cancel:** both non-parallel. After Close, the goroutine count is back to baseline within 2s and `pool.Global().InUse()` is back to its starting value. Context cancel returns `context.Canceled` while one block hangs on an injected one-minute delay.
### TDD evidence
- **RED:** `RCLONE_CONFIG=/notfound go test -run 'TestDownload' ./backend/protondrive/` failed to build with `c.open undefined`, `undefined: errIntegrity`, `undefined: errBlockHash`. This was expected, as nothing existed yet.
- **First run after implementing:** everything passed except TestDownloadBlockHashMismatch's revision count (got 4, want 3). The test was wrong because it forgot the empty end-of-list page, so the expectation and its comment were fixed.
- **GREEN:**
- `RCLONE_CONFIG=/notfound go test -race -count=3 -run 'TestDownload' ./backend/protondrive/` → `ok ... 28.745s`.
- Full verification:
- `go build ./backend/protondrive/...` OK.
- `go vet ./backend/protondrive/...` OK.
- `golangci-lint run ./backend/protondrive/...` → `0 issues.`
- `RCLONE_CONFIG=/notfound go test -race ./backend/protondrive/...` → `ok backend/protondrive 67.068s`, `ok backend/protondrive/protontest 25.122s`.
- **Output:** the tests that exercise the `IgnoreSignatureErrors` paths log the expected `ERROR : protondrive: ... integrity check failed` lines (the behaviour under test). Nothing else is printed.
## Files changed
- backend/protondrive/download.go (new)
- backend/protondrive/download_internal_test.go (new)
- backend/protondrive/nodes.go (pacedCall factored out of apiCall)
- backend/protondrive/crypto.go (blockDecrypter; decryptBlock uses it)
- backend/protondrive/api/types.go (revision and block types)
- backend/protondrive/protontest/drive.go (SeedFileContentKeySignedBy; insertLink keeps a preset creation time)
- docs/plans/001-protondrive-tier1.md, docs/TODO.md
## Concerns / notes
- `pacer.TokenDispenser.Get` can't be cancelled. A cancelled fetch first waits for a token from the other in-flight blocks, which finish or fail quickly. Logged in TODO.md.
- The fake's `Dump` checks content key signatures the way current clients do, so it reports a `SeedFileContentKeySignedBy` file unless `email` is the primary address. This is documented on the seeder; no test dumps such a file.
- No identifier collided with protondrive.go. The new block-size constant is named `defaultBlockSize`.
## Fix round 1
**Commit:** e8b135976 protondrive: reject absurd recorded block sizes when downloading
### What changed
- **`download.go` `open`:**
- Any recorded block size above `maxBlockSize` (8 MiB) now returns `errIntegrity` from `open`. This also bounds the total, so it can't overflow.
- A full read (offset 0, limit < 0) no longer takes the empty-reader shortcut and never has its limit clamped. It always lists the blocks and reaches the manifest verdict.
- Added the FIXME where the start block is computed: without recorded block sizes, the blocks before the first one read can't be checked.
- **`protontest/drive.go`:** added `SeedFileClaimingBlockSizes(parentID, name, content, blockSizes)`, which writes arbitrary `BlockSizes` into the extended attributes. It is recorded in the plan's Interfaces block.
- **`TestDownloadHostileBlockSizes` (new):** every case below reads in full and must end in `errIntegrity`.
- "overflowing": `[MaxInt64, 1]`.
- "above the cap": `[8 MiB + 1]` on 1000 bytes.
- "sum too small": `[1000]` on 4 MiB + 1.
- "too few blocks": `[4 MiB]` on 4 MiB + 1.
- **`TestDownloadStorageHeaders`:** a second part rewrites the revision responses so block `BareURL`s point at a second `httptest` server on a different host:port. That server records the headers and passes the request on to the fake. The test asserts both blocks went there, with `pm-storage-token` but without `Authorization` or `x-pm-uid`.
### Pre-2023 fallback time (confirmed, unchanged)
- The SDK applies the fallback unless `node.creationTime > new Date(2023, 0, 1)` (`client/js/src/internal/nodes/cryptoService.ts:652`).
- `node.creationTime` is `new Date(link.Link.CreateTime * 1000)` (`client/js/src/internal/nodes/apiService.ts:858`), i.e. the link's creation time.
- Ours uses `n.CreateTime.Unix() <= contentKeyFallbackUntil` (1672531200). `n.CreateTime` is `time.Unix(l.CreateTime, 0)` from `api.Link.CreateTime` in `decryptNode`, so it is the same field.
- The only difference: the SDK's `Date(2023, 0, 1)` is in local time, while ours is fixed at UTC.
### RED against the old code
Command: `RCLONE_CONFIG=/notfound go test -run 'TestDownloadHostileBlockSizes' ./backend/protondrive/`
```
--- FAIL: TestDownloadHostileBlockSizes/overflowing (0.03s)
download_internal_test.go:539:
Error: Expected error with "integrity check failed" in chain but got nil.
FAIL
```
The overflowing case reproduces the finding: a clean, empty EOF with no error. The other three cases already passed on the old code:
- "above the cap": the real block's size doesn't match the recorded size.
- "sum too small" and "too few blocks": more blocks are listed than recorded.
They are kept as guards. After the cap, no recorded sizes can make a full read's total ≤ 0, so the full-read rule has no separate RED case and is defence in depth.
### GREEN
- `go build ./backend/protondrive/...` OK.
- `go vet ./backend/protondrive/...` OK.
- `golangci-lint run ./backend/protondrive/...` → `0 issues.`
- `RCLONE_CONFIG=/notfound go test -race ./backend/protondrive/...` → `ok backend/protondrive 65.405s`, `ok backend/protondrive/protontest 26.191s`. The only output is the expected `IgnoreSignatureErrors` ERROR log lines.
@@ -0,0 +1,96 @@
### Task 11: Upload
**Files:**
- Create: `backend/protondrive/upload.go`, `backend/protondrive/upload_internal_test.go`
- Create: `backend/protondrive/protontest/upload.go`
**Interfaces:**
- Produces (`protontest`): routes
`POST /drive/v2/volumes/{volumeID}/files`,
`POST .../files/{linkID}/revisions`,
`GET .../links/{linkID}/revisions/{revisionID}/verification`,
`POST /drive/blocks`, `POST /storage/upload/{token}` (multipart, needs
`pm-storage-token`, tokens expire - `func (s *Server) ExpireUploadTokens()`),
`PUT .../files/{linkID}/revisions/{revisionID}`,
`DELETE .../files/{linkID}/revisions/{revisionID}`,
`POST .../delete_multiple`.
Strictness: draft creation enforces name-hash uniqueness and returns
`ConflictLinkID`, `ConflictDraftRevisionID`, `ConflictDraftClientUID` in
`Details`; `/drive/blocks` rejects a block whose `Verifier.Token` is not
`code XOR block-prefix` for the uploaded bytes (checked at commit, HTTP
422 code 2000); commit rejects a manifest that does not verify over the
stored block hashes, a missing block index, or an `XAttr` that is empty;
`CurrentRevisionID` must match for new revisions.
- Produces (`protondrive`):
- `type uploadRequest struct { ParentID, Name string; Existing *node; In io.Reader; Size int64; ModTime time.Time; MIMEType, SHA1 string }`
- `Size < 0` means unknown; `SHA1` is the source's, `""` if unknown.
- `func (c *client) upload(ctx context.Context, r uploadRequest) (*node, error)`
Behaviour is spec section 6.3, steps 1-7, in that order. Decisions the
spec leaves open:
- Blocks are read into `pool.Global()` buffers; a producer reads and
encrypts, `UploadConcurrency` workers upload, all bounded by the upload
token dispenser. Every channel send/receive selects on `ctx.Done()`.
- A block upload is attempted up to 3 times through `storagePacer`
(`CallNoRetry` inside an explicit loop so the buffer is re-sent);
a 404 from storage re-requests that block's URL.
- `IntendedUploadSize` is `Size` rounded down to a multiple of 100,000,
omitted when `Size < 0`.
- SHA1 is computed while reading. If `r.SHA1 != ""` and differs, or if
`Size >= 0` and the bytes read differ from `Size` (short or long), fail
with `errUploadMismatch` before commit.
- Commit error: `getNode` and compare the active revision ID; if it is
ours, return success.
- Cleanup on any failure: for a new file delete the draft link
(`delete_multiple`); for a new revision delete the draft revision.
Cleanup uses `context.WithoutCancel(ctx)` with a 30s timeout.
- Draft conflict: own `DraftClientUID` -> delete it, retry once; another
client's draft -> `errExists` unless `ReplaceExistingDraft`; an active
file (no draft revision in the details) -> the caller passed no
`Existing`, so fetch that node and upload a new revision to it.
Reference: `client/js/src/internal/upload/` (`manager.ts`,
`streamUploader.ts`, `fileUploader.ts`, `apiService.ts`,
`blockVerifier.ts`, `cryptoService.ts`).
- [ ] **Step 1: Write failing tests** (assert with `user.Dump()` and
`user.Drafts()`):
- `TestUploadSizes` (Review Focus 1): 0, 1, 4 MiB - 1, 4 MiB, 4 MiB + 1,
9 MiB + 123; content, modtime (1s precision), SHA1 and block count
(`ceil(size / 4 MiB)`, 0 for empty) are right.
- `TestUploadUnknownSize`: `Size: -1` works for 0 and 5 MiB.
- `TestUploadNewRevision`: same node ID, new content, old revision
superseded.
- `TestUploadOverExistingNameCreatesRevision`.
- `TestUploadShortSource`, `TestUploadLongSource` (Review Focus 3):
`errUploadMismatch`, `Drafts()` empty, pool `InUse` restored.
- `TestUploadSHA1Mismatch`.
- `TestUploadBlock502Once`: succeeds; `TestUploadBlock502Forever`
(issue 10224): returns an error within 30s, `Drafts()` empty, goroutine
count back to baseline.
- `TestUploadStorageTokenExpired`: `ExpireUploadTokens()` mid-upload,
succeeds.
- `TestUploadCommitErrorButCommitted`: `Fault{AfterHandler: true}` on
commit; `upload` returns the node, exactly one file exists.
- `TestUploadOwnDraftReplaced` (issue 9622): leave a draft via a faulted
commit plus faulted cleanup, upload again -> succeeds, one file.
- `TestUploadForeignDraft`: `errExists`; with `ReplaceExistingDraft`
succeeds.
- `TestUploadCancel`: cancel at draft, mid-blocks and before commit (use
`Fault.Delay`); `Drafts()` empty each time.
- `TestUploadConcurrencyBound`: with `UploadConcurrency: 2` the fake's
peak concurrent `/storage/upload` count is at most 2; with 16 parallel
`upload` calls it is at most 10.
- `TestUploadVerifierChecked`: an `upload` whose verifier is sabotaged
through a test hook is rejected by the fake (proves the fake checks).
- `TestUploadClockSkew`: server 3h ahead and 3h behind both succeed.
- `TestUploadThenDownload`: round trip through Task 10 for 9 MiB + 123.
- [ ] **Step 2: Run** - expect FAIL.
- [ ] **Step 3: Implement.**
- [ ] **Step 4: Run** the verification commands - expect PASS. Also
`go test -race -count=5 ./backend/protondrive/ -run TestUpload`.
- [ ] **Step 5: Commit** `protondrive: add verified block upload`.
@@ -0,0 +1,304 @@
# Task 11 report: verified block upload
Commit: `9a4ed61a8 protondrive: add verified block upload`
## What was implemented
### Client (`backend/protondrive/upload.go`, 879 lines)
You set a limit of about 900 lines. upload.go is at 879, so I am reporting it rather than splitting it.
- `uploadRequest` and `(*client).upload` match the interface in the plan (no change to it).
- **Step 1, create the draft.** `createDraft` creates a draft file with these parts:
- a v4 file key advertising AEAD, locked with an ordinary S2K;
- a v6 PKESK content key;
- the name and passphrase without AEAD;
- `ClientUID` from `DraftClientUID`, sent as null if empty;
- `IntendedUploadSize`, rounded as the SDK rounds it.
For `Existing` it calls `createRevision` instead, which sends `CurrentRevisionID`.
- **Step 2, name conflict (`resolveConflict`).**
- If `ConflictRevisionID` is set, or there is no draft revision, the node is re-read. A file gets a new revision. A folder, or anything else, returns `errExists`.
- If the draft is ours (non-empty UID that matches), or `ReplaceExistingDraft` is set, it calls `delete_multiple` and retries once. A draft that comes back after that returns `errExists`.
- Otherwise it returns `errExists` with a hint about `replace_existing_draft`.
- Without a `DraftClientUID`, no draft counts as ours. This matches the SDK.
- **Step 3, verification.** `verificationData` decrypts the server's `ContentKeyPacket`, as `BlockVerifier` does. Each block is then:
- encrypted and signed in one pass by a new `crypto.go:encryptBlockTo`; `encryptBlock` now wraps it, so there is one implementation and the existing vectors and diff tests cover it;
- hashed with SHA-256;
- decrypted again as a check;
- given its verifier token.
A failed check is retried once, like the SDK's `MAX_BLOCK_ENCRYPTION_RETRIES`.
- **Step 4, the upload pipeline.** It uses an errgroup:
- one producer reads and encrypts blocks;
- one requester batches the waiting blocks into `POST /drive/blocks`;
- `uploadConcurrency()` workers upload (default 4, max 5).
Every channel send and receive selects on `ctx.Done()`. Each block holds two things until it is uploaded or freed: a per-file slot (capacity = concurrency) and a client token from `uploadTokens` (10).
- **Uploading a block:** a multipart POST with field `Block` and filename `blob`, as the SDK sends it. It sends `pm-storage-token` plus the rest client's `x-pm-appversion`, and never Authorization or x-pm-uid. Calls go through `c.paced` (one retry and Retry-After implementation) inside `storagePacer.CallNoRetry` and an explicit loop of 3 attempts. A 404 requests that block's URL again, and the 404 counts as an attempt so the loop stays bounded.
- **Refactor in nodes.go:** `pacedCall` is split into `pacedCall` and `paced`. The behaviour is unchanged.
- **Request bodies:** each attempt's body is a `multipartBlock` that stops reading the pool buffer once the attempt ends. The HTTP transport may keep reading a request body after `RoundTrip` returns, which would otherwise race with the retry's `Seek` or with `free`.
- **Step 5, checks before commit.**
- Size: a long source fails with `errUploadMismatch` as soon as it passes `Size`; a short one fails before commit.
- SHA1: compared case-insensitively against the source's.
- Block count: must equal `ceil(size/4 MiB)`.
- **Step 6, commit.** It signs the manifest and encrypts the xattrs (ModTime if set, Size, BlockSizes, SHA1), then commits with `ChecksumVerified` and `Photo: null`. Then it always drops the cache entry and calls `getNode`:
- If the commit failed but the node is active with our revision ID, the upload succeeds.
- If the commit succeeded but reading the node back failed, it returns an error and does not clean up, so a committed file is never deleted.
- **Step 7, cleanup.** On any failure before commit, `deleteDraft` uses `context.WithoutCancel` with a 30s timeout. A new file is deleted with `delete_multiple`; a revision with `DELETE .../revisions/{id}`. A cleanup failure is logged at ERROR.
- **Token wait.** `acquireToken` puts a context around `TokenDispenser.Get`. If the wait is abandoned, its goroutine returns the token as soon as it gets one, so nothing deadlocks and no token leaks (`TestAcquireTokenCancel`).
- **Test hook.** `client.verifierHook` is an instance field, not package state.
- **Wire types** are in `api/types.go`: `ConflictDetails`, `CreateFile*`, `CreateRevision*`, `VerificationResponse`, `BlockUpload*`, `UploadLink`, `CommitRevisionRequest`.
### Fake (`protontest/upload.go`, 693 lines, plus small edits)
- **Routes:** the 8 listed in the brief, plus `ExpireUploadTokens()` and `StorageUploadPeak()`. The peak is counted in `serveHTTP`, so `Fault.Delay` counts toward it, and `ResetRequests` resets it.
- **Strictness**, all answered with HTTP 422 code 2001 unless noted:
- A file draft must have a non-AEAD Name and NodePassphrase (via `checkMessages`).
- The file key must be v4 with iterated-salted S2K. `checkFolderKey` was generalised into `checkNodeKey(armored, aead)` plus `checkFileKey`.
- `ContentKeyPacket` must be exactly one v6 PKESK.
- `ContentKeyPacketSignature`, `NodePassphraseSignature` and `MIMEType` are required.
- `IntendedUploadSize` must be rounded.
- `SignatureAddress` must be one of the user's addresses.
- Name-hash uniqueness is enforced, and the conflict Details include the draft revision and its ClientUID.
- **At commit:**
- XAttr must be present and non-AEAD (2001).
- Blocks 1..n must all have been requested and uploaded, with no gaps (2000).
- A declared `Hash`, if one was sent, must match (2000).
- Each verifier token must equal code XOR the prefix of the uploaded bytes (2000).
- Each block must be a single SEIPD packet whose version matches the content key packet: SEIPDv2 for v6, SEIPDv1 for a seeded legacy file (2001).
- The manifest signature must verify over the stored hashes, in order, with the keys of `SignatureAddress`, at the server's clock (2001).
- On success, the previous active revision becomes obsolete (state 2).
- **Storage upload rules:**
- A request carrying session credentials gets HTTP 400.
- A wrong `pm-storage-token` gets 403.
- An expired URL, or a block that is no longer awaited, gets 404.
- **Other routes:**
- `delete_multiple` deletes drafts only and gives a per-link error for anything else.
- `DELETE` revision accepts draft revisions only.
- `createRevision` requires a matching `CurrentRevisionID` (2000).
- **Fake changes needed for the clock-skew test:** the 3h-behind case could not even sign in before, so I changed three things.
- The fake now generates keys and makes signatures at `signTime()`, which is now minus 24h.
- `Dump` checks signatures at the server clock (`driveView.now`). For that, `decryptArmored`, `verifyDetached`, `unlockDriveKey`, `decryptContentKey` and `decryptBlock` take an `at time.Time` (zero means now).
- `serveFault` reads the request body before a `Delay`. Without that, Go's server never notices that the client gave up, and the delayed handler kept running (httptest Close blocked for a minute).
## Decisions made (no review stop, per your standing instruction)
1. **IntendedUploadSize follows the SDK, not the brief.** For a new file it is `floor(size/1e5)*1e5`, sent only when that is over 100,000. For a revision it is `reduceSizePrecision` (4095 under 4 KiB, 100,000 under 100 kB). It is null when the size is unknown. The commit message says so.
2. **No `Hash` in BlockList**, because the SDK omits it (deprecated and unused). "The SHA-256 the client declared" is taken to mean the manifest, which commit checks against the hashes of the uploaded bytes. A `Hash` field, if sent, is also checked, and there is a fake-side test for it.
3. **Conflict with an active file that also has a draft revision** (both `ConflictRevisionID` and `ConflictDraftRevisionID` set): we upload a new revision. The SDK would `delete_multiple` the link. I chose this because it cannot destroy an active file. It is logged in TODO.md.
4. **An empty stream of unknown size uploads as zero blocks.** The SDK refuses that, but the brief requires it to work.
5. **"Every declared block index uploaded exactly once"** is implemented as: each requested index 1..n has exactly one stored block at commit. Uploading again to the same URL replaces the block, so a retry after a lost storage response works.
6. **A cleanup failure is logged at ERROR**, as the SDK does. Two deliberate fault tests therefore print an ERROR line each.
7. **A storage 404 counts toward the 3 attempts**, so a storage host that only ever answers 404 cannot loop forever.
8. **Fault codes:** the fake uses 422/2000 for verifier failures, missing blocks and a stale CurrentRevisionID, and 2001 for format and signature failures. The real codes still need confirming; this is in TODO.md.
## Memory
- `pool.Global()` pages are 1 MiB (`pool.BufferSize`).
- A 4 MiB plaintext block uses 4 pages, plus a 5th page touched by `RW.ReadFrom` at exact-4 MiB EOF. The plaintext is freed right after encryption.
- The ciphertext is about 4 MiB plus AEAD overhead, so 5 pages, held until that block's upload finishes.
- Peak is therefore about 10 × 5 MiB of ciphertext per client, plus at most one transient plaintext per uploading file. Plaintext is read only while a client token is held, so it is bounded by 10 as well.
- Nothing per block is allocated outside the pool on the hot path. The exceptions are small: gopenpgp's internal AEAD and stream buffers (256 KiB chunk size), the detached signature, and the 32-byte token prefix.
## Tests
Client tests (`upload_internal_test.go`):
- TestUploadSizes
- TestUploadUnknownSize
- TestUploadNewRevision (checks the old revision has State 2)
- TestUploadOverExistingNameCreatesRevision (also: a folder in the way gives `errExists`)
- TestUploadShortSource and TestUploadLongSource (each runs 2 sizes and checks for goroutine and pool leaks)
- TestUploadSHA1Mismatch
- TestUploadBlock502Once
- TestUploadBlock502Forever (finishes in seconds, leak check)
- TestUploadStorageTokenExpired (expires the tokens from a transport hook on the first storage request)
- TestUploadCommitErrorButCommitted
- TestUploadDraftResponseLost (your ruling: the 2500 points at our own UID, then delete and retry)
- TestUploadOwnDraftReplaced
- TestUploadForeignDraft (also: no UID means no own draft)
- TestUploadCancel (cancel at draft, blocks and commit, with a leak check)
- TestUploadConcurrencyBound (per file ≤ 2; 16 parallel uploads ≤ 10 and > 1)
- TestUploadVerifierChecked
- TestUploadClockSkew (±3h, including a revision upload of unknown size)
- TestUploadThenDownload
- TestIntendedUploadSize
- TestAcquireTokenCancel
After every successful upload test, `requireUploaded` checks the fake's `Dump()` for each node: no `Err`, and content, SHA1, mtime and block sizes (via Dump's xattr checks) all match. It also checks that `Drafts()` is empty.
Fake tests (`protontest/upload_test.go`):
- TestCreateFileFormats: AEAD Name and passphrase variants, a v6 key, an AEAD-locked key, salted S2K, a v3 content key packet, missing signatures, an unrounded size, the conflict details.
- TestCommitChecks: AEAD, empty or missing XAttr; verifier; a block not uploaded; an index gap; a declared hash; the manifest; the manifest signer; a SEIPDv1 block. Then a valid commit is checked through Dump, and recommit and deletes are refused.
- TestStorageUploadRules
- TestRevisionRules
- TestUploadWireNames
Commands, all run with `RCLONE_CONFIG=/notfound`:
- `go build ./backend/protondrive/...`: OK
- `go vet ./backend/protondrive/...`: OK
- `golangci-lint run ./backend/protondrive/...`: 0 issues
- `go test -race ./backend/protondrive/...`: `ok protondrive ~75s`, `ok protontest ~30s`. After the fix described below, I ran it 6 times and it passed every time.
- `go test -race -count=5 ./backend/protondrive/ -run TestUpload`: `ok 105.461s`
- Output: the expected ERROR lines from the two fault tests whose draft cleanup fails, plus existing log lines from other tests.
One full run failed before I fixed a race in my own test: the PerClient subtest read a map from goroutines, and the fatal concurrent-map error aborted the binary. Fixed; all runs since then are green.
## TDD evidence
I did not follow TDD strictly. upload.go and the fake routes were written before `upload_internal_test.go`, so there is no RED output from before the implementation.
To show the tests check behaviour, I ran a mutation check. I disabled the fake's verifier comparison (`false && ...`):
- `TestUploadVerifierChecked` failed with "An error is expected but got nil".
- `TestCommitChecks/verifier` failed.
With the check restored, both pass.
The first runs also caught real defects, which I then fixed:
- ±3h clock skew failed: Dump verified at local time, and the fake signed at local time.
- Cancel tests stalled httptest Close for a minute (the delayed request body was never read).
- The data race described above.
## Files changed
- New: `backend/protondrive/upload.go`, `upload_internal_test.go`, `protontest/upload.go`, `protontest/upload_test.go`
- Modified: `api/types.go`, `crypto.go` (`encryptBlockTo`), `nodes.go` (`paced` split, `verifierHook`), `protontest/{account,crypto,drive,drive_test,mutate,server}.go`, `docs/TODO.md` (3 entries), `docs/plans/001-protondrive-tier1.md` (Task 11 steps ticked)
## Concerns
- upload.go is at 879 lines, close to your 900-line limit. It is not split.
- The fake changes go beyond upload. Every fake signature is now backdated one day, and Dump checks at the skewed clock. These were needed for the 3h-behind case, and all existing fake and client tests still pass.
- The real Proton status and code values for verifier, missing-block and stale-revision errors are guesses (TODO.md). The real server's `delete_multiple` behaviour for active links is unconfirmed.
- If a lost response causes `createRevision` to be retried, an orphan draft revision can be left behind (TODO.md).
- A source reader that blocks forever cannot be interrupted by ctx. That is outside the client's control.
## Fix round 1
### Important finding: commit check under cancellation
The problem: after a commit error, `uploadDraft` read the node back with the caller's ctx. If that ctx was cancelled, or the read failed for any other reason, the code concluded "not active" and cleanup deleted the draft. For a commit that had actually landed, that meant deleting the new file (`delete_multiple`) or the new active revision.
What changed in `backend/protondrive/upload.go`:
- The post-commit `getNode` now runs under `context.WithTimeout(context.WithoutCancel(ctx), 30s)`, the same as cleanup.
- If the commit errored and the read also failed, the outcome is unknown. Nothing is deleted (committed=true), and the error wraps a new sentinel, `errUploadOutcomeUnknown` ("upload outcome unknown: it may have succeeded"), plus the commit error and the read error. No node is returned. Any draft left behind is handled by the own-draft rule on the next upload.
- If the read shows our revision is active, the upload succeeds, as before.
- The draft is deleted only when the read positively shows our revision is not active.
- The doc comments of `upload` and `uploadDraft` now state this contract.
New tests in `upload_internal_test.go`:
1. **`TestUploadCancelDuringCommit`**, with subtests for a new file and a new revision. A transport hook forwards the commit PUT, so the fake's handler really runs and commits. Then it cancels ctx and returns `context.Canceled` to the client. The test asserts:
- no `POST delete_multiple` and no `DELETE .../revisions/{id}` was sent;
- `upload` returns success, because the read shows the revision active;
- Dump shows the file intact, and there are no drafts.
2. **`TestUploadCommitOutcomeUnknown`**: the commit gets a 422 and `POST .../links` also gets a 422. The test asserts the error `Is` `errUploadOutcomeUnknown` and contains "may have succeeded", that no delete of either kind was sent, and that one draft is left.
3. **`TestUploadCommitRefused`**: the commit gets a 422 once and the read shows the revision not active. The test asserts exactly one `delete_multiple` was sent, no drafts are left, no file exists, and the error is not `errUploadOutcomeUnknown`.
RED against 9a4ed61a8 (tests added, fix not yet applied). Command:
```
RCLONE_CONFIG=/notfound go test -count=1 -run 'TestUploadCancelDuringCommit|TestUploadCommitOutcomeUnknown|...' ./backend/protondrive/
--- FAIL: TestUploadCommitOutcomeUnknown (0.32s)
Error: Target error should be in err chain:
Error: "upload \"f\": commit: protontest: injected fault (Code=2001, Status=422)" does not contain "may have succeeded"
Error: Should be zero, but was 1 Messages: draft deletion sent
Error: "[]" should have 1 item(s), but has 0
--- FAIL: TestUploadCancelDuringCommit (0.60s)
--- FAIL: TestUploadCancelDuringCommit/revision=false
Error: Received unexpected error:
upload "f": commit: Put "http://127.0.0.1:41259/drive/v2/volumes/.../revisions/...": context canceled
--- FAIL: TestUploadCancelDuringCommit/revision=true
(same)
FAIL
```
The first assertion in test (1) stopped the run, so the delete counts were not reached. To show them, I temporarily changed that `require.NoError` to `assert.NoError` and ran it again:
- `revision=false` failed with "draft deletion sent": cleanup sent `delete_multiple` for the committed file.
- `revision=true` failed with "revision deletion sent": cleanup sent DELETE for the committed revision.
I reverted that change afterwards. With the fix, all three tests pass.
### Minor findings
- **Fake data race:** `handleStorageUpload` read `up.expired` after unlocking. It now computes `usable` while holding `s.mu`.
- **New `TestUploadSourceError`:** the reader returns 5 MiB + 10 bytes, then an error. The test asserts `ErrorIs` the source error, no drafts, no file, and no goroutine or pool leak.
- **New `TestUploadBlockFailsProducerBlocked`:** `UploadConcurrency: 1`, 4 blocks, storage answers 502 forever. The test asserts an error, exactly 3 storage requests (one block tried 3 times), no drafts, no file, and no leaks.
- **`TestUploadThenDownload`** now runs the Dump check (`requireUploaded`).
- **Plan:** I unticked Task 11 "Step 2: Run - expect FAIL" and added a note that no RED run happened because the implementation was written first.
### Verification
All run with `RCLONE_CONFIG=/notfound`:
- `go vet ./backend/protondrive/...`: OK
- `golangci-lint run ./backend/protondrive/...`: 0 issues
- `go test -race -count=1 ./backend/protondrive/...`:
```
ok github.com/rclone/rclone/backend/protondrive 73.966s
ok github.com/rclone/rclone/backend/protondrive/protontest 29.910s
```
- `go test -race -count=5 ./backend/protondrive/ -run TestUpload`:
```
ok github.com/rclone/rclone/backend/protondrive 120.787s
```
## Fix round 2
### Finding: "not active" is not proof that the commit failed
The problem: round 1 deleted the draft whenever the follow-up read showed our revision was not active. Two cases make that wrong:
- **New file:** the node may be active with another client's revision, or trashed. Either proves our commit landed, yet cleanup sent `delete_multiple` for the node.
- **New revision:** a third revision may supersede ours. Cleanup then sent `DELETE` for our committed, now historical, revision.
The fix in `backend/protondrive/upload.go` follows the controller's ruling:
- `draft` gains `baseRevisionID`, the `CurrentRevisionID` the draft revision was created from.
- `(*draft).notCommitted(n)` is true only in two cases: a new file whose node is still a draft, or a revision whose node's active revision is still `baseRevisionID`. Only then is the draft deleted.
- After a failed commit and a successful read:
- our revision active on an active node: success;
- `notCommitted`: delete the draft, and return the commit error;
- anything else: delete nothing, and return `errUploadOutcomeUnknown` ("... the file has changed since").
- If the commit failed and the read failed too: `errUploadOutcomeUnknown`, nothing deleted (unchanged from round 1).
- **The commit succeeded but the read shows a revision that is not ours:** a new sentinel, `errUploadSuperseded`, with no node returned and nothing deleted. If that read fails instead, the error is "read the uploaded file" and nothing is deleted.
- The `upload` doc comment now states the real contract. It deletes a draft only before commit or on positive proof that the commit did not land. It names both sentinels, and says the returned node is always this upload's own revision.
- The stale `failingReader` comment is fixed.
### Fake
- New `func (u *User) SeedRevision(id string, content []byte, modTime time.Time) (revisionID string)`. It gives a file a new active revision as another client would, and makes the previous active revision obsolete. It is recorded in the plan's Interfaces block for Task 11.
- `seedFile`'s revision building was factored into `contentKey.newRevision` so both use it.
- Test: `TestSeedRevision`, for a current and a legacy file; Dump is clean.
### Tests
Each test below asserts zero `POST delete_multiple` and zero `DELETE .../revisions/{id}` requests, and checks Dump. The hook (`interveneAfterCommit`) forwards the commit PUT, so the server really commits. It then runs "another client's" change, and then shows the client a network error.
1. **`TestUploadCommitThenChanged/NewFileNewRevision`:** after the commit, `SeedRevision` adds a newer revision. Result: `errUploadOutcomeUnknown`, and the file is present with the other client's content.
2. **`TestUploadCommitThenChanged/NewFileTrashed`:** a second client trashes the node after the commit. Result: `errUploadOutcomeUnknown`, and the trashed file is intact with our content.
3. **`TestUploadCommitThenChanged/NewRevision`:** a new revision, with a third revision seeded after the commit. Result: `errUploadOutcomeUnknown`. Dump shows the third revision active, and our committed revision still exists with State 2 (read through the revision route).
4. **`TestUploadCommittedThenSuperseded`:** the commit succeeds (real response kept), then another revision is seeded. Result: `errUploadSuperseded`, nil node, and nothing deleted.
5. **`TestUploadRevisionCommitRefused`:** a new revision whose commit is refused (422, handler not run, active revision unchanged). Result: exactly one revision DELETE, and the original content is intact.
RED against 1f1b2bb32. The tests were added with only the `errUploadSuperseded` declaration, not its behaviour. Command:
```
RCLONE_CONFIG=/notfound go test -count=1 -v -run 'TestUploadCommitThenChanged|TestUploadCommittedThenSuperseded|TestUploadRevisionCommitRefused' ./backend/protondrive/
--- PASS: TestUploadRevisionCommitRefused (0.30s)
--- FAIL: TestUploadCommittedThenSuperseded (0.30s)
Error: Expected error with "upload committed, but the file now has another revision" in chain but got nil.
Error: Expected nil, but got: &protondrive.node{... RevisionID:"59735c0b..." ...} (another client's revision returned as ours)
--- FAIL: TestUploadCommitThenChanged (0.00s)
--- FAIL: TestUploadCommitThenChanged/NewFileNewRevision Target error should be in err chain; Should be zero, but was 1: draft deletion sent
--- FAIL: TestUploadCommitThenChanged/NewRevision Target error should be in err chain; Should be zero, but was 1: revision deletion sent
--- FAIL: TestUploadCommitThenChanged/NewFileTrashed Target error should be in err chain; Should be zero, but was 1: draft deletion sent
FAIL
```
Test 5 passes before and after, as expected: that path deleted correctly in 1f1b2bb32 and still must.
### Verification
All run with `RCLONE_CONFIG=/notfound`:
- `go vet ./backend/protondrive/...`: OK
- `golangci-lint run ./backend/protondrive/...`: 0 issues
- `go test -race -count=1 ./backend/protondrive/...`:
```
ok github.com/rclone/rclone/backend/protondrive 81.363s
ok github.com/rclone/rclone/backend/protondrive/protontest 31.375s
```
- `go test -race -count=3 ./backend/protondrive/ -run TestUpload`:
```
ok github.com/rclone/rclone/backend/protondrive 75.294s
```
@@ -0,0 +1,20 @@
### Task 12: Differential interop of whole files
**Files:**
- Modify: `backend/protondrive/crypto_diff_internal_test.go`
**Interfaces:** Consumes Tasks 10 and 11, and the old library.
- [ ] **Step 1: Write failing tests**:
- `TestUploadReadableByOldLibraryCrypto`: upload a 5 MiB file with
Task 11 to the fake; take the stored link, revision, blocks and xattrs
from the fake; decrypt the node, content key, every block and the
xattrs using only old-library exported helpers and plain gopenpgp as in
Task 6; compare content and SHA1.
- `TestFrozenFileFixture`: add a frozen fixture `testdata/oldlib/file/`
(link JSON, revision JSON, block files) written by the old-library
path, and download it through Task 10 by loading it into the fake.
- [ ] **Step 2: Run** - expect FAIL, then implement helper code until PASS.
- [ ] **Step 3: Commit** `protondrive: add whole-file differential tests`.
@@ -0,0 +1,151 @@
# Task 12 report: whole-file differential interop
**Status:** DONE_WITH_CONCERNS. The work is complete and all checks pass. The concerns are one unexplained failure in a full race run, a missing RED run for two of the tests, and the scope of the v6 fixture. All three are listed at the end.
Commit: `e76b6783b protondrive: add whole-file differential tests`
## Findings
**No interop mismatch in either direction.**
- **New writer, old reader:** the old library reads what `client.upload` writes, in full. This covers the node key, name, content key, both blocks, the block signatures and the xattrs. The SHA1, size, block sizes and mod time all match, and the manifest verifies with plain gopenpgp. Nothing to record in `docs/TODO.md`.
- **Old writer, new reader:** the new download path reads files the old library wrote. It was tested at 0 B, 2500 B and 5 MiB + 3, in both formats. Every signature verifies (`c.reported` stays empty) and the content, SHA1, size and mod time match. No bug to fix.
**The one defect found was in the fake.** It always signed at `now - 24h` (`signTime`). A key generated just now, such as the old library's fresh test keys, then had "no valid signing keys", and `AddUserWithKeys` panicked. Fix: the new `signTimeOf(signer)` signs at `max(signTime(), the key's creation time)`. It is used in `encryptArmored`, `signArmored` and `signBinary`. Keys the fake generates itself are unaffected, because they are created at `signTime()`.
## Which formats the old library can write
- **Legacy (v3 PKESK, SEIPDv1):** this is every **new file** it writes. `SetContentKeyPacketAndSignature` always makes a non-v6 session key. The whole file is old-library output.
- **v6 (v6 PKESK, SEIPDv2 blocks):** the old library writes this format only as a **revision of an existing crypto-refresh file**. It reads the stored v6 content key with `link.GetSessionKey` (bridge `file_upload.go:229-252`), and `protonDrivePGP()` then emits SEIPDv2 blocks. It never creates the file's node key or v6 content key packet itself.
- So in the v6 fixture, `sdkFileKeys` (plain gopenpgp, SDK style) makes the node key (v4, advertising SEIPDv2) and the v6 content key packet. They stand in for the client that created the file.
- The old library writes everything else: the node passphrase (`oldEncryptWithSignature`), name, hash, session key read-back, blocks, block signatures, manifest and xattrs.
- `TestFrozenFileFixture` asserts each fixture's packet versions: legacy is PKESK v3 / SEIPD v1, v6 is PKESK v6 / SEIPD v2.
## What I implemented
### `protontest` (new `stored.go`; no import from `backend/protondrive` except `api`)
- **Stored-file types:**
- `type StoredFile struct { Link StoredLink; Revision StoredRevision }`
- `StoredLink`: `Name, NameHash, NodeKey, NodePassphrase, NodePassphraseSignature, SignatureEmail, NameSignatureEmail, MediaType, ContentKeyPacket, ContentKeyPacketSignature`
- `StoredRevision`: `ManifestSignature, XAttr, SignatureEmail, Blocks []StoredBlock`
- `StoredBlock`: `Hash []byte, EncSignature, SignatureEmail, Data []byte` (`json:"-"`)
- `func (u *User) StoredFile(id string) StoredFile` is the read-only accessor. It returns copies of the file and its active revision.
- `func (u *User) SeedStoredFile(parentID string, f StoredFile) string` installs the material exactly as given, with no checks, through `insertLink`.
- `type FixedKeys struct { AddressKey, AddressKeyPassphrase, RootKey, RootKeyPassphrase, RootHashKey string }` and `func (s *Server) AddUserWithKeys(username, password string, keys FixedKeys) *User`:
- This is `AddUser` with the primary address key and the root folder's node key and hash key supplied.
- The user key and share key are still generated. The fake seals the address key passphrase to the new user key, and the root passphrase to the share key.
- This is the narrowest set that lets a stored file verify: its name and passphrase are encrypted to the parent, and it is signed by the address.
- `newDrive` is split into `newDrive` plus `newDriveWithRoot(addr, root, hashKey)`.
- New test `TestStoredFileRoundTrip`: two users built from the same `FixedKeys`. A file stored by one, seeded into the other, dumps cleanly.
- All of this is in the plan's Task 12 Interfaces block, along with the extra files.
### `download_internal_test.go` (no old-library import; Task 19 keeps it as is)
- `frozenFile`: the email, `FixedKeys`, name, mod time and plaintext, plus `Stored`.
- `loadFrozenFile`, `frozenFileParts`, `blockFileName`.
- `checkFrozenFile`: builds a user from the fixture's keys, seeds the stored file and reads it with `getNode` and `open`. It checks the name, mod time, size, SHA1, content, and that no problems were reported.
- `TestFrozenFileFixture`: runs over `testdata/oldlib/file/{legacy,v6}`, asserts each fixture's format, then calls `checkFrozenFile`.
- With `crypto_diff_internal_test.go` moved aside, this test still compiles and passes. I checked this, then moved the file back.
### `crypto_diff_internal_test.go`
- `oldEncryptWithSignature` is extracted from `oldGenerateNodeKeys` (bridge `crypto.go:88-117`), because the v6 path reuses it.
- `writeOldLibStoredFile(name, content, modTime, v6)` follows the bridge's upload path. It:
- sets the parent hash key with `CreateFolderReq.SetNodeHashKey` and reads it back with `GetHashKey`;
- fills `CreateFileReq` with `SetName`, `SetHash` and `SetContentKeyPacketAndSignature` (or, for v6, `sdkFileKeys` plus `GetSessionKey`);
- encrypts 4 MiB blocks with `oldEncryptBlock`;
- signs the manifest with `oldSignManifest`;
- writes the xattrs with `CommitRevisionReq.SetEncXAttrString` and the bridge's `-0700` time layout.
- `saveFrozenFile` writes a fixture directory.
- `TestUploadReadableByOldLibraryCrypto` uploads 5 MiB + 3 through `client.upload`, then takes `u.StoredFile`. Using only the old library and plain gopenpgp, it:
- unlocks the address keys with `proton.Unlock` from the same `/core/v4/users` and `/core/v4/addresses` responses;
- reads the share key with `proton.Share.GetKeyRing` and the root key with `proton.Link.GetKeyRing`, from my-files;
- reads the file's `GetKeyRing`, `GetName` and `GetSessionKey`;
- decrypts both blocks with `oldDecryptBlock`, verifying against the address keys plus the node key, as bridge `file_download.go` does;
- verifies the manifest with gopenpgp;
- reads the xattrs with `GetDecXAttrString` and parses the time with `iso8601`.
- `TestOldLibraryFileReadable` covers both formats at 0, 2500 and 5 MiB + 3 bytes, and passes each through `checkFrozenFile`. With `-update` it also rewrites the 2500 B frozen fixtures. Regenerate them with `go test ./backend/protondrive/ -run 'TestFrozenFixtures|TestOldLibraryFileReadable' -update` (now in the file header).
### Fixtures
- `testdata/oldlib/file/legacy/` and `v6/` each hold `file.json`, `link.json`, `revision.json` and `block-1.bin`, about 12 KB per directory.
- They were generated once with `-update` and committed.
- They are a single block on purpose: a multi-block fixture would mean committing more than 4 MiB of incompressible ciphertext per format.
### Docs
- Plan: the Task 12 Files and Interfaces are filled in and the boxes ticked.
- `docs/TODO.md`: the cleanup entry now says `TestFrozenFileFixture` stays. It also notes that multi-block old-library files lose coverage when `TestOldLibraryFileReadable` is deleted.
## Tests
- **RED:** `RCLONE_CONFIG=/notfound go test -count=1 ./backend/protondrive/ -run TestFrozenFileFixture` gave:
```
--- FAIL: TestFrozenFileFixture/legacy ... open testdata/oldlib/file/legacy/file.json: no such file or directory
--- FAIL: TestFrozenFileFixture/v6 ... open testdata/oldlib/file/v6/file.json: no such file or directory
```
This was expected: no fixture existed yet.
- **First run of `TestOldLibraryFileReadable`:** it panicked with `protontest: sign: ... no valid signing keys`. That is the signing-time defect in the fake described above; I fixed it.
- **Mutation checks**, run against the live test and then reverted:
- manifest signed by the parent key instead of the address key gives `integrity check failed: verify manifest`;
- name signed by the wrong key gives `Should be empty, but was map[verify:<id>:true]`.
- **GREEN:**
```
go build ./backend/protondrive/... ok
go vet ./backend/protondrive/... ok
golangci-lint run ./backend/protondrive/... 0 issues.
go mod tidy -diff no diff
RCLONE_CONFIG=/notfound go test -count=1 -race ./backend/protondrive/... ok (x5, see concern 1)
RCLONE_CONFIG=/notfound go test -race -count=5 ./backend/protondrive/ -run 'TestFrozen|TestOldLibrary|TestUploadReadable' ok 60.3s
RCLONE_CONFIG=/notfound go test -race -count=3 ./backend/protondrive/protontest/ ok 92.8s
```
With `-v`, the new tests print only their PASS lines.
## Files changed
- `backend/protondrive/protontest/stored.go` (new)
- `backend/protondrive/protontest/drive.go`, `crypto.go`, `drive_test.go`
- `backend/protondrive/crypto_diff_internal_test.go`, `download_internal_test.go`
- `backend/protondrive/testdata/oldlib/file/{legacy,v6}/*` (new)
- `docs/plans/001-protondrive-tier1.md`, `docs/TODO.md`
## Concerns
1. **One unexplained failure.** The first full `go test -race ./backend/protondrive/...` run reported FAIL for the main package. I had only captured the tail of the output, so I don't know which test failed. Five more full race runs passed, and so did the new tests at `-race -count=5`. It may be an existing flaky test, but I could not confirm that.
2. **TDD order.** I wrote the `protontest` helpers before their test, and `TestUploadReadableByOldLibraryCrypto` and `TestOldLibraryFileReadable` passed the first time they compiled. The only RED runs were the missing-fixture failure and the signing-time panic. The mutation checks above show the tests can fail.
3. **v6 fixture scope.** Its node key and content key come from `sdkFileKeys`, not the old library, because the old library cannot create them (see "Which formats" above). If you want a v6 fixture made purely by the old library, that is impossible: it only writes v6 content as a revision of a file someone else created.
## Fix round 1
Commit: `protondrive: state the v6 file fixture's provenance in the fixture` (see `git log -1`).
### What changed
- **The fixture's `Comment` now depends on `v6`.** The change is in `writeOldLibStoredFile` (`crypto_diff_internal_test.go`). For v6 it reads:
> Revision written by go-proton-api v1.0.4 and Proton-API-Bridge v1.0.5 with test-only keys, as stored by the server, of a crypto-refresh file: the node key, content key packet and its signature are SDK-style stand-ins made with plain gopenpgp; the old library wrote the node passphrase, name, hash, blocks, block signatures, manifest and extended attributes.
The legacy `Comment` is unchanged.
- **The `frozenFileDir` comment** (`download_internal_test.go`) now notes the exception. The old library writes v6 content only as a revision of a file another client created, so in `v6` the node key, the content key packet and its signature are gopenpgp SDK-style stand-ins.
- **One shared name and size.** The new constants `frozenFileName = "Résumé – final.txt"` and `frozenFileSize = 2500` are used both by the `TestOldLibraryFileReadable` loop and by its `-update` branch. The loop previously used "Fichiers d'été.txt". I picked the frozen name so that the legacy fixture still matches without being regenerated.
### Regeneration
- **Command:** `RCLONE_CONFIG=/notfound go test -count=1 ./backend/protondrive/ -run 'TestOldLibraryFileReadable/v6' -update` gave `ok`.
- **Only the v6 fixture changed.** `git status` showed changes to exactly these four files:
- `testdata/oldlib/file/v6/block-1.bin`
- `testdata/oldlib/file/v6/file.json`
- `testdata/oldlib/file/v6/link.json`
- `testdata/oldlib/file/v6/revision.json`
- **All four were rewritten.** Regenerating makes new random keys, so the whole v6 fixture is new.
- **Untouched:** `testdata/oldlib/file/legacy/*` and Task 6's `testdata/oldlib/{file,folder}.json`.
### Verification
- `RCLONE_CONFIG=/notfound go test -race -count=1 ./backend/protondrive/... -run 'Frozen|OldLibrary|OldWriter|NewWriter|StoredFile'`:
- `ok github.com/rclone/rclone/backend/protondrive 16.042s`
- `ok github.com/rclone/rclone/backend/protondrive/protontest 2.621s`
- That run covers `TestFrozenFixtures`, `TestFrozenFileFixture`, `TestOldLibraryFileReadable`, `TestOldWriterNewReader`, `TestNewWriterOldReader` and `TestStoredFileRoundTrip`. All pass.
- `go vet ./backend/protondrive/...`: ok.
- `golangci-lint run ./backend/protondrive/...`: 0 issues.
@@ -0,0 +1,32 @@
### Task 13: Hermetic contract-suite harness (old backend untouched)
**Files:**
- Create: `backend/protondrive/protontest/cmd/protonfake/main.go`
- Create: `backend/protondrive/fake_test.go`
**Interfaces:**
- Produces:
- `protonfake`: `go run ./backend/protondrive/protontest/cmd/protonfake -addr 127.0.0.1:0`
prints `endpoint=http://...`, `client_uid=...`,
`client_access_token=...`, `client_refresh_token=...`,
`client_salted_key_pass=...`, `type=protondrive` one per line, then
serves until killed. Needs a non-test constructor:
`func NewStandalone(addr string) (*Server, error)`.
- `fake_test.go` (package `protondrive_test`):
`func setupFakeRemote(t *testing.T) (remoteName string, srv *protontest.Server, user *protontest.User)`
- sets `RCLONE_CONFIG_TESTPROTONDRIVEFAKE_*` environment variables
(type, endpoint, the four `client_*` keys, `draft_client_uid`) with
`t.Setenv` and returns `"TestProtonDriveFake:"`.
- `TestIntegrationFake`: `fstests.Run` with
`RemoteName: remoteName, NilObject: (*protondrive.Object)(nil), QuickTestOK: true`.
Guarded by `t.Skip` until Task 14 (the old backend has no `endpoint`
option).
- [ ] **Step 1:** Write `fake_test.go` and `protonfake`; run
`go run ./backend/protondrive/protontest/cmd/protonfake -addr 127.0.0.1:0`
and confirm the six lines print; `go test ./backend/protondrive/ -run TestIntegrationFake -v`
shows SKIP.
- [ ] **Step 2: Commit** `protondrive: add hermetic contract test harness`.
@@ -0,0 +1,114 @@
# Task 13 report: hermetic contract-suite harness
**Status:** DONE
**Commit:** 414de88c9 protondrive: add hermetic contract test harness
## What I implemented
- `backend/protondrive/protontest/server.go`: the setup in `New` moved into an
unexported `newServer()` (routes registered, not listening), which both
constructors use, so there is no duplication.
- `New(t)` is unchanged in behaviour: `httptest.NewServer` plus `t.Cleanup(s.Close)`.
- New `NewStandalone(addr string) (*Server, error)`: it runs `net.Listen("tcp", addr)`,
then `httptest.NewUnstartedServer`, swaps in that listener and calls `Start`.
`URL()` therefore reports the real bound port when the address is `:0`.
- New `(*Server).Close()`, which wraps `httptest.Server.Close`.
- `backend/protondrive/protontest/cmd/protonfake/main.go`: `main` parses
`-addr` (default `127.0.0.1:0`) and closes a stop channel on SIGINT or SIGTERM.
- `run(addr, out, stop)` starts `NewStandalone` and adds a user
`protonfake`/`protonfake` with `AddUser`, which seeds a Drive volume.
- It mints a fresh session with the existing `Server.NewSession(u)`, writes the
six `key=value` lines in the brief's order, and blocks until stop is closed.
- Errors go to stderr with exit code 1. stdout carries only the six lines.
- The program imports only `protontest`, not `backend/protondrive`.
- `backend/protondrive/fake_test.go` (package `protondrive_test`):
- `setupFakeRemote(t)` starts `protontest.New(t)` with `AddUser("user","pass")`
and a fresh `NewSession`. Using `t.Setenv`, it sets `RCLONE_CONFIG_TESTPROTONDRIVEFAKE_`
`TYPE`, `ENDPOINT`, `CLIENT_UID`, `CLIENT_ACCESS_TOKEN`, `CLIENT_REFRESH_TOKEN`,
`CLIENT_SALTED_KEY_PASS` and `DRAFT_CLIENT_UID` (`random.String(32)`).
It returns `"TestProtonDriveFake:"`.
- `TestSetupFakeRemote` checks the env values (the key pass decodes to
`user.KeyPassword()`). It also makes an authenticated `GET /core/v4/users`
through `srv.Client()` with the UID and access token, and expects 200.
- `TestIntegrationFake` skips when `-remote` is set, following the pattern in
`backend/sftp/sftp_test.go`. It then calls `t.Skip` (marked TODO) before
`setupFakeRemote` or `fstests.Run`. The `fstests.Run` call with
`RemoteName`, `NilObject: (*protondrive.Object)(nil)`, `QuickTestOK: true` is
already written below the skip.
- The Task 13 checkboxes in `docs/plans/001-protondrive-tier1.md` are ticked.
No helper to mint a session was needed. `Server.NewSession(u)` already
existed and returns a fresh, unconsumed session: its refresh token is new and
nobody has used it. No new identifiers collide with `protondrive.go`.
## client_salted_key_pass encoding
The value is **base64 (StdEncoding) of the raw key password.**
- Confirmed in the old library, which the existing config keys belong to:
`~/go/pkg/mod/github.com/rclone/!proton-!a!p!i-!bridge@v1.0.5/common/config.go:86`
(`SaltedKeyPass string // []byte <-> base64`) and `common/user.go:68`
(`base64.StdEncoding.DecodeString(config.ReusableCredential.SaltedKeyPass)`).
- The old `protondrive.go` stores that string unchanged (`setConfigMap(..., auth.SaltedKeyPass)`).
- The new session layer holds the **raw** value: `credentials.KeyPassword` in
`session.go:96`, set from `computeKeyPassword` output at `session.go:487`, and
used as `[]byte(KeyPassword)` at `nodes.go:159`.
- So the Task 14 config-backed `credentialStore` must base64-decode on Load and
encode on Save. The spec's "saved sessions keep working ... with their current
meaning" requires this.
## Tests (all with RCLONE_CONFIG=/notfound)
TDD RED:
- `go test ./backend/protondrive/protontest/ -run TestNewStandalone` gave
`undefined: NewStandalone`.
- `go test ./backend/protondrive/protontest/cmd/protonfake/` gave `undefined: run`.
- Both failures were expected before the implementation existed.
- `TestSetupFakeRemote` passed on its first run, because it uses only existing
protontest APIs.
GREEN and final run:
- `go build ./backend/protondrive/...`: ok.
- `go vet ./backend/protondrive/...`: ok.
- `RCLONE_CONFIG=/notfound go test -race ./backend/protondrive/...`:
- `backend/protondrive`: ok, 96.6s
- `protontest`: ok, 32.6s
- `protontest/cmd/protonfake`: ok, 1.4s
- `golangci-lint run ./backend/protondrive/...`: 0 issues. An earlier gocritic
hint on `log.Fatal` was fixed by writing to stderr and calling `os.Exit(1)`.
- `TestRun`:
- It reads exactly six lines in order through a pipe: each is `key=value` with a
non-empty value, `type=protondrive`, and the endpoint is not `:0`.
- The key pass decodes to 31 bytes.
- An authenticated `GET /core/v4/users` against the printed endpoint returns 200.
- After stop closes, no further output appears, `run` returns nil and the server
no longer answers.
- `TestRunBadAddress` returns an error and writes no output.
Manual checks from Step 1:
- `go run ./backend/protondrive/protontest/cmd/protonfake -addr 127.0.0.1:0` printed
the six lines (`endpoint=http://127.0.0.1:38487`, ...) and stderr was empty.
The built binary exits 0 on SIGTERM. `-addr bad` exits 1 with a message on stderr.
- `go test ./backend/protondrive/ -run TestIntegrationFake -v` shows
`--- SKIP: TestIntegrationFake`.
## Files changed
- backend/protondrive/protontest/server.go
- backend/protondrive/protontest/server_test.go
- backend/protondrive/protontest/cmd/protonfake/main.go (new)
- backend/protondrive/protontest/cmd/protonfake/main_test.go (new)
- backend/protondrive/fake_test.go (new)
- docs/plans/001-protondrive-tier1.md (checkboxes)
## Decisions and concerns
- I added a `-remote` skip guard to `TestIntegrationFake`, which the brief did not
ask for.
- Without it, `go test -remote TestProtonDrive:` (and test_all) would also run
`TestIntegrationFake`, because `fstests.Run` prefers `-remote` over
`Opt.RemoteName`. That would hit the live account.
- Task 14 should keep this guard when it removes the second skip.
- `-run TestIntegration` matches both `TestIntegration` and `TestIntegrationFake`.
That is harmless today and worth knowing for Task 18's test_all config.
- I did not update `docs/ARCHITECTURE.md`. Tasks 1–12 did not record protontest
there either. It may be worth one pass when the swap lands.
@@ -0,0 +1,94 @@
### Task 14: Swap - rewrite `protondrive.go` on the new client
**Files:**
- Rewrite: `backend/protondrive/protondrive.go`
- Rewrite: `backend/protondrive/protondrive_internal_test.go`
- Keep: `backend/protondrive/protondrive_test.go` (live `TestProtonDrive:`)
- Modify: `backend/protondrive/fake_test.go` (remove the skip)
**Interfaces:**
- Consumes: everything above.
- Produces: `Fs`, `Object`, `Options`, `NewFs`, as other backends; template
for structure and method order is `backend/box/box.go`, as the current
file already follows it.
Decisions:
- **Options.** Existing ones unchanged (names, defaults, help text may be
improved; `app_version` help warns that a wrong value can get the client
blocked). New, all `Advanced: true`:
`upload_concurrency` (int, default 4, values above 5 are clamped with a
log line), `ignore_signature_errors` (bool, default false),
`draft_client_uid` (`Hide: fs.OptionHideBoth`, `Sensitive: true`;
generated with `random.String(32)` and saved on first use),
`endpoint` (`Hide: fs.OptionHideBoth`, default
`https://drive-api.proton.me`). `username` and `password` become
`Required: false` (browser sign-in needs neither).
- **`Config` state machine** (model: `backend/internxt/internxt.go:139-209`):
state `""` asks "Sign in with a web browser?" (default true) with the
text `rclone is a third-party application not officially supported by
Proton.`; `"browser"` calls `forkStart`, shows the URL, then
`forkPoll`; `"password"` runs `loginSRP`, asking for a 2FA code through
`fs.ConfigInput` only when needed. Both end by saving the four
`client_*` keys.
- **`NewFs`.** `credentialStore` is a per-`Fs` type over the
`configmap.Mapper` (no package globals). If the saved session is missing
or refresh fails with `errSessionExpired` and `username`+`password` are
configured, sign in with `loginSRP` (using `2fa`, else `otp_secret_key`
via `totp.GenerateCode`); otherwise return the reconnect error. Then
`newClient`, dircache rooted at `client.rootID()`, the box-style
"root is a file" fallback.
- **Features:** `ReadMimeType: true`, `CanHaveEmptyDirectories: true`; no
`NoMultiThreading`. Implement and assert: `fs.Purger`, `fs.Copier`,
`fs.Mover`, `fs.DirMover`, `fs.PutStreamer`, `fs.ListPer`,
`fs.CleanUpper`, `fs.Abouter`, `fs.Disconnecter`, `fs.Shutdowner`,
`fs.DirCacheFlusher`, `fs.MimeTyper`, `fs.IDer`.
- **Error mapping (one function, `translateError`):** `errNotFound` ->
`fs.ErrorObjectNotFound` or `fs.ErrorDirNotFound` by call site;
`errExists` on Mkdir -> nil; on Move/DirMove -> `fs.ErrorCantMove` /
`fs.ErrorDirExists`; API codes 200001-200004, 200300, 200301 ->
`fserrors.NoRetryError` keeping Proton's message; 2000 and 2028 ->
`fserrors.FatalError("... upgrade rclone")`; `errIntegrity` and
`errUploadMismatch` -> `fserrors.NoRetryError`.
- `Object.Open` handles `fs.RangeOption` and `fs.SeekOption` by computing
`offset, limit` and calling `client.open`; no `io.NopCloser`.
- `Object.Update` passes the source's SHA1 when
`src.Fs().Hashes().Contains(hash.SHA1)`, and the MIME type from
`fs.MimeType(ctx, src)`.
- `Object.SetModTime` returns `fs.ErrorCantSetModTime`. `Precision` is
`time.Second`. `Hashes` is `hash.Set(hash.SHA1)`.
- `Rmdir` lists the directory first and returns `fs.ErrorDirectoryNotEmpty`
if it has children; `Purge` of the root returns the existing
`errCanNotPurgeRootDirectory`.
- `DirMove` to itself or an existing destination returns
`fs.ErrorDirExists`.
- `Shutdown` calls `client.close()` once (guard with `sync.Once`).
- `go.mod`: `go get github.com/ProtonMail/gopenpgp/v3 github.com/ProtonMail/go-srp`
as direct requirements, remove the bridge imports, `go mod tidy`.
**The differential tests still import the old libraries**, so they remain
in `go.mod` after this task; their removal is Task 19.
- [ ] **Step 1: Write failing tests** in `protondrive_internal_test.go`:
`TestTranslateError` table for every mapping above;
`TestOptionsBackwardsCompatible`: a `configmap.Simple` holding exactly
the keys of a v1.76 config (list in Global Constraints) builds an `Fs`
against the fake **without any auth request**
(`Requests()` has no `/core/v4/auth` key);
`TestNewFsPasswordFallback`: revoked session + username/password signs
in again and saves new `client_*` values;
`TestNewFsNoCredentials`: error mentions `rclone config reconnect`;
`TestTwoRemotesIsolated`: two `Fs` for two users in one process never see
each other's tokens in their mappers;
`TestConfigBrowser` and `TestConfigPassword`: drive `Config` through its
states with `fs.ConfigIn` results; the browser state's text contains the
third-party notice.
- [ ] **Step 2: Run** - expect FAIL.
- [ ] **Step 3: Rewrite `protondrive.go`.**
- [ ] **Step 4: Run** `go test -race ./backend/protondrive/ -run TestIntegrationFake -v 2>&1 | tail -40`.
Fix failures in the new code (never by weakening the fake or skipping a
test) until the suite passes. Then the four verification commands, and
`go build ./...`.
- [ ] **Step 5: Commit** `protondrive: rewrite the backend on a native client following Proton's SDK`.
@@ -0,0 +1,163 @@
# Task 14 report: swap protondrive.go onto the native client
## What was implemented
- `backend/protondrive/protondrive.go` is rewritten on the native client. It follows box.go's structure. It contains no crypto and no routes, and it has no package globals.
- **Options:** every v1.76 option keeps its name, default and meaning. `username` and `password` are no longer required. Four new advanced options: `upload_concurrency` (default 4; values above 5 are lowered to 5 with a log line), `ignore_signature_errors`, `draft_client_uid` (hidden, sensitive) and `endpoint` (hidden, default `https://drive-api.proton.me`). The `app_version` help text now warns that a wrong value can get the client blocked.
- **`configStore`** is a per-Fs `credentialStore` over the `configmap.Mapper`:
- On every Load it re-reads the mapper.
- It base64-decodes `client_salted_key_pass`. A value that fails to decode loads as empty, which NewFs treats as "no session" and turns into the reconnect error.
- Save writes all four keys.
- **`Config` state machine.** States run `""` → `choose` → `browser` | `password` → `password_2fa`:
- `browser` calls forkStart, shows the URL, then forkPoll.
- `password` runs loginSRP. When the account needs a code and none is configured, the callback returns `errNeedTwoFA`. Config then asks with `fs.ConfigInput`, and `password_2fa` signs in again with that code. The code is never saved.
- The URL-showing function and the poll timings are passed in through `signInOptions`, not globals, so tests can approve the fork.
- **NewFs:**
- Generates and saves `draft_client_uid` before anything else.
- Signs in with the password (`2fa`, else a TOTP from `otp_secret_key` using server time) when the saved session is missing or refresh fails with `errSessionExpired`. Otherwise it returns a fatal error saying `run "rclone config reconnect <name>:"`.
- Then calls `newClient`, roots the dircache at `rootID()`, and uses box's "root is a file" fallback.
- **Features and interfaces:** ReadMimeType and CanHaveEmptyDirectories, with no NoMultiThreading. Every interface the brief lists is implemented and asserted.
- **`translateError`** is the single place errors are mapped (see decisions below).
- `fake_test.go`: the "until Task 14" skip is removed. The `-remote` guard stays.
- `protondrive_internal_test.go` is rewritten. The old tests of the old code's app version and shouldRetry are gone; their new counterparts live in session/nodes tests.
- Docs:
- `docs/ARCHITECTURE.md` has a protondrive section covering the file layout, the seam and `protontest`.
- `docs/DECISIONS.md` has an entry for the swap.
- `docs/TODO.md` has four new entries.
- The plan's checkboxes are ticked.
## Decisions where the brief or spec left a choice
1. **Browser sign-in default.** The default is "yes" unless `username` and `password` are already set; then it is "no". This keeps scripted `rclone config create … username= password=` setups (`--auto` or `--non-interactive`) from sitting in a browser poll for 10 minutes. The brief said "default true".
2. **`errUploadOutcomeUnknown` maps to `fserrors.NoLowLevelRetryError`.**
- rclone's operations-level retry won't immediately re-upload.
- A high-level `--retries` pass re-reads the destination first: if the commit landed, the file matches and is skipped. Otherwise the leftover own draft is replaced through the `draft_client_uid` rule.
- So no duplicate file can appear, and at worst there is one extra revision.
3. **`errUploadSuperseded` maps to NoRetryError.** The upload landed, and retrying would only overwrite the other client's newer revision.
4. **Not-found during delete:**
- `Remove` treats `errNotFound` from trash as success (already gone).
- `Rmdir` and `Purge` map it to `fs.ErrorDirNotFound`, because their contract says to error when the directory doesn't exist.
5. **Mkdir when the name exists.** CreateDir handles `errExists` itself: it finds the existing folder's ID, because dircache needs an ID. If the name is taken by a file or an unlisted node, the error is returned as is. `translateError(errExists, opMkdir) == nil` is implemented and tested as the brief asks, but no production path uses it, since returning "" as an ID would break dircache.
6. **Copy onto an existing name** maps to `fs.ErrorCantCopy`, which is not in the brief. rclone then falls back to a streamed copy, i.e. an Update of the existing file. Move maps to `ErrorCantMove`, and operations deletes the destination first.
7. **Error priority in translateError.** The upload and integrity sentinels are checked before not-found and the API codes. Otherwise an outcome-unknown error wrapping a 2501 would become ObjectNotFound. Code 2501 is also mapped directly (spec 6.6), not only `errNotFound`.
8. **Rmdir/Purge of the drive root** returns `errCanNotRemoveRootDirectory` (new) or `errCanNotPurgeRootDirectory`.
9. **Paths are encoded at the API boundary, as box does.** The dircache holds standard names, and `Enc.From/ToStandardName` is applied in FindLeaf, CreateDir, ListP, NewObject and uploads. The on-server name of each rclone path is unchanged: `Enc.FromStandardName(leaf)`, the same as before. The old code encoded paths before the dircache and then `Put` standard paths into it, which was inconsistent.
10. **Browser poll** runs every 3 seconds for up to 10 minutes. The URL is printed to stdout and never logged.
11. **Move and DirMove across two Fs of one account.** After a move done through `f`, the source Fs's client cache entries are forgotten (`Fs.forget`). FsDirMove needed this: two Fs have two caches. Spec 6.2 events are the proper fix.
## Fixes below the seam
None. No client file or fake file was changed.
The one fix outside the plan is in my own file. `configStore` remembers the values it last saved and what the mapper held before saving. If a value from the environment or the command line still shadows a saved one, Load returns the saved value. Without this, a refresh on an env-configured remote (including `TestIntegrationFake`'s) would adopt the spent original tokens on the next refresh and fail. `TestConfigStore/overridden` failed before the fix and passes after it.
## fstests.Opt opt-outs
None. The Opt is unchanged: `RemoteName`, `NilObject`, `QuickTestOK`.
## Contract subtests (TestIntegrationFake, -v run)
- **Run:** 126 subtests. **Passed:** 97. **Failed:** 0. **Skipped:** 29.
- Every skip is the suite's own feature check, not an opt-out:
- ListR (non-goal): FsListR* 6.
- ChangeNotify and Metadata (later milestones): Metadata, ObjectMetadata, ObjectSetMetadata, FsMkdirMetadata, FsDirSetModTime, FsCopy/Metadata, FsMove/Metadata.
- OpenWriterAt and OpenChunkWriter (non-goals), plus the Chunked variants.
- PublicLink, SetTier, FsCommand, the CheckWraps, Bucket, Internal, FsNewObjectCaseInsensitive and FsDirectory.
## TDD evidence
- **RED:** `RCLONE_CONFIG=/notfound go test ./backend/protondrive/ -run 'TestTranslateError|TestOptions|TestNewFs|TestTwoRemotes|TestConfig'` failed to build with `undefined: errorOp`, `undefined: opObject`, `undefined: signInOptions`, … This was expected: none of it existed yet.
- **RED (configStore):** `TestConfigStore/overridden` failed: it got `"old uid"/"old access"/"old refresh"` where it wanted the saved credentials.
- **GREEN:** the same command printed `ok github.com/rclone/rclone/backend/protondrive 8.178s`.
- **First contract run:** FsDirMove failed because the source Fs's cache still held the moved folder. FsIsFile and FromRoot then failed as a knock-on. After `Fs.forget` the whole suite passes.
## Verification (run in this session)
- `go build ./...`: printed `BUILD-OK` with no output.
- `RCLONE_CONFIG=/notfound go test -race ./backend/protondrive/...`:
```
ok github.com/rclone/rclone/backend/protondrive 144.641s
? github.com/rclone/rclone/backend/protondrive/api [no test files]
ok github.com/rclone/rclone/backend/protondrive/protontest (cached)
ok github.com/rclone/rclone/backend/protondrive/protontest/cmd/protonfake (cached)
```
- `RCLONE_CONFIG=/notfound go test -race -count=3 -run TestIntegrationFake ./backend/protondrive/`:
`ok github.com/rclone/rclone/backend/protondrive 88.348s`
- `go vet ./backend/protondrive/...`: clean. `golangci-lint run ./backend/protondrive/...`: `0 issues.`
- `RCLONE_CONFIG=/notfound go test ./backend/all/ ./fs/config/...`: every package ok (backend/all has no test files and compiles).
## Concerns
1. **go.mod was left unchanged.**
- gopenpgp v3.4.1 and go-srp v0.0.7 were already in the direct block. `go get …@<current version>` was a no-op. I pinned the versions on purpose, because a bare `go get` would upgrade them.
- `go mod tidy` wanted to **delete `github.com/rclone/Proton-API-Bridge`**, a direct requirement. No Go file imports it any more: the differential tests import only `go-proton-api`.
- That goes beyond what I was allowed to accept, so I restored go.mod and go.sum. The module is still listed, unused. The build is unaffected, but go.mod is not tidy. Please decide whether to drop it now or in Task 19 (also logged in TODO.md).
2. **Rmdir can trash a folder holding only hidden nodes.** Rmdir's emptiness check uses `listChildren`, which leaves out Docs/Sheets, drafts and unreadable nodes, so such a folder is trashed. It can be recovered from the trash. Logged in TODO.
3. **The fake returns 422/2000 for a bad verifier or a stale revision.** translateError maps 2000 to a fatal "upgrade rclone" error, as the brief and spec say. If the real server uses 2000 for those cases, a stale revision would stop a sync. This is an existing TODO.
4. **Several Fs of one account in one process cache separately.** Only Move and DirMove sources are invalidated across them, until events land.
5. **Test timing.** `TestNewFsPasswordFallback` and `TestNewFsNoCredentials/revoked` each take about 3 seconds: session.go's adoptWait for a rejected refresh.
## Fix round 1
Each fix had a failing test before the change, except where noted.
1. **Rmdir never trashes a folder with any child on the server.**
- New below-seam method `client.hasChildren(ctx, id)` in nodes.go. It makes one uncached `GET …/folders/{id}/children` and does not decrypt anything. It reports true when the page holds any LinkID or `More` is set.
- Rmdir returns `fs.ErrorDirectoryNotEmpty` when it reports true.
- **What counts as a child:** every ID the server's children route returns, whatever its type or readability (Docs/Sheets, undecryptable nodes, and drafts too, if the server lists them). Nothing is filtered out.
- The fake lists active children only, so a folder holding only a draft or only trashed children reads as empty there.
- Trashed children are already in the trash, so trashing the parent loses nothing more.
- Whether the real server lists drafts is unknown. If it does, they count and Rmdir refuses.
- **Tests:**
- `TestHasChildren` (nodes_internal_test.go) covers: an empty folder, a Docs-only folder, a folder holding only an undecryptable file, a child added after an empty listing was cached, and a missing folder (code 2501).
- `TestRmdirRefusesHiddenChildren` (protondrive_internal_test.go) covers a Docs-only folder and a folder filled by another client after an empty listing was cached with caching on. It failed before the fix (`expected "directory not empty", actual <nil>`).
- The TODO.md entry is removed.
2. **Config keeps an existing session.**
- When all four `client_*` values are present, the first state asks `ConfigConfirm("reauth", false, "config_refresh_token", "Already signed in - sign in again?")`. Answering no finishes without touching the credentials or making any request.
- Answering yes leads to the browser/password choice (state `method`).
- After a new session is saved, `signOut` ends the old session best-effort, through a throwaway `memoryStore` session, so the config is never touched. A failure is logged at debug level with the API error only, never tokens.
- **Tests:**
- `TestConfigKeepsSession/default` and `/config update`. The latter goes through `fs.BackendConfig` with `config_refresh_token=false`, as `rclone config update` does. Both assert `srv.Requests()` is empty and the mapper is unchanged.
- `TestConfigSignInAgain` asserts a new UID, one `DELETE /core/v4/auth`, and that the old session no longer works.
- Both failed before the fix (an unexpected "choose" question; 0 logouts).
3. **Shutdown keeps the Fs usable.**
- Shutdown now only clears the node cache (the client has no background work yet). It is idempotent and has no `sync.Once`.
- `client.close` is now called only on NewFs's own error paths.
- Disconnect logs out and clears the cache. Keys are not cleared: every later operation needs an API call anyway.
- Below the seam, `session.call` now returns `fserrors.FatalError(errNotSignedIn)` (`not signed in: run "rclone config reconnect remote:"`) without sending a request when the session has no credentials. `shouldRetry` treats it as final. `TestLogout` was updated to assert this and that no request is sent.
- **Tests:**
- `TestShutdownKeepsFsUsable` calls Shutdown twice, then Open, Put, List, NewObject and Open again. It failed before the fix.
- `TestDisconnect` checks that List, Open and Put return the reconnect error. It passed before the fix too, through the errSessionExpired path; it now exercises errNotSignedIn.
- This ruling supersedes the brief's "Shutdown calls client.close() once".
4. **Update, Move, Copy and DirMove refuse a node that is not active.**
- New below-seam method `client.activeNode(ctx, id)`, built on the new `fetchNode` (the body of getNode minus the cache lookup). It reads the node from the server. If it is not active, it forgets the node and its parent's listing and returns errNotFound. That becomes ObjectNotFound, or DirNotFound for DirMove.
- Costs one metadata request per Update/Move/Copy/DirMove.
- **Tests:**
- `TestActiveNode` (nodes_internal_test.go) uses a node trashed by another client while cached, and checks the stale listing is dropped.
- `TestTrashedBehindTheFsBack` already passed before the fix: the fake refuses those operations on a trashed node. It now pins the behaviour at the seam whatever the real server does.
5. **Open range maths uses the plaintext size.**
- New `node.plaintextSize()` in nodes.go; `size()` uses it, with no behaviour change.
- Open uses it for FixRangeOption and Decode in both `original_file_size` modes.
- When the extended attributes don't record the size and a suffix range is asked for, Open reads the whole file once to count its bytes (`countContent`, which logs at debug level), then opens the range.
- Other ranges don't need the size: the reader stops at EOF.
- **Test:** `TestOpenRange` covers an uploaded file and a file stored with no extended attributes, in both modes, with suffix ranges, ranges past the end, and seek offsets. It failed before the fix (`expected "vwxyz", actual ""`).
6. The `XXX:` comment is now a plain comment.
7. `TestTwoRemotesIsolated` now resets the request counts and asserts exactly 2 `POST /auth/v4/refresh`, and that each mapper's access token changed.
### Files changed
- `backend/protondrive/protondrive.go`
- `nodes.go` (hasChildren, activeNode, fetchNode, plaintextSize, shouldRetry case)
- `session.go` (errNotSignedIn)
- the three matching test files
- `docs/TODO.md`
### Verification (fix round 1)
- `go build ./...`: ok.
- `RCLONE_CONFIG=/notfound go test -race ./backend/protondrive/...`: `ok github.com/rclone/rclone/backend/protondrive 148.205s`; protontest and protonfake ok.
- `RCLONE_CONFIG=/notfound go test -race -count=3 -run TestIntegrationFake ./backend/protondrive/`: `ok … 86.174s`.
- `go vet`: clean. `golangci-lint run ./backend/protondrive/...`: `0 issues.`
### Concerns (fix round 1)
- A suffix range on a file without recorded size reads the file twice. This is rare: it only affects files from old clients without extended attributes.
- Update, Move, Copy and DirMove each cost one extra metadata request for the active-state check.
- Whether the real server's children route lists drafts is unverified; Rmdir counts whatever it lists.
@@ -0,0 +1,109 @@
# Task 14 review: protondrive.go on the native client (414de88c9..0ec7dce0e)
Reviewed in passes: the diff stat, then `backend/protondrive/protondrive.go` and `protondrive_internal_test.go` read whole from the repo (the diff hunks of a full rewrite are unreadable), then one focused check below the seam for each named risk.
### Spec Compliance
- ❌ Issues found:
- Rmdir (protondrive.go:834-839) trashes folders that are not empty. Spec 5 says "Trash an empty folder", but the emptiness check sees only readable, non-Docs, possibly cached children. Details under Important 1.
- Everything else in the brief is present and matches it:
- Options: names and defaults kept, four new advanced options, `app_version` warning, `username`/`password` not required.
- The `Config` states, with the notice text verbatim (protondrive.go:49, 559-565). The browser default follows ruling (b) (protondrive.go:556-559).
- NewFs fallback order (protondrive.go:491-524). `draft_client_uid` is generated with `random.String(32)` and `m.Set` before `connect` (protondrive.go:613-618).
- Features, with no NoMultiThreading. All 13 interfaces are asserted (protondrive.go:1188-1204).
- `translateError` (protondrive.go:355-400) covers every mapping in the brief.
- Open has no NopCloser. Update passes SHA1 and MIME type. SetModTime, Precision and Hashes are as specified. Shutdown runs under `sync.Once`.
- The fake_test skip is removed and the `-remote` guard is kept (fake_test.go:66-68).
- The fstests Opt has no opt-outs.
- ⚠️ Cannot verify from the diff alone:
- Whether the real server accepts a new revision on a trashed file (relevant to Minor 1).
- Whether Proton detects refresh-token reuse (Minor 4).
- Whether go-crypto panics or returns an error when a cleared key is used (Important 3).
- go.mod is left untidy under ruling (a); not a finding.
Checks run:
- `RCLONE_CONFIG=/notfound go test -race ./backend/protondrive/ -run 'TestTranslateError|TestOptions|TestNewFs|TestTwoRemotes|TestConfig' -v` printed `ok ... 21.396s`. Output was pristine (no log noise).
Seam checks:
- protondrive.go imports `api` only for error codes.
- It has no routes. `defaultEndpoint` is a host, not a path.
- Its only package-level vars are sentinel errors.
- The only crypto-adjacent code is `totp.GenerateCode`, which the brief mandates.
Secret checks:
- The sign-in URL goes to stdout via `fmt.Printf` and is never logged (protondrive.go:545-547).
- No token or password reaches an error or log line in protondrive.go.
### Strengths
- `configStore`'s shadow logic (protondrive.go:278-339) is correct. I traced the cases:
- **Env-configured remote:**
- Refresh 1: Load returns the env R0, and refresh saves R1 with shadowed = R0. The next Load reads R0, which equals shadowed, so it returns R1. `session.refresh` (session.go:287) therefore sees stored == cur and does not adopt the spent R0.
- Refresh 2 behaves the same.
- Without the fix, Load returns R0, the session adopts R0, gets a second 401, waits for adoption and dies. The implementer's RED evidence matches this.
- **Two processes, no env:** B writes R2 to the file. A reads R2 ≠ shadowed R0 and adopts it. Single-use tokens never return to a pre-save value, so the "read == shadowed" branch only fires for a shadowing source or a failed persist. Both are cases where the saved value is the right one.
- **Isolation:** there is one store per mapper and no globals, so one remote's tokens cannot leak into another's mapper.
- **Key password:** base64 Std is decoded on read and encoded on save. A bad value loads empty, which leads to password sign-in or the reconnect error (tested at protondrive_internal_test.go:212-218).
- NewFs auth (protondrive.go:491-524) makes at most one SRP per NewFs, and only when refresh is rejected:
- `session.refresh` returns `errSessionExpired` only for a 4xx other than 429. Network or 5xx errors return a plain error and do not trigger a re-login.
- `loginSRP` saves only on success and deletes the half session otherwise (session.go:399+).
- `TestNewFsPasswordFallback` asserts exactly one `POST /core/v4/auth`.
- `translateError` checks the upload sentinels first, so an outcome-unknown error wrapping 2501 is not mistaken for not-found. The NoLowLevelRetry and NoRetry choices are sound:
- `fserrors.ShouldRetry` is false for NoLowLevelRetry, so `operations.Copy` does not loop.
- The next `--retries` pass re-lists the destination. Drafts are filtered at nodes.go:757 and own drafts are replaced by `draft_client_uid`, so there is no duplicate.
- Superseded is a landed upload, so NoRetry loses nothing.
- Move invalidation is correct:
- `c.move` does not mutate `n`, so `n.ParentID` passed to `forget` (protondrive.go:941) is the old parent.
- `c.move` already forgets in the destination client.
- DirMove flushes the source dircache.
- Encoding at the API boundary keeps on-server names identical to v1.76. The old `FromStandardPath` on whole paths encodes each segment the way `FromStandardName` does here.
- `TestOptionsBackwardsCompatible` really proves "no auth request": `authRequests` matches every `/auth` route, including refresh. It also checks that the access token is unchanged and that `draft_client_uid` is generated.
### Issues
#### Critical (Must Fix)
None.
#### Important (Should Fix)
1. **Rmdir trashes non-empty folders** (protondrive.go:834-839).
- **What's wrong:** emptiness comes from `c.listChildren`, which:
- leaves out Proton Docs/Sheets and unreadable nodes (nodes.go:636-640);
- is served from the node cache when `enable_caching` (default true) holds the listing (nodes.go:642-647).
- **Failure scenario A:** `rclone sync /local remote:`. The remote has `Notes/` holding only a Proton Doc, and the source lacks `Notes`. rclone sees an empty extraneous directory and calls Rmdir, and the Doc goes to the trash. A later `rclone cleanup`, or Proton's trash expiry, makes the loss permanent.
- **Failure scenario B:** the web client adds a file to a folder whose empty listing this process cached earlier. Then `rclone rmdirs` or `--delete-empty-src-dirs` trashes it.
- **Why it matters:** rclone must never delete what it cannot see. Logging this in TODO.md does not lower the severity.
- **Fix:** add a below-seam `hasChildren(ctx, folderID)` that calls the children route uncached, without decrypting. It should count every link the server returns, optionally excluding only this client's own drafts. Rmdir should return `fs.ErrorDirectoryNotEmpty` on any child. Add a fake test with a Docs node in a folder.
2. **Every run of `Config` forces a new sign-in** (protondrive.go:557-565).
- **What's wrong:** state `""` always asks browser-or-password. Unlike oauthutil (lib/oauthutil/oauthutil.go:720, "Token already configured - replace it?" with `config_refresh_token`), it offers no way to keep the saved session.
- **Why it matters:** v1.76 had no `Config`, so editing a remote never touched auth. The same flow runs for `rclone config update` and for editing a remote in `rclone config`.
- **Failure scenario A:** a browser-signed-in user with no password runs `rclone config update proton enable_caching=false`. The default answer is browser sign-in, so rclone prints a URL and blocks for up to 10 minutes, then errors.
- **Failure scenario B:** a password user gets a fresh SRP login on every edit. The old server session is never logged out, sessions pile up, and repeated logins risk CAPTCHA blocks.
- **Fix:** in state `""`, when all four `client_*` values are present, first ask `ConfigConfirm(..., false, "config_refresh_token", "Already signed in - sign in again?")`. Finish with `nil` on "false" and honour `config_refresh_token=false` from the command line. When re-signing in, log the old session out.
3. **Shutdown leaves a client that fails obscurely if used again** (protondrive.go:1028-1031, nodes.go:1167-1180, account.go:331-338).
- **What's wrong:** Shutdown clears all private key material, and nothing marks the client closed.
- **How it gets hit:** fs/cache's finalizer calls `Shutdown` on any unpinned entry after `--fs-cache-expire-duration`, 5 minutes by default (fs/cache/cache.go:31-35). The rc layer does not pin (no `Pin` in fs/rc), so a `sync/copy` rc job running for more than 5 minutes keeps using an Fs whose keys were zeroed.
- **Result:** the next folder-key derivation or decryption runs on cleared keys. That gives obscure crypto errors mid-job, or a panic if go-crypto dereferences the cleared params.
- **Plan context:** the brief mandates "Shutdown calls client.close() once", so this is partly plan-mandated. The missing guard is the defect.
- **Fix:** either
- add a `closed` flag to the client so later calls return a clear `errClosed` (and a test that uses an Fs after Shutdown); or
- have Shutdown drop caches only and leave zeroing to process exit.
#### Minor (Nice to Have)
1. Update, Move and Copy act on a node of any state. `getNode` is documented "of any state" (nodes.go:598-600), so Update (protondrive.go:1138-1142) can create a revision of a file another client just trashed, if the server allows it. Check `n.State == api.LinkStateActive`, else return `fs.ErrorObjectNotFound`.
2. Open's range maths uses `o.Size()` (protondrive.go:1102-1110). That is the encrypted size when `original_file_size=false` or when the xattrs lack a size. `FixRangeOption` then turns a `bytes=-N` suffix range into the wrong offset and returns wrong bytes. Use `o.n.size(true)`.
3. Move, Copy and DirMove do not refuse a different account explicitly (protondrive.go:868-873, 925-930, 993-1002). A foreign ID gives ObjectNotFound or DirNotFound instead of `ErrorCant*`, so there is no fallback. DirMove also creates the destination parents before failing. rclone's same-name gate makes this unlikely. Compare account or volume IDs first and return `fs.ErrorCantMove`, `fs.ErrorCantCopy` or `fs.ErrorCantDirMove`.
4. `configStore` has an inherent limit when tokens come from the environment and two processes share them. Neither process can see the other's refresh, so the second process refreshes with a spent token and its session expires. Proton may also flag the reuse. Document this in TODO.md or the user docs.
5. `Fs.forget` (protondrive.go:952-956) can race with a listing. A source-client `listChildren` that started before the move can re-cache the stale listing after `forget`. This is acceptable until events land, but note it beside the existing TODO entry.
6. `TestTwoRemotesIsolated` (protondrive_internal_test.go:232-250) never asserts that a refresh happened, so the store-isolation path may go unexercised. Assert that the access tokens changed from their originals, or count `/auth/v4/refresh` requests == 2.
7. `connect` (protondrive.go:503-508) gives no reconnect hint for a non-expiry `newClient` failure, such as a wrong key password after a password change. `errCaptcha` from the fallback sign-in also does not suggest browser sign-in.
8. The `XXX:` comment at protondrive.go:666 (copied from box) breaks AGENTS.md's FIXME/TODO convention.
9. Code 2000 maps to a fatal "upgrade rclone" error (protondrive.go:396-397). This is plan-mandated and already in TODO.md. If the real server uses 2000 for a stale revision, it would abort a whole sync.
### Assessment
**Task quality:** Needs fixes
**Reasoning:** The swap is careful: `configStore`, the auth fallback, error mapping, Move invalidation and the tests are sound, and the tests prove what they claim. However, Rmdir can trash content rclone cannot see, `Config` regresses `rclone config update`/edit by forcing re-authentication, and Shutdown turns cache expiry in long rc jobs into broken clients.
@@ -0,0 +1,41 @@
### Task 15: Regression tests for known failures
**Files:**
- Create: `backend/protondrive/regression_test.go` (package `protondrive_test`, through `fs.Fs` and the fake)
**Interfaces:** Consumes Task 13 `setupFakeRemote`.
- [ ] **Step 1: Write the tests** (spec 9.4, rows not marked M5/M6). Each
is named `TestRegression<Issue or Topic>`:
- `TwoFsRefreshRace` (9880, 8135): two `Fs` from one config; expire the
token; both `List` concurrently; both succeed; the mapper holds valid
credentials.
- `TwoProcessRefreshRace`: two `Fs` on two `configmap.Simple` copies that
are synced through a shared backing map with a delay; the loser adopts
the winner's token, credentials never blank.
- `ManyNewFs` (9816): 16 concurrent `NewFs`; unlock concurrency 1.
- `FileOverDirectory` and `DirectoryOverFile` (9438): `operations.Copy`
reports a clean error, nothing is destroyed.
- `DocsNodeSkipped` (7959): a sync from a folder holding a Docs node
completes with 0 errors.
- `BadSignatureFile` (8003): lists; `Open` fails with a clear message;
with `ignore_signature_errors=true` it succeeds.
- `SDKCreatedFolderResolvable` (9622): a `SeedFolder`/`SeedFile` tree is
reachable by `NewObject("dir/sub/file")` with a cold cache.
- `MoveIsServerSide`, `CopyIsServerSide`: no block upload requests.
- `MultiThreadDownload`: `operations.Copy` of a 20 MiB object to local
with `--multi-thread-streams 4` matches SHA1.
- `PutStreamUnknownSize`.
- `Captcha`: `NewFs` with password fallback under `RequireCaptcha`
returns the browser sign-in hint.
- `AppVersionRejected`: fault code 2028 -> fatal error mentioning
upgrading rclone.
- `QuotaExceeded`: fault code 200001 on draft creation -> not retried
(request count 1), message preserved.
- `RateLimited`: 5 consecutive 429s on listing -> succeeds, waited at
least the sum of `Retry-After`.
- [ ] **Step 2: Run** `go test -race ./backend/protondrive/ -run TestRegression -v` -
fix the backend until PASS.
- [ ] **Step 3: Commit** `protondrive: add regression tests for known failures`.
@@ -0,0 +1,92 @@
# Task 15 report: regression tests for known failures
Status: DONE_WITH_CONCERNS (one concern: ManyNewFs can't prove unlock concurrency 1 black-box; see below)
## Commits
- 6e2410de6 protondrive: name the remote in the CAPTCHA error and offer browser sign-in in config
- 14128d59c protondrive: say how to read a file whose signature fails to verify
- 860081538 protondrive: add regression tests for known failures (regression_test.go, plan ticks, TODO entries)
## What was implemented
`backend/protondrive/regression_test.go` (package `protondrive_test`, 567 lines). It uses only `fs.Fs`, operations, sync and the fake. Tests:
| Test | How |
|---|---|
| TwoFsRefreshRace (#9880, #8135) | Two `protondrive.NewFs` on one mutex-guarded mapper (`lockedMap`, which records every value set). The test expires the access token, and a `Gather` fault on `POST /auth/v4/refresh` (Times 2) makes both refreshes reach the server with the same refresh token. It then lists both concurrently and checks: both succeed, 2 refresh requests, the mapper's session is accepted by the server (raw `GET /core/v4/users` = 200), no credential key was ever set blank, and a second concurrent listing makes no further refresh. |
| TwoProcessRefreshRace | Each "process" has a `processMap` that reads its own writes at once but writes them to the shared backing `lockedMap` only after 500 ms. The race is forced the same way. Asserts as above, which covers the loser adopting the winner's token (no third refresh) and credentials never blank in the backing store. Mutation check: with `adoptWait` set to 0, both race tests fail. |
| ManyNewFs (#9816) | 16 sessions, 16 concurrent `NewFs`. A `Gather` fault on `GET /core/v4/addresses` (Times 16) holds every NewFs until all 16 have fetched their keys, so all 16 unlocks contend at once. Asserts: all succeed, all list, and users/addresses are each fetched exactly 16 times (one unlock per Fs). |
| FileOverDirectory (#9438) | `operations.Copy` of file `x` over directory `x` returns `fs.ErrorIsDir`. A fresh Fs still lists `x/inside.txt`. |
| DirectoryOverFile (#9438) | `operations.Copy` to `x/y` where `x` is a file fails with `create folder "x": already exists`. A fresh Fs reads file `x` intact. |
| DocsNodeSkipped (#7959) | `sync.Sync` from a folder holding `SeedDoc` and a file, to local. Returns nil, stats errors 0, only the file is copied. |
| BadSignatureFile (#8003) | Content key signed by another address after 2023. The file is listed. `Open` fails, and the error names the file and contains "signature" and "ignore_signature_errors". With `ignore_signature_errors=true` (connection-string remote) the content reads correctly. |
| SDKCreatedFolderResolvable (#9622) | `SeedFolder`/`SeedFolder`/`SeedFile` trees ("dir/sub/file" and a non-ASCII/combining-character one). `NewObject` works on a fresh Fs (cold cache) and the content matches. |
| MoveIsServerSide / CopyIsServerSide | After `ResetRequests`: no `/storage/*` or `POST /drive/blocks` request. The content is right at the destination. For Move the source is gone; for Copy it remains. |
| MultiThreadDownload | 20 MiB seeded file. `fs.AddConfig`: streams 4, `MultiThreadSet`, cutoff 1 MiB, chunk size 5 MiB (does not line up with 4 MiB blocks). `operations.Copy` to local, SHA1 matches. There are more than 5 block GETs, so ranged parallel reads did happen. |
| PutStreamUnknownSize | `Features().PutStream` of 9 MiB with size -1. A fresh Fs reads back the same size, modtime and content. |
| Captcha | `NewFs` with username/password under `RequireCaptcha`. The error is fatal and contains `rclone config reconnect myremote:` and "browser sign-in". |
| AppVersionRejected | Fault 400/code 2028 on children. `List` gives `fserrors.IsFatalError` with "upgrade rclone". |
| QuotaExceeded | Fault 422/code 200001 on draft creation. `operations.Copy` gives a NoRetry error, the message keeps Proton's text, and draft requests = 1. |
| RateLimited | 5 × 429 with Retry-After 1 on children. `List` succeeds, elapsed ≥ 5 s, 6 children requests. |
## Backend bugs found and fixed (each its own commit, test first)
1. **CAPTCHA hint named the wrong remote** (`session.go`, `protondrive.go`). `errCaptcha` hard-coded `run "rclone config reconnect remote:"`, so a user saw a remote literally called `remote:`. Fixes:
- `errCaptcha` is now just "password sign-in needs a CAPTCHA solved".
- `connect` wraps it as a fatal error with the real remote's reconnect hint and "choose browser sign-in".
- The Config `password` state now answers a CAPTCHA with `fs.ConfigError("method", "...sign in with a web browser instead.")`. Before, it aborted config with a bare error.
Tests:
- `TestRegressionCaptcha`.
- New subtest `TestConfigPassword/captcha`.
- `TestLoginSRPCaptcha` no longer checks for the reconnect text, because the session layer can't know the remote name. It still checks `ErrorIs(errCaptcha)`.
RED was seen by reverting the two source files: both new tests failed.
2. **Signature failures gave no way out** (`download.go`). Content key and manifest signature errors gave no hint about `ignore_signature_errors`. Both now end with "set ignore_signature_errors to read the file anyway". `TestDownloadContentKey` and `TestDownloadBadManifest` gained assertions, and RED was seen before the fix.
## ManyNewFs: what it proves and what it can't
It proves:
- 16 concurrent `NewFs` calls, all at the unlock step together, complete correctly without races (`-race`).
- Each Fs fetches and unlocks keys exactly once.
It **cannot** prove that unlocks run one at a time. Unlocking is local CPU work with no server traffic, so no fake-side hook can see it, and the window between the addresses response and the next request includes time spent waiting for the mutex. A black-box check would need a test-only export of `unlockMu` or of the session hook. I did not add one, per the brief.
`TestLoadAccountSerialised`, an internal test using `unlockHook`, does prove peak concurrency 1. Logged in docs/TODO.md.
## Verification (all run in this session, RCLONE_CONFIG=/notfound)
- `go build ./...`: OK.
- `go test -race ./backend/protondrive/ -run TestRegression -v`: all 16 PASS, 25.3 s. RateLimited takes 5.7 s and MultiThreadDownload 1.5 s; each other test takes 0.6–2.5 s.
- `go test -race -count=3 ./backend/protondrive/ -run TestRegression`: ok (72 s), no flakes.
- `go test -race ./backend/protondrive/...`: ok (180 s); protontest ok.
- `go vet ./backend/protondrive/...` and `golangci-lint run ./backend/protondrive/...`: 0 issues. One revive finding (ctx parameter order) was fixed before commit.
PASS list:
```
--- PASS: TestRegressionTwoFsRefreshRace (0.92s)
--- PASS: TestRegressionTwoProcessRefreshRace (1.32s)
--- PASS: TestRegressionManyNewFs (2.53s)
--- PASS: TestRegressionFileOverDirectory (1.20s)
--- PASS: TestRegressionDirectoryOverFile (1.22s)
--- PASS: TestRegressionDocsNodeSkipped (0.83s)
--- PASS: TestRegressionBadSignatureFile (1.10s)
--- PASS: TestRegressionSDKCreatedFolderResolvable (1.47s)
--- PASS: TestRegressionMoveIsServerSide (1.04s)
--- PASS: TestRegressionCopyIsServerSide (0.99s)
--- PASS: TestRegressionMultiThreadDownload (1.50s)
--- PASS: TestRegressionPutStreamUnknownSize (1.43s)
--- PASS: TestRegressionCaptcha (1.76s)
--- PASS: TestRegressionAppVersionRejected (0.60s)
--- PASS: TestRegressionQuotaExceeded (0.63s)
--- PASS: TestRegressionRateLimited (5.68s)
ok github.com/rclone/rclone/backend/protondrive 25.273s
```
## Files changed
- backend/protondrive/regression_test.go (new)
- backend/protondrive/session.go, protondrive.go, download.go (fixes)
- backend/protondrive/session_internal_test.go, protondrive_internal_test.go, download_internal_test.go
- docs/plans/001-protondrive-tier1.md (Task 15 ticked), docs/TODO.md
## Other notes
- The test file imports `backend/local` (blank) as the local destination for sync and multi-thread copy.
- The fake was not changed.
- `errNotSignedIn` has the same literal `remote:` problem as the CAPTCHA error. It was not fixed because it is outside this task's tests; logged in docs/TODO.md.
- Output is not pristine: the backend's own ERROR/NOTICE log lines appear for the expected failures (failed copy, docs notice, signature error). No `t.Log` output.
@@ -0,0 +1,24 @@
### Task 16: Security tests
**Files:**
- Create: `backend/protondrive/security_test.go`
- [ ] **Step 1: Write the tests** (spec 9.6):
- `TestHostileServer` subtests: wrong block hash, swapped blocks,
truncated block list, older revision content under the current
revision ID, attacker-signed passphrase, names `"../x"`, `"a/b"`, NUL,
10 MiB - each yields an error or a skipped entry, never file data or a
path outside the listed directory.
- `TestNoSecretsInLogs`: capture rclone's log output at
`fs.LogLevelDebug` (as `fstest` log capture helpers do) across password
sign-in, a 5 MiB upload, a download and `Disconnect`; assert the log
contains none of: the password, mailbox password, key password, access
token, refresh token, the node passphrase and session key (exposed by
the fake for the test), any `pm-storage-token` value.
- `TestConfigHoldsNoPasswordAfterBrowserSignIn`, and
`TestPasswordStoredObscured` (the stored value differs from the
plaintext and `obscure.Reveal` restores it).
- [ ] **Step 2: Run** - fix until PASS.
- [ ] **Step 3: Commit** `protondrive: add hostile-server and secret-hygiene tests`.
@@ -0,0 +1,496 @@
# Task 16 report: security tests
Status: DONE_WITH_CONCERNS (concerns at the end)
## Commits (branch protondrive-tier1, not pushed)
1. `606580196 protondrive: refuse to read a file whose key isn't signed by its owner` (bug fix, test first)
2. `59f0f31b3 protondrive: name the remote to reconnect when the session expires or is signed out` (known defect, test first)
3. `db97ef7c8 protondrive: stop a call rejected during disconnect retrying without credentials` (suspected defect, confirmed, test first)
4. `f4105adfc protondrive: add hostile-server and secret-hygiene tests` (the brief's commit: tests, fake extensions, plan, TODO, DECISIONS)
## What was implemented
### security_test.go (package protondrive_test, black-box through fs.Fs / operations / sync)
It reuses `setupFakeRemote`, `newFakeFs`, `lockedMap` and `newLockedMap` from fake_test.go and regression_test.go.
**TestHostileServer.** Each content case checks the following:
(a) `NewObject` still finds the file.
(b) `Open` plus `ReadAll` returns an error. The bytes released before that error are at most `released`, and they are never equal to the file's content.
(c) `operations.CopyFile` to a fresh local directory fails, and the directory is left empty.
(d) It repeats (b) and (c) with `ignore_signature_errors=true`. Integrity-only cases must still fail at read. When the read returns something other than the true content, the copy must still fail, because rclone's SHA1 check against the claimed hash catches it.
Cases:
- **wrong block hash** (`Corrupt block-hash`, 2 blocks). Nothing is released.
- **swapped blocks**: 3 blocks, the first two both 4 MiB. Only the manifest signature catches this. The whole swapped content is released before the error at EOF. With signatures ignored, the copy's SHA1 check catches it.
- **swapped blocks of different sizes** (4 MiB + 10 B). The block-size check catches it before any release, even with the signature flag set.
- **truncated block list** (4 MiB + 10 B, last block dropped). The block-count check against the recorded sizes catches it, even with the flag. The genuine first 4 MiB is released before the error.
- **older revision content under the current revision ID**: a rollback with an old revision of the same size. The new fake mode `Corrupt rollback` serves the old revision's validly signed blocks and hashes under the current revision's ID, manifest and xattrs. The manifest catches it at EOF, and the old content is released first. With the flag set, the copy's SHA1 check catches it.
- **older revision content of another size**. The block-size check catches it. Nothing is released, even with the flag.
- **block from another file**, i.e. wrong decryption. In the new mode `Corrupt foreign-block`, the first block is re-encrypted with an unrelated content key, and the hash and block signature are updated to match. Decryption fails. Nothing is released, even with the flag.
- **attacker-signed passphrase** (`Corrupt passphrase-signature`). Nothing is released. With the flag set the file is readable, as designed. This exposed bug 1.
**TestHostileServer/names.**
- Seeded names: `../x`, `a/b`, `nul\x00name`, `..`, `.` and a 10 MiB name. Each is seeded as a file in `dir`, as a file at the root, and as a folder inside `sub N`. A legitimate fullwidth-slash name `a/b` and `ok.txt` are seeded alongside.
- Every `Remote()` listed in `dir` starts with `dir/`, and the leaf contains no `/`, no NUL, and is neither `..` nor `.`. Exactly 2 entries are listed. The `sub N` folders list empty.
- A `sync` of the whole remote into `<tmp>/dst` writes nothing outside `dst`; in particular there is no `<tmp>/x`.
- With `-race`, the listing including the 10 MiB name took about 2-4 s and allocated about 230-460 MiB, the fake's own encoding included. The test asserts less than 30 s.
**TestNoSecretsInLogs.** It captures rclone's log through `fs/log.Handler.SetOutput` at debug level, with the global `LogLevel` set to debug. The run covers:
- password sign-in of a two-password user;
- a 5 MiB upload via `operations.Copy`;
- a forced token refresh with `ExpireAccessToken`;
- downloads of the uploaded file and of a seeded multi-block file;
- a failing download of a damaged file, to exercise the error and retry log paths;
- `Disconnect`.
It then asserts that none of these appear in the log, as raw text, std or raw-URL base64, or hex:
- the password and the mailbox password;
- the key password;
- every access and refresh token saved to the config;
- every `pm-storage-token` the fake issued;
- the node passphrase and content session key of all three files, exposed by the new `User.NodeSecrets`.
It also checks that "refreshing session" is present, which proves debug output was captured.
The subtest `dump headers` runs the same flow with `--dump headers` and asserts the same set minus storage tokens. A temporary probe confirmed what that dump does contain:
- all 9 pm-storage-token values;
- `X-Pm-Uid`;
- `Authorization: XXXX`, which fshttp redacts.
`--dump bodies` is deliberately not asserted. It prints the tokens in the sign-in and refresh JSON bodies, as spec section 7 says. This is documented in the test comment and in docs/TODO.md; Task 20 owns the docs warning.
**TestConfigHoldsNoPasswordAfterBrowserSignIn.**
- It runs the browser sign-in state of `configure` through a new `export_test.go` helper, `ConfigBrowserSignIn`, which hands the sign-in URL to a callback instead of printing it and polls every 10 ms.
- It checks that `password` and `mailbox_password` were never set, and that no config value contains the password, plainly or obscured.
- The session works.
- The log holds neither the sign-in URL's fragment (the fork key and user code), nor any issued secret (tokens and fork selector), nor the key password.
**TestPasswordStoredObscured.** The `password`, `mailbox_password` and `otp_secret_key` options are `IsPassword`, so `rclone config` obscures them. After password sign-in through `NewFs`, the stored value differs from the plaintext and `obscure.Reveal` restores it. No config value ever set contains either password.
**draft_client_uid decision.** It is not treated as a secret. Spec 6.3 and 6.7 make it a stable per-remote draft identifier; it isn't in spec 7's list of secrets and grants no access. It is not asserted absent.
### Fake extensions (protontest; it still imports only `api`, and its strict checks are unchanged)
- `Corrupt(id, "rollback")` and `Corrupt(id, "foreign-block")`.
- `Server.IssuedSecrets()`: every access and refresh token, fork selector, and block upload or download token. These are recorded through a new `newSecretLocked`.
- `User.NodeSecrets(id)`.
- A `driveView.contentKey` helper, which `SeedRevision` now reuses.
- The fake's TestCorrupt covers the two new modes. There are new TestNodeSecrets and TestIssuedSecrets tests.
- The plan's protontest Interfaces block is updated, and the Task 16 checkboxes are ticked.
### Bugs found and fixed (each test first, one commit each)
1. **Attacker-signed node passphrase was readable** (606580196).
- **Before:** `decryptNode` only logged a failed node-key signature. `open` then decrypted content with a key the server could have made itself, sealed to the parent's public key. Combined with an anonymous signature email, that allows a fully forged file.
- **Now:** the node keeps `keyErr`, and `contentKey` returns `errIntegrity` with the ignore_signature_errors hint unless the option is set. The node is still listed, per spec 6.2.
- **Test:** `TestDownloadNodeKeySignature`, failing before the fix.
- **Records:** a DECISIONS.md entry, added in the final commit; see the concerns.
2. **errNotSignedIn and errSessionExpired said `remote:`** (59f0f31b3).
- The sentinels are now plain ("not signed in", "session expired").
- The seam (`connect` in protondrive.go) sets `session.reconnect` to `run "rclone config reconnect NAME:"`, and `session.signInNeeded` appends it to fatal sign-in errors.
- **Why a seam-supplied hint and not `translateError`:** read errors from the block reader and other paths never pass through `translateError`, while every one of these errors originates in `session`. The session still knows no remote name; the seam gives it a string.
- `connect`'s "saved session can't be used" no longer repeats the hint.
- **Tests:** `TestDisconnect` was strengthened to require `reconnect test:` on List, Read and Put. `TestSessionExpiredNamesRemote` is new. `TestSessionRevoked` was updated.
- errSessionExpired had the same defect as errNotSignedIn, so both are fixed here.
3. **A call rejected with 401 during Disconnect was retried with empty `x-pm-uid` and Authorization headers** (db97ef7c8).
- `refresh` saw the cleared credentials, returned nil, and `call` retried with them.
- `call` now returns errNotSignedIn when the credentials are gone after refresh.
- **Test:** `TestCallDuringLogout`, failing before the fix. It saw 2 requests where 1 was expected, and the error was not errNotSignedIn.
## What is and is not guaranteed about partial data (spec 6.4: the manifest is verified at EOF)
**Guaranteed:**
- A full read of tampered content never ends in a clean EOF.
- A block is released only after its encrypted bytes match the listed hash, it decrypts (AEAD, or MDC for legacy) and its size matches the recorded size.
- A block failing any of those checks is never released.
- Content-key and node-key signature failures fail at `Open`, before any byte.
- `operations.Copy` and `sync` leave nothing at the destination.
**Not guaranteed:**
- Blocks that pass those per-block checks but sit in the wrong order or come from an older revision are released before the manifest error at EOF. Example: swapped equal-size blocks, or a rollback of the same size.
- So a streaming consumer (`rclone cat`, mount) can see wrong bytes followed by an error.
- In the truncation case, a genuine prefix is released before the error.
- With `ignore_signature_errors=true`, such signature-only tampering reads without error; only the SHA1 check of a copy catches it.
This is logged in docs/TODO.md with a suggested hardening: list every block page and verify the manifest before the first byte.
## Proof the tests bite (temporary local mutations, all restored and not committed)
- **M1:** the block-size check in `fetchOnce` disabled. "swapped blocks of different sizes" and "older revision content of another size" FAIL.
- **M2:** the block-count check against the recorded sizes disabled. "truncated block list" FAILS.
- **M3:** manifest verification disabled. "swapped blocks" and "older revision content under the current revision ID" FAIL with "read without error".
- **M5:** the `/` check in `validateName` disabled. "names" FAILS.
- **Passphrase check disabled.** "attacker-signed passphrase" and `TestDownloadNodeKeySignature` FAIL.
- **Debug log of the refresh token added.** Both TestNoSecretsInLogs subtests FAIL ("the log holds session token 3").
- **M4:** the block hash check disabled. "wrong block hash" still PASSES, because AEAD decryption of the flipped ciphertext fails, so the block is still never released. This is defence in depth, not a gap in the test.
## Verification (run in this session)
- `go build ./...`: OK.
- `RCLONE_CONFIG=/notfound go test -race ./backend/protondrive/ -run 'TestHostileServer|TestNoSecretsInLogs|TestConfigHoldsNoPassword|TestPasswordStoredObscured' -v`: PASS.
```
--- PASS: TestHostileServer (28.20s)
--- PASS: TestHostileServer/wrong_block_hash (2.04s)
--- PASS: TestHostileServer/swapped_blocks (1.33s)
--- PASS: TestHostileServer/swapped_blocks_of_different_sizes (1.52s)
--- PASS: TestHostileServer/truncated_block_list (1.30s)
--- PASS: TestHostileServer/older_revision_content_under_the_current_revision_ID (1.17s)
--- PASS: TestHostileServer/older_revision_content_of_another_size_under_the_current_revision_ID (1.43s)
--- PASS: TestHostileServer/block_from_another_file (1.54s)
--- PASS: TestHostileServer/attacker-signed_passphrase (1.00s)
--- PASS: TestHostileServer/names (16.88s)
--- PASS: TestNoSecretsInLogs (8.02s)
--- PASS: TestNoSecretsInLogs/debug (3.78s)
--- PASS: TestNoSecretsInLogs/dump_headers (4.24s)
--- PASS: TestConfigHoldsNoPasswordAfterBrowserSignIn (0.62s)
--- PASS: TestPasswordStoredObscured (2.66s)
PASS
ok github.com/rclone/rclone/backend/protondrive 40.737s
```
- The same with `-count=3`: all 3 runs of all 4 tests PASS (`ok ... 120.488s`).
- `go vet ./backend/protondrive/...`: OK.
- `golangci-lint run ./backend/protondrive/...`: 0 issues.
- Full `RCLONE_CONFIG=/notfound go test -race ./backend/protondrive/...`: ok backend/protondrive (222s), ok protontest (36s), ok protontest/cmd/protonfake
## Files changed
- backend/protondrive/security_test.go (new)
- backend/protondrive/export_test.go (new)
- backend/protondrive/download.go, nodes.go, download_internal_test.go (fix 1)
- backend/protondrive/session.go, protondrive.go, session_internal_test.go, protondrive_internal_test.go (fixes 2 and 3)
- backend/protondrive/protontest/{auth,drive,drive_test,server,upload}.go
- docs/plans/001-protondrive-tier1.md, docs/TODO.md, docs/DECISIONS.md
## Concerns
1. **Data released before the manifest check.** Wrong-order or rolled-back blocks of the right sizes reach the reader before the EOF error, per spec 6.4. The spec 9.6 wording ("an integrity error, not data") is stricter than what 6.4 delivers. The controller or owner should decide whether to verify the manifest up front; see the TODO.
2. **DECISIONS entry not in the fix commit.** The entry for fix 1, which also explains the reconnect-hint design, landed in the final test commit, not in fix commits 1 and 2 as AGENTS.md asks. I didn't rewrite committed history.
3. **Hint design differs from the requested pattern.** Fix 2 uses a seam-supplied hint on the session rather than `translateError`, because many error paths, reads included, bypass `translateError`. If the reviewer insists on `translateError`, the read path would need wrapping.
4. **Signature-only cases, as designed.**
- Name signature failures still only log (spec 6.2).
- Encrypted names aren't bound to their node, so a name swap within a folder is undetectable.
- A 10 MiB name is decrypted in full before it is rejected.
- All three are logged in TODO.md.
5. **Test helper file outside the brief's list.** `export_test.go` is an extra test-only file. It is the idiomatic way to drive `configure` with a fake sign-in page from a black-box test.
## Fix round 1 (base f4105adfc)
Status: DONE_WITH_CONCERNS
### Commits
- `88654eb04 protondrive: distrust files and folders beneath a folder with a forged key` (A)
- `9598e22d9 protondrive: check a file's signed block list before handing out any of it` (B, plus C1)
- `248f46705 protondrive: don't save refreshed tokens after disconnect` (C5)
- `751a3ea35 protondrive: test config obscuring the password and refused updates` (C2, C3, C4)
248f46705 had a one-line lint fix folded in with `rebase --autosquash` before this report. Only this round's unreported commits were rewritten.
### A. Forged folder key
**How the trust state is held.**
- `nodeKeys.unverified` records why a key may not be its owner's. It replaces the per-node `keyErr` from fix 1, so the state lives in one place.
- `decryptNode` now takes the parent's `*nodeKeys`; `folderKeyRing` became `folderKeysOf`.
- A key is unverified in two cases:
- its own passphrase signature fails;
- it is anonymous (`SignatureEmail == ""`, so it was checked against the parent key) and the parent key is unverified.
- Folder keys are cached together with this state, so it carries down the tree. The share key's verify error is recorded too.
**What is refused.**
- `contentKey` refuses unverified keys. That covers reads, ranged reads included, and `createRevision` (`Update`).
- `targetFolder` refuses a folder whose key is unverified, unless `ignore_signature_errors` is set, with an `errIntegrity` error saying why. That covers create folder, upload, rename, move-into and copy-into.
- Listing still works. Only the forged folder itself logs an ERROR; inherited children are not logged again.
- Names are not refused (spec 6.2).
**Fake.**
- New `SeedForgedFolder`: a server-made key sealed to the real parent, signed by an impostor key, with no emails.
- New `SeedAnonymousFolder`.
- `SeedFolder` is refactored onto a shared `seedFolder`.
- New test `TestSeedForgedFolder`.
**Tests.** New TestHostileServer cases:
- "anonymous file in a folder with a forged key": the read is refused with nothing released, the copy fails, and the file reads with the flag set.
- "… nested in a folder with a forged key": the same, one level down.
- "writing into a folder with a forged key": upload, Mkdir, server-side copy, move and Update into `forged` and `forged/nested` are all refused; `Dump` shows nothing written; upload succeeds with the flag set.
**Evidence.**
- RED/mutation: disabling the inheritance line makes both read cases and the nested upload fail. Disabling the `targetFolder` gate makes the write case fail.
- A DECISIONS entry is in the same commit, as is the plan's Interfaces block.
### B. Manifest before release (full reads)
**What changed.**
- `listBlocks`, for full reads, now collects `[]listedBlock` (`api.Block` plus decoded hash: metadata only), runs the index, maxBlocks and count checks, then `verifyManifest`, and only then fetches.
- `fetch`, `slots` and `Read` are unchanged.
- `fetchBlock` now errors when a re-fetched block (after an expired URL or a hash mismatch) is listed with a different hash: `errIntegrity`, "listed again with another hash".
**Ranged reads.** Left as they were. Making them verify even small files would add the empty terminating page request or change how the list ends, and would turn today's documented "ranged reads can't verify" into errors per chunk for mounts and multi-thread copies. So it was not zero-cost, and I left it.
**Tests.**
- The security tests' `released` field is removed. Every content case now asserts that zero bytes are released, with and without the flag. The exception is the signature-only cases with the flag set: their output is caught by the copy's SHA1 check.
- `TestDownloadBadManifest` now asserts no bytes come before the verdict.
- New `TestDownloadRefetchOtherHash`: a delayed 404 on storage, during which the block is corrupted with `foreign-block`.
- RED: with the pre-B `download.go`, these fail: "swapped blocks", "truncated block list", "older revision's blocks under the current revision", `TestDownloadBadManifest` and `TestDownloadRefetchOtherHash`.
**Docs.**
- Spec 6.4 and 9.6 reworded: the manifest is verified before the first byte; whole-revision rollback is a protocol limit.
- DECISIONS entry added.
- TODO entries: sequential paging latency (about 12 requests for 1 GiB, about 1280 for 100 GiB; parallel paging is a possible improvement); what ranged reads verify today, with a per-revision verified-hash cache as the fix; whole-revision rollback, to raise with Proton.
- The old "manifest only at EOF" TODO is removed.
### C. Minors
1. The fake's corruption is renamed `block-rollback`, and its doc says whole-revision rollback isn't modelled. The subtests are renamed "older revision's blocks … under the current revision", and `seedRolledBack` documents the limit. Spec and TODO updated (in the B commit).
2. `IssuedSecrets(kinds ...SecretKind)` with kinds `SessionToken`, `ForkSelector` and `StorageToken`. The log test checks every issued session token, saved or not, in both subtests, and requires every saved token to be among those issued.
3. `TestPasswordStoredObscured` now runs `config.CreateRemote` with a plaintext password and mailbox password (`--obscure`, non-interactive, answering "choose" with password sign-in) against a temp config file. It asserts the stored values differ from the plaintext, `obscure.Reveal` restores them, the remote signed in, and the config file holds neither plaintext. Mutation: making `configure` store the revealed password makes it fail.
4. New subtest "updating a file with an attacker-signed passphrase": `Update` is refused and the content is unchanged. Mutation: disabling the `contentKey` gate makes it fail.
5. `logout` clears the credentials under `refreshMu`, so a refresh in flight finishes first and logout clears afterwards. New `TestRefreshDuringLogout`: a transport holds the refresh response until logout has deleted the session. Before the fix the store ended with "new-access"/"new-refresh"; after it, the store is empty and the in-flight call fails.
### Verification (this session)
- `go build ./...`: OK.
- `golangci-lint run ./backend/protondrive/...`: 0 issues.
- `go vet`: OK.
- Full `RCLONE_CONFIG=/notfound go test -race ./backend/protondrive/...`: ok protondrive (219s), ok protontest (35s), ok protonfake. This ran before the one-line assertion fixup, which was then re-run with `-count=5` and passed.
- Security tests with `-race -count=3`: every subtest PASS 3/3. There are 13 TestHostileServer subtests, plus NoSecretsInLogs (debug and dump headers), ConfigHoldsNoPasswordAfterBrowserSignIn and PasswordStoredObscured.
- `TestIntegrationFake`, `TestFrozenFixtures`, `TestFrozenFileFixture`, `TestOldLibraryFileReadable` and `TestNewWriterOldReader`: PASS, with old-library files read without the flag.
### Concerns
1. **Writes into forged folders.** Rename within a forged folder is refused too, since it goes through `targetFolder`. That is broader than the ruling's list, but it is the same reasoning: a new name would be encrypted to a key the server may hold.
2. **Large full reads.** A full read of a very large file waits for its whole block list (sequential pages) before the first byte. This is noted in TODO.
3. **Ranged reads.** They still don't verify the manifest. This is documented, with the cache plan in TODO.
4. **Config global state in a test.** `TestPasswordStoredObscured` switches rclone's global config path to a temp file for its duration and restores it afterwards. The package's tests are not parallel, so this is safe today.
## Fix round 2 (base 12ce57ed3)
Status: DONE_WITH_CONCERNS
### Commits
- `787bb3b3f protondrive: refuse to move or copy a file or folder whose key isn't verified` (1)
- `cb6e6759b protondrive: trust a drive only if it is shared and signed by your own address` (2)
- `58fde59f3 protondrive: check the signed block list when a range asks for the whole file` (3)
- `202b64cf1 protondrive: cap the number of blocks a file may list` (4)
- `2a1a8f046 protondrive: restore rclone's config storage after the password test` (5)
- `e7e43095d protondrive: restore the config storage even when rclone's config is in memory` (5, follow-up)
No existing commit was rewritten. The follow-up to 5 is a separate commit: the first version's cleanup restored the config path before the storage, and that failed in the full package run, where rclone's config is in memory.
### 1. Laundering by move or copy out of a forged folder (787bb3b3f)
**Code.**
- New `client.checkVerified(n)`. `encryptForNewParent` calls it, which covers move, copy and DirMove (DirMove goes through move), and so does `rename`.
- Each refuses when the source node's key is unverified, unless `ignore_signature_errors` is set. The error is an `errIntegrity` saying why and naming the option.
**Inheritance widened, and why.** The ruling asked that a child created inside, or moved under, an unverified folder with the flag set keep the inherited unverified state, and that the cache never be cleaner than a fresh listing. With round 1's anonymous-only inheritance, a fresh listing of a user-signed child of a forged folder said "verified". That is unsound:
- whoever made the folder key can decrypt the child's passphrase;
- with it, they can serve revisions signed by the node key.
So every node beneath an unverified folder is now unverified, whatever its signer. `createFolder` sets the new folder's state from its parent, which keeps the cache-insert path from Task 17 (`cache.add`) consistent. Uploads, copies and moves re-read the node, so they already match a fresh listing.
**Errors.**
- The root-cause error names the folder: `the key of "forged" isn't signed by its owner: …`.
- Descendants carry that same error value, so the message doesn't nest.
**Tests.**
- "moving or copying out of a folder with a forged key":
- Move, Copy, DirMove of `forged/nested`, DirMove of `forged` itself, and renaming a root file with an attacker-signed passphrase are all refused.
- A fresh Fs then finds `real` empty and still refuses to read `forged/victim.bin`, `forged/nested/deep.bin` and `signed.bin`.
- "what is written into a folder with a forged key stays unverified": a folder and file written there with the flag set are, in a fresh Fs without the flag, unreadable, and uploading into them is refused.
- Internal `TestCreatedUnderUnverifiedFolderStaysUnverified`: the node returned by `createFolder`, the cached listing and a fresh `fetchNode` all carry the unverified state.
**RED and mutations.**
- All three tests failed before the fix.
- With `checkVerified` disabled, the move test fails.
- With inheritance back to anonymous-only, the "stays unverified" test and the internal test fail.
- With the `createFolder` inheritance line dropped, the internal test fails.
**Docs.** DECISIONS entry "Proton Drive won't vouch for a node it couldn't verify". It supersedes round 1's inheritance rule, and its consequence says that a genuine folder whose key fails, e.g. one signed by a since-deleted address, makes its subtree unreadable and unwritable without the flag.
### 2. Root of trust (cb6e6759b)
**Code.**
- In `bootstrap`, the My Files share's `CreatorEmail` and the root folder's `SignatureEmail` must be addresses whose keys the account holds. The new `account.owns` compares ignoring case.
- Otherwise `shareKeys.unverified`, or the root's, is set to a plain message ("the drive's share was created by "x", which is not one of your addresses"; likewise "the drive's root folder is signed by …"), and one ERROR is logged saying reading and writing need `ignore_signature_errors`.
- `shareKeys.unverified` is now used: the root inherits it, and everything inherits from the root.
- "Own" means addresses the account lists and whose keys unlock, disabled ones included if listed. An address whose keys don't unlock is skipped by `loadAccount`, and its keys would then come from the server, so it can't anchor trust.
**Fake.** New `User.ForgeShareCreator(other)` and `User.ForgeRootSigner(other)`; both re-sign with the other user's key, which the fake serves. New test `TestForgeShareAndRoot`.
**Tests.** "drive shared by another user" and "root folder signed by another user":
- the listing works;
- the read is refused with zero bytes, and the error says "not one of your addresses" and "ignore_signature_errors";
- upload is refused the same way;
- with the flag set, the file reads.
**Mutations.** Each check disabled makes its own case fail.
**Docs.**
- Spec section 7 (Security; the round brief said "section 8", but the threat-model text lives in 7, and 9.6 makes no conflicting claim). It now says trust is rooted in own addresses, and states the limit: below the root, other users' signatures are checked against keys the server supplies, which the SDK shares; key transparency or address-key pinning is the answer.
- TODO entry for that limit.
- DECISIONS correction entry "Proton Drive roots trust in the user's own addresses". It corrects the two earlier entries without editing them, and notes that a genuine folder signed by a since-deleted own address refuses writes, and reads beneath it, without the flag.
### 3. `bytes=0-` (58fde59f3)
**Code.** `Object.Open` passes no limit when the offset is 0, the size is known and the limit reaches the size.
**Test.** "a range covering the whole of a file with swapped blocks" opens with `{0,-1}`, `{0,size-1}` and `{0,size+100}`. Each errors, with zero bytes released. It was RED before the fix: all bytes were released.
**Docs.** Spec 6.4 says a whole-file range is a full read.
### 4. Block cap (202b64cf1)
**The cap.** `maxFileBlocks = 1 << 19` blocks, which is 2 TiB at 4 MiB.
- No single file on Proton can be that large: the largest plans hold a few TB in all, and no client uploads such a file.
- At a few hundred bytes of metadata per listed block, the worst case is about 200 MiB held during the listing phase, whatever the unsigned `EncryptedSize` says.
**Code.**
- `open` refuses recorded block sizes beyond the cap before any request.
- `listBlocks` refuses a listing that goes beyond the cap.
- The cap is a `client.maxFileBlocks` field, initialised from the constant, so a test can lower it, as `adoptWait` is lowered.
**Tests.** The endless-pages transport of `TestDownloadEndlessBlockList` became the helper `endlessBlockList`, now reused. New `TestDownloadBlockCap`:
- with the cap at 45 and `EncryptedSize` at 2^50, the listing stops at 3 pages or fewer with `errIntegrity`, and nothing is released;
- recorded sizes beyond the cap fail with zero requests.
**Mutation.** With the listing cap removed, the test runs until the 40 s timeout.
**Docs.** Spec 6.4 sentence added.
### 5. Config storage restore (2a1a8f046, e7e43095d)
The cleanup now restores `config.Data()` with `SetData` before restoring the path, because `SetData` is a no-op while the path is empty, and asserts that the storage was restored.
**Mutation.** Without `SetData`, the assertion fails.
### Verification (this session)
- `go build ./...`: OK. `go vet`: OK. `golangci-lint`: 0 issues.
- Full `RCLONE_CONFIG=/notfound go test -race ./backend/protondrive/...`: ok, protondrive in 314s; protontest and protonfake also ok. This includes the TestMain legacy-route check and every `TestBudget*`. No budget changed.
- Frozen-fixture and differential tests (`TestFrozenFixtures`, `TestFrozenFileFixture`, `TestOldLibraryFileReadable`, `TestNewWriterOldReader`) and `TestIntegrationFake`: PASS. Old-library files are read without the flag.
- Security tests with `-race -count=3`: all 24 tests and subtests PASS 3/3 (18 TestHostileServer subtests, 54 subtest passes), ok in 151s.
### Concerns
1. **Wider inheritance.** Inheritance now covers all children of an unverified folder, not only anonymous ones (see 1). A genuine folder whose own key fails to verify, such as one signed by a since-deleted address, now locks its whole subtree, reads included, behind `ignore_signature_errors`. That is broader than round 1, and the DECISIONS consequence says so.
2. **Root check needs a live account.** The root-of-trust check assumes that Proton's My Files share has `CreatorEmail` set, and the root a `SignatureEmail`, equal (ignoring case) to one of the user's own addresses. This should be confirmed on a live account (Task 21). If the check is wrong for some real accounts, those users would have to set `ignore_signature_errors`.
3. **Memory before the cap.** A hostile server can still make a full read hold up to about 200 MiB of block metadata before the cap stops it.
4. **Spec section.** The foreign-signer limit went into spec section 7 (Security), not section 8 (Constraints), because 7 holds the threat model.
## Fix round 3 (base c28077174)
Status: DONE_WITH_CONCERNS
### Commits (nothing existing rewritten)
- `15bc60ead protondrive: verify your own address in any letter case with your own keys` (1, CRITICAL)
- `52a44e41c protondrive: log an error for each unverified file or folder used with ignore_signature_errors` (2). This commit also carries a gofmt import-order fix to `account_internal_test.go`, which 15bc60ead left unformatted; that file is test-only.
- `8666b6603 protondrive: stop at a folder the server reports as its own ancestor` (3)
- `0cd3af429 protondrive: return only the bytes asked for when a range covers the whole file` (4)
- `9c90998f7 protondrive: correct the memory a full read may hold and note what remains unbounded` (5)
### 1. Own address in another case (CRITICAL)
**Code.**
- One rule everywhere: the new `emailKey` lowercases ASCII letters only.
- Proton treats addresses case-insensitively.
- Unicode folding (`strings.EqualFold`) is deliberately avoided: it maps the Kelvin sign U+212A to k and the long s U+017F to s.
- `byEmail` compares `emailKey`s, and `owns` is exactly `byEmail(e) != nil`.
- `publicKeys` takes the local keys whenever `byEmail` matches, at the root and below, and only then looks at the server. So an own address never enters the foreign cache.
- The foreign cache is keyed by `emailKey`, so `A@x` and `a@x` share one lookup.
**Fake.**
- `ForgeShareCreator` and `ForgeRootSigner` now take an email.
- New `ServeKeysAs(email, owner)` makes keys/all serve the attacker's keys for any email.
- New `SeedFolderSignedAs` and `SeedFileSignedAs`.
- Fake test `TestServeKeysAs`.
**Tests.**
- Share creator, root signer, folder signer and file signer as `USER@PROTON.TEST`, with the attacker's keys served for that email: each is refused (zero bytes, error names `ignore_signature_errors`), with zero keys/all requests.
- Share creator and root signer as `uſer@proton.test` (long s): foreign, so refused with "not one of your addresses". Below the root a foreign signer is the documented protocol limit, so it isn't tested there.
- Genuine mixed case: user `Mixed@Proton.Test`, with nodes signed as `mixed@proton.test`, `MIXED@…` and `mixed@PROTON.test`. Reads succeed and keys/all requests are 0.
- `TestSameEmail`: covers Kelvin, long s, É.
- `TestPublicKeysCached`: case variants share one lookup, and the own address in capitals is never fetched.
**RED and mutations.**
- Before the fix, all 7 new hostile subtests failed. Every forgery read without error.
- With the cache keyed by the raw email, `TestPublicKeysCached` fails.
- With `byEmail` exact, all 5 capitals and mixed-case cases fail.
**Docs.** DECISIONS entry "Proton Drive matches email addresses by ASCII case only".
### 2. ERROR log for what the flag lets through
**Code.** New `reportUnverified(op, node, consequence)`, once per operation and node per Fs, via `reportOnce`. It names the operation, the node's name and ID, and the reason its key is unverified; it logs no secrets. It is called from:
- `contentKey` ("reading");
- `targetFolder` ("writing into"; consequence: whoever made its key can read what is written);
- `checkVerified` ("moving", "copying", "renaming"; consequence: "it is trusted from now on"). `encryptForNewParent` now takes the operation name.
**Test.** "what signature checks off let through on unverified nodes is logged":
- two reads, two uploads into `forged`, a copy and a move out;
- each ERROR appears exactly once, with ID and operation, "trusted from now on" appears, and there are exactly 4 such lines;
- a verified file that is read and copied never appears.
**Mutation.** With the `checkVerified` log removed, the test fails.
**Docs.** DECISIONS entry "Proton Drive logs what ignore_signature_errors lets through", with the plain consequence: with the option set, moving or copying out of an unverified folder makes the node trusted from then on.
### 3. Parent cycles
**Code.**
- `getNode`, `fetchNode` and `folderKeysOf` gained `…In` variants that carry the chain of descendants being fetched.
- A node already in the chain is an `errIntegrity` ("the server reports it as its own ancestor").
- A chain deeper than `maxFolderDepth = 2048` is an `errIntegrity` too. 2048 levels of one-letter names fill Linux's 4096-byte PATH_MAX, so no usable path is deeper; Proton also refuses deep nesting itself (code 200301).
- The limit is a client field, so tests can lower it.
**Fake.** New `SetParent(id, parentID)`.
**Tests.**
- `TestParentCycle`: A↔B and a self-parent each give an error after at most 3 metadata requests.
- `TestFolderDepthLimit`: a chain of 6 errors at depth 4 and works at depth 6.
**Mutations.**
- Without the cycle check, `TestParentCycle` only stops at the depth limit, after 42 s and thousands of requests, and fails. Without either check the recursion would be unbounded.
- Without the depth check, `TestFolderDepthLimit` fails.
### 4. Whole-file range keeps the requested count
**Code.**
- `client.open` now delegates to `openRead(…, full)`.
- New `openWhole(ctx, n, limit)` lists and verifies the whole file, then serves only `limit` bytes (`remaining`).
- `Object.Open` uses `openWhole` for an offset of 0 with a known size and a limit of at least the size.
**Fake.** New `ClaimSize(id, size)` re-signs the xattrs with a smaller Size.
**Test.** "a range covering the whole of a file claiming a smaller size": `{0,-1}` and `{0,99}` on a 4 MiB file claiming 100 bytes return exactly those 100 bytes. RED: 4194314 bytes were returned. The swapped-blocks range test still releases zero bytes.
**Mutation.** With `openWhole` not verifying, the swapped-range test fails.
**Docs.**
- TODO: an unknown-size `bytes=0-N` stays a ranged read.
- Spec 6.4 wording updated.
### 5. Docs
- The memory figure is corrected to about 250-350 MiB at the cap, with tokens, URLs and API bodies unbounded, in both the code comment and spec 6.4. Round 2's report said "about 200 MiB", which was too low.
- TODO: an API body and field size limit.
- TODO: confirm the root-of-trust rule, and count failing folders, on a drive made by Proton's own apps (Task 21).
- The plan's Interfaces block now lists every new fake helper.
### Verification (this session)
- `go build ./...` OK; `go vet` OK; `golangci-lint` 0 issues.
- Full `RCLONE_CONFIG=/notfound go test -race ./backend/protondrive/...`: ok, protondrive 304s; protontest and protonfake also ok. This includes the TestMain legacy-route check and every `TestBudget*`, and no budget changed.
- Frozen-fixture and differential tests and `TestIntegrationFake`: PASS. Old-library files are read without the flag.
- Security tests with `-race -count=3`: 33 tests and subtests, each PASS 3/3 (27 TestHostileServer subtests).
### Concerns
1. Below the root, a signer that is genuinely foreign is still verified against keys the server serves. This is the documented protocol limit, so the long-s variant is only tested at the root.
2. Addresses that differ only in non-ASCII case are different addresses to rclone. If Proton folds non-ASCII case, a genuine address stored that way would be treated as foreign, and at the root that means refused. Task 21 should check this with a real account.
3. The new ERROR lines are once per node and operation per Fs. A long-running mount with the flag set logs one line per distinct unverified node it touches.
@@ -0,0 +1,49 @@
# Task 16 re-review: fix round 1 (f4105adfc..751a3ea35)
**Verdict: NOT ALL CLOSED.** A has a bypass through move/copy.
Ran: `go test -race ./backend/protondrive/ -run 'TestHostileServer|TestFrozen|Diff|TestOpen'`, which passed (39.6s). I also ran a probe test in a scratch copy of 751a3ea35; the real tree is untouched.
## Status per item
- **A: not closed.**
- Reads and Update are refused (`download.go:173`). Writes into the folder are refused (`nodes.go:877`).
- Inheritance works (`nodes.go:485-488`), and the trust state is held in `nodeKeys`. Neither the cache nor two nodes claiming one ID can upgrade it: `listable` checks `ParentLinkID` (`nodes.go:813`).
- The tests bite (mutation evidence plus my run). But they only check the destination. The source side is open; see Important 1.
- **B: closed.**
- Nothing is fetched before `verifyManifest` (`download.go:387-388`, `:403-410`).
- Every case asserts zero bytes released. `TestDownloadRefetchOtherHash` bites.
- Zero-block and one-page files, thumbnails and `countContent` (`protondrive.go:1188`) all use the verified path. So do legacy files without a manifest signature: refused unless the flag is set, now before the first byte.
- Closing the reader during listing cancels the context. No leak.
- **C1-C4: closed.**
- The rename is consistent everywhere.
- `IssuedSecrets(kinds)`: both log tests check every issued session token.
- `TestPasswordStoredObscured` now goes through `config.CreateRemote --obscure` (mutation evidence).
- The Update-refusal subtest is in.
- **C5: closed, race-free.** `refresh` reads the credentials and saves them under `refreshMu` (`session.go:301-304`). `logout` clears them under the same lock (`session.go:627`). A refresh that starts afterwards sees empty credentials, and fix 3 handles that.
- **Seam / global state:** fine. There are no new package-level variables.
## New findings
### Important
1. **A move or copy out of a forged folder turns server keys into trusted ones** (`nodes.go:1086-1092`). `move` and `copyNode` only check the destination (`:1107`, `:1148`). For a node with no signature email, the client signs the forged key's passphrase with the user's address key. After that the key verifies, and so does everything beneath it.
- Probe:
1. `SeedForgedFolder` plus an anonymous file.
2. `operations.Move("forged/victim.bin" → "real/victim.bin")` returns nil.
3. A fresh Fs then reads "server content" with no error.
- `DirMove` of `forged` → `real/moved` does the same to the whole subtree. Later uploads there are encrypted to the server's key.
- Fix: in `encryptForNewParent`, refuse when `n.keys.unverified != nil && !IgnoreSignatureErrors`. Add a TestHostileServer case covering Move, Copy and DirMove out of `forged`.
2. **A forged key signed by a foreign email the server vouches for passes** (`account.go:254-281`). `publicKeys` fetches other users' keys from the server.
- Scenario: a forged folder (or file) with `SignatureEmail: "x@proton.me"`, signed by a key the server serves for that email. Then `unverified` is nil, reads succeed and writes are allowed.
- The same works at the top: a My Files share with `CreatorEmail` set to a foreign address (`nodes.go:333`) and a root signed by it forge the whole drive.
- The SDK trusts server-served keys too (`addresses.ts:150`), so this is a protocol limit (no key transparency). But DECISIONS currently claims more than the code does.
- Fix: require the My Files share creator and the root's signer to be one of the user's own addresses (cheap). Document the foreign-signer limit in spec 7, TODO and DECISIONS.
### Minor
- **`bytes=0-` is read as a ranged read.** With a known size, `fs.FixRangeOption` turns End -1 into size-1 (`protondrive.go:1150`). So `full` is false (`download.go:96`) and the manifest is skipped. `serve http`/`webdav` clients send `Range: bytes=0-` routinely. Fix: in `Object.Open`, set `limit = -1` when `offset == 0 && limit >= size`.
- **Memory is bounded by an unsigned field.** For a file without `BlockSizes`, `maxBlocks` comes from the unsigned `EncryptedSize` and can reach 2^30 (`download.go:127-130`). Every listed block is now held in `listed`. Cap the count at an absolute limit.
- **A since-deleted address costs writes, not just reads.** A folder whose key was signed by a since-deleted address is now unverified, so writes into it are refused; that could be the root. Anonymous descendants become unreadable. Because inheritance is limited to anonymous children, this is proportionate, and the SDK only reports `keyAuthor` here. But the DECISIONS consequence should say that writes are refused too.
- **`shareKeys.unverified` is never read** (`nodes.go:339`). The root has no parent verify key, so nothing inherits it. Either use it (see Important 2) or drop it.
- **`TestPasswordStoredObscured` leaves global state behind.** It calls `configfile.Install()` and never restores the previous storage.
@@ -0,0 +1,64 @@
# Task 16 re-review, round 2 (12ce57ed3..e7e43095d)
**Verdict: NOT ALL CLOSED.** Item 2 can be bypassed: the server only has to change the case of the user's own address (Critical below).
What I ran: one probe test, in a scratch worktree at e7e43095d, which I have since removed. The main tree is untouched.
## Status per item
1. **Move / Copy / DirMove / rename: closed.**
- `checkVerified` is called from `encryptForNewParent` (nodes.go:1105) and from `rename` (:1041).
- Inheritance covers every child (:502-507), and `createFolder` inherits too (:989).
- The tests bite, going by the mutation evidence.
- `Update` is refused through `contentKey` (download.go:183). So is the upload-conflict path (`createRevision`, upload.go:306).
- Only a forged destination is refused by `targetFolder` (nodes.go:899).
- Trash, Purge, Rmdir and Remove only send IDs and need no trust; that is correct. There is no restore operation.
2. **Root of trust: NOT closed.** See Critical 1. The tests only forge an exact foreign email.
3. **`bytes=0-`: closed** when the size is known (protondrive.go:1165). The detection uses the signed `X.Size` and is independent of `original_file_size`. See Minor 3 for when the size is unknown.
4. **Block cap: closed for the block count.**
- The cap is checked before each append (download.go:380), and the recorded-sizes check happens before any request (:126).
- `TestDownloadBlockCap` bites (EncryptedSize 2^50, maxBlocks 2^30).
- See Minor 2 on the memory claim.
5. **Config storage restore: closed.**
## New findings
### Critical
1. **The own-address check compares case-insensitively but looks keys up exactly** (account.go:246-253 vs :235-243 and :267).
- The attack: the server sets `CreatorEmail` to `USER@proton.me`. `owns` is true. `publicKeys` misses `byEmail` and fetches `/core/v4/keys/all` from the server, which returns the attacker's key. `verifyErr` is nil, so the share is trusted (nodes.go:332-347). The root's `SignatureEmail` check (:362) has the same hole.
- Probe: `ForgeShareCreator(other)`, then a transport that uppercases `CreatorEmail` and serves other's keys for it. `shareKeys.unverified` and `root.keys.unverified` are both nil, and a file reads without the flag.
- Unicode folding widens the hole (Kelvin sign K ≡ k).
- Below the root, a case-variant of the user's own address is likewise checked against keys from the server.
- Fix: one comparison rule for both functions. `owns(e)` should be `byEmail(e) != nil`, with `byEmail` matching case-insensitively, so lookups for the user's own addresses never reach the server. Add case-variant subtests.
### Important
1. **With the flag set, laundering is silent and permanent** (nodes.go:1080, :899).
- Moving a node out of `forged` re-encrypts its passphrase. Anonymous nodes are re-signed by the user, and user-signed nodes keep their signature. A fresh Fs then fully trusts the node, including server-made content whose manifest is signed by the node key.
- Nothing is logged, yet spec 6.4 says the flag downgrades failures "to a logged ERROR". DECISIONS doesn't mention it either.
- Fix: when the flag is set, log an ERROR once per node in `checkVerified` and `targetFolder`. Add a DECISIONS consequence.
### Minor
1. **A cycle of parents recurses without bound.** `fetchNode` calls `folderKeysOf`, which calls `getNode`, which calls `fetchNode` (nodes.go:667, :619). A server reporting A→B→A causes unbounded recursion and eventually a fatal stack overflow, killing the mount. This predates this round. Fix: a depth limit.
2. **The memory figure is soft.**
- With realistic tokens and URLs (~550 B per block, plus slice doubling), 2^19 blocks needs about 250-350 MiB, not 200.
- API responses have no size limit, and `Token`/`BareURL`/`Thumbnails` are unbounded, so the cap limits the count, not the bytes, against a hostile server.
- Add a TODO entry for a response-body limit.
3. **Some whole-file ranges skip the full read.**
- If the size is unknown, `bytes=0-N` with N ≥ the size remains a ranged read.
- If `X.Size` understates the content (an owner-signed client bug, e.g. Size omitted while SHA1 is set), `limit=-1` returns more bytes than the range asked for.
- Better: pass "verify" separately and keep `remaining`.
## Proportionality
Blocking reads of user-signed children is technically justified:
- Whoever holds a forged parent key decrypts the child's passphrase, and with it the node key and content session key.
- With those they can forge AEAD blocks that ranged reads, which don't check the manifest, would accept.
Allowing full reads only (manifest by the user's own address) while refusing ranged reads would be sound, but it adds complexity.
The SDK shows real-world signature quirks, but they are content-key and date fallbacks, which rclone already mirrors, plus failure telemetry. There is no evidence that failing folder keys are common. The realistic false positive is a folder signed by a since-removed custom-domain address, and the error names that folder.
Judgement: keep the rule for tier 1. In Task 21, count failures on a live account. If they occur, add a narrower option (full reads only, beneath a named folder) rather than steering users to the blunt global flag.
@@ -0,0 +1,101 @@
# Task 16 re-review, round 3 (c28077174..9c90998f7)
**Verdict: ALL CLOSED.** I found no bypass of the trust root. There are three new Minor findings and no Critical or Important ones.
What I ran, in a scratch worktree at 9c90998f7 (since removed; the main tree is untouched):
- a probe that injects address keys and user keys;
- one mutation (`byEmail` exact);
- a probe of a 2050-deep folder chain.
## Status per item
### 1. Trust root (CRITICAL): closed
**One rule everywhere.**
- `owns` is `byEmail(e) != nil` (account.go:261-263).
- `publicKeys` takes the local keys whenever `byEmail` matches, before any cache or fetch (account.go:277-279).
- The foreign cache is keyed by `emailKey` (account.go:281-296).
- `account.addresses` is only written in `loadAccount` (account.go:91), so no concurrent path or cache entry can send an own address to the server.
**Every email that selects keys goes through `publicKeys`.**
- The share creator (nodes.go:340).
- Through `verifyKeys` (nodes.go:433-439):
- node `SignatureEmail` and `NameSignatureEmail` (nodes.go:483, :489);
- the revision signer for xattrs (nodes.go:562);
- the manifest signer, whose hashes cover thumbnails and blocks (download.go:380, :455).
- `fetchPublicKeys` has no other caller.
- The content-key fallback uses the account's address keys, not an email.
- Upload, conflict, copy and move only write `c.addr.Email`.
**Normalisation edge cases.** None of these is own:
- leading or trailing whitespace;
- a trailing dot;
- `%`-encoding or `+tag`;
- NUL or control bytes;
- `Name <a@b>`;
- NFC/NFD or IDN variants.
Each is therefore foreign: refused at the root ("not one of your addresses"), and checked against server keys below it, which is the documented protocol limit. Because `owns` is defined by `byEmail`, nothing can be "own" but resolved through the server, or the other way round.
The empty email is the only quirk:
- If the server renamed a genuine address to `""`, `owns("")` would be true.
- `verifyKeys("")` still uses the anonymous fallback, which is the share key for the root.
- The server can't sign with the share key, so this can't be exploited.
**Address keys.**
- A token's signature must verify against `userKR` (account.go:186-198), and `userKR` holds only keys that unlock with the password-derived key (account.go:108).
- Probe results:
- a token encrypted to the user key and signed by an attacker key: refused ("No matching signature");
- a token with no signature: refused;
- a legacy key that isn't locked: refused ("key is not locked", since keyPassword is non-nil);
- a key locked with an empty passphrase: refused;
- the same holds for injected user keys.
- So the server cannot add an address with keys it made. It can only relabel the `Email` of genuine addresses, which is unsigned. That maps one of the user's own keys to another email: denial of service, not forgery.
- `TestLoadAccountBadAddressKeySignature` covers the corrupt-signature case.
**Volume creation** signs with the primary address (nodes.go:395-445). The server derives the creator from `AddressID`.
**The tests bite.** With `byEmail` exact, all 4 "in capitals" subtests and "own address stored in mixed case" fail.
### 2. ERROR log: closed
- `reportOnce` writes `c.reported`, a per-client map guarded by `c.mu` (nodes.go:598-607). It is not package-level and has no race.
- DECISIONS states the laundering consequence.
- The test checks exact counts.
### 3. Parent cycle and depth limit: closed
- Depth is counted per resolution through the `below` slice, copied with a full slice expression (nodes.go:544), so there is no aliasing.
- A cached node or the root ends the walk.
- Go stacks grow, so the recursion of about 3 frames per level is safe at 2048.
- Probe of a real 2050-deep chain at the default limit:
- depth 2047 resolves with no error (56 s against the fake, mostly its crypto);
- depth 2050 returns errIntegrity "folders nested more than 2048 deep".
- See Minor 3 on the size of that error.
### 4. Whole-file range: closed
- `last=-1` lists every block, and the manifest is verified before any `fetch` (download.go:426-435).
- `remaining` truncates the read and returns EOF at 0 (download.go:567-583).
- `Close` cancels the download and waits for its goroutines, so nothing leaks.
- `FixRangeOption` turns `{0,-1}` into a whole-file range, so the test exercises `openWhole`.
### 5. Docs: closed
The spec 6.4 memory figure, the download.go:259-267 comment and the three TODO entries are all corrected.
**Seam:** protondrive.go only calls `openWhole`. No new package-level mutable state; `maxFolderDepth` is a const plus a client field.
## New findings
### Minor
1. **`Update` of an unverified file is logged as "reading", with no consequence** (upload.go:306 to download.go:196-200).
- Scenario: with the flag set, a read and then an overwrite of `forged/x` logs only the read. The overwrite writes new plaintext to a key the server made, and no ERROR says so.
- Fix: give `contentKey` an operation, and log "updating" with "whoever made its key can read what is written".
2. **The `below` chain is copied at every level.** At depth 2048 that is about 33 MB of transient slices, plus 2048 sequential requests. This is acceptable for a hostile-server bound. Optional fix: a map, or a linked list of `(id, parent)`.
3. **The depth error is about 88 KB long** (nodes.go:533, :546). Each level wraps the error in another "get node <id>: ", so a hostile server can fill the log with about 88 KB per lookup.
- Fix: return the integrity error without wrapping per level, for example by not wrapping an error that already wraps errIntegrity from `fetchNodeIn`, or by wrapping the parent error only once at the top.
The `c.reported` map grows by one entry per node and operation for the life of the mount, like the existing decrypt and verify entries. That is acceptable.
@@ -0,0 +1,116 @@
# Task 16 review: security tests (860081538..f4105adfc)
### Spec Compliance
- ❌ Issues found:
- Spec 9.6 says each hostile case "must produce an integrity error, not data", and the brief says "never file data". For "swapped blocks" and "older revision content under the current revision ID", the test explicitly allows the whole wrong plaintext out before the EOF error (`security_test.go:116-121`, `:131-136`, `released` asserted at `:170`). In "truncated block list" a genuine 4 MiB prefix is released. This matches spec 6.4 (manifest at EOF), so the brief contradicts the spec. The implementer flagged it honestly (Concern 1), but as delivered, 9.6 is not met. See the Concern 1 recommendation below.
- Fix 1 (606580196) claims "refuse to read a file whose key isn't signed by its owner". A forged parent folder bypasses that guarantee completely (Important 1).
- ⚠️ Cannot verify from the diff:
- Whether a date exists before which Proton nodes carried no `NodePassphraseSignature`. `contentKey` now refuses those with `errSignatureMissing` and has no legacy exemption, unlike names (`nameSignedSince`) and hash keys (`hashKeySignedSince`). Old rclone (`go-proton-api@v1.0.4/link_types.go:78`) also hard-failed on these, so this is not a regression against v1.76. The controller should confirm on a real account (Task 19/live).
- Live behaviour for files signed by a since-removed or rotated address key. The DECISIONS entry acknowledges this case.
### Strengths
- All three production fixes were found by writing tests first, and each landed in its own commit.
- Fix 1 is correct as far as it goes:
- `contentKey` gates both `open` (`download.go:87`) and `createRevision` (`upload.go:306`). So a hostile key also can't be used to encrypt a new revision to the attacker. This is a confidentiality win the commit doesn't mention.
- Anonymous files still read (`download_internal_test.go:196`, parent-key path `nodes.go:446-452`).
- The frozen old-library fixtures still read: `TestFrozenFixtures` and `TestFrozenFileFixture` PASS, run by me.
- With `ignore_signature_errors=true` the file reads. Hash, size, count and AEAD checks stay on.
- The SDK only reports `keyAuthor`. The old library refused outright, using only the user's own address keys. The new behaviour sits between the two, and DECISIONS records it.
- Fix 2: `session.reconnect` is a per-session field, written in `connect` (`protondrive.go:516`) on a session `NewFs` has just created (`protondrive.go:658`), before any call. There is no package-level state and no race. The value is a remote name, not a secret.
- Fix 3: re-checking `creds.UID` after `refresh` (`session.go`, the `call` hunk) closes the empty-header retry. `TestCallDuringLogout` reproduces it deterministically, with a delayed 401 and a request count.
- TestHostileServer is thorough:
- Each content case asserts that `NewObject` succeeds, that `Read` errors, a released-bytes bound, released != content, `CopyFile` fails and leaves the destination empty, and the same again with signatures ignored.
- Six recorded mutations show the test bites (M1, M2, M3, M5, the passphrase check, the refresh-token log line).
- The names case checks prefix, leaf, count, empty hostile subfolders, and that sync writes nothing outside `dst`. Seeding `../x` at the root, so a naive sync would write to `<tmp>/x`, is a good touch.
- TestNoSecretsInLogs has a real positive control: `require.Contains(run.log, "refreshing session")`, plus `HTTP REQUEST` for the dump subtest. So it can't pass vacuously. It also searches for base64, raw-URL base64 and hex forms, refreshed tokens included via `m.values`, issued storage tokens from the fake, and node passphrases and session keys for 3 files.
- `export_test.go` is minimal, test-only (`_test.go`), and has one function.
- The new fake modes go through `changeActiveRevision`/`Corrupt` and keep the strict checks. The fake still imports only `api`.
### Issues
#### Critical (Must Fix)
None.
#### Important (Should Fix)
1. **A forged folder key lets a hostile server serve a fully verifying forged file. Fix 1 is bypassable.**
- Where: `nodes.go:446-452` (`parentVerifyKR = parentKR` for anonymous children), `nodes.go:475-488` (`keyErr` stays on the node), and `rememberKeys`/`folderKeyRing` (folder keys are cached and used whatever their `keyErr`).
- Scenario:
1. The server inserts a folder link into a real folder. Its node key and passphrase are the server's own, encrypted to the parent's public key. Its passphrase signature fails, so it is listed with one ERROR (by design, spec 6.2).
2. Inside it, the server creates a file with `SignatureEmail == ""` (anonymous).
3. Every check on that file then verifies against keys the server holds:
- passphrase signature: the forged folder key, via `parentVerifyKR`;
- name signature: `nameVerifyKR = parentKR`;
- content key packet signature: `joinKeyRings(nodeKR, …)`;
- revision xattrs: `verifyKeys("", keys.keyRing)`;
- manifest: `verifyKeys("", r.n.keys.keyRing)`.
4. `n.keyErr` is nil, so `rclone copy`/`sync` reads attacker content with no error at all.
- Writes into that folder also encrypt new names and passphrases to the server's key. That is pre-existing, but it is the same root cause.
- Fix: record the key's verification state in `nodeKeys`, for example an `unverified error` set from `keyVerifyErr`. In `decryptNode`, when the child's key or name was verified with the parent's key (`SignatureEmail == ""`) and the parent's keys are unverified, set `n.keyErr` to the inherited error. That makes reads (and `createRevision`) refuse unless `ignore_signature_errors` is set. Consider also refusing to create children in a folder whose own key is unverified.
- Test: a fake helper such as `Corrupt(folder, "forge-key")`, or a `SeedForgedFolder` that re-keys a folder with a server-made key and seeds an anonymous child. Add it as a `TestHostileServer` case.
2. **Spec 9.6 / brief not met for same-size swapped and rolled-back blocks.**
- Where: `security_test.go:116-121`, `:131-136`, `:170`; `download.go` `listBlocks`.
- The test codifies the gap (`released: 2*blockSize+10`) instead of failing on it. Either the code changes (recommended, see below) or the spec owner amends 9.6 to match 6.4. It shouldn't be left as a test which asserts the weaker property.
#### Minor (Nice to Have)
- `security_test.go:131` ("older revision content under the current revision ID"): the fake's `rollback` keeps the current manifest and xattrs. A real server can also serve the old manifest and xattrs, all validly signed. That is undetectable: nothing signed binds content to a revision ID, and the SDK can't detect it either. Say so in the test comment and in TODO.md, so the case name doesn't overclaim.
- `security_test.go:392-396`, `:423-430`: tokens issued but never saved are classed as "storage tokens", which the `dump headers` subtest doesn't check. Classify them by kind (`IssuedSecrets` could return labelled secrets).
- `TestPasswordStoredObscured` feeds an already-obscured value. Only the `IsPassword` assertions test real storage behaviour. Acceptable, but weak.
- No test covers the new refusal of `Update` or overwrite of a file whose key signature failed (`upload.go:306`). Add one assertion.
- `session.go` `refresh`: a refresh in flight when `logout` saves empty credentials can still `setCredentials` and `store.Save` its new tokens afterwards. Those tokens are dead after the server-side logout, but they are written back to the config. This is not an empty-header path. Fix: logout takes `refreshMu`, or refresh checks it hasn't been signed out before saving.
- `contentKey` refuses a file with no parent (a directly shared file, `parentVerifyKR == nil`), where the SDK skips verification. Out of tier-1 scope; note it in TODO.
- DECISIONS: the entry landed in f4105adfc, not in 606580196 or 59f0f31b3, against AGENTS.md. It also bundles two decisions (the refusal and the reconnect hint) in one entry, against "one entry per decision".
- `protondrive.go:515`: for on-the-fly remotes (`:protondrive,...:`) the hint names the bare backend name. Cosmetic.
### Concern 1: verify the manifest before releasing data
**(a) The data is already there.**
- Every `GET …/revisions/{id}?FromBlockIndex&PageSize` page returns each block's `Hash`. Page 1 also returns the thumbnails' hashes and `ManifestSignature`.
- The manifest is the signature over thumbnail hashes followed by block hashes, in order (`verifyManifest` in crypto.go; SDK `fileDownloader.ts:187-244`).
- So list every page, verify the manifest over the listed hashes, then fetch. `fetchOnce` already compares each block's SHA-256 with its listed hash. With that, no byte is released before the manifest is verified.
**(b) What it closes, and what it can't.**
- **Closed, for full and ranged reads alike** (provided ranged reads list the whole file too):
- Swaps. A reordered list fails the manifest; reordered bodies fail the hash check.
- Truncation. The manifest fails, and the count check moves before release too.
- Foreign blocks. The hash would have to change, so the manifest fails; AEAD fails anyway.
- Block-level rollback. Old hashes don't match the current manifest.
- **Not closed: a whole-revision rollback.** Here the server serves the old revision complete under the current ID: old block list, old manifest, and old xattrs in the link's `ActiveRevision`.
- Neither the manifest nor the xattrs signature covers the revision ID, link ID or a counter.
- The SDK verifies only hashes against the manifest (`iterateRevisionBlocks`, `verifyManifest`) and can't detect it either.
- It is a protocol limitation: document it and raise it with Proton. A weak heuristic, such as xattr `ModificationTime` vs the link's `ModifyTime`, isn't worth it.
**(c) Cost.**
- **Full reads:** no extra requests (the same pages, fetched before the blocks instead of interleaved).
- **Latency before the first byte:**
- zero extra round trips for files of 20 blocks or fewer (80 MiB at `blocksPerPage = 20`, the SDK's value);
- otherwise ceil(N/20)-1 sequential requests: about 12 for 1 GiB, about 1280 for 100 GiB (minutes, unless pages are fetched in parallel under the existing 15-request metadata limit).
- **Memory:** the hashes are already kept today (32 B/block). Keeping the `api.Block` URLs and tokens adds a few hundred bytes per block, about 10 MB for 100 GiB. The alternative is to keep only hashes and re-request URLs, which doubles the requests.
- **Token expiry:** tokens may expire before late blocks are fetched. `fetchBlock` already re-requests a URL once after a 404.
- **Ranged reads:** listing the whole file on every `Open(range)` is costly for mount, serve and multi-thread copy (chunks × pages). It would want a bounded per-(node, revision) cache of verified hashes.
**(d) Difficulty.**
- Nothing in download.go makes it hard. `listBlocks` currently interleaves `r.fetch` with paging. The change is two phases in the same function:
1. Collect `[]api.Block` plus hashes, with the index, count and size checks.
2. Run `verifyManifest` (which already handles signer lookup and `ignore_signature_errors`).
3. Loop over `r.fetch`.
- `list`, `fetch`, the `slots` pipeline and `Read` are unchanged. For verify mode, the `last`-bounded page size is simply not used.
**Recommendation: do it now for full reads; defer ranged reads.**
- It is a small, local change and costs no extra requests.
- It closes exactly the `rclone cat` / streaming-mount / `serve` exposure the implementer found, and it makes 9.6 true for full reads.
- Then tighten `released` to 0 for every content case except whole-revision rollback, which stays documented.
- Defer ranged-read verification until a verified-hash cache is designed. Keep spec 6.4's "ranged reads cannot verify" documented as it is. Parallel page fetching for very large files can come as a follow-up.
### Assessment
**Task quality:** Needs fixes
**Reasoning:**
- The tests are strong and the three fixes are correct within their scope.
- But fix 1's guarantee is bypassable through a forged folder plus an anonymous child (Important 1).
- And the hostile-content tests encode, rather than close, the "data before integrity error" gap that spec 9.6 forbids (Important 2).
@@ -0,0 +1,30 @@
### Task 17: Request budgets and fuzzing
**Files:**
- Create: `backend/protondrive/budget_test.go`
- Create: `backend/protondrive/fuzz_internal_test.go`
- [ ] **Step 1: Write budget tests**, using `Requests()` deltas after
`NewFs` (exclude bootstrap by `ResetRequests()`):
- `TestBudgetList`: folder of N in {0, 1, 100, 101, 250} files with page
size 150 costs at most `P + ceil(N/100) + 1` requests (P = child
pages).
- `TestBudgetWarmList`: second `List` costs 0.
- `TestBudgetNoopSync`: tree of 5 folders x 40 files, already in sync;
`sync.Sync` cold costs at most `2*D + ceil(N/100) + 5` requests for
D = 6, N = 200, and uploads/downloads 0 blocks.
- `TestBudgetNewObject`: `NewObject` three levels deep, cold: at most 2
requests per level + 1.
- `TestBudgetNoLegacyRoutes`: after the whole `fstests` run, assert in a
`TestMain` hook that no counted route matches the deprecated patterns.
Record the measured values in a comment next to each bound.
- [ ] **Step 2: Write fuzz targets** seeded from `testdata/oldlib`:
`FuzzDecryptXAttrs`, `FuzzDecodeAPIError`, `FuzzDecryptForkPayload`,
`FuzzDecodeLink` (link JSON -> `node`), `FuzzValidateName`. Each must not
panic and must not return success with an invalid name.
- [ ] **Step 3: Run** `go test ./backend/protondrive/ -run 'TestBudget'` and
`go test ./backend/protondrive/ -fuzz FuzzDecryptXAttrs -fuzztime 30s`
(repeat per target) - fix findings.
- [ ] **Step 4: Commit** `protondrive: add request budget tests and fuzz targets`.
@@ -0,0 +1,320 @@
# Task 17 report: request budgets and fuzzing
Status: DONE_WITH_CONCERNS (all work done; the concerns are listed at the end)
## Commits (BASE 751a3ea35)
| SHA | Subject |
|---|---|
| 3fac25de7 | protondrive: don't list a folder again after each file or folder made in it (bug fix) |
| 32710eefd | protondrive: refuse metadata which decompresses to an excessive size (bug fix) |
| 90b2a48e7 | protondrive: skip nodes the server sends without an ID or with a negative size (bug fix) |
| 12ce57ed3 | protondrive: add request budget tests and fuzz targets (the brief's commit) |
## What was implemented
- `backend/protondrive/budget_test.go` (package `protondrive_test`): TestMain plus
TestBudgetList, TestBudgetWarmList, TestBudgetNoopSync, TestBudgetNewObject,
TestBudgetUpload (added, as asked), TestBudgetMkdir (added, see bug 1),
TestBudgetNoLegacyRoutes.
- `TestMain` (the package had none, in either package) runs `m.Run()`, then fails
the binary if `protontest.LegacyRequests()` is non-empty. The internal and
external test packages build into one binary, so the check covers every test,
internal ones and `TestIntegrationFake` included, through every fake server.
Checked by hand: a temporary test that requested `/drive/shares/x/links/y` made
the run print `FAIL: TestBudgetNoLegacyRoutes: removed share-scoped routes were
requested: map[GET /drive/shares/x/links/y:1]` and fail. I then deleted that test.
- `protontest/server.go`: new `LegacyRequests() map[string]int`, a process-wide
tally under a mutex, counted whatever the request's headers and left alone by
`ResetRequests`. It is test infrastructure and imports only `api`. Its test is
`TestLegacyRequestsProcessWide`. I added it to the plan's Interfaces block.
- `backend/protondrive/fuzz_internal_test.go` (package `protondrive`) has these targets:
FuzzValidateName, FuzzDecodeLink, FuzzDecryptXAttrs, FuzzDecodeAPIError (it also
fuzzes the `Retry-After` header through `retryAfter`) and FuzzDecryptForkPayload.
- Seeds come from `testdata/oldlib`: the `file.json`/`folder.json` names and
xattrs, decrypted with the fixture's node key, and the `file/{v6,legacy}`
links.
- Live-shaped seeds come from the fake at test time. A user holding the oldlib
v6 keys gets the stored oldlib file plus a folder, a file, an empty file, a
legacy file, an anonymous file and folder, a file claiming block sizes, and a
Doc. Their link JSON is fetched through `fetchLinks`.
- Hand-made hostile seeds: a 2^60 size, negative sizes, years past 9999,
nesting 20000 and 100000 deep, and 10000 block sizes.
- To make the helpers usable from `*testing.F`, these now take `testing.TB` instead
of `*testing.T`: `newTestClient`, `newTestClientOpt`, `newTestClientFor`,
`loadFrozenFile` and `sealForkPayload`.
## Budget table
All bounds were measured on the fake first and asserted at the measured value.
None is looser than the brief's bound.
| Budget | Brief's bound | Measured, per route | Asserted |
|---|---|---|---|
| TestBudgetList N=0 (page 150, Fs rooted at the folder) | P+ceil(N/100)+1 = 2 | children 1 + links 0 = 1 | ≤ P+ceil(N/100) = 1 (children ≤ P, links ≤ batches) |
| N=1 | 3 | children 1 + links 1 = 2 | ≤ 2 |
| N=100 | 3 | children 1 + links 1 = 2 | ≤ 2 |
| N=101 | 4 | children 1 + links 2 = 3 | ≤ 3 |
| N=250 | 6 | children 2 + links 3 = 5 | ≤ 5 |
| TestBudgetWarmList, caching default | 0 | 0 at N = 0, 1, 250 | `Empty` |
| TestBudgetWarmList, `enable_caching=false` | (cold paid again, not more) | identical to the cold map at each N | `Equal(cold, warm)` and ≤ P+ceil(N/100) |
| TestBudgetNoopSync, D=6, N=200, cold, modtime and `--checksum` | 2D+ceil(N/100)+5 = 19 | children 6 + links 6 = 12, both modes; no block transfer, draft or revision read | ≤ 2D = 12; 0 block transfers; 0 drafts (file and revision); no `/revisions/` route, so no content read under `--checksum` |
| TestBudgetNewObject a/b/c/file.txt, cold | 2/level + 1 = 7 | children 4 + links 4 = 8 | ≤ 2 x 4 folders listed = 8 (see concern 1) |
| TestBudgetUpload small.txt (cold, 1 block) | (new) | children 1 + links 1 (name check listing), draft 1, verifier 1, block URLs 1, block PUT 1, commit 1, links 1 (read back) = 8 | exact per route; total ≤ 8 |
| TestBudgetUpload big.bin (3 blocks, folder listed) | (new) | draft 1, verifier 1, block URLs 1-3 (batched as blocks are ready, so not fixed), block PUTs 3, commit 1, links 1 = 8-10; **children 0** | children 0, links 1, URLs in [1,3], PUTs 3, total ≤ 10 |
| TestBudgetUpload again.txt (1 block) | (new) | 6, children 0 | total ≤ 6 |
| TestBudgetMkdir x,y,z in one folder | (new) | children 1 + links 1 + create 3 = 5 | exact map |
| TestBudgetNoLegacyRoutes | none counted | none | per-server check in the test; process-wide check in TestMain |
Before bug fix 1, TestBudgetUpload measured big.bin at children 1 + links 2 = 12
and again.txt at 8, and TestBudgetMkdir at children 3 + links 3 + create 3.
## Bugs found and fixed (each test-first, each in its own commit)
1. **Request storm when making many files or folders in one folder** (3fac25de7).
Committing an upload, creating a folder or making a server-side copy dropped the
parent's cached listing. The next `Put` calls `NewObject` to check the name is
free, so it listed the whole folder again. Copying M new files into a folder of
N cost about M x (P + N/100) extra requests.
- Fix: a new `nodeCache.add`, which caches the new node and appends it to the
parent's listing if one is cached. The upload commit now uses `fetchNode`
instead of forget+`getNode`. Failure paths still forget, since a failed call
may have taken effect.
- RED: `TestNewNodesKeepListing` (internal; the warm list cost 2 requests) and
`TestNodeCacheAdd` (build failure). TestBudgetUpload and TestBudgetMkdir
failed as described above.
2. **Compression bomb in metadata** (32710eefd). Names, passphrases, hash keys and
xattrs are encrypted to public keys, and they were decompressed with no limit. A
server could send a few KB that expand to gigabytes.
- Fix: `decryptMessage` takes a `maxSize` and sets gopenpgp's
`MaxDecompressedMessageSize`. The limit is 64 KiB for names, passphrases and
hash keys, and 16 MiB for xattrs (an 8 TiB file's block list fits).
- The limit counts packet headers and signatures, so the test checks "limit
minus 1 KiB passes, limit plus 1 fails".
- The same commit makes a negative xattr `Size` read as no size. Before, a file
with a SHA-1 and `Size:-5` listed with size -5.
- RED: `TestDecryptedSizeBounded`: 4 assertions failed, no error was returned.
`TestXAttrs/NegativeSize` also failed.
3. **Nodes with an empty LinkID or a negative EncryptedSize were handed on**
(90b2a48e7). An empty ID gets cached under "", and a negative encrypted size
gives `Object.Size() < 0`. `decryptNode` now returns them as unreadable, so they
are skipped and logged, the same handling as an invalid name.
- RED: `TestDecryptNodeRefusesHostileValues` (both subtests returned a node).
The FuzzDecodeLink seed variants failed 16 seeds on "ID empty" and "size -5".
## Test evidence
- `go build ./...`: OK.
- `go vet ./backend/protondrive/...`: OK. `golangci-lint run ./backend/protondrive/...`: 0 issues.
- `RCLONE_CONFIG=/notfound go test -race ./backend/protondrive/ -run 'TestBudget' -v`:
```
--- PASS: TestBudgetList (33.78s)
--- PASS: TestBudgetList/0 (0.53s)
--- PASS: TestBudgetList/1 (0.59s)
--- PASS: TestBudgetList/100 (5.97s)
--- PASS: TestBudgetList/101 (6.00s)
--- PASS: TestBudgetList/250 (6.04s)
--- PASS: TestBudgetWarmList (27.37s)
--- PASS: TestBudgetWarmList/caching=true (6.85s)
--- PASS: TestBudgetWarmList/caching=false (12.58s)
--- PASS: TestBudgetNoopSync (14.71s)
--- PASS: TestBudgetNoopSync/checksum=false (3.94s)
--- PASS: TestBudgetNoopSync/checksum=true (4.01s)
--- PASS: TestBudgetNewObject (1.72s)
--- PASS: TestBudgetUpload (1.56s)
--- PASS: TestBudgetUpload/small.txt (0.30s)
--- PASS: TestBudgetUpload/big.bin (0.38s)
--- PASS: TestBudgetUpload/again.txt (0.13s)
--- PASS: TestBudgetMkdir (1.03s)
--- PASS: TestBudgetNoLegacyRoutes (1.32s)
PASS
ok github.com/rclone/rclone/backend/protondrive 82.671s
```
- Full `RCLONE_CONFIG=/notfound go test -race -timeout 30m ./backend/protondrive/...`
(TestMain's legacy-route check ran after the whole package):
```
ok github.com/rclone/rclone/backend/protondrive 303.630s
? github.com/rclone/rclone/backend/protondrive/api [no test files]
ok github.com/rclone/rclone/backend/protondrive/protontest 35.271s
ok github.com/rclone/rclone/backend/protondrive/protontest/cmd/protonfake 1.381s
```
- Fuzz runs, `RCLONE_CONFIG=/notfound go test ./backend/protondrive/ -run '^T$' -fuzz '^T$' -fuzztime 30s`.
No crasher, so nothing was committed under testdata/fuzz.
| Target | execs in 30s | new interesting |
|---|---|---|
| FuzzValidateName | 2,773,059 | 4 |
| FuzzDecodeLink | 269,592 (537,891 with `-fuzzminimizetime 5s`) | 8 (192) |
| FuzzDecryptXAttrs | 275,744 (133,249 with `-fuzzminimizetime 5s`) | 45 (133) |
| FuzzDecodeAPIError | 1,119,036 | 282 |
| FuzzDecryptForkPayload | 859,935 | 37 |
The progress lines of FuzzDecodeLink sit at "0/sec" for long stretches. A goroutine
dump of the workers shows they are in `workerServer.minimizeInput`: the default
minimisation time is 60s per new interesting input, and each run of this target
makes a call to the fake. It is not a hang. A per-iteration timer found no call
taking over 1s.
## Invariants each fuzz target asserts
- **FuzzValidateName**: an accepted name is never empty, never `.` or `..`, has no
`/` or NUL, and is at most 255 UTF-16 units.
- **FuzzDecodeLink**:
- Exactly one of node, unreadable or err comes back.
- A returned node has a non-empty ID equal to `LinkID`, a valid UTF-8 name, and
`IsFolder` matching `Type`.
- Non-root nodes pass `validateName`. A root's name is not validated by design
and is never shown.
- `size(true)` and `size(false)` are at least 0, and the xattr invariants below
hold.
- **FuzzDecryptXAttrs**: tries raw armored input and plaintext encrypted
(compressed) to the fixture key.
- Valid UTF-8 plaintext always decrypts; invalid UTF-8 is an error.
- Size ≥ 0; BlockSizes is nil or every entry is > 0.
- ModTime is UTC with a year in [-1, 10000]: RFC 3339's range, moved by a zone
offset of under a day.
- Extra never holds Common.
- **FuzzDecodeAPIError**:
- Always an `*api.Error` with Status equal to the HTTP status and a non-empty
message.
- `apiErrorCode` is consistent with the decoded Code.
- `retryAfter` is in (0, 60s].
- **FuzzDecryptForkPayload**: on success, an independent AES-GCM open with the key
and the "fork" AAD must succeed and give the same keyPassword. An error never
comes with a password.
## Concerns
1. **TestBudgetNewObject cannot meet the brief's 7.** Reading "three levels deep"
as `a/b/c/file.txt`, the path has four folders to list: the root, a, b and c.
Each listing is at least a children page plus a links batch, because Proton has
no lookup-by-name route. That makes 8. I asserted 8 and wrote the arithmetic in
the comment. Under the other reading, `a/b/file.txt`, the measured count would
be 6, which is ≤ 7. The cost also grows with the size of every folder on the
path, since `findChild` lists the whole parent.
2. **The brief's xattr invariant "block sizes sum to size or an error" is not a
parse-time invariant.** The SDK checks neither value against the other. The read
path checks each block against its recorded size (errIntegrity), and rclone
checks the total against `Size()`, so inconsistent attributes fail at read time.
This is documented on `checkXAttrs`.
3. **API error messages are not sanitised or bounded.** The code promises neither,
so the fuzz target doesn't assert it; the message can contain control
characters and be any length. It is logged in docs/TODO.md.
4. **Left in docs/TODO.md:**
- The address-key `Token` is still decrypted with no decompression limit
(account.go).
- `createRevision`, move, rename and trash still drop the parent listing.
`Put` over existing files re-lists; `sync` doesn't, because it uses `Update`.
- A file whose xattrs have a SHA-1 but no Size reports size 0.
- The event-decoding fuzz target from spec 9.5 is to be added with events.
5. **Runtime.** The race suite for the package now takes about 300s, of which the
budget tests are about 80s. Decrypting 100 nodes takes about 6s under -race.
TestBudgetWarmList uses N ∈ {0, 1, 250} instead of all five sizes to save time.
TestBudgetList keeps all five.
## Files changed
- backend/protondrive/budget_test.go (new)
- backend/protondrive/fuzz_internal_test.go (new)
- backend/protondrive/cache.go, cache_internal_test.go
- backend/protondrive/nodes.go, nodes_internal_test.go
- backend/protondrive/upload.go
- backend/protondrive/crypto.go, crypto_internal_test.go
- backend/protondrive/download_internal_test.go, testutil_internal_test.go (helpers now take `testing.TB`)
- backend/protondrive/protontest/server.go, server_test.go
- docs/TODO.md, docs/plans/001-protondrive-tier1.md (Task 17 boxes ticked; `LegacyRequests` added to the Interfaces block)
## Fix round 1 (BASE e7e43095d)
Review: task-17-review.md. Before editing I read the six Task 16 commits on
top of mine. The rule that a node inherits its parent's unverified state is
already met by what this fix inserts into a listing:
- Uploads and copies are read back through `fetchNode`, then `decryptNode`
with the parent's keys.
- `createFolder` now sets `keys.unverified = parent.keys.unverified`
(commit 787bb3b3f..e7e43095d).
### Commits
| SHA | Subject |
|---|---|
| f89be42ea | protondrive: don't cache a folder listing read while the folder changed (Important 1) |
| 18b4f7d1d | protondrive: don't list a file as empty when its recorded size is missing or invalid (Minor: negative Size) |
| 16480e3d8 | protondrive: refuse to write names which aren't valid UTF-8 (Minor: FuzzValidateName found a real gap) |
| c28077174 | protondrive: check warm listings within the listing budget test (Minor: runtime) |
### 1. Stale listing overwrote an inserted child (f89be42ea)
- **Cache change.** `nodeCache` keeps `fetching map[folderID]*fetchState{count, changes}`, which exists only while a listing of that folder is in progress:
- `startFetch` creates or increments the entry and returns `changes` as a token.
- `endFetch` stores the listing only if it is complete and `changes` still equals the token. It then decrements the count and deletes the entry at 0, so the map holds only in-flight folders.
- **What bumps the count.** Every mutator of a folder's children goes through `childrenChangedLocked`, which drops the listing and bumps the count:
- `invalidate` bumps the node's parent and the node itself as a folder. If the node isn't cached, so its parent is unknown, it bumps every in-flight listing.
- `invalidateChildren` bumps the folder.
- `put` and `add` bump both parents when a node changes parent.
- `add` also bumps its parent without dropping the listing, since it appends to it.
- `clear` bumps every in-flight listing.
- Task 16 round 2 added no cache mutators.
- **listChildren.** It takes the token after the cache miss and the folder-key lookup, before the first page. It calls `endFetch` on every path; failures pass complete=false. `setChildren` is now used only by tests.
- **Fake.** The fake gains a `Hold` / `Held` fault, tested in `TestFaultInjection/Hold`. The real handler runs on arrival, and the response is withheld until `Hold` is closed, so the response is as of arrival. This lets the test pause a listing between reading and caching with no sleeps.
- **RED.** `TestListingRacingChange` (internal) has subtests upload, mkdir, copy and trash. Each holds the listing's links batch, makes the change, releases the batch, then lists. Before the fix, `-race -count=3` failed all 12 runs:
- upload, mkdir and copy: `map[keep.txt trash.txt] does not contain "new"`.
- trash: `should not contain "trash.txt"`, so the trashed child came back.
- **GREEN.**
- `go test -race -run TestListingRacingChange -count=20` → ok (31s).
- `TestNodeCacheFetch` covers each mutator, two overlapping fetches, changes to other folders, a disabled cache, and that `fetching` is empty afterwards.
- **Budgets.** No budget changed. A listing is left uncached only when a change happened while it was in flight, and no budget test makes a change during a listing. All TestBudget* tests pass at the same bounds.
### 2. Negative or missing xattr Size (18b4f7d1d)
- `parseXAttrs` handles a missing, malformed or negative `Size` this way:
- If every block size is positive and no larger than `maxBlockSize`, the size is the sum of `BlockSizes`.
- Otherwise the new `xattrs.NoSize` is set, and `plaintextSize` reports the size as unknown. `size(true)` then falls back to the encrypted size, the same as for a file without extended attributes.
- Attributes built locally (`NoSize` false) are unchanged.
- Tests: `TestXAttrs/NegativeSize` has five cases: -5 with [5] gives 5; missing with [4 MiB, 3] gives the sum; "big" with blocks gives the sum; -5 with no blocks is unknown; a block of 2^60 is unknown. `MissingCommon` and `BadValues` now expect `NoSize`. `TestNodeWithoutXAttrs` checks that `NoSize` is reported as unknown.
- RED was a build failure, since there was no `NoSize`. Against the old parser the NegativeSize expectations of Size 5 and the sum would also fail.
- FuzzDecryptXAttrs asserts that `NoSize` implies Size 0.
- Removed the matching docs/TODO.md entry.
### 3. FuzzValidateName and UTF-8 (16480e3d8)
- Asserting `utf8.ValidString` failed at once on the existing seed `"\xff\xfe"`, a real gap: `validateName` accepted names that `decryptName` refuses, so such a file disappears from listings. That can happen with a custom `encoding`.
- RED: `TestValidateName` with `"a\xffb"` and a UTF-16 surrogate `"\xed\xa0\x80"` → "An error is expected but got nil". GREEN after adding the check.
### 4. TestBudgetWarmList runtime (c28077174)
- I folded it into TestBudgetList's subtests, so the warm listing uses the same fixture and nothing is re-seeded. The assertions are unchanged:
- warm listing is empty at all five sizes;
- with caching off, the first and the second listing each equal the cold map, at N = 0, 1 and 250.
- Under -race: TestBudgetList went from 33.8s to 45.6s, and TestBudgetWarmList (27.4s) is gone, a net saving of about 16s.
### Verification
- `go build ./...`: OK. `go vet` and `golangci-lint run ./backend/protondrive/...`: 0 issues.
- `RCLONE_CONFIG=/notfound go test -race ./backend/protondrive/ -run TestBudget -v`: all PASS (66.5s): List with subtests 0/1/100/101/250, NoopSync with both modes, NewObject, Upload with 3 subtests, Mkdir, NoLegacyRoutes.
- `-run TestListingRacingChange -race -count=20`: ok.
- `-run '^TestIntegrationFake$' -count=2`: ok (30.0s).
- Fuzz, 30s each: FuzzValidateName, FuzzDecryptXAttrs and FuzzDecodeLink all PASS with no crasher. They ran while the race suite was running, so exec rates were lower: 673k, about 24k+ and 321k execs.
- Full `RCLONE_CONFIG=/notfound go test -race -timeout 30m ./backend/protondrive/...`, which includes the TestMain legacy-route check:
```
ok github.com/rclone/rclone/backend/protondrive 322.536s
? github.com/rclone/rclone/backend/protondrive/api [no test files]
ok github.com/rclone/rclone/backend/protondrive/protontest 67.077s
ok github.com/rclone/rclone/backend/protondrive/protontest/cmd/protonfake 1.686s
```
The fuzz runs overlapped this run, which explains the longer times.
### Corrections to my first report
- "16 MiB fits an 8 TiB file's block list" was wrong, as the reviewer noted: 8 TiB is about 16 MiB of raw block sizes before JSON overhead. The code comment, 1 TiB ≈ 2 MiB, is right. Real files are far below the limit.
### Concerns
- `invalidate` of a node that isn't cached marks every in-flight listing out of date, because its parent is unknown. That is conservative: a concurrent listing elsewhere may go uncached and cost one extra listing later. It never serves stale data.
- I left the legacy-tally map as is, as instructed.
@@ -0,0 +1,159 @@
# Task 17 review: request budgets and fuzzing (751a3ea35..12ce57ed3)
Line numbers are at 12ce57ed3.
### Spec Compliance
- ✅ Spec compliant for the brief and the extra requirements. All five budget
tests exist, plus TestBudgetUpload and TestBudgetMkdir
(backend/protondrive/budget_test.go:93-344). Each bound equals the measured
value and is never looser than the brief's. The arithmetic is commented per
route. TestBudgetWarmList covers `enable_caching=false` (:123-154). TestBudgetNoopSync
seeds through the fake and asserts 0 block transfers, 0 drafts and no
`/revisions/` route under `--checksum` (:165-207). TestMain checks the
process-wide legacy tally after `m.Run` and exits non-zero (:34-42). The five
fuzz targets assert real invariants. Each of the 3 production fixes was made
test-first in its own commit. The NewObject bound of 8 follows the controller's ruling.
- ⚠️ Cannot verify from diff: the brief's "after the whole `fstests` run"
holds only if `TestIntegrationFake` is in this package's test binary. The
report says it is. The controller can confirm by grepping for
`TestIntegrationFake` in backend/protondrive/*_test.go.
### Strengths
- The budgets are tight and would catch the regressions that matter.
TestBudgetList (:107-109), TestBudgetNoopSync (≤ 2D) and TestBudgetNewObject
(≤ 8) each fail on one extra links fetch per file. TestBudgetUpload asserts
`children == 0` after the first file (:288) and TestBudgetMkdir asserts the
exact map (:312), so either fails on a re-list after each Put.
- TestBudgetWarmList with caching off compares `Equal(cold, warm)`. If the env
override were ignored, cold would be non-empty and warm empty, so the test
can't pass by accident. Each `fs.NewFs` builds a new client, so no cache
carries over between subtests.
- The legacy tally in protontest/server.go:39-57 is mutex-guarded. It is counted
before the header checks, so a request with bad headers is still counted.
`ResetRequests` doesn't clear it, and the fake still imports only `api`.
`TestLegacyRequestsProcessWide` covers two servers and checks that a copy is returned.
- The decompression limit is a streaming bound. gopenpgp's
`MaxDecompressedMessageSize` becomes go-crypto's `LimitedBodyReader` on the
compressed packet (go-crypto v1.5.2 openpgp/read.go:267). It does not read
everything and then check. Every `decryptMessage` caller passes a limit:
64 KiB for names, passphrases and hash keys, 16 MiB for xattrs.
- The fuzz invariants are real. FuzzDecryptForkPayload re-opens the payload with
an independent AES-GCM. FuzzDecodeLink checks that exactly one outcome comes
back, that the ID equals LinkID, the type, and the sizes. The seeds include
live fake payloads and hostile JSON. Under plain `go test` the seed corpus
runs in about 1.2s in total (checked).
### Issues
#### Critical (Must Fix)
None.
#### Important (Should Fix)
1. **backend/protondrive/cache.go:96-110 with nodes.go:704-779: a listing that
started before an insert overwrites it, and the stale listing now persists.**
`listChildren` reads the children pages, decrypts, then calls
`setChildren(parentID, all)` (nodes.go:779) without checking whether the
folder changed in the meantime. Take two listings of folder D started
together with a cold cache: two concurrent Puts with `--transfers 4` that
each check the name with `findChild`, or a VFS `List` racing a Put.
- Listing A finishes and caches L0.
- Put f1 commits and `add` appends f1 to L0.
- Listing B, whose pages were fetched before the commit, finishes and
`setChildren` replaces the listing with one that lacks f1.
The base code had the same window, but the next mutation in D dropped the
listing, so it healed. Now every later Put or Mkdir in D appends to the stale
listing, and f1 stays missing until a rename, trash or revision drops it.
Long-lived processes are hit: mount, serve, rcd. A later `NewObject("f1")`
reports not found, and an upload of f1 then fails with errExists.
Checked: I added a temporary internal test in a scratch worktree at
12ce57ed3. It puts a one-shot 2s `Delay` fault on the links route, starts
listing B, runs listing A, uploads f1, waits for B, uploads f2, then lists.
At HEAD, f1 is missing from the list (0 requests, served from the cache).
With backend/protondrive at 751a3ea35, the same test passes: the f2 upload
dropped the listing and it was re-listed.
Fix: keep a per-folder generation in `nodeCache`, bumped by `add`,
`invalidate`, `invalidateChildren` and `put` when a node moves. Read it
before `listChildren` fetches its first page, and have `setChildren` store
the listing only if the generation is unchanged. Otherwise drop the
listing, or merge in the nodes added since. Add the race test above as a
regression test, test-first in its own commit.
#### Minor (Nice to Have)
- crypto.go:835 with nodes.go:134-138: the commit says a negative `Size` reads
as "no size", but `plaintextSize` still reports `(0, true)` whenever a SHA-1
or BlockSizes is present. The TestXAttrs/NegativeSize file, with
BlockSizes [5], lists as 0 bytes, so a copy of it fails with a size
mismatch. Before the change it listed as -5, which rclone treats as unknown
size. Prefer the sum of BlockSizes, or `size(false)`. This is already in
docs/TODO.md.
- Report only: "an 8 TiB file's block list fits" in 16 MiB is wrong. 8 TiB at
4 MiB blocks is 2,097,152 entries × 8 bytes = exactly 16 MiB before the JSON
and packet overhead. The code comment (crypto.go:207, 1 TiB ≈ 2 MiB) is
right, and 100 GiB ≈ 200 KiB. Real files are far below the limit.
- fuzz_internal_test.go:102-111: FuzzValidateName mirrors `validateName` and
does not assert `utf8.ValidString`. `validateName` accepts invalid UTF-8
that `decryptName` later refuses. With the default encoding
(EncodeInvalidUtf8) this can't happen, but it can with a custom `encoding`.
Assert it, or reject invalid UTF-8 in `validateName`.
- protontest/server.go:39: `legacyRequests` is a process-wide, unbounded map
keyed by raw path. In the long-running `protonfake` binary, a client can grow
it without limit. It is test infrastructure, so this is acceptable. Key it by
the matched prefix if that matters.
- budget_test.go:123: TestBudgetWarmList re-seeds 251 files and repeats the
cold listing that TestBudgetList already measures. It takes about 27s of the
roughly 80s the budget tests add under -race. Folding the warm listing into
TestBudgetList's subtests, with caching on and off, would save most of it.
The rest of the ~300s suite is decrypting under -race, not this task.
- The listing race in Important 1 predates this change (base: forget, then the
late `setChildren`). Add it to docs/TODO.md if the generation fix is deferred.
### Checks run (one per named risk)
- Atomicity of `add`: the read-modify-write is under `c.mu`. `append` writes
only to a slice held in the map, and `children()` copies under the lock. No
data race.
- Re-insertion of a stale listing: `add` only appends to a listing that is
already cached, never creates one, and drops both listings on a parent
change (cache.go:102-108). This is correct.
- Failed or unknown uploads: upload.go:396-401 adds only when `ours`
(active and our revision). Otherwise it forgets the node and the parent
listing. createFolder and copyNode invalidate on error, and copyNode also
invalidates when the read-back fails (nodes.go:1176-1185). This is correct.
- New revision and replaced conflicts: `createRevision` invalidates the node
and its parent (upload.go:317). The conflict path invalidates the
conflicting link (upload.go:276), so `add` after the commit only caches the
node. There is no duplicate, because `add` checks `slices.Contains`.
- Whether the inserted node equals a fresh listing: for an upload or copy it
is decrypted by `fetchNode` → `decryptNode`, the same as a listing, with the
same verification state, MIME type, revision and xattrs. For createFolder
the node is built locally, as before. Only `ServerModTime` (local estimate)
differs from a fresh read, and that predates this change. The keys are
generated and signed by the address, so the key is not unverified.
- Skipped nodes (90b2a48e7): listings log them once with `fs.Errorf` and count
them in `skipped` (nodes.go decryptChildren). `fetchNode` returns an error.
`hasChildren` counts raw `LinkIDs`/`More` from the server (nodes.go:689-697),
so a skipped node can't make Rmdir think a folder is empty.
- Legitimate sizes under the limits: names are ≤ 1020 bytes, passphrases and
hash keys are tens of bytes, and signatures are at most a few KiB. All are
far below 64 KiB.
- Legacy routes in tests: no other test in backend/protondrive requests a
share-scoped legacy route on purpose, so the process-wide `assert.Empty` in
TestBudgetNoLegacyRoutes (:343) does not depend on test order.
### Assessment
**Task quality:** Needs fixes
**Reasoning:** The budgets, the fuzz targets and two of the three fixes are
sound and well tested. Updating the listing in place (3fac25de7) turns a
listing race that used to heal itself into a persistent stale listing. I
reproduced it at HEAD and it passes at base. It needs a generation check in
the cache before this task can be trusted.
@@ -0,0 +1,73 @@
### Task 2: Session - authenticated calls, refresh, server time
**Files:**
- Create: `backend/protondrive/session.go`
- Create: `backend/protondrive/session_internal_test.go`
- Create: `backend/protondrive/protontest/auth.go`
- Modify: `backend/protondrive/api/types.go`
**Interfaces:**
- Consumes: Task 1.
- Produces (`protontest`):
- `type User struct { Username, Password, MailboxPassword string; ID string; ... }`
- `func (s *Server) AddUser(username, password string) *User` - for now
creates the record only (keys arrive in Task 4).
- `type Session struct { UID, AccessToken, RefreshToken string }`
- `func (s *Server) NewSession(u *User) Session`
- `func (s *Server) ExpireAccessToken(uid string)`,
`func (s *Server) RevokeSession(uid string)`
- Route `POST /auth/v4/refresh`: single-use refresh tokens; a reused or
unknown token returns HTTP 422 code 10013.
- Every authenticated route returns HTTP 401 for an unknown or expired
access token.
- Produces (`protondrive`):
- `type credentials struct { UID, AccessToken, RefreshToken, KeyPassword string }`
- `type credentialStore interface { Load() credentials; Save(credentials) }`
- `type memoryStore` implementing it (for tests).
- `func appVersionFromRcloneVersion(v string) string`
- `func newSession(client *http.Client, baseURL, appVersion string, store credentialStore) *session`
- `func (s *session) call(ctx context.Context, opts *rest.Opts, request, response any) (*http.Response, error)`
- sets `x-pm-uid`, `Authorization: Bearer`, `x-pm-appversion`,
`Accept: application/vnd.protonmail.v1+json`; decodes a non-2xx or
non-1000-class body into `*api.Error`; on HTTP 401 refreshes and
retries the request once.
- `func (s *session) callUnauthenticated(...)` - same without `x-pm-uid`
and bearer.
- `func (s *session) serverTime() time.Time` - last `Date` header seen,
advanced by local elapsed time; zero before the first response.
- `var errSessionExpired` - wraps `fserrors.FatalError`-compatible text
`"session expired: run \"rclone config reconnect remote:\""`.
Reference: `incubating/account/js/src/apiClient.ts`, `cli/src/api/serverTimeHook.ts`.
- [ ] **Step 1: Write failing tests**:
- `TestAppVersionFromRcloneVersion` table: `v1.77.0` ->
`external-drive-rclone@1.77.0-stable`; `v1.77.0-DEV` ->
`...@1.77.0-alpha`; `v1.77.0-beta.10754.448d7361c` ->
`...@1.77.0-beta+10754.448d7361c`; garbage -> `...@1.0.0-stable`.
Every output matches the Task 1 regex.
- `TestSessionHeaders`: a call reaches the fake with the three headers.
- `TestSessionRefreshOn401`: after `ExpireAccessToken`, a call succeeds,
`POST /auth/v4/refresh` count is 1, and the store holds the new tokens.
- `TestSessionRefreshSingleFlight`: 20 concurrent calls after expiry cause
exactly 1 refresh.
- `TestSessionRefreshRereadsStore`: two sessions share one store; session
A refreshes; session B's next 401 adopts A's saved tokens without
calling refresh (refresh count stays 1), following
`lib/oauthutil.reReadToken`.
- `TestSessionRevoked` (Review Focus 4): after `RevokeSession`, a call
returns an error that `errors.Is(err, errSessionExpired)`; the store's
credentials are **not** blanked; exactly one refresh attempt was made.
- `TestSessionServerTime`: with `SetClockSkew(3 * time.Hour)`,
`serverTime()` is within 5s of now+3h.
- `TestSessionAPIError`: a faulted 422 code 2500 surfaces as `*api.Error`
with `Status == 422`, `Code == 2500`.
- [ ] **Step 2: Run** `go test ./backend/protondrive/ -run 'TestSession|TestAppVersion'` - expect FAIL.
- [ ] **Step 3: Implement.** Refresh is guarded by a mutex; inside it,
`store.Load()` first and compare the refresh token with the one in
memory before calling the server.
- [ ] **Step 4: Run** the verification commands - expect PASS.
- [ ] **Step 5: Commit** `protondrive: add session with safe token refresh`.
@@ -0,0 +1,123 @@
# Task 2 report: session (authenticated calls, refresh, server time)
Status: DONE. Commit `ea41704ad protondrive: add session with safe token refresh`.
## What I built
### `backend/protondrive/session.go`
- `errSessionExpired` = `session expired: run "rclone config reconnect remote:"`. It is always returned wrapped in `fserrors.FatalError` (so `errors.Is` and `fserrors.IsFatalError` both hold).
- `appVersionFromRcloneVersion(v)`: a new function, separate from the old `protonDriveAppVersionFromRcloneVersion`, which I left alone. It uses a regex, not semver:
- no suffix -> `-stable`
- `-beta` -> `-beta`
- `-beta.X.Y...` -> `-beta+X.Y...`. Each build identifier has characters outside `[0-9A-Za-z-]` replaced with `-`, and empty identifiers are dropped.
- any other suffix (`-DEV`, `-rc1`, ...) -> `-alpha`
- a version that can't be parsed -> `1.0.0-stable`
- `credentials`, `credentialStore`, `memoryStore` (has a mutex and a `creds` field).
- `newSession(client, baseURL, appVersion, store)`: starts from `store.Load()`. `x-pm-appversion` and `Accept: application/vnd.protonmail.v1+json` are default headers on the `rest.Client`. The error handler decodes every non-2xx response into `*api.Error{Status: ...}`, using `resp.Status` as the message if the body has none.
- `call`: adds `x-pm-uid` and `Authorization: Bearer` to a copied `opts` (with a cloned ExtraHeaders map).
- A 2xx body whose `Code` is outside 1000-1999 also becomes `*api.Error`. A body with no `Code` at all is passed through.
- On 401 it refreshes and retries once. Before the retry, `opts.Body` is rewound if it is an `io.Seeker`. If the body can't be rewound, the original 401 error is returned (this is documented on `call`).
- `callUnauthenticated`: the same call without uid, bearer or refresh.
- `serverTime()`: the `Date` header of the last response, including error responses, plus local time elapsed since. It is zero before the first response.
- `refresh(ctx, rejectedAccessToken)` runs under `refreshMu`, in this order:
1. If the access token in memory is no longer the rejected one, another goroutine already refreshed: return.
2. `store.Load()`. If the stored refresh token is set and differs from the one in memory, adopt the stored credentials. This is the `reReadToken` pattern.
3. If the refresh token is empty or was already rejected (`deadRefresh`), return the expired error without calling the server.
4. `POST /auth/v4/refresh` with `{ResponseType:"token", GrantType:"refresh_token", RefreshToken}`, sending x-pm-uid and the stale bearer, as the SDK does.
- A 4xx other than 429 marks the token dead and returns `FatalError(errSessionExpired: <api error>)`. This follows the SDK's sign-out condition, except that **the store is never written on failure**.
- Any other error is returned wrapped and is not fatal.
- On success the session saves `UID` (falling back to the old one), `AccessToken` and `RefreshToken` (falling back to the old one), keeps `KeyPassword`, and writes them to the store.
- Logs: two `fs.Debugf` lines that contain no secrets.
### `backend/protondrive/api/types.go`
Adds `AuthRefreshRequest` and `AuthRefreshResponse`, with field names taken from the SDK's `api-auth-types.ts`.
### `backend/protondrive/protontest/auth.go`, plus a small edit to `server.go`
- `server.go` gets `users` and `sessions` maps under `s.mu`, and `New` calls `registerAuth()`.
- `User{ID, Username, Password, MailboxPassword}`. `AddUser` sets `MailboxPassword = password`, which is single-password mode.
- `Session`, `NewSession`, `ExpireAccessToken` and `RevokeSession` (which deletes the session).
- `handleAuth(method, pattern, h(w, r, *User))`: answers 401 (body `Code` 401) unless `x-pm-uid` names a live session whose current, unexpired access token matches the bearer.
- `POST /auth/v4/refresh`:
- A malformed body, or wrong ResponseType/GrantType, gets 400 code 2001.
- An unknown `x-pm-uid`, or a refresh token that isn't the session's current one, gets 422 code 10013.
- Otherwise both tokens are rotated (single use, and the old access token stops working) and the expiry is cleared.
- Test routes `GET /tests/auth` (returns `UserID`) and `POST /tests/echo` (echoes `Echo`), both authenticated.
## Decisions to review
1. **Unknown pre-release suffixes map to `alpha`**, not to the `1.0.0-stable` fallback the old code used. This keeps the real version number and does not claim to be stable.
2. **A rejected refresh token is remembered.** Later calls fail at once, with no new refresh request, until the store holds a different refresh token. Without this, every call after a revocation, or each of N concurrent calls, would hit the refresh route again. `TestSessionRevoked` checks that a second call still leaves the refresh count at 1.
3. **Which failures count as "session expired"** follows the SDK: a 4xx other than 429 on refresh. A 5xx or network error is returned as an ordinary error that can be retried.
4. **Only the `Date` header is read** for server time. The SDK hook prefers `x-pm-date`, but its format is not documented, so I left it out.
5. **I added two test routes and `handleAuth` to the fake** because the session tests need an authenticated route, and Task 4 owns `/core/v4/users`. I added them to Task 2's Interfaces block in the plan in the same commit.
6. **I added `protontest/auth_test.go`**, which is not in the brief's file list. It checks the fake's contract directly: 401 for a wrong or unknown token, refresh rotation, reuse giving 422 10013, and revoke.
7. The `remote:` in `errSessionExpired` is literal, as the brief specifies, because the session does not know the remote's name. Task 14 may want to add the name in the `Fs` layer.
## Tests
- `TestAppVersionFromRcloneVersion`: every value from the brief, plus a bare version, `-beta`, `-rc1`, a branch name with `/` and `_`, an empty string and `v1.77`. Every output is checked against a copy of the Task 1 grammar **with the channel required**.
- `TestSessionHeaders`: a recording RoundTripper checks all four headers on an authenticated call, checks there is no uid or bearer on the unauthenticated call, and checks no `x-pm-drive-sdk-version` is sent.
- `TestSessionUnauthenticatedNoRefresh`, `TestSessionRefreshOn401` (refresh count 1, new tokens in the store, KeyPassword kept, no second refresh), `TestSessionRefreshResendsBody` (both a marshalled `request` and a seekable `opts.Body` are re-sent after a refresh).
- `TestSessionRefreshSingleFlight` (20 goroutines, 1 refresh), `TestSessionRefreshRereadsStore` (refresh count stays 1), `TestSessionRevoked` (errors.Is, fatal, store unchanged, 1 refresh even after a second call), `TestSessionServerTime` (zero before the first response, then within 5s of now+3h), `TestSessionAPIError` (422 and 200, both with code 2500, give `*api.Error` with the right Status and Code and no refresh).
### TDD evidence
- RED: `go test ./backend/protondrive/ -run 'TestSession|TestAppVersion'` failed to build with `undefined: appVersionFromRcloneVersion`, `undefined: session`, `undefined: memoryStore`, `undefined: credentials`, `undefined: newSession`. This was expected because the tests were written before `session.go` existed.
- On the first GREEN run every test passed except `TestSessionRefreshRereadsStore`, which expected `GET /tests/auth` count 3 and got 4. The test was wrong: each session makes a 401 call and then a retry, so 4 is correct. I fixed the expected value; the code did not change.
- GREEN: `go test -race -count=3 ./backend/protondrive/...` gave `ok backend/protondrive`, `ok backend/protondrive/protontest`. A final `go test -race ./backend/protondrive/...` also passed, including the old tests. `go build`, `go vet` and `golangci-lint run ./backend/protondrive/...` all passed (`0 issues.`).
## Files
- /home/user/rclone/backend/protondrive/session.go (new)
- /home/user/rclone/backend/protondrive/session_internal_test.go (new)
- /home/user/rclone/backend/protondrive/protontest/auth.go (new)
- /home/user/rclone/backend/protondrive/protontest/auth_test.go (new)
- /home/user/rclone/backend/protondrive/protontest/server.go (maps and `registerAuth` call)
- /home/user/rclone/backend/protondrive/api/types.go (refresh types)
- /home/user/rclone/docs/plans/001-protondrive-tier1.md (Task 2 ticked, Interfaces note)
## Concerns
None blocking. `call` with a 2xx empty body (when `NoResponse` is not set) returns a JSON decode error, because Proton always sends a JSON envelope. Callers that expect no body should set `NoResponse`.
## Fix round 1
Commit `9d894ab1b protondrive: keep two sessions on one config working when both refresh`.
**Finding:** two sessions that share one store can race. Both post the same single-use refresh token. The one that loses gets 422 code 10013 and returns a fatal errSessionExpired, even though the other session is about to save valid tokens to the store.
**Change in `backend/protondrive/session.go`:**
- New field `session.adoptWait` (default `3 * time.Second`).
- New helper `waitForStoredRefresh(ctx, spent)`. It polls `store.Load()` every 50ms, stopping at `adoptWait` or when ctx is done. It returns the stored credentials as soon as their refresh token is non-empty and differs from the rejected token.
- On a 4xx other than 429, `refresh` now calls the helper before declaring the session dead. If new credentials appear, it adopts them and returns nil, and `call` retries with them. Otherwise it behaves as before: it marks the token dead (`deadRefresh`) and returns `FatalError(errSessionExpired: ...)`.
- The store is still never written on failure. I added no cross-session or package-level lock.
- The doc comment on `refresh` now describes the wait.
**Tests in `session_internal_test.go`:**
- New `TestSessionRefreshTwoSessionsConcurrent`:
- Setup: two sessions share one memoryStore. The test injects `Fault{Method: "POST", Pattern: "/auth/v4/refresh", Delay: 200ms}` and calls `ExpireAccessToken`. The delay makes both sessions post the same refresh token before either one saves.
- Assertions: both concurrent calls succeed, with no errSessionExpired. The refresh count is 2, which proves the race really happened. A third session built from the store succeeds without another refresh, so the stored credentials are valid.
- `TestSessionRevoked` sets `s.adoptWait = 50ms`. Everything else is unchanged and it still passes: the call ends in errSessionExpired, exactly one refresh request is made, and the store is untouched.
**RED:** before the fix, with only the field added, the new test failed:
```
$ go test ./backend/protondrive/ -run 'TestSessionRefreshTwoSessionsConcurrent'
--- FAIL: TestSessionRefreshTwoSessionsConcurrent (0.21s)
Error: Received unexpected error:
session expired: run "rclone config reconnect remote:": protontest: invalid refresh token (Code=10013, Status=422)
FAIL
```
**GREEN:**
```
$ go test -race ./backend/protondrive/... -run 'TestSession' -count=5
ok github.com/rclone/rclone/backend/protondrive 2.597s
? github.com/rclone/rclone/backend/protondrive/api [no test files]
ok github.com/rclone/rclone/backend/protondrive/protontest 1.020s [no tests to run]
$ go test -race ./backend/protondrive/...
ok github.com/rclone/rclone/backend/protondrive 1.400s
ok github.com/rclone/rclone/backend/protondrive/protontest (cached)
$ go test -race ./backend/protondrive/ -run 'TestSessionRefreshTwoSessionsConcurrent|TestSessionRevoked' -v -count=1
--- PASS: TestSessionRefreshTwoSessionsConcurrent (0.21s)
--- PASS: TestSessionRevoked (0.05s)
$ go vet ./backend/protondrive/... (no output)
$ golangci-lint run ./backend/protondrive/...
0 issues.
```
**Remaining limit:** if the winning process takes longer than `adoptWait` (3s) between its refresh round trip and its save, the loser still reports the session expired. The bound on the wait is deliberate.
@@ -0,0 +1,72 @@
### Task 3: Sign-in - SRP password login and browser fork login
**Files:**
- Modify: `backend/protondrive/session.go`, `backend/protondrive/session_internal_test.go`
- Modify: `backend/protondrive/protontest/auth.go`
- Create: `backend/protondrive/crypto.go`, `backend/protondrive/crypto_internal_test.go`
**Interfaces:**
- Produces (`protontest`):
- Routes `POST /core/v4/auth/info`, `POST /core/v4/auth`,
`POST /core/v4/auth/2fa`, `GET /core/v4/keys/salts`,
`DELETE /core/v4/auth`, `GET /auth/v4/sessions/forks`,
`GET /auth/v4/sessions/forks/{selector}` (HTTP 422 until approved).
Server-side SRP uses `go-srp`'s `server.go`.
- `func (u *User) EnableTOTP() (secret string)`
- `func (s *Server) RequireCaptcha(u *User)` - `POST /core/v4/auth`
returns HTTP 422 code 9001.
- `func (s *Server) ApproveFork(userCode string, u *User)` - the "user
signed in on the web page" step; the next poll returns `UID`,
`AccessToken`, `RefreshToken` and `Payload` encrypted as the SDK
expects.
- Produces (`protondrive`):
- `func computeKeyPassword(password []byte, salt string) ([]byte, error)`
(bcrypt; the result is the last 31 characters - SDK issue 21 and
`incubating/account/js/src/srp.ts`).
- `func decryptForkPayload(payloadB64 string, key []byte) (keyPassword string, err error)`
(AES-256-GCM, 12-byte nonce prefix, AAD `"fork"`).
- `func (s *session) loginSRP(ctx context.Context, username, password, mailboxPassword string, twoFA func() (string, error)) error`
- on success the store holds complete `credentials` including
`KeyPassword`. `twoFA` is called only if the server asks for TOTP.
- `type forkRequest struct { UserCode, Selector, URL string; key []byte }`
- `func (s *session) forkStart(ctx context.Context) (*forkRequest, error)`
- URL is
`https://account.proton.me/desktop/login?app=drive&pv=3#payload=0:{UserCode}:{base64 key}:external-drive`.
- `func (s *session) forkPoll(ctx context.Context, f *forkRequest, interval, timeout time.Duration) error`
- production values 5s and 10 min; saves credentials on success.
- `func (s *session) logout(ctx context.Context) error`
- `var errCaptcha` with text telling the user to run
`rclone config reconnect` and choose browser sign-in.
Reference: `incubating/account/js/src/auth.ts`, `authWeb.ts`, `srp.ts`;
old flow in `go-proton-api@v1.0.4/manager_auth.go`.
- [ ] **Step 1: Write failing tests**:
- `TestComputeKeyPassword`: fixed password and salt produce a 31-byte
result equal to a vector generated once with the old library
(`proton.SaltForKey` / `Salts.SaltForKey`) and pasted into the test.
- `TestDecryptForkPayload`: round trip against a payload built in the
test with `crypto/aes`+`cipher.NewGCM`; a flipped byte fails; wrong AAD
fails.
- `TestLoginSRP`: correct password succeeds and the stored `KeyPassword`
equals `computeKeyPassword(password, user salt)`; wrong password fails
without storing anything.
- `TestLoginSRPPasswords`: passwords `"with space "`, `"quo\"te'"`,
`"пароль-密码"`, a 100-byte ASCII password all sign in.
- `TestLoginSRPMailboxPassword`: two-password user; key password derives
from the mailbox password.
- `TestLoginSRPTOTP`: `twoFA` is called once; wrong code fails.
- `TestLoginSRPCaptcha`: `errors.Is(err, errCaptcha)`.
- `TestForkLogin`: `forkStart`, then `ApproveFork`, then `forkPoll`
(interval 10ms) succeeds; stored credentials work for an authenticated
call. `TestForkLoginTimeout`: never approved -> error after the timeout,
nothing stored. `TestForkLoginCancel`: context cancel returns promptly.
- `TestLogout`: the session is unusable afterwards.
- [ ] **Step 2: Run** - expect FAIL.
- [ ] **Step 3: Implement.** SRP proofs via `go-srp` (`srp.NewAuth`,
`GenerateProofs`); verify the server proof.
- [ ] **Step 4: Run** the verification commands - expect PASS.
- [ ] **Step 5: Commit** `protondrive: add password and browser sign-in`.
@@ -0,0 +1,169 @@
# Task 3 report: sign-in (SRP password login and browser fork login)
Status: DONE_WITH_CONCERNS (all required work is done; the concerns are listed at the end)
Commit: `2ba0848dc protondrive: add password and browser sign-in`
## What I implemented
### Client (`backend/protondrive`)
- `crypto.go` (new):
- `computeKeyPassword(password []byte, salt string) ([]byte, error)`: decodes the base64 salt, runs go-srp `srp.MailboxPassword` (bcrypt `$2y$10$`), and returns the last 31 characters. It errors on an empty or invalid salt and on output that is too short. (The old library returned `nil, nil` when bcrypt failed. This code does not.)
- `decryptForkPayload(payloadB64, key)`: AES-256-GCM with a 12-byte nonce prefix and AAD `"fork"`. It parses the JSON `{"keyPassword": ...}` and errors if `keyPassword` is missing.
- `session.go`:
- `errCaptcha` reads: `password sign-in needs a CAPTCHA solved: run "rclone config reconnect remote:" and choose browser sign-in`. It is lower-case because staticcheck ST1005 rejects a capitalised error string. Code 9001 from `/core/v4/auth/info` or `/core/v4/auth` is wrapped so that `errors.Is(err, errCaptcha)` is true.
- `loginSRP` flow:
- `POST /core/v4/auth/info` with `{Intent:"Proton", Username}`, then `srp.NewAuth` and `GenerateProofs(2048)`.
- `POST /core/v4/auth` with `PersistentCookies:1` and `Payload:{}`, as the SDK sends.
- Compares the server proof in constant time.
- If `2FA.Enabled&1` is set, calls `twoFA` once and sends `POST /core/v4/auth/2fa`. FIDO2-only accounts get an error that points to browser sign-in.
- Picks the key password source: the mailbox password if given; otherwise, if `PasswordMode==2`, an error naming `mailbox_password`; otherwise the login password.
- `GET /core/v4/keys/salts`, then `computeKeyPassword` on the first salt, then saves the credentials.
- Nothing is saved on failure. If a failure happens after the session exists, the half-signed-in session is deleted on a best-effort basis (`DELETE /core/v4/auth`).
- `forkRequest`, `forkStart`:
- `GET /auth/v4/sessions/forks`, then a random 32-byte key.
- The URL is `https://account.proton.me/desktop/login?app=drive&pv=3#payload=` plus `url.QueryEscape("0:{UserCode}:{b64 key}:external-drive")`.
- `forkPoll(ctx, f, interval, timeout)`:
- Polls `GET /auth/v4/sessions/forks/{selector}` on a ticker; the first poll comes after one interval, matching the SDK's initial delay.
- HTTP 422 means "not ready yet". Any other error is returned at once, as in the SDK.
- On success it decrypts the payload and saves the credentials.
- On timeout it returns `browser sign-in: not completed within <timeout>`. If the parent context is cancelled it returns `ctx.Err()`.
- `logout`: sends `DELETE /core/v4/auth` through `call` (so it refreshes on a 401), treats `errSessionExpired` as already signed out, then clears the in-memory and stored credentials.
- Helpers `signInError` and `saveCredentials` (sets the credentials in use and saves them to the store).
- `api/types.go`:
- `CodePasswordWrong = 8002` (the name comes from go-proton-api).
- Request/response types: `AuthInfoRequest/Response`, `AuthRequest/Response`, `TwoFAInfo`, `Auth2FARequest`, `KeySalt`, `KeySaltsResponse`, `ForkInitResponse`, `ForkStatusResponse`.
- Constants `TwoFATOTP/TwoFAFIDO2` and `PasswordModeOne/Two`.
- Shapes come from `api-core-types.ts` and `accountApi.ts`.
### Fake server (`backend/protondrive/protontest`)
- Routes:
- `POST /core/v4/auth/info`: real go-srp `NewServerFromSigned` and `GenerateChallenge`. The verifier is built from the user's password with `NewAuthForVerifier`, computed lazily once per user (a `sync.Once`) so existing `AddUser` callers don't pay for a bcrypt hash.
- `POST /core/v4/auth`:
- Single-use SRP session; `VerifyProofs` checks the proof. A wrong password gets 422 / 8002; captcha users get 422 / 9001.
- Returns `PasswordMode` 2 when the mailbox password differs from the login password.
- TOTP users get a session that stays locked until 2FA.
- `POST /core/v4/auth/2fa`: validates with `totp.Validate`; a wrong code gets 422.
- `DELETE /core/v4/auth`.
- `GET /core/v4/keys/salts`: one salt with the user's key ID.
- `GET /auth/v4/sessions/forks`.
- `GET /auth/v4/sessions/forks/{selector}`: 422 until approved, then returns the result once.
- A locked session gets HTTP 403 on normal authenticated routes. Two routes accept it: 2FA and sign-out. To support this I split `handleAuth` into `handleSession` (checks the token only) and `handleAuth` (also refuses locked sessions).
- New API:
- `(*User).EnableTOTP() string`
- `(*Server).RequireCaptcha(u)`
- `(*Server).AddTwoPasswordUser(username, password, mailboxPassword)`; `AddUser` now delegates to it.
- `User.KeySalt` (base64, 16 random bytes)
- `(*Server).ApproveFork(signInURL string, u *User) error`
- The signed modulus is go-srp's test constant, which is Proton-signed and verifies against go-srp's built-in modulus key.
- `server.go` only gains the `srp` and `forks` maps.
## Interface changes (plan updated in the same commit)
- `ApproveFork(userCode string, u *User)` became `ApproveFork(signInURL string, u *User) error`. The fork's encryption key exists only in the URL fragment, which is the whole point of the design: the server never sees it. So a fake given only the user code cannot encrypt the payload "as the SDK expects". The fake now does what Proton's page does: it parses the URL, finds the fork by user code, and encrypts with the key from the URL. It returns an error rather than panicking on a malformed URL.
- Added `AddTwoPasswordUser` and the exported `User.KeySalt`. `TestLoginSRP` needs the user's salt, and setting `MailboxPassword` after `AddUser` would race with the server goroutines.
- Fork URL: the brief gives the literal `#payload=0:{UserCode}:{b64}:external-drive`. The SDK (`authWeb.ts generateSignInUrl`) wraps the payload in `encodeURIComponent`. Under the "SDK wins" rule I escape it with `url.QueryEscape`, which matches `encodeURIComponent` for every character a payload can contain. The decoded fragment is exactly the brief's literal; `TestForkLogin` checks this. Recorded in the plan and the commit message.
## Test vector provenance (TestComputeKeyPassword)
I generated it by temporarily adding `backend/protondrive/zz_vector_throwaway_test.go` to the package:
```go
salts := proton.Salts{{ID: "key", KeySalt: "dGVzdC1rZXktc2FsdC0xNg=="}}
out, err := salts.SaltForKey([]byte("correct horse battery staple"), "key")
fmt.Printf("len=%d %q\n", len(out), out)
```
`proton` is `github.com/rclone/go-proton-api` v1.0.4, as resolved by rclone's go.mod. I ran it with `go test -run TestZZVectorThrowaway -v ./backend/protondrive/`; it printed `len=31 "K97KhNyfE4CFNr9Gu8ONGrNnFPQYivK"`. Then I deleted the file. The salt is base64 of `test-key-salt-16`.
## Tests
- `crypto_internal_test.go`:
- `TestComputeKeyPassword`: the vector, plus bad and empty salt.
- `TestDecryptForkPayload`: round trip; flipped byte, wrong AAD, wrong key, short input and missing `keyPassword` all fail.
- `session_internal_test.go`:
- `TestLoginSRP`, `TestLoginSRPPasswords` (the four passwords from the brief), `TestLoginSRPMailboxPassword` (also checks that omitting the mailbox password gives an error naming `mailbox_password` and stores nothing), `TestLoginSRPTOTP` (one call, success; wrong code fails and stores nothing), `TestLoginSRPCaptcha`.
- `TestForkLogin` (checks the URL format and decoded payload, approves after 50 ms, polls every 10 ms, then makes an authenticated call), `TestForkLoginTimeout`, `TestForkLoginCancel`.
- `TestLogout`: the store is empty, the session fails with `errSessionExpired`, and the old tokens are rejected by the server.
- Login tests use `t.Parallel()`: SRP plus bcrypt is about 1.5 s per login under `-race`. That cut the package from 26 s to about 6 s.
- `protontest/auth_test.go`: `TestLoginTOTPLocksSession` covers fake-side SRP with go-srp. It checks that the server proof matches, the session gets 403 while locked, a wrong code is rejected, a right code unlocks it, and an SRP session can't be used twice.
### TDD evidence
- RED: `go test ./backend/protondrive/` was run after writing the tests and before any implementation:
```
backend/protondrive/crypto_internal_test.go:22:14: undefined: computeKeyPassword
backend/protondrive/crypto_internal_test.go:52:14: undefined: decryptForkPayload
backend/protondrive/session_internal_test.go:305:15: undefined: computeKeyPassword
... too many errors
FAIL github.com/rclone/rclone/backend/protondrive [build failed]
```
This failure was expected because none of the functions or fake methods existed yet.
- GREEN:
```
$ go test -race -count=1 ./backend/protondrive/...
ok github.com/rclone/rclone/backend/protondrive 5.845s
? github.com/rclone/rclone/backend/protondrive/api [no test files]
ok github.com/rclone/rclone/backend/protondrive/protontest 3.008s
$ go build ./backend/protondrive/... # ok
$ go vet ./backend/protondrive/... # ok
$ golangci-lint run ./backend/protondrive/...
0 issues.
$ go test -count=3 -race -run 'Fork|Login|Logout' ./backend/protondrive/
ok github.com/rclone/rclone/backend/protondrive 16.188s
```
The `-v` output is clean apart from the existing `TestIntegration` "TestProtonDrive: not in config, skipping" notice.
## go.mod
`go mod tidy` moved `github.com/ProtonMail/go-srp v0.0.7` from indirect to direct. Tidy put it in a new block of its own, so I moved it into the main require block in sorted order and re-ran tidy (no further change). The diff is exactly that one line moving. `ProtonMail/bcrypt` stays indirect: bcrypt is reached through `srp.MailboxPassword`, the same call the old library makes. No go.sum change.
## Files changed
- /home/user/rclone/backend/protondrive/crypto.go (new)
- /home/user/rclone/backend/protondrive/crypto_internal_test.go (new)
- /home/user/rclone/backend/protondrive/session.go
- /home/user/rclone/backend/protondrive/session_internal_test.go
- /home/user/rclone/backend/protondrive/api/types.go (not in the brief's Files list; wire types belong here per AGENTS.md)
- /home/user/rclone/backend/protondrive/protontest/auth.go
- /home/user/rclone/backend/protondrive/protontest/auth_test.go (not in the brief's list)
- /home/user/rclone/backend/protondrive/protontest/server.go (two map fields only; not in the brief's list)
- /home/user/rclone/docs/plans/001-protondrive-tier1.md (Task 3 ticked; Files and Interfaces updated)
- /home/user/rclone/docs/TODO.md
- /home/user/rclone/go.mod
None of the new identifiers collide with `protondrive.go`.
## Self-review notes and concerns
1. **First KeySalt, as the SDK does.** For accounts with several user keys the first salt may not belong to the primary key. The old library matched the salt by key ID. Logged in docs/TODO.md for Task 4, which will have `GET /core/v4/users`.
2. **Fake status codes for a locked session and a wrong TOTP code are my choice.** Locked sessions get 403 with code 0; a wrong TOTP code gets 422 / 2001. I could not confirm Proton's exact codes from the SDK. The client only relies on "not 2xx", so the behaviour is not tied to these values.
3. **The fork payload JSON has only `keyPassword`.** The SDK type has an optional `type` field whose value I could not confirm, so the fake omits it and the client ignores it.
4. **The fork URL escaping and the `ApproveFork` signature change depart from the brief.** Both are explained above. The controller may want to confirm the URL decision.
5. **Task 4 will need to lock the fake user's keys with the right password.** It should use `User.MailboxPassword` with `User.KeySalt`. The fake already has an unexported `keyPassword()` doing exactly that, which Task 4's planned `KeyPassword()` can expose.
## Fix round 1
Finding: no test showed that a bad server proof is rejected.
### What changed
- `protontest/auth.go`: new `(*Server).CorruptServerProof(u *User)`. It sets a per-user flag (guarded by `s.mu`), and while the flag is set `POST /core/v4/auth` flips one bit in the first byte of `ServerProof` after `VerifyProofs` succeeds.
- `session_internal_test.go`: new `TestLoginSRPBadServerProof`. It asserts that the error contains "server proof verification", that `store.Load()` is empty and that `s.current()` is empty.
- `docs/plans/001-protondrive-tier1.md`: the Task 3 Interfaces block now lists `CorruptServerProof`.
- `session.go` is unchanged (the check itself was already correct).
### RED: proof check temporarily disabled
I changed the check to `if err != nil || (false && subtle.ConstantTimeCompare(...) != 1) {`, ran the test, then restored session.go from a backup. `git diff --stat backend/protondrive/session.go` was empty afterwards.
```
$ go test -race -count=1 -run TestLoginSRPBadServerProof ./backend/protondrive/
--- FAIL: TestLoginSRPBadServerProof (2.11s)
session_internal_test.go:335:
Error: An error is expected but got nil.
Test: TestLoginSRPBadServerProof
FAIL github.com/rclone/rclone/backend/protondrive 2.134s
```
### GREEN: check restored
```
$ go test -race -count=1 ./backend/protondrive/... -run 'TestLogin|TestFork|TestLogout'
ok github.com/rclone/rclone/backend/protondrive 5.276s
? github.com/rclone/rclone/backend/protondrive/api [no test files]
ok github.com/rclone/rclone/backend/protondrive/protontest 2.851s
$ go test -race -count=1 ./backend/protondrive/...
ok github.com/rclone/rclone/backend/protondrive 5.816s
ok github.com/rclone/rclone/backend/protondrive/protontest 2.951s
$ go vet ./backend/protondrive/... # ok
$ golangci-lint run ./backend/protondrive/...
0 issues.
```
@@ -0,0 +1,47 @@
### Task 4: Account - user and address keys
**Files:**
- Create: `backend/protondrive/account.go`, `backend/protondrive/account_internal_test.go`
- Create: `backend/protondrive/protontest/account.go`
**Interfaces:**
- Produces (`protontest`):
- `AddUser` now generates a real user key and one address with an address
key (address key passphrase stored as a `Token` encrypted to the user
key and signed, as Proton does), locked with
`bcrypt(password, salt)`-derived key password. Uses gopenpgp directly.
- `func (u *User) AddAddress(email string)`,
`func (u *User) KeyPassword() string`, `func (u *User) Email() string`.
- Routes `GET /core/v4/users`, `GET /core/v4/addresses`,
`GET /core/v4/keys/all?Email=`.
- Produces (`protondrive`):
- `var unlockMu sync.Mutex` - held while unlocking keys.
- `type address struct { ID, Email string; keyRing *crypto.KeyRing }`
- `type account struct { userKR *crypto.KeyRing; addresses []*address; ... }`
- `func loadAccount(ctx context.Context, s *session, keyPassword []byte) (*account, error)`
- `func (a *account) primary() *address`
- `func (a *account) byID(id string) *address`,
`func (a *account) byEmail(email string) *address` (nil if absent)
- `func (a *account) publicKeys(ctx context.Context, s *session, email string) (*crypto.KeyRing, error)`
- own addresses answered locally; others fetched once and cached.
- `func (a *account) close()` - clears private key material.
Reference: `go-proton-api@v1.0.4/unlock.go`, `keyring.go`;
`cli/src/api/driveAccountAdapter.ts`.
- [ ] **Step 1: Write failing tests**:
- `TestLoadAccount`: keys unlock; `primary().Email` matches; the address
key ring can decrypt a message the fake encrypted to it.
- `TestLoadAccountWrongKeyPassword`: error, no panic.
- `TestLoadAccountSerialised` (issue 9816): 16 goroutines call
`loadAccount`; a test hook counter shows unlock concurrency never
exceeds 1.
- `TestPublicKeysCached`: 5 lookups of one foreign email cause 1 request;
own address causes 0.
- [ ] **Step 2: Run** - expect FAIL.
- [ ] **Step 3: Implement.**
- [ ] **Step 4: Run** the verification commands - expect PASS.
- [ ] **Step 5: Commit** `protondrive: load and unlock account keys`.
@@ -0,0 +1,230 @@
# Task 4 report: account - user and address keys
Status: DONE. Commit `6ec49842c protondrive: load and unlock account keys`.
## Implemented
### `protondrive` (client)
- `account.go`: `unlockMu`, `address`, `account`, `loadAccount`, `primary`,
`byID`, `byEmail`, `publicKeys`, `close`.
- `loadAccount` calls `GET /core/v4/users` and `GET /core/v4/addresses`
(`Page=0&PageSize=50`, as the SDK does), then unlocks with `unlockMu`
held. HTTP happens outside the lock.
- User keys: only active keys. A key that doesn't unlock is skipped
(debug log with the key ID only). If none unlocks, the error is
`load account: unlock user keys: no user key unlocks, is the password right?: ...`.
- Address keys: a key with a `Token` is unlocked with the token decrypted
by the user key ring. The detached `Signature` must verify against the
user keys, checked at server time (`session.serverTime`, or now if
unset). As in the SDK, a token without a valid signature is refused. A
key without a `Token` is unlocked with the key password (legacy).
The token plaintext is zeroed after use.
- Primary address = first address in the response (SDK
`addresses.Addresses.at(0)`). If the primary address can't be unlocked,
loading fails. Any other address that can't be unlocked is skipped with
a debug log.
- `publicKeys`: an own address returns the public parts of its key ring,
with no request. A foreign email is fetched with
`GET /core/v4/keys/all?Email=&InternalOnly=1` (SDK `accountApi.keys`),
single-flight per email and cached for the life of the account. Codes
33102/33103 give an empty key ring, cached (SDK resolves `[]`). Other
errors are not cached.
- `close` calls `ClearPrivateParams` on the user key ring and every
address key ring.
- `session.go`:
- New field `unlockHook func()`, the test hook called while `unlockMu` is
held. I put it on the session rather than in a package variable
because of the "no package-level mutable state except unlockMu" rule.
- **Salt ruling:** `loginSRP` now fetches `GET /core/v4/users` and chooses
the salt whose ID matches the primary user key, via the new
`primaryKeySalt`. It falls back to the first salt when none matches.
- `api/types.go`: `UserKey`, `User`, `UserResponse`, `AddressKey`, `Address`,
`AddressesResponse`, `PublicKey`, `PublicKeysResponse`, and the codes
`CodeAddressMissing` (33102) and `CodeDomainExternal` (33103), named from
`incubating/account/js/src/accountApi.ts`. Field names come from
`coreTypes.ts` (`UserKey`, `AddressUser`, `AddressKey`, keys/all).
### `protontest` (fake)
- `account.go`: gopenpgp v3 directly, default profile, so keys are
EdDSA/Curve25519 (never RSA).
- The user key is locked with `bcrypt(mailbox password, salt)`, last 31
chars. That is the same value the client derives; a test checks this.
- Each address key is locked with a random passphrase (hex of 32 bytes).
`Token` holds the passphrase encrypted to the primary user key, and
`Signature` is an armored detached signature of it by the primary user
key.
- `User.KeyPassword()`, `User.Email()` and `User.AddAddress(email)` as
the brief specifies.
- Added beyond the brief, and recorded in the plan's Interfaces block:
- `User.AddUserKey()` adds a new primary user key with a new salt. The
old key stays first in the salts list and is locked with its old
password. Address tokens are re-sealed to the new key. This is the
two-key user the salt ruling asks for.
- `User.EncryptToAddress(email, plaintext)` lets tests encrypt to an
address.
- Routes `GET /core/v4/users`, `GET /core/v4/addresses` and
`GET /core/v4/keys/all?Email=`. An unknown email gets HTTP 422 with
code 33102.
- `auth.go`:
- `AddTwoPasswordUser` generates the user key and one address. The
address is `username` if it contains "@", else `username@proton.test`.
- The salts response lists one entry per user key with its key ID.
- The fork payload carries `KeyPassword()`.
- The private `keyPassword()` and `keyID` are gone.
### Docs
- Removed the key-salt note from `docs/TODO.md`.
- Ticked the Task 4 steps in the plan and updated its Interfaces block.
- `go.mod`: `gopenpgp/v3 v3.4.1` moved from indirect to the direct block.
The diff has nothing else and `go.sum` is unchanged. `go mod tidy` first
put it in its own `require` block, so I moved it into the direct block
by hand and re-ran tidy, which kept it there.
## Tests
New tests in `account_internal_test.go`:
- `TestLoadAccount`: two addresses. Checks `primary().Email == u.Email()`,
`byID`/`byEmail` (including nil for unknown), and that each address key
ring decrypts a message the fake encrypted to it. The primary address
key can't decrypt a message sent to the alias.
- `TestLoadAccountTwoPassword`: `KeyPassword()` equals
`computeKeyPassword(mailbox, KeySalt)`, and the account unlocks.
- `TestLoadAccountWrongKeyPassword`: a wrong key password and a nil one
both return an error and a nil account, with no panic.
- `TestLoadAccountRotatedUserKey`: the old user key fails to unlock and
loading still succeeds.
- `TestLoadAccountSerialised`: 16 goroutines; the hook counts peak
concurrency, which must be 1 across 16 calls.
- `TestAccountClose`: after `close`, no key in the user or address key
rings is private.
- `TestPublicKeysCached`: 5 lookups of a foreign email make 1 request, and
a message encrypted to the returned key decrypts with the other user's
address key. 5 lookups of an own address make 0 requests. An unknown
email gives an empty key ring and is cached.
- `TestPublicKeysErrorNotCached`: an injected 500 is not cached.
New tests in `session_internal_test.go`:
- `TestLoginSRPSaltOfPrimaryKey`: user with `AddUserKey()`. The stored key
password equals `u.KeyPassword()` and unlocks the account.
- `TestPrimaryKeySalt`: matching salt, fallback to the first salt, no keys,
and no salts.
### TDD evidence
- RED 1: `go test ./backend/protondrive/ -run 'TestLoadAccount|TestPublicKeys|TestAccountClose|TestLoginSRPSalt'`
failed to build: `u.KeyPassword undefined`, `u.AddAddress undefined`,
`undefined: loadAccount`, ... (expected, nothing implemented yet).
- RED 2, the salt ruling: with the fake and `account.go` in place but
`loginSRP` unchanged, the same command passed every account test and
failed `TestLoginSRPSaltOfPrimaryKey`. The expected key password was
`"HVr8a129..."` and the actual `"4BrLK1HE..."`, and loading then failed
with `unlock keys of primary address: ... decrypt token: ... incorrect key`.
This is expected: the first salt belongs to the old key.
- GREEN: after the `primaryKeySalt` change,
`go test ./backend/protondrive/ -run TestLoginSRPSalt` gave
`ok github.com/rclone/rclone/backend/protondrive 0.402s`.
### Verification commands (all from the repo root)
```
go build ./backend/protondrive/... # ok
go test -race ./backend/protondrive/...
ok github.com/rclone/rclone/backend/protondrive 10.717s
? github.com/rclone/rclone/backend/protondrive/api [no test files]
ok github.com/rclone/rclone/backend/protondrive/protontest 3.497s
go vet ./backend/protondrive/... # clean
golangci-lint run ./backend/protondrive/... # 0 issues.
```
The old `protondrive.go` still compiles and its tests pass, since the full
package ran.
## Self-review notes / concerns
- **Cost of creating a user:** `AddUser` now runs one bcrypt (cost 10)
plus two Curve25519 key generations up front, because the keys must be
locked with the key password. That is roughly 50-100 ms per user
(`TestAccountClose`, which creates a user, signs in and loads the
account, takes 0.13 s). Making key generation lazy would remove this but
adds complexity; I didn't do it.
- **Exported `User.KeySalt`:** `AddUserKey` changes it while holding
`srv.mu`. Tests read it directly, which is safe only when nothing else is
running at the same time. It was already an exported field before this
task.
- **Fake user key identity:** the fake's user key user ID is
`username <username>`. It's cosmetic and nothing reads it.
- **Exact email matching:** `byEmail` and `publicKeys` compare emails
exactly, case included, as the SDK does.
- **Address key order:** each address key ring keeps the server's order.
The SDK treats index 0 as primary; Primary flags are not reordered.
## Fix round 1
Commit `483c3a151 protondrive: test that address keys with a bad signature are refused`.
### Finding
No test covered rejecting an address key whose Token signature is bad.
### Changes
- `protontest/account.go`: added
`func (s *Server) CorruptAddressKeySignature(u *User, email string, empty bool)`.
- With `empty` false, it re-signs the real passphrase of every key of
that address with a freshly generated unrelated key, so the signature
is valid OpenPGP and only the signer is wrong.
- With `empty` true, it sets `Signature` to "".
- `AddUserKey` re-seals the address keys, which restores the signatures.
- Added to the plan's Interfaces block.
- `account_internal_test.go`: `TestLoadAccountBadAddressKeySignature`, with
subtests `impostor` and `empty`. Each checks that:
- when the primary address is corrupted, `loadAccount` returns an error
containing "verify token" and a nil account;
- when a secondary address (`alias@proton.test`) is corrupted, loading
succeeds, `byEmail("alias@proton.test")` is nil, only one address is
left, and the primary is intact.
- `account.go`: in `unlockUserKeys` and `unlockAddressKeys`, an unlocked key
that `kr.AddKey` rejects now has `ClearPrivateParams()` called on it.
### RED evidence (verification temporarily disabled, then restored from a backup copy)
1. With the `if err != nil { return ..."verify token"... }` block replaced
by `_ = err`, running
`go test ./backend/protondrive/ -run TestLoadAccountBadAddressKeySignature` gave:
```
--- FAIL: TestLoadAccountBadAddressKeySignature/empty (0.09s)
account_internal_test.go:130: Error: An error is expected but got nil.
--- FAIL: TestLoadAccountBadAddressKeySignature/impostor (0.13s)
account_internal_test.go:130: Error: An error is expected but got nil.
FAIL
```
2. With only the `verified.SignatureError()` check dropped, the same
command gave:
```
--- FAIL: TestLoadAccountBadAddressKeySignature/impostor (0.11s)
Error: An error is expected but got nil.
FAIL
```
`empty` still passes here because `VerifyDetached` itself errors on an
empty signature. Each check is now covered by at least one subtest.
After both experiments, `git diff` on `account.go` showed only the
ClearPrivateParams change, so the verification code is restored.
### GREEN
```
$ go test -race ./backend/protondrive/... -run 'TestLoadAccount|TestPublicKeys|TestLogin'
ok github.com/rclone/rclone/backend/protondrive 6.731s
? github.com/rclone/rclone/backend/protondrive/api [no test files]
ok github.com/rclone/rclone/backend/protondrive/protontest 3.259s
$ go vet ./backend/protondrive/... # clean
$ golangci-lint run ./backend/protondrive/... # 0 issues.
$ RCLONE_CONFIG=/notfound go test -race ./backend/protondrive/...
ok github.com/rclone/rclone/backend/protondrive 12.032s
? github.com/rclone/rclone/backend/protondrive/api [no test files]
ok github.com/rclone/rclone/backend/protondrive/protontest (cached)
```
### Note: environment change, not caused by this work
Run without `RCLONE_CONFIG=/notfound`, the full package now fails in the
old backend's `TestIntegration`. It finds a `TestProtonDrive:` remote in
the active rclone config and contacts live Proton, which fails with
`refusing to follow HTTPS to HTTP redirect ... http://drive-api.proton.me/drive/shares`.
During the first round the same command passed, so the test was being
skipped then and the config must have changed since. I did not look at
the user's config. AGENTS.md's `make quicktest` uses
`RCLONE_CONFIG=/notfound`, which passes. The controller may want the
plan's verification commands to set it too.
@@ -0,0 +1,83 @@
### Task 5: Node cryptography
**Files:**
- Modify: `backend/protondrive/crypto.go`, `backend/protondrive/crypto_internal_test.go`
**Interfaces:**
- Produces (all pure functions; `now` comes from `session.serverTime`):
- `type nodeKeys struct { keyRing *crypto.KeyRing; passphrase []byte; passphraseSessionKey *crypto.SessionKey }`
- `type verifyResult error` semantics: every decrypt function returns
`(value, verifyErr, err)`; `err` means "cannot decrypt", `verifyErr`
means "decrypted but the signature did not verify" (the SDK treats
this as data, not failure).
- `func generateNodeKeys(parentKR, signerKR *crypto.KeyRing, now time.Time) (armoredKey, encPassphrase, passphraseSig string, keys *nodeKeys, err error)`
- Ed25519/X25519 v4 key, user ID `"Drive key"`, random 32-byte
passphrase base64-encoded, passphrase encrypted to parent without AEAD
and signed detached by `signerKR`.
- `func decryptNodeKeys(parentKR, verifyKR *crypto.KeyRing, armoredKey, encPassphrase, passphraseSig string, now time.Time) (keys *nodeKeys, verifyErr, err error)`
- `func reencryptPassphrase(keys *nodeKeys, newParentKR *crypto.KeyRing) (encPassphrase string, err error)`
- reuses the session key (for move).
- `func encryptName(name string, parentKR, signerKR *crypto.KeyRing, now time.Time) (string, error)` (no AEAD)
- `func decryptName(encName string, parentKR, verifyKR *crypto.KeyRing, now time.Time) (name string, verifyErr, err error)`
- `func validateName(name string) error` - rejects empty, `> 255`
characters, containing `/` or NUL, and the names `.` and `..`.
- `func generateHashKey(nodeKR, signerKR *crypto.KeyRing, now time.Time) (encHashKey string, hashKey []byte, err error)`
- 32 random bytes base64-encoded; **the base64 text's bytes are the
HMAC key** (SDK `driveCrypto.ts`, `generateLookupHash`).
- `func decryptHashKey(encHashKey string, nodeKR, verifyKR *crypto.KeyRing, now time.Time) (hashKey []byte, verifyErr, err error)`
- `func nameHash(name string, hashKey []byte) string` - lower-case hex
HMAC-SHA256 of the UTF-8 name, **no Unicode normalisation**.
- `func generateContentKey(nodeKR *crypto.KeyRing, now time.Time) (contentKeyPacketB64, contentKeyPacketSig string, sk *crypto.SessionKey, err error)`
- v6 PKESK; signature by the node key over the session key.
- `func decryptContentKey(contentKeyPacketB64 string, nodeKR *crypto.KeyRing) (*crypto.SessionKey, error)`
- accepts v3 and v6 PKESK.
- `func encryptBlock(plain []byte, sk *crypto.SessionKey, nodeKR, signerKR *crypto.KeyRing, now time.Time) (enc []byte, encSignature string, hash [32]byte, err error)`
- SEIPDv2 AES-GCM; detached signature of the plaintext, itself
encrypted to the node key; `hash` is SHA-256 of `enc`.
- `func decryptBlock(enc []byte, sk *crypto.SessionKey) ([]byte, error)`
- reads SEIPDv1 and v2.
- `func verifierToken(verificationCode, encBlock []byte) []byte` - XOR
over `len(verificationCode)`, block zero-padded.
- `func signManifest(blockHashes [][]byte, signerKR *crypto.KeyRing, now time.Time) (armoredSig string, err error)`
- detached armored signature over the concatenated hashes.
- `func verifyManifest(blockHashes [][]byte, armoredSig string, verifyKR *crypto.KeyRing, now time.Time) error`
- `type xattrs struct { ModTime time.Time; Size int64; BlockSizes []int64; SHA1 string; Extra map[string]json.RawMessage }`
- `func encryptXAttrs(x *xattrs, nodeKR, signerKR *crypto.KeyRing, now time.Time) (string, error)`
- JSON `{"Common":{"ModificationTime":ISO8601,"Size":n,"BlockSizes":[...],"Digests":{"SHA1":hex}}}`,
unknown top-level members preserved from `Extra`; no AEAD.
- `func decryptXAttrs(enc string, nodeKR, verifyKR *crypto.KeyRing, now time.Time) (x *xattrs, verifyErr, err error)`
Reference: `client/js/src/crypto/driveCrypto.ts`,
`client/js/src/internal/nodes/extendedAttributes.ts`,
`client/js/src/internal/upload/` (verifier, manifest);
old `!proton-!a!p!i-!bridge@v1.0.5/crypto.go` and
`go-proton-api@v1.0.4/crypto_v3_compat.go` for gopenpgp v3 profile usage.
- [ ] **Step 1: Write failing tests** (one `Test<Func>` each, plus):
- round trips for every generate/decrypt pair with freshly generated
parent and signer keys;
- each decrypt with a wrong `verifyKR` returns the value **and** a
non-nil `verifyErr`, `err == nil`;
- each decrypt with a wrong parent key returns `err != nil`;
- `TestNameHash`: `nameHash("report.pdf", []byte("a2V5a2V5..."))` equals
the value computed in the test with `crypto/hmac` directly; `"é"` (NFC)
and `"é"` (NFD) hash differently; `"A"` and `"a"` differ;
- `TestValidateName` table including a 255-rune and a 256-rune name;
- `TestVerifierToken`: code longer than the block, equal, shorter;
a hand-computed 4-byte example;
- `TestXAttrs`: round trip; `ModificationTime` serialises as UTC ISO 8601
with millisecond precision; an input with an extra member `"Media"`
survives a round trip byte-for-byte in `Extra`; missing `Common` yields
zero values, no error;
- `TestEncryptBlockFormat`: parse `enc` with gopenpgp/go-crypto packet
reader and assert the packet is SEIPD version 2; assert the content key
packet is PKESK version 6;
- `TestServerTimeUsedForSignatures`: keys generated with `now` three
hours ahead verify when checked with the same `now`.
- [ ] **Step 2: Run** `go test ./backend/protondrive/ -run 'TestNode|TestName|TestHash|TestContent|TestBlock|TestVerifier|TestManifest|TestXAttrs|TestValidate|TestEncrypt|TestServerTime'` - expect FAIL.
- [ ] **Step 3: Implement.**
- [ ] **Step 4: Run** the verification commands - expect PASS.
- [ ] **Step 5: Commit** `protondrive: add node cryptography`.
@@ -0,0 +1,196 @@
# Task 5 report: node cryptography
**Status:** DONE_WITH_CONCERNS (the code is complete and tested; the concerns are about formats only a real account can settle, listed below)
Commit: `0bb2dbdf3 protondrive: add node cryptography`
## What I implemented
All in `backend/protondrive/crypto.go`, as pure functions that take `now` (server time; zero means the local clock, as in `account.go`):
- `nodeKeys`, `generateNodeKeys`, `decryptNodeKeys`, `reencryptPassphrase`
- `encryptName`, `decryptName`, `nameSessionKey` (new), `validateName`
- `generateHashKey`, `decryptHashKey`, `nameHash`
- `generateContentKey`, `decryptContentKey`
- `encryptBlock`, `decryptBlock`, `verifierToken`
- `signManifest`, `verifyManifest`
- `xattrs`, `encryptXAttrs`, `decryptXAttrs`
- Internal helpers: `aeadPGP` (default profile plus AEAD GCM), `pgpFor(sk)`, `encryptWithSessionKey`, `encryptArmored`, `armorMessage`, `decryptMessage`, `signDetached`/`signDetachedEncoded`, `verifyDetached`, `joinKeyRings`, `generatePassphrase`.
The format of every message is set explicitly by the session key. For a non-v6 key: a v3 PKESK, then SEIPDv1 written with the default profile. For a v6 key: a v6 PKESK, then SEIPDv2 GCM. This holds whatever the recipient keys advertise. It works the same way as `EncryptMessageNonAead` in go-proton-api, which avoids gopenpgp's preference negotiation. `unlockKey` and `publicKeyRing` from `account.go` are reused. No new identifier collides with `protondrive.go`.
`type verifyResult error` is not a real type: every decrypt function returns `(value, verifyErr, err)`, as the brief describes.
## Interface changes (plan Interfaces block updated in the same commit)
| Function | Change | Why |
|---|---|---|
| `generateNodeKeys` | new `aead bool` | `upload/cryptoService.ts:47-49` creates file keys with `enableAead: true`. Folders use the default `false` (`driveCrypto.ts:81`; `nodes/cryptoService.ts:~756` calls `generateKey` without it). `openPGPCrypto.ts:54-60` maps this to `aeadProtect`, which on a v4 key advertises SEIPDv2. |
| `reencryptPassphrase` | `(keys, newParentKR, signerKR, now) (encPassphrase, passphraseSig, err)` | `nodes/cryptoService.ts:866-871` (`encryptNodeWithNewParent`) calls `encryptPassphrase`, which re-signs, and sends `NodePassphraseSignature` (`nodes/apiService.ts:560`). |
| `encryptName` | new `sk *crypto.SessionKey` (nil for a new node) and new `nameSessionKey` | `encryptNewName` (`nodes/cryptoService.ts:813-818`) and the move (`:859-864`) re-encrypt with the existing `nameSessionKey`. `driveCrypto.ts:460-467` documents that use. |
| `generateHashKey` | `(nodeKR, now)`: no signer | `driveCrypto.ts:429-446` encrypts and signs with the node key itself. |
| `decryptHashKey` | `(enc, nodeKR, extraVerifyKR, now)` | `driveCrypto.ts:535-549` verifies against `[nodeKey, ...extraVerificationKeys]` (older clients signed with the address key). |
| `decryptContentKey` | `(b64, sig, nodeKR, extraVerifyKR, now) (sk, verifyErr, err)` | `nodes/cryptoService.ts:637-641` verifies the content key packet signature against `[key, ...keyVerificationKeys]`. An empty `sig` skips the check, as the upload verifier does (`upload/cryptoService.ts:194`, signature `undefined`). |
| `validateName` | the 255 limit counts UTF-16 code units | `nodes/validations.ts:5-16` uses JS `name.length`. |
## Packet formats and their sources
| Value | Format written | SDK lines | Old-library lines (gopenpgp v3 usage) |
|---|---|---|---|
| Node key | v4, EdDSA legacy (algo 22) with an ECDH cv25519 subkey (18). User ID `Drive key` with no email. Locked with CFB and an iterated+salted S2K (usage 254, specifier 3). File keys: features SEIPDv2 and cipher suites AES256-GCM first. Folder keys: no SEIPDv2. | `openPGPCrypto.ts:54-60` (`ed25519Legacy`, `userIDs: [{name:'Drive key'}]`, `aeadProtect`); `upload/cryptoService.ts:47-49` | bridge `crypto.go:53-69` (`KeyGeneration().AddUserId`, `LockKey` with the default profile, v4 when `aead` is false), `:77-80`. Locking ignores `aeadProtect`'s AEAD key locking on purpose (#9844 and your note). |
| Passphrase | armored v3 PKESK + SEIPDv1 with its own session key. Armored detached signature over the passphrase text. | `driveCrypto.ts:94-106,167-190` (`ignoreSEIPDv2FeatureFlag` via `enableAeadWithEncryptionKeys:false`, `encryptAndSignDetachedArmored`) | bridge `crypto.go:93-117`; go-proton-api `crypto_v3_compat.go:56-96` (`EncryptMessageNonAead`) |
| Name | armored v3 PKESK + SEIPDv1, signed inline (not compressed) | `driveCrypto.ts:468-491` | `crypto_v3_compat.go:73-96` |
| Hash key | armored v3 PKESK + SEIPDv1, encrypted to and signed inline by the node key. The value is base64 text of 32 bytes, and those text bytes are the HMAC key. | `driveCrypto.ts:429-458`, `openPGPCrypto.ts:14-19` | - |
| Content key | the PKESK is v6 when the node key advertises SEIPDv2, otherwise v3. Raw base64 of the binary packet. Armored detached signature by the node key over the raw session key bytes. | `driveCrypto.ts:128-158` (`enableAeadWithEncryptionKeys: true` follows the key preferences); `openPGPCrypto.ts:21-41` | bridge `crypto.go:16-31` (`RFC9580` with GCM: the v6 session key selects v6 PKESK and SEIPDv2); `link_file_types.go:118-144` for the signature |
| Block | the data packet only, with no PKESK. SEIPDv2 AES-256-GCM for a v6 session key, SEIPDv1 otherwise. Not compressed. | `driveCrypto.ts:779-805` (`encryptAndSignDetached` with no encryption keys) | bridge `file_upload.go:361-375` (`protonDrivePGP().Encryption().SessionKey(sk)`) |
| Block signature | binary detached signature of the plaintext, encrypted to the node key **with the content session key**, then armored. So for a v6 key it is v6 PKESK + SEIPDv2. | `driveCrypto.ts:406-423,799`: `encryptSignature` passes the content `sessionKey`, and openpgp.js takes the format from the session key's `aeadAlgorithm` | **Differs** from bridge `file_upload.go:377-396`, which uses `EncryptMessageNonAead`. See concern 2. |
| Manifest | armored detached signature over the concatenated hashes, in the caller's order | `driveCrypto.ts:818-847`; `download/cryptoService.ts:82-110` | bridge `file_upload.go:428-441` |
| XAttrs | armored v3 PKESK + SEIPDv1, compressed (ZLIB), signed inline. JSON as `extendedAttributes.ts:107-128`. Time in `toISOString` form (`2024-03-05T06:07:08.123Z`). On read, old rclone's `-0700` zone form is also accepted. | `driveCrypto.ts:557-575` (`compress: true`); `extendedAttributes.ts` | `link_file_types.go:172-207`; bridge `file_upload.go:499` (old time format) |
| Verifier | `code[i] ^ (block[i] or 0)` over `len(code)` | `upload/cryptoService.ts:225-227` | - |
Armor: a CRC checksum is written for v3/SEIPDv1 messages, and none for v6 messages (RFC 9580).
## Tests (in `crypto_internal_test.go`)
Tests: `TestNodeKeysRoundTrip`, `TestNodeKeyFormat`, `TestReencryptPassphrase`, `TestNameRoundTrip`, `TestValidateName`, `TestNameHash`, `TestHashKeyRoundTrip`, `TestContentKeyRoundTrip`, `TestBlockRoundTrip`, `TestEncryptBlockFormat`, `TestVerifierToken`, `TestManifest`, `TestXAttrs`, `TestServerTimeUsedForSignatures`.
They cover everything the brief lists:
- round trips;
- a wrong verify key gives the value plus `verifyErr`;
- a wrong parent or node key gives `err`;
- the NFC/NFD and case vectors for `nameHash`;
- names of 255 and 256 runes, plus astral characters that count as 2 UTF-16 units;
- verifier tokens where the code is longer than, equal to and shorter than the block, plus a hand-computed example;
- xattrs: exact JSON, millisecond UTC time, a `Media` member preserved byte for byte through two round trips, missing `Common`, empty input, malformed members ignored, and `Extra["Common"]` refused;
- packet versions parsed with go-crypto's `packet.Reader`;
- signatures made 3h ahead verify at the same `now` and fail at local time.
They also check:
- the raw secret-key packets parse to S2K usage 254 and specifier 3 (`TestNodeKeyFormat`);
- the passphrase is non-AEAD even when the parent key advertises SEIPDv2;
- a block decrypts with the session key read back from the base64 packet. A v6 session key from a PKESK has no algorithm, and decryption still works.
Mutation checks (each reverted afterwards). All four were caught:
1. content key from the default profile: `TestContentKeyRoundTrip` and `TestEncryptBlockFormat` fail;
2. non-AEAD values written with the AEAD handle: `TestNodeKeyFormat` and `TestNameRoundTrip` fail;
3. key locked with the `RFC9580` profile (Argon2): `TestNodeKeyFormat` fails;
4. `SignTime` dropped: `TestServerTimeUsedForSignatures` fails.
## TDD evidence
RED: `RCLONE_CONFIG=/notfound go test ./backend/protondrive/ -run 'TestNode|TestName|TestHash|TestContent|TestBlock|TestVerifier|TestManifest|TestXAttrs|TestValidate|TestEncrypt|TestServerTime|TestReencrypt'`
```
backend/protondrive/crypto_internal_test.go:148:151: undefined: nodeKeys
backend/protondrive/crypto_internal_test.go:150:57: undefined: generateNodeKeys
...
FAIL github.com/rclone/rclone/backend/protondrive [build failed]
```
This was expected: none of the functions existed yet.
GREEN: the same command with `-count=1 -v`:
```
--- PASS: TestNodeKeysRoundTrip (0.12s)
--- PASS: TestNodeKeyFormat (0.03s)
--- PASS: TestReencryptPassphrase (0.03s)
--- PASS: TestNameRoundTrip (0.01s)
--- PASS: TestValidateName (0.00s)
--- PASS: TestNameHash (0.00s)
--- PASS: TestHashKeyRoundTrip (0.00s)
--- PASS: TestContentKeyRoundTrip (0.02s)
--- PASS: TestBlockRoundTrip (0.02s)
--- PASS: TestEncryptBlockFormat (0.01s)
--- PASS: TestVerifierToken (0.00s)
--- PASS: TestManifest (0.00s)
--- PASS: TestXAttrs (0.01s)
--- PASS: TestServerTimeUsedForSignatures (0.04s)
ok github.com/rclone/rclone/backend/protondrive 0.306s
```
Verification commands (run from the repo root):
```
go build ./backend/protondrive/... # ok
go vet ./backend/protondrive/... # ok
golangci-lint run ./backend/protondrive/... # 0 issues.
RCLONE_CONFIG=/notfound go test -count=1 -race ./backend/protondrive/...
ok github.com/rclone/rclone/backend/protondrive 11.666s
? github.com/rclone/rclone/backend/protondrive/api [no test files]
ok github.com/rclone/rclone/backend/protondrive/protontest 3.469s
```
## Files changed
- `backend/protondrive/crypto.go`
- `backend/protondrive/crypto_internal_test.go`
- `docs/plans/001-protondrive-tier1.md` (Task 5 Interfaces block; Task 5 boxes ticked)
- `docs/TODO.md` (one entry under Next)
## Concerns
1. **The SDK and the old library disagree on the content format** (recorded in `docs/TODO.md`).
- go-proton-api `link_file_types.go:119-125` deliberately writes v3/SEIPDv1 content. It says the official clients "do not yet create or reliably read" crypto-refresh files.
- bridge `crypto.go:47-52` says the server rejects a v6 content key packet for a v4 node key.
- I followed the SDK and the spec (6.3: "v4 node key, v6 PKESK content key, SEIPDv2"). The node key now advertises SEIPDv2, as the SDK's `aeadProtect` keys do. The bridge never sent a v6 packet for such a key, so its server-rejection claim may not apply.
- Only the fake server and the real account can settle this. Task 6 cannot, because the old library reads both formats.
2. **Block signature format.** Your note said "only blocks are SEIPDv2". The SDK, however, encrypts the block signature with the content session key (`driveCrypto.ts:799` → `encryptSignature` → `encryptArmored(..., sessionKey, ...)`). In openpgp.js the format of the output follows that key's `aeadAlgorithm`, so for new files the signature is v6 PKESK + SEIPDv2. Per the authority order I followed the SDK.
- The SDK reader does not verify block signatures (`download/cryptoService.ts:33-38`).
- The old library reads either format (bridge `crypto.go:245-253` decrypts with the node key).
- If you prefer the old library's non-AEAD signature, the fix is one line in `encryptBlock`: pass a fresh non-v6 session key instead of `sk`.
- My claim about openpgp.js internals comes from my knowledge of openpgp.js v6 `Message.encrypt` and `encryptSessionKey`. I did not read it: openpgp.js is not in the SDK checkout.
3. **Folder vs file xattrs are chosen by `SHA1 == ""`.** A folder's xattrs carry only `ModificationTime`, as `generateFolderExtendedAttributes` does.
- Callers must know this rule. It is documented on `xattrs.marshal` and in the plan.
- The SDK's pre-2025 descending sort of `BlockSizes` needs the node creation time, so it is left to the caller (Task 9/10).
4. **Smaller deviations from the brief:**
- `decryptXAttrs` returns zero values, not an error, for JSON that does not parse, as the SDK does (`extendedAttributes.ts:150-153`). Invalid UTF-8 is an error, as in the SDK (`uint8ArrayToUtf8` with `fatal: true`). Names are treated the same way.
- The test regex in Step 2 does not match `TestReencryptPassphrase`. I added `TestReencrypt` when running it; the full suite runs it anyway.
## Fix round 1
Commit: `a1e1333c7 protondrive: check node signatures against the verification keys only`
### What changed
- **`decryptMessage`:** it now decrypts the session key with `decKR`, then decrypts the data with `crypto.PGP().Decryption().SessionKey(sk).VerificationKeys(verifyKR)` and no `DecryptionKeys`. Only the verification keys can now verify a signature.
- Background: gopenpgp v3.4.1 adds the decryption key ring to the signer lookup on both paths (`decryption_core.go:40-47` and `:157-160`), so `DecryptionKeys` must not be set on the decrypting handle.
- It now returns the session key directly (`(res, sk, err)`), in place of `RetrieveSessionKey`.
- **`decryptNodeKeys`:** it takes the session key from `decryptMessage` and clears the decrypted passphrase on both unlock error paths.
- **`kb.SignTime` in `encryptWithSessionKey` is kept, against the reviewer's suggestion, because it is not a no-op.**
- `encryptionHandle.EncryptSessionKey` (gopenpgp `encryption_handle.go:118-131`) selects the recipients' encryption keys with the handle clock.
- Evidence: I removed the call and ran `TestServerTimeUsedForSignatures`. It failed with `generate node keys: encrypt passphrase: gopenpgp: encryption key is unavailable for key id fab3de6a26b14518`, because the parent key was created at server time, 3h ahead.
- I restored the call and added a comment explaining why it is there.
### Decrypt-and-verify paths checked
- **`decryptMessage` (the only place a `DecryptionKeys` handle checked an inline signature):** fixed. It serves four callers:
- `decryptName`: was leaking, now fixed. Test `TestNameRoundTrip/SignedByParentKey`.
- `decryptXAttrs`: was leaking, now fixed. Test `TestXAttrs/SignedByNodeKey`.
- `decryptHashKey`: no leak was possible, because `nodeKR` is meant to verify (SDK `driveCrypto.ts:545-549`) and is already in the verifier set through `joinKeyRings`. New test `TestHashKeyRoundTrip/SignedByOtherNodeKey` locks this in: only keys in `nodeKR` and the extra keys verify. It passes on both old and new code.
- `decryptNodeKeys`: no inline signature (the passphrase signature is detached and checked with a `Verify` handle via `verifyDetached`). Not affected.
- **`verifyDetached`** (passphrase, content key signature, `verifyManifest`): a `Verify()` handle with only `VerificationKeys`. Not affected.
- **`decryptContentKey`:** `DecryptSessionKey` only, with no verification on that handle; the signature goes through `verifyDetached`. Not affected.
- **`decryptBlock` and `nameSessionKey`:** no signature check. Not affected.
### RED (new tests against the old code)
`RCLONE_CONFIG=/notfound go test -count=1 ./backend/protondrive/ -run 'TestNameRoundTrip|TestXAttrs|TestHashKey'`
```
--- FAIL: TestNameRoundTrip (0.01s)
--- FAIL: TestNameRoundTrip/SignedByParentKey (0.00s)
crypto_internal_test.go:349:
--- FAIL: TestXAttrs (0.02s)
--- FAIL: TestXAttrs/SignedByNodeKey (0.00s)
crypto_internal_test.go:679:
FAIL github.com/rclone/rclone/backend/protondrive 0.042s
```
Both failures are on `assert.Error(t, verifyErr)`: `verifyErr` was nil.
### GREEN
```
$ go vet ./backend/protondrive/... # ok
$ golangci-lint run ./backend/protondrive/...
0 issues.
$ RCLONE_CONFIG=/notfound go test -count=1 -race ./backend/protondrive/...
ok github.com/rclone/rclone/backend/protondrive 11.836s
? github.com/rclone/rclone/backend/protondrive/api [no test files]
ok github.com/rclone/rclone/backend/protondrive/protontest 3.489s
```
@@ -0,0 +1,48 @@
### Task 6: Differential tests and frozen fixtures from the old library
**Files:**
- Create: `backend/protondrive/crypto_diff_internal_test.go`
- Create: `backend/protondrive/testdata/oldlib/*.json`
**Interfaces:**
- Consumes: Task 5 functions; exported helpers of the old libraries:
`proton.Link.GetKeyRing`, `GetName`, `GetHashKey`, `GetSessionKey`,
`proton.Share.GetKeyRing`, `proton.Unlock`, `proton.Keys.Unlock`,
`proton.EncryptMessageNonAead`.
- Produces: `testdata/oldlib/` fixtures and
`func loadOldLibFixture(t *testing.T, name string) oldLibFixture`.
This is the crypto oracle until a real account exists (spec 9.1).
- [ ] **Step 1: Write failing tests**:
- `TestNewWriterOldReader`: build parent keys, then with Task 5 create a
folder node (keys, name, hash key) and a file node (keys, name, content
key). Populate a `proton.Link` and assert `GetKeyRing`, `GetName`,
`GetHashKey` and `GetSessionKey` return the same plaintexts.
- `TestOldWriterNewReader`: produce node material using only exported
old-library functions (plus plain gopenpgp where the old library's
writer is unexported, mirroring `!proton-!a!p!i-!bridge@v1.0.5/crypto.go`
line for line - cite the lines in a comment) and read it with Task 5.
- `TestKeyUnlockMatchesOldLibrary`: the `protontest` user from Task 4,
expressed as `proton.User`/`proton.Address`, unlocks with
`proton.Unlock` to key rings with the same fingerprints as
`loadAccount`.
- `TestFrozenFixtures`: reads every file in `testdata/oldlib/` with Task 5
functions and compares to the plaintext stored beside it.
- [ ] **Step 2: Run** - expect FAIL.
- [ ] **Step 3: Implement**, and add a `-update` test flag that regenerates
`testdata/oldlib/` (armored test-only keys, passphrases, encrypted and
plain values for: folder node, file node, name, hash key, content key
packet, one encrypted 1 KiB block with its signature, manifest signature,
xattrs). Generate once and commit the output.
- [ ] **Step 4: Run** the verification commands - expect PASS. Run
`go test ./backend/protondrive/ -run TestFrozenFixtures -count=3`.
- [ ] **Step 5: Commit** `protondrive: add differential crypto tests against the old library`.
Any mismatch found here is a bug in Task 5, not something to adapt the
test around. If the old library and the SDK disagree, stop and record the
disagreement in `docs/TODO.md`; the SDK decides what is written, the old
library what must stay readable.
@@ -0,0 +1,149 @@
# Task 6 report: differential tests and frozen fixtures from the old library
**Status:** DONE_WITH_CONCERNS. The work is complete. It found one real bug in `crypto.go`, which is now fixed. The concerns are a judgement call in that fix and a hand edit to `go.mod`; both are listed below.
Commits:
- `fdcb6ab32 protondrive: verify hash keys signed by older rclone versions` (fixes `crypto.go`, adds a regression test)
- `3b63f3c7d protondrive: add differential crypto tests against the old library`
## What I implemented
The new file is `backend/protondrive/crypto_diff_internal_test.go`.
- `TestNewWriterOldReader`
- **Folder:** Task 5 writes the node keys, name and hash key. A `proton.Link` reads them with `GetKeyRing` (fingerprints compared), `GetName` and `GetHashKey`. The hash key is verified against address keys plus the node key, as bridge `folder.go:116-120` does. `proton.GetNameHash` and `nameHash` give the same hash. A move (`reencryptPassphrase` plus `encryptName` with the reused `nameSessionKey`) is read by the old `GetKeyRing` and `GetName` under the new parent.
- **File:** Task 5 writes node keys with `aead=true`, the name and the content key. The old library reads them with `GetKeyRing`, `GetName` and `GetSessionKey`, and the session key bytes match.
- The test first asserts that the output really is in the new format: a v6 PKESK content key, SEIPDv2 blocks and a SEIPDv2 block signature.
- Then `encryptBlock` output goes through `oldDecryptBlock`, a mirror of bridge `crypto.go:218-280` (`decryptBlockIntoBuffer`, which is unexported).
- `encryptXAttrs` output goes through `proton.RevisionMetadata.GetDecXAttrString`, and `iso8601.ParseString` parses the mod time as bridge `file.go:73` does.
- `TestOldWriterNewReader`: Folder and File are written by the old library and read by `checkOldLibFixture`, the same checker the frozen fixtures use. Two more cases:
- `ShareKey`: the share passphrase is written by the old library. `proton.Share.GetKeyRing` and `decryptNodeKeys` agree on the passphrase and the fingerprints.
- `CryptoRefreshNodeKey`: a v6 node key locked with the RFC 9580 profile (the bridge's `aead=true` path) unlocks with `decryptNodeKeys`.
- `TestKeyUnlockMatchesOldLibrary`: the `protontest` user has a primary and an alias address. `loadAccount` runs first. Then the same `/core/v4/users` and `/core/v4/addresses` responses are decoded into `proton.User`/`[]proton.Address` and unlocked with `proton.Unlock`. The user key ring and every address key ring have identical fingerprints, in the same order.
- `TestFrozenFixtures`: reads every `testdata/oldlib/*.json` with `checkOldLibFixture` and asserts that there are 2 fixtures. With `-update` it first rewrites them with the old library.
- `loadOldLibFixture(t, name) oldLibFixture`, as the plan specifies.
### How the old library writes the values
Exported old-library functions write wherever they can:
- `proton.EncryptMessageNonAead`
- `CreateFolderReq.SetName` and `SetNodeHashKey`
- `CreateFileReq.SetName` and `SetContentKeyPacketAndSignature`
- `CommitRevisionReq.SetEncXAttrString`
- `proton.GetNameHash`
Three writers are unexported, so I mirrored them. Each mirror's comment cites the old file and lines:
- bridge `crypto.go:33-41`, `53-69`, `71-86` and `93-135` (passphrase and node key generation)
- bridge `file_upload.go:360-406` (block and encrypted block signature)
- bridge `file_upload.go:429-437` (manifest)
The xattr input is built as in bridge `file_upload.go:498-505`, with the old `-0700` time layout.
### Fixtures (`testdata/oldlib/folder.json`, `file.json`)
Each fixture holds:
- armored test-only keys and their passphrases: a parent node key generated the bridge's way, and an address key with identity `test <test@proton.test>`;
- the node key, the encrypted passphrase with its signature, and the plain passphrase;
- the name.
`folder.json` adds the hash key, its plaintext, and a child name with its hash. `file.json` adds:
- the content key packet with its signature and the session key in hex;
- one 1 KiB block, its plaintext, its encrypted signature and its base64 SHA-256;
- the manifest signature;
- the xattrs, plus the values the old library put in them.
I generated them once with `-update` and committed them. I also checked them at the year 2075 in a throwaway test, which I did not commit. They still pass, so the keys have no expiry.
The fixture type and `TestFrozenFixtures` (apart from its `-update` branch) use no old-library type, so they can outlive the libraries. That cleanup is now a TODO entry for Task 14.
## Findings
### 1. Bug fixed in `crypto.go`: hash keys written by released rclone failed signature verification
`TestOldWriterNewReader/Folder` failed:
```
Error: Signature Verification Error: Invalid signature caused by openpgp: invalid signature: intended recipients in non-encrypted message
Messages: hash key signature
```
- **Cause.** go-proton-api's `SetNodeHashKey` (`link_folder_types.go:44-63`) encrypts and signs in one gopenpgp call. gopenpgp then writes Intended Recipient Fingerprint subpackets into the signature. Task 5's fix round 1 decrypts the data with the session key alone, and go-crypto (`openpgp/v2/read.go:602-608`) rejects any signature that lists recipients in what it sees as an unencrypted message.
- **Effect.** Every folder created by released rclone would have reported a `verifyErr` on its hash key. The same applies to any name or xattrs that another gopenpgp-based client writes this way.
- **Fix.**
- `decryptMessage` now sets `DisableIntendedRecipients()` on the session-key handle.
- It checks the recipients itself in a new function, `intendedRecipientsError`. A valid signature passes if it lists no recipients, or if it lists the primary fingerprint of a key in `decKR`. This is the rule go-crypto applies (`checkIntendedRecipientsMatch`), and the rule the old library enforced, because it decrypted with `DecryptionKeys`.
- Task 5's protection is kept: only `verifyKR` can verify.
- `decryptMessage` now returns `verifyErr` explicitly. Its four callers changed by one line each.
- **Regression test.** `TestHashKeyRoundTrip/IntendedRecipients` covers both directions:
- a signature listing the node key verifies;
- a signature listing only another key fails (the node key is a hidden recipient).
- **RED** (before the fix): `RCLONE_CONFIG=/notfound go test -count=1 ./backend/protondrive/ -run TestHashKeyRoundTrip`
```
--- FAIL: TestHashKeyRoundTrip/IntendedRecipients
Error: Received unexpected error: ... intended recipients in non-encrypted message
```
- **Mutation check.** I replaced the check with plain `res.SignatureError()`. The "another recipient" half then failed (`An error is expected but got nil`). I reverted the mutation.
### 2. The new content format: released rclone reads it
The new writer emits a v4 node key advertising SEIPDv2, a v6 PKESK content key, SEIPDv2 GCM blocks and SEIPDv2 block signatures. The old library (`GetSessionKey` and the `decryptBlockIntoBuffer` mirror) reads all of it, and verifies the block signature. It also parses the new `toISOString` modification time with `iso8601`.
So there is **no interop limit with released rclone** at the library level. I added this to the existing `docs/TODO.md` entry. What only a live check can still answer is whether the official clients and the server accept the format.
### 3. Everything else matched
Every other non-content value matched in both directions: passphrases, names, hash keys, the name hash, xattrs and the manifest. `proton.Unlock` and `loadAccount` produce identical fingerprints.
The old reader never verifies the manifest (bridge `file_upload.go:440` only sends it). So the manifest is checked only in the old-writer → new-reader direction.
## Tests
The first run of the new tests, before the fix and with no fixtures yet, was the expected RED:
```
--- FAIL: TestOldWriterNewReader/Folder (hash key signature, finding 1)
--- FAIL: TestFrozenFixtures open testdata/oldlib: no such file or directory
```
Then I generated the fixtures:
`RCLONE_CONFIG=/notfound go test -count=1 ./backend/protondrive/ -run 'TestFrozenFixtures$' -update` → `ok`
Final verification:
```
go build ./backend/protondrive/... # ok
go vet ./backend/protondrive/... # ok
golangci-lint run ./backend/protondrive/... # 0 issues.
go mod tidy -diff # no diff
RCLONE_CONFIG=/notfound go test -count=1 -race ./backend/protondrive/...
ok github.com/rclone/rclone/backend/protondrive 16.048s
? github.com/rclone/rclone/backend/protondrive/api [no test files]
ok github.com/rclone/rclone/backend/protondrive/protontest 3.573s
RCLONE_CONFIG=/notfound go test -count=3 ./backend/protondrive/ -run TestFrozenFixtures -v
--- PASS: TestFrozenFixtures (x3, subtests file and folder)
ok github.com/rclone/rclone/backend/protondrive 0.311s
```
With `-v`, the new tests print nothing except their PASS lines.
## Files changed
- `backend/protondrive/crypto.go`: the fix (`decryptMessage`, new `intendedRecipientsError`, and the callers)
- `backend/protondrive/crypto_internal_test.go`: the regression subtest
- `backend/protondrive/crypto_diff_internal_test.go`: new
- `backend/protondrive/testdata/oldlib/folder.json` and `file.json`: new
- `go.mod`: `github.com/relvacode/iso8601 v1.7.0` moved from indirect to direct. This is exactly what `go mod tidy -diff` asked for. The version is unchanged and `go.sum` is untouched.
- `docs/TODO.md`: a note on the content-format entry, and a new Task 14 cleanup entry
- `docs/plans/001-protondrive-tier1.md`: Task 6 boxes ticked
## Decisions and concerns
1. **Intended-recipient check in `crypto.go`.** I kept the check, as the old library and go-crypto enforce it, rather than simply disabling it. The SDK checkout never mentions intended recipients, and I could not read openpgp.js to see whether it checks them.
- If the SDK clients do not check, ours is stricter.
- It still accepts everything the old library accepted, plus messages signed without recipients, which is what Task 5 and the SDK write.
- If you prefer to match an SDK that does not check, delete `intendedRecipientsError` and keep `DisableIntendedRecipients()`.
2. **`go.mod` edited by hand** (no `tidy` run; the change is the one `tidy -diff` printed), so that the test can parse times with the bridge's own parser. It goes away with the bridge (TODO entry).
3. **One test file, as the plan specifies.** The old-library generators and diff tests sit beside the code that must outlive them. The file header and the TODO entry say what to delete in Task 14.
4. **Rotated user key left out of `TestKeyUnlockMatchesOldLibrary`.** `proton.Keys.Unlock` logs a logrus warning for every key that does not unlock, which would make the test output noisy. Only active keys that unlock are compared: a primary and an alias address.
5. **Old writer for `CryptoRefreshNodeKey`.** Bridge v1.0.5 never calls this path with `aead=true`. It is tested only as a reader check of a v6, RFC 9580-locked node key, and it is not frozen as a fixture.
@@ -0,0 +1,52 @@
### Task 7: Drive state in the fake server
**Files:**
- Create: `backend/protondrive/protontest/drive.go`, `backend/protondrive/protontest/drive_test.go`
- Create: `backend/protondrive/protontest/crypto.go`
**Interfaces:**
- Produces (`protontest`):
- `AddUser` additionally creates a volume, a main share (share key
locked by a passphrase encrypted to the address key) and a root folder
link encrypted to the share key. `protontest/crypto.go` is an
independent, minimal implementation - it must not copy from or import
`backend/protondrive`.
- `func (u *User) VolumeID() string`, `RootID() string`, `ShareID() string`
- Seeding helpers that act like "another client":
`func (u *User) SeedFolder(parentID, name string) (id string)`,
`func (u *User) SeedFile(parentID, name string, content []byte, modTime time.Time) (id string)`,
`func (u *User) SeedDoc(parentID, name string) (id string)` (a node of
MIME type `application/vnd.proton.doc` with no blocks),
`func (u *User) SeedLegacyFile(parentID, name string, content []byte) (id string)`
(v3 PKESK, SEIPDv1 blocks),
`func (u *User) Corrupt(id string, what string)` with `what` one of
`"name"`, `"name-signature"`, `"passphrase-signature"`, `"block-hash"`,
`"manifest"`, `"swap-blocks"`, `"truncate-blocks"`.
- `func (u *User) Dump() []DumpedNode` - decrypted tree for assertions
(`Path`, `IsFolder`, `Content`, `ModTime`, `SHA1`, `State`).
- `func (u *User) Drafts() []string` - IDs of uncommitted drafts.
- Read routes: `GET /drive/v2/shares/my-files`, `GET /drive/volumes/{volumeID}`,
`GET /drive/shares/{shareID}`, `POST /drive/v2/volumes/{volumeID}/links`
(metadata by ID list, max 100 per call - more returns HTTP 422),
`GET /drive/v2/volumes/{volumeID}/folders/{linkID}/children`
(`AnchorID`/`More` paging; page size settable with
`func (s *Server) SetChildrenPageSize(n int)`, default 150),
`GET /drive/v2/volumes/{volumeID}/files/{linkID}/revisions/{revisionID}`
(`PageSize`, `FromBlockIndex`, `NoBlockUrls`),
`GET /storage/blocks/{token}` on the same server for block bytes
(requires `pm-storage-token`), `GET /core/v4/users` quota fields.
Reference: `client/js/src/internal/shares/apiService.ts`,
`nodes/apiService.ts`, `download/apiService.ts`, `driveTypes.ts`.
- [ ] **Step 1: Write failing tests** in `drive_test.go`:
`TestSeedAndDump` (seeded tree equals the dump), `TestChildrenPaging`
(250 children, page size 100 -> 3 pages, no duplicates),
`TestLinksBatchLimit`, `TestBlockDownloadNeedsToken`.
- [ ] **Step 2: Run** - expect FAIL.
- [ ] **Step 3: Implement.**
- [ ] **Step 4: Run** the verification commands - expect PASS.
- [ ] **Step 5: Commit** `protondrive: add drive state to the fake server`.
@@ -0,0 +1,144 @@
# Task 7 report: drive state in the fake server
**Status:** DONE_WITH_CONCERNS
**Commit:** `27d06dca1 protondrive: add drive state to the fake server`
## What I built
### `protontest/crypto.go` (new, 314 lines)
I wrote this file from the SDK only: `driveCrypto.ts`, `openPGPCrypto.ts`, `nodes/cryptoService.ts`, `shares/cryptoService.ts`, `upload/cryptoService.ts` and `extendedAttributes.ts`. The gopenpgp v3 API usage comes from the old library's `crypto_v3_compat.go`. I did not open `backend/protondrive/crypto.go`. `go list -deps` shows that protontest imports only `backend/protondrive/api`.
- **Node and share keys:** `newDriveKey(aead)` makes an ed25519-legacy v4 key named "Drive key", locked with base64 of 32 random bytes. When `aead` is set, the key advertises SEIPDv2 with a GCM preference, which matches openpgp.js `aeadProtect`. Only file node keys use this.
- **Messages that are not file content:** names, passphrases, hash keys and extended attributes go through `encryptArmored`. It generates an AES-256 session key without AEAD, wraps it in a v3 PKESK and writes a SEIPDv1 data packet. This is the SDK's `ignoreSEIPDv2FeatureFlag`. Encryption with a detached signature has its own helper (`sealPassphrase`). Extended attributes are compressed.
- **Content keys:** `newContentKey(nodeKey, aead)`.
- Current format: v6 PKESK and a GCM SEIPDv2 session key.
- Legacy format: v3 PKESK and SEIPDv1.
- The key-packet signature is a detached signature by the node key over the session key bytes.
- **Blocks:** each block is a data packet encrypted with the content key. Its signature is a detached signature by the address key over the plaintext, encrypted to the node key with the content key, as in the SDK's `encryptBlock`.
- **Decrypt and verify helpers:** used by `Dump` and by volume-creation checks.
### `protontest/drive.go` (new, 1518 lines)
- **Accounts:** `AddUser` and `AddTwoPasswordUser` now create a volume, a main share and a root folder, built the way the SDK's `generateVolumeBootstrap` builds them:
- the share passphrase is encrypted to the address key and signed by it;
- the root passphrase is encrypted to the share key and signed by the address key;
- the name "root" is encrypted to the share key;
- the hash key is encrypted to the root key and signed by it.
- **`AddUserWithoutDrive`:** `GET /drive/v2/shares/my-files` answers HTTP 422 code 2501 "Volume not found" until `POST /drive/volumes` creates the volume.
- The request and response follow the SDK's `createVolume`, `CreateVolumeRequestDto` and `GetVolumeResponseDto`.
- The fake checks that the share key unlocks with the address key (named by `AddressID`/`AddressKeyID`) and that the passphrase signature is by that key.
- It checks that the folder key unlocks with the share key and that its passphrase signature is by the address key.
- It checks that the folder name decrypts and is non-empty, with a valid signature.
- It checks that the hash key decrypts and is non-empty, signed by the folder key or the address key.
- Anything else gets HTTP 422 code 2001. A second creation gets 422 code 2500.
- **Accessors:** `VolumeID`, `ShareID`, `RootID`, and `SetChildrenPageSize` (default 150).
- **Seeding:** `SeedFolder`, `SeedFile`, `SeedLegacyFile` and `SeedDoc` act as another client whose address key signs everything.
- Folders use a node key without AEAD and have no xattr.
- `SeedFile` writes an AEAD node key, a v6 PKESK, SEIPDv2 blocks of 4 MiB, encrypted block signatures and a manifest. The manifest is the concatenated SHA-256 of the encrypted blocks, signed by the address key.
- Its xattr `{"Common":{"ModificationTime":"…sssZ","Size","BlockSizes","Digests":{"SHA1"}}}` is in the SDK's field order and `toISOString` format.
- `SeedLegacyFile` writes a key without AEAD, v3/SEIPDv1, and the legacy `2006-01-02T15:04:05-0700` time format. Its modtime is the current time.
- `SeedDoc` writes MediaType `application/vnd.proton.doc` and an active revision with no blocks and no xattr.
- Seeding panics on a duplicate name hash in the same folder, to catch mistakes in tests.
- **`Corrupt(id, what)`:** supports all seven kinds.
- `"name"`: the name is encrypted to an unrelated key.
- `"name-signature"`: the name is re-signed by an impostor key.
- `"passphrase-signature"`: the passphrase signature is by an impostor key.
- `"block-hash"`: the bytes of block 1 are flipped, so its stored `Hash` no longer matches.
- `"manifest"`: the address key signs the wrong bytes.
- `"swap-blocks"`: blocks 1 and 2 trade places, and each still matches its own hash.
- `"truncate-blocks"`: the last block is dropped.
- **`Dump()`:** decrypts the whole tree from the stored ciphertext, down from the share key. It verifies:
- passphrase signatures, against `SignatureEmail`'s keys, or the parent key when that is empty;
- name signatures, against `NameSignatureEmail`'s keys;
- hash keys;
- the content key packet signature;
- each block's hash, decryption and signature;
- the manifest signature;
- the xattr signature, and its claims about Size, BlockSizes and SHA1 against the decrypted content.
It reports all failures in `DumpedNode.Err` instead of panicking. Later tasks can use this to check client uploads strictly.
- **`Drafts()`:** returns links in the draft state, plus draft revisions of other links.
- **Routes:** response field names and nesting follow `driveTypes.ts`. That covers `PrimaryRootShareResponseDto`, `GetVolumeResponseDto`, `BootstrapShareResponseDto`, `LoadLinkDetailsResponseDto` (`FolderDetailsDto`/`FileDetailsDto`, `LinkDto`, `FolderDto`, `FileDto`, `ActiveRevisionDto`), `ListChildrenResponseDto` and `GetRevisionResponseDto` (`DetailedRevisionResponseDto`, `BlockResponseDto`). Empty optional strings are sent as `null`, and `Thumbnails` is always `[]`.
- `GET /drive/v2/shares/my-files`, `GET /drive/volumes/{volumeID}` and `GET /drive/shares/{shareID}`. Another user's volume or share gets 422 code 2501.
- `POST /drive/v2/volumes/{volumeID}/links`: unknown IDs are left out. More than 100 IDs gets 422 code 2001.
- `GET …/folders/{linkID}/children`: lists active children in creation order. It supports `AnchorID`/`More` and `FoldersOnly`.
- `GET …/files/{linkID}/revisions/{revisionID}`: supports `PageSize`, `FromBlockIndex` (blocks are numbered from 1) and `NoBlockUrls` (`1` or `true`). Each block URL gets a new random ID and token, and its `BareURL` is `<server URL>/storage/blocks/{id}`.
- `GET /storage/blocks/{id}`: needs no session. It requires a `pm-storage-token` that matches the token issued with that ID, and answers 403 code 2011 otherwise. An unknown ID gets 404 code 2501.
- `GET /core/v4/users`: now includes `UsedSpace`, `MaxSpace` (`protontest.DefaultMaxSpace`, 5 GiB) and `ProductUsedSpace` with `Drive` = UsedSpace. These come from the `User`/`UserUsage` schemas in `coreTypes.ts`. Used space is the encrypted size of every stored revision. `Volume.UsedSpace` reports the same figure.
- **Key cache:** `Server.unlockKey` caches unlocked keys by the hash of (armored key, passphrase). The S2K key derivation took about 95% of the seeding time: 250 seeds dropped from 16 s to 2.4 s. The cache is exact, because the same armored key and passphrase always give the same result, so Dump's verification is unaffected.
### Changes to existing files
- **`auth.go`:** added `User.drive`. `AddTwoPasswordUser` is split into `newUser` and `addUser`. Added `AddUserWithoutDrive`.
- **`account.go`:** `handleUser` now embeds `api.User` in a local struct with the quota fields, so `api/types.go` is untouched.
- **`server.go`:** added the `childrenPageSize`, `downloads` and `unlockedKeys` fields and calls `registerDrive()`.
- **`server_test.go`:**
- `TestLegacyRoutesGone` expected `GET /drive/shares/abc` to be unrouted (404). That route is now real and authenticated, so the test now expects 401. It still asserts that this path is not a 410 legacy route.
- `TestRequestCounting` registered its own handler on the real children pattern, which now panics as a duplicate. It now uses `/tests/volumes/{volumeID}/folders/{linkID}/children`; what it tests is unchanged.
- **Plan:** I ticked the Task 7 checkboxes and recorded these additions in the Interfaces section:
- `AddUserWithoutDrive` and `POST /drive/volumes`;
- `DumpedNode.ID` and `DumpedNode.Err`;
- what `Drafts` returns;
- the storage path `/storage/blocks/{id}`, which replaces `{token}` since the path segment is a block-URL ID and the token travels in the header;
- the 403 answer and the quota field names.
## Tests
`drive_test.go` contains these tests:
| Test | What it checks |
|---|---|
| `TestSeedAndDump` | Two folders and five files: empty, 5 bytes, exactly 4 MiB, 8 MiB+123, and legacy. Plus a doc. The dump matches on ID, path, folder flag, state, content, modtime and SHA1, with `Err == nil`, and there are no drafts. |
| `TestSeedFormats` | Checks the packets directly. Current files: v6 PKESK, SEIPDv2 blocks and block signatures, and the node key advertises SEIPDv2. Legacy files: v3/SEIPDv1, and the key has no SEIPDv2. Name, passphrase, xattr and hash key are SEIPDv1 even when the recipient is an AEAD key. The folder key does not advertise SEIPDv2. |
| `TestCorrupt` (7 subtests) | `Dump` reports an error for each kind of corruption, and a bystander file stays clean. |
| `TestBootstrapRoutes` | my-files, volume and share agree on IDs, key and address. Another user's volume or share gets 422 code 2501. |
| `TestWireFieldNames` | Decodes the links response into maps and asserts the presence of every key the SDK's `nodes/apiService.ts` reads. |
| `TestChildrenPaging` | 250 children plus one trashed child, page size 100: 3 pages, no duplicates, exactly the 250, and 3 requests counted. |
| `TestLinksBatchLimit` | 100 IDs gives 200 and only the 2 known links. 101 IDs gives 422 code 2001. |
| `TestBlockDownloadNeedsToken` | `NoBlockUrls` gives null Token and BareURL. `PageSize=1&FromBlockIndex=2` gives exactly block 2. No token or a wrong token gives 403; the right token gives 200 with bytes whose SHA-256 equals `Hash`. An unknown ID gives 404. |
| `TestBlockBodyIsReadable` | A downloaded block decrypts with the content key and its signature verifies. |
| `TestQuota` | UsedSpace and ProductUsedSpace.Drive equal the stored encrypted size; MaxSpace equals `DefaultMaxSpace`. |
| `TestAccountWithoutDrive` | Gets 422 code 2501 "Volume not found". Five spoiled requests are each rejected with 422 code 2001. A good creation, built in the test with protontest's own crypto, succeeds. A second creation gets code 2500. Afterwards my-files works, Dump is empty, and seeding works. |
Verification commands, run from the repo root after the final edit:
```
$ go build ./backend/protondrive/... # ok
$ go vet ./backend/protondrive/... # ok
$ golangci-lint run ./backend/protondrive/...
0 issues.
$ RCLONE_CONFIG=/notfound go test -race -count=1 ./backend/protondrive/...
ok github.com/rclone/rclone/backend/protondrive 15.190s
? github.com/rclone/rclone/backend/protondrive/api [no test files]
ok github.com/rclone/rclone/backend/protondrive/protontest 21.137s
```
All 21 top-level tests in protontest pass and the output is clean. The protondrive package tests, which use `AddUser`, also still pass.
### TDD evidence
**I did not follow strict RED-then-GREEN.** I wrote the implementation scaffolding before `drive_test.go`, so I have no RED run to show. I am not going to fake one. Two real failures did show up when the tests first ran, and both were real bugs or conflicts:
1. `TestSeedFormats` failed with "extended attributes: expected 0x1, actual 0x2". gopenpgp v3, through go-crypto's `openpgp/v2` `encrypt`, chooses SEIPDv2 whenever every recipient advertises it, whatever the profile's `AEADConfig` says. The xattr encrypted to an AEAD file node key therefore came out as SEIPDv2. The fix is `encryptArmored`: a separately generated session key, a v3 PKESK and SEIPDv1, which is how the SDK's `ignoreSEIPDv2FeatureFlag` behaves.
2. `TestRequestCounting` panicked because it registered a duplicate pattern on the now-real children route.
## Self-review findings, fixed before the commit
- Seeding 250 children took 16 s because of the S2K unlocks. I added the exact key cache, which brought it to 2.4 s. Task 8 seeds 250 children too.
- `Corrupt` block kinds on a file with too few blocks would have panicked with index out of range. They now panic with a clear message.
## Concerns
1. **`drive.go` is 1518 lines**, much larger than the plan probably expected. About 300 lines are wire structs that mirror `driveTypes.ts`, and the rest is seeding, corruption, Dump and routes. I did not split it, per the instructions. A natural split would put the wire types and routes in a `drive_routes.go`, if the controller wants that.
2. **Possible pitfall for the backend.** gopenpgp v3's recipient encryption path silently uses SEIPDv2 and a v6 PKESK for any message to an AEAD-advertising key, such as file node keys, even with the default profile. Names, passphrases and xattrs to file keys must avoid it. Task 5's `crypto.go` may already handle this; I did not look, as instructed. The fake's `Dump` accepts either format when reading, so it would not catch a client that writes SEIPDv2 xattrs. `TestSeedFormats` only checks the fake's own output.
3. **The storage route goes through the server-wide `x-pm-appversion` check**, like every route. If a later client fetches blocks with a bare HTTP client that does not set the header, it will get 400. The SDK itself sends `x-pm-drive-sdk-version` to storage, which the fake rejects by design.
4. **Assumptions about the real API**, none confirmed live:
- children lists only active links, excluding drafts and trashed links;
- an unknown `AnchorID` gets 422 code 2001;
- an unknown or foreign volume, share, link or revision gets 422 code 2501;
- a missing or wrong storage token gets 403 code 2011;
- the link metadata route returns draft links, with `ActiveRevision: null`;
- `MaxSpace` is fixed at 5 GiB.
5. **Lock held while writing:** route handlers under `handleDrive` write their response while holding `srv.mu`. That is harmless for tests but differs from `handleUser`, which unlocks first.
## Files
- `backend/protondrive/protontest/crypto.go` (new)
- `backend/protondrive/protontest/drive.go` (new)
- `backend/protondrive/protontest/drive_test.go` (new)
- `backend/protondrive/protontest/auth.go`, `account.go`, `server.go`, `server_test.go` (modified)
- `docs/plans/001-protondrive-tier1.md` (Task 7 ticked, Interfaces updated)
@@ -0,0 +1,91 @@
### Task 8: Client bootstrap, pacing, node read and listing
**Files:**
- Create: `backend/protondrive/nodes.go`, `backend/protondrive/nodes_internal_test.go`
- Create: `backend/protondrive/cache.go`, `backend/protondrive/cache_internal_test.go`
- Create: `backend/protondrive/testutil_internal_test.go`
- Modify: `backend/protondrive/api/types.go`
**Interfaces:**
- Consumes: Tasks 2, 4, 5, 7.
- Produces:
- `type clientOptions struct { UploadConcurrency int; ReplaceExistingDraft, EnableCaching, IgnoreSignatureErrors bool; DraftClientUID string }`
- `type client struct { ... }` and
`func newClient(ctx context.Context, s *session, opt clientOptions) (*client, error)`
- loads the account with the stored key password, bootstraps volume,
share and root; creates `apiPacer` and `storagePacer`
(`fs.NewPacer(ctx, pacer.NewDefault(pacer.MinSleep(10*time.Millisecond), pacer.MaxSleep(2*time.Second), pacer.DecayConstant(2)))`)
and two `pacer.TokenDispenser`s of 10 (upload blocks, download blocks).
- `func (c *client) rootID() string`
- `func (c *client) apiCall(ctx context.Context, opts *rest.Opts, request, response any) error`
- `apiPacer.Call` around `session.call` with `c.shouldRetry`.
- `func (c *client) shouldRetry(ctx context.Context, resp *http.Response, err error) (bool, error)`:
context error -> no; 429 -> `pacer.RetryAfterError` honouring
`Retry-After` (default 10s); 408 and 5xx -> retry; `*api.Error` with
any other 4xx -> no; `errSessionExpired`, `errCaptcha` -> no; else
`fserrors.ShouldRetry(err)`.
- `type node struct { ID, ParentID, Name, MIMEType string; IsFolder bool; State int; CreateTime, ServerModTime time.Time; EncryptedSize int64; RevisionID string; X *xattrs; ... }`
(unexported fields hold keys and the raw link),
`func (n *node) size(original bool) int64`,
`func (n *node) modTime() time.Time` (xattr time, else server time),
`func (n *node) sha1() string` (lower-case, `""` if unknown).
- `func (c *client) getNode(ctx context.Context, id string) (*node, error)`
- `func (c *client) listChildren(ctx context.Context, parentID string, fn func([]*node) error) error`
- pages child IDs, fetches metadata 100 per request with at most 15
requests in flight, decrypts, calls `fn` per batch. Skips, with one
`fs.Errorf` each and a counter, nodes whose name cannot be decrypted or
fails `validateName`; skips Proton Docs/Sheets nodes with one
`fs.Logf` notice per client. Signature `verifyErr`s are logged at
`ERROR` once per node and do not skip.
- `func (c *client) findChild(ctx context.Context, parentID, name string) (*node, error)`
- cache first, else lists the parent (populating the cache);
`errNotFound` if absent.
- `var errNotFound`, `var errExists`
- `func (c *client) about(ctx context.Context) (used, total int64, err error)`
- `func (c *client) close()`
- `cache.go`: `type nodeCache` with `get(id)`, `put(*node)`,
`children(parentID) ([]*node, bool)`, `setChildren(parentID, []*node)`,
`invalidate(id)`, `invalidateChildren(parentID)`, `clear()`; a nil or
disabled cache is a valid no-op (`EnableCaching: false`).
- `testutil_internal_test.go`:
`func newTestClient(t *testing.T) (*client, *protontest.Server, *protontest.User)`
(seeds a session with `NewSession`, no login round trip) and
`func newTestClientOpt(t, clientOptions)`.
Reference: `client/js/src/internal/nodes/` (`apiService.ts`,
`nodesAccess.ts`, `cryptoService.ts`), `shares/`,
`apiService/apiService.ts` for retry behaviour.
- [ ] **Step 1: Write failing tests**:
- `TestClientBootstrap`: `rootID()` equals `user.RootID()`; no legacy
route was requested (`Requests()` has no key containing
`/drive/shares/` other than `GET /drive/shares/{shareID}`).
- `TestListChildren`: seeded files and folders come back with the right
names, sizes (`size(true)` = plaintext length), modtimes, SHA1.
- `TestListChildrenPaged` (Review Focus 5): 250 children with page size
100 are each returned exactly once; request count is
`3 + 3 + bootstrap` (3 child pages, 3 metadata batches).
- `TestListUnicodeAndCase` (Review Focus 2): seeded names `"é.txt"`
(NFC), `"é.txt"` (NFD), `"A.txt"`, `"a.txt"` are four distinct
nodes, and `findChild` returns the right ID for each.
- `TestListSkipsDocs`, `TestListSkipsUndecryptableName`
(`Corrupt(id, "name")`; the other children still list),
`TestListBadSignatureStillListed` (`"name-signature"`),
`TestListRejectsHostileNames`: seeded raw names `"../x"`, `"a/b"`,
`"n\x00ul"`, and a 10 MiB name are all skipped.
- `TestFindChildNotFound`: `errors.Is(err, errNotFound)`.
- `TestCacheWarmListing`: a second `listChildren` of the same folder
makes 0 requests; with `EnableCaching: false` it repeats them.
- `TestShouldRetry` table for every row of the rule above.
- `TestRetryAfterHonoured`: injected 429 `RetryAfter: 1`, twice; the call
succeeds after roughly 2s and made 3 attempts.
- `TestAbout`.
- `TestLegacyFileReadable`: a `SeedLegacyFile` node lists with correct
name and size.
- [ ] **Step 2: Run** - expect FAIL.
- [ ] **Step 3: Implement.**
- [ ] **Step 4: Run** the verification commands - expect PASS.
- [ ] **Step 5: Commit** `protondrive: add node listing with cache and pacing`.
@@ -0,0 +1,172 @@
# Task 8 report: client bootstrap, pacing, node read and listing
**Status:** DONE_WITH_CONCERNS (the concerns are small and listed at the end)
**Commit:** `2065b7b09 protondrive: add node listing with cache and pacing`
## What I implemented
### `backend/protondrive/nodes.go` (≈760 lines, under the 900 limit)
- `clientOptions`, `client`, and `newClient(ctx, s, opt)`. `newClient` loads the account with the stored key password, then bootstraps:
- `GET /drive/v2/shares/my-files`.
- On code 2501 it calls `createVolume`, which follows the SDK's `generateVolumeBootstrap` and `createVolume`. It generates the share key (encrypted to and signed by the primary address key) and the root key (encrypted to the share key, signed by the address key), encrypts the name `"root"`, generates the hash key, and sends `POST /drive/volumes` with `AddressID` and `AddressKeyID`. Code 2500 means another client created the volume first, and is accepted.
- It then fetches my-files again.
- It decrypts the share key with the keys of the share's address, verified against `publicKeys(CreatorEmail)` as the SDK's `decryptRootShare` does, and decrypts the root node.
- It creates `apiPacer` and `storagePacer` with the exact values from the brief, and two `TokenDispenser(10)`s.
- `apiCall` runs `apiPacer.Call` around `session.call` with `shouldRetry`. **Extra behaviour:** on a retryable `RetryAfterError` it sleeps for the Retry-After (bounded by ctx) before returning to the pacer. I found that lib/pacer starts the Retry-After wait only when the *retry* begins, so the first retry goes out at once. Without the sleep, `TestRetryAfterHonoured` measured 1.0s, not 2s.
- `shouldRetry` follows the brief's rule in the brief's order: context error, 429 (`Retry-After` in seconds or as a date, default 10s), 408 and 5xx, `*api.Error` with another 4xx, `errSessionExpired`/`errCaptcha`, then `fserrors.ShouldRetry`.
- `node` has the fields from the brief plus unexported `keys`, `hashKey`, `keyVerifyKR` and `link`, and the methods `size(original)`, `modTime()` and `sha1()`.
- `decryptNode` picks verification keys exactly as `nodes/cryptoService.ts` `decryptNode` does:
- Key: the signature email's keys, else the parent key. A root has no parent, so it gets none.
- Name: the same keys if `NameSignatureEmail == SignatureEmail`, else the name email's keys, else the parent key.
- Hash key: the node key plus the signature email's keys.
- Folder xattrs: the signature email's keys, else the node key.
- Revision xattrs: the revision email's keys, else the node key.
- The SDK's leniency for unsigned names created before 2021-01-01 and unsigned hash keys created before 2021-08-01 is kept.
- The node is unreadable, and left out, if its name or key can't be decrypted, its name fails `validateName`, or its type is unknown.
- A hash key or xattrs that won't decrypt is logged once and the node is kept.
- `getNode` covers any state. It walks up the parents, using an always-on cache of folder keys (node keys never change), so it works with caching off too. `listChildren` and `findChild` match the brief. `about` returns `UsedSpace` and `MaxSpace` from `/core/v4/users`. `close` clears the cache and the key material.
- Listing pages child IDs in order and drops duplicate IDs across pages. It cuts batches of 100 and runs them in an `errgroup` limited to 15, with calls to `fn` serialised. If a batch fails, its error wins over the paging error caused by the cancellation. The listing is cached only once it completes.
- A child is left out silently if it is no longer in this folder, is not active, or is a file without an active revision (a draft). Docs and Sheets (`application/vnd.proton.doc` / `.sheet`) are left out with one `fs.Logf` NOTICE per client.
- Unreadable nodes get one `fs.Errorf` and one count in `c.skipped` per node per client. Signature failures get one `fs.Errorf` per node per client and are still listed.
### `backend/protondrive/cache.go`
`nodeCache` with the methods from the brief; a nil cache is a no-op. The listings field is named `listings` because a field called `children` would clash with the `children()` method. `invalidate(id)` also drops the parent's listing and the node's own listing.
### `backend/protondrive/api/types.go`
New types: `Volume`, `Share`, `Link`, `Folder`, `ActiveRevision`, `File`, `LinkDetails`, `MyFilesResponse`, `CreateVolumeRequest`, `ShareReference`, `VolumeDetails`, `VolumeResponse`, `LinksRequest`, `LinksResponse`, `ChildrenResponse`, plus constants for link types and states. `User` gains `UsedSpace` and `MaxSpace`. All JSON names come from `driveTypes.ts` / `coreTypes.ts`, and nothing is shared with protontest.
### `backend/protondrive/account.go` (small change)
`address.primaryKeyID` is set from `unlockAddressKeys`, which now also returns the ID of its first unlocked key. `address.signingKeyRing()` returns a key ring holding only that key. The SDK sends `AddressKeyID` (the primary key) and signs and encrypts with that single key; the ring holding all address keys could sign with several.
### `backend/protondrive/testutil_internal_test.go`
`newTestClient`, `newTestClientOpt`, and a helper `newTestClientFor(t, srv, u, opt)` for an existing user.
### Plan
I ticked Task 8 and updated its Interfaces block:
- volume creation
- the Retry-After wait
- the unexported node fields
- `errExists` moves to Task 9, and I added it to Task 9's Produces
- the `account.go` additions
- the renamed test `TestClientShouldRetry`
## Tests
`nodes_internal_test.go`: TestClientBootstrap, TestClientBootstrapCreatesVolume, TestClientBootstrapCreateVolumeRace, TestListChildren, TestNodeWithoutXAttrs, TestGetNode, TestListChildrenPaged, TestListUnicodeAndCase, TestListSkipsDocs, TestListSkipsUndecryptableName, TestListBadSignatureStillListed, TestListRejectsHostileNames, TestFindChildNotFound, TestCacheWarmListing, TestClientShouldRetry (14 rows), TestRetryAfterHonoured, TestAbout, TestLegacyFileReadable.
`cache_internal_test.go`: TestNodeCacheDisabled, TestNodeCache.
What some of them check:
- **TestListChildrenPaged:** with page size 100, after `ResetRequests`, the listing makes exactly 3 children requests and 3 links requests, 6 in total. All 250 children come back once each, with the right IDs.
- **TestClientBootstrapCreatesVolume:**
- Exactly one `POST /drive/volumes` is made.
- `Dump()` is empty.
- The fake then seeds a folder and a file into the new root, which requires decrypting the root key and hash key the client generated. `Dump()` shows both with no `Err`, and the client lists both.
- A second `newClient` makes 0 create requests.
- **TestClientBootstrapCreateVolumeRace:** a fault delays the first two `my-files` GETs by 500 ms, so both clients see 2501 before either creates. Both POST (count == 2), so one of them necessarily gets 2500. Both clients come up with the same root and are usable. I ran it with `-count=5`: passing.
- **TestListRejectsHostileNames:** also covers `".."`.
- **TestListBadSignatureStillListed:** also covers `passphrase-signature`.
- **TestCacheWarmListing:** with caching on, the second listing plus a `findChild` make 0 requests. With caching off they make 4 requests: the listing twice, and no parent lookup because folder keys are always kept.
### TDD evidence
I drafted the implementation before writing the tests, which is not the TDD order. For RED I moved `nodes.go` and `cache.go` out of the package and ran the new tests:
```
RCLONE_CONFIG=/notfound go test ./backend/protondrive/ -run 'TestClient|TestList|...'
backend/protondrive/nodes_internal_test.go:26:31: undefined: client
backend/protondrive/testutil_internal_test.go:19:41: undefined: clientOptions
backend/protondrive/cache_internal_test.go:11:7: undefined: newNodeCache
FAIL github.com/rclone/rclone/backend/protondrive [build failed]
```
This failure was expected because none of the client exists without those files. The first real run after restoring them did give a genuine behavioural RED: `TestRetryAfterHonoured` failed with `"1.012814364s" is not greater than or equal to "2s"`, which led to the Retry-After wait in `apiCall`.
GREEN:
```
go build ./backend/protondrive/... ok
RCLONE_CONFIG=/notfound go test -race ./backend/protondrive/... -count=1
ok github.com/rclone/rclone/backend/protondrive 30.733s
ok github.com/rclone/rclone/backend/protondrive/protontest 21.321s
go vet ./backend/protondrive/... ok
golangci-lint run ./backend/protondrive/... 0 issues.
```
Test output contains the expected NOTICE and ERROR log lines from the code under test (for example "leaving node ... out of listing"). There are no stray warnings.
## Files changed
- /home/user/rclone/backend/protondrive/nodes.go (new)
- /home/user/rclone/backend/protondrive/nodes_internal_test.go (new)
- /home/user/rclone/backend/protondrive/cache.go (new)
- /home/user/rclone/backend/protondrive/cache_internal_test.go (new)
- /home/user/rclone/backend/protondrive/testutil_internal_test.go (new)
- /home/user/rclone/backend/protondrive/api/types.go
- /home/user/rclone/backend/protondrive/account.go
- /home/user/rclone/docs/plans/001-protondrive-tier1.md
## Decisions and concerns
1. **`errExists` is not in Task 8.** The `unused` linter rejects it with no user. I moved it to Task 9's Produces in the plan.
2. **The content key packet is not decrypted when listing.** The SDK's `decryptNode` does decrypt it. I deferred it to download (Task 10), which saves an asymmetric decrypt per file in every listing. The node keeps `keys`, `keyVerifyKR` and the raw `link` so Task 10 can decrypt and verify it the SDK's way: node key plus key verification keys, and for nodes created before 2023 a fallback to all of the account's own address keys. That fallback is **not implemented yet**; Task 10 should add it.
3. **The Retry-After sleep inside `apiCall`** is needed because of how lib/pacer orders its wait. With `--tpslimit`-style connection limits, the sleeping call holds its pacer connection token while it waits.
4. **Unreadable node keys and unknown node types** are skipped and counted like undecryptable names. The brief named only name failures; a node without a key can't be read at all.
5. **`getNode` compared with listing:** `getNode` returns an error for an unreadable node. Listing hides Docs, drafts and trashed nodes, but `getNode` returns them by ID, since callers may need a trashed node.
6. **Unchanged:** `IgnoreSignatureErrors`, `UploadConcurrency`, `ReplaceExistingDraft` and `DraftClientUID` are stored but not used yet, and listing never fails on a signature error anyway. No identifiers collide with `protondrive.go`: I used `newDrivePacer`, `defaultRetryAfter` and `blocksInFlight` rather than `minSleep` and the like.
---
## Fix round 1
**Commit:** `a8856ff7c protondrive: cap Retry-After and retry public key lookups`
### Finding 1: a hostile Retry-After could stall the client; Retry-After was also waited twice
- `retryAfter` now caps the wait at `maxRetryAfter = 60s` (a named constant):
- A value of digits is parsed with `ParseUint` and compared with the cap *before* it is multiplied, so it can't overflow. A value too large for a uint64 also gives the cap.
- An HTTP-date is capped with `min(time.Until(t), maxRetryAfter)`.
- A value that is zero, negative, past, garbage or missing gives the 10s default. The 10s for zero is what the SDK does (`parseInt(...) || 10`).
- **One mechanism now does the waiting:**
- `apiCall` waits out the capped Retry-After itself, before the retry. The wait ends early if the context is cancelled.
- It then hands the pacer `pacer.RetryAfterError(callErr, 0)`, which `pacer.Default` turns into its MinSleep.
- So the retry is delayed by about N, once. The shared pacer's sleep never comes from a Retry-After, so it stays at or below its MaxSleep (2s).
- Other callers are never held in `beginCall`, which can't be cancelled.
- The comment in `apiCall` documents this choice.
### Finding 2: public key lookups were not paced or retried
- `account.go` gains `type apiCaller func(ctx, *rest.Opts, request, response any) error`, and `publicKeys` and `fetchPublicKeys` now take an `apiCaller` instead of `*session`.
- The client passes `c.apiCall`, so key lookups are paced and retried. This adds no new import and no package state.
- The account tests pass a `sessionCaller(s)` adapter.
- For the test, protontest gains `User.SeedFileSignedBy(signer, parentID, name, content, modTime)`. It seeds a file whose signatures and signature emails all belong to another user's primary address. It is a small spec field in `seedFile`, and the fake's `Dump` still verifies such a file.
### Tests added
- **`TestClientShouldRetry`:** now table-driven by status and header. New rows:
- huge `999999999` gives 60s
- overflowing `99999999999999999999999999` gives 60s
- a date an hour ahead gives 60s
- a past date, `0`, `-5` and `soon` give the 10s default
- **`TestRetryAfterDate`:** a date 5s ahead gives a wait in (3s, 5s].
- **`TestRetryAfterDoesNotHoldOtherCalls`:** after a 429 with `RetryAfter: 1`, the retried call takes at least 1s. An unrelated call made right after it takes under 500ms.
- **`TestRetryAfterCancel`:** a 429 with `RetryAfter: 999999999` followed by a 300ms context timeout returns `context.DeadlineExceeded` in under 2s. The next call is not delayed (under 500ms).
- **`TestListPublicKeysRetried`:** one 429 (`RetryAfter: 1`) on `GET /core/v4/keys/all` while listing a folder holding a file signed by another user. The listing succeeds with 2 key requests, and the signature verifies.
- **`TestListFailedBatchNotCached`:** a links-route fault with `Times: 1` makes `listChildren` return an error. The next listing hits the server again.
- **`TestListCallbackError`:** a callback error stops the listing after 1 call and is returned. Nothing is cached, so a later listing returns all 150 children from the server.
- **`TestListCancel`:** with metadata requests delayed by 10s, a 300ms timeout returns `DeadlineExceeded` in under 2s. `runtime.NumGoroutine()` settles back to the baseline within a 5s settle loop. This test is not parallel.
- `TestRetryAfterHonoured` still passes: 3 attempts in about 2s.
### Commands and output
```
RCLONE_CONFIG=/notfound go test -race ./backend/protondrive/ -count=3 -run 'TestRetryAfter|TestListCancel|TestListPublicKeys'
ok github.com/rclone/rclone/backend/protondrive 67.654s
RCLONE_CONFIG=/notfound go test -race ./backend/protondrive/... -count=1
ok github.com/rclone/rclone/backend/protondrive 53.358s
? github.com/rclone/rclone/backend/protondrive/api [no test files]
ok github.com/rclone/rclone/backend/protondrive/protontest 20.921s
go vet ./backend/protondrive/... (no output)
golangci-lint run ./backend/protondrive/...
0 issues.
```
### Plan
I updated Task 8's Interfaces block:
- the `apiCall` Retry-After mechanism and its 60s cap
- `apiCaller` and the new `publicKeys` signature
- `protontest.SeedFileSignedBy`
@@ -0,0 +1,50 @@
### Task 9: Folder and node mutations
**Files:**
- Modify: `backend/protondrive/nodes.go`, `backend/protondrive/nodes_internal_test.go`
- Create: `backend/protondrive/protontest/mutate.go`
**Interfaces:**
- Produces (`protontest`): routes
`POST /drive/v2/volumes/{volumeID}/folders`,
`PUT .../links/{linkID}/rename`, `PUT .../links/{linkID}/move`,
`POST /drive/volumes/{volumeID}/links/{linkID}/copy`,
`POST /drive/v2/volumes/{volumeID}/trash_multiple`,
`DELETE /drive/volumes/{volumeID}/trash`,
`POST .../links/{parentID}/checkAvailableHashes`.
The fake enforces: name-hash uniqueness within a folder among non-trashed
nodes (HTTP 422 code 2500 with `Details.ConflictLinkID`); rename requires
the correct `OriginalHash`; move into own descendant is rejected; unknown
IDs give code 2501.
- Produces (`protondrive`):
- `var errExists`
- `func (c *client) createFolder(ctx context.Context, parentID, name string, modTime time.Time) (*node, error)`
- `errExists` on code 2500.
- `func (c *client) rename(ctx context.Context, n *node, newName string) error`
- `func (c *client) move(ctx context.Context, n *node, newParentID, newName string) error`
- re-encrypts passphrase and name to the new parent; `errExists` on
2500.
- `func (c *client) copyNode(ctx context.Context, n *node, newParentID, newName string) (*node, error)`
- `func (c *client) trash(ctx context.Context, ids ...string) error`
- batched; per-ID failures are joined into the returned error.
- `func (c *client) emptyTrash(ctx context.Context) error`
- Every mutation updates the cache (`invalidate`/`invalidateChildren`).
Reference: `client/js/src/internal/nodes/nodesManagement.ts`,
`apiService.ts` lines for rename, move, copy, trash.
- [ ] **Step 1: Write failing tests**: `TestCreateFolder`,
`TestCreateFolderExists`, `TestRename`, `TestMove` (file and folder,
content still downloadable by the fake's `Dump`), `TestMoveExists`,
`TestMoveIntoSelf`, `TestCopy` (new ID, same content, source intact),
`TestTrash` (state changes, name becomes reusable), `TestTrashMissing`
(`errNotFound`), `TestEmptyTrash`, `TestMutationsInvalidateCache`
(list, mutate, list again reflects the change without a manual flush).
Each asserts with `user.Dump()`.
- [ ] **Step 2: Run** - expect FAIL.
- [ ] **Step 3: Implement.**
- [ ] **Step 4: Run** the verification commands - expect PASS.
- [ ] **Step 5: Commit** `protondrive: add folder, move, copy and trash operations`.
@@ -0,0 +1,171 @@
# Task 9 report: folder and node mutations
**Status:** DONE
**Commit:** `ee81e742f protondrive: add folder, move, copy and trash operations`
## What was implemented
### Client (`backend/protondrive/nodes.go`, `cache.go`, `api/types.go`)
- `errExists` is defined in nodes.go, alongside `errNotFound`.
- `mutationError` maps API code 2500 to `errExists` and 2501 to `errNotFound`. The API error stays wrapped as well.
- `createFolder(ctx, parentID, name, modTime)` follows the SDK's `createFolder`/`cryptoService.createFolder`:
- a v4 node key without AEAD (`generateNodeKeys(..., false, ...)`);
- the name and passphrase are signed by the share's address key (`addr.signingKeyRing()`);
- a hash key;
- folder extended attributes holding only `ModificationTime`, and only when `modTime` is non-zero;
- `SignatureEmail` is the address email.
- It does not check names in advance: the server decides, and its 2500 becomes `errExists`.
- It builds the returned node locally, as the SDK's `generateNodeFolder` does, with a synthesised `LinkDetails` so that a later rename or move has the encrypted name and hash. It remembers the node's keys and caches it.
- `rename(ctx, n, newName)`: the new name reuses the old name's session key. It sends `OriginalHash`, then forgets the node and its parent's listing whether the call succeeded or failed.
- `move(ctx, n, newParentID, newName)` and `copyNode(...)` share `encryptForNewParent`, which is the SDK's `encryptNodeWithNewParent`. The name and passphrase are re-encrypted to the new parent with their session keys and signed by the address.
- `NodePassphraseSignature` and `SignatureEmail` are sent **only** when the node's key is anonymous (`Link.SignatureEmail == ""`), as `nodesManagement.ts` does for moves and copies.
- Move forgets the node and both parents' listings.
- Copy sends only what the SDK sends (name, passphrase, hash, target, signature fields). It invalidates the target listing and returns the new node via `getNode`.
- `trash(ctx, ids...)` sends batches of `metadataBatchSize` (100) to `trash_multiple`. Each failed `Responses[]` entry becomes a `mutationError`, so code 2501 wraps `errNotFound`; these are joined. As in the SDK's `handleResponseErrors`, a node with no response entry counts as trashed. Every ID in the batch is invalidated.
- `emptyTrash(ctx)` calls `DELETE /drive/volumes/{id}/trash`, then `cache.clear()`. Trashed nodes, and cached descendants of trashed folders, would otherwise stay readable from the cache.
- `nodeCache.put` fix (you asked for it or a workaround): if the cached node had a different parent, both parents' listings are forgotten. Move also invalidates explicitly, so either mechanism alone keeps listings correct.
- `api/types.go` gains these types, with Proton's JSON names: `CreateFolderRequest/Response`, `RenameRequest`, `MoveRequest` (MoveLinkV2RequestDto), `CopyRequest/Response`, `LinkResult` and `MultiLinkResponse`. `LinksRequest` now also documents `trash_multiple`.
- Two small helpers: `targetFolder` (the folder must have a readable hash key) and `volumePath`. `forget` combines invalidate with invalidateChildren.
### Fake (`protontest/mutate.go`, plus small changes to `drive.go`)
- Routes, all under `handleDrive`:
- `POST /drive/v2/volumes/{v}/folders`
- `PUT .../links/{id}/rename`
- `PUT .../links/{id}/move`
- `POST /drive/volumes/{v}/links/{id}/copy`
- `POST /drive/v2/volumes/{v}/trash_multiple` (returns 1001 with a response per link)
- `DELETE /drive/volumes/{v}/trash` (removes trashed links and everything below them)
- `POST /drive/v2/volumes/{v}/links/{id}/checkAvailableHashes` (available hashes, plus pending drafts filtered by `ClientUID`)
- Format strictness (your ruling), parsed with go-crypto's `OpaqueReader` and independent of the backend's crypto:
- `checkMessage` rejects anything other than v3 PKESKs followed by one SEIPDv1 packet. It is applied to Name, NodePassphrase, NodeHashKey and XAttr on create, to Name on rename, and to Name and NodePassphrase on move and copy.
- `checkFolderKey` requires every secret key packet to be v4 with S2K usage 254 (CFB, SHA-1 check) and S2K mode 3 (iterated and salted). It rejects any signature carrying the SEIPDv2 feature or preferred AEAD cipher suites.
- Every rejection is HTTP 422, code 2001.
- Checks a real server could make:
- `NodeHashKey` and `NodePassphraseSignature` are present.
- The hash is 64 lower-case hex characters.
- Signature emails are the user's own addresses.
- Name hashes are unique among untrashed nodes. A clash gets 2500 with `Details.ConflictLinkID`, plus revision and draft IDs.
- Rename and move require a matching `OriginalHash` (code 2000, which the SDK maps to `NodeOutOfSyncError`).
- Moving or copying a folder into itself or a descendant gets 2000.
- The root can't be renamed, moved, copied or trashed (2000).
- Unknown links or parents get 2501.
- **Anonymous rule:** move and copy must include `NodePassphraseSignature` and `SignatureEmail` exactly when the stored node is anonymous. A signed node keeps its stored signature and signer, since the passphrase plaintext is unchanged. An anonymous node takes the given ones.
- Copy follows the SDK: only the name, passphrase, hash and (for an anonymous node) the signature come from the client. The fake copies the node key, content key packet, active revision (new revision ID, shared immutable blocks), hash key and xattr. A folder copy recursively copies its active descendants with new IDs; their names and passphrases still decrypt, because the folder key doesn't change.
- `drive.go` changes:
- `registerMutations()` is wired in.
- `SeedAnonymousFile` seeds a public-link upload: no signature emails, the name and passphrase signed by the parent node key, everything else by the file key.
- **`Dump` now also checks every name hash** against the decrypted name and the parent's hash key. Without this, a wrong `Hash` from the client would go unnoticed, because Dump didn't look at hashes before.
## Tests and results
Verification commands, run from the repo root at the final state:
```
$ go build ./backend/protondrive/... # ok
$ go vet ./backend/protondrive/... # ok
$ RCLONE_CONFIG=/notfound go test -race -count=1 ./backend/protondrive/...
ok github.com/rclone/rclone/backend/protondrive 56.289s
? github.com/rclone/rclone/backend/protondrive/api [no test files]
ok github.com/rclone/rclone/backend/protondrive/protontest 25.131s
$ golangci-lint run ./backend/protondrive/...
0 issues.
```
### Client tests (`nodes_internal_test.go`)
After every mutation, `requireDump` fails the test if any node returned by `user.Dump()` has a non-empty `Err`.
- `TestCreateFolder`: nested folders, the modtime round trip, a second client without cache lists them with the same hash key, an invalid name, and an unknown parent giving `errNotFound`.
- `TestCreateFolderExists`: an existing folder, an existing file, and a **Docs document hidden from listings**. All three give `errExists` from the server, which shows there is no `findChild` pre-check.
- `TestRename`: a file and a folder (the child stays readable). A stale node gives an error that is not `errExists`. An existing name gives `errExists`. Renaming the root or to an empty name fails.
- `TestMove`: a file, a folder with a child, moving to the root, and an **anonymous file**. After the move, `SignatureEmail` and `NameSignatureEmail` are the user, and Dump verifies the passphrase signature. An unknown target gives `errNotFound`.
- `TestMoveExists`: a visible name and a hidden Docs name both give `errExists`.
- `TestMoveIntoSelf`: moving into itself and into a descendant are rejected, not with `errExists`.
- `TestCopy`: covers each of the following.
- A file copy gets a new ID with the same SHA-1, modtime and content, and the source is intact.
- Copying into the same folder works under a new name and gives `errExists` under the same name.
- An anonymous copy is signed by the user.
- A folder copy includes its child.
- `TestTrash`: trashed states in Dump, the listing updates, the names become reusable, and an empty `trash()` call works.
- `TestTrashMissing`: an unknown ID gives `errNotFound`. In a mixed batch the real node is trashed and only the missing IDs appear in the error. 101 IDs take exactly 2 `trash_multiple` requests.
- `TestEmptyTrash`: only the untrashed file remains. Cached and uncached trashed nodes, including a cached child of a trashed folder, then give `errNotFound`.
- `TestMutationsInvalidateCache`: the warm cache reflects each change without a manual flush.
- Move between two already-listed folders, **in both directions**, with `getNode` returning the right `ParentID` each time.
- Rename, createFolder, copy, trashing a file and trashing a folder.
- `TestNodeCache` (in `cache_internal_test.go`) gains a case: `put` with a changed parent forgets both listings.
### Fake tests (`protontest/mutate_test.go`)
- `TestCreateFolderFormats`:
- Name, NodePassphrase, NodeHashKey and XAttr are each rejected in three forms: v6 PKESK with SEIPDv2, v3 with SEIPDv2, and v6 with SEIPDv1. The two mixed forms are spliced from real packets.
- NodeKey is rejected when it advertises AEAD, is v6, is AEAD-locked with an iterated S2K, is Argon2-locked, uses a salted (non-iterated) S2K, or is unlocked.
- The request is rejected for a missing hash key, a missing signature, a bad hash, a missing signature email, or someone else's email; for an unknown parent it gets 2501.
- A valid request yields a node that Dump decrypts with the right modtime.
- A name clash gets 2500 with the right `ConflictLinkID` and `ConflictRevisionID`.
- `TestRenameRules`: unknown link, stale `OriginalHash`, root, AEAD name, clash, and renaming to its own hash (allowed).
- `TestMoveRules`: 14 rejection cases, including both directions of the anonymous rule. A signed node keeps its signature; an anonymous one takes the given signature.
- `TestCopyRules`: rejections, then a copy shares the node key, content key packet and blocks, under a new revision ID.
- `TestTrashAndEmptyTrash`, `TestCheckAvailableHashes` (draft pending, filtered by `ClientUID`, trashed hash available), `TestDumpChecksNameHash`.
- `TestMutationWireNames`: each backend `api` request and response type decodes into the fake's type with `DisallowUnknownFields`, which catches JSON name typos on either side.
### Sabotage checks
These were run to confirm the tests bite; each change was reverted afterwards.
- Removing the cache invalidation from `move` makes `TestMutationsInvalidateCache` fail ("Should be empty, but was [f]").
- Always sending `NodePassphraseSignature` makes `TestMove` and `TestCopy` fail with the fake's "must not be given for a signed node" (2001).
## TDD evidence
- **RED** (client tests written before the implementation):
```
$ RCLONE_CONFIG=/notfound go test ./backend/protondrive/ -run 'TestNodeCache$|TestCreateFolder|TestRename|TestMove|TestCopy|TestTrash|TestEmptyTrash|TestMutations'
backend/protondrive/nodes_internal_test.go:651:16: c.createFolder undefined (type *client has no field or method createFolder)
...
backend/protondrive/nodes_internal_test.go:698:26: undefined: errExists
FAIL github.com/rclone/rclone/backend/protondrive [build failed]
```
This was expected: none of the methods existed yet.
- **GREEN**:
```
$ RCLONE_CONFIG=/notfound go test ./backend/protondrive/ -count=1 -v -run '...same...'
--- PASS: TestNodeCache, TestEmptyTrash, TestTrashMissing, TestMoveExists, TestMoveIntoSelf,
TestMove, TestCreateFolderExists, TestTrash, TestRename, TestCreateFolder, TestCopy,
TestMutationsInvalidateCache
ok github.com/rclone/rclone/backend/protondrive 0.913s
```
- The fake-side tests in `mutate_test.go` were written **after** `mutate.go`, not test-first. They are the proofs of rejection that your ruling asked for, not TDD. One test bug was fixed while writing them: two moves used the same target hash.
## Files changed
- `backend/protondrive/nodes.go`: errExists and the mutation methods.
- `backend/protondrive/cache.go`: the `put` fix for a changed parent.
- `backend/protondrive/api/types.go`: request and response types.
- `backend/protondrive/nodes_internal_test.go`, `cache_internal_test.go`: tests.
- `backend/protondrive/protontest/mutate.go` (new): routes and format checks.
- `backend/protondrive/protontest/mutate_test.go` (new): fake tests.
- `backend/protondrive/protontest/drive.go`: route registration, `SeedAnonymousFile`, Dump name-hash check.
- `docs/plans/001-protondrive-tier1.md`: Task 9 boxes ticked. The Interfaces block now lists the extra fake rules, `SeedAnonymousFile` and the Dump hash check.
- `docs/TODO.md`: one entry (below).
## Decisions made
- `nodeCache.put` was fixed, and move/rename also invalidate explicitly.
- `emptyTrash` clears the whole cache, because descendants of trashed folders are deleted too and the cache can't find them cheaply. Folder keys are kept, since keys never change.
- The fake rejects a passphrase signature sent for a signed node, enforcing the SDK's "if and only if" comment strictly. It is unknown whether the real server rejects this.
- On move and rename, the fake returns code 2000 both for a stale `OriginalHash` and for moving a folder into its own descendant. The SDK documents 2000 for out-of-sync nodes; the code for moving into a descendant is my choice.
- A trash entry for an unknown or non-active link gets code 2501.
- A fake folder copy is deep, copying active descendants only.
- The fake accepts only S2K usage 254 for folder keys, rejecting the legacy 255 as well.
- The created-folder node is built locally (no extra GET). The copied node is fetched with `getNode`, since the server assembled it.
- `trash` stops at the first batch whose whole request fails, and returns the per-ID errors collected so far joined with that error.
- Wire format: `CreateFolderRequest` sends `SignatureEmail`, as the SDK does, although `CreateFolderDto` lists `SignatureAddress`. This is noted in the commit.
- No identifier collided with `protondrive.go`.
## Concerns
- `client.apiCall` retries POST and PUT mutations after 5xx and network errors, as the SDK does. If a lost response hid a success, the retry surfaces `errExists` or a stale-hash error even though the change happened. This is recorded in `docs/TODO.md` and not fixed here.
- `nodes.go` is now about 1,160 lines. The plan puts the mutations there, so I did not split it.
- The fake's write-time checks cover format and server-plausible rules only. Whether the content decrypts and verifies is left to `Dump`, now including name hashes, which every client test calls after each mutation.
+13 -1
View File
@@ -1,5 +1,5 @@
---
status: approved
status: in-progress
spec: docs/specs/001-protondrive-tier1.md
---
@@ -25,6 +25,13 @@ must never import `backend/protondrive`.
`fs/fshttp`, `github.com/ProtonMail/gopenpgp/v3`,
`github.com/ProtonMail/go-srp`, `testify`.
> **Paused 2026-11-11 after Task 17.** Tasks 1-17 are done and reviewed;
> 18-21 and the final whole-branch review are not started. Read
> `docs/plans/001-protondrive-tier1-HANDOFF.md` before continuing: it holds
> the state, the rulings that override parts of this plan, the test-account
> setup and how to resume. The execution ledger, briefs, reports and reviews
> are in `docs/plans/001-protondrive-tier1-workspace/`.
**Spec:** `docs/specs/001-protondrive-tier1.md` - read it first. This plan
covers its milestones M0 to M4. Events (M5) and the persistent cache,
Metadata and small-file path (M6) get their own plan afterwards.
@@ -1325,6 +1332,11 @@ allocated in `PORTS.md`, echo its `key=value` lines and
### Task 21: Live verification (blocked until a test account exists)
> Out of date since 2026-11-11: two test accounts exist and are configured
> as `TestProtonDrive:` (free) and `TestProtonDrivePaid:` (paid, never signed
> in to) with username and password. See section 5 of the handoff for the
> setup and section 4 for what to check first.
Needs the fork owner to create the account and configure it themselves as
`TestProtonDrive:` in their rclone config (`rclone config`, browser
sign-in). Credentials must never be pasted into an agent conversation or
+3
View File
@@ -12,3 +12,6 @@ Fork-only. Implementation plans from the superpowers writing-plans workflow.
- Tick checkboxes as you go so another agent can resume an interrupted session.
- Front matter: `status: draft | approved | in-progress`. There is no `done` -
a finished plan is deleted.
- A plan paused mid-way may carry `NNN-short-slug-HANDOFF.md` and a
`NNN-short-slug-workspace/` copy of the execution ledger, briefs, reports
and reviews. Both are removed with the plan.
+16
View File
@@ -15,6 +15,13 @@ Nothing is posted upstream for now, the owner is not offering permanent
maintainership, and the fake server stays in the fork. The design still
follows rclone's conventions so that upstreaming remains possible later.
**Implementation status (2026-11-11):** paused after plan Task 17 of 21.
M1 to M3 are built; M4's swap is done (the backend runs on the native
client and passes the contract suite against the fake) but its remaining
parts - wider suites, removal of the old libraries, user documentation and
live verification - are not. M5 to M7 are not started. State, rulings and
how to resume: `docs/plans/001-protondrive-tier1-HANDOFF.md`.
## 1. Intent
Rclone's Proton Drive backend works today, but on borrowed time and on one
@@ -660,6 +667,12 @@ interactive login for fixture capture and interop - are the fork owner's.
Everything that needs the live service (9.7, criteria 3, 6 and 7) waits for
that and is reported as not done until it has run.
Update 2026-11-11: the owner created two test accounts, a free one
(`TestProtonDrive:`) and a paid one (`TestProtonDrivePaid:`), and supplied
them configured with username and password on the development machine.
Early live probes against the free account have run (handoff section 6);
the formal live verification (9.7, plan Task 21) has not.
## 12. Acceptance criteria
The first upstream milestone (M4) is done when:
@@ -733,6 +746,9 @@ Answered by the fork owner on 2026-11-11:
4. Fake server: accepted at roughly a third of the code volume, kept in the
fork and not offered upstream.
Added 2026-11-11: a paid account was also provided, so the live matrix in
9.7 is free plus paid. The paid account has not been signed in to yet.
Still open, for whenever the upstream path is taken.
For rclone's maintainers (to ask in step 13.1):