Compare commits

...
300 Commits
Author SHA1 Message Date
Lucas WintherandClaude Opus 5.5 fbf2d565e8 docs: bring the Proton Drive handoff up to date after the final review
ci / protondrive (push) Successful in 23m29s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 15:12:51 +01:00
Lucas WintherandClaude Opus 5.5 944b360054 protondrive: follow the renamed Proton code constant in the fake's tests
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 14:55:58 +01:00
Lucas WintherandClaude Opus 5.5 ad3552e2e5 docs: note the Proton Drive block size cache in the architecture
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 14:48:33 +01:00
Lucas WintherandClaude Opus 5.5 bfa9b7122b protondrive: tighten tests and docs from the final review, log the rest
Timing bounds in parallel tests leave room for a slow race runner, the
block 502 test checks its fault fired, refusedCommit's 408 exclusion is
pinned, the user docs explain the "server doesn't show it yet" error and
the conformance checklist matches the current draft and trust rules.
Every other minor from the review is listed in docs/TODO.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 14:48:17 +01:00
Lucas WintherandClaude Opus 5.5 d3aee2af19 protondrive: tidy small review findings
Log lines no longer cut a name inside a UTF-8 sequence, the "superseded
upload" error says the file's state rather than claiming a revision is
active, a test-only block decrypter moves to the tests, a one-line alias
of encryptPassphrase goes, the upload godoc is rewrapped and the fake's
server struct is gofmt clean.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 14:46:52 +01:00
Lucas WintherandClaude Opus 5.5 30cfcac416 protondrive: retry a token refresh that failed with a server error or timeout
When the access token expired and the refresh request got a 5xx, 429 or
408, the operation failed instead of being retried, and a 408 even ended
the session as if the refresh token had been rejected. Such failures are
now retried like any other call.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 14:45:39 +01:00
Lucas WintherandClaude Opus 5.5 634a2423eb protondrive: make the tests honour RCLONE_CONFIG whichever tests run
Only fstest.Initialise, run by the integration test, applied
RCLONE_CONFIG, so a filtered test run read the default rclone config,
which may hold a real Proton remote. TestMain now applies it first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 14:44:18 +01:00
Lucas WintherandClaude Opus 5.5 b5da26169a ci: give the Proton Drive race suite room on a slow runner and pin its tools
The race suite ran under Go's default 10 minute limit, which a slow
runner would hit; it now has 40 minutes in a 60 minute job. Go is the
1.27 line the code was verified with, not go.mod's minimum, so
govulncheck checks a patched standard library, and govulncheck is
pinned to v1.9.0 so a new release can't break the build.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 14:41:48 +01:00
Lucas WintherandClaude Opus 5.5 b4a23a31ed protondrive: log Proton's announced drive requirements at debug level
Proton announces requirements it will enforce, such as a minimum client
version, in x-pm-drive-requirements response headers. They are now
logged at debug level whenever they change, so they show in -vv bug
reports.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 14:41:19 +01:00
Lucas WintherandClaude Opus 5.5 5dbf5b04c6 protondrive: stop telling users to upgrade rclone on unrelated Proton errors
Proton's generic "requirements not met" code 2000, which it returns for
a node changed since it was read (as when a lost rename response is
resent), aborted the whole run with "upgrade rclone". It is now an
ordinary error that isn't retried. Code 2028 (a feature disabled,
rate-limited or blocked) is retried, and the codes Proton actually uses
for a rejected or missing app version, 5003 and 5001, now give the
upgrade message.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 14:40:13 +01:00
Lucas WintherandClaude Opus 5.5 788a711dc9 protondrive: make multi-thread downloads of files without block sizes linear
A file whose extended attributes don't record its block sizes, as some
other clients write, was read from the first block for every ranged
read, so a multi-thread copy, which opens each chunk as a range,
downloaded data quadratically: about 8.5 GiB for a 1 GiB file. The sizes
of blocks already read are now remembered per Fs (bounded to 16
revisions and 65536 blocks), so later chunks start at the right block.
A block list that disagrees with what was learnt is an integrity error.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 14:37:40 +01:00
Lucas WintherandClaude Opus 5.5 674b538ca5 protondrive: keep the draft client ID stable for remotes configured from the environment
A remote set up with environment variables or a connection string can't
save its draft client ID, so it got a new random one on every run, and a
draft left by an earlier run then blocked uploading that name as if
another client were uploading it. When the ID can't be saved it is now
derived from the remote name and the account's volume, so it is the same
on every run. A remote in the config file still gets a random ID, saved.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 14:32:14 +01:00
Lucas WintherandClaude Opus 5.5 94eedc942f protondrive: ignore modification times and hashes the owner didn't sign
Extended attributes whose signature failed or was missing were used, so
a server could set a file's modification time, size and SHA-1 at will:
for example make `rclone sync --update` skip every changed file. Without
ignore_signature_errors they are now not used and an ERROR is logged:
the modification time is the server's, there is no SHA-1, and the size
comes from the recorded block sizes, which reads check. The file stays
readable.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 14:29:40 +01:00
Lucas WintherandClaude Opus 5.5 67e4cfeb7a protondrive: never reuse the session key of an unverified name on rename or move
Renaming or moving a node whose name signature failed reused the old
name's session key, which a server that made that name knows, so it could
read the new name. The new name now gets a new session key unless the
old name's signature verified.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 14:26:02 +01:00
Lucas WintherandClaude Opus 5.5 b3f5685e62 protondrive: refuse writing into a folder whose name hash key is unsigned
A folder's hash key whose signature failed or was missing was only
logged, and the names of everything written into the folder were then
hashed with it, so a server which made the key could test guesses at
those names. Creating, uploading, renaming, moving or copying into such
a folder is now refused unless ignore_signature_errors is set, when an
ERROR is logged once per folder. Reading is unaffected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 14:24:56 +01:00
Lucas WintherandClaude Opus 5.5 db7afafc08 protondrive: refuse node keys whose Argon2 parameters would exhaust memory
A node or share key from the server was unlocked with whatever Argon2
parameters it carried, so a crafted key could make every listing
including it allocate up to 2 TiB and crash rclone. Node and share keys
locked with Argon2 beyond 4 passes, parallelism 8 or 128 MiB are now
refused before the key derivation runs, as an integrity error.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 14:23:50 +01:00
Lucas WintherandClaude Opus 5.5 e95317b52a protondrive: refuse files whose content key signature is missing
A server could replace a file's content key with one it made, without a
signature: updates were then encrypted with a key the server knows, and
ranged reads returned whatever it encrypted under that key. A missing or
invalid content key signature now makes the file unverified, like an
unverified node key: reads and updates are refused unless
ignore_signature_errors is set, when an ERROR is logged once per file and
operation. The fake gains corruptions for this and for the hash key,
name and extended attribute checks that follow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 14:21:14 +01:00
Lucas WintherandClaude Opus 5.5 ea8d09ce43 docs: say which Proton Drive test files can't go upstream without the fake
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 14:10:53 +01:00
Lucas WintherandClaude Opus 5.5 29ad269444 docs: record the Proton Drive live verification results
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 13:53:13 +01:00
Lucas WintherandClaude Opus 5.5 b440df2e1f docs: note the conflict-cleanup delete after an unknown upload outcome
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 12:33:37 +01:00
Lucas WintherandClaude Opus 5.5 61733fce6e protondrive: add re-keyed fixtures of files written by Proton's web app and CLI
Captured from a throwaway test account on 2026-11-12 and re-keyed onto
test-only keys; no key, passphrase, ID or email of the account is in them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 12:32:19 +01:00
Lucas WintherandClaude Opus 5.5 f8e398fce1 protondrive: skip the live integration test during a golden capture
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 12:31:02 +01:00
Lucas WintherandClaude Opus 5.5 be00536078 protondrive: never delete an upload's draft unless Proton refused the commit
After a failed commit the client read the file back and deleted the
draft if the read showed it uncommitted. Proton's server can take a
moment to show a commit, so a stale read could send a delete against a
file or revision which had in fact been committed.

The draft is now deleted only when the server refused the commit with a
4xx API error on a single attempt which was not resent; any other
failure leaves it and reports that the upload may have succeeded. A
cancelled upload whose commit succeeded stops waiting for the server to
show it at once. The fake server's read lag can also serve a file
without an active revision.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 12:30:07 +01:00
Lucas WintherandClaude Opus 5.5 1b2fcbd212 protondrive: make the golden capture fail closed on foreign keys
The capture names its folder with -capture-remote and refuses -remote,
so a mistyped -run can't start the integration tests against the
account. Every key written has a placeholder user ID; a node key holding
another user's email, or a signature by or intended for a key not being
replaced, fails the capture without naming it. Salt notations are
written only where the original had one.

TestGoldenFixtures now also walks every key, key packet and signature of
a fixture, inside encrypted messages too, and checks folder modification
times; tampered fixtures are shown to fail.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 12:19:05 +01:00
Lucas WintherandClaude Opus 5.5 af6fdd7899 protondrive: fix uploads failing while Proton catches up after a commit
Proton's server can take a moment to show a change: a file read back
just after its commit may be missing or still a draft with no active
revision. Uploads then failed with "upload committed, but the file now
has another revision (revision  is active ...)" or "object not found".

The read back after a commit, the read of a server-side copy and, with
enable_caching off, the read of a folder to write into now retry with
backoff for about 7 s while the change doesn't show. A committed upload
the server still doesn't show after that fails with a new "committed,
but the server doesn't show it yet" error, deletes nothing and is not
retried at once. The fake server gains SetReadLag to model the lag.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 11:57:02 +01:00
Lucas WintherandClaude Opus 5.5 a2dc6f71f1 protondrive: test against re-keyed files written by Proton's own clients
TestGoldenCapture (-capture DIR -capture-label NAME -remote NAME:PATH)
captures a folder another client wrote, re-keyed onto keys made for the
tests: blocks and signature-free SEIPD packets kept byte for byte, key
packets re-wrapped, signatures re-made with the same parameters (names,
hash keys and extended attributes encrypted again with their own session
keys, as their signatures are embedded), IDs and emails replaced.
TestGoldenFixtures reads every fixture in testdata/golden through the
backend from the fake server with every signature checked.

The fake server can now seed stored folders and keeps the times of
seeded nodes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 11:35:36 +01:00
Lucas WintherandClaude Opus 5.5 502a32b5f3 protondrive: name the SDK commit the client was last checked against
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 10:30:31 +01:00
Lucas WintherandClaude Opus 5.5 22b9c4fdc3 protondrive: fix ranged reads of files which don't record block sizes
A file written by an old client without recorded block sizes was read
in ranges by assuming 4 MiB blocks, so a range past the first block could
return the wrong bytes, or too few, without an error. Such a range is now
read from the first block, discarding the bytes before it, with every
block still checked against its hash.

Also identify the SDK the conformance checklist was checked against by
commit, and align the enable_caching help with the docs page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 10:19:53 +01:00
Lucas WintherandClaude Opus 5.5 7be50f9e22 protondrive: update docs for the rewritten backend
Rewrite the hand-written parts of the Proton Drive docs page for the
native client: browser and password sign-in with a transcript taken from
the current code, the third-party notice, what is verified on upload and
download, the ranged-read caveat, the trust rules and the
ignore_signature_errors option, the two protocol limits, the --dump
warning, filename rules, drafts, trash and limits. The generated options
section is left for make backenddocs.

Describe everything ignore_signature_errors allows in its help text,
update the feature list and score in protondrive.yaml, and add the SDK
conformance checklist and fork notes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 10:06:19 +01:00
Lucas WintherandClaude Opus 5.5 c1464a39ab docs: mark Task 19 done in the Proton Drive handoff
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 09:54:41 +01:00
Lucas WintherandClaude Opus 5.5 fd7a0dc98d chore: allow golangci-lint run in the Claude Code settings
The owner added the rule; docs/PERMISSIONS.md already listed it in the
Allow tier. Also record that the force-with-lease deny rule misses the
`--force-with-lease=` form.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 09:53:08 +01:00
Lucas WintherandClaude Opus 5.5 cf7577dc1d protondrive: drop Proton-API-Bridge and go-proton-api
The old libraries were only used by the differential crypto tests. Those
go, and TestFrozenFixtures moves into crypto_internal_test.go without its
-update branch, so files the old library wrote stay checked through the
frozen fixtures in testdata/oldlib. go mod tidy removes the two libraries
and the modules only they pulled in; relvacode/iso8601 leaves go.mod.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 09:51:43 +01:00
Lucas WintherandClaude Opus 5.5 b667a1ab0f docs: record the owner's answers, the history rewrite and Task 18 in the Proton Drive handoff
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 09:29:46 +01:00
Lucas WintherandClaude Opus 5.5 5afbe9e4fa protondrive: run the wider suites against the fake server in fork CI
Add the TestProtonDriveFake init.d script, which builds and starts
protonfake on port 28642 and passes on the session it prints, and a
test_all entry for it, so fs/operations, fs/sync, vfs and cmd/bisync
run against the fake server. Five tests that need a second Fs of the
same account to see what the first wrote are ignored, as each Fs
caches separately, and cmd/gitannex is skipped as for every init.d
remote; both are in docs/TODO.md.

Add a Gitea Actions workflow that builds, vets, lints, race-tests,
fuzzes and vulnerability-checks the backend, and re-verify the agent
permission table on the host toolchain.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 09:23:26 +01:00
Lucas WintherandClaude Fable 5.2 65840bdc57 docs: record open questions and credential locations in the Proton Drive handoff
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-11 21:24:07 +01:00
Lucas WintherandClaude Fable 5.2 4282540e02 docs: correct the Proton Drive handoff after a cold read
build / windows (push) Skipped
build / other_os (push) Skipped
build / mac_amd64 (push) Skipped
build / mac_arm64 (push) Skipped
build / linux (push) Skipped
build / go1.26 (push) Skipped
build / linux_386 (push) Skipped
build / lint (push) Skipped
build / android-all (push) Skipped
Build & Push Docker Images / Build Docker Image for linux/386 (push) Skipped
Build & Push Docker Images / Build Docker Image for linux/amd64 (push) Skipped
Build & Push Docker Images / Build Docker Image for linux/arm/v6 (push) Skipped
Build & Push Docker Images / Build Docker Image for linux/arm/v7 (push) Skipped
Build & Push Docker Images / Build Docker Image for linux/arm64 (push) Skipped
Build & Push Docker Images / Merge & Push Final Docker Image (push) Waiting for needed jobs
A reader with only the repository walked the handoff and the remaining
plan tasks against the code. Mark the plan and spec lines that later
rulings superseded, add corrections for Tasks 18 to 21, fix stale TODO
and PERMISSIONS entries, ignore the skill workspace, point AGENTS.md at
the handoff and take the test account usernames out of it.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-11 21:21:43 +01:00
Lucas WintherandClaude Fable 5.2 31f6aafd91 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>
2026-11-11 21:09:43 +01:00
Lucas WintherandClaude Opus 5.5 9c90998f76 protondrive: correct the memory a full read may hold and note what remains unbounded
The comment and spec put the block metadata a full read holds at a few
hundred bytes a block; with realistic tokens and URLs the cap allows
about 250-350 MiB, and those fields and API response bodies aren't
bounded at all. TODO also asks the live check to confirm the
root-of-trust rule on a drive made by Proton's own apps.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 20:46:18 +01:00
Lucas WintherandClaude Opus 5.5 0cd3af4295 protondrive: return only the bytes asked for when a range covers the whole file
A range covering the whole file was read as a plain full read, so a
file whose recorded size understated its content returned more bytes
than the range asked for. It is now checked as a full read but stops
at the end of the range.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 20:45:54 +01:00
Lucas WintherandClaude Opus 5.5 8666b66038 protondrive: stop at a folder the server reports as its own ancestor
Fetching a file or folder fetches its folders up to one already known,
and a server reporting folders as each other's parents made that
recurse until rclone crashed. A cycle, or folders nested more than 2048
deep, is now an error.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 20:44:19 +01:00
Lucas WintherandClaude Opus 5.5 52a44e41ce protondrive: log an error for each unverified file or folder used with ignore_signature_errors
With ignore_signature_errors set, reading a file, writing into a folder,
or moving, copying or renaming either when its key isn't verified went
ahead without a word. Each now logs an error, once per file or folder
and operation, saying what was done and why the key isn't trusted - and
that what is moved or copied out is trusted from then on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 20:42:14 +01:00
Lucas WintherandClaude Opus 5.5 15bc60ead9 protondrive: verify your own address in any letter case with your own keys
The check that the drive is shared and signed by your own address
ignored letter case, but keys were looked up by exact address, so a
server naming your address in capitals could serve keys of its own for
it. Addresses are now matched ignoring ASCII case only, everywhere, and
your own address in any such case is always verified with your own
keys, never ones the server supplies.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 20:40:43 +01:00
Lucas WintherandClaude Opus 5.5 c28077174e protondrive: check warm listings within the listing budget test
The warm listing budget, with caching on and off, reuses the folders
TestBudgetList seeds instead of seeding its own, which saves about 15s
under -race. The assertions are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 20:30:05 +01:00
Lucas WintherandClaude Opus 5.5 16480e3d80 protondrive: refuse to write names which aren't valid UTF-8
With an encoding which lets invalid UTF-8 through, rclone could create
a file or folder whose name Proton's clients, and rclone itself, then
refuse to read, so it vanished from listings. Such names are now
refused when writing.

Found by FuzzValidateName.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 20:28:21 +01:00
Lucas WintherandClaude Opus 5.5 18b4f7d1d1 protondrive: don't list a file as empty when its recorded size is missing or invalid
A file whose extended attributes had a SHA-1 but a missing, negative or
malformed Size was listed with size 0, so copying it failed with a size
mismatch. Its size is now the sum of its recorded block sizes, if they
are usable, and otherwise unknown, as for a file without extended
attributes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 20:27:33 +01:00
Lucas WintherandClaude Opus 5.5 f89be42eaf protondrive: don't cache a folder listing read while the folder changed
A listing whose pages were read before a file or folder was made in,
copied into or trashed from the folder cached what it read when it
finished, overwriting the change. As new files are added to a cached
listing rather than dropping it, the stale listing then lasted: a file
just uploaded stayed missing, and a trashed one stayed listed, until
something else dropped the listing.

The node cache now counts changes to the children of each folder being
listed, and a listing is cached only if none happened since its first
page was read. The count is kept only while a listing is in progress.

The fake server gains a Hold fault, which answers a request with the
response made on arrival only once released, to test this without
sleeps.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 20:25:35 +01:00
Lucas WintherandClaude Opus 5.5 e7e43095d8 protondrive: restore the config storage even when rclone's config is in memory
The password test's cleanup restored the config path before the
storage, and setting the storage does nothing while the path is empty,
so with an in-memory config the storage was left behind.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 20:10:25 +01:00
Lucas WintherandClaude Opus 5.5 2a1a8f046e protondrive: restore rclone's config storage after the password test
TestPasswordStoredObscured installed the config file storage for its
temporary config and left it in place for the tests after it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 20:03:39 +01:00
Lucas WintherandClaude Opus 5.5 202b64cf1e protondrive: cap the number of blocks a file may list
A full read holds the list of a file's blocks until it has checked the
signed manifest, and how long that list could grow was bounded only by
a size the server sends unsigned. A file listing more than 2^19 blocks
(2 TiB in 4 MiB blocks) is now an error.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 20:03:18 +01:00
Lucas WintherandClaude Opus 5.5 58fde59f34 protondrive: check the signed block list when a range asks for the whole file
A request for the whole file as a range, as "bytes=0-" from serve http
or webdav clients, was read as a ranged read, which can't check the
file's signed block list. It is now read as a full read.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 20:01:02 +01:00
Lucas WintherandClaude Opus 5.5 cb6e6759b8 protondrive: trust a drive only if it is shared and signed by your own address
The drive's share and root folder were accepted when signed by any
address whose keys the server served, so a hostile server could vouch
for a whole drive of its own making. If the share wasn't created, or
the root folder signed, by one of the account's own addresses, reading
and writing now fail with an error saying so, unless
ignore_signature_errors is set.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 20:00:16 +01:00
Lucas WintherandClaude Opus 5.5 787bb3b3fa protondrive: refuse to move or copy a file or folder whose key isn't verified
Moving or copying a file or folder out of a folder with a forged key
had rclone sign the server's key as the user's, after which it read
without error. Moving, copying or renaming a node whose key isn't
verified now fails unless ignore_signature_errors is set, and anything
beneath such a folder, including what rclone writes there with the
option set, stays unverified.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 19:57:18 +01:00
Lucas WintherandClaude Opus 5.5 12ce57ed31 protondrive: add request budget tests and fuzz targets
Request budgets, measured against the fake server and asserted at the
measured counts, for listing (cold and warm, with and without
enable_caching), a no-op sync by modification time and by checksum,
NewObject three folders deep, uploads and making folders. A TestMain
hook fails the package if any test, through any fake server, requested
a removed share-scoped route; the fake keeps a process-wide tally of
them for it.

Fuzz targets, seeded from testdata/oldlib and from what the fake
writes, for name validation, link decoding, extended attributes, API
error bodies with Retry-After and session fork payloads. Each checks
invariants of what is accepted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 19:48:18 +01:00
Lucas WintherandClaude Opus 5.5 90b2a48e71 protondrive: skip nodes the server sends without an ID or with a negative size
A node which decrypted was handed on whatever its link ID and encrypted
size, so a faulty or hostile server could make rclone cache a node
under an empty ID or list a file with a negative size. Such nodes are
now left out of listings and logged, as nodes with invalid names are.

Found by FuzzDecodeLink.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 19:33:39 +01:00
Lucas WintherandClaude Opus 5.5 32710eefd1 protondrive: refuse metadata which decompresses to an excessive size
Names, node passphrases, hash keys and extended attributes are
encrypted to public keys, so the server, or anyone, can write them as a
small compressed message which expands to gigabytes when decrypted.
They were decompressed into memory without a limit.

Names, passphrases and hash keys are now limited to 64 KiB and extended
attributes to 16 MiB, enough for the block sizes of an 8 TiB file.

Extended attributes with a negative Size are now read as having no
size, as the reference SDK reads other invalid sizes, so a file is never
listed with a negative size.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 19:22:28 +01:00
Lucas WintherandClaude Opus 5.5 3fac25de75 protondrive: don't list a folder again after each file or folder made in it
Making a file or folder, or a server-side copy, forgot the cached
listing of its folder, so the next upload into that folder - which
checks the name is free - listed the whole folder again. Copying M new
files into a folder of N cost on the order of M*N/100 extra requests.

The new node is now added to the cached listing. A failed call still
forgets it, as the call may have taken effect.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 19:19:14 +01:00
Lucas WintherandClaude Opus 5.5 751a3ea357 protondrive: test config obscuring the password and refused updates
The password test now runs rclone config with the password in plain
text and checks it is stored obscured; a test checks a file whose key
isn't its owner's can't be updated; and the log test checks every
session token the fake issued, which it now labels by kind, not only
the saved ones.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 18:59:21 +01:00
Lucas WintherandClaude Opus 5.5 248f467054 protondrive: don't save refreshed tokens after disconnect
A token refresh still in flight when the remote was disconnected could
write its new tokens back to the config after Disconnect removed them.
Disconnect now waits for it and removes them afterwards.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 18:59:21 +01:00
Lucas WintherandClaude Opus 5.5 9598e22d90 protondrive: check a file's signed block list before handing out any of it
A full read verified the manifest signature only at the end, so blocks
a server swapped, dropped or took from an older revision reached the
reader - and rclone cat or a mount - before the error. A full read now
lists every block and verifies the manifest before releasing the first
byte, and a block listed again with a different hash is an integrity
error. Ranged reads are unchanged.

The fake's "rollback" corruption is renamed "block-rollback": serving a
whole earlier revision under the current revision ID can't be detected,
which the spec and TODO now say.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 18:51:15 +01:00
Lucas WintherandClaude Opus 5.5 88654eb04d protondrive: distrust files and folders beneath a folder with a forged key
A server could put a folder with a key of its own making into a real
folder and serve anonymous files beneath it whose every signature
checked out against that key, so they were read without an error.
A key checked against an unverified folder key is now unverified too,
all the way down: reading such a file, or creating, uploading, moving
or copying anything into such a folder, fails unless
ignore_signature_errors is set. Listing it still works.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 18:48:11 +01:00
Lucas WintherandClaude Opus 5.5 f4105adfc1 protondrive: add hostile-server and secret-hygiene tests
A hostile fake server tampers with block hashes, block order and count,
serves an older revision's or another key's blocks, signs a node
passphrase with its own key and gives nodes names such as "../x", "a/b",
NUL and 10 MiB: each read fails or the node is left out, with and
without ignore_signature_errors, and a copy or sync writes nothing
wrong or outside its destination. The log at debug level, and with
--dump headers, holds no password, key password, session or storage
token, node passphrase or session key; browser sign-in leaves no
password in the config and password sign-in keeps it obscured.

The fake gains rollback and foreign-block corruptions and exposes the
secrets it issued and a file's node secrets.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 18:34:50 +01:00
Lucas WintherandClaude Opus 5.5 db97ef7c8d protondrive: stop a call rejected during disconnect retrying without credentials
A call in flight when the remote was disconnected, and then rejected
as unauthorised, was sent again with empty credentials. It now fails
with the "not signed in" error instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 18:22:53 +01:00
Lucas WintherandClaude Opus 5.5 59f0f31b34 protondrive: name the remote to reconnect when the session expires or is signed out
An expired session, or one signed out with "rclone config disconnect"
while a long running rclone was using it, told the user to reconnect a
remote literally called "remote:". The error now names the remote.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 18:22:02 +01:00
Lucas WintherandClaude Opus 5.5 6065801968 protondrive: refuse to read a file whose key isn't signed by its owner
A file whose node passphrase signature didn't verify was read with
only an error logged when it was listed, although a server could have
substituted a key of its own making and with it any content. Reading
such a file now fails unless ignore_signature_errors is set, as for a
content key or manifest signature which fails.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 18:21:07 +01:00
Lucas WintherandClaude Opus 5.5 8600815384 protondrive: add regression tests for known failures
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 18:05:33 +01:00
Lucas WintherandClaude Opus 5.5 14128d59c0 protondrive: say how to read a file whose signature fails to verify
The error for a content key or manifest signature which fails now
ends with a hint to set ignore_signature_errors.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 17:59:16 +01:00
Lucas WintherandClaude Opus 5.5 6e2410de69 protondrive: name the remote in the CAPTCHA error and offer browser sign-in in config
A CAPTCHA on password sign-in told the user to reconnect a remote
literally called "remote:". The error from NewFs now names the remote
and is fatal, and rclone config goes back to the choice of sign-in
method with a hint to use the web browser.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 17:58:35 +01:00
Lucas WintherandClaude Opus 5.5 c00162ed20 protondrive: keep folders with unseen content, saved sessions on config edits and a usable Fs after shutdown
- rmdir asks the server whether a folder has any child, so it never
  trashes a folder holding Proton Docs, unreadable files or children
  added by another client since it was listed
- rclone config asks before replacing a saved session (default no), and
  signs the old session out after signing in again
- Shutdown only drops caches; after Disconnect the remote reports that
  it isn't signed in
- update, move and copy refuse a file another client has trashed
- ranged reads are computed on the decrypted size whatever
  original_file_size says

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 17:47:54 +01:00
Lucas WintherandClaude Opus 5.5 0ec7dce0e5 protondrive: rewrite the backend on a native client following Proton's SDK
The backend no longer uses Proton-API-Bridge or go-proton-api at run
time. Saved sessions from earlier versions keep working without signing
in again, and every option keeps its name and meaning. rclone config
now offers sign-in with a web browser. The full backend test suite runs
against an in-process fake Proton server.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 17:28:24 +01:00
Lucas WintherandClaude Opus 5.5 414de88c99 protondrive: add hermetic contract test harness
Add protonfake, a command which serves the fake Proton server on a
given address and prints the endpoint and a saved session of a user
with a Drive volume as key=value lines, for an fstest test server
script. protontest gains NewStandalone and Close for it.

Add setupFakeRemote, which configures TestProtonDriveFake: with a
fresh session on the fake through environment variables, and
TestIntegrationFake, which runs the backend contract suite against it.
It is skipped until the backend has an endpoint option.

client_salted_key_pass is written base64 encoded, the meaning the key
has in existing configs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 17:01:53 +01:00
Lucas WintherandClaude Opus 5.5 e23de0b586 protondrive: make the volume creation race test deterministic
TestClientBootstrapCreateVolumeRace failed now and then in whole
package race runs with "expected 2 POST /drive/volumes, got 1".

loadAccount holds the process-wide unlockMu while unlocking keys, so
the second client's first GET drive/v2/shares/my-files is sent one
key unlock, or under load many queued unlocks, after the first's. The
500ms Delay fault on both GETs shifted them equally and so did not
keep them together: when the first client's create had finished
before the second client's GET was handled, the second client found
the volume and never POSTed. The client handles that ordering
correctly; only the test's assertion depended on timing.

Add a Gather option to protontest faults, which holds each faulted
request until all Times have arrived, and use it for both GETs and
both POSTs. The test now starts the second client as late as the
server lets it, which failed 10 of 10 runs with the Delay fault.

Gathering the POSTs runs both create volume checks in the fake at
once, which exposed a data race there: go-crypto records signature
checks in a key on its first use, and the new user's address key had
never been used. Check each request with its own copy of the key.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 16:37:14 +01:00
Lucas WintherandClaude Opus 5.5 e65c785c15 protondrive: state the v6 file fixture's provenance in the fixture
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 15:07:50 +01:00
Lucas WintherandClaude Opus 5.5 e76b6783b2 protondrive: add whole-file differential tests
Files uploaded by the new client are read whole by the old library's
readers, and files written by the old library (go-proton-api v1.0.4,
Proton-API-Bridge v1.0.5), in the legacy and the v6 format, are
downloaded by the new client through the fake server. Frozen fixtures
of both formats keep the second direction tested once the old library
is gone.

protontest gains read-only access to a file as stored, seeding of a
stored file, and users built from fixed address and root keys. It now
signs no earlier than the signing key's creation time, which a key
supplied by a test may be.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 14:52:55 +01:00
Lucas WintherandClaude Opus 5.5 dc7d977e17 protondrive: delete an upload's draft only on proof its commit failed
After a failed commit the draft was deleted whenever the file read
back did not show the upload's revision active. A file changed by
another client since (a newer revision, or trashed) proves nothing,
and deleting then removed committed content. Now the draft is deleted
only when the read shows a new file still a draft, or the revision the
upload started from still active; otherwise nothing is deleted and
the outcome is reported as unknown. A successful commit whose file
already has another revision when read back is an error rather than
returning another client's revision as the upload's.

The fake gains SeedRevision, giving a file a new revision as another
client would.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 14:25:08 +01:00
Lucas WintherandClaude Opus 5.5 1f1b2bb325 protondrive: never delete an upload whose commit may have succeeded
After a failed commit the upload read the file back with the caller's
context. When that context was cancelled, or the read failed, the
commit was taken as not done and its draft deleted, which deletes the
file or revision if the commit had in fact taken effect. The read now
runs past cancellation, the draft is deleted only when the read shows
the revision is not active, and an outcome that can't be read is
reported as possibly successful without deleting anything.

Also fix a data race in the fake's storage upload handler and add
tests for a failing source and for a failing block with the producer
blocked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 14:18:00 +01:00
Lucas WintherandClaude Opus 5.5 9a4ed61a88 protondrive: add verified block upload
Upload follows the SDK's upload module (js/v0.23.0) step for step:
draft file or draft revision, name-conflict handling (own draft
deleted and retried once, another client's draft refused unless
replace_existing_draft, an existing file given a new revision),
verification code, blocks encrypted and checked against the server's
content key, upload URLs requested in batches, uploads bounded per file
(upload_concurrency, default 4, max 5) and per client (10 blocks),
size and SHA1 checks, signed manifest and extended attributes, commit
with a check that a failed commit is not in fact active, and draft
cleanup on any failure or cancellation. Blocks are streamed through
lib/pool buffers; a block is tried 3 times and a storage 404 requests a
new URL. Cancellation is bounded everywhere, including waits for the
client's upload tokens.

The fake server gains the upload routes with strict checks: names,
passphrases and extended attributes must be non-AEAD, the file key a
v4 key with an ordinary S2K, the content key packet a v6 PKESK and
blocks SEIPDv2; verifier tokens, block indexes, declared hashes and the
manifest signature are checked at commit. It now signs a day in the
past and checks signatures in Dump at its own clock, so clock-skew
tests can run in both directions.

Where the plan and the SDK differ on the wire, the SDK wins:
- IntendedUploadSize follows the SDK: for a new file it is sent only
  when the rounded size is over 100,000 bytes; for a new revision it is
  reduceSizePrecision (4095 under 4 KiB, 100,000 under 100 kB).
- BlockList entries carry no Hash or Size (deprecated, unused); the
  fake checks a Hash only if one is sent.
- An empty stream of unknown size is uploaded as zero blocks, which
  the SDK refuses.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 14:07:27 +01:00
Lucas WintherandClaude Opus 5.5 e8b1359760 protondrive: reject absurd recorded block sizes when downloading
A recorded block size above 8 MiB is now an integrity error, so the
sizes can't overflow, and a full read always lists the blocks so it
reaches the manifest check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 13:26:35 +01:00
Lucas WintherandClaude Opus 5.5 4bcafe5cd4 protondrive: add verified ranged download
Download a range of a file's content block by block: block URLs come
from the revision's paged block list (20 per page), blocks are fetched
through the storage pacer holding a download token without the
session's credentials, checked against their SHA-256 before decrypting
and decrypted into pool buffers. A full read checks the manifest
signature, by the content author's address or the node key for an
anonymous upload, and the content key packet signature is checked on
open with the SDK's fallback to every address key for files created
before 2023.

The retry and capped Retry-After logic of apiCall is shared with block
downloads through the new pacedCall.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 13:20:18 +01:00
Lucas WintherandClaude Opus 5.5 ee81e742f2 protondrive: add folder, move, copy and trash operations
Add createFolder, rename, move, copyNode, trash and emptyTrash to the
new client, following the SDK's nodesManagement.ts: names and
passphrases are re-encrypted to the new parent with their session keys
and signed by the share's address; the node passphrase signature is
sent on move and copy only for an anonymous node; API code 2500 maps to
errExists and 2501 to errNotFound. Every mutation updates the node
cache, and nodeCache.put forgets both parents' listings when a node's
parent changes.

The fake gains the matching routes plus checkAvailableHashes. It
rejects SEIPDv2 or v6 PKESK names, passphrases, hash keys and extended
attributes, and folder keys which are not v4, advertise AEAD or are not
locked with an iterated and salted S2K. It enforces name hash
uniqueness, OriginalHash, no move into a descendant and the anonymous
signature rule. Dump now also checks every name hash, and
SeedAnonymousFile seeds a public-link upload.

Wire format: the create folder request sends SignatureEmail as the SDK
does, although CreateFolderDto lists SignatureAddress.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 12:59:34 +01:00
Lucas WintherandClaude Opus 5.5 a8856ff7cd protondrive: cap Retry-After and retry public key lookups
A 429's Retry-After is now honoured for at most 60 seconds and is
waited out by the call alone, so a hostile value can neither stall the
client nor hold every other call behind the shared pacer. Public key
lookups during listing are paced and retried like every other call
instead of failing the listing on one 429 or 5xx.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 12:42:20 +01:00
Lucas WintherandClaude Opus 5.5 2065b7b09a protondrive: add node listing with cache and pacing
Add the client the new backend will use above the wire: it loads the
account, finds or creates the main volume, share and root, and lists
folders by paging child IDs and fetching their metadata 100 at a time
with at most 15 requests in flight, decrypting with the verification
keys the SDK's nodes module chooses.

A brand-new account has no volume: my-files answers code 2501 and the
client creates the volume as the SDK's shares module does, accepting
code 2500 from a concurrent creator.

Nodes whose name or key can't be decrypted, or whose name is not valid,
are left out with one error each; Proton Docs and Sheets are left out
with one notice; failed signature checks are logged once and the node
is still listed.

API calls are paced and retried: 429 waits for Retry-After before
retrying, 408 and 5xx are retried, other client errors are not.

errExists moves to Task 9, which first uses it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 12:32:38 +01:00
Lucas WintherandClaude Opus 5.5 27d06dca19 protondrive: add drive state to the fake server
Every fake user now has a real, correctly encrypted drive: a volume, a
main share and a root folder, built as the SDK's
generateVolumeBootstrap does. Seeding helpers create folders, files in
the current format (v6 PKESK content key, SEIPDv2 GCM blocks, encrypted
block signatures, signed manifest, extended attributes), legacy files
(v3 PKESK, SEIPDv1) and Proton Docs nodes; Corrupt damages them as a
hostile server would and Dump decrypts and verifies the whole tree.

The read routes (my-files, volume, share, link metadata, children,
revision) answer with the field names of the SDK's driveTypes.ts, and
block bytes are served from the same server behind pm-storage-token.

AddUserWithoutDrive models a brand-new account, whose my-files answers
HTTP 422 code 2501, and POST /drive/volumes creates its volume after
checking the key material decrypts with the address key.

protontest/crypto.go is written from the SDK independently of the
backend's crypto.go. Interface additions are recorded in the plan.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 12:14:13 +01:00
Lucas WintherandClaude Opus 5.5 3b63f3c7d5 protondrive: add differential crypto tests against the old library
Cross-test crypto.go against go-proton-api v1.0.4 and Proton-API-Bridge
v1.0.5 in both directions, compare account key unlocking with
proton.Unlock, and freeze old-library output under testdata/oldlib so
it stays tested once the old libraries are gone. github.com/relvacode/iso8601
becomes a direct requirement because the test parses modification
times as the bridge does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 11:52:16 +01:00
Lucas WintherandClaude Opus 5.5 fdcb6ab322 protondrive: verify hash keys signed by older rclone versions
Folder hash keys written by go-proton-api (and any message gopenpgp
encrypts and signs in one go) carry intended recipient fingerprints in
their signature. Decrypting with the session key alone made gopenpgp
reject those as "intended recipients in non-encrypted message", so
every folder created by released rclone reported a hash key signature
error. The intended recipients are now checked against the decryption
keys by hand.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 11:52:16 +01:00
Lucas WintherandClaude Opus 5.5 a1e1333c7d protondrive: check node signatures against the verification keys only
gopenpgp looks the signer of an inline signature up in the decryption
keys as well as the verification keys, so a name or extended attributes
signed by the parent or node key reported as verified whatever keys the
caller asked for. Decrypt the session key first and the data with the
session key alone, so only the verification keys count, as in the SDK.

Also clear the decrypted passphrase when the node key fails to unlock.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 11:33:33 +01:00
Lucas WintherandClaude Opus 5.5 0bb2dbdf34 protondrive: add node cryptography
Add the OpenPGP operations of the Drive data model as pure functions:
node keys and passphrases, names, hash keys and lookup hashes, content
keys, blocks, verifier tokens, manifests and extended attributes.

The formats follow the reference SDK (driveCrypto.ts, openPGPCrypto.ts),
which differs from the plan's Task 5 interfaces in these ways, now
updated in the plan:

- node keys are v4 EdDSA/ECDH with user ID "Drive key" and ordinary S2K
  locking; file keys (aead=true) advertise SEIPDv2 with AES-256-GCM and
  folder keys don't, as the SDK's enableAead
- the content key is v6 only when the node key advertises SEIPDv2, as
  the SDK's preference-following session key generation
- the content key signature is checked on read against the node key and
  extra (address) keys
- block signatures are encrypted with the content session key, as the
  SDK's encryptSignature
- hash keys are signed by the node key; reading also accepts address keys
- reencryptPassphrase signs anew, and encryptName takes the current name
  session key for rename and move
- name length is counted in UTF-16 code units, as the SDK

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 11:27:57 +01:00
Lucas WintherandClaude Fable 5.2 666df153e2 docs: run plan tests with RCLONE_CONFIG=/notfound
Fork-only. Not for upstream.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-11 11:14:27 +01:00
Lucas WintherandClaude Opus 5.5 483c3a1518 protondrive: test that address keys with a bad signature are refused
Add a fake server switch that signs an address key's passphrase with an
unrelated key or removes the signature, and test that loading fails on
the primary address and leaves out a secondary one. Also clear an
unlocked key that can't be added to its key ring.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 11:13:50 +01:00
Lucas WintherandClaude Opus 5.5 6ec49842c4 protondrive: load and unlock account keys
Load the user and addresses and unlock their keys, one unlock at a time
across all sessions so concurrent sign-ins don't multiply peak memory.
Address key passphrases are taken from their Token only when the user
key's signature on it verifies, as the SDK requires. Other users' public
keys are fetched once per account and cached, an email without a Proton
address having no keys.

Password sign-in now derives the key password from the salt of the
primary user key rather than the first salt, which is wrong for accounts
whose user keys have different salts. The SDK uses the first salt; it
remains the fallback when no salt matches.

The fake server gives each user real Curve25519 user and address keys,
and serves GET /core/v4/users, /core/v4/addresses and /core/v4/keys/all.

gopenpgp v3 moves to a direct dependency.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 11:08:40 +01:00
Lucas WintherandClaude Opus 5.5 5a8bb0247f protondrive: test that password sign-in rejects a bad server proof
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 10:59:52 +01:00
Lucas WintherandClaude Opus 5.5 2ba0848dcc protondrive: add password and browser sign-in
Add SRP password sign-in (with TOTP, two-password accounts and a clear
error when Proton asks for a CAPTCHA), browser sign-in through a session
fork, and sign-out to the new session, with the matching routes in the
fake server. The fake does real server-side SRP with go-srp and issues
a per-user key salt.

Where the plan and the SDK differ, the SDK wins:
- the fork sign-in URL escapes its payload like encodeURIComponent
  (authWeb.ts generateSignInUrl), so the plan's literal URL is the
  unescaped form;
- POST /core/v4/auth sends PersistentCookies 1 and an empty Payload
  (auth.ts authViaPassword);
- the key password uses the first KeySalts entry (auth.ts
  getUserKeyPassword).

protontest.ApproveFork takes the sign-in URL instead of the user code,
since the payload key exists only in the URL; the plan is updated.

go-srp moves to a direct dependency.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 10:56:04 +01:00
Lucas WintherandClaude Opus 5.5 9d894ab1b7 protondrive: keep two sessions on one config working when both refresh
When two sessions that share stored credentials refresh at the same
time, both post the same single-use refresh token and the server
rejects one. The session that loses now waits briefly for the winner's
tokens to appear in the store and uses them, instead of failing with
"session expired".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 10:37:19 +01:00
Lucas WintherandClaude Opus 5.5 ea41704ade protondrive: add session with safe token refresh
Add the session used for authenticated API calls: it sends x-pm-uid,
the bearer token, x-pm-appversion and the Proton Accept header, decodes
failures into api.Error, and on HTTP 401 refreshes the tokens and
retries once. Refresh is single-flight, first adopts tokens another
session saved to the shared store, and on a rejected refresh token
returns a fatal "session expired" error without clearing the stored
credentials or retrying the refresh. The session also tracks the
server's clock from the Date header.

The app version maps development builds to the "alpha" channel, as
Proton's x-pm-appversion grammar has no "dev" channel.

The fake server gains users, sessions, single-use refresh tokens and
401 for invalid access tokens on authenticated routes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 10:33:43 +01:00
Lucas WintherandClaude Opus 5.5 2e3dbac3a4 protondrive: add fake Proton server skeleton for tests
Add the api.Error type with Proton response codes and the protontest
package: an httptest-based fake that rejects legacy share-scoped routes
with 410, enforces the published x-pm-appversion grammar, refuses
x-pm-drive-sdk-version, counts requests per route, skews the Date header
and injects scripted faults.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 10:27:41 +01:00
Lucas WintherandClaude Fable 5.2 06700aaf22 docs: add implementation plan for the Proton Drive rewrite (M0-M4)
Fork-only. Not for upstream.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-11 10:23:09 +01:00
Lucas WintherandClaude Fable 5.2 83ae5d1876 docs: approve Proton Drive spec with the fork owner's decisions
Fork-only. Not for upstream.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-11 10:17:10 +01:00
Lucas WintherandClaude Fable 5.2 6f92948dac docs: add draft spec for an SDK-conformant Proton Drive backend
Fork-only. Not for upstream.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-11 10:12:23 +01:00
Lucas WintherandClaude Fable 5.2 a28ee25855 build: add agent docs, permissions and spec/plan layout for the fork
Fork-only. Appends a Fork Workflow section to AGENTS.md and adds
GEMINI.md, per-tool permission files and docs/ pages for agent driven
development. Not for upstream.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-11 10:01:49 +01:00
Enduriel 448d7361c8 filen: update filen-sdk-go to v0.0.40 - fixes #10282
Files uploaded through the multi-thread path were finalized with
CompleteFileUpload only, which did not add the new file to the parent
folder's shares and public links, so large files never showed up in
public links. filen-sdk-go v0.0.40 moves that step into
CompleteFileUpload, so no backend changes are needed.
2026-11-10 17:11:10 +00:00
David Castro cbc8c3fbd9 internetarchive: fix missing metadata options during server-side copy
Apply metadata overrides and the metadata mapper before queuing a
server-side copy. Preserve the new update tracker and read-only file
attributes, and return metadata errors before sending a PUT.

Fixes #7901
Assisted-by: OpenAI Codex
Co-authored-by: david.castro <david.castro@refinery89.com>
2026-11-10 12:07:08 +00:00
Anubhav Lal 65d4b73a44 serve s3: fix CompleteMultipartUpload failing with InvalidPart while parts are being sent
Completion now waits for acknowledged parts still being written instead of failing.

Fixes #10257
2026-11-09 17:19:11 +00:00
javad seydi d459c3f992 s3: add Abrha Storage provider 2026-11-08 13:20:44 +00:00
Nick Craig-Wood 82cb84b201 Start v1.77.0-DEV development 2026-11-06 16:31:22 +00:00
Nick Craig-Wood 7d9963121a build: stop the librclone tests marking the Linux release binaries as modified
librclone/python/test_rclone.py builds ./librclone.so with
-buildmode=c-shared, which also writes librclone.h. The test removes
the .so but not the header, so the CI Linux job built the release
binaries from a tree with an untracked file and they reported
vcs.modified=true in their build info.

Ignore librclone.h so it no longer counts as a modification.
2026-11-06 15:57:32 +00:00
Nick Craig-Wood 98d4e8f369 build: stop make fetch_binaries downloading the beta site's index files
The beta site now has index.html and index.json directory listings
made by rclone index. fetch_binaries copied them into build/ where
make upload and make upload_github would have published them with the
release.
2026-11-06 15:52:50 +00:00
Nick Craig-Wood ea9f816061 docs: wait for CI on master to pass before pushing the release tag
Commits which were only local, such as security fixes, aren't tested
by CI until master is pushed. Pushing the tag at the same time meant a
CI failure in them could stop the release binaries being built, which
happened for the Windows build of v1.76.0.
2026-11-06 15:46:29 +00:00
Nick Craig-Wood dfbe3ae1d0 lib/http: fix auth timing tests failing on Windows
The tests timed one rejection at a time, but checking a --user/--pass
password takes less than the resolution of the clock on Windows, so
the known and unknown users both measured 0s and the comparison
failed.

Time many rejections with testing.Benchmark instead, which runs
enough of them to measure on a coarse clock.
2026-11-06 15:39:27 +00:00
Nick Craig-Wood 6a4b77208e local: fix TestRemoveThroughPlantedSymlinkBlocked failing on Windows
On Windows os.Root.Symlink always makes a file symlink when the
target is an absolute path outside the root, and Windows can't
traverse a file symlink as a directory. The planted link can't reach
the outside files, so the test failed setting up the attack with "The
filename, directory name, or volume label syntax is incorrect".

Skip the test on Windows as the other planted symlink tests do.
2026-11-06 15:39:27 +00:00
Nick Craig-Wood e982ff3666 Version v1.76.0 2026-11-06 14:37:03 +00:00
Nick Craig-Wood 53b2d7aaf2 rc: reject unauthorised and unknown calls before reading the request GHSA-f8p4-8g4j-q3m2 CVE-PENDING
The rc server decoded the whole POST body before looking up the method
and checking whether it needed authorisation, then echoed the decoded
input back in the 403 or 404 error. A client that was not allowed to
call a method could still make rclone read, decode and re-encode its
request.

The method lookup and authorisation check now happen before the body
is read, and these errors no longer echo the request input.
2026-11-06 12:08:10 +00:00
Nick Craig-Wood 17ebd339da rc: limit request size when the rc server has no authentication GHSA-f8p4-8g4j-q3m2 CVE-PENDING
When the rc server was running without HTTP authentication and without
--rc-no-auth, anyone able to connect to it could POST an arbitrarily
large body to any rc path. A JSON body was decoded in full and then
echoed back in the response, so a single large request could make
rclone use several times the body size in memory and be killed by the
OOM killer.

In this mode only the small set of NoAuth methods can be called, so
POST bodies are now limited to 64 KiB and oversized requests are
rejected with 413 Request Entity Too Large without echoing the input.
Servers with authentication or --rc-no-auth are unaffected.
2026-11-06 12:08:10 +00:00
Nick Craig-Wood e3c400f583 lib/http: stop --htpasswd and --user revealing which usernames exist through timing GHSA-hwj7-wp9g-wrv9 CVE-PENDING
When a username wasn't in the htpasswd file the request was rejected
without checking the password at all, whereas a known username with a
wrong password went through the (deliberately slow) bcrypt check, so
the time taken let an unauthenticated client find out which usernames
exist. The same was true of --user/--pass with MD5-crypt.

Unknown usernames are now checked against a decoy hash of a random
password made in the same format and with the same cost as the most
expensive entry in the htpasswd file, remade whenever the file
changes, and --user/--pass always hash the password.

This affects serve http, serve webdav, serve restic, serve s3, rcd
--rc-htpasswd and --metrics-htpasswd.
2026-11-06 12:08:10 +00:00
Nick Craig-Wood 5c5a3199d7 local: fix DirMove creating the destination parent through a planted symlink
With -l/--links the local backend recreates a source ".rclonelink" as a real
symlink at the destination. DirMove created the parent of the destination
directory by path, which follows a symlink in any parent component, so when
the destination contained a planted symlink "pwn" pointing outside the root
a directory move to "pwn/moved" moved the directory outside the destination.
rclone's sync and the VFS never name a destination through a symlink, so this
was only reachable with a caller supplied name.

Create the parent through os.Root when translating symlinks, as the other
writes do, so a planted symlink can no longer redirect the move out of the
destination.
2026-11-06 12:08:10 +00:00
Nick Craig-Wood 37b306d5d1 local: fix Remove and Rmdir deleting outside the root through a planted symlink
With -l/--links the local backend recreates a source ".rclonelink" as a real
symlink at the destination. Remove and Rmdir deleted by path, which follows a
symlink in any parent component, so when the destination contained a planted
symlink "pwn" pointing outside the root, an object or directory named through
it such as "pwn/secret" resolved to the outside file or directory and was
deleted there. rclone's own sync never names destination paths through a
symlink as the listing shows it as a ".rclonelink" file, but the clean up of a
failed copy looks up and removes a ".partial" name derived from the source,
and callers of the rc API or the Go API may pass such a name directly.

Delete through os.Root when translating symlinks so a planted symlink can no
longer redirect the removal out of the destination. This also covers the
removal of a partially written file after a failed write. In-tree removals
and the Windows sharing violation retry are unchanged.
2026-11-06 12:08:10 +00:00
Nick Craig-Wood b000741c98 local: fix MkdirMetadata finding directories outside the root through a planted symlink
With -l/--links the local backend recreates a source ".rclonelink" as a real
symlink at the destination. MkdirMetadata checked whether the directory
already existed with a plain lstat, which follows a symlink in any parent
component. When the destination contained a planted symlink "pwn" pointing
outside the root and the target held an existing child "sub", syncing a source
directory "pwn/sub" found the outside directory, skipped the confined Mkdir
(which would have refused the path) and went on to apply the source's metadata
to it. The mode, time and user metadata writes are individually confined, but
the birth time write on Windows was not, so an untrusted source could set the
creation time of a directory outside the destination.

Do the existence check through os.Root when translating symlinks so a path
through a planted symlink is refused up front, whatever metadata follows. The
check is unchanged when not translating symlinks.
2026-11-06 12:08:09 +00:00
Nick Craig-Wood c0bf21991e local: fix user metadata escaping the root through a planted symlink GHSA-r5wv-vqqh-2r9c CVE-PENDING
With -l/--links the local backend faithfully recreates a source ".rclonelink"
as a real symlink at the destination. Directory and file metadata writes
(chmod/chown/chtimes/btime) are confined to the destination with os.Root so a
symlink planted by an untrusted source can't redirect them outside it. The
user metadata write (user.* xattrs) was not confined though: setXattr called
xattr.LSet on the raw path, which doesn't follow the leaf but does follow any
symlink in the parent components. When the destination already contained a
planted symlink "pwn" pointing outside the root and the target held an
existing child "sub", syncing a source directory "pwn/sub" with --metadata
set attacker chosen user.* xattrs on the outside directory, and reported
success as the confined mode and time writes had nothing to do.

Write the xattrs through a descriptor opened under os.Root when translating
symlinks, so a planted symlink can no longer redirect the write out of the
destination, while legitimate in-tree writes are unaffected. A write only
file which can't be opened for reading is opened for writing instead so its
owner can still set xattrs on it. Translated links are themselves the leaf
and keep the non-following path based write.
2026-11-06 12:08:09 +00:00
Nick Craig-Wood 9aba8e4e41 serve: make auth proxy revocation take effect within 5 minutes GHSA-vmgx-g9c5-669p CVE-PENDING
With --auth-proxy, serve sftp, ftp, http and webdav cached the
program's answer for a user and password/public key and never asked
the program again while the entry stayed in use. As the cache only
expires after 5 minutes of disuse, a user revoked in the program kept
access for as long as they kept making requests or logging in from the
same address with the same credential.

A cached answer is now checked with the program again after 5
minutes, as serve s3 already does for access key IDs, so revoking a
user takes effect within 5 minutes. For serve sftp this applies to new
logins; an existing connection is not closed.
2026-11-06 12:08:09 +00:00
Nick Craig-Wood 8d0b08d125 ulozto: fix auth tokens leaking on redirects from download links GHSA-3xpx-hjg2-vrjh CVE-PENDING
Every download sends the app token (X-Auth-Token) and the session
token (X-User-Token) to the download URL handed out by the API so that
premium access is recognised. The http client followed any redirect
from that URL with Go's default policy, which only strips Authorization
and Cookie style headers on a change of host and never looks at the
scheme, so both tokens were forwarded to any host the download server
redirected to, including over plaintext HTTP.

The client now uses rest.StripHeadersOnCrossHostRedirectFn, so it
refuses to follow a redirect which downgrades HTTPS to HTTP and strips
both tokens from any redirect which leaves the host and port of the
original request, matching the b2, s3, webdav and http backends.
2026-11-06 12:08:09 +00:00
Nick Craig-Wood 2fc95aef84 seafile: fix double slash in the URL when getting a 2FA token
The two-factor authentication step of the config flow checked whether
the server URL started with a slash rather than ended with one, so a
URL which already ended in a slash had another appended and the token
request went to "//api2/auth-token/".
2026-11-06 12:08:09 +00:00
Nick Craig-Wood 0c6e76b6b3 seafile: fix HTTPS to HTTP redirects leaking credentials GHSA-m97j-7x65-wh93 CVE-PENDING
The seafile backend used Go's default redirect policy, which copies the
Authorization header onto a redirect to the same host even when the
redirect downgrades from HTTPS to plaintext HTTP. A server or proxy
which issued such a redirect would cause the long lived Seafile API
token, or the user's password during the 2FA configuration flow, to be
sent in cleartext where anyone on the network path could read it.

Refuse to follow redirects which downgrade HTTPS to HTTP, as the webdav
backend already does.
2026-11-06 12:08:09 +00:00
Nick Craig-Wood b2dc0e3931 netstorage: fix API auth headers leaking to other hosts on redirect GHSA-m97j-7x65-wh93 CVE-PENDING
Every NetStorage request carries the account name and a request
signature in the X-Akamai-ACS-Auth-Data and X-Akamai-ACS-Auth-Sign
headers, with the signed operation in X-Akamai-ACS-Action. The http
client followed any redirect with Go's default policy, which only
strips Authorization and Cookie style headers on a change of host and
never looks at the scheme, so all three headers were forwarded to any
host the NetStorage endpoint redirected to, including over plaintext
HTTP. The signature is bound to the path and action but can be
replayed for that same request within the server's time window.

The client now uses rest.StripHeadersOnCrossHostRedirectFn, so it
refuses to follow a redirect which downgrades HTTPS to HTTP and strips
the three headers from any redirect which leaves the host and port of
the original request, matching the b2, s3, webdav and http backends.
2026-11-06 12:08:09 +00:00
Nick Craig-Wood 544758e553 swift: fix password being resent to another host on auth redirect GHSA-m97j-7x65-wh93 CVE-PENDING
A v2 or v3 auth request posts the user's password, or the application
credential secret, to the auth server in a JSON body which net/http
can rewind. On a 307 or 308 redirect to another host it resent that
body, password included, to the new host.

The transport now refuses to follow a redirect which leaves the host
and port of the original request if it would resend the request body.
2026-11-06 12:08:09 +00:00
Nick Craig-Wood 7b29032d4d swift: fix auth token leaking to other hosts or over plaintext on redirect GHSA-m97j-7x65-wh93 CVE-PENDING
Every Swift storage request carries the account scoped session token
in the X-Auth-Token header, and a v1 auth request carries the user
name and API key in X-Auth-User and X-Auth-Key. The swift library
makes its own http.Client with Go's default redirect policy, which
only strips Authorization and Cookie style headers on a change of host
and never looks at the scheme, and it does not let rclone replace that
client. So the token, or the API key, was forwarded to any host the
storage or auth server redirected to, including over plaintext HTTP.

rclone does supply the library's Transport, so the redirect policy is
now applied there instead: the transport refuses to follow a redirect
which downgrades HTTPS to HTTP and removes the three headers from any
hop once the redirect chain has left the host and port of the original
request, matching the b2, s3, webdav and http backends.
2026-11-06 12:08:09 +00:00
Nick Craig-Wood b4659519df fshttp: refuse HTTPS to HTTP redirects unless --allow-https-downgrade GHSA-m97j-7x65-wh93 CVE-PENDING
A server which redirects an https:// request to an http:// URL makes
rclone replay the request in cleartext. Any credentials the backend
sets as headers are then readable by anyone on the network path, and
the response can be altered in transit.

The transport now refuses to follow such a redirect, so this holds for
every client built on it, including those of libraries which install
their own redirect policy. Use --allow-https-downgrade to follow the
redirect anyway, for example when downloading from a mirror which
redirects to plain HTTP.

Backends which check redirects themselves still refuse a downgrade
which would send their credentials in cleartext, regardless of the
flag.
2026-11-06 12:08:09 +00:00
Nick Craig-Wood 866e7ac577 serve sftp: fix auth proxy running for public keys the client can't use GHSA-66qv-66mg-9h74 CVE-PENDING
With --auth-proxy, the proxy program was run from PublicKeyCallback,
which the SSH library also calls when a client merely offers a public
key without a signature to ask whether it would be accepted. Anyone
who knew a user's public key could make the proxy program run and a
backend be created and cached for that user without holding the
private key. Combined with GHSA-66qv-66mg-9h74 this let an attacker
log in as any user whose public key they knew, from their own IP
address and without the user having logged in.

The proxy program is now run from VerifiedPublicKeyCallback, which is
only called once the client has signed with the private key.
2026-11-06 12:08:09 +00:00
Nick Craig-Wood f6ca72fabb serve sftp: fix auth proxy letting a public key be used as a password GHSA-66qv-66mg-9h74 CVE-PENDING
With --auth-proxy, the cache of backends made by the auth proxy
program was keyed on the user, the credential and the client IP but
not on whether the credential was a password or a public key. A public
key is not a secret, so once its owner had logged in with it anyone
who knew it could, from the same IP address and within the cache
lifetime, log in as that user by sending the base64 public key as
their password. The cached backend was returned without the proxy
program being asked, so a program which refuses all passwords was
bypassed. A password login arriving at the same time as the public key
login could also share its proxy call for the same reason.

The kind of credential is now part of the cache key and is checked on
a cache hit, so a public key sent as a password is passed to the proxy
program to decide, as it was on a cold cache.
2026-11-06 12:08:09 +00:00
Nick Craig-Wood a3c6bb2a32 build: fix macOS CI failing when the runner image has no homebrew/core tap
Newer Homebrew makes 'brew untap --force' fail with 'No available tap'
if the tap isn't installed. Whether homebrew/core is tapped varies
between macOS runner images, so the mac_arm64 and mac_amd64 jobs were
failing intermittently. Only untap the taps which are present.
2026-11-06 11:17:22 +00:00
Nick Craig-Wood 7c60060d4c onedrive: OneDrive for Business and SharePoint users must upgrade to v1.76+ before April 1, 2027
Microsoft is retiring pre-authenticated (tempauth) URLs in OneDrive
for Business and SharePoint from April 1, 2027. Rclone versions before
v1.76 rely on them, so after that date their downloads, chunked
uploads and server-side copies will fail. Rclone v1.76 and later keep
working with the existing configuration.

Call this out at the top of the OneDrive docs and in the
Pre-authenticated URLs section.

See: #10070
2026-11-05 12:52:29 +00:00
Nick Craig-Wood 2b4cb5f85b onedrive: return an error if a ranged download doesn't return the requested range
If the server sent the whole file, or a different range, in response
to a ranged download rclone returned it as if it were the requested
range, which gave wrong data when seeking.

Check the response with rest.CheckContentRange and return an error if
it doesn't satisfy the requested range. fs.ErrorRangeIgnored lets
multi-thread copies fall back to a single stream.

This guards against Graph serving downloads itself after the
retirement of pre-authenticated (tempauth) URLs.

See: #10070
2026-11-05 12:52:29 +00:00
Nick Craig-Wood 84bdebae77 onedrive: authenticate SharePoint downloads, uploads and copies after tempauth retirement
Microsoft is retiring pre-authenticated (tempauth) URLs in OneDrive for
Business and SharePoint from April 1, 2027. After that the download
redirect, upload session and copy monitor URLs need a SharePoint token,
which the Graph URL tenant setting doesn't change for upload sessions
or the copy monitor.

When SharePoint returns one of these URLs without tempauth rclone now
gets a token for the SharePoint host by redeeming the existing refresh
token, which works with the existing Graph consent so no reconnect or
config change is needed. Pre-authenticated URLs are still called
without authentication, so nothing changes until Microsoft retires
them.

If no SharePoint token can be got, for example with client credentials
using a secret, files under 250 MiB are uploaded in a single request
instead and server-side copies fall back to download and upload. A
copy monitor which redirects to the new item, as authenticated ones
do, is treated as finished, a refused monitor fails straight away
instead of polling until the timeout, and 401 errors explain the
retirement.

See: #10070
2026-11-05 12:52:29 +00:00
Nick Craig-Wood c7a34b80d3 lib/oauthutil: add ScopedTokenSource to get tokens for other scopes
ScopedTokenSource returns a token source which fetches tokens for
different scopes using the same credentials. Without the client
credentials flow it redeems the current refresh token for the new
scopes, which Microsoft Entra ID allows for other resources such as
SharePoint. The tokens are cached but never stored in the config file.
2026-11-05 12:52:29 +00:00
Nick Craig-Wood 5ecc9f685b Add 16 new contributors
- berce
- Fred
- Florian Josef Altendorfer
- ashwinibailangadi
- ilya lavrenov
- Soujas K
- Robert Kubiś
- b84330808
- Guillaume MALLERET
- Louis
- 唐僧洗发用飘柔
- Seth Westphal
- ZainnQureshii
- Rishi Raj Jain
- Enbiya Göral
- Florian Josef Altendorfer
2026-11-05 12:52:29 +00:00
Enbiya Göral 545936b681 s3: add missing Huawei OBS regions (#9090) 2026-11-05 12:36:19 +00:00
Rishi Raj Jain 1a319f2191 s3: add Neon Object Storage provider 2026-11-05 12:35:07 +00:00
ZainnQureshiiandClaude Opus 5.5 5930441699 b2,s3,azurefiles,drive: fix flag names in option help
The help for these options named flags that do not exist, so copying
them gives "unknown flag": --sse-customer-key(-base64) is
--b2-/--s3-sse-customer-key(-base64), --azurefile-upload-concurrency is
--azurefiles-upload-concurrency and --ignore size is --ignore-size.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 12:33:27 +00:00
Seth WestphalandClaude Opus 5.5 bbf4a983d7 fs: add MIME type for JPEG XL (.jxl) files
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 12:32:14 +00:00
kevin cf93478f87 lib/http: accept the Basic auth scheme in any case
The Authorization header parser only accepted the scheme spelled
exactly "Basic", so a client sending "basic" or "BASIC" was refused
with 401 even with the right credentials. Auth schemes are
case-insensitive (RFC 9110 section 11.1), and net/http's
Request.BasicAuth already compares the scheme with EqualFold.
2026-11-05 12:30:49 +00:00
berce b3d0b38694 oracleobjectstorage: retry 409 ConcurrentObjectUpdate errors - fixes #10194 2026-11-05 12:23:22 +00:00
唐僧洗发用飘柔 3ca4fd99b6 operations: fix oversized getfile tail requests
Clamp suffix ranges that extend past the start of the file before checking the maximum response size.

Fixes #9941
2026-11-05 12:21:26 +00:00
kevin 5c143c89bf lib/proxy: send the decoded user and password in Proxy-Authorization
HTTPConnectDial built the Basic credential from url.Userinfo.String(),
which percent-encodes the user name and password. A proxy URL such as
http://me%40example.com:p%40ss@proxy:3128 sent "me%40example.com:p%40ss"
instead of "me@example.com:p@ss" and the proxy refused it.

Use Username() and Password() like net/http does, which also always
puts the ":" separator in.
2026-11-05 12:20:41 +00:00
Neil Cawse d1c54c4e5d vfs: fix uploads never starting while a dirty file keeps being read
Every close of a dirty cache item re-adds it to the writeback queue,
and WriteBack.Add reset the upload timer even when the file had not
been modified since it was last queued. A file which is opened and
closed more often than --vfs-write-back - for example a git object
that another process keeps reading - therefore never got uploaded
while the reads continued.

Before this change, with --vfs-write-back 2s, a file reread every
0.5s through a mount was still not uploaded after 12s of reads. After
it the file is uploaded after 2.6s, while the reads carry on.

This only moves the timer on when the item has been modified, which
was already the condition for cancelling an upload in progress. An
unmodified re-add now also leaves the retry backoff of a failed
upload alone.
2026-11-05 12:12:15 +00:00
Leon Brocard 2ff2bfee8f s3: fix presigned URLs for GCS, Linode and Ceph
Presigning failed with "not found: FixupRequest" for providers with
use_x_id or sign_accept_encoding set to false, breaking rclone link,
the link backend command and use_presigned_request uploads.

Presigning replaces the Signing middleware with PresignHTTPRequest
before APIOptions run, so anchor the request fixups on whichever is
present.
2026-11-05 12:04:58 +00:00
phatlc 2fb0f2c58c serve sftp: fix rmdir deleting a file instead of returning an error
An SSH_FXP_RMDIR request on a file removed the file, as rmdir and
remove were both handled by VFS.Remove. Make rmdir return ENOTDIR for
anything which isn't a directory, as serve ftp already does.
2026-11-05 11:46:56 +00:00
phatlc 5795966909 serve dlna: fix crash on Browse with a negative StartingIndex or RequestedCount
A Browse request with a negative StartingIndex or RequestedCount made
the handler panic slicing the directory listing, so the client got a
plain text 500 with the Go runtime error instead of a SOAP fault.

These arguments are ui4 in the ContentDirectory SCPD, so reject
negative values with the UPnP "Argument Value Invalid" (600) error.
2026-11-05 10:23:52 +00:00
Louis 334e7046e3 swift: Add Infomaniak Swiss Backup provider 2026-11-04 18:06:20 +00:00
Louis 89f2a613ab s3: add Infomaniak Swiss Backup provider 2026-11-04 18:06:20 +00:00
Nick Craig-Wood 2b1eabe992 gui: update embedded release to 1.2.1 2026-11-04 17:58:05 +00:00
Nick Craig-Wood 47fb4879a4 docs: update pending CVE markers 2026-11-04 17:52:56 +00:00
Nick Craig-Wood e74889ec87 build: update github.com/andybalholm/brotli to v1.2.7 to fix CVE-2026-111497
The brotli decoder before v1.2.7 can panic on malformed input
(GHSA-c9gr-gmg4-2vqv). It is reached via github.com/mholt/archives when
"rclone archive list" or "rclone archive extract" sniffs the format of
an input file, so a crafted file could crash those commands.

Thanks to @rvkumar396 for reporting.
2026-11-04 16:55:23 +00:00
Nick Craig-Wood c9a5f106a4 test_all: run the 50 byte chunker tests against Minio instead of AWS S3
The 5 chunker remotes with 50 byte chunks split each test file into
many objects which made them expensive to run against AWS S3. Run
them against the TestS3Minio docker server instead and keep
TestChunkerS3 with the default chunk size on AWS.
2026-11-03 15:40:03 +00:00
Nick Craig-Wood 918f5a68ee testserver: start the test server for remotes wrapped by chunker, crypt etc
If a remote has no start script but wraps another remote via its
"remote" config value, start the server for the wrapped remote
instead. This lets wrapping backends be tested against the docker
test servers, e.g. chunker over TestS3Minio.

test_all now honours RCLONE_CONFIG in the same way as the tests it
runs, so it finds the same wrapping remotes.
2026-11-03 13:13:19 +00:00
Nick Craig-Wood 9751d168bc pikpak: fix uploads failing when the server is slow to complete them
After an upload PikPak completes it in an asynchronous task. rclone
polled the task with low level retries which gave up after about 12
seconds, and upload tasks now regularly take longer than that (up to
34s seen), so the upload was cancelled and failed with "can't verify
the task is completed".

Poll the task with its own backoff for up to 5 minutes instead. This
also stops a pending task from slowing every other API call by
counting as rate limiting in the pacer.
2026-11-02 17:40:22 +00:00
Nick Craig-Wood bbec5f7676 Update sponsors 2026-11-02 16:16:21 +00:00
Nick Craig-Wood f0cb5dd6d3 lib/http: replace github.com/abbot/go-http-auth with built in htpasswd support
The go-http-auth library is unmaintained and rclone only used a small
part of it, so this replaces it with a small implementation of basic
auth and htpasswd file checking supporting the same bcrypt,
MD5-crypt ($1$ and $apr1$) and {SHA} formats.

It is tested against hashes made by htpasswd, openssl, mkpasswd and
Python bcrypt, expecting the same results as go-http-auth gave.

A missing or unreadable htpasswd file now logs an error and rejects
requests rather than panicking, malformed lines are logged and skipped,
and entries in unsupported formats are logged when the file is loaded.
2026-11-01 11:34:25 +00:00
Nick Craig-Wood ba11197157 fstest: move the Sia and Owncloud test servers out of the ephemeral port range
Their fixed ports, 39980 and 38081, are in Linux's ephemeral range
(32768-60999), so a socket from another concurrent test could hold
them and stop the server starting. This made all 5 tries of the Sia
fs/operations tests fail with "address already in use".
2026-10-31 17:22:39 +00:00
Nick Craig-Wood 8c2a650661 chunker: skip norename overwrite tests on remotes without metadata
The server-side overwrite tests ran norename transactions on the
nometa test remotes, which can't support them, so they failed with
"readXactID requires valid metaobject".
2026-10-31 17:22:39 +00:00
Guillaume MALLERET 5118e0c32a kdrive: add backend for Infomaniak kDrive
- fixes #8456
2026-10-31 16:57:03 +00:00
Guillaume MALLERET 47e22cac82 lib/oauthutil: add PKCE support 2026-10-31 16:57:03 +00:00
b84330808andClaude Opus 5.5 4e19a969f5 operations: fix cat --offset before the start of a file printing its end
When a negative --offset reached back past the start of a file and
--count was set, rclone cat printed bytes from the end of the file
instead of from its start. For example on a 10 byte file

    rclone cat --offset -12 --count 5 file

printed the last 2 bytes rather than the first 5. With several files
of different sizes, each file shorter than -offset was affected.

The start of the range stayed negative after the file size was added,
and a negative Start with an End set is a suffix range (the last End
bytes). Clamp the start to 0 instead, which matches what
`tail -c 12 file | head -c 5` prints. --tail and --offset without
--count were not affected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 15:29:07 +00:00
kevin a4a2a78b90 lib/transform: make titlecase capitalise words instead of upper-casing them
The titlecase transform is documented as converting the file name to
Title Case, but it used strings.ToTitle, which maps every letter to its
title case form - upper case for nearly all letters - so it did the
same as uppercase: "hello world.txt" became "HELLO WORLD.TXT".

Use golang.org/x/text/cases instead, so it becomes "Hello World.txt".
2026-10-31 15:26:48 +00:00
Robert Kubiś 68906658fd ftp: fix ranged reads failing when the server answers 425 to an early close - Fixes #10200
A ranged read through the VFS opens the object at offset 0 and then
seeks, so the first RETR is closed before anything was read. Egnyte
answers that close with 425 instead of 426.

425 was not masked, so ftpReadCloser.Close quit the connection and
returned an error. The VFS then retried and closed the same reader
again; the second Close got nil from the already closed response, put
the connection that had been quit back in the pool and returned its
token a second time. Later operations failed with "use of closed
network connection" and serve s3 returned InternalError.

Now a second Close does nothing, a connection is never pooled after
Quit, and 425 is masked only when nothing was read, so a transfer cut
after data was read is still an error.
2026-10-31 15:22:04 +00:00
Soujas K a89766467e fs: reject NaN durations instead of wrapping
strconv.ParseFloat accepts "NaN", and NaN compares false with every
bound, so it got past the overflow check added for 300y and was then
converted to a time.Duration, which gives the minimum int64 on amd64.
--max-age NaN or --min-age NaNM silently became about -292 years,
which moves the filter's ModTimeFrom into the future and excludes every
file, instead of being rejected.
2026-10-31 15:18:08 +00:00
Soujas K 0a0661ade0 onedrive: poll async copy status with the configured HTTP client
waitForJob polled the copy monitor URL with http.Get, which uses
http.DefaultClient and no context. The poll ignored the configured
transport (--ca-cert, --no-check-certificate, --timeout, --user-agent,
--dump and so on) and could not be cancelled, so a monitor that stopped
answering blocked a server-side copy indefinitely.

Build the request with the context and send it through the unAuth
client, which uses the configured transport without adding the
Authorization header the monitor URL does not need.
2026-10-31 15:17:16 +00:00
ilya lavrenov b44c24cfca accounting: fix totalBytes counting server-side copies and moves twice
When a server-side copy or move finished without reporting progress
along the way, its bytes were added to the stats but not to the
transfer, so until the transfer ended core/stats reported totalBytes as
the bytes done plus the whole size of the transfer still in progress.
A 1 GiB server-side copy on the local backend showed 1 GiB of 2 GiB
while rclone checked the hashes.

The bytes now go on the transfer as well, as they already did for
backends which report progress through NewServerSideCopyAccounter.
2026-10-31 13:17:30 +00:00
albertony 941f6a7217 jottacloud: fix traditional authentication failing with invalid_client
Configuring traditional authentication set client_id but kept any
existing client_secret, for example one left in the config from legacy
authentication with a machine specific API key. Whitelabel services
that have moved to an Ory Hydra based identity provider, such as elkjop
and elko, reject a client secret for their public clients, so the token
exchange failed with:

  invalid_client: The OAuth 2.0 Client supports client authentication
  method 'none', but method 'client_secret_post' was requested.

Clear client_secret, as standard authentication already does.
2026-10-31 13:16:23 +00:00
albertony 8df62024e9 jottacloud: fix authentication with whitelabel services elkjop and elko
Elkjøp Cloud and ELKO Cloud have moved from Keycloak to an Ory Hydra
based identity provider. rclone picks up the new endpoints from the
OpenID discovery document, but authentication failed with:

  invalid_scope: The OAuth 2.0 Client is not allowed to request scope
  'jotta-default'.

The discovery documents now only list openid, offline and
offline_access as supported scopes, so drop jotta-default and request
openid and offline_access, as already done for phonero and letsgo,
which are also on Hydra.

Existing remotes must be reconnected with "rclone config reconnect",
as tokens issued by the old provider are rejected with invalid_grant
when refreshed.
2026-10-31 13:16:23 +00:00
ashwinibailangadi e6308417a2 docs: fix description of directory times when they are unknown 2026-10-31 13:15:36 +00:00
Florian Josef AltendorferandFlorian Josef Altendorfer f17291df25 seafile,pcloud,pikpak,zoho,imagekit: stop multipart writers after failed uploads - fixes #10168
Co-authored-by: Florian Josef Altendorfer <341419330+florianaltendorfer@users.noreply.github.com>
2026-10-31 13:11:08 +00:00
Fred b2664e513a seafile: fix operations on files whose names are not in Unicode NFC form
Seafile stores file and directory names in NFC whatever form they are
uploaded in, but looks them up byte for byte. A file uploaded with a
decomposed (NFD) name, as macOS produces, could therefore not be found,
renamed, copied or moved afterwards, and the operations integration
test TestMoveUnicodeEquivalentWithoutServerSideMove failed.

This normalizes paths inside a library to NFC before they are sent to
the server. Library names are left as they are since Seafile stores
them verbatim.
2026-10-31 12:41:17 +00:00
berce d1ae66cac0 iclouddrive: mark the backend as case insensitive - fixes #10162
findLeafItem matches names with EqualFold, but the backend did not set
CaseInsensitive, so a case-only rename was not seen as the same object
and the file being renamed was trashed before the move.
2026-10-31 12:38:53 +00:00
kevin9327 64a68c7380 fs: reject --bwlimit times without a colon or with signed digits
validateHour only checked the length of the hh:mm field and then
parsed the two halves with strconv.Atoi. It never checked the third
character, so "10h20" or "Mon-10.20" were accepted as 10:20, and
since Atoi accepts a sign, "+1:00" and "Mon-10:+5" were accepted
as 01:00 and 10:05.

Require the colon and four digits so these are reported as invalid
time specifications like other malformed times.
2026-10-31 12:36:13 +00:00
kevin9327 18c7de0920 fs: reject NaN sizes and CountSuffix values that overflow
SizeSuffix.Set and CountSuffix.Set accepted "nan" before a suffix, so
--max-transfer nank became -1 ("off") without an error. CountSuffix.Set
also had no overflow check, unlike SizeSuffix, so 10E or infk wrapped
to the same value.
2026-10-31 12:27:06 +00:00
kevin9327 0de32e2269 filter: make [^...] in globs exclude / and document it
A glob like file[^a].txt was translated to (^|/)file[^a]\.txt$ in path
mode, so it matched file/.txt across a directory boundary, unlike ?
and the [!...] form.

Treat a leading ^ in a character class the same as a leading !: in
path mode it now also excludes /, and [^] is rejected as an empty
character class. Document both negation forms in the filtering
grammar.
2026-10-31 12:26:02 +00:00
kevin9327 c434df94d1 filter: make [!...] in globs a negated character class
The filtering docs give the character class syntax as [ [ ! ]
{ character-range } ], but globToRegexp copied the ! into the regexp
unchanged, so file[!a].txt matched filea.txt and file!.txt and
excluded fileb.txt - the opposite of what was asked for.

Translate a ! at the start of a character class to ^. In path mode
the class also excludes /, in the same way ? does. [!] is rejected
as an empty character class.
2026-10-31 12:26:02 +00:00
kevin dee8b7e285 fs: accept multi-unit relative times like 1d12h in Time options
The docs say Time options take the same relative forms as Duration
options, with "1d12h" as an example, but ParseTime only tried
time.ParseDuration and single-suffix values, so --max-age style
Time options such as --s3-version-at 1d12h were rejected.
2026-10-31 12:24:46 +00:00
Nick Craig-Wood 989fd82b13 build: disable Multipath TCP listeners by default to mitigate CVE-2026-98432
Go enables MPTCP on listening sockets on Linux by default, which
exposes rclone servers (rcd, serve http/webdav/s3/sftp/ftp etc) to the
Linux kernel use-after-free CVE-2026-98432, demonstrated as remote code
execution via an MPTCP listener.

This sets `godebug multipathtcp=0` in go.mod so rclone listens with
plain TCP. Outgoing connections are unaffected as Go does not use MPTCP
for them by default.

This is a mitigation only - the fix is to upgrade to a patched kernel.
Users who need MPTCP can re-enable it with GODEBUG=multipathtcp=2.

See: https://go.dev/issue/82717
2026-10-30 17:11:15 +00:00
Nick Craig-Wood 5f50a4ce61 Add 3 new contributors
- hahahahahayesyeseys
- qodelicious
- fixfon
2026-10-30 17:11:15 +00:00
fixfon 5152a0d96e crypt: fix nil pointer panic in ObjectInfo.Hash - fixes #10173
OverrideRemote.UnWrap returns nil when the wrapped ObjectInfo is not an
fs.Object, as when serve s3 uploads through a combine remote over crypt.
Hash then called Fs() on that nil object and panicked. Return an empty
hash instead, as for other sources which cannot be unwrapped.
2026-10-29 18:51:30 +00:00
qodelicious be698038d8 docs: fix dead links in the box, sftp and webdav docs
The Box OAuth page, the SSH CBC paper on isg.rhul.ac.uk and the
nextcloud/nextcloud-snap issue all 404 now. Point them at the current
Box refresh token guide, the IEEE S&P 2009 copy of the paper and the
moved nextcloud-snap/nextcloud-snap repo.
2026-10-29 18:48:05 +00:00
Sven Rebhan 5f4d7c0e97 build: put google.golang.org/grpc back onto a released version
Downgrade the google.golang.org/grpc dependency from  a dev-version
(v1.85.0-dev.0.20260825072537-93e31b48545e) to the latest stable version
(v1.84.0). The downgrade does not introduce any vulnerabilities according to
govulncheck. The -dev version got carried forward from a110fa5d4 when it was
needed.
2026-10-29 18:45:55 +00:00
kevin9327 337c130c12 lib/readers: return the cache length when RepeatableReader.Seek goes past it
Seek documents that a position past the cached data returns the maximum
usable offset with an error, but it returned offset - (abs - cacheLen),
which is only the cache length for io.SeekStart. For io.SeekCurrent and
io.SeekEnd it returned an arbitrary, possibly negative, number.
2026-10-29 18:40:46 +00:00
Dirk Petersen 11b681a1f0 lib/transform: fix flaky date test at the end of a minute
TestVarious computed the expected date when it built the test table and
the transformed name later. When a minute ended between the two, the
macfriendlytime case failed, for example 0243PM against 0244PM.

The date cases are now in TestDate, which reads the clock before and
after the call to Path and accepts either date.
2026-10-29 18:33:48 +00:00
Dirk Petersen e906ecf25f local: set only the ownership a non-root user may set
With --metadata rclone applied the uid and gid of the source with
chown, which fails with "operation not permitted" for a user other than
root whenever the source belongs to another user or group (a file of
root or of a colleague), so the copy of such a file failed.

As a user other than root, leave the uid as it is (only root can give
a file away) and set the gid only to a group the process is a member
of, as rsync -a does; skip the chown if neither applies. Root is
unchanged.
2026-10-29 18:32:22 +00:00
Rohit Behera f17b89b0c2 lib/http: return 416 for ranges --rc-serve and serve restic can't satisfy - fixes #10140
Before this change, a Range request starting at or after the end of the
object, or ending before it started, was answered with a 206 and a
malformed Content-Range such as "bytes 10-9/10". Resuming a complete
download with curl -C - or wget -c, which send such a range and expect a
416, saw a broken partial response instead.

After this change these requests get a 416 with "Content-Range: bytes */size",
and a Range on an empty object is ignored, the same as http.ServeContent
does for rclone serve http.
2026-10-29 18:28:49 +00:00
hahahahahayesyeseys fe813c53f2 chunker: clean stale chunks after server-side overwrite - fixes #10123 2026-10-29 18:26:10 +00:00
jxj 6e01cb6e22 webdav: fix memory leak uploading to Infinite Scale - fixes #10131
Remove the unused progress notification goroutine, which kept each
Uploader and its buffered Upload alive after uploading finished or failed.

Test that uploads can be collected after success, failure and empty uploads,
while preserving retries and repeated Upload calls.
2026-10-29 18:23:37 +00:00
Nick Craig-Wood e236d983a6 serve s3: fix tests leaving multipart uploads open on Windows
newPutTestBackend returned a second backend rather than the server's
own, so the server's Shutdown never aborted the multipart uploads made
through it. Their temporary files were left open, which stopped the
test's temporary directory being removed on Windows:

    TempDir RemoveAll cleanup: unlinkat ...\test-37: The directory is not empty.
2026-10-29 17:31:57 +00:00
Nick Craig-Wood 94319c360b vfs: retry finding the object in the test which checks modtime support
canSetModTime looked up the object it had just written without any
retries, so on backends with read-after-write lag such as Internxt the
lookup failed. As the result is only worked out once, the remaining
TestFileSetModTime subtests then ran assuming the modtime could be set.
2026-10-29 15:11:33 +00:00
Nick Craig-Wood 7935d517fb fstest: raise the Seafile API throttle limits for the integration tests
Seahub throttles anonymous API calls, which includes logging in, to 60
per minute. When cmd/bisync and backend/seafile ran concurrently they
exceeded this and every new remote failed with "failed to
authenticate" until the end of the retries.
2026-10-29 14:30:25 +00:00
Nick Craig-Wood 396b38e7bc huaweidrive: fix creating, copying and renaming files with non-ASCII names
Huawei Drive doesn't decode JSON request bodies as UTF-8 unless the
charset is given. This meant creating directories or empty files with
non-ASCII names failed with "The fileName can not be blank and can not
contain..." and server-side copies mangled the names, eg `équivalent.txt`
became `�quivalent.txt`.

Send "application/json; charset=UTF-8" on all requests with a JSON
body as is already done for the multipart upload metadata.
2026-10-29 14:19:10 +00:00
Nick Craig-Wood 67472a7c08 fstest: remove the vsftpd per-IP connection limit for the integration tests
The fauria/vsftpd image limits each IP to 50 connections. When
cmd/bisync and backend/ftp ran concurrently against the shared server
the backend tests were refused with 421 "There are too many
connections from your internet address" on every retry.
2026-10-29 14:19:09 +00:00
Nick Craig-Wood 283277aa2b serve s3: document the design goals
Explain the choices behind many of the limitations of serve s3, such as
keeping metadata in memory rather than in a database, and link the
limitations to them. Also fix the statement that empty directories
can't be made below the buckets, as they can by putting a directory
marker.
2026-10-29 11:01:49 +00:00
Nick Craig-Wood ba5c6c89fd serve s3: make --no-cleanup a deprecated alias of --directory-markers
--no-cleanup was documented as keeping the directories emptied by
DeleteObject, but has never been implemented. --directory-markers
keeps them, listing them as directory markers so the listings stay
consistent, so make --no-cleanup do that, logging that it is
deprecated.
2026-10-29 11:01:47 +00:00
Nick Craig-Wood 41aafbdb92 serve s3: test the s3 backend with directory markers against serve s3
Run the s3 backend integration tests with --s3-directory-markers
against serve s3 with --directory-markers.
2026-10-29 11:01:47 +00:00
Nick Craig-Wood 399fd8e102 serve s3: remove a directory with its last key after its marker is deleted
With --directory-markers, deleting the directory marker of a directory
holding other keys had no effect, so the directory was still listed
and wasn't removed when the last key below it was deleted. This left
directories behind when the keys of a directory were deleted in an
order other than deepest first, as rclone's s3 backend does when
purging.

Remember the deletion in memory, so the marker isn't listed and the
directory is removed with the last key below it.
2026-10-29 11:01:46 +00:00
Nick Craig-Wood 98a8e793bd s3: fix rmdir of the root directory with --s3-directory-markers
Rmdir only removed the directory marker of a directory below the root
of the remote, so `rclone rmdir remote:bucket/dir` left the marker of
dir behind, and with it the directory.
2026-10-29 11:01:45 +00:00
Nick Craig-Wood cb73c3ae0b serve s3: document that the Content-MD5 and checksum of all request bodies are checked
gofakes3 v0.0.12 checks the bodies of requests which aren't object
data, such as DeleteObjects, against their Content-MD5 and
x-amz-checksum-* headers.

See #10082
2026-10-29 11:01:44 +00:00
Nick Craig-Wood 19902ee657 serve s3: document that x-amz-expected-bucket-owner is ignored
See #10082
2026-10-29 11:01:43 +00:00
Nick Craig-Wood 4dfbe7f320 serve s3: add --directory-markers to list every directory as a marker
Clients which rely on directory markers, such as rclone's s3 backend
with --s3-directory-markers, need the markers of directories holding
other keys listed, which serve s3 only does while it remembers the
marker was put.

With --directory-markers every directory is listed as a directory
marker, and isn't removed when the last key below it is deleted.

See #10082
2026-10-29 11:01:41 +00:00
Nick Craig-Wood cb150c014d serve s3: list objects with the storage class they were put with
Listings gave every object the STANDARD storage class, while HEAD and
GET gave the one it was put with.

See #10082
2026-10-29 11:01:40 +00:00
Nick Craig-Wood be847e8be6 serve s3: let a failed CompleteMultipartUpload be retried
A CompleteMultipartUpload which failed because the parts it listed
didn't match those uploaded, or had a gap in the part numbers, aborted
the whole upload. Leave the upload open instead, as S3 does, so the
client can upload any missing parts and complete it again.

See #10082
2026-10-29 11:01:39 +00:00
Nick Craig-Wood e670d0b9e4 serve s3: test Last-Modified of HEAD and GET of objects put with an mtime
Check the Last-Modified of HEAD and GET is the modification time set
from the X-Amz-Meta-Mtime or mtime metadata, as in listings, rather
than the time of the PUT.

See #10082
2026-10-29 11:01:38 +00:00
Nick Craig-Wood 85f1f9d39b serve s3: list the directory markers put for directories holding other keys
A directory marker such as dir/ was only listed while its directory
was empty, as nothing recorded whether it had been put or the
directory made by putting a key below it. On S3 a marker which was put
is listed until it is deleted, which some clients rely on.

The markers put are now remembered with the object metadata, in memory,
and listed while the directory holds other keys, and their directories
are no longer removed when the last key below them is deleted.

See #10082
2026-10-29 11:01:37 +00:00
Nick Craig-Wood d1dad75105 serve s3: document which S3 features are supported
Replace the list of supported operations with a table of what is and
isn't compatible with AWS S3, and remove the bug about multipart server
side copies, which work now that UploadPartCopy is implemented.

See #7454
2026-10-29 11:01:36 +00:00
Nick Craig-Wood fc9e6a5ca4 serve s3: don't compute the MD5 of multipart uploads unless the ETag is MD5
The MD5 of the whole object, needed only for MD5 ETags, was computed
for every multipart upload, costing about a CPU-second per GiB even
with --etag-hash "" or a different hash.
2026-10-29 11:01:35 +00:00
Nick Craig-Wood 300ce62b7f serve s3: don't read directories needlessly when listing with a delimiter
With a delimiter other than "/" found in directory names, each
directory rolling up into a common prefix was read to see if it had
any keys, even if that prefix had already been listed, on this page or
an earlier one.
2026-10-29 11:01:34 +00:00
Nick Craig-Wood cbfd024bbe serve s3: keep the MD5 ETag of objects on remotes without MD5
With --etag-hash MD5, the default, on a remote which can't give the
MD5 of objects, such as crypt or sftp, PutObject returned the MD5 of
the data as the ETag but HeadObject and listings returned "".
Listings also ignored the MD5 kept for multipart uploads, and copying
an object onto itself to replace its metadata lost it, along with the
sizes of its parts.

The MD5 of a PutObject is now kept with the object's metadata, as for
multipart uploads, listings return it, and replacing the metadata of
an object keeps it.

See #10082
2026-10-29 11:01:32 +00:00
Nick Craig-Wood f0e9e4c70e serve s3: remove the directories made for a failed upload
A PutObject or CreateMultipartUpload which failed left behind the
directories made for its key, which are listed as directory markers,
so the client saw keys such as "newdir/sub/" it never made.

They now remove the directories they made if they are still empty, as
aborting a multipart upload already did.
2026-10-29 11:01:31 +00:00
Nick Craig-Wood 79c19dc1d3 serve s3: allow copying directory markers
CopyObject with a directory marker such as "dir/" as its source failed
with InvalidArgument, although HeadObject and GetObject found it.

It now copies the marker, making the directory of another marker or an
empty object.

See #10082
2026-10-29 11:01:30 +00:00
Nick Craig-Wood 617476d0e7 serve s3: list empty directories as directory markers
Putting a directory marker such as "dir/" made the directory, but a
listing without a delimiter didn't return the "dir/" key, so clients
which make empty directories with markers, as the AWS console and many
sync tools do, lost them.

An empty directory is now listed as its directory marker. Other
directories aren't, as they may not have been made by putting one.

See #10082
2026-10-29 11:01:29 +00:00
Nick Craig-Wood c42e311c1c serve s3: make the ETag returned on upload agree with HEAD for any --etag-hash
With an --etag-hash other than MD5, including "" to turn hashing off,
PutObject returned the MD5 of the data and CompleteMultipartUpload an
S3 style multipart ETag, neither of which matched the ETag HeadObject
and listings returned for the object afterwards.

These now return the ETag HeadObject does. Listings now quote an empty
ETag as HeadObject does.

See #10082
2026-10-29 11:01:28 +00:00
Nick Craig-Wood df5b121b6e serve s3: update the s3-tests expected to pass
This updates expected-pass.txt for the fixes to gofakes3 and serve
s3, which make 125 more of the s3-tests pass, 297 in all.

36 tests no longer pass, as they only passed because requests were
silently ignored or mishandled:

- 19 used SSE-C or SSE-KMS, now refused as they aren't supported
- 12 browser POST uploads expected 400, but unauthenticated requests
  now get 403 as on AWS
- 5 set an ACL other than private, or object lock, now refused

The s3-tests config now gives the owner gofakes3 uses for the main
user, so the ACLs returned match it, and no api_name, as bucket
locations aren't stored.
2026-10-29 11:01:27 +00:00
Nick Craig-Wood ae473af277 serve s3: make conditional writes atomic against other writes through the server
gofakes3 now checks the If-Match and If-None-Match conditions of
PutObject and CompleteMultipartUpload before the object is written,
but another write can replace the object before this one finishes.
For example, of many concurrent uploads of a new object with
If-None-Match: *, most succeeded.

Now when the object is written to a temporary object and moved into
place, the conditions are checked again as it is moved, with the moves
of objects to the same path through the server serialised, so only one
of the uploads in the example succeeds.
2026-10-29 11:01:26 +00:00
Nick Craig-Wood 9b5d4567a9 serve s3: make the ETag of multipart uploads agree with HEAD and support partNumber
CompleteMultipartUpload returned the AWS S3 style multipart ETag (the
MD5 of the parts' MD5s with a "-N" suffix), but HeadObject and GET
returned the hash of the object, so clients saw the ETag of an object
change as soon as it was uploaded.

Now with --etag-hash MD5, the default, the ETag returned by
CompleteMultipartUpload is the MD5 of the whole object, worked out as
it is streamed, so is the same as HeadObject returns. With other
hashes it is stored with the object's metadata and returned by
HeadObject while the metadata is kept.

The sizes of the parts are kept with the metadata too, so GET and
HEAD with partNumber and GetObjectAttributes can return the parts of
the object.
2026-10-29 11:01:25 +00:00
Nick Craig-Wood 63bd68739b serve s3: support directory markers and refuse file/directory conflicts
Keys ending in "/", such as "dir/", are used by S3 clients as
directory markers. gofakes3 used to strip the "/", so putting "dir/"
made a file "dir", which then stopped "dir/file" being written. Now
that gofakes3 keeps the "/" these keys were refused.

Now a key ending in "/" stands for the directory: putting an empty one
makes the directory, putting one with data is refused, HEAD and GET
return an empty object if the directory exists, and deleting one
removes the directory if it is empty.

Writing an object which conflicts with a directory, or whose directory
conflicts with an object, now fails with InvalidRequest rather than an
internal error, which clients retried.
2026-10-29 11:01:24 +00:00
Nick Craig-Wood b80e7e8a7e serve s3: support listing with delimiters other than "/"
Listings treated any delimiter as "/", so a listing with a delimiter
such as "a" or "." returned the keys and directories rather than the
keys rolled up into common prefixes at the delimiter. A delimiter of
whitespace was ignored altogether.

Now any delimiter is supported. Delimiters other than "/" are found by
walking the directory tree below the prefix, skipping directories
whose names contain the delimiter as all their keys roll up into the
same common prefix.

A prefix starting with "/" now matches nothing, as no key can start
with "/", rather than listing the whole bucket.
2026-10-29 11:01:22 +00:00
Nick Craig-Wood 705d37fdde serve s3: quote the ETags of objects in listings
The ETags of objects in listings were returned without the double
quotes S3 uses, as in HEAD and GET responses, so clients which compare
them saw a difference.
2026-10-29 11:01:21 +00:00
Nick Craig-Wood 76c40ece85 serve s3: support changing object tags
Object tags given when an object is created are kept with its metadata
by gofakes3. This implements gofakes3's ObjectTaggingBackend so the
tags of an existing object can be changed with PutObjectTagging and
DeleteObjectTagging.

Like the rest of the metadata, tags are only kept in memory.
2026-10-29 11:01:20 +00:00
Nick Craig-Wood 85a0617d21 serve s3: fix CopyObject metadata including the source's and an mtime
CopyObject merged the source object's metadata into the metadata it
was given, so a copy with x-amz-metadata-directive: REPLACE kept the
source's metadata. It also kept the source's modification time by
adding it to the metadata, so every copy gained "mtime" user metadata
the client hadn't set.

gofakes3 now works out the complete metadata of the copy from the
metadata directive, so it is used as given. The source's modification
time is still kept, unless the metadata sets one, but without adding
it to the metadata.
2026-10-29 11:01:19 +00:00
Nick Craig-Wood 476d71616d serve s3: fix buckets being undeletable after an aborted multipart upload
Starting a multipart upload makes the directory its object will be in,
but aborting the upload didn't remove it. The empty directory isn't
visible to S3 clients, so the bucket looked empty, but deleting it
failed with BucketNotEmpty.

Now aborting an upload removes the directories made for it if they are
still empty, and deleting a bucket holding nothing but empty
directories removes them and succeeds. Deleting a bucket which fails
for some other reason than holding objects now returns that error
rather than BucketNotEmpty.

Removing the empty directories left after deleting an object now
checks they are empty first, rather than logging an error for each
directory which isn't.
2026-10-29 11:01:18 +00:00
Nick Craig-Wood 3e66f4fba7 build: update github.com/rclone/gofakes3 to v0.0.12
This brings in the S3 compatibility fixes found with the ceph s3-tests.
2026-10-29 11:01:17 +00:00
Nick Craig-Wood 82644742b7 serve s3: add runner for the ceph/s3-tests compatibility suite
This adds cmd/serve/s3/s3tests/run.sh which runs the ceph/s3-tests S3
compatibility suite against rclone serve s3, built from the tree.

It fetches s3-tests at a pinned commit, makes a venv with pinned
dependencies, starts rclone serve s3 with the users s3-tests needs and
summarises the results by feature area.

As serve s3 doesn't implement many S3 features (ACLs, versioning,
lifecycle, policies, etc) the results are checked against
expected-pass.txt, a list of the tests which should pass, rather than
a list of failures. The run fails if any of these don't pass, and
run.sh --update records newly passing tests.

A small pytest plugin adapts the s3-tests cleanup to serve s3 so a
bucket which can't be deleted fails only the test which left it
rather than every test after it.
2026-10-29 11:01:15 +00:00
Nick Craig-Wood 1053463755 test_all: ignore flaky Linkbox and Internxt tests
Linkbox renames an upload to name(1) when its name is the start of an
existing file's name, which breaks TestCaseInsensitiveMoveWithoutServerSideMove.

Internxt listings lag behind directory renames and deletes for longer than
the list retries allow, which breaks TestDirRename and
TestDirRenameParentEntries.
2026-10-28 15:04:56 +00:00
Nick Craig-Wood f45e78e294 test_all: make ignored tests also ignore their subtests
Previously ignoring a test with subtests had no effect, as the failures
are reported against the subtests.
2026-10-28 15:04:56 +00:00
Nick Craig-Wood 4db2817932 fstest: shut down test remotes when finished with them
Remotes made by RandomRemote aren't in the fs cache so they were never
shut down, leaving any pooled connections open until they timed out.

On TestFTPProftpd this used up the server's 30 connection limit part way
through the fs/operations tests, making all the following tests fail
with EOF when connecting.
2026-10-28 15:04:56 +00:00
Nick Craig-Wood 8594059b6a build: fix CVE-2026-111943 by upgrading to go1.27.3
- CVE-2026-111943: crypto/x509: mitigation for security concerns with a specific Certificate Authority

This also updates the go1.26 test job to go1.26.10 which contains the
same fix.
2026-10-28 13:16:31 +00:00
Nick Craig-Wood 661e46082b build: update golang.org/x/net to v0.62.0 to fix multiple CVEs
Upgrade to v0.62.0 of golang.org/x/net in order to address:

- CVE-2026-78661: html: avoid quadratic complexity when coalescing text nodes
- CVE-2026-97045: html: check all ancestor namespace transitions when rendering raw text
2026-10-28 13:13:07 +00:00
Nick Craig-Wood 545b142ad4 linkbox: document lower cased extensions and name(1) renames
Linkbox converts file extensions to lower case on upload, and renames an
uploaded file to name(1) if its name is the start of the name of a file
already in the directory, e.g. "apple" next to "applebanana". Document
both and ignore the TestTransform cases which they break.
2026-10-27 17:24:14 +00:00
Nick Craig-Wood f6d987d74b sftp: fix hashing files with a newline in their name on rsync.net
Since control characters in names are no longer encoded by default, a
file uploaded with a newline in its name keeps it on the server. Some
restricted shells, such as the one on rsync.net, end the command at a
newline even when it is quoted, so the hash command ran on a different
path and failed, or could have hashed a different file.

rclone now doesn't use a remote command to hash a file with a newline in
its name on a unix shell, and reports the hash as unavailable instead.
2026-10-27 17:24:14 +00:00
Nick Craig-Wood 4b80301485 test_all: ignore TestRWFileTruncateShrinkUncached on TestCloudinary
Cloudinary carries on returning the old size and contents for a while
after an object is overwritten.
2026-10-27 17:24:14 +00:00
Nick Craig-Wood 97649ffb24 test_all: ignore TestZipLargeFiles on TestKoofr
Koofr refuses to download the zip file the test makes with FileBlocked
"File download restricted due to possible dangerous content".
2026-10-27 17:24:14 +00:00
Nick Craig-Wood d6499a91a6 test_all: ignore TestMoveOntoItself on TestArchive
The archive backend returns the underlying objects, so a move onto the
same path isn't recognised as one and the file is copied onto itself.
2026-10-27 17:24:14 +00:00
Nick Craig-Wood 7d6140ff9a test_all: ignore bisync extended_filenames on TestS3GCS
GCS can't store CR or LF in object names and the S3 provider doesn't
encode them. These cases ran once the bisync pre-flight check was fixed
to stop skipping them.
2026-10-27 17:24:14 +00:00
Nick Craig-Wood a9f38b4a96 bisync: fix equal_skipped test on S3 providers which recreate buckets
Some S3 providers, such as Wasabi and Alibaba, report success when
creating a bucket which already exists, so rclone logs that the bucket
was created. The during-sync hooks in the equal_skipped test create a
new Fs, which logged this in the middle of the bisync run and made the
log differ from the golden one. Ignore these messages.
2026-10-27 17:24:14 +00:00
Nick Craig-Wood 611416b140 operations: fix TestMoveOntoItself on eventually consistent backends
The test looked up the object it had just uploaded without retrying, so
it failed with "object not found" on backends such as Cloudinary and
Internxt where new objects take a moment to appear.
2026-10-27 17:24:14 +00:00
Nick Craig-Wood a91ddbeddc jottacloud: fix server-side copy onto a trashed file keeping the old name case
Jottacloud is case insensitive, so a server-side copy to "File1" while
"file1" was in the trash restored the trashed file under its old name
"file1". The restored file is now renamed to the requested name.
2026-10-27 17:24:14 +00:00
Nick Craig-Wood b8d4ea09c4 jottacloud: fix server-side copy onto a trashed file failing as corrupted
When a server-side copy landed on a file that was in the trash, rclone
restored it with a zero creation and modification time unless
--metadata was in use. The server then returned a time which couldn't be
parsed, the copy was reported as having size 0 and failed with
"corrupted on transfer".

The restored file now gets the source's times, as it did before
metadata support was added.
2026-10-27 17:24:14 +00:00
Nick Craig-Wood 9034f1bfb4 Add 9 new contributors
- David Legrand
- Azhar
- Daniel Bae
- frodleTian
- Cestercian
- Ayush Sharma
- Dirk Petersen
- Codex
- bunnysayzz
2026-10-27 17:24:14 +00:00
Dirk Petersen a7759387d6 hash: add CRC-32C (Castagnoli) support
Add the hash type crc32c. It is the checksum that AWS S3 stores for
objects uploaded with x-amz-checksum-crc32c (FULL_OBJECT checksum),
and the checksum that iSCSI, ext4 and Btrfs use. Go calculates it with
the SSE 4.2 and ARMv8 CRC32 instructions where they are available.

With it, rclone can calculate the value that S3 returns in
ChecksumCRC32C for a local file:

    rclone hashsum crc32c file

prints it as hex; `--base64` prints it in the URL-safe base64 alphabet
that rclone uses for every hash (`-` and `_` where S3 prints `+` and
`/`).

The new type is registered after the existing core types, so their
Type values do not change. The types that backends register (dropbox,
hidrive, mailru, quickxor) move up one bit, as they did when blake3,
xxh3 and xxh128 were added; no Type value is persisted (the hasher
cache and bisync listings store hash names).
2026-10-27 14:40:02 +00:00
TastyHeadphones 1d11d94927 bisync: fix internal error when a conflict rename reuses deleted names - fixes #10165
If a file is changed on both sides and the conflict names it gets renamed
to (e.g. X.conflict1 and X.conflict2) were deleted on both sides since the
last run, the listing update removed the just-copied conflict files again
when processing the deleted-on-both queue. The rename bookkeeping then
could not find them and bisync aborted with "bisync.renames: internal
error: missing info".

Skip names that this run copied when removing files deleted on both sides,
and add a test case for the scenario.
2026-10-26 17:41:40 +00:00
nielash ea465cbb97 operations: skip the Unicode move test where NFD names aren't kept
Before this change, `TestMoveUnicodeEquivalentWithoutServerSideMove`
failed on backends that can't store a file under the NFD name it was given:
`mailru` rejects the name ("invalid characters in object name"), and
`cloudinary`, `filelu` and `seafile` don't find the file again under it.
Tests in `fs/operations` run against every backend, so this one needs to
skip where the precondition doesn't hold.

After this change, the test skips when the NFD name can't be uploaded or
the file isn't found again under it.
2026-10-26 17:41:06 +00:00
nielash 9f1d0cf021 operations: fix moving a file onto its own name deleting it
Before this change, `operations.Move` and `operations.MoveTransfer`
deleted a file when asked to move it onto its own path without a `dst`
object. Without server-side `Move`, `operations.move` copied the file onto
itself and then deleted the source, which was the same file, and the
`memory` backend's server-side `Move` did the same. `storj` stores every
name in NFC, so there a move from a file's NFD name to its NFC name is a
move onto its own path, and `TestMoveUnicodeEquivalentWithoutServerSideMove`
lost the file on `TestStorj`.

After this change, a move whose destination is the file's own path on the
same remote does nothing, including when the destination `Fs` has a
different root.
2026-10-26 17:41:06 +00:00
Ayush Sharma 2278207ab9 local: fix server-side copy on macOS when the destination exists - fixes #10117
Server-side copies with the local backend on macOS failed with `errno -1` when the destination file already existed, for example with `--inplace` or when used under chunker. This was because copyfile(3) with COPYFILE_CLONE implies COPYFILE_EXCL.

This clones to a temporary name in the same directory and renames it over the destination, which keeps APFS cloning for overwrites and leaves the destination untouched if the clone fails.
2026-10-26 17:39:57 +00:00
Cestercian c9f491800b pikpak: fix downloads that return an HTTP error page as the file - fixes #10187
PikPak's download node sometimes answers a ranged GET with 502 Bad
Gateway and a short HTML body. client.Do treats any completed response
as success, and shouldRetry returns immediately when err is nil, so
that HTML was saved as the file and rclone exited 0. With the VFS
cache those bytes were then served again.

Non-2xx download responses are now converted with the backend error
handler, which closes the body. Statuses in retryErrorCodes (429 and
5xx, including 502) are retried by the pacer. Any other status is
returned as an error. Successful 200 and 206 responses are unchanged.
2026-10-26 20:09:22 +09:00
kevin9327 81e7372f6f serve s3: report the object's modtime as Last-Modified after a PUT - fixes #10128
gofakes3 adds the time of the request as "Last-Modified" to the
metadata it passes to PutObject, CopyObject and multipart uploads.
storeMeta cached it with the rest, so HeadObject and GetObject
replaced the object's modification time with the upload time until the
cache entry was evicted, while ListObjects reported the right time.

storeMeta now drops "Last-Modified" before caching the metadata.
2026-10-20 11:17:18 +01:00
kevin9327 40139a749e fs: reject durations like 300y that overflow instead of wrapping
ParseDuration checked for overflow in multi-unit durations such as
300y1d but not in single-suffix ones, so --max-age 300y silently
became a large negative duration, which moved the filter's
ModTimeFrom into the future and excluded every file.
2026-10-20 10:49:46 +01:00
frodleTianandCodex 90cd86b47b docs: explain Windows path escaping in the rc API
Document forward slashes and escaped backslashes in JSON, including
object parameters sent through a query string or form data. Distinguish
these from ordinary string parameters that do not use JSON encoding.

Fixes #6673

Co-authored-by: Codex <noreply@openai.com>
2026-10-20 10:46:00 +01:00
nielash 57833ef11d bisync: stop tracking the generated VS Code debugger configs
Before this change, `cmd/bisync/testdata/bisync_vscode_debuggers_launch.json`
was in the repository, and every `-golden` run regenerated it from
`fstest/test_all/config.yaml`, adding unnecessary noise to commit diffs.

After this change, the file is in `.gitignore` and no longer tracked.
`-golden` still writes it locally, for copying debug configurations into
VS Code's `launch.json`.
2026-10-19 20:30:38 +01:00
nielash 1426f08cba bisync: replace the docs changelog with a link to its archived copy
Before this change, `docs/content/bisync.md` kept its own changelog, and
each bisync pull request added a line under the same heading. The rclone
changelog is already made from commit messages (`bin/make_changelog.py`),
so the entries duplicated it, and caused unnecessary merge conflicts: open
pull requests conflicted with each other whenever one of them merged.

After this change, the section links to the rclone changelog and to the
last version of the bisync changelog on GitHub. The `## Changelog` heading
stays, so links to `/bisync/#changelog` still work.
2026-10-19 20:30:38 +01:00
nielash 44f3ed8d41 build: mark bisync golden test files as generated
Before this change, GitHub showed the bisync golden test files
(`cmd/bisync/testdata/*/golden/`) in full in pull request diffs, where a
new test scenario can add hundreds of lines of expected output.

After this change, they are marked `linguist-generated`, like `MANUAL.*`
and `rclone.1`, so GitHub collapses them in diffs and leaves them out of
the language statistics. They are still one click away in a diff.
2026-10-19 20:30:38 +01:00
nielash 6cb5c0617b test_all: run the bisync integration tests on TestS3Rclone
Before this change, `cmd/bisync` was excluded on `TestS3Rclone`
(`rclone serve s3`), one of the backends `108504963` excluded because the
tests always failed there at the time. After this change, they run there
again: with `test_all` on Linux, every case passes or skips, with or
without the preceding changes to the test harness.
2026-10-19 20:29:13 +01:00
nielash 60f97ef633 bisync: fix integration tests passing raw control characters as names
Before this change, the `{eol}`, `{tab}` and `{chr:XX}` placeholders in a
scenario became raw bytes in the name the harness handed to `Put`. A raw
control character isn't a valid rclone name: rclone names are in the
`Standard` encoding, where a newline is `␊`, and `FsEncoding` in
`fstests` encodes every raw name before uploading it for the same
reason.

The `extended_filenames` scenario dates from 2021, when bisync was tested
on local, Drive, Dropbox, OneDrive, S3 and SFTP, but not on crypt or
memory. Its header said that all rclone backends, including local,
return `\n` as a glyph. That was an assumption, and it doesn't hold. A
backend with an encoder lists a raw control character as its glyph
because `ToStandardName` runs `Standard.Encode` on every name it
returns. A backend without one (crypt, memory, linkbox, netstorage,
pixeldrain, storj) lists exactly the name it was given. The same thing
happens with no bisync involved: `rclone touch` with a raw newline in
the name stores a raw newline on crypt, and `sync` from there to local
then copies and deletes the file on alternate runs.

The scenario has never passed on crypt or memory for the right reason.
Its golden listings were recorded on local with `␊`, so until January
2024 they would not have matched the raw names crypt lists, but the
scenario wasn't run there. `57624629d` ran it on crypt, treated the raw
names as an encoding difference, and normalized them away in both the
log and the listings, so from then on the goldens matched. `81d7b6f29`
added the check that each listing matches its path, which that
normalization doesn't cover, and the step has failed on those backends
since: local lists the copy as `␊` while the listing, built from the
source names, holds the raw newline. The pre-flight probe fixed in the
previous commit skipped the scenario on many nightly backends in the
meantime, which kept the failure out of the nightly results.

After this change, a placeholder for a control character (0x01-0x1f and
0x7f) becomes its `Standard` glyph, so every backend receives the same
name. On local the file on disk is unchanged, since the local backend
decodes the glyph to the raw character, and the golden logs are
unchanged. Placeholders for other bytes, such as the invalid UTF-8 in
`{chr:81}` and `{chr:fe}`, still become raw bytes, which `Standard`
leaves as they are.
2026-10-19 20:29:13 +01:00
nielash 7b63879af5 bisync: fix integration tests skipping the special-character cases
Before this change, the pre-flight check for the `normalization` and
`extended_filenames` cases uploaded its probe file to the path root, which
already held the scenario's files, and compared the first entry each path
listed. That entry was usually an unrelated file, so the result depended on
listing order rather than on how each backend stored the probe name. On 42
of 71 backends in the 2026-10-15 nightly, the paths appeared to differ and
the cases were skipped; elsewhere the check passed without examining the
probe at all. The check also named `extended_char_paths`, a case renamed to
`ext_paths` in 2024. The probe's uploads counted in the step's transfer
stats, which removed `There was nothing to transfer` lines from the logs of
the checked cases.

After this change, the probe goes in a fresh subdirectory with its own
stats group, and the check compares the name listed back from there. It
names only `normalization` and `extended_filenames`: `ext_paths` uses none
of the probe's invalid UTF-8 or emoji, and doesn't use `--fix-case`. The
`normalization` and `extended_filenames` golden logs gain the lines the
probe's stats had hidden.
2026-10-19 20:29:13 +01:00
nielash 0c9826d344 sftp: fix sync deleting files with control characters in their names
Before this change, the default SFTP encoding (`Slash,Del,Ctl,Dot`, the same
as rclone's standard form) passed names through unchanged, so a name on the
server containing a control character, such as a raw newline, reached rclone
in a non-standard form. Other backends, such as local on Linux, list the same
name in the standard form (`␊`), so the two never matched: syncing such a file
from SFTP to local deleted it on every second run, and bisync recorded
listings that didn't match the files.

After this change, the default encoding is `Slash,Dot`, the same as the local
backend's default on Linux, so control characters in names on the server are
converted to and from the standard form. Servers running on Windows can add the
Windows restricted characters with `--sftp-encoding`, as now documented.
The issue was discovered on the bisync integration tests.

This also fixes `mkParentDir`, which encoded the parent `.` of a top-level
file as a name. With the new default encoding, that created a stray `.`
directory on the server.
2026-10-18 18:17:39 +01:00
Daniel Bae ebfaae73c9 ftp: discard connections that fail with non-FTP errors - fixes #10102
A connection returned after an error without an FTP reply code can no
longer be trusted, so close it instead of returning it to the pool.
Errors carrying an FTP reply code still get a NOOP liveness probe.

Add a unit test driving putFtpConnection against a fake FTP server
covering network-error discard, live-connection pooling after an FTP
reply-code error, and discard when the NOOP probe fails.
2026-10-18 18:12:50 +01:00
Azharandbunnysayzz 0677b0ca1e serve restic: fix temporary errors confusing restic into leaving locks behind
deleteObject() replied 404 for any error from newObject(), while
serveObject() in the same file replies 404 only for
fs.ErrorObjectNotFound and 500 otherwise.

restic treats a 404 on DELETE as final and never retries, so a
transient lookup failure while removing a lock left the lock behind
and broke the next exclusive operation (eg forget --prune failing with
"repository is already locked").

This change mirrors serveObject() handling of 404/500 errors in
deleteObject().

Fixes #10100

Co-authored-by: bunnysayzz <bunnysayzz@users.noreply.github.com>
2026-10-18 17:39:27 +01:00
nielash 694dae2ed7 vfs: fix renamed items listed twice after a case or Unicode-only match
Before this change, renaming a file or directory through a name that
matched it only by ignoring case (`--vfs-case-insensitive`, the default on
macOS and Windows) or Unicode normalization left its old entry in the
parent directory. The item was listed twice, once under its new name and
once under the old key, and the old name still resolved to it until the
directory cache expired. `Dir.Rename` removed the parent's entry under the
name it was given rather than the item's real name. For a directory
renamed within its own parent, this was masked until `36ce1a28f`, which
removed a second deletion in `Dir.rename`.

After this change, `Dir.Rename` removes the entry under the item's real
name. The issue was discovered on the bisync integration tests.
2026-10-18 17:26:27 +01:00
nielash a22df73dec operations: fix case- or Unicode-only renames deleting the file
Before this change, on backends without server-side `Move`, renaming a
file to an equivalent name deleted it: a name differing only in case on
a case-insensitive backend, or only in Unicode normalization on a
backend such as Cloudflare R2, which treats NFC and NFD names as
equivalent. `operations.move` fell back to copying the file to the new
name and then deleting the source, but both resolved to the same object,
so the delete removed the file it just copied. This affected
`--fix-case` renames in `sync` and `bisync`, `--track-renames` with
`--no-unicode-normalization`, and renames through the VFS
(`rclone mount`, `rclone serve`). `MoveFile` and backends with
server-side `Move` already handled these renames by moving through a
temporary name.

After this change, the copy-and-delete fallback also moves through a
temporary name when the two names are equivalent. The issue was discovered on
the bisync integration tests.
2026-10-18 17:24:30 +01:00
David Legrand ea588aac1e s3: add Clever Cloud Cellar provider
Cellar is the S3-compatible object storage service of Clever Cloud,
hosted in France.

rclone's defaults work for it: virtual-host style addressing, v2
listings, URL-encoded listings and BucketAlreadyExists behave like AWS.

Cellar does not apply Object Lock headers sent with
CreateMultipartUpload, so object_lock_supported is set to false and the
Object Lock integration tests are skipped. The docs tell users to set
--s3-object-lock-set-after-upload, which applies the lock on multipart
uploads with separate API calls.

The SSE-C options are exposed since Cellar supports customer-provided
keys. The docs note that Cellar rejects CopyObject with an SSE-C source,
so --no-update-modtime is needed and encrypted objects smaller than
--s3-copy-cutoff can't be moved server-side.

The integration tests pass against cellar-c2.services.clever-cloud.com.
2026-10-18 17:22:37 +01:00
Giridharandp1 f43a2146d2 http: add metadata defaults for --http-no-head - fixes #7988
Signed-off-by: Giridhar <80974392+giri256@users.noreply.github.com>
Co-authored-by: p1 <giridharpavan593@gmail.com>
2026-10-18 17:18:35 +01:00
Nick Craig-Wood 35a03a47e0 box: update github.com/golang-jwt/jwt from v4 to v5
jwt v5 removes StandardClaims so use RegisteredClaims instead. v5
sends a single audience as an array of strings, so the audience is
overridden in boxCustomClaims to keep sending it as a string as
before.

This adds a test which checks the JWT assertion sent to the token
endpoint is identical to the one sent with jwt v4.
2026-10-17 17:40:25 +01:00
Nick Craig-Wood 4b0029e1e7 box: fix ~ and environment variables not being expanded in box_config_file
This was broken in v1.72.0 when the config_credentials option was
added.
2026-10-17 17:40:25 +01:00
Nick Craig-Wood 2a49dd9bf5 ncdu: update to tcell v3 which fixes duplicated keys on Windows
The tcell v2 pin at v2.9.0 is removed as tcell v3 contains the fix for
duplicated key events on Windows (tcell issue 1124).

Note that tcell v3 uses the Windows VT console modes so ncdu requires
Windows 10 build 1703 or later.

See: #9539
2026-10-17 17:39:51 +01:00
Nick Craig-Wood 7436d05e36 list: fix ext sort error sometimes being ignored at the end of a listing
Sorter.Send selects on both the output and error channels of the ext
sort. If the ext sort failed it closes both, so the select could pick
the closed output channel and return without the error. This was seen
as a flaky TestSorterExtTempFileError under the race detector.
2026-10-17 15:48:14 +01:00
Nick Craig-Wood 3ae50732b5 list: fix TestSorterExtTempFileError with lanrat/extsort v1.5.0
extsort now only creates temp files when the data doesn't fit in
one chunk, so the test needs to add more than a chunk of entries to
provoke the temp file error.
2026-10-17 15:48:14 +01:00
Nick Craig-Wood 5fe99911ca serve: fix errcheck lint errors from golangci-lint v2.15.0
errcheck now flags ignoring an error received from a channel, and
staticcheck rejects assigning it to _, so use nolint instead.
2026-10-17 15:48:14 +01:00
Nick Craig-Wood 742e9d8d47 docs: update RELEASE.md dependency pins 2026-10-17 15:48:14 +01:00
Nick Craig-Wood 8a91f15bb7 build: update all dependencies
This fixes the code to compile with the updated dependencies:

- dlna: pass a *slog.Logger to the ssdp server as required by
  anacrolix/dms v1.8.0

github.com/Files-com/files-sdk-go/v3 was not updated as the latest
version requires go 1.26.9.

The tcell and go-systemd pins are kept and github.com/hanwen/go-fuse/v2
is pinned to v2.11.0 as v2.12.0 fails to compile on 32 bit linux - see
https://github.com/hanwen/go-fuse/issues/628
2026-10-17 15:48:14 +01:00
Nick Craig-Wood 45a9851f66 Add 23 new contributors
- kevin9327
- drakeo338
- Alessio Margelli
- Ludovic Bondon
- fmterrors
- GingerAdonis
- Satvik Mishra
- wjc
- Maksym Tymoshyk
- Aniket
- Oshino Penguin
- Yousef Issa
- ghwany
- DEVN
- 47th
- Sash
- Shreyansh Jain
- Biagio Peccerillo
- gomitrah
- Jim Counter
- Dirk Einecke
- ASRodrigo1
- chaogeshuodiannao
2026-10-17 15:48:14 +01:00
Dirk Einecke 4c52ae0575 sftp: fix hash check for names starting with "-" with relative --sftp-path-override
With a relative path override such as `--sftp-path-override @.` (useful
on chrooted hosting where the SSH shell starts in the directory that
SFTP shows as "/"), a file in the root gets the shell path "-name".
`quoteOrEscapeShellPath` does not escape "-", so the hash command
became `md5sum -name`, which md5sum parses as an option. The hash then
failed and the transfer was reported as "corrupted on transfer".

Absolute shell paths always start with "/", so only relative overrides
were affected. Prefix such paths with "./".
2026-10-16 17:43:49 +01:00
Dirk Petersen 7181430fa1 sftp: fix data race when using --sftp-ssh
When the sftp backend uses an external ssh program (--sftp-ssh), one
goroutine waits for the ssh program to finish by calling cmd.Wait().
cmd.Wait() sets cmd.ProcessState when the program ends.

At the same time, CanReuse() calls exited(), which reads
cmd.ProcessState without any lock. This is a data race and the Go
race detector reports it. It happens in normal use, for example when
a connection is put back in the pool after a stat.

The race only decides whether a pooled connection can be reused, so
it is unlikely to cause problems in practice, but it makes programs
that use the sftp backend fail when they are tested with -race.

This fix sets an atomic.Bool after cmd.Wait() returns and makes
exited() read that instead of cmd.ProcessState.

It also adds a test which calls exited() while Wait() is running. It
fails with -race before this fix and passes after it.
2026-10-16 17:41:03 +01:00
Jim Counter 5c91f87398 s3: add Autonomys Auto Drive provider
Auto Drive is permanent storage on the Autonomys Network, with an S3
compatible API at s3.auto-drive.autonomys.xyz.

Storage is permanent: a delete hides an object, and every version is
kept. The provider sets:

- force_path_style, as the API uses path-style requests.
- copy_cutoff to the maximum. UploadPartCopy is not supported, so
  server-side copies always use a single CopyObject.
- object_lock_supported false. Object Lock is always on in COMPLIANCE
  mode and can't be set by clients, so rclone's Object Lock tests
  can't pass.
- No Purge. Deleting a version is always refused, so the S3 Purge,
  which deletes every version, fails. rclone's generic purge hides
  the current version of each object instead.
- no_check_bucket. There are no bucket endpoints: buckets are created
  by the first upload into them, and a CreateBucket request would be
  stored as an object.
2026-10-16 17:38:56 +01:00
nielash 99f904d711 bisync: fix Dropbox rate limit notices failing the integration tests
Before this change, the integration tests dropped Dropbox's "Too many
requests or write operations" notice from the logs only in the format it
had when the tests were written, which began with the error code
(`too_many_requests/...`). Since 110bf468a (v1.65), the notice begins with
`Error `, so it was left in the log and caused a miscompare whenever
Dropbox rate limited a run.

After this change, the notice is dropped in either format.
2026-10-16 11:33:00 -04:00
nielash 51102afe08 bisync: speed up integration tests on non-local remotes
Before this change, the integration tests checked whether each remote could
set modtimes on every `bisync` step (`checkPreReqs`, called from
`runBisync`), not once per remote. On a remote that isn't `IsLocal`, each
check sleeps for 1s twice (the Google Cloud Storage per-object rate limit
workaround from be73a10a9), for each side. That added 2-4s to each of the
~110 `bisync` steps in each test, which on a fast remote was most of the
run: on the integration test server, the `:memory:` tests took about 14
minutes.

After this change, the result of the check is remembered for each remote,
so it runs, and sleeps, once per remote per test run. The `:memory:` tests
now take about 7s locally.
2026-10-16 11:33:00 -04:00
gomitrah d369079a14 docs: document that remote names are not converted to valid env var names - fixes #8668
When a remote is configured with RCLONE_CONFIG_<NAME>_<OPTION> the remote
name is only converted to uppercase, so characters such as "-" and "." are
not changed to "_" and may not be settable in some shells.

Explain this in the environment variables section and link to it from
"Valid remote names".
2026-10-15 17:41:49 +01:00
Biagio Peccerillo dcf3483513 vfs: fix virtual entries leak in removed directory
Deleting the contents of a directory and then the directory itself,
before a listing has confirmed the deletes, left the directory's
virtual entries behind. They are counted in every parent directory, and
a directory which counts virtual entries refuses to forget its cache, so
vfs/forget could not make changes made outside rclone visible.

This lasted until the removed directory's own cleanup timer purged its
entries, which happens 2 x --dir-cache-time later.

This fix drops the removed directory's virtual entries once it has been
removed.

Fixes #10083
2026-10-15 17:39:54 +01:00
Shreyansh Jain 36ce1a28fe vfs: fix crash renaming a directory while the dir cache expires - fixes #10071 2026-10-15 17:31:28 +01:00
Sash c82efbee5b drive: stop a fast-list error from hanging the sync
When a Drive listing error closed the ListR input while directories
were still in the overflow queue, the overflow goroutine sent on that
nil channel while holding the mutex. The main loop then blocked on the
same mutex, so the sync never returned the error.

refillListRInput now stops when the input is already closed, and it
does not add those jobs to the wait group.

Fixes #10069
2026-10-15 16:40:06 +01:00
47th 78be7079b9 vfs: preserve shrinking truncate on uncached files
If a file that hadn't been read was shrunk with Truncate, the cache
file was cut to the new size but the kept part of the file still had
to be fetched on close. The downloader wrote the whole remote object
into the cache file, which grew it back to its old size, so the upload
replaced the object with the original contents.

Stop the downloader writing past the end of the item, and add tests at
the vfscache and vfs levels.

Fixes #10047
2026-10-15 16:36:28 +01:00
Cao Yuhang 36febacabd operations: respect backup-dir in deletefile 2026-10-15 16:31:37 +01:00
DEVN f2bb215ce5 serve s3: upgrade gofakes3 to v0.0.11 to fix IsTruncated and Docker Distribution Registry
gofakes3 prior to v0.0.11 defined the IsTruncated XML field in
ListMultipartUploadsResult and ListMultipartUploadPartsResult with
`xml:"IsTruncated,omitempty"`. When an upload result was not truncated
(IsTruncated == false), the <IsTruncated> tag was omitted from the XML
response.

According to the AWS S3 REST API specifications for ListParts and
ListMultipartUploads, IsTruncated is a required boolean element. Strict
S3 clients such as the official AWS Go SDK (used by Docker Distribution
and OCI registries) unmarshal this field as a *bool pointer. Because the
tag was omitted, the pointer remained nil, causing Docker Distribution
to panic with a nil-pointer dereference at s3.go:593 (for *resp.IsTruncated)
during multi-chunk blob uploads.

Updating github.com/rclone/gofakes3 to v0.0.11 brings in rclone/gofakes3#22,
which removes omitempty and ensures <IsTruncated>false</IsTruncated> is
always present in the response XML.

Fixes #10078
2026-10-15 10:10:47 +01:00
nielash be46eaa1f0 bisync: test that every flag can be set through the rc
Before this change, nothing checked that the rc `sync/bisync` call accepted
every bisync flag, so the rc could fall behind the command line as flags
were added, as in #7799.

After this change, `TestRcFlagParity` passes each non-hidden bisync flag to
the rc, under the parameter name `rc.md` uses, with a value of the wrong
type. Every getter `rcBisync` uses rejects it, so the call fails only if
the parameter is read, and the test names any flag the rc doesn't handle.
2026-10-14 15:42:18 -04:00
nielash 0891ce3060 bisync: fix rc ignoring legacy backupdir1/2 and invalid values
Before this change, since v1.74.0 (bb78eb8ab), the rc `sync/bisync` call
read the `backupdir1` and `backupdir2` parameters (the names used before
they were renamed to `backupDir1` and `backupDir2`) only when the new name
was also given with an invalid value, because the fallback sat inside an
`rc.NotErrParamNotFound(err)` check, which is true only for an invalid
value. Otherwise, `backupdir1` and `backupdir2` had no effect.

The same change made an invalid value for any optional parameter (for
example `"resync": "yes"`, or a `filtersFile` that isn't a string) be
treated as if the parameter were missing, instead of returning an error.

After this change, the old backup dir names are read when the new ones are
missing, and an invalid value returns an error again, as it did before
v1.74.0.
2026-10-14 15:42:18 -04:00
nielash bedfe4a483 bisync: reject --max-delete above 100
Before this change, bisync treated a `--max-delete` above 100 as 100%, so
the `--max-delete` check never triggered. A run that left no file on a side
unchanged was still stopped, by the "all files were changed" or empty
listing checks, but one that deleted most files while leaving even one
unchanged was not.

For bisync, `--max-delete` has always been a
percentage, "in range 0..100" as Ivan put it when he designed it
(https://github.com/rclone/rclone/pull/5164#discussion_r633470815), but the
range was never enforced. So a value meant as a file count, such as
`--max-delete 500`, turned the check off. Now that the rc takes
`--max-delete` from the global config too, that includes a `--max-delete`
the rcd itself was started with.

After this change, a `--max-delete` above 100 is an error, as the rc's
`maxDelete` parameter already was.
2026-10-14 15:42:18 -04:00
nielash 914fa87933 bisync: fix rc ignoring global --dry-run and --max-delete settings
Before this change, `--max-delete` and `--dry-run` were read from the
global config by `applyContext`, which only the command line called. The
rc `sync/bisync` call started from empty `Options` and never ran it, so it
ignored both settings:

- A dry run requested through the global option (`_config`
  `{"DryRun": true}` or the flat `dry_run` parameter) was ignored.
  `setDryRun` resets `ci.DryRun` from `opt.DryRun` before copying, and
  `opt.DryRun` was only set by bisync's own `dryRun` parameter.
- When the `maxDelete` parameter was omitted, the `--max-delete` limit was
  0%. A `max_delete` set through `_config` or the flat parameter was not
  used for that limit, and applied to the inner syncs as a file count
  instead.

After this change, `Bisync` runs `applyContext` itself, on its own copy of
the config, so the command line, the rc, and any other caller follow the
same rules, and `Options.MaxDelete` and `Options.DryRun` are set by
`Bisync`. With no `--max-delete` anywhere, the limit is 50%, as #5680 says
the rc should.

The rc's `maxDelete` and `dryRun` parameters now set the call's config.
`maxDelete` overrides the global `--max-delete`. `dryRun: true` asks for a
dry run, but `dryRun: false` doesn't cancel one from the global config, so
the run is a dry run if either asks for one. The `maxDelete` parameter is
also documented again; it was left out when the rc help became generated
from the command's flags.
2026-10-14 15:42:18 -04:00
nielash 17df19341a bisync: fix listings ignoring a modtime less than 1s older
Before this change, when bisync updated a listing entry, it kept the old
modtime if the new one was less than a second earlier. This dated back to
when bisync truncated modtimes to the destination's precision before
recording them (`ConvertPrecision`), so that a truncated time would not
overwrite the more precise original.

`ConvertPrecision` was removed in 956c2963f, and since then, bisync has kept
the original precision and relied on the modify window when comparing. But
the rule in `ls.put` was left behind, and it then fired only when a file
really had an earlier modtime, recording a time the file did not have. This
could cause:

- a `--resync` to abort with "path1 and path2 are out of sync" when Path1's
  version was slightly older than Path2's
- later runs to report changes that didn't happen, for a file changed to a
  version less than a second older, or copied from a remote with lower
  modtime precision within the same second as the old version (for example,
  from SFTP to local)

After this change, bisync records the modtime it is given, like any other
update. Comparisons already account for differences in precision through
the modify window.
2026-10-14 15:06:02 -04:00
ghwany cf7cdbc49f sync: fix --delete-empty-src-dirs disabled by a prior error in a reused stats group - fixes #9634
deleteEmptyDirectories gated on the cumulative accounting stats
(accounting.Stats(ctx).Errored()) instead of the current operation's error.

On a long-running rcd that reuses one stats group (rc "_group") across many
sync/move calls, a single earlier error left Errored() true forever, which
permanently disabled empty-source-directory deletion for every later,
error-free operation sharing the group - each run just logged "not deleting
directories as there were IO errors" and never cleaned up.

Gate on s.currentError() instead, so empty-directory cleanup depends only on
the current operation's outcome. This matches the per-operation guards already
used elsewhere in run(), and is a no-op for normal single runs where
Errored() (s.errors != 0) tracks the same errors that reach s.currentError().
2026-10-14 17:52:31 +01:00
Yousef Issa 5f0e72c03f rest: reject negative sizes in HTTP response headers - fixes #10041 2026-10-14 17:07:21 +01:00
Oshino Penguin e18da08372 archive: fix reading an archive in the root of a remote
Opening an archive in the root of a remote, such as remote:file.zip,
failed with "file does not exist" because the archive's leaf name was
found with path.Base, which doesn't understand remote names. It is now
found with fspath.Split.
2026-10-14 12:36:16 +01:00
Aniket 6aed73d2d3 hasher: keep cached hash when only the modtime changes - fixes #6176
Signed-off-by: Aniket <aniketkomailkro@gmail.com>
2026-10-14 12:12:01 +01:00
Maksym Tymoshyk ae39ec6f64 drime: fix endless listing of directories with more than 20,000 entries
Drime won't list more than 20,000 entries of a directory. Asking for a
later page gets the last page it allows again, so rclone asked for the
same page forever and used more and more memory until it was killed.

Return an error instead. An error stops sync deleting files based on
the listing and uploading duplicates of files it couldn't see.

Also return listing errors from getItem rather than "object not found".
2026-10-14 12:07:51 +01:00
kaii9 af364f5468 filter: speed up large --include-from lists of directories - fixes #9014 2026-10-14 12:05:05 +01:00
nielashandASRodrigo1 ff0704b7e2 bisync: add tests for skipping the check for identical files
- TestCheckConflictsSkipsCheck: the check for identical files is skipped
  with --size-only, and with --ignore-checksum only when the paths have no
  hash in common.
- test_equal_skipped: an identical file that is new on both paths with
  --ignore-checksum is recorded, so deleting it on Path1 deletes it on
  Path2. Adds the "ignore-checksum" scenario argument.
- test_conflict_window: Path1 changes between the listing snapshot and
  the check for identical files.

Co-Authored-By: ASRodrigo1 <45823308+asrodrigo1@users.noreply.github.com>
2026-10-14 12:00:45 +01:00
nielashandASRodrigo1 c6749ff70e bisync: fall back to --download when a checksum is missing
Before renaming a sync conflict, bisync checks whether the two files are
identical. It compares hashes using check or cryptcheck, and as a last
resort, when neither is available, it compares the files by downloading
them (--download), so that the result is certain regardless of checksum
support.

Before this change, the fallback to --download happened only when bisync
knew up front that the remotes had no usable hash. It did not happen
when a hash that was expected to be present turned out to be missing
(for example, a backend that normally supports hashes returned a blank
one, or cryptcheck could not get the underlying hash). Those files were
counted as identical based on size alone.

After this change, a missing hash falls back to --download, the same as
when the remotes have no hash in common. --size-only and
--ignore-checksum still disable the fallback and keep the size-only
comparison.

Co-Authored-By: ASRodrigo1 <45823308+asrodrigo1@users.noreply.github.com>
2026-10-14 12:00:45 +01:00
nielashandASRodrigo1 e6a2ddb2f8 bisync: fix endless retry loop for skipped files - fixes #9939
Before this change, when a file was new or changed on both paths and the pre-sync
conflict check deemed the two copies identical, bisync skipped the copy and then
rechecked the file after the sync to get its listing info. If the recheck could
not confirm equality (for example, hashes unavailable due to --size-only or
(--ignore-checksum + no common hash)), the file was rolled back to be retried on
the next run, and every later run repeated the same check with the same result.
A later change on one side was then handled against a missing or stale listing
entry.

After this change, files we've declared identical pre-sync are no longer
rechecked post-sync. The pre-sync conflict check now also returns the values it
compared, and a skipped file is recorded from the listing snapshot. If either
path changed between the snapshot and the check, the prior listing is kept and
the next run rechecks.

When --size-only or (--ignore-checksum + no common hash) are in use, the "new or
changed on both paths and equal" case skips the conflict check entirely, because
it is unnecessary. The check could only compare sizes, which we already know are
equal from the snapshot.

The new `test_equal_skipped`, `test_conflict_window`, and
`test_conflict_window_size` scenarios cover identical files that change on
one path during the sync, files that change between the listing snapshot and
the check for identical files, and `--size-only` files with the same size but
different content.

Co-Authored-By: ASRodrigo1 <45823308+asrodrigo1@users.noreply.github.com>
2026-10-14 12:00:45 +01:00
nielash 85c9c46e1e bisync: add test hooks for changes during a run
Adds a test-only TestFnConflictCheck option, called before checking
potential conflicts, and a "hook <when> <step>" scenario command that runs
an ordinary scenario step once when the next bisync reaches that point:
before-check (TestFnConflictCheck) or during-sync (TestFn). A hook that
never runs is noted in the log so a scenario can't silently skip its setup.
2026-10-14 12:00:45 +01:00
Nick Craig-Wood a96a8b7757 rc: fix LogLevel in _config being ignored
Before this change setting LogLevel in the _config parameter of an rc
call had no effect as both the log functions and the log handler only
looked at the global log level.

The context aware log functions now use the log level from the config
in the context, and the log handler lets a log level in the context
which differs from the global one override its own level. This means
an rc call can be made more or less verbose than the rest of rclone
with, for example

    "_config": {"LogLevel": "DEBUG"}

This applies to the logs made with the context aware log functions, so
not to those made by the backends which still use the global log level.

Note that a config copied into a context no longer follows later
changes to the global log level.
2026-10-14 09:38:25 +01:00
Nick Craig-Wood 896ea32789 operations, sync, march, walk, list, filter: attribute logs to the rc job
Use the context aware log functions throughout the code which runs
sync, copy, move, check and the other operations, and the directory
listings they do, so that the logs they make, such as the per file
"Copied (new)" logs, are attributed to the rc job and stats group of
the operation.
2026-10-14 09:38:25 +01:00
Nick Craig-Wood 8c5a60f31d fs: add context aware log functions so logs can be attributed
This adds ErrorfCtx, LogfCtx, InfofCtx, DebugfCtx and LogPrintfCtx
which take a context and pass it on to the log handler. The handler
can then attribute the log to what made it using the new
StatsGroupFromContext and JobIDFromContext function pointers, which
fs/accounting and fs/rc/jobs fill in.

The format string is no longer assigned to in logPrintf so that go
vet recognises these as printf wrappers and checks the arguments of
their callers.
2026-10-14 09:38:25 +01:00
Nick Craig-WoodandEd Craig-Wood 18262df465 docs: add section on performance
Co-authored-by: Ed Craig-Wood <138211970+edc-w@users.noreply.github.com>
2026-10-14 09:36:13 +01:00
551 changed files with 84640 additions and 67615 deletions

No files matched your search

+25
View File
@@ -0,0 +1,25 @@
{
"permissions": {
"allow": [
"Bash(git status:*)", "Bash(git diff:*)", "Bash(git log:*)", "Bash(git show:*)",
"Bash(git rev-parse:*)", "Bash(git add:*)", "Bash(git commit:*)",
"Bash(go mod download:*)", "Bash(go build:*)", "Bash(go test:*)", "Bash(go vet:*)",
"Bash(gofmt -l:*)",
"Bash(golangci-lint run:*)"
],
"ask": [
"Bash(git push:*)", "Bash(git merge:*)", "Bash(git rebase:*)", "Bash(git switch:*)",
"Bash(git restore:*)", "Bash(git commit --amend:*)", "Bash(rm:*)",
"Bash(go get:*)", "Bash(go mod tidy:*)", "Bash(go install:*)",
"Bash(make:*)", "Bash(docker:*)"
],
"deny": [
"Bash(git push --force:*)", "Bash(git push -f:*)", "Bash(git push --force-with-lease:*)",
"Bash(git reset --hard:*)", "Bash(git clean:*)", "Bash(git add -f:*)", "Bash(git add --force:*)",
"Bash(git commit --no-verify:*)", "Bash(git commit -n:*)",
"Bash(rm -r:*)", "Bash(rm -R:*)", "Bash(rm -rf:*)", "Bash(rm -fr:*)", "Bash(rm -Rf:*)",
"Bash(rm -fR:*)", "Bash(rm --recursive:*)", "Bash(sudo:*)",
"Read(./.env)", "Read(./.env.*)", "Read(./secrets/**)"
]
}
}
+2
View File
@@ -0,0 +1,2 @@
sandbox_mode = "workspace-write"
approval_policy = "on-request"
+20
View File
@@ -0,0 +1,20 @@
# Rendered from docs/PERMISSIONS.md - change both in the same commit.
# Deny
prefix_rule(pattern = ["git", "push", ["--force", "-f", "--force-with-lease"]], decision = "forbidden",
justification = "Force-push rewrites shared history.")
prefix_rule(pattern = ["git", "reset", "--hard"], decision = "forbidden", justification = "Discards work.")
prefix_rule(pattern = ["git", "clean"], decision = "forbidden", justification = "Deletes untracked files.")
prefix_rule(pattern = ["git", "add", ["-f", "--force"]], decision = "forbidden",
justification = "Stages ignored files, e.g. secrets.")
prefix_rule(pattern = ["git", "commit", ["--no-verify", "-n"]], decision = "forbidden", justification = "Skips hooks.")
prefix_rule(pattern = ["rm", ["-r", "-R", "-rf", "-fr", "-Rf", "-fR", "--recursive"]], decision = "forbidden",
justification = "Recursive delete.")
prefix_rule(pattern = ["sudo"], decision = "forbidden", justification = "No privilege escalation.")
# Ask
prefix_rule(pattern = ["git", ["push", "merge", "rebase", "switch", "restore"]], decision = "prompt")
prefix_rule(pattern = ["git", "commit", "--amend"], decision = "prompt")
prefix_rule(pattern = ["rm"], decision = "prompt")
prefix_rule(pattern = ["go", ["get", "install"]], decision = "prompt")
prefix_rule(pattern = ["go", "mod", "tidy"], decision = "prompt")
prefix_rule(pattern = ["make"], decision = "prompt")
prefix_rule(pattern = ["docker"], decision = "prompt")
+20
View File
@@ -0,0 +1,20 @@
{
"tools": {
"allowed": [
"run_shell_command(git status)", "run_shell_command(git diff)", "run_shell_command(git log)",
"run_shell_command(git show)", "run_shell_command(git branch --list)",
"run_shell_command(git add)", "run_shell_command(git commit)",
"run_shell_command(go mod download)", "run_shell_command(go build)",
"run_shell_command(go test)", "run_shell_command(go vet)", "run_shell_command(gofmt -l)",
"run_shell_command(golangci-lint run)"
],
"exclude": [
"run_shell_command(git push --force)", "run_shell_command(git push -f)",
"run_shell_command(git push --force-with-lease)", "run_shell_command(git reset --hard)",
"run_shell_command(git clean)", "run_shell_command(git add -f)", "run_shell_command(git add --force)",
"run_shell_command(git commit --no-verify)", "run_shell_command(git commit -n)",
"run_shell_command(rm -r)", "run_shell_command(rm -R)", "run_shell_command(rm -rf)",
"run_shell_command(rm -fr)", "run_shell_command(rm -Rf)", "run_shell_command(sudo)"
]
}
}
+1
View File
@@ -8,6 +8,7 @@ lib/http/templates/** text eol=lf
# Ignore generated files in GitHub language statistics and diffs
/MANUAL.* linguist-generated=true
/rclone.1 linguist-generated=true
cmd/bisync/testdata/*/golden/** linguist-generated=true
# Don't fiddle with the line endings of test data
**/testdata/** -text
+56
View File
@@ -0,0 +1,56 @@
# Fork CI for the Proton Drive backend. Every step is hermetic: the
# tests run against the fake server in backend/protondrive/protontest.
name: ci
on:
push:
pull_request:
env:
# Never pick up a config holding a real Proton remote.
RCLONE_CONFIG: /notfound
jobs:
protondrive:
runs-on: ubuntu-latest
# The race suite takes about 6 minutes on a fast machine and 7 on
# two cores; the slow runner may take several times that.
timeout-minutes: 60
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install Go
uses: actions/setup-go@v5
with:
# The line the code was verified with, not go.mod's minimum,
# so govulncheck sees a patched standard library.
go-version: '1.27.x'
# Caches the module and build caches, keyed on go.sum.
cache: true
- name: Build
run: go build ./...
- name: Vet
run: go vet ./backend/protondrive/...
- name: Lint
uses: golangci/golangci-lint-action@v8
with:
version: v2.15.0
args: ./backend/protondrive/...
- name: Test
# Go's default 10 minute limit per test binary is too short
# for the race suite on a slow runner.
run: go test -race -timeout 40m ./backend/protondrive/...
- name: Fuzz
run: |
for target in $(go test -list '^Fuzz' ./backend/protondrive/ | grep '^Fuzz'); do
go test -run '^$' -fuzz "^${target}\$" -fuzztime 20s ./backend/protondrive/
done
- name: Vulnerability check
run: go run golang.org/x/vuln/cmd/govulncheck@v1.9.0 ./backend/protondrive/...
+15 -12
View File
@@ -37,7 +37,7 @@ jobs:
include:
- job_name: linux
os: ubuntu-latest
go: '~1.27.2'
go: '~1.27.3'
gotags: cmount
build_flags: '-include "^linux/"'
check: true
@@ -48,14 +48,14 @@ jobs:
- job_name: linux_386
os: ubuntu-latest
go: '~1.27.2'
go: '~1.27.3'
goarch: 386
gotags: cmount
quicktest: true
- job_name: mac_amd64
os: macos-latest
go: '~1.27.2'
go: '~1.27.3'
gotags: 'cmount'
build_flags: '-include "^darwin/amd64" -cgo'
quicktest: true
@@ -64,14 +64,14 @@ jobs:
- job_name: mac_arm64
os: macos-latest
go: '~1.27.2'
go: '~1.27.3'
gotags: 'cmount'
build_flags: '-include "^darwin/arm64" -cgo -macos-arch arm64 -cgo-cflags=-I/usr/local/include -cgo-ldflags=-L/usr/local/lib'
deploy: true
- job_name: windows
os: windows-latest
go: '~1.27.2'
go: '~1.27.3'
gotags: cmount
cgo: '0'
build_flags: '-include "^windows/"'
@@ -81,14 +81,14 @@ jobs:
- job_name: other_os
os: ubuntu-latest
go: '~1.27.2'
go: '~1.27.3'
build_flags: '-exclude "^(windows/|darwin/|linux/)"'
compile_all: true
deploy: true
- job_name: go1.26
os: ubuntu-latest
go: '~1.26.9'
go: '~1.26.10'
quicktest: true
racequicktest: true
@@ -177,9 +177,12 @@ jobs:
# https://github.com/Homebrew/brew/issues/15621#issuecomment-1619266788
# https://github.com/orgs/Homebrew/discussions/4612#discussioncomment-6319008
unset HOMEBREW_NO_INSTALL_FROM_API
brew untap --force homebrew/core
brew untap --force homebrew/cask
brew untap --force aws/tap
# brew untap fails if the tap isn't installed, which varies between runner images
for tap in homebrew/core homebrew/cask aws/tap; do
if brew tap | grep -qx "$tap"; then
brew untap --force "$tap"
fi
done
brew update
brew install --cask macfuse
brew install git-annex git-annex-remote-rclone
@@ -284,7 +287,7 @@ jobs:
id: setup-go
uses: actions/setup-go@v7
with:
go-version: '~1.27.2'
go-version: '~1.27.3'
check-latest: true
cache: false
@@ -399,7 +402,7 @@ jobs:
id: setup-go
uses: actions/setup-go@v7
with:
go-version: '~1.27.2'
go-version: '~1.27.3'
# Caching is handled explicitly below to share the module cache
# with the other jobs - see the build job for the rationale.
cache: false
+8
View File
@@ -19,4 +19,12 @@ Thumbs.db
__pycache__
.DS_Store
resource_windows_*.syso
/librclone.h
.devcontainer
/cmd/bisync/testdata/bisync_vscode_debuggers_launch.json
.env
.env.*
CLAUDE.local.md
.claude/settings.local.json
.worktrees/
.superpowers/
+13
View File
@@ -134,3 +134,16 @@ Uses golangci-lint v2 with config in `.golangci.yml`. Enabled linters: errcheck,
- Command docs are in the command source code (e.g., `cmd/ls/ls.go`).
- Don't commit autogenerated doc changes from `make backenddocs` or `make commanddocs`.
- Website docs are in `docs/content/` as markdown, built with Hugo (`make serve` to preview).
## Fork Workflow
This fork carries agent tooling that upstream does not. Never include `docs/specs/`, `docs/plans/`, `docs/{ARCHITECTURE,DECISIONS,TODO,PERMISSIONS}.md`, `GEMINI.md`, `.claude/`, `.codex/`, `.gemini/` or this section in a PR to rclone/rclone.
- Required skill: `superpowers` - check at session start, install if missing. There is no product UI here, so `impeccable` is not needed.
- Specs go in `docs/specs/NNN-slug.md` (permanent). Plans go in `docs/plans/NNN-slug.md` and are removed with `git rm` before merge. This overrides the superpowers default paths.
- Paused or in-flight work is listed under "Now" in `docs/TODO.md` with the handoff to read first.
- Non-trivial work: brainstorm → spec → plan → TDD → review. For trivial fixes say "trivial, skipping spec" and proceed.
- Log things noticed but deliberately not fixed in `docs/TODO.md`, record decisions in `docs/DECISIONS.md`, and keep `docs/ARCHITECTURE.md` in step with structural changes - in the same commit as the code.
- `docs/PERMISSIONS.md` is the source for the `.claude/`, `.codex/` and `.gemini/` permission files - change them together.
- Never push, force-push or commit directly to `master`. Ask before adding or upgrading a dependency (`go get`, `go mod tidy`).
- Done means run in this session and seen passing: `go build`, `go test` on the touched packages, and `golangci-lint run` on them.
+8
View File
@@ -0,0 +1,8 @@
# GEMINI.md
**`AGENTS.md` is the single source of truth for this repository.** Read
`./AGENTS.md` before doing anything else and follow it exactly.
This file exists only so Gemini CLI finds the rules. It contains no project
rules of its own - add those to `AGENTS.md`. If anything here ever conflicts
with `AGENTS.md`, `AGENTS.md` wins.
Generated
+7688 -2672
View File
File diff suppressed because it is too large. Load diff
Generated
+5269 -1283
View File
File diff suppressed because it is too large. Load diff
Generated
+5730 -1334
View File
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -285,7 +285,7 @@ endif
# Fetch the binary builds from GitHub actions
fetch_binaries:
rclone -P sync --exclude "/testbuilds/**" --delete-excluded $(BETA_UPLOAD) build/
rclone -P sync --exclude "/testbuilds/**" --exclude "/index.{html,json}" --delete-excluded $(BETA_UPLOAD) build/
serve: website
cd docs && hugo server --logLevel info -w --disableFastRender --ignoreCache
+7
View File
@@ -24,16 +24,19 @@ directories to and from different cloud storage providers.
## Storage providers
- 1Fichier [:page_facing_up:](https://rclone.org/fichier/)
- Abrha Storage [:page_facing_up:](https://rclone.org/s3/#abrhastorage)
- Akamai Netstorage [:page_facing_up:](https://rclone.org/netstorage/)
- Alibaba Cloud (Aliyun) Object Storage System (OSS) [:page_facing_up:](https://rclone.org/s3/#alibaba-oss)
- Amazon S3 [:page_facing_up:](https://rclone.org/s3/)
- ArvanCloud Object Storage (AOS) [:page_facing_up:](https://rclone.org/s3/#arvan-cloud-object-storage-aos)
- Autonomys Auto Drive [:page_facing_up:](https://rclone.org/s3/#autonomys)
- Bizfly Cloud Simple Storage [:page_facing_up:](https://rclone.org/s3/#bizflycloud)
- Backblaze B2 [:page_facing_up:](https://rclone.org/b2/)
- Box [:page_facing_up:](https://rclone.org/box/)
- Ceph [:page_facing_up:](https://rclone.org/s3/#ceph)
- China Mobile Ecloud Elastic Object Storage (EOS) [:page_facing_up:](https://rclone.org/s3/#china-mobile-ecloud-eos)
- Citrix ShareFile [:page_facing_up:](https://rclone.org/sharefile/)
- Clever Cloud Cellar [:page_facing_up:](https://rclone.org/s3/#clever-cloud-cellar)
- Cloudflare R2 [:page_facing_up:](https://rclone.org/s3/#cloudflare-r2)
- Cloudinary [:page_facing_up:](https://rclone.org/cloudinary/)
- Cubbit DS3 [:page_facing_up:](https://rclone.org/s3/#Cubbit)
@@ -71,8 +74,11 @@ directories to and from different cloud storage providers.
- Jottacloud [:page_facing_up:](https://rclone.org/jottacloud/)
- IBM COS S3 [:page_facing_up:](https://rclone.org/s3/#ibm-cos-s3)
- Impossible Cloud [:page_facing_up:](https://rclone.org/s3/#impossible-cloud)
- Infomaniak Swiss Backup (S3) [:page_facing_up:](https://rclone.org/s3/#infomaniak)
- Infomaniak Swiss Backup (Swift) [:page_facing_up:](https://rclone.org/swift/)
- Intercolo Object Storage [:page_facing_up:](https://rclone.org/s3/#intercolo)
- IONOS Cloud [:page_facing_up:](https://rclone.org/s3/#ionos)
- kDrive [:page_facing_up:](https://rclone.org/kdrive/)
- Koofr [:page_facing_up:](https://rclone.org/koofr/)
- Leviia Object Storage [:page_facing_up:](https://rclone.org/s3/#leviia)
- Liara Object Storage [:page_facing_up:](https://rclone.org/s3/#liara-object-storage)
@@ -88,6 +94,7 @@ directories to and from different cloud storage providers.
- Microsoft Azure Files Storage [:page_facing_up:](https://rclone.org/azurefiles/)
- Microsoft OneDrive [:page_facing_up:](https://rclone.org/onedrive/)
- Minio [:page_facing_up:](https://rclone.org/s3/#minio)
- Neon Object Storage [:page_facing_up:](https://rclone.org/s3/#neon)
- Nextcloud [:page_facing_up:](https://rclone.org/webdav/#nextcloud)
- Blomp Cloud Storage [:page_facing_up:](https://rclone.org/swift/)
- OpenDrive [:page_facing_up:](https://rclone.org/opendrive/)
+13 -5
View File
@@ -32,10 +32,14 @@ This file describes how to make the various kinds of releases
- git commit -a -v -m "Version v1.XX.0"
- make check # if this fails, fix, git commit --amend and run it again
- make retag # moves the tag onto the Version commit
- git push origin # without --follow-tags so it doesn't push the tag if it fails
- git push origin # without --follow-tags so it doesn't push the tag yet
- Wait for the GitHub actions build of master to complete and check it is
green - this is the first time CI sees any commits which were only local,
such as security fixes, so it must pass before the tag is pushed
- if it fails: fix, git commit, make retag, git push origin and wait again
- git push --follow-tags origin
- \# Wait for the GitHub builds to complete then...
- \# Don't commit anything until after make upload_github as these steps
- \# Wait for the GitHub builds of the tag to complete then...
- \# Don't commit or push anything until after make upload_github as these steps
use the tag on HEAD to find the version
- make fetch_binaries
- make tarball
@@ -62,10 +66,14 @@ This file describes how to make the various kinds of releases
Early in the next release cycle update the dependencies.
- Active Pins
- `github.com/gdamore/tcell/v2` v2.9.0 - awaiting fix for [tcell/issues/1124](https://github.com/gdamore/tcell/issues/1124)
- See: [#9539](https://github.com/rclone/rclone/issues/9539)
- `github.com/coreos/go-systemd/v22` v22.6.0 - v22.7.0 fails to compile on netbsd
- `github.com/hanwen/go-fuse/v2` v2.11.0 - v2.12.0 fails to compile on 32
bit linux, awaiting fix for
[go-fuse/issues/628](https://github.com/hanwen/go-fuse/issues/628)
- Review Active Pins in go.mod and remove if possible
- `make updatedirect`
- The update ignores the pins so put them back, eg
`go get github.com/coreos/go-systemd/v22@v22.6.0` then `go mod tidy`
- `make GOTAGS=cmount`
- `make compiletest`
- Fix anything which doesn't compile at this point and commit changes here
+1 -1
View File
@@ -1 +1 @@
v1.76.0
v1.77.0
+1
View File
@@ -39,6 +39,7 @@ import (
_ "github.com/rclone/rclone/backend/internetarchive"
_ "github.com/rclone/rclone/backend/internxt"
_ "github.com/rclone/rclone/backend/jottacloud"
_ "github.com/rclone/rclone/backend/kdrive"
_ "github.com/rclone/rclone/backend/koofr"
_ "github.com/rclone/rclone/backend/linkbox"
_ "github.com/rclone/rclone/backend/local"
+6 -2
View File
@@ -194,8 +194,12 @@ func NewFs(ctx context.Context, name, root string, m configmap.Mapper) (outFs fs
}
// Path to the archive
archiveRemote := remote[:len(foundArchive.remote)]
// Remote is archive leaf name
foundArchive.remote = path.Base(archiveRemote)
// Remote is archive leaf name. This must understand remote
// names as the archive may be in the root, e.g. "remote:file.zip"
_, foundArchive.remote, err = fspath.Split(archiveRemote)
if err != nil {
return nil, fmt.Errorf("failed to parse archive path %q: %w", archiveRemote, err)
}
foundArchive.prefix = ""
// Point remote to archive file
remote = archiveRemote
+45
View File
@@ -17,6 +17,7 @@ import (
"strings"
"testing"
_ "github.com/rclone/rclone/backend/alias"
"github.com/rclone/rclone/backend/archive/archiver"
_ "github.com/rclone/rclone/backend/local"
"github.com/rclone/rclone/fs"
@@ -323,6 +324,50 @@ func TestArchiveUncleanRoot(t *testing.T) {
}
}
// TestArchiveInRemoteRoot checks that an archive in the root of a
// remote, with no "/" between the remote name and the archive name, can
// be read, as can a path within it.
func TestArchiveInRemoteRoot(t *testing.T) {
fstest.Initialise()
ctx := context.Background()
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
w, err := zw.Create("sub/a.txt")
require.NoError(t, err)
_, err = w.Write([]byte("data"))
require.NoError(t, err)
require.NoError(t, zw.Close())
dir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(dir, "test.zip"), buf.Bytes(), 0600))
t.Chdir(dir)
for _, test := range []struct {
name string
remote string
}{
{name: "plain", remote: ":local:test.zip"},
{name: "slash in remote name", remote: ":alias,remote='" + dir + "':test.zip"},
} {
for _, sub := range []struct {
path string
want string
}{
{path: "", want: "sub"},
{path: "/sub", want: "a.txt"},
} {
t.Run(test.name+sub.path, func(t *testing.T) {
f, err := cache.Get(ctx, ":archive:"+test.remote+sub.path)
require.NoError(t, err)
entries, err := f.List(ctx, "")
require.NoError(t, err)
require.Len(t, entries, 1)
assert.Equal(t, sub.want, entries[0].Remote())
})
}
}
}
// escapingObject is an object whose remote is not where it was asked for.
type escapingObject struct {
fs.Object
+2 -2
View File
@@ -79,7 +79,7 @@ This is required and is the name of the share to access.
Help: `Upload chunk size.
Note that this is stored in memory and there may be up to
"--transfers" * "--azurefile-upload-concurrency" chunks stored at once
"--transfers" * "--azurefiles-upload-concurrency" chunks stored at once
in memory.`,
Default: defaultChunkSize,
Advanced: true,
@@ -95,7 +95,7 @@ links and these uploads do not fully utilize your bandwidth, then
increasing this may help to speed up the transfers.
Note that chunks are stored in memory and there may be up to
"--transfers" * "--azurefile-upload-concurrency" chunks stored at once
"--transfers" * "--azurefiles-upload-concurrency" chunks stored at once
in memory.`,
Default: 16,
Advanced: true,
+2 -2
View File
@@ -277,7 +277,7 @@ See: [rclone backend lifecycle](#lifecycle) for setting lifecycles after bucket
Name: "sse_customer_key",
Help: `To use SSE-C, you may provide the secret encryption key encoded in a UTF-8 compatible string to encrypt/decrypt your data
Alternatively you can provide --sse-customer-key-base64.`,
Alternatively you can provide --b2-sse-customer-key-base64.`,
Advanced: true,
Examples: []fs.OptionExample{{
Value: "",
@@ -288,7 +288,7 @@ Alternatively you can provide --sse-customer-key-base64.`,
Name: "sse_customer_key_base64",
Help: `To use SSE-C, you may provide the secret encryption key encoded in Base64 format to encrypt/decrypt your data
Alternatively you can provide --sse-customer-key.`,
Alternatively you can provide --b2-sse-customer-key.`,
Advanced: true,
Examples: []fs.OptionExample{{
Value: "",
+10 -9
View File
@@ -27,7 +27,7 @@ import (
"sync/atomic"
"time"
"github.com/golang-jwt/jwt/v4"
"github.com/golang-jwt/jwt/v5"
"github.com/rclone/rclone/backend/box/api"
"github.com/rclone/rclone/fs"
"github.com/rclone/rclone/fs/config"
@@ -77,7 +77,10 @@ var (
)
type boxCustomClaims struct {
jwt.StandardClaims
jwt.RegisteredClaims
// Audience overrides RegisteredClaims.Audience so it is sent as
// a single string rather than an array of strings
Audience string `json:"aud,omitempty"`
BoxSubType string `json:"box_sub_type,omitempty"`
}
@@ -223,7 +226,7 @@ func getBoxConfig(m configmap.Mapper) (boxConfig *api.ConfigJSON, err error) {
if configFileCredentials == "" {
configFile, _ := m.Get("box_config_file")
configFileBytes, err = os.ReadFile(configFile)
configFileBytes, err = os.ReadFile(env.ShellExpand(configFile))
if err != nil {
return nil, fmt.Errorf("box: failed to read Box config: %w", err)
}
@@ -243,15 +246,13 @@ func getClaims(boxConfig *api.ConfigJSON, boxSubType string) (claims *boxCustomC
}
claims = &boxCustomClaims{
//lint:ignore SA1019 since we need to use jwt.StandardClaims even if deprecated in jwt-go v4 until a more permanent solution is ready in time before jwt-go v5 where it is removed entirely
//nolint:staticcheck // Don't include staticcheck when running golangci-lint to avoid SA1019
StandardClaims: jwt.StandardClaims{
Id: val,
RegisteredClaims: jwt.RegisteredClaims{
ID: val,
Issuer: boxConfig.BoxAppSettings.ClientID,
Subject: boxConfig.EnterpriseID,
Audience: tokenURL,
ExpiresAt: time.Now().Add(time.Second * 45).Unix(),
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Second * 45)),
},
Audience: tokenURL,
BoxSubType: boxSubType,
}
return claims, nil
+118
View File
@@ -0,0 +1,118 @@
package box
import (
"crypto"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/rclone/rclone/backend/box/api"
"github.com/rclone/rclone/fs/config/configmap"
"github.com/rclone/rclone/lib/jwtutil"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// decodeJWTPart decodes one base64url encoded part of a JWT into a map
func decodeJWTPart(t *testing.T, part string) map[string]any {
data, err := base64.RawURLEncoding.DecodeString(part)
require.NoError(t, err)
var result map[string]any
require.NoError(t, json.Unmarshal(data, &result))
return result
}
// Test the JWT assertion sent to the token endpoint is in the format Box expects
func TestJWTAssertion(t *testing.T) {
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
require.NoError(t, err)
boxConfig := &api.ConfigJSON{
BoxAppSettings: api.AppSettings{
ClientID: "client-id",
ClientSecret: "client-secret",
AppAuth: api.AppAuth{
PublicKeyID: "public-key-id",
},
},
EnterpriseID: "enterprise-id",
}
var form map[string][]string
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "POST", r.Method)
assert.NoError(t, r.ParseForm())
form = r.PostForm
_, _ = w.Write([]byte(`{"access_token":"access-token","token_type":"bearer","expires_in":3600}`))
}))
defer ts.Close()
claims, err := getClaims(boxConfig, "enterprise")
require.NoError(t, err)
m := configmap.Simple{}
start := time.Now()
err = jwtutil.Config("box", "test", ts.URL, *claims, getSigningHeaders(boxConfig), getQueryParams(boxConfig), privateKey, m, ts.Client())
require.NoError(t, err)
// Check the token was stored
assert.Contains(t, m["token"], `"access_token":"access-token"`)
// Check the form parameters
assert.Equal(t, []string{"urn:ietf:params:oauth:grant-type:jwt-bearer"}, form["grant_type"])
assert.Equal(t, []string{"client-id"}, form["client_id"])
assert.Equal(t, []string{"client-secret"}, form["client_secret"])
require.Len(t, form["assertion"], 1)
parts := strings.Split(form["assertion"][0], ".")
require.Len(t, parts, 3)
// Check the signature independently of the JWT library
signature, err := base64.RawURLEncoding.DecodeString(parts[2])
require.NoError(t, err)
hash := sha256.Sum256([]byte(parts[0] + "." + parts[1]))
require.NoError(t, rsa.VerifyPKCS1v15(&privateKey.PublicKey, crypto.SHA256, hash[:], signature))
// Check the header
assert.Equal(t, map[string]any{
"alg": "RS256",
"typ": "JWT",
"kid": "public-key-id",
}, decodeJWTPart(t, parts[0]))
// Check the claims - aud must be a string, not an array
payload := decodeJWTPart(t, parts[1])
exp, ok := payload["exp"].(float64)
require.True(t, ok, "exp should be a number")
assert.InDelta(t, float64(start.Add(45*time.Second).Unix()), exp, 5)
jti, ok := payload["jti"].(string)
require.True(t, ok, "jti should be a string")
assert.Len(t, jti, 40)
delete(payload, "exp")
delete(payload, "jti")
assert.Equal(t, map[string]any{
"aud": tokenURL,
"iss": "client-id",
"sub": "enterprise-id",
"box_sub_type": "enterprise",
}, payload)
}
// Test box_config_file has ~ and environment variables expanded
func TestGetBoxConfigShellExpand(t *testing.T) {
dir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(dir, "config.json"), []byte(`{"enterpriseID":"enterprise-id"}`), 0600))
t.Setenv("RCLONE_TEST_BOX_CONFIG_DIR", dir)
m := configmap.Simple{
"box_config_file": "$RCLONE_TEST_BOX_CONFIG_DIR/config.json",
}
boxConfig, err := getBoxConfig(m)
require.NoError(t, err)
assert.Equal(t, "enterprise-id", boxConfig.EnterpriseID)
}
+26 -5
View File
@@ -1502,11 +1502,21 @@ func (c *chunkingReader) rollback(ctx context.Context, metaObject fs.Object) {
func (f *Fs) removeOldChunks(ctx context.Context, remote string) {
oldFsObject, err := f.NewObject(ctx, remote)
if err == nil {
oldObject := oldFsObject.(*Object)
for _, chunk := range oldObject.chunks {
if err := chunk.Remove(ctx); err != nil {
fs.Errorf(chunk, "Failed to remove old chunk: %v", err)
}
removeStaleChunks(ctx, oldFsObject.(*Object), nil)
}
}
func removeStaleChunks(ctx context.Context, oldObject *Object, currentChunks []fs.Object) {
currentRemotes := make(map[string]struct{}, len(currentChunks))
for _, chunk := range currentChunks {
currentRemotes[chunk.Remote()] = struct{}{}
}
for _, chunk := range oldObject.chunks {
if _, found := currentRemotes[chunk.Remote()]; found {
continue
}
if err := chunk.Remove(ctx); err != nil {
fs.Errorf(chunk, "Failed to remove old chunk: %v", err)
}
}
}
@@ -1687,12 +1697,20 @@ func (f *Fs) copyOrMove(ctx context.Context, o *Object, remote string, do copyMo
// metadata format which might involve unsupported chunk types.
return nil, fmt.Errorf("can't %s this file: %w", opName, err)
}
// Snapshot destination chunks before its metadata is replaced.
var oldObject *Object
if oldFsObject, err := f.NewObject(ctx, remote); err == nil {
oldObject = oldFsObject.(*Object)
}
if !o.isComposite() {
fs.Debugf(o, "%s non-chunked object...", opName)
oResult, err := do(ctx, o.mainChunk(), remote) // chain operation to a single wrapped chunk
if err != nil {
return nil, err
}
if oldObject != nil {
removeStaleChunks(ctx, oldObject, nil)
}
return f.newObject("", oResult, nil), nil
}
@@ -1758,6 +1776,9 @@ func (f *Fs) copyOrMove(ctx context.Context, o *Object, remote string, do copyMo
silentlyRemove(ctx, newObj)
return nil, err
}
if oldObject != nil {
removeStaleChunks(ctx, oldObject, newChunks)
}
return newObj, nil
}
+102
View File
@@ -11,6 +11,7 @@ import (
"strings"
"testing"
_ "github.com/rclone/rclone/backend/memory"
"github.com/rclone/rclone/fs"
"github.com/rclone/rclone/fs/config/configmap"
"github.com/rclone/rclone/fs/fspath"
@@ -877,6 +878,104 @@ func testChunkerServerSideMove(t *testing.T, f *Fs) {
_ = operations.Purge(ctx, f.base, dir)
}
func testChunkerServerSideOverwrite(t *testing.T, moveFs *Fs) {
ctx := context.Background()
const dir = "serveroverwrite"
remote := fmt.Sprintf(":chunker,remote=':memory:%s',chunk_size=3B:", dir)
fsResult, err := fs.NewFs(ctx, remote)
require.NoError(t, err)
copyFs, ok := fsResult.(*Fs)
require.True(t, ok)
oldMoveChunkSize := moveFs.opt.ChunkSize
moveFs.opt.ChunkSize = fs.SizeSuffix(3)
defer func() {
moveFs.opt.ChunkSize = oldMoveChunkSize
_ = operations.Purge(ctx, copyFs.base, "")
_ = operations.Purge(ctx, moveFs.base, dir)
}()
type operation struct {
name string
fs *Fs
enabled bool
do copyMoveFn
}
operations := []operation{
{name: "Copy", fs: copyFs, enabled: copyFs.Features().Copy != nil, do: copyFs.Copy},
{name: "Move", fs: moveFs, enabled: moveFs.Features().Move != nil, do: moveFs.Move},
}
testCases := []struct {
name string
sourceContents string
targetContents string
}{
{name: "SmallOverComposite", sourceContents: "hi", targetContents: "abcdef"},
{name: "FewerChunks", sourceContents: "abcd", targetContents: "abcdefghi"},
}
transactionModes := []struct {
name string
useNoRename bool
}{
{name: "Rename"},
{name: "NoRename", useNoRename: true},
}
for _, operation := range operations {
t.Run(operation.name, func(t *testing.T) {
if !operation.enabled {
t.Skipf("wrapped remote does not support server-side %s", strings.ToLower(operation.name))
}
for _, transactionMode := range transactionModes {
t.Run(transactionMode.name, func(t *testing.T) {
f := operation.fs
if transactionMode.useNoRename && !f.useMeta {
t.Skip("Can't test norename transactions without metadata")
}
oldUseNoRename := f.useNoRename
f.useNoRename = transactionMode.useNoRename
defer func() { f.useNoRename = oldUseNoRename }()
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
prefix := path.Join(dir, strings.ToLower(operation.name), strings.ToLower(transactionMode.name), strings.ToLower(testCase.name))
source := testPutFile(ctx, t, f, prefix+"-source", testCase.sourceContents, "source", true)
target := testPutFile(ctx, t, f, prefix+"-target", testCase.targetContents, "target", true)
targetObject := target.(*Object)
require.NotEmpty(t, targetObject.chunks)
oldChunks := append([]fs.Object(nil), targetObject.chunks...)
result, err := operation.do(ctx, source, target.Remote())
require.NoError(t, err)
reader, err := result.Open(ctx)
require.NoError(t, err)
contents, err := io.ReadAll(reader)
require.NoError(t, err)
require.NoError(t, reader.Close())
assert.Equal(t, testCase.sourceContents, string(contents))
resultObject := result.(*Object)
currentChunks := make(map[string]struct{}, len(resultObject.chunks))
for _, chunk := range resultObject.chunks {
currentChunks[chunk.Remote()] = struct{}{}
}
removed := 0
for _, chunk := range oldChunks {
if _, found := currentChunks[chunk.Remote()]; found {
continue
}
_, err := f.base.NewObject(ctx, chunk.Remote())
assert.Equal(t, fs.ErrorObjectNotFound, err)
removed++
}
assert.Positive(t, removed)
})
}
})
}
})
}
}
// Test that md5all creates metadata even for small files
func testMD5AllSlow(t *testing.T, f *Fs) {
ctx := context.Background()
@@ -950,6 +1049,9 @@ func (f *Fs) InternalTest(t *testing.T) {
t.Run("ChunkerServerSideMove", func(t *testing.T) {
testChunkerServerSideMove(t, f)
})
t.Run("ChunkerServerSideOverwrite", func(t *testing.T) {
testChunkerServerSideOverwrite(t, f)
})
t.Run("MD5AllSlow", func(t *testing.T) {
testMD5AllSlow(t, f)
})
+1 -1
View File
@@ -1197,7 +1197,7 @@ func (o *ObjectInfo) Hash(ctx context.Context, hash hash.Type) (string, error) {
return "", nil
}
// if this is wrapping a local object then we work out the hash
if srcObj.Fs().Features().IsLocal {
if srcObj != nil && srcObj.Fs().Features().IsLocal {
// Read the data and encrypt it to calculate the hash
fs.Debugf(o, "Computing %v hash of encrypted source", hash)
return o.f.computeHashWithNonce(ctx, o.nonce, srcObj, hash)
+10
View File
@@ -93,6 +93,16 @@ func testObjectInfo(t *testing.T, f *Fs, wrap bool) {
assert.Equal(t, fmt.Sprintf("%x", wantHash), gotHash)
}
// Test ObjectInfo.Hash doesn't panic when the source is an
// fs.OverrideRemote which wraps an fs.ObjectInfo that isn't an fs.Object
func TestObjectInfoHashOverrideRemoteNotObject(t *testing.T) {
src := object.NewStaticObjectInfo("file", time.Now(), 3, true, nil, nil)
oi := (&Fs{}).newObjectInfo(fs.NewOverrideRemote(src, "new_remote"), nonce{})
gotHash, err := oi.Hash(context.Background(), hash.MD5)
require.NoError(t, err)
assert.Equal(t, "", gotHash)
}
func testComputeHash(t *testing.T, f *Fs) {
var (
contents = random.String(100)
+9 -1
View File
@@ -321,10 +321,13 @@ func (f *Fs) getItem(ctx context.Context, id string, dirID string, leaf string)
}
return false
})
if err != nil {
return nil, err
}
if !found {
return nil, fs.ErrorObjectNotFound
}
return info, err
return info, nil
}
// errorHandler parses a non 2xx error response into an error
@@ -546,6 +549,11 @@ OUTER:
if err != nil {
return found, fmt.Errorf("couldn't list files: %w", err)
}
// Drime won't page past a fixed number of entries (20,000 for
// rclone) and serves the last page it allows again instead
if result.CurrentPage != page {
return found, errors.New("couldn't list files: directory has more entries than drime will list")
}
for _, item := range result.Data {
if item.Type == api.ItemTypeFolder {
if filesOnly {
+158
View File
@@ -0,0 +1,158 @@
package drime
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strconv"
"sync/atomic"
"testing"
"time"
"github.com/rclone/rclone/backend/drime/api"
"github.com/rclone/rclone/fs"
"github.com/rclone/rclone/lib/pacer"
"github.com/rclone/rclone/lib/rest"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
const (
testPerPage = 5 // entries per page the test server returns
testLimit = 20 // entries the test server will page through
)
// listingServer serves /drive/file-entries from entries the way Drime
// does.
//
// It returns testPerPage entries per page and won't page past testLimit
// entries. Asking for a later page returns the last page it allows
// again, with last_page one beyond it.
//
// It counts the requests made in requests.
func listingServer(t *testing.T, entries []api.Item, requests *atomic.Int64) *httptest.Server {
t.Helper()
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests.Add(1)
assert.Equal(t, "/drive/file-entries", r.URL.Path)
params := r.URL.Query()
page, err := strconv.Atoi(params.Get("page"))
require.NoError(t, err)
maxPage := testLimit / testPerPage
currentPage := min(page, maxPage)
start := min((currentPage-1)*testPerPage, len(entries))
end := min(start+testPerPage, len(entries))
lastPage := currentPage
if page > maxPage || end < len(entries) {
lastPage = currentPage + 1
}
w.Header().Set("Content-Type", "application/json")
require.NoError(t, json.NewEncoder(w).Encode(map[string]any{
"current_page": currentPage,
"last_page": lastPage,
"per_page": testPerPage,
"total": (currentPage + 1) * testPerPage,
"data": entries[start:end],
}))
}))
}
// makeEntries makes n file entries
func makeEntries(n int) []api.Item {
entries := make([]api.Item, n)
for i := range entries {
entries[i] = api.Item{
ID: json.Number(strconv.Itoa(1000 + i)),
Name: fmt.Sprintf("file%05d", i),
Type: "text",
}
}
return entries
}
// testFs makes an Fs which uses server as its API
func testFs(t *testing.T, server *httptest.Server) *Fs {
t.Helper()
f := &Fs{
opt: Options{ListChunk: 1000},
srv: rest.NewClient(server.Client()).SetRoot(server.URL),
}
f.pacer = fs.NewPacer(context.Background(), pacer.NewDefault(pacer.MinSleep(minSleep), pacer.MaxSleep(maxSleep), pacer.DecayConstant(decayConstant)))
f.srv.SetErrorHandler(errorHandler)
return f
}
// listNames lists a directory with listAll and returns the names found
func listNames(ctx context.Context, f *Fs) (names []string, err error) {
_, err = f.listAll(ctx, "1", false, false, "", func(item *api.Item) bool {
names = append(names, item.Name)
return false
})
return names, err
}
// TestListAll checks that listAll returns every entry exactly once
func TestListAll(t *testing.T) {
for _, test := range []struct {
name string
entries int
}{
{name: "Empty", entries: 0},
{name: "OnePage", entries: 3},
{name: "ExactPages", entries: 10},
{name: "PartialLastPage", entries: 13},
{name: "AtLimit", entries: testLimit},
} {
t.Run(test.name, func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
var requests atomic.Int64
entries := makeEntries(test.entries)
server := listingServer(t, entries, &requests)
defer server.Close()
f := testFs(t, server)
names, err := listNames(ctx, f)
require.NoError(t, err)
var want []string
for _, entry := range entries {
want = append(want, entry.Name)
}
assert.ElementsMatch(t, want, names)
})
}
}
// TestListAllTooManyEntries checks that listAll returns an error for a
// directory with more entries than the server will list, rather than
// looping forever.
func TestListAllTooManyEntries(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
var requests atomic.Int64
server := listingServer(t, makeEntries(2*testLimit), &requests)
defer server.Close()
f := testFs(t, server)
_, err := listNames(ctx, f)
require.Error(t, err)
assert.NoError(t, ctx.Err(), "listing didn't finish")
assert.LessOrEqual(t, requests.Load(), int64(testLimit/testPerPage+1))
}
// TestGetItemListError checks that getItem returns listing errors
// rather than fs.ErrorObjectNotFound.
func TestGetItemListError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, `{"message":"access denied"}`, http.StatusForbidden)
}))
defer server.Close()
f := testFs(t, server)
_, err := f.getItem(context.Background(), "1000", "1", "file00000")
require.Error(t, err)
assert.NotErrorIs(t, err, fs.ErrorObjectNotFound)
assert.Contains(t, err.Error(), "access denied")
}
+22 -9
View File
@@ -568,7 +568,7 @@ recommended usage is using the flag form --drive-size-as-quota when
doing rclone ls/lsl/lsf/lsjson/etc only.
If you do use this flag for syncing (not recommended) then you will
need to use --ignore size also.`,
need to use --ignore-size also.`,
Advanced: true,
Hide: fs.OptionHideConfigurator,
}, {
@@ -2298,15 +2298,9 @@ func (f *Fs) ListR(ctx context.Context, dir string, callback fs.ListRCallback) (
wg.Wait()
// if the input channel overflowed add the collected entries to the channel now
for len(overflow) > 0 {
mu.Lock()
// only fill half of the channel to prevent entries being put into overflow again
l := min(len(overflow), listRInputBuffer/2)
wg.Add(l)
for _, d := range overflow[:l] {
in <- d
if !refillListRInput(&mu, &in, &overflow, &wg) {
return
}
overflow = overflow[l:]
mu.Unlock()
// wait again for the completion of all directories
wg.Wait()
@@ -2354,6 +2348,25 @@ func (f *Fs) ListR(ctx context.Context, dir string, callback fs.ListRCallback) (
return nil
}
// refillListRInput moves queued directory jobs back onto in.
// It returns false when a listing error has already closed in.
// Sending on that nil channel would block forever while holding mu.
func refillListRInput(mu *sync.Mutex, in *chan listREntry, overflow *[]listREntry, wg *sync.WaitGroup) bool {
mu.Lock()
defer mu.Unlock()
if *in == nil {
return false
}
// only fill half of the channel to prevent entries being put into overflow again
l := min(len(*overflow), listRInputBuffer/2)
wg.Add(l)
for _, d := range (*overflow)[:l] {
*in <- d
}
*overflow = (*overflow)[l:]
return true
}
const shortcutSeparator = '\t'
// joinID adds an actual drive ID to the shortcut ID it came from
+48
View File
@@ -916,3 +916,51 @@ func (f *Fs) InternalTest(t *testing.T) {
}
var _ fstests.InternalTester = (*Fs)(nil)
func TestRefillListRInputStopsWhenInputClosed(t *testing.T) {
mu := stdsync.Mutex{}
wg := stdsync.WaitGroup{}
in := make(chan listREntry, listRInputBuffer)
overflow := []listREntry{{id: "folder", path: "folder"}}
mu.Lock()
close(in)
in = nil
mu.Unlock()
done := make(chan bool, 1)
go func() {
done <- refillListRInput(&mu, &in, &overflow, &wg)
}()
select {
case ok := <-done:
require.False(t, ok, "refill continued after the input channel was closed")
case <-time.After(time.Second):
t.Fatal("refill blocked after the input channel was closed")
}
require.Len(t, overflow, 1)
finished := make(chan struct{})
go func() {
wg.Wait()
close(finished)
}()
select {
case <-finished:
case <-time.After(time.Second):
t.Fatal("refill incremented the wait group after the input channel was closed")
}
}
func TestRefillListRInputSendsQueuedDirectory(t *testing.T) {
mu := stdsync.Mutex{}
wg := stdsync.WaitGroup{}
in := make(chan listREntry, listRInputBuffer)
overflow := []listREntry{{id: "folder", path: "folder"}}
require.True(t, refillListRInput(&mu, &in, &overflow, &wg), "refill stopped while the input channel was open")
require.Empty(t, overflow)
got := <-in
require.Equal(t, listREntry{id: "folder", path: "folder"}, got)
wg.Done()
}
+32 -14
View File
@@ -584,8 +584,9 @@ func (f *Fs) getFtpConnection(ctx context.Context) (c *ftp.ServerConn, err error
//
// It nils the pointed to connection out so it can't be reused
//
// if err is not nil then it checks the connection is alive using a
// NOOP request
// if err is not nil then it discards the connection unless the error
// carries an FTP reply code, in which case it checks the connection
// is alive using a NOOP request
func (f *Fs) putFtpConnection(pc **ftp.ServerConn, err error) {
if f.opt.Concurrency > 0 {
defer f.tokens.Put()
@@ -599,14 +600,17 @@ func (f *Fs) putFtpConnection(pc **ftp.ServerConn, err error) {
}
*pc = nil
if err != nil {
// If not a regular FTP error code then check the connection
if tpErr := textprotoError(err); tpErr != nil {
nopErr := c.NoOp()
if nopErr != nil {
fs.Debugf(f, "Connection failed, closing: %v", nopErr)
_ = c.Quit()
return
}
// An error without an FTP reply code means the control
// connection can no longer be trusted, so discard it.
if textprotoError(err) == nil {
fs.Debugf(f, "Discarding connection after non-FTP error: %v", err)
_ = c.Quit()
return
}
if nopErr := c.NoOp(); nopErr != nil {
fs.Debugf(f, "Connection failed, closing: %v", nopErr)
_ = c.Quit()
return
}
}
f.poolMu.Lock()
@@ -1265,15 +1269,18 @@ func (o *Object) Storable() bool {
// ftpReadCloser implements io.ReadCloser for FTP objects.
type ftpReadCloser struct {
rc io.ReadCloser
c *ftp.ServerConn
f *Fs
err error // errors found during read
rc io.ReadCloser
c *ftp.ServerConn
f *Fs
err error // errors found during read
closed bool // set by Close so a second call is a no-op
read int64 // bytes returned by Read
}
// Read bytes into p
func (f *ftpReadCloser) Read(p []byte) (n int, err error) {
n, err = f.rc.Read(p)
f.read += int64(n)
if err != nil && err != io.EOF {
f.err = err // store any errors for Close to examine
}
@@ -1282,6 +1289,10 @@ func (f *ftpReadCloser) Read(p []byte) (n int, err error) {
// Close the FTP reader and return the connection to the pool
func (f *ftpReadCloser) Close() error {
if f.closed {
return nil
}
f.closed = true
var err error
errchan := make(chan error, 1)
go func() {
@@ -1305,6 +1316,7 @@ func (f *ftpReadCloser) Close() error {
// if errors while reading or closing, dump the connection
if err != nil || f.err != nil {
_ = f.c.Quit()
f.c = nil // never pool a connection after Quit
f.f.putFtpConnection(nil, nil)
} else {
f.f.putFtpConnection(&f.c, nil)
@@ -1316,6 +1328,12 @@ func (f *ftpReadCloser) Close() error {
switch errX.Code {
case ftp.StatusTransfertAborted, ftp.StatusFileUnavailable, ftp.StatusAboutToSend, ftp.StatusRequestedFileActionOK:
err = nil
case ftp.StatusCanNotOpenDataConnection:
// Egnyte answers 425 when a RETR is closed before the first
// read; a transfer cut after data was read stays an error
if f.read == 0 {
err = nil
}
}
}
return err
+163
View File
@@ -1,12 +1,19 @@
package ftp
import (
"bufio"
"context"
"errors"
"fmt"
"io"
"net"
"net/textproto"
"strings"
"sync"
"testing"
"time"
"github.com/jlaffaye/ftp"
"github.com/rclone/rclone/fs"
"github.com/rclone/rclone/fs/config/configmap"
"github.com/rclone/rclone/fs/object"
@@ -144,3 +151,159 @@ func (f *Fs) InternalTest(t *testing.T) {
}
var _ fstests.InternalTester = (*Fs)(nil)
// fakeFTPServer is a minimal FTP control-connection server which answers
// every command with 200 OK.
type fakeFTPServer struct {
ln net.Listener
mu sync.Mutex
conn net.Conn
}
func startFakeFTPServer(t *testing.T) *fakeFTPServer {
t.Helper()
ln, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)
s := &fakeFTPServer{ln: ln}
go s.serve()
t.Cleanup(func() { s.close() })
return s
}
func (s *fakeFTPServer) serve() {
conn, err := s.ln.Accept()
if err != nil {
return
}
s.mu.Lock()
s.conn = conn
s.mu.Unlock()
defer func() { _ = conn.Close() }()
_, _ = fmt.Fprint(conn, "220 fake FTP server\r\n")
scanner := bufio.NewScanner(conn)
for scanner.Scan() {
_, _ = fmt.Fprint(conn, "200 OK\r\n")
}
}
// killConnection closes the server side of the control connection,
// simulating a server that dropped an idle connection.
func (s *fakeFTPServer) killConnection() {
s.mu.Lock()
defer s.mu.Unlock()
if s.conn != nil {
_ = s.conn.Close()
}
}
func (s *fakeFTPServer) close() {
s.mu.Lock()
defer s.mu.Unlock()
if s.conn != nil {
_ = s.conn.Close()
}
_ = s.ln.Close()
}
// TestPutFtpConnectionDiscardsDeadConnection checks that a connection
// returned with a raw network error is closed and never re-pooled, while
// a connection returned with a plain FTP reply-code error is probed with
// NOOP and pooled while the probe succeeds.
func TestPutFtpConnectionDiscardsDeadConnection(t *testing.T) {
t.Run("network error discards", func(t *testing.T) {
server := startFakeFTPServer(t)
c, err := ftp.Dial(server.ln.Addr().String())
require.NoError(t, err)
f := &Fs{opt: Options{}}
f.putFtpConnection(&c, errors.New("unexpected EOF"))
assert.Nil(t, c)
assert.Empty(t, f.pool)
})
t.Run("FTP reply error with live connection pools", func(t *testing.T) {
server := startFakeFTPServer(t)
c, err := ftp.Dial(server.ln.Addr().String())
require.NoError(t, err)
f := &Fs{opt: Options{}}
f.putFtpConnection(&c, &textproto.Error{Code: 550, Msg: "not found"})
assert.Nil(t, c)
require.Len(t, f.pool, 1)
assert.NoError(t, f.pool[0].NoOp())
_ = f.pool[0].Quit()
})
t.Run("FTP reply error with dead connection discards", func(t *testing.T) {
server := startFakeFTPServer(t)
c, err := ftp.Dial(server.ln.Addr().String())
require.NoError(t, err)
server.killConnection()
// Let the FIN propagate so the NOOP probe observes the dead connection.
time.Sleep(100 * time.Millisecond)
f := &Fs{opt: Options{}}
f.putFtpConnection(&c, &textproto.Error{Code: 550, Msg: "not found"})
assert.Nil(t, c)
assert.Empty(t, f.pool)
})
}
// fakeDataReader stands in for the RETR data stream of ftpReadCloser.
type fakeDataReader struct {
data []byte
closeErr error
}
func (r *fakeDataReader) Read(p []byte) (int, error) {
if len(r.data) == 0 {
return 0, io.EOF
}
n := copy(p, r.data)
r.data = r.data[n:]
return n, nil
}
func (r *fakeDataReader) Close() error { return r.closeErr }
// TestFtpReadCloserClose checks that an early-closed RETR answered with 425
// is masked only when nothing was read, that a connection which got QUIT is
// never pooled, and that a second Close does not touch the pool.
func TestFtpReadCloserClose(t *testing.T) {
err425 := &textproto.Error{Code: ftp.StatusCanNotOpenDataConnection, Msg: "Can't open data connection."}
newReader := func(t *testing.T, data []byte, closeErr error) (*ftpReadCloser, *Fs) {
server := startFakeFTPServer(t)
c, err := ftp.Dial(server.ln.Addr().String())
require.NoError(t, err)
f := &Fs{opt: Options{}}
return &ftpReadCloser{rc: &fakeDataReader{data: data, closeErr: closeErr}, c: c, f: f}, f
}
t.Run("425 before any read is masked and the connection is not pooled", func(t *testing.T) {
rc, f := newReader(t, nil, err425)
assert.NoError(t, rc.Close())
assert.Empty(t, f.pool)
assert.NoError(t, rc.Close())
assert.Empty(t, f.pool)
})
t.Run("425 after data was read stays an error", func(t *testing.T) {
rc, f := newReader(t, []byte("partial"), err425)
buf := make([]byte, 4)
n, err := rc.Read(buf)
require.NoError(t, err)
require.Equal(t, 4, n)
err = rc.Close()
require.Error(t, err)
assert.Equal(t, ftp.StatusCanNotOpenDataConnection, textprotoError(err).Code)
assert.Empty(t, f.pool)
})
t.Run("clean close pools the connection once", func(t *testing.T) {
rc, f := newReader(t, []byte("data"), nil)
_, err := io.ReadAll(rc)
require.NoError(t, err)
assert.NoError(t, rc.Close())
require.Len(t, f.pool, 1)
assert.NoError(t, rc.Close())
assert.Len(t, f.pool, 1)
_ = f.pool[0].Quit()
})
}
+44
View File
@@ -122,6 +122,49 @@ func (f *Fs) testUpdateStoresHash(t *testing.T) {
_ = operations.Purge(ctx, f, dirName)
}
func (f *Fs) testSetModTimeKeepsHash(t *testing.T) {
ctx := context.Background()
const dirName = "setmodtime_hash_1"
const fileName = dirName + "/file_setmodtime_1"
const longTime = fs.ModTimeNotSupported
hashType := f.keepHashes.GetOne()
// upload a file to hasher via Put
src := putFile(ctx, t, f, fileName, "setmodtime content")
dst, err := f.NewObject(ctx, fileName)
require.NoError(t, err)
require.NotNil(t, dst)
_ = src
// verify hash was stored after Put
if f.opt.MaxAge <= 0 {
t.Skip("hash cache is disabled")
}
hash1, err := f.getRawHash(ctx, hashType, fileName, anyFingerprint, longTime)
require.NoError(t, err)
require.NotEmpty(t, hash1)
// change only the modification time, as operations does when it
// syncs the modtime of an otherwise unchanged destination file
newTime := fstest.Time("2002-03-04T05:06:07.499999999Z")
require.NoError(t, dst.SetModTime(ctx, newTime))
// the cached hash must survive a pure modtime change
hash2, err := f.getRawHash(ctx, hashType, fileName, anyFingerprint, longTime)
assert.NoError(t, err)
assert.Equal(t, hash1, hash2, "hash should be kept after SetModTime")
// and it must be found via the fingerprint-checked lookup too
dst2, err := f.NewObject(ctx, fileName)
require.NoError(t, err)
hashVal, err := dst2.Hash(ctx, hashType)
assert.NoError(t, err)
assert.Equal(t, hash1, hashVal, "hash should be returned after SetModTime")
_ = operations.Purge(ctx, f, dirName)
}
// InternalTest dispatches all internal tests
func (f *Fs) InternalTest(t *testing.T) {
if !kv.Supported() {
@@ -129,6 +172,7 @@ func (f *Fs) InternalTest(t *testing.T) {
}
t.Run("UploadFromCrypt", f.testUploadFromCrypt)
t.Run("UpdateStoresHash", f.testUpdateStoresHash)
t.Run("SetModTimeKeepsHash", f.testSetModTimeKeepsHash)
}
var _ fstests.InternalTester = (*Fs)(nil)
+28
View File
@@ -206,6 +206,34 @@ func (op *kvPut) Do(ctx context.Context, b kv.Bucket) (err error) {
return err
}
// kvUpdateFingerprint: rewrite the fingerprint of an existing record
type kvUpdateFingerprint struct {
key string
oldFp string
newFp string
age time.Duration
}
func (op *kvUpdateFingerprint) Do(ctx context.Context, b kv.Bucket) error {
data := b.Get([]byte(op.key))
if len(data) == 0 {
return nil
}
var r hashRecord
if err := r.decode(op.key, data); err != nil {
return nil
}
if r.Fp != op.oldFp || time.Since(r.Created) > op.age {
return nil
}
r.Fp = op.newFp
data, err := r.encode(op.key)
if err != nil {
return err
}
return b.Put([]byte(op.key), data)
}
// kvDump: dump the database.
// Note: long dump can cause concurrent operations to fail.
type kvDump struct {
+36 -6
View File
@@ -56,6 +56,23 @@ func (o *Object) putHashes(ctx context.Context, rawHashes hashMap) error {
return o.f.putRawHashes(ctx, key, fp, hashes)
}
// updateFingerprint rewrites the fingerprint of the cached hash
// record for remote from oldFp to newFp, keeping the hashes. It is
// used when only the modification time of an object changes, so the
// cached hashes remain valid under the new fingerprint.
func (f *Fs) updateFingerprint(ctx context.Context, remote, oldFp, newFp string) error {
if oldFp == "" || newFp == "" || oldFp == newFp || f.opt.MaxAge <= 0 {
return nil
}
key := path.Join(f.Fs.Root(), remote)
return f.db.Do(true, &kvUpdateFingerprint{
key: key,
oldFp: oldFp,
newFp: newFp,
age: time.Duration(f.opt.MaxAge),
})
}
// set hashes for a path without any validation
func (f *Fs) putRawHashes(ctx context.Context, key, fp string, hashes operations.HashSums) error {
return f.db.Do(true, &kvPut{
@@ -196,14 +213,27 @@ func (o *Object) Remove(ctx context.Context) error {
}
// SetModTime sets the modification time of the file.
// Also prunes the cache entry when modtime changes so that
// touching a file will trigger checksum recalculation even
// on backends that don't provide modTime with fingerprint.
//
// When the fingerprint includes the modtime, the contents have not
// changed, so the cached hashes are carried over to the fingerprint
// for the new modtime. Otherwise the cache entry is pruned when the
// modtime changes so that touching a file will trigger checksum
// recalculation even on backends that don't provide modTime with
// fingerprint.
func (o *Object) SetModTime(ctx context.Context, mtime time.Time) error {
if mtime != o.Object.ModTime(ctx) {
_ = o.f.pruneHash(o.Remote())
if mtime == o.Object.ModTime(ctx) {
return o.Object.SetModTime(ctx, mtime)
}
return o.Object.SetModTime(ctx, mtime)
if !o.f.fpTime {
_ = o.f.pruneHash(o.Remote())
return o.Object.SetModTime(ctx, mtime)
}
oldFp := o.fingerprint(ctx)
if err := o.Object.SetModTime(ctx, mtime); err != nil {
return err
}
_ = o.f.updateFingerprint(ctx, o.Remote(), oldFp, o.fingerprint(ctx))
return nil
}
// Open opens the file for read.
+43 -10
View File
@@ -63,7 +63,8 @@ The headers are only sent to the host in the configured URL. If the
server redirects to another host (including a subdomain or a different
port) the headers are not sent to it, or to any further hop in that
redirect chain. When headers are set, a redirect from https to http is
refused as it would send them in cleartext.`,
refused as it would send them in cleartext, even with
--allow-https-downgrade.`,
Default: fs.CommaSepList{},
Advanced: true,
}, {
@@ -100,6 +101,30 @@ that directory listings are much quicker, but rclone won't have the times or
sizes of any files, and some files that don't exist may be in the listing.`,
Default: false,
Advanced: true,
}, {
Name: "no_head_size",
Help: `Set the reported file size for --http-no-head.
When HEAD requests are disabled, rclone normally reports an unknown file size.
Set this option to a non-negative value to report that size for every file,
including 0. Leave it unset (the default, shown as "off") to keep reporting
an unknown size.
The configured size is authoritative. If it does not match the actual file
size, transfers may fail their size check and retry unless --ignore-size is
used. It can also make multi-thread transfers request incorrect ranges and
make mounted files unreadable past the reported size.`,
Default: fs.SizeSuffix(-1),
Advanced: true,
}, {
Name: "no_head_time",
Help: `Set a default modification time for --http-no-head.
When HEAD requests are disabled, rclone normally reports an unset modification
time. Set this option to report the specified time for every file instead.
Changing the configured time later makes every file appear modified to a sync.`,
Default: fs.Time{},
Advanced: true,
}, {
Name: "no_escape",
Help: "Do not escape URL metacharacters in path names.",
@@ -145,11 +170,13 @@ var systemMetadataInfo = map[string]fs.MetadataHelp{
// Options defines the configuration for this backend
type Options struct {
Endpoint string `config:"url"`
NoSlash bool `config:"no_slash"`
NoHead bool `config:"no_head"`
Headers fs.CommaSepList `config:"headers"`
NoEscape bool `config:"no_escape"`
Endpoint string `config:"url"`
NoSlash bool `config:"no_slash"`
NoHead bool `config:"no_head"`
NoHeadSize fs.SizeSuffix `config:"no_head_size"`
NoHeadTime fs.Time `config:"no_head_time"`
Headers fs.CommaSepList `config:"headers"`
NoEscape bool `config:"no_escape"`
}
// Fs stores the interface to the remote HTTP files
@@ -289,8 +316,9 @@ func (f *Fs) httpConnection(ctx context.Context, opt *Options) (isFile bool, err
}
client := fshttp.NewClient(ctx)
// Without configured headers keep the default policy which
// public mirrors that redirect from https to http rely on.
// Without configured headers keep the default policy so
// --allow-https-downgrade can follow public mirrors that
// redirect from https to http.
if len(opt.Headers) > 0 {
client.CheckRedirect = checkRedirect(opt)
}
@@ -322,7 +350,9 @@ func (f *Fs) httpConnection(ctx context.Context, opt *Options) (isFile bool, err
// the host specified in the config file.
func NewFs(ctx context.Context, name, root string, m configmap.Mapper) (fs.Fs, error) {
// Parse config into Options struct
opt := new(Options)
opt := &Options{
NoHeadSize: fs.SizeSuffix(-1),
}
err := configstruct.Set(m, opt)
if err != nil {
return nil, err
@@ -717,8 +747,11 @@ func (o *Object) url() string {
// head sends a HEAD request to update info fields in the Object
func (o *Object) head(ctx context.Context) error {
if o.fs.opt.NoHead {
o.size = -1
o.size = int64(o.fs.opt.NoHeadSize)
o.modTime = timeUnset
if o.fs.opt.NoHeadTime.IsSet() {
o.modTime = time.Time(o.fs.opt.NoHeadTime)
}
o.contentType = fs.MimeType(ctx, o)
return nil
}
+65 -4
View File
@@ -314,6 +314,54 @@ func TestOpen(t *testing.T) {
}
}
func TestNoHeadMetadataDefaults(t *testing.T) {
tests := []struct {
name string
size string
modTime string
wantSize int64
wantModTime time.Time
}{
{
name: "defaults",
wantSize: -1,
wantModTime: timeUnset,
}, {
name: "zero size",
size: "0B",
wantSize: 0,
wantModTime: timeUnset,
}, {
name: "configured metadata",
size: "100B",
modTime: "2024-08-05T07:36:07Z",
wantSize: 100,
wantModTime: time.Date(2024, 8, 5, 7, 36, 7, 0, time.UTC),
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
m := prepareServer(t)
m.Set("no_head", "true")
if test.size != "" {
m.Set("no_head_size", test.size)
}
if test.modTime != "" {
m.Set("no_head_time", test.modTime)
}
f, err := NewFs(context.Background(), remoteName, "", m)
require.NoError(t, err)
o, err := f.NewObject(context.Background(), "four/under four.txt")
require.NoError(t, err)
assert.Equal(t, test.wantSize, o.Size())
assert.Equal(t, test.wantModTime, o.ModTime(context.Background()))
})
}
}
func TestMimeType(t *testing.T) {
f := prepare(t)
@@ -640,7 +688,7 @@ func TestRedirectRefusesHTTPSDowngrade(t *testing.T) {
// open makes a remote pointing at ts with or without headers
// and opens file.txt on it
open := func(t *testing.T, withHeaders bool) (io.ReadCloser, error) {
open := func(t *testing.T, withHeaders, allowDowngrade bool) (io.ReadCloser, error) {
configfile.Install()
m := configmap.Simple{
"type": "http",
@@ -652,6 +700,7 @@ func TestRedirectRefusesHTTPSDowngrade(t *testing.T) {
}
ctx, ci := fs.AddConfig(context.Background())
ci.InsecureSkipVerify = true
ci.AllowHTTPSDowngrade = allowDowngrade
f, err := NewFs(ctx, remoteName, "", m)
require.NoError(t, err)
@@ -663,13 +712,25 @@ func TestRedirectRefusesHTTPSDowngrade(t *testing.T) {
t.Run("WithHeaders", func(t *testing.T) {
// Headers could leak so the downgrade must be refused
_, err := open(t, true)
_, err := open(t, true, false)
require.ErrorIs(t, err, rest.ErrHTTPSDowngrade)
})
t.Run("WithHeadersAllowDowngrade", func(t *testing.T) {
// Headers could leak so the downgrade is refused even when allowed
_, err := open(t, true, true)
require.ErrorIs(t, err, rest.ErrHTTPSDowngrade)
})
t.Run("WithoutHeaders", func(t *testing.T) {
// Nothing to leak so the downgrade is followed
fd, err := open(t, false)
// The transport refuses downgrades by default
_, err := open(t, false, false)
require.ErrorIs(t, err, rest.ErrHTTPSDowngrade)
})
t.Run("WithoutHeadersAllowDowngrade", func(t *testing.T) {
// Nothing to leak so the downgrade is followed when allowed
fd, err := open(t, false, true)
require.NoError(t, err)
data, err := io.ReadAll(fd)
require.NoError(t, err)
+13 -3
View File
@@ -50,6 +50,10 @@ const (
defaultChunkSize = 8 * fs.Mebi
)
// jsonContentType is sent with JSON request bodies as the API doesn't
// decode non-ASCII file names in them as UTF-8 unless told the charset.
const jsonContentType = "application/json; charset=UTF-8"
// OAuth2 configuration
var oauthConfig = &oauthutil.Config{
Scopes: []string{
@@ -547,6 +551,7 @@ func (f *Fs) CreateDir(ctx context.Context, pathID, leaf string) (newID string,
Parameters: url.Values{
"fields": []string{"*"},
},
ExtraHeaders: map[string]string{"Content-Type": jsonContentType},
}
var info *api.File
@@ -1015,6 +1020,7 @@ func (f *Fs) Copy(ctx context.Context, src fs.Object, remote string) (fs.Object,
Parameters: url.Values{
"fields": []string{"*"},
},
ExtraHeaders: map[string]string{"Content-Type": jsonContentType},
}
copyReq := api.CopyFileRequest{
@@ -1199,9 +1205,10 @@ func (f *Fs) updateFile(ctx context.Context, fileID string, parameters url.Value
}
parameters.Set("fields", "*")
opts := rest.Opts{
Method: "PATCH",
Path: "/files/" + fileID,
Parameters: parameters,
Method: "PATCH",
Path: "/files/" + fileID,
Parameters: parameters,
ExtraHeaders: map[string]string{"Content-Type": jsonContentType},
}
err = f.pacer.Call(func() (bool, error) {
resp, err := f.srv.CallJSON(ctx, &opts, req, &info)
@@ -1671,6 +1678,7 @@ func (o *Object) uploadEmpty(ctx context.Context, leaf, directoryID string, src
Parameters: url.Values{
"fields": []string{"*"},
},
ExtraHeaders: map[string]string{"Content-Type": jsonContentType},
}
var info *api.File
@@ -1846,6 +1854,7 @@ func (o *Object) uploadResume(ctx context.Context, in io.Reader, leaf, directory
"fields": []string{"*"},
},
ExtraHeaders: map[string]string{
"Content-Type": jsonContentType,
"X-Upload-Content-Length": strconv.FormatInt(size, 10),
},
RootURL: o.fs.uploadURL,
@@ -2145,6 +2154,7 @@ func (o *Object) SetMetadata(ctx context.Context, metadata fs.Metadata) error {
Parameters: url.Values{
"fields": []string{"*"},
},
ExtraHeaders: map[string]string{"Content-Type": jsonContentType},
}
var info *api.File
+1
View File
@@ -694,6 +694,7 @@ func NewFs(ctx context.Context, name, root string, m configmap.Mapper) (fs.Fs, e
pacer: fs.NewPacer(ctx, pacer.NewDefault(pacer.MinSleep(minSleep), pacer.MaxSleep(maxSleep), pacer.DecayConstant(decayConstant))),
}
f.features = (&fs.Features{
CaseInsensitive: true, // names are matched with EqualFold, see findLeafItem
CanHaveEmptyDirectories: true,
PartialUploads: false,
}).Fill(ctx, f)
+2
View File
@@ -77,6 +77,8 @@ func (ik *ImageKit) Upload(ctx context.Context, file io.Reader, param UploadPara
if err != nil {
return nil, nil, fmt.Errorf("failed to make multipart upload: %w", err)
}
// Stop the form writer reading file if the request fails before sending it all
defer func() { _ = formReader.Close() }()
opts := rest.Opts{
Method: "POST",
@@ -634,6 +634,27 @@ func (f *Fs) Copy(ctx context.Context, src fs.Object, remote string) (_ fs.Objec
"x-archive-filemeta-rclone-update-track": updateTracker,
}
mdata, err := fs.GetMetadataOptions(ctx, f, src, fs.MetadataAsOpenOptions(ctx))
if err != nil {
return nil, err
}
if mdata != nil {
// IA ignores file metadata headers with the COPY directive.
headers["x-amz-metadata-directive"] = "REPLACE"
}
for key, value := range mdata {
key = strings.ToLower(key)
if key == "mtime" {
key = "rclone-mtime"
} else if strings.HasPrefix(key, "rclone-") {
// Keep the new copy tracker and mapped mtime, not the source internals.
continue
} else if _, readOnly := roMetadataKey[key]; readOnly {
continue
}
headers["x-archive-filemeta-"+key] = value
}
// make a PUT request at (IAS3)/:item/:path without body
var resp *http.Response
opts := rest.Opts{
+10 -4
View File
@@ -86,8 +86,8 @@ func getServices() []service {
{"jottacloud", "Jottacloud", "id.jottacloud.com", "jottacloud", "desktop", []string{"openid", "jotta-default", "offline_access"}}, // Chose client id "desktop" here, will be identified as "Jottacloud for Desktop" in "My logged in devices", but could have used "jottacli" here as well.
{"elgiganten_dk", "Elgiganten Cloud (Denmark)", "cloud.elgiganten.dk", "elgiganten", "desktop", []string{"openid", "jotta-default", "offline_access"}},
{"elgiganten_se", "Elgiganten Cloud (Sweden)", "cloud.elgiganten.se", "elgiganten", "desktop", []string{"openid", "jotta-default", "offline_access"}},
{"elkjop", "Elkjøp Cloud (Norway)", "cloud.elkjop.no", "elkjop", "desktop", []string{"openid", "jotta-default", "offline_access"}},
{"elko", "ELKO Cloud (Iceland)", "cloud.elko.is", "elko", "desktop", []string{"openid", "jotta-default", "offline_access"}},
{"elkjop", "Elkjøp Cloud (Norway)", "cloud.elkjop.no", "elkjop", "desktop", []string{"openid", "offline_access"}},
{"elko", "ELKO Cloud (Iceland)", "cloud.elko.is", "elko", "desktop", []string{"openid", "offline_access"}},
{"gigantti", "Gigantti Cloud (Finland)", "cloud.gigantti.fi", "gigantti", "desktop", []string{"openid", "jotta-default", "offline_access"}},
{"letsgo", "Let's Go Cloud (Germany)", "letsgo.jotta.cloud", "letsgo", "desktop-win", []string{"openid", "offline_access"}},
{"mediamarkt", "MediaMarkt Cloud (Multiregional)", "mediamarkt.jottacloud.com", "mediamarkt", "desktop", []string{"openid", "jotta-default", "offline_access"}},
@@ -258,6 +258,7 @@ service, for the official service on https://www.jottacloud.com/web/secure.`)
}
m.Set("configVersion", fmt.Sprint(configVersion))
m.Set(configClientID, service.clientID)
m.Set(configClientSecret, "")
m.Set(configTokenURL, wellKnown.TokenEndpoint)
return oauthutil.ConfigOut("choose_device", &oauthutil.Options{
OAuth2Config: &oauthutil.Config{
@@ -1500,9 +1501,9 @@ func (f *Fs) Copy(ctx context.Context, src fs.Object, remote string) (fs.Object,
info, err := f.copyOrMove(ctx, "cp", srcObj.filePath(), remote)
if err == nil {
var createTime time.Time
createTime := srcObj.createTime
var createTimeMeta bool
var modTime time.Time
modTime := srcObj.modTime
var modTimeMeta bool
if meta != nil {
createTime, createTimeMeta = srcObj.parseFsMetadataTime(meta, "btime")
@@ -1518,6 +1519,11 @@ func (f *Fs) Copy(ctx context.Context, src fs.Object, remote string) (fs.Object,
// Workaround necessary when destination was a trashed file, to avoid the copied file also being in trash (bug in api?)
fs.Debugf(src, "Server-side copied to trashed destination, restoring")
info, err = f.createOrUpdate(ctx, remote, createTime, modTime, info.Size, info.MD5)
// The restored file keeps the name it was trashed with, which may differ in case
if err == nil && info != nil && info.Name != f.opt.Enc.FromStandardName(path.Base(remote)) {
fs.Debugf(src, "Renaming restored %q to %q", info.Name, path.Base(remote))
info, err = f.copyOrMove(ctx, "mv", f.filePath(remote), remote)
}
} else if createTimeMeta || modTimeMeta {
info, err = f.createOrUpdate(ctx, remote, createTime, modTime, info.Size, info.MD5)
}
+235
View File
@@ -0,0 +1,235 @@
// Package api has type definitions for kDrive
//
// Converted from the API docs with help from https://mholt.github.io/json-to-go/
package api
import (
"fmt"
"strconv"
"time"
)
// Time represents unix timestamp date and time information for the
// kDrive API
type Time time.Time
// UnmarshalJSON turns JSON into a Time
func (t *Time) UnmarshalJSON(data []byte) error {
if string(data) == "null" {
return nil
}
timestamp, err := strconv.ParseInt(string(data), 10, 64)
if err != nil {
return err
}
newT := time.Unix(timestamp, 0)
*t = Time(newT)
return nil
}
// ResultStatus return error details from kdrive when things go wrong
//
// If Status is "success" then everything is OK
type ResultStatus struct {
Status string `json:"result"`
ErrorDetail struct {
Result string `json:"code"`
ErrorString string `json:"description"`
Context struct {
Values []string `json:"values"`
} `json:"context"`
Errors []struct {
Result string `json:"code"`
ErrorString string `json:"description"`
} `json:"errors"`
} `json:"error"`
}
// Error returns a string for the error and satisfies the error interface
func (e *ResultStatus) Error() string {
var details string
for i := range e.ErrorDetail.Errors {
details += "|" + e.ErrorDetail.Errors[i].Result
}
return fmt.Sprintf("kDrive error: %s (%s: %s %s)", e.Status, e.ErrorDetail.Result, e.ErrorDetail.ErrorString, details)
}
// IsError returns true if there is an error
func (e *ResultStatus) IsError() bool {
return e.Status != "success"
}
// Update returns err directly if it was != nil, otherwise it returns
// an Error or nil if no error was detected
func (e *ResultStatus) Update(err error) error {
if err != nil {
return err
}
if e.IsError() {
return e
}
return nil
}
// Check ResultStatus satisfies the error interface
var _ error = (*ResultStatus)(nil)
// Item describes a folder or a file as returned by Get Folder Items and others
type Item struct {
ID int `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
FullPath string `json:"path"`
Status string `json:"status"`
Hash string `json:"hash"`
Size int64 `json:"size"`
Visibility string `json:"visibility"`
DriveID int `json:"drive_id"`
Depth int `json:"depth"`
CreatedBy int `json:"created_by"`
CreatedAt Time `json:"created_at"`
AddedAt int `json:"added_at"`
LastModifiedAt Time `json:"last_modified_at"`
LastModifiedBy int `json:"last_modified_by"`
RevisedAt int `json:"revised_at"`
UpdatedAt int `json:"updated_at"`
MimeType string `json:"mime_type"`
ParentID int `json:"parent_id"`
Color string `json:"color"`
}
// ItemResult is returned when an item is queried
type ItemResult struct {
ResultStatus
Data Item `json:"data"`
}
// SearchResult is returned when a list of items is requested
type SearchResult struct {
ResultStatus
Data []Item `json:"data"`
Cursor string `json:"cursor"`
HasMore bool `json:"has_more"`
ResponseAt int `json:"response_at"`
}
// ModTime returns the modification time of the item
func (i *Item) ModTime() (t time.Time) {
t = time.Time(i.LastModifiedAt)
if t.IsZero() {
t = time.Time(i.CreatedAt)
}
return t
}
// CancelResource is a kdrive resource that can be cancelled after some action
type CancelResource struct {
CancelID string `json:"cancel_id"`
ValidUntil int `json:"valid_until"`
}
// CancellableResponse is returned from kdrive when an action can be cancelled afterwards
type CancellableResponse struct {
ResultStatus
Data CancelResource `json:"data"`
}
// CreateDirResult is returned from kdrive after a call to MkDir
type CreateDirResult struct {
ResultStatus
Data Item `json:"data"`
}
// FileCopyResponse is returned from kdrive after a call to Copy
type FileCopyResponse struct {
ResultStatus
Data Item `json:"data"`
}
// UploadFileResponse is returned from kdrive after a call to Upload
type UploadFileResponse struct {
ResultStatus
Data Item `json:"data"`
}
// ChecksumFileResult is returned from kdrive after a call to Hash
type ChecksumFileResult struct {
ResultStatus
Data struct {
Hash string `json:"hash"`
} `json:"data"`
}
// PubLinkResult is returned from kdrive after a call to public link API
type PubLinkResult struct {
ResultStatus
Data struct {
URL string `json:"url"`
FileID int `json:"file_id"`
Right string `json:"right"`
ValidUntil int `json:"valid_until"`
CreatedBy int `json:"created_by"`
CreatedAt int `json:"created_at"`
UpdatedAt int `json:"updated_at"`
Capabilities struct {
CanEdit bool `json:"can_edit"`
CanSeeStats bool `json:"can_see_stats"`
CanSeeInfo bool `json:"can_see_info"`
CanDownload bool `json:"can_download"`
CanComment bool `json:"can_comment"`
CanRequestAccess bool `json:"can_request_access"`
} `json:"capabilities"`
AccessBlocked bool `json:"access_blocked"`
} `json:"data"`
}
// PubLinkUpdateResult is returned from kdrive after a updating a public link
type PubLinkUpdateResult struct {
ResultStatus
Data bool `json:"data"`
}
// QuotaInfo is return from kdrive after a call get drive info
type QuotaInfo struct {
ResultStatus
Data struct {
Size int64 `json:"size"`
UsedSize int64 `json:"used_size"`
} `json:"data"`
}
// SessionStartResponse is used to start a multichunk upload session
type SessionStartResponse struct {
ResultStatus
Data struct {
Token string `json:"token"`
UploadURL string `json:"upload_url"` // URL pour uploader les chunks
} `json:"data"`
}
// ChunkUploadResponse is returned by kdrive after a chunk upload
type ChunkUploadResponse struct {
ResultStatus
Data struct {
Hash string `json:"hash,omitempty"` // Only present when requested with with:'hash'
} `json:"data"`
}
// SessionFinishResponse is used to end a multichunk upload session
type SessionFinishResponse struct {
ResultStatus
Data struct {
Token string `json:"token"`
File Item `json:"file"`
Result bool `json:"result"`
Message string `json:"message"`
} `json:"data"`
}
// SessionCancelResponse is used to cancel a multichunk upload session
type SessionCancelResponse struct {
ResultStatus
Data bool `json:"data"`
}
File diff suppressed because it is too large. Load diff
+551
View File
@@ -0,0 +1,551 @@
// Unit tests for the kdrive backend
package kdrive
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/rclone/rclone/backend/kdrive/api"
"github.com/rclone/rclone/fs"
"github.com/rclone/rclone/fs/config/configmap"
"github.com/rclone/rclone/fs/hash"
"github.com/rclone/rclone/lib/dircache"
"github.com/rclone/rclone/lib/pacer"
"github.com/rclone/rclone/lib/rest"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestCheckChunkSize checks chunk_size validation against the API limits
func TestCheckChunkSize(t *testing.T) {
for _, test := range []struct {
in fs.SizeSuffix
wantErr string
}{
{in: 20 * fs.Mebi, wantErr: ""}, // default
{in: 1000 * 1000, wantErr: ""}, // API minimum
{in: 1000 * 1000 * 1000, wantErr: ""}, // API maximum
{in: 0, wantErr: "too small"}, // panics without validation
{in: 999 * 1000, wantErr: "too small"}, // below API minimum
{in: 1000*1000*1000 + 1, wantErr: "too large"}, // above API maximum
{in: 2 * 1000 * 1000 * 1000, wantErr: "too large"}, // way above API maximum
} {
t.Run(test.in.String(), func(t *testing.T) {
err := checkChunkSize(test.in)
if test.wantErr == "" {
assert.NoError(t, err)
} else {
require.Error(t, err)
assert.Contains(t, err.Error(), test.wantErr)
}
})
}
}
// TestOpenChunkWriterClampsCallerChunkSize checks that a caller
// supplied chunk size hint below the API minimum is raised to it
func TestOpenChunkWriterClampsCallerChunkSize(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = io.Copy(io.Discard, r.Body)
_, _ = fmt.Fprintln(w, `{"result":"success","data":{"token":"tok","upload_url":"http://unused"}}`)
}))
defer ts.Close()
f := newTestFs(t, ts.URL)
f.dirCache = dircache.New("", "1", f)
src := &mockObjectInfo{remote: "file.txt", size: 4, modTime: time.Unix(1700000000, 0)}
info, _, err := f.OpenChunkWriter(context.Background(), "file.txt", src, &fs.ChunkOption{ChunkSize: 512 * 1024})
require.NoError(t, err)
assert.Equal(t, int64(minChunkSize), info.ChunkSize, "a chunk size hint below the API minimum must be raised to it")
}
// TestObjectSetMetaDataResetsStaleHash checks that setMetaData always
// replaces the hash (a chunked upload finish response carries none)
func TestObjectSetMetaDataResetsStaleHash(t *testing.T) {
for _, test := range []struct {
name string
staleHash string
staleNested bool
newHash string
wantHash string
wantIsNested bool
wantHashKnown bool
}{
{
name: "empty hash clears stale plain hash",
staleHash: "deadbeef",
staleNested: false,
newHash: "",
wantHash: "",
wantIsNested: false,
wantHashKnown: false,
},
{
name: "empty hash clears stale nested hash",
staleHash: "deadbeef",
staleNested: true,
newHash: "",
wantHash: "",
wantIsNested: false,
wantHashKnown: false,
},
{
name: "plain hash is set",
staleHash: "deadbeef",
staleNested: false,
newHash: "xxh3:12345678",
wantHash: "12345678",
wantIsNested: false,
wantHashKnown: true,
},
{
name: "nested hash is set",
staleHash: "deadbeef",
staleNested: false,
newHash: "N:xxh3:87654321",
wantHash: "87654321",
wantIsNested: true,
wantHashKnown: true,
},
} {
t.Run(test.name, func(t *testing.T) {
o := &Object{
remote: "file.txt",
hasMetaData: true,
xxh3: test.staleHash,
nestedHash: test.staleNested,
}
info := &api.Item{
ID: 42,
Name: "file.txt",
Type: "file",
Hash: test.newHash,
}
err := o.setMetaData(info)
require.NoError(t, err)
assert.Equal(t, test.wantHash, o.xxh3, "hash after setMetaData")
assert.Equal(t, test.wantIsNested, o.nestedHash, "nested flag after setMetaData")
assert.True(t, o.hasMetaData)
// Hash() must reflect the new state. Only checked when a
// hash is known: with no hash Hash() would query the API.
// Nested hashes are not comparable with plain XXH3, so
// Hash() returns "" for them.
if test.wantHashKnown {
got, gotErr := o.Hash(context.Background(), hash.XXH3)
require.NoError(t, gotErr)
if test.wantIsNested {
assert.Equal(t, "", got, "nested hash must not be returned as XXH3")
} else {
assert.Equal(t, test.wantHash, got)
}
}
})
}
}
// newTestFs creates an Fs wired to a test server with a plain client
// (no OAuth) and a fast pacer
func newTestFs(t *testing.T, endpoint string) *Fs {
t.Helper()
return &Fs{
name: "TestKdrive",
root: "",
opt: Options{DriveID: "1234", Endpoint: endpoint},
srv: rest.NewClient(&http.Client{}).SetRoot(endpoint),
pacer: fs.NewPacer(context.Background(),
pacer.NewDefault(pacer.MinSleep(time.Millisecond), pacer.MaxSleep(2*time.Millisecond), pacer.DecayConstant(2))),
}
}
// TestWriteChunkDetectsResultError checks that a 200 with result:error
// is reported as an error
func TestWriteChunkDetectsResultError(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = io.Copy(io.Discard, r.Body)
_, _ = fmt.Fprintln(w, `{"result":"error","error":{"code":"chunk_error","description":"chunk upload failed"}}`)
}))
defer ts.Close()
f := newTestFs(t, ts.URL)
u := &uploadSession{
f: f,
token: "tok",
uploadURL: ts.URL,
fileSize: 4,
chunkSize: 4,
chunkHashes: make(map[int64]string),
}
_, err := u.WriteChunk(context.Background(), 0, bytes.NewReader([]byte("test")))
require.Error(t, err, "result:error with HTTP 200 must not be treated as success")
assert.Contains(t, err.Error(), "chunk upload failed")
}
// TestWriteChunkRetriesAndRewinds checks that transient failures are
// retried with the chunk re-sent from the start of the reader
func TestWriteChunkRetriesAndRewinds(t *testing.T) {
var mu sync.Mutex
var attempts int
var bodies []string
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
mu.Lock()
attempts++
bodies = append(bodies, string(body))
current := attempts
mu.Unlock()
w.Header().Set("Content-Type", "application/json")
if current == 1 {
w.WriteHeader(http.StatusInternalServerError)
_, _ = fmt.Fprintln(w, `{"result":"error","error":{"code":"internal_error","description":"transient"}}`)
return
}
_, _ = fmt.Fprintln(w, `{"result":"success","data":{"chunk_number":1,"status":"OK"}}`)
}))
defer ts.Close()
f := newTestFs(t, ts.URL)
u := &uploadSession{
f: f,
token: "tok",
uploadURL: ts.URL,
fileSize: 4,
chunkSize: 4,
chunkHashes: make(map[int64]string),
}
written, err := u.WriteChunk(context.Background(), 0, bytes.NewReader([]byte("test")))
require.NoError(t, err)
assert.Equal(t, int64(4), written)
mu.Lock()
defer mu.Unlock()
require.Len(t, bodies, 2, "chunk upload must be retried once after a 5xx")
assert.Equal(t, "test", bodies[0], "first attempt sends the chunk")
assert.Equal(t, "test", bodies[1], "retry must re-send the chunk from the start of the reader")
}
// kdriveMockItem returns a JSON body for a single item result
func kdriveMockItem(id int, name string) string {
return fmt.Sprintf(`{"result":"success","data":{"id":%d,"name":%q,"type":"file","path":%q,"last_modified_at":1700000000}}`, id, name, "/"+name)
}
// mockObjectInfo is a minimal fs.ObjectInfo for testing
type mockObjectInfo struct {
remote string
size int64
modTime time.Time
hash string
}
func (m *mockObjectInfo) Fs() fs.Info { return nil }
func (m *mockObjectInfo) String() string { return m.remote }
func (m *mockObjectInfo) Remote() string { return m.remote }
func (m *mockObjectInfo) Size() int64 { return m.size }
func (m *mockObjectInfo) ModTime(_ context.Context) time.Time { return m.modTime }
func (m *mockObjectInfo) Storable() bool { return true }
func (m *mockObjectInfo) MimeType(_ context.Context) string { return "application/octet-stream" }
func (m *mockObjectInfo) Hash(_ context.Context, t hash.Type) (string, error) {
if m.hash == "" || t != hash.XXH3 {
return "", hash.ErrUnsupported
}
return m.hash, nil
}
// TestUpdateDirectNoRetryOnServerError checks that a failed single
// part upload is not retried here: the input must not be buffered, the
// higher layers retry the whole transfer
func TestUpdateDirectNoRetryOnServerError(t *testing.T) {
var attempts int32
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt32(&attempts, 1)
w.Header().Set("Content-Type", "application/json")
_, _ = io.Copy(io.Discard, r.Body)
w.WriteHeader(http.StatusInternalServerError)
_, _ = fmt.Fprintln(w, `{"result":"error","error":{"code":"internal_error","description":"transient"}}`)
}))
defer ts.Close()
f := newTestFs(t, ts.URL)
o := &Object{fs: f, remote: "file.txt"}
src := &mockObjectInfo{remote: "file.txt", size: 4, modTime: time.Unix(1700000000, 0), hash: "12345678"}
err := o.updateDirect(context.Background(), bytes.NewReader([]byte("test")), "1", "file.txt", src)
require.Error(t, err)
assert.Equal(t, int32(1), atomic.LoadInt32(&attempts), "single part uploads must not be retried here")
}
// TestUpdateDirect checks the upload parameters and metadata of a
// successful single part upload, and that total_chunk_hash is only
// sent when the source hash is known
func TestUpdateDirect(t *testing.T) {
for _, test := range []struct {
name string
srcHash string
wantTotalHash string
}{
{name: "hash known", srcHash: "12345678", wantTotalHash: "xxh3:12345678"},
{name: "hash unknown", srcHash: "", wantTotalHash: ""},
} {
t.Run(test.name, func(t *testing.T) {
var gotTotalHash, gotBody string
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
gotTotalHash = r.URL.Query().Get("total_chunk_hash")
body, _ := io.ReadAll(r.Body)
gotBody = string(body)
_, _ = fmt.Fprintln(w, `{"result":"success","data":{"id":55,"name":"file.txt","type":"file","path":"/file.txt","hash":"xxh3:99998888","last_modified_at":1700000000}}`)
}))
defer ts.Close()
f := newTestFs(t, ts.URL)
o := &Object{fs: f, remote: "file.txt"}
src := &mockObjectInfo{remote: "file.txt", size: 4, modTime: time.Unix(1700000000, 0), hash: test.srcHash}
err := o.updateDirect(context.Background(), bytes.NewReader([]byte("test")), "1", "file.txt", src)
require.NoError(t, err)
assert.Equal(t, "test", gotBody, "the content must be streamed as-is")
assert.Equal(t, test.wantTotalHash, gotTotalHash, "total_chunk_hash")
assert.Equal(t, "55", o.id)
gotHash, hashErr := o.Hash(context.Background(), hash.XXH3)
require.NoError(t, hashErr)
assert.Equal(t, "99998888", gotHash)
})
}
}
// TestNewFsRequiresDriveID checks that NewFs fails fast when drive_id
// is missing
func TestNewFsRequiresDriveID(t *testing.T) {
m := configmap.Simple{"chunk_size": "20Mi"}
_, err := NewFs(context.Background(), "kdrive", "", m)
require.Error(t, err)
assert.Contains(t, err.Error(), "drive_id")
}
// TestUploadSessionStringMasksToken checks that the upload session
// String does not leak the session token in debug logs
func TestUploadSessionStringMasksToken(t *testing.T) {
u := &uploadSession{token: "very-secret-uuid"}
assert.NotContains(t, u.String(), "very-secret-uuid")
}
// TestListNonRecursiveSingleRoundTrip checks that a non recursive
// listing needs a single API request: without depth=unlimited the API
// returns direct children only, so neither the parent item lookup nor
// a child filter is needed
func TestListNonRecursiveSingleRoundTrip(t *testing.T) {
var requests int32
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt32(&requests, 1)
w.Header().Set("Content-Type", "application/json")
_, _ = io.Copy(io.Discard, r.Body)
switch {
case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/files/1/files"):
// Direct children only, as returned without depth=unlimited
_, _ = fmt.Fprintln(w, `{"result":"success","data":[`+
`{"id":10,"name":"sub","type":"dir","path":"/sub"},`+
`{"id":11,"name":"file.txt","type":"file","path":"/file.txt","hash":"xxh3:12345678","last_modified_at":1700000000}],"has_more":false}`)
default:
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
_, _ = fmt.Fprintln(w, `{"result":"error","error":{"code":"unexpected","description":"unexpected request"}}`)
}
}))
defer ts.Close()
f := newTestFs(t, ts.URL)
f.dirCache = dircache.New("", "1", f)
entries, err := f.List(context.Background(), "")
require.NoError(t, err)
assert.Equal(t, int32(1), atomic.LoadInt32(&requests), "non recursive listing must make a single API request")
names := make([]string, 0, len(entries))
for _, entry := range entries {
names = append(names, entry.Remote())
}
assert.ElementsMatch(t, []string{"sub", "file.txt"}, names)
}
// TestMoveConflictReturnsError checks that moving onto an occupied
// destination reports the API conflict instead of resolving it: rclone
// removes the destination itself before calling Move
func TestMoveConflictReturnsError(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = io.Copy(io.Discard, r.Body)
switch {
case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/files/111/move/1"):
_, _ = fmt.Fprintln(w, `{"result":"error","error":{"code":"conflict_error","description":"destination already exists"}}`)
default:
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
_, _ = fmt.Fprintln(w, `{"result":"error","error":{"code":"unexpected","description":"unexpected request"}}`)
}
}))
defer ts.Close()
f := newTestFs(t, ts.URL)
f.dirCache = dircache.New("", "1", f)
srcObj := &Object{fs: f, remote: "src.txt", id: "111", hasMetaData: true}
_, err := f.Move(context.Background(), srcObj, "file2")
require.Error(t, err, "a move onto an occupied destination must fail")
assert.Contains(t, err.Error(), "conflict", "the API conflict must be reported")
}
// TestMoveDetectsForeignObjectAtDestination checks that Move does not
// return a file re-created by another writer at the destination
func TestMoveDetectsForeignObjectAtDestination(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = io.Copy(io.Discard, r.Body)
switch {
case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/files/111/move/1"):
// The move API reports success...
_, _ = fmt.Fprintln(w, `{"result":"success","data":{"cancel_id":"abc","valid_until":1700000000}}`)
case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/files/1/name"):
// ...but the file at the destination is not the moved
// file: another writer re-created it
_, _ = fmt.Fprintln(w, kdriveMockItem(999, "file2"))
default:
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
_, _ = fmt.Fprintln(w, `{"result":"error","error":{"code":"unexpected","description":"unexpected request"}}`)
}
}))
defer ts.Close()
f := newTestFs(t, ts.URL)
f.dirCache = dircache.New("", "1", f)
srcObj := &Object{fs: f, remote: "src.txt", id: "111", hasMetaData: true}
_, err := f.Move(context.Background(), srcObj, "file2")
require.Error(t, err, "Move must not report success when the destination is not the moved file")
}
// TestMoveSuccess checks the nominal move: the object returned is the
// moved file (same ID as the source).
func TestMoveSuccess(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = io.Copy(io.Discard, r.Body)
switch {
case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/files/111/move/1"):
_, _ = fmt.Fprintln(w, `{"result":"success","data":{"cancel_id":"abc","valid_until":1700000000}}`)
case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/files/1/name"):
// After the move, the moved file is at the destination
_, _ = fmt.Fprintln(w, kdriveMockItem(111, "file2"))
default:
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
_, _ = fmt.Fprintln(w, `{"result":"error","error":{"code":"unexpected","description":"unexpected request"}}`)
}
}))
defer ts.Close()
f := newTestFs(t, ts.URL)
f.dirCache = dircache.New("", "1", f)
srcObj := &Object{fs: f, remote: "src.txt", id: "111", hasMetaData: true}
dstObj, err := f.Move(context.Background(), srcObj, "file2")
require.NoError(t, err)
moved, ok := dstObj.(*Object)
require.True(t, ok)
assert.Equal(t, "111", moved.id, "the returned object must be the moved file")
}
// TestFormatModTime checks that pre-1970 times format as negative
// unix timestamps instead of wrapping through a uint64 cast
func TestFormatModTime(t *testing.T) {
for _, test := range []struct {
in time.Time
want string
}{
{in: time.Unix(1700000000, 0), want: "1700000000"},
{in: time.Unix(0, 0), want: "0"},
{in: time.Unix(-1577923200, 0), want: "-1577923200"}, // Sat Jan 2 1965
} {
t.Run(test.want, func(t *testing.T) {
assert.Equal(t, test.want, formatModTime(test.in))
})
}
}
// TestObjectSetMetaDataIsDir checks that directories are rejected
func TestObjectSetMetaDataIsDir(t *testing.T) {
o := &Object{remote: "dir"}
info := &api.Item{ID: 42, Name: "dir", Type: "dir"}
err := o.setMetaData(info)
assert.Equal(t, fs.ErrorIsDir, err)
}
// TestShouldRetryLockError checks that transient locks held by server
// side copies are retried while locks held by applications and other
// errors are not
func TestShouldRetryLockError(t *testing.T) {
for _, test := range []struct {
name string
body string
status int
wantRetry bool
}{
{
name: "lock held by internal copy",
body: `{"result":"error","error":{"code":"lock_error","description":"There is a lock on this file, locked by internal_copy","context":{"values":["internal_copy"]}}}`,
status: http.StatusForbidden,
wantRetry: true,
},
{
name: "lock held by an application",
body: `{"result":"error","error":{"code":"lock_error","description":"There is a lock on this file, locked by onlyoffice","context":{"values":["onlyoffice"]}}}`,
status: http.StatusForbidden,
wantRetry: false,
},
{
name: "lock without context",
body: `{"result":"error","error":{"code":"lock_error","description":"There is a lock on this file, locked by internal_copy"}}`,
status: http.StatusForbidden,
wantRetry: false,
},
{
name: "other api error",
body: `{"result":"error","error":{"code":"object_not_found","description":"File not found"}}`,
status: http.StatusNotFound,
wantRetry: false,
},
{
name: "server error",
status: http.StatusServiceUnavailable,
wantRetry: true,
},
} {
t.Run(test.name, func(t *testing.T) {
var apiErr error
if test.body != "" {
e := new(api.ResultStatus)
require.NoError(t, json.Unmarshal([]byte(test.body), e))
apiErr = e
}
resp := &http.Response{StatusCode: test.status, Header: http.Header{}}
got, gotErr := shouldRetry(context.Background(), resp, apiErr)
assert.Equal(t, test.wantRetry, got)
assert.Equal(t, apiErr, gotErr)
})
}
}
+25
View File
@@ -0,0 +1,25 @@
// Test kdrive filesystem interface
package kdrive
import (
"testing"
"github.com/rclone/rclone/fs"
"github.com/rclone/rclone/fstest/fstests"
)
// TestIntegration runs integration tests against the remote
func TestIntegration(t *testing.T) {
fstests.Run(t, &fstests.Opt{
RemoteName: "TestKdrive:",
NilObject: (*Object)(nil),
ChunkedUpload: fstests.ChunkedUploadConfig{
MinChunkSize: minChunkSize,
MaxChunkSize: maxChunkSize,
},
})
}
func (f *Fs) SetUploadChunkSize(cs fs.SizeSuffix) (fs.SizeSuffix, error) {
return f.setChunkSize(cs)
}
+67
View File
@@ -0,0 +1,67 @@
// Package khash provides the kDrive specific hash handling.
//
// kDrive uses XXH3-64 for file hashing. Files uploaded in chunks (by
// rclone, or by the kDrive apps which each use their own chunk size)
// get a "nested" hash: the XXH3 of the concatenated lowercase hex
// strings of the individual chunk hashes. Such a nested hash can't be
// compared with a plain XXH3 of the file contents.
//
// Hash formats returned by the kDrive API:
// - "xxh3:HASH" for a plain XXH3 of the file contents
// - "N:xxh3:HASH" for the nested hash of a chunked upload
package khash
import (
"encoding/hex"
"errors"
"fmt"
"strings"
"github.com/zeebo/xxh3"
)
// ParseHash parses a kDrive hash string from the API and returns the hex hash value
// along with whether it is a nested (chunked) hash.
func ParseHash(hashStr string) (hexHash string, isNested bool, err error) {
if hashStr == "" {
return "", false, nil
}
// Check for nested hash format: "N:xxh3:HASH"
if strings.HasPrefix(hashStr, "N:xxh3:") {
hashStr = strings.TrimPrefix(hashStr, "N:xxh3:")
isNested = true
} else {
hashStr = strings.TrimPrefix(hashStr, "xxh3:")
}
// Validate hex string
if _, decodeErr := hex.DecodeString(hashStr); decodeErr != nil {
return "", false, fmt.Errorf("invalid hash format: %w", decodeErr)
}
return hashStr, isNested, nil
}
// NestedChunkHash computes the kDrive nested hash from the hashes of the
// file chunks, in order.
//
// kDrive stores the nested hash of a chunked file as the XXH3 of the
// concatenated lowercase hex strings of the individual chunk hashes.
// Each chunk hash may be passed with or without the "xxh3:" prefix.
func NestedChunkHash(chunkHashes []string) (string, error) {
if len(chunkHashes) == 0 {
return "", errors.New("no chunk hashes to nest")
}
hexHashes := make([]string, len(chunkHashes))
for i, chunkHash := range chunkHashes {
hexHash, _, err := ParseHash(chunkHash)
if err != nil {
return "", fmt.Errorf("invalid chunk hash %q: %w", chunkHash, err)
}
hexHashes[i] = hexHash
}
hasher := xxh3.New()
_, _ = hasher.Write([]byte(strings.Join(hexHashes, "")))
return hex.EncodeToString(hasher.Sum(nil)), nil
}
+150
View File
@@ -0,0 +1,150 @@
package khash
import "testing"
func TestParseHash(t *testing.T) {
tests := []struct {
name string
input string
expectedHash string
isNested bool
expectError bool
}{
{
name: "simple hash with prefix",
input: "xxh3:1db797b96febd334",
expectedHash: "1db797b96febd334",
isNested: false,
},
{
name: "nested hash",
input: "N:xxh3:877abf3579f0a5c0",
expectedHash: "877abf3579f0a5c0",
isNested: true,
},
{
name: "plain hex",
input: "1db797b96febd334",
expectedHash: "1db797b96febd334",
isNested: false,
},
{
name: "empty string",
input: "",
expectedHash: "",
isNested: false,
},
{
name: "invalid hex",
input: "xxh3:notahexvalue",
expectError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
hash, isNested, err := ParseHash(tt.input)
if tt.expectError {
if err == nil {
t.Error("Expected error but got none")
}
return
}
if err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if hash != tt.expectedHash {
t.Errorf("Hash: got %q, expected %q", hash, tt.expectedHash)
}
if isNested != tt.isNested {
t.Errorf("IsNested: got %t, expected %t", isNested, tt.isNested)
}
})
}
}
func TestNestedChunkHash(t *testing.T) {
// Golden values: the nested hash is the XXH3 of the concatenated
// lowercase hex strings of the chunk hashes, in order
chunk1 := "xxh3:d447b1ea40e6988b" // XXH3 of "hello world"
chunk2 := "2d06800538d394c2" // XXH3 of ""
tests := []struct {
name string
chunkHashes []string
expected string
expectError bool
}{
{
name: "two chunks in order",
chunkHashes: []string{chunk1, chunk2},
expected: "6d51edccc9c05a8b",
},
{
name: "order matters",
chunkHashes: []string{chunk2, chunk1},
expected: "596c127c7bcc8820",
},
{
name: "single chunk",
chunkHashes: []string{chunk1},
expected: "b73081dff8fe443c",
},
{
name: "mixed prefixes",
chunkHashes: []string{chunk1, "xxh3:" + chunk2, chunk1},
expected: "ca0b13abcdcd1661",
},
{
name: "no chunks",
chunkHashes: []string{},
expectError: true,
},
{
name: "invalid chunk hash",
chunkHashes: []string{chunk1, "notahexvalue"},
expectError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
nested, err := NestedChunkHash(tt.chunkHashes)
if tt.expectError {
if err == nil {
t.Error("Expected error but got none")
}
return
}
if err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if nested != tt.expected {
t.Errorf("NestedChunkHash: got %q, expected %q", nested, tt.expected)
}
})
}
}
func TestNestedChunkHashAcceptsPrefixedHashes(t *testing.T) {
plain := []string{"d447b1ea40e6988b", "2d06800538d394c2"}
prefixed := []string{"xxh3:d447b1ea40e6988b", "N:xxh3:2d06800538d394c2"}
gotPlain, err := NestedChunkHash(plain)
if err != nil {
t.Fatalf("Unexpected error: %v", err)
}
gotPrefixed, err := NestedChunkHash(prefixed)
if err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if gotPlain != gotPrefixed {
t.Errorf("Prefixed hashes should give the same nested hash: %q != %q", gotPlain, gotPrefixed)
}
}
+383
View File
@@ -0,0 +1,383 @@
package kdrive
import (
"context"
"encoding/hex"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"path"
"strconv"
"strings"
"sync"
"time"
"github.com/rclone/rclone/backend/kdrive/api"
"github.com/rclone/rclone/backend/kdrive/khash"
"github.com/rclone/rclone/fs"
"github.com/rclone/rclone/fs/chunksize"
"github.com/rclone/rclone/lib/rest"
"github.com/zeebo/xxh3"
"golang.org/x/text/unicode/norm"
)
const (
// maxUploadParts is the maximum number of chunks the kDrive API accepts per file
maxUploadParts = 10000
// maxChunkSize is the largest chunk size the kDrive API accepts
maxChunkSize = 1 * 1000 * 1000 * 1000
// minChunkSize is the smallest chunk size the kDrive API accepts
minChunkSize = 1 * 1000 * 1000
)
// checkChunkSize checks the user supplied chunk_size against the
// limits the kDrive API accepts
func checkChunkSize(chunkSize fs.SizeSuffix) error {
if chunkSize < minChunkSize {
return fmt.Errorf("chunk_size %s is too small: the kDrive API requires chunks of at least 1 MB", chunkSize)
}
if chunkSize > maxChunkSize {
return fmt.Errorf("chunk_size %s is too large: the kDrive API accepts chunks of at most 1 GB (1000000000 bytes)", chunkSize)
}
return nil
}
// setChunkSize sets the chunk size, returning the old value
func (f *Fs) setChunkSize(cs fs.SizeSuffix) (old fs.SizeSuffix, err error) {
err = checkChunkSize(cs)
if err == nil {
old, f.opt.ChunkSize = f.opt.ChunkSize, cs
}
return
}
// formatModTime formats the modification time of an upload as the
// unix timestamp string the kDrive API expects
func formatModTime(modTime time.Time) string {
return fmt.Sprintf("%d", modTime.Unix())
}
// uploadSession implements fs.ChunkWriter for kdrive multipart uploads
type uploadSession struct {
f *Fs
token string
uploadURL string
fileInfo *api.Item
fileSize int64 // total size of the file being uploaded
chunkSize int64 // size of the chunks of this session
mu sync.Mutex
chunkHashes map[int64]string // chunk number -> hash of the chunk contents
}
// OpenChunkWriter returns the chunk size and a ChunkWriter
//
// Pass in the remote and the src object
// You can also use options to hint at the desired chunk size
// @see https://developer.infomaniak.com/docs/api/post/3/drive/%7Bdrive_id%7D/upload/session/start
func (f *Fs) OpenChunkWriter(ctx context.Context, remote string, src fs.ObjectInfo, options ...fs.OpenOption) (info fs.ChunkWriterInfo, writer fs.ChunkWriter, err error) {
fileSize := src.Size()
if fileSize < 0 {
return info, nil, errors.New("kdrive can't upload files with unknown size")
}
dir, leaf := path.Split(remote)
dir = strings.TrimSuffix(dir, "/")
// Create parent directories if they don't exist
parentID, err := f.dirCache.FindDir(ctx, dir, true)
if err != nil {
return info, nil, fmt.Errorf("failed to find parent directory: %w", err)
}
// Calculate the chunk size, honouring any fs.ChunkOption hint
chunkSize := f.opt.ChunkSize
for _, opt := range options {
if chunkOpt, ok := opt.(*fs.ChunkOption); ok && chunkOpt.ChunkSize > 0 {
chunkSize = fs.SizeSuffix(chunkOpt.ChunkSize)
}
}
chunkSize = chunksize.Calculator(src, fileSize, maxUploadParts, chunkSize)
if chunkSize < minChunkSize {
chunkSize = minChunkSize
}
if chunkSize > maxChunkSize {
chunkSize = maxChunkSize
}
// Calculate the total number of chunks
totalChunks := max((fileSize+int64(chunkSize)-1)/int64(chunkSize), 1)
sessionReq := struct {
Conflict string `json:"conflict"`
DirectoryID string `json:"directory_id"`
FileName string `json:"file_name"`
LastModifiedAt string `json:"last_modified_at"`
TotalChunks int64 `json:"total_chunks"`
TotalSize int64 `json:"total_size"`
}{
Conflict: "version",
DirectoryID: parentID,
FileName: f.opt.Enc.FromStandardName(norm.NFC.String(leaf)),
LastModifiedAt: formatModTime(src.ModTime(ctx)),
TotalChunks: totalChunks,
TotalSize: fileSize,
}
// https://developer.infomaniak.com/docs/api/post/3/drive/%7Bdrive_id%7D/upload/session/start
opts := rest.Opts{
Method: "POST",
Path: fmt.Sprintf("/3/drive/%s/upload/session/start", f.opt.DriveID),
}
var sessionResp api.SessionStartResponse
var resp *http.Response
err = f.pacer.Call(func() (bool, error) {
var callErr error
resp, callErr = f.srv.CallJSON(ctx, &opts, &sessionReq, &sessionResp)
callErr = sessionResp.ResultStatus.Update(callErr)
return shouldRetry(ctx, resp, callErr)
})
if err != nil {
return info, nil, fmt.Errorf("failed to start upload session: %w", err)
}
info = fs.ChunkWriterInfo{
ChunkSize: int64(chunkSize),
Concurrency: f.opt.UploadConcurrency,
}
writer = &uploadSession{
f: f,
token: sessionResp.Data.Token,
uploadURL: sessionResp.Data.UploadURL,
fileSize: fileSize,
chunkSize: int64(chunkSize),
chunkHashes: make(map[int64]string),
}
fs.Debugf(f, "open chunk writer: started upload session for %d chunks of %v", totalChunks, chunkSize)
return info, writer, nil
}
// WriteChunk uploads a single chunk
// @see https://developer.infomaniak.com/docs/api/post/3/drive/%7Bdrive_id%7D/upload/session/%7Bsession_token%7D/chunk
func (u *uploadSession) WriteChunk(ctx context.Context, chunkNumber int, reader io.ReadSeeker) (bytesWritten int64, err error) {
if chunkNumber < 0 {
return -1, fmt.Errorf("invalid chunk number provided: %v", chunkNumber)
}
// Calculate the size of this chunk from the session parameters so
// the chunk can be streamed to the API in a single pass
chunkLen := u.chunkSize
if remaining := u.fileSize - int64(chunkNumber)*u.chunkSize; remaining < chunkLen {
chunkLen = remaining
}
if chunkLen <= 0 {
return 0, nil
}
sourceChunkNumber := chunkNumber + 1 // kDrive API uses 1-based numbering
// The chunk_hash parameter is optional, so the chunk is streamed
// in a single pass, hashing the contents on the way with a
// TeeReader. The server returns the hash of the chunk it received,
// which is checked below. Retries seek back to the chunk start
// and hash with a fresh hasher.
var chunkHash string
err = u.f.pacer.Call(func() (bool, error) {
if _, seekErr := reader.Seek(0, io.SeekStart); seekErr != nil {
return false, fmt.Errorf("failed to rewind chunk %d: %w", sourceChunkNumber, seekErr)
}
chunkHasher := xxh3.New()
// https://developer.infomaniak.com/docs/api/post/3/drive/%7Bdrive_id%7D/upload/session/%7Bsession_token%7D/chunk
chunkOpts := rest.Opts{
Method: "POST",
RootURL: u.uploadURL,
Path: fmt.Sprintf("/3/drive/%s/upload/session/%s/chunk", u.f.opt.DriveID, u.token),
Parameters: url.Values{
"chunk_number": {fmt.Sprintf("%d", sourceChunkNumber)},
"chunk_size": {fmt.Sprintf("%d", chunkLen)},
"with": {"hash"},
},
Body: io.TeeReader(reader, chunkHasher),
ContentLength: &chunkLen,
}
var chunkResp api.ChunkUploadResponse
resp, callErr := u.f.srv.CallJSON(ctx, &chunkOpts, nil, &chunkResp)
callErr = chunkResp.ResultStatus.Update(callErr)
if callErr != nil {
return shouldRetry(ctx, resp, callErr)
}
chunkHash = hex.EncodeToString(chunkHasher.Sum(nil))
// The server hashed the chunk it received, so compare with what we
// sent to detect any corruption immediately
if chunkResp.Data.Hash != "" {
if serverHash, _, parseErr := khash.ParseHash(chunkResp.Data.Hash); parseErr == nil {
if !strings.EqualFold(chunkHash, serverHash) {
return false, fmt.Errorf("chunk %d hash mismatch: client=%s, server=%s", sourceChunkNumber, chunkHash, serverHash)
}
} else {
fs.Debugf(u, "Failed to parse server chunk hash %q: %v", chunkResp.Data.Hash, parseErr)
}
}
return false, nil
})
if err != nil {
return -1, err
}
u.mu.Lock()
u.chunkHashes[int64(chunkNumber)] = chunkHash
u.mu.Unlock()
fs.Debugf(u, "uploaded chunk %d (size: %d, hash: %s)", sourceChunkNumber, chunkLen, chunkHash)
return chunkLen, nil
}
// Close finalizes the upload session, verifies the hash of the uploaded
// file and returns the created file info
// @see https://developer.infomaniak.com/docs/api/post/3/drive/%7Bdrive_id%7D/upload/session/%7Bsession_token%7D/finish
func (u *uploadSession) Close(ctx context.Context) error {
// https://developer.infomaniak.com/docs/api/post/3/drive/%7Bdrive_id%7D/upload/session/%7Bsession_token%7D/finish
opts := rest.Opts{
Method: "POST",
Path: fmt.Sprintf("/3/drive/%s/upload/session/%s/finish", u.f.opt.DriveID, u.token),
}
var resp *http.Response
var finishResp api.SessionFinishResponse
err := u.f.pacer.Call(func() (bool, error) {
var callErr error
resp, callErr = u.f.srv.CallJSON(ctx, &opts, nil, &finishResp)
callErr = finishResp.ResultStatus.Update(callErr)
return shouldRetry(ctx, resp, callErr)
})
if err != nil {
return fmt.Errorf("failed to finish upload session: %w", err)
}
u.fileInfo = &finishResp.Data.File
fs.Debugf(u, "multipart upload completed: file id %d", finishResp.Data.File.ID)
return u.verifyHash(ctx)
}
// verifyHash checks the hash of the uploaded file against the hashes of
// the chunks we uploaded, deleting the file if it is corrupted
func (u *uploadSession) verifyHash(ctx context.Context) (err error) {
remoteHash := u.fileInfo.Hash
if remoteHash == "" {
// Hash might be missing in the finish response, try to fetch it
obj := &Object{
fs: u.f,
id: strconv.Itoa(u.fileInfo.ID),
}
var hashErr error
if remoteHash, hashErr = obj.retrieveHash(ctx); hashErr != nil {
// skip verification
fs.Debugf(u, "Failed to retrieve hash for verification: %v", hashErr)
return nil
}
}
parsedRemoteHash, isNested, err := khash.ParseHash(remoteHash)
if err != nil {
fs.Debugf(u, "Failed to parse server hash %q: %v", remoteHash, err)
return nil
}
u.mu.Lock()
defer u.mu.Unlock()
// Rebuild the hash the server should have stored from the chunk
// hashes we uploaded, in order
chunkCount := int64(len(u.chunkHashes))
if chunkCount == 0 {
return nil
}
chunkHashes := make([]string, 0, chunkCount)
for i := int64(0); i < chunkCount; i++ {
chunkHash, ok := u.chunkHashes[i]
if !ok {
fs.Debugf(u, "Chunk %d hash missing, skipping hash verification", i)
return nil
}
chunkHashes = append(chunkHashes, chunkHash)
}
var localHash string
if isNested {
if localHash, err = khash.NestedChunkHash(chunkHashes); err != nil {
fs.Debugf(u, "Failed to compute nested hash: %v", err)
return nil
}
} else if chunkCount == 1 {
localHash, _, _ = khash.ParseHash(chunkHashes[0])
} else {
// The server returned a simple hash for a multi chunk upload,
// nothing to compare it against
fs.Debugf(u, "Server returned a simple hash for a %d chunk upload, skipping hash verification", chunkCount)
return nil
}
if !strings.EqualFold(localHash, parsedRemoteHash) {
err = fmt.Errorf(
"multipart upload hash mismatch: local=%s, remote=%s",
localHash, parsedRemoteHash,
)
// Remove the corrupted file
obj := &Object{
fs: u.f,
id: strconv.Itoa(u.fileInfo.ID),
}
if delErr := obj.Remove(ctx); delErr != nil {
fs.Errorf(nil, "Failed to remove corrupted file after hash mismatch: %v", delErr)
} else {
fs.Debugf(nil, "Removed corrupted file after hash mismatch")
}
return err
}
fs.Debugf(u, "Multipart upload hash verified: %s", localHash)
return nil
}
// Abort the upload session
// @see https://developer.infomaniak.com/docs/api/delete/2/drive/%7Bdrive_id%7D/upload/session/%7Bsession_token%7D
func (u *uploadSession) Abort(ctx context.Context) error {
// https://developer.infomaniak.com/docs/api/delete/2/drive/%7Bdrive_id%7D/upload/session/%7Bsession_token%7D
opts := rest.Opts{
Method: "DELETE",
Path: fmt.Sprintf("/2/drive/%s/upload/session/%s", u.f.opt.DriveID, u.token),
}
var resp *http.Response
var cancelResp api.SessionCancelResponse
err := u.f.pacer.Call(func() (bool, error) {
var callErr error
resp, callErr = u.f.srv.CallJSON(ctx, &opts, nil, &cancelResp)
callErr = cancelResp.ResultStatus.Update(callErr)
return shouldRetry(ctx, resp, callErr)
})
if err != nil {
return fmt.Errorf("failed to cancel upload session: %w", err)
}
fs.Debugf(u, "upload session cancelled")
return nil
}
// String implements fmt.Stringer
func (u *uploadSession) String() string {
return "kdrive upload session"
}
// Verify interface compliance
var _ fs.ChunkWriter = (*uploadSession)(nil)
+24 -1
View File
@@ -6,11 +6,13 @@ package local
import (
"context"
"fmt"
"os"
"path/filepath"
"runtime"
"github.com/go-darwin/apfs"
"github.com/rclone/rclone/fs"
"github.com/rclone/rclone/lib/random"
)
// Copy src to this remote using server-side copy operations.
@@ -63,7 +65,7 @@ func (f *Fs) Copy(ctx context.Context, src fs.Object, remote string) (fs.Object,
if err != nil {
return nil, err
}
err = Clone(srcPath, dstPath)
err = cloneOver(srcPath, dstPath)
if err != nil {
return nil, err
}
@@ -79,6 +81,27 @@ func (f *Fs) Copy(ctx context.Context, src fs.Object, remote string) (fs.Object,
return f.NewObject(ctx, remote)
}
// cloneOver clones src to dst, replacing dst if it already exists.
//
// COPYFILE_CLONE implies COPYFILE_EXCL so Clone fails if dst exists.
// Cloning to a temporary name in the same directory and renaming it
// over dst avoids this and leaves dst untouched if the clone fails.
func cloneOver(src, dst string) error {
if _, err := os.Lstat(dst); os.IsNotExist(err) {
return Clone(src, dst)
}
tmp := filepath.Join(filepath.Dir(dst), "."+random.String(16)+".rclone-clone")
err := Clone(src, tmp)
if err == nil {
err = os.Rename(tmp, dst)
}
if err != nil {
_ = os.Remove(tmp)
return fmt.Errorf("failed to clone %q to %q: %w", src, dst, err)
}
return nil
}
// Clone uses APFS cloning if possible, otherwise falls back to copying (with full metadata preservation)
// note that this is closely related to unix.Clonefile(src, dst, unix.CLONE_NOFOLLOW) but not 100% identical
// https://opensource.apple.com/source/copyfile/copyfile-173.40.2/copyfile.c.auto.html
+76 -17
View File
@@ -87,8 +87,10 @@ Metadata is supported on files and directories.
When restoring metadata with ` + "`--metadata`" + ` rclone applies the
"mode", "uid" and "gid" from the source. These come from the source
remote which may not be trusted, so restoring metadata as root from an
untrusted source can change file ownership and is not recommended. The
setuid, setgid and sticky bits are not restored by default - see the
untrusted source can change file ownership and is not recommended. A
user other than root keeps its own uid and sets the gid only to a group
it is a member of, as rsync does. The setuid, setgid and sticky bits are
not restored by default - see the
` + "`--local-metadata-restore-special-bits`" + ` flag.
`,
},
@@ -252,6 +254,13 @@ deduplicated by default (i.e. having two identical files does not consume more
storage than having just one.) However, for use cases where data redundancy is
preferable, --local-no-clone can be used to disable cloning and force "deep" copies.
Cloning also preserves macOS-specific metadata such as extended attributes,
which a normal copy does not, so this metadata is lost when cloning is disabled.
If the destination file already exists, cloning replaces it with a new file,
even with --inplace, so hard links to it are broken. Use --local-no-clone to
update existing files in place.
Currently, cloning is only supported when using APFS on macOS (support for other
platforms may be added in the future.)`,
Default: false,
@@ -951,13 +960,13 @@ func (f *Fs) MkdirMetadata(ctx context.Context, dir string, metadata fs.Metadata
if err != nil {
return nil, err
}
fi, err := f.lstat(localPath)
fi, err := f.lstatRoot(localPath)
if errors.Is(err, os.ErrNotExist) {
err := f.Mkdir(ctx, dir)
if err != nil {
return nil, fmt.Errorf("mkdir metadata: failed make directory: %w", err)
}
fi, err = f.lstat(localPath)
fi, err = f.lstatRoot(localPath)
if err != nil {
return nil, fmt.Errorf("mkdir metadata: failed to read info: %w", err)
}
@@ -989,20 +998,34 @@ func (f *Fs) MkdirMetadata(ctx context.Context, dir string, metadata fs.Metadata
// Rmdir removes the directory
//
// If it isn't empty it will return an error
func (f *Fs) Rmdir(ctx context.Context, dir string) error {
func (f *Fs) Rmdir(ctx context.Context, dir string) (err error) {
localPath, err := f.localPath(dir)
if err != nil {
return err
}
if fi, err := os.Stat(localPath); err != nil {
// When translating symlinks go through os.Root so a symlink planted at
// any parent can't redirect the removal out of the destination, and
// don't follow the leaf as a symlink is a file in this mode. The root
// itself is the trusted destination and is removed directly.
stat, removeFn, chmod := os.Stat, os.Remove, os.Chmod
if f.opt.TranslateSymlinks && localPath != f.root {
var root *os.Root
root, localPath, err = f.osRoot(localPath)
if err != nil {
return err
}
defer fs.CheckClose(root, &err)
stat, removeFn, chmod = root.Lstat, root.Remove, root.Chmod
}
if fi, err := stat(localPath); err != nil {
return err
} else if !fi.IsDir() {
return fs.ErrorIsFile
}
err = os.Remove(localPath)
err = removeFn(localPath)
if runtime.GOOS == "windows" && errors.Is(err, iofs.ErrPermission) { // https://github.com/golang/go/issues/26295
if os.Chmod(localPath, 0o600) == nil {
err = os.Remove(localPath)
if chmod(localPath, 0o600) == nil {
err = removeFn(localPath)
}
}
return err
@@ -1179,9 +1202,14 @@ func (f *Fs) DirMove(ctx context.Context, src fs.Fs, srcRemote, dstRemote string
return fs.ErrorDirExists
}
// Create parent of destination
// Create parent of destination. The root itself is the trusted
// destination so its parent, which lies outside it, is created directly.
dstParentPath := filepath.Dir(dstPath)
err = file.MkdirAll(dstParentPath, 0777)
if dstPath == f.root {
err = file.MkdirAll(dstParentPath, 0777)
} else {
err = f.mkdirAll(dstParentPath)
}
if err != nil {
return err
}
@@ -1598,10 +1626,26 @@ func (f *Fs) mkdirAll(localPath string) (err error) {
return root.MkdirAll(rel, 0777)
}
// openFile opens localPath for writing. When translating symlinks it goes
// through os.Root so a symlink planted at the path, or at any parent, is never
// followed out of the destination. The returned file is independent of the
// root, which is closed before returning.
// lstatRoot returns the info for localPath without following a symlink at
// the leaf. When translating symlinks it goes through os.Root so a symlink
// planted at any parent can't make a path outside the destination look like
// it exists, which would let its metadata be written there.
func (f *Fs) lstatRoot(localPath string) (fi os.FileInfo, err error) {
if !f.opt.TranslateSymlinks {
return f.lstat(localPath)
}
root, rel, err := f.osRoot(localPath)
if err != nil {
return nil, err
}
defer fs.CheckClose(root, &err)
return root.Lstat(rel)
}
// openFile opens localPath with the given flags and perm. When translating
// symlinks it goes through os.Root so a symlink planted at the path, or at any
// parent, is never followed out of the destination. The returned file is
// independent of the root, which is closed before returning.
func (f *Fs) openFile(localPath string, flags int, perm os.FileMode) (fi *os.File, err error) {
if !f.opt.TranslateSymlinks {
return file.OpenFile(localPath, flags, perm)
@@ -1670,6 +1714,21 @@ func (f *Fs) chtimes(localPath string, atime, mtime time.Time) (err error) {
return root.Chtimes(rel, atime, mtime)
}
// remove removes the file, symlink or empty directory at localPath. When
// translating symlinks it goes through os.Root so a symlink planted at any
// parent can't redirect the removal out of the destination.
func (f *Fs) remove(localPath string) (err error) {
if !f.opt.TranslateSymlinks {
return remove(localPath, os.Remove)
}
root, rel, err := f.osRoot(localPath)
if err != nil {
return err
}
defer fs.CheckClose(root, &err)
return remove(rel, root.Remove)
}
// mkdirAll makes all the directories needed to store the object
func (o *Object) mkdirAll() error {
return o.fs.mkdirAll(filepath.Dir(o.path))
@@ -1794,7 +1853,7 @@ func (o *Object) Update(ctx context.Context, in io.Reader, src fs.ObjectInfo, op
if err != nil {
fs.Logf(o, "Removing partially written file on error: %v", err)
if removeErr := os.Remove(o.path); removeErr != nil {
if removeErr := o.fs.remove(o.path); removeErr != nil {
fs.Errorf(o, "Failed to remove partially written file: %v", removeErr)
}
return err
@@ -1942,7 +2001,7 @@ func (o *Object) lstat() error {
// Remove an object
func (o *Object) Remove(ctx context.Context) error {
o.clearHashCache()
return remove(o.path)
return o.fs.remove(o.path)
}
// Metadata returns metadata for an object
+134
View File
@@ -99,6 +99,31 @@ func TestVerifyCopy(t *testing.T) {
assert.Error(t, err)
}
// Test server-side copy over an existing file
func TestCopyOverExisting(t *testing.T) {
ctx := context.Background()
r := fstest.NewRun(t)
doCopy := r.Flocal.Features().Copy
if doCopy == nil {
t.Skip("local backend has no server-side copy on this platform")
}
t1 := fstest.Time("2001-02-03T04:05:06.499999999Z")
t2 := fstest.Time("2002-02-03T04:05:06.499999999Z")
file1 := r.WriteFile("a.txt", "hello1", t1)
file2 := r.WriteFile("b.txt", "HELLO2 longer", t2)
src, err := r.Flocal.NewObject(ctx, file1.Path)
require.NoError(t, err)
dst, err := doCopy(ctx, src, file2.Path)
require.NoError(t, err)
assert.Equal(t, file2.Path, dst.Remote())
file2.Size = file1.Size
file2.Hashes = file1.Hashes
file2.ModTime = file1.ModTime
r.CheckLocalItems(t, file1, file2)
}
// skipIfNoSymlinks skips the test if this process can't create symlinks.
//
// Windows grants the privilege only to an elevated process or one running with
@@ -535,6 +560,115 @@ func TestDirBTimeThroughPlantedSymlinkBlocked(t *testing.T) {
require.Equal(t, before, readBTime(), "btime escaped through planted symlink to %q", evilDir)
}
// TestMkdirMetadataThroughPlantedSymlinkBlocked checks MkdirMetadata can't
// be fooled into treating a directory outside the root, reached through a
// planted symlink, as the directory to apply metadata to.
func TestMkdirMetadataThroughPlantedSymlinkBlocked(t *testing.T) {
skipIfNoSymlinks(t)
ctx := context.Background()
evil := t.TempDir()
evilDir := filepath.Join(evil, "secret.d")
require.NoError(t, os.Mkdir(evilDir, 0700))
require.NoError(t, os.Mkdir(filepath.Join(evilDir, "sub"), 0700))
r := fstest.NewRun(t)
f := r.Flocal.(*Fs)
linksMode(f)
// Control: an existing in-tree directory is found as normal.
require.NoError(t, f.Mkdir(ctx, "sub"))
_, err := f.MkdirMetadata(ctx, "sub", nil)
require.NoError(t, err)
// The planted symlink dst/pwn -> outside, faithfully reproduced by --links.
require.NoError(t, putLink(ctx, f, "pwn", evilDir))
// "pwn/sub" exists outside the root but must not be found through the
// planted symlink, and must not be created there either.
_, err = f.MkdirMetadata(ctx, "pwn/sub", nil)
require.Error(t, err, "finding a directory through a planted symlink should be refused")
_, err = f.MkdirMetadata(ctx, "pwn/new", nil)
require.Error(t, err, "making a directory through a planted symlink should be refused")
_, err = os.Lstat(filepath.Join(evilDir, "new"))
require.True(t, os.IsNotExist(err), "a directory escaped the destination into %q", evilDir)
}
// TestRemoveThroughPlantedSymlinkBlocked checks Remove and Rmdir can't
// delete anything outside the root through a planted symlink.
func TestRemoveThroughPlantedSymlinkBlocked(t *testing.T) {
if runtime.GOOS == "windows" {
// os.Root.Symlink makes a file symlink to a directory outside
// the root, which Windows can't traverse, so the planted link
// can't reach the outside files.
t.Skip("planted directory symlinks can't be traversed on Windows")
}
skipIfNoSymlinks(t)
ctx := context.Background()
evil := t.TempDir()
evilFile := filepath.Join(evil, "secret.txt")
require.NoError(t, os.WriteFile(evilFile, []byte("keep me"), 0600))
evilDir := filepath.Join(evil, "secret.d")
require.NoError(t, os.Mkdir(evilDir, 0700))
r := fstest.NewRun(t)
f := r.Flocal.(*Fs)
linksMode(f)
// Control: in-tree removals work as normal.
require.NoError(t, putFile(ctx, f, "file.txt", "data"))
o, err := f.NewObject(ctx, "file.txt")
require.NoError(t, err)
require.NoError(t, o.Remove(ctx))
require.NoError(t, f.Mkdir(ctx, "dir"))
require.NoError(t, f.Rmdir(ctx, "dir"))
// The planted symlink dst/pwn -> outside, faithfully reproduced by --links.
require.NoError(t, putLink(ctx, f, "pwn", evil))
// Removing the outside file and directory named through the planted
// symlink must be refused.
o, err = f.NewObject(ctx, "pwn/secret.txt")
require.NoError(t, err)
err = o.Remove(ctx)
require.Error(t, err, "removing through a planted symlink should be refused")
_, err = os.Lstat(evilFile)
require.NoError(t, err, "a file was removed outside the destination at %q", evilFile)
err = f.Rmdir(ctx, "pwn/secret.d")
require.Error(t, err, "removing a directory through a planted symlink should be refused")
_, err = os.Lstat(evilDir)
require.NoError(t, err, "a directory was removed outside the destination at %q", evilDir)
}
// TestDirMoveThroughPlantedSymlinkBlocked checks DirMove can't move a
// directory out of the root through a planted symlink.
func TestDirMoveThroughPlantedSymlinkBlocked(t *testing.T) {
skipIfNoSymlinks(t)
ctx := context.Background()
evil := t.TempDir()
r := fstest.NewRun(t)
f := r.Flocal.(*Fs)
linksMode(f)
// Control: an in-tree directory move works as normal.
require.NoError(t, f.Mkdir(ctx, "dir"))
require.NoError(t, f.DirMove(ctx, f, "dir", "sub/moved"))
// The planted symlink dst/pwn -> outside, faithfully reproduced by --links.
require.NoError(t, putLink(ctx, f, "pwn", evil))
err := f.DirMove(ctx, f, "sub/moved", "pwn/moved")
require.Error(t, err, "moving a directory through a planted symlink should be refused")
_, err = os.Lstat(filepath.Join(evil, "moved"))
require.True(t, os.IsNotExist(err), "a directory escaped the destination into %q", evil)
_, err = os.Lstat(filepath.Join(f.root, "sub", "moved"))
require.NoError(t, err, "the directory should still be in the destination")
}
// TestEncodingEscapeBlocked checks that a name from a malicious source can't
// be decoded into path syntax which writes outside the destination.
func TestEncodingEscapeBlocked(t *testing.T) {
+29 -1
View File
@@ -5,7 +5,9 @@ import (
"math"
"os"
"runtime"
"slices"
"strconv"
"sync"
"time"
"github.com/rclone/rclone/fs"
@@ -121,13 +123,15 @@ func (o *Object) writeMetadataToFile(m fs.Metadata) (outErr error) {
uid, hasUID := o.parseMetadataInt(m, "uid", 10)
gid, hasGID := o.parseMetadataInt(m, "gid", 10)
if hasUID {
// FIXME should read UID and GID of current user and only attempt to set it if different
if !hasGID {
gid = uid
}
if runtime.GOOS == "windows" || runtime.GOOS == "plan9" {
fs.Debugf(o, "Ignoring request to set ownership %o.%o on this OS", gid, uid)
} else if setUID, setGID := ownerToSet(uid, gid); setUID < 0 && setGID < 0 {
fs.Debugf(o, "Not setting ownership %d.%d: only root can", uid, gid)
} else {
uid, gid = setUID, setGID
if o.translatedLink {
err = os.Lchown(o.path, uid, gid)
} else {
@@ -169,3 +173,27 @@ func (o *Object) writeMetadataToFile(m fs.Metadata) (outErr error) {
// FIXME not parsing rdev yet
return outErr
}
// processGroups are the supplementary groups of this process
var processGroups = sync.OnceValue(func() []int {
groups, _ := os.Getgroups()
return groups
})
// ownerToSet returns the uid and gid to pass to chown for the wanted
// ownership: unchanged for root, else -1 (leave it as it is) for a uid
// other than our own and for a group we are not a member of, which
// only root may set.
func ownerToSet(uid, gid int) (setUID, setGID int) {
euid := os.Geteuid()
if euid <= 0 {
return uid, gid
}
if uid != euid {
uid = -1
}
if gid != os.Getegid() && !slices.Contains(processGroups(), gid) {
gid = -1
}
return uid, gid
}
+50
View File
@@ -0,0 +1,50 @@
//go:build !windows && !plan9 && !js
package local
import (
"context"
"os"
"path/filepath"
"slices"
"syscall"
"testing"
"time"
"github.com/rclone/rclone/fs"
"github.com/rclone/rclone/fstest"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestMetadataOwnershipAsNonRoot(t *testing.T) {
if os.Geteuid() == 0 {
t.Skip("needs a user other than root")
}
uid, gid := os.Geteuid(), os.Getegid()
other := uid + 1
notMember := gid + 1
for slices.Contains(processGroups(), notMember) {
notMember++
}
// only what a non-root user may set: its own uid, its groups
setUID, setGID := ownerToSet(other, notMember)
assert.Equal(t, [2]int{-1, -1}, [2]int{setUID, setGID})
setUID, setGID = ownerToSet(other, gid)
assert.Equal(t, [2]int{-1, gid}, [2]int{setUID, setGID})
setUID, setGID = ownerToSet(uid, notMember)
assert.Equal(t, [2]int{uid, -1}, [2]int{setUID, setGID})
// a file of another user (e.g. root:root) is copied with our ownership
// instead of failing with "operation not permitted"
r := fstest.NewRun(t)
r.WriteFile("owned.txt", "payload", time.Now())
f := r.Flocal.(*Fs)
obj, err := f.NewObject(context.Background(), "owned.txt")
require.NoError(t, err)
require.NoError(t, obj.(*Object).writeMetadataToFile(fs.Metadata{"uid": "0", "gid": "0"}))
fi, err := os.Stat(filepath.Join(f.root, "owned.txt"))
require.NoError(t, err)
assert.Equal(t, uint32(uid), fi.Sys().(*syscall.Stat_t).Uid)
}
+3 -5
View File
@@ -2,9 +2,7 @@
package local
import "os"
// Removes name, retrying on a sharing violation
func remove(name string) error {
return os.Remove(name)
// remove removes name with removeFn, retrying on a sharing violation
func remove(name string, removeFn func(string) error) error {
return removeFn(name)
}
+1 -1
View File
@@ -38,7 +38,7 @@ func TestRemove(t *testing.T) {
require.NoError(t, fd.Close())
})
// delete the open file
err = remove(name)
err = remove(name, os.Remove)
require.NoError(t, err)
// check it no longer exists
assert.False(t, exists())
+3 -3
View File
@@ -10,12 +10,12 @@ import (
"golang.org/x/sys/windows"
)
// Removes name, retrying on a sharing violation
func remove(name string) (err error) {
// remove removes name with removeFn, retrying on a sharing violation
func remove(name string, removeFn func(string) error) (err error) {
const maxTries = 10
var sleepTime = 1 * time.Millisecond
for i := range maxTries {
err = os.Remove(name)
err = removeFn(name)
if err == nil {
break
}
+26 -2
View File
@@ -4,6 +4,7 @@ package local
import (
"fmt"
"os"
"strings"
"syscall"
@@ -95,6 +96,11 @@ func (o *Object) setXattr(metadata fs.Metadata) (err error) {
if !xattrSupported || o.fs.xattrSupported.Load() == 0 {
return nil
}
// When translating symlinks (-l/--links) write the xattrs through a
// descriptor opened under os.Root so a symlink planted in the destination
// can't redirect the write outside it (CWE-59). A translated link is
// itself the leaf, so it keeps the non-following path based write.
var fd *os.File
for k, value := range metadata {
k = strings.ToLower(k)
if _, found := systemMetadataInfo[k]; found {
@@ -102,9 +108,27 @@ func (o *Object) setXattr(metadata fs.Metadata) (err error) {
}
k = xattrPrefix + k
v := []byte(value)
if o.fs.opt.FollowSymlinks {
switch {
case o.fs.opt.TranslateSymlinks && !o.translatedLink:
if fd == nil {
fd, err = o.fs.openFile(o.path, os.O_RDONLY, 0)
if os.IsPermission(err) {
// A write only file can't be opened for reading but its
// owner may still set xattrs on it, so try opening it for
// writing instead (which can't succeed for a directory).
if wfd, werr := o.fs.openFile(o.path, os.O_WRONLY, 0); werr == nil {
fd, err = wfd, nil
}
}
if err != nil {
return fmt.Errorf("failed to open for xattr: %w", err)
}
defer fs.CheckClose(fd, &err)
}
err = xattr.FSet(fd, k, v)
case o.fs.opt.FollowSymlinks:
err = xattr.Set(o.path, k, v)
} else {
default:
err = xattr.LSet(o.path, k, v)
}
if err != nil {
+96
View File
@@ -0,0 +1,96 @@
//go:build !openbsd && !plan9
package local
import (
"context"
"os"
"path/filepath"
"testing"
"github.com/pkg/xattr"
"github.com/rclone/rclone/fs"
"github.com/rclone/rclone/fstest"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestXattrThroughPlantedSymlinkBlocked checks user metadata (xattrs)
// can't be written outside the root through a planted symlink with
// --links, while in-tree writes still work.
func TestXattrThroughPlantedSymlinkBlocked(t *testing.T) {
if !xattrSupported {
t.Skip("xattrs not supported on this OS")
}
skipIfNoSymlinks(t)
ctx := context.Background()
r := fstest.NewRun(t)
f := r.Flocal.(*Fs)
linksMode(f)
metadata := fs.Metadata{
"pwned": "attacker-controlled-value",
}
// Control: an in-tree directory gets the xattr as normal.
_, err := f.MkdirMetadata(ctx, "sub", metadata)
require.NoError(t, err)
if f.xattrSupported.Load() == 0 {
t.Skip("xattrs not supported on this filesystem")
}
v, err := xattr.LGet(filepath.Join(f.root, "sub"), "user.pwned")
require.NoError(t, err)
assert.Equal(t, "attacker-controlled-value", string(v))
// A directory outside the destination, with a pre-existing child,
// whose xattrs the attacker targets.
evil := t.TempDir()
evilDir := filepath.Join(evil, "secret.d")
require.NoError(t, os.Mkdir(evilDir, 0700))
evilSub := filepath.Join(evilDir, "sub")
require.NoError(t, os.Mkdir(evilSub, 0700))
// The planted symlink dst/pwn -> outside, faithfully reproduced by --links.
require.NoError(t, putLink(ctx, f, "pwn", evilDir))
// The source now presents "pwn/sub" as a directory with attacker-chosen
// user metadata. Applying it must not reach through the planted symlink.
_, err = f.MkdirMetadata(ctx, "pwn/sub", metadata)
require.Error(t, err, "applying metadata through a planted symlink should be refused")
_, err = xattr.LGet(evilSub, "user.pwned")
require.Error(t, err, "xattr escaped through planted symlink to %q", evilSub)
}
// TestXattrWriteOnlyFile checks user metadata can still be set on a
// write-only file with --links, where the confined open can't read it.
func TestXattrWriteOnlyFile(t *testing.T) {
if !xattrSupported {
t.Skip("xattrs not supported on this OS")
}
skipIfNoSymlinks(t)
ctx := context.Background()
r := fstest.NewRun(t)
f := r.Flocal.(*Fs)
linksMode(f)
require.NoError(t, putFile(ctx, f, "wo.txt", "write only"))
path := filepath.Join(f.root, "wo.txt")
require.NoError(t, os.Chmod(path, 0200))
obj, err := f.NewObject(ctx, "wo.txt")
require.NoError(t, err)
o := obj.(*Object)
err = o.setXattr(fs.Metadata{"potato": "chips"})
require.NoError(t, err)
if f.xattrSupported.Load() == 0 {
t.Skip("xattrs not supported on this filesystem")
}
// Reading xattrs needs read permission
require.NoError(t, os.Chmod(path, 0600))
v, err := xattr.LGet(path, "user.potato")
require.NoError(t, err)
assert.Equal(t, "chips", string(v))
}
+19 -1
View File
@@ -208,6 +208,24 @@ func (d Du) GetName() xml.Name {
//------------------------------------------------------------------------------
// apiHeaders are the NetStorage API request headers which carry the
// account name, the request signature and the signed action. They must
// not be forwarded when a redirect leaves the configured host.
//
// The action header is sent uncanonicalised, hence the "*" prefix.
var apiHeaders = []string{"X-Akamai-ACS-Auth-Data", "X-Akamai-ACS-Auth-Sign", "*X-Akamai-ACS-Action"}
// newClient makes the http client used for all NetStorage requests.
//
// The signer adds the API headers to every request, so the client
// strips them on cross host redirects and refuses an HTTPS to HTTP
// downgrade.
func newClient(ctx context.Context) *http.Client {
client := fshttp.NewClient(ctx)
client.CheckRedirect = rest.StripHeadersOnCrossHostRedirectFn(apiHeaders...)
return client
}
// NewFs creates a new Fs object from the name and root. It connects to
// the host specified in the config file.
//
@@ -240,7 +258,7 @@ func NewFs(ctx context.Context, name, root string, m configmap.Mapper) (fs.Fs, e
if err != nil {
return nil, err
}
client := fshttp.NewClient(ctx)
client := newClient(ctx)
f := &Fs{
name: name,
@@ -4,12 +4,17 @@ import (
"context"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/rclone/rclone/fs"
"github.com/rclone/rclone/fs/config/configmap"
"github.com/rclone/rclone/fs/config/obscure"
"github.com/rclone/rclone/fs/object"
"github.com/rclone/rclone/lib/rest"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -59,3 +64,142 @@ func TestListResumeCannotChangeHost(t *testing.T) {
assert.Equal(t, 1, listRequests, "list request sent to the configured host")
assert.Equal(t, 0, requests, "request sent to the other host")
}
// apiTestHeaders is a deliberately literal copy of apiHeaders, without
// the "*" prefix, so that a header dropped from the production list
// shows up as a leak in the cross host tests rather than silently
// losing coverage.
var apiTestHeaders = []string{"X-Akamai-ACS-Auth-Data", "X-Akamai-ACS-Auth-Sign", "X-Akamai-ACS-Action"}
// statXML is a minimal stat response saying the root is a directory
const statXML = `<?xml version="1.0" encoding="UTF-8"?>
<stat directory="/dir"><file type="dir" name="dir" mtime="1"/></stat>`
// newTestFs makes an Fs via NewFs pointing at serverURL.
//
// NewFs makes a stat request for the root and treats any failure as
// the root not existing, so callers check what the servers received
// or make a further request rather than relying on its error.
func newTestFs(ctx context.Context, t *testing.T, serverURL string) *Fs {
u, err := url.Parse(serverURL)
require.NoError(t, err)
m := configmap.Simple{
"type": "netstorage",
"protocol": u.Scheme,
"host": u.Host + "/dir",
"account": "test-account",
"secret": obscure.MustObscure("test-secret"),
}
f, err := NewFs(ctx, "netstorage-test", "", m)
require.NoError(t, err)
return f.(*Fs)
}
// assertAPIHeaders checks all the API headers are present (want) or
// absent (!want) in r.
func assertAPIHeaders(t *testing.T, r *http.Request, want bool) {
for _, header := range apiTestHeaders {
if want {
assert.NotEmpty(t, r.Header.Get(header), "%s should be sent", header)
} else {
assert.Empty(t, r.Header.Get(header), "%s should have been stripped", header)
}
}
}
// TestRedirectStripsAPIHeadersOnCrossHost checks the API headers are
// sent to the configured host but not to another host it redirects to.
func TestRedirectStripsAPIHeadersOnCrossHost(t *testing.T) {
var otherHits atomic.Int32
other := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
otherHits.Add(1)
assertAPIHeaders(t, r, false)
_, _ = w.Write([]byte(statXML))
}))
defer other.Close()
var originHits atomic.Int32
origin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
originHits.Add(1)
assertAPIHeaders(t, r, true)
http.Redirect(w, r, other.URL+r.URL.Path, http.StatusTemporaryRedirect)
}))
defer origin.Close()
newTestFs(context.Background(), t, origin.URL)
assert.Equal(t, int32(1), originHits.Load())
assert.Equal(t, int32(1), otherHits.Load())
}
// TestUploadStripsAPIHeadersOnCrossHost checks an upload, which
// carries the API headers on the PUT as well as in the trailers, does
// not send them to another host the configured host redirects to.
func TestUploadStripsAPIHeadersOnCrossHost(t *testing.T) {
var otherHits atomic.Int32
other := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
otherHits.Add(1)
assertAPIHeaders(t, r, false)
_, _ = w.Write([]byte(statXML))
}))
defer other.Close()
var originHits atomic.Int32
origin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
originHits.Add(1)
assertAPIHeaders(t, r, true)
http.Redirect(w, r, other.URL+r.URL.Path, http.StatusMovedPermanently)
}))
defer origin.Close()
ctx := context.Background()
f := newTestFs(ctx, t, origin.URL)
o := &Object{fs: f, remote: "file"}
src := object.NewStaticObjectInfo("file", time.Now(), 4, true, nil, f)
err := o.netStorageUploadRequest(ctx, strings.NewReader("data"), src)
require.NoError(t, err)
// NewFs stat, PUT and the stat after the upload
assert.Equal(t, int32(3), originHits.Load())
assert.Equal(t, int32(3), otherHits.Load())
}
// TestRedirectKeepsAPIHeadersOnSameHost checks the API headers are
// still sent after a redirect which stays on the configured host.
func TestRedirectKeepsAPIHeadersOnSameHost(t *testing.T) {
var hits atomic.Int32
origin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hits.Add(1)
assertAPIHeaders(t, r, true)
if !strings.HasSuffix(r.URL.Path, "/moved") {
http.Redirect(w, r, r.URL.Path+"/moved", http.StatusTemporaryRedirect)
return
}
_, _ = w.Write([]byte(statXML))
}))
defer origin.Close()
newTestFs(context.Background(), t, origin.URL)
assert.Equal(t, int32(2), hits.Load())
}
// TestRedirectRefusesHTTPSDowngrade checks a redirect from HTTPS to
// plaintext HTTP is not followed.
func TestRedirectRefusesHTTPSDowngrade(t *testing.T) {
var targetHits atomic.Int32
target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
targetHits.Add(1)
_, _ = w.Write([]byte(statXML))
}))
defer target.Close()
origin := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, target.URL+r.URL.Path, http.StatusTemporaryRedirect)
}))
defer origin.Close()
ctx, ci := fs.AddConfig(context.Background())
ci.InsecureSkipVerify = true
f := newTestFs(ctx, t, origin.URL)
_, err := f.netStorageStatRequest(ctx, f.url(""), true)
require.ErrorIs(t, err, rest.ErrHTTPSDowngrade)
assert.Equal(t, int32(0), targetHits.Load())
}
+277 -38
View File
@@ -41,6 +41,7 @@ import (
"github.com/rclone/rclone/lib/oauthutil"
"github.com/rclone/rclone/lib/pacer"
"github.com/rclone/rclone/lib/rest"
"golang.org/x/oauth2"
)
const (
@@ -844,19 +845,33 @@ type Options struct {
// Fs represents a remote OneDrive
type Fs struct {
name string // name of this remote
root string // the path we are working on
opt Options // parsed options
ci *fs.ConfigInfo // global config
features *fs.Features // optional features
srv *rest.Client // the connection to the OneDrive server
unAuth *rest.Client // no authentication connection to the OneDrive server
dirCache *dircache.DirCache // Map of directory path to directory id
pacer *fs.Pacer // pacer for API calls
tokenRenewer *oauthutil.Renew // renew the token on expiry
driveID string // ID to use for querying Microsoft Graph
driveType string // https://developer.microsoft.com/en-us/graph/docs/api-reference/v1.0/resources/drive
hashType hash.Type // type of the hash we are using
name string // name of this remote
root string // the path we are working on
opt Options // parsed options
ci *fs.ConfigInfo // global config
features *fs.Features // optional features
srv *rest.Client // the connection to the OneDrive server
unAuth *rest.Client // no authentication connection to the OneDrive server
dirCache *dircache.DirCache // Map of directory path to directory id
pacer *fs.Pacer // pacer for API calls
tokenRenewer *oauthutil.Renew // renew the token on expiry
tokenSource *oauthutil.TokenSource // source of the API token
sharePoint *sharePointClients // authenticated clients for SharePoint hosts
driveID string // ID to use for querying Microsoft Graph
driveType string // https://developer.microsoft.com/en-us/graph/docs/api-reference/v1.0/resources/drive
hashType hash.Type // type of the hash we are using
}
// sharePointClients holds the clients authenticated for SharePoint hosts
type sharePointClients struct {
mu sync.Mutex
hosts map[string]sharePointAuth
}
// sharePointAuth is a client authenticated for a SharePoint host
type sharePointAuth struct {
client *rest.Client
tokenSource oauth2.TokenSource
}
// Object describes a OneDrive object
@@ -1076,6 +1091,154 @@ func errorHandler(resp *http.Response) error {
return errResponse
}
// errPreauthRetired explains an authentication failure on a URL which
// SharePoint used to return pre-authenticated.
var errPreauthRetired = errors.New("SharePoint refused to authenticate a request - Microsoft is retiring pre-authenticated (tempauth) URLs, see https://rclone.org/onedrive/#pre-authenticated-urls")
// sharePointDomains are the domains of SharePoint Online in the
// different Microsoft clouds
var sharePointDomains = []string{".sharepoint.com", ".sharepoint.us", ".sharepoint-mil.us", ".sharepoint.cn", ".sharepoint.de"}
// stripTempauth removes the tempauth parameter from the URLs the API
// returns, which simulates the retirement of pre-authenticated URLs
// in tests.
var stripTempauth = false
// returnedURL processes a URL returned by the API before use
func returnedURL(rawURL string) string {
if !stripTempauth {
return rawURL
}
u, err := url.Parse(rawURL)
if err != nil {
return rawURL
}
q := u.Query()
q.Del("tempauth")
u.RawQuery = q.Encode()
return u.String()
}
// urlAuth says how a URL returned by the API is authenticated
type urlAuth int
const (
urlAuthNone urlAuth = iota // call without authentication
urlAuthAPI // call with the API token
urlAuthSharePoint // call with a token for the SharePoint host
)
// apiHost returns the host the API calls made with f.srv go to.
func (f *Fs) apiHost() string {
endpoint := graphAPIEndpoint[f.opt.Region]
if f.opt.TenantURL != "" {
endpoint = f.opt.TenantURL
}
u, err := url.Parse(endpoint)
if err != nil || u.Scheme != "https" {
return ""
}
return u.Host
}
// urlAuthFor returns how to authenticate a URL returned by the API,
// such as an upload session or an async job monitor.
//
// Pre-authenticated URLs fail if they are sent an Authorization
// header and tokens must not be sent to other hosts, so only HTTPS
// URLs without tempauth on the API host or a SharePoint host are
// authenticated.
func (f *Fs) urlAuthFor(rawURL string) urlAuth {
u, err := url.Parse(rawURL)
if err != nil || u.Scheme != "https" || u.Query().Has("tempauth") {
return urlAuthNone
}
if apiHost := f.apiHost(); apiHost != "" && strings.EqualFold(u.Host, apiHost) {
return urlAuthAPI
}
if isSharePointHost(u.Hostname()) {
return urlAuthSharePoint
}
return urlAuthNone
}
// isSharePointHost returns true if host is in a SharePoint Online domain.
func isSharePointHost(host string) bool {
host = strings.ToLower(host)
for _, domain := range sharePointDomains {
if strings.HasSuffix(host, domain) {
return true
}
}
return false
}
// checkSharePointRedirect stops the SharePoint token being sent to a
// host which isn't SharePoint, as the token is added to every request
// the client makes.
func checkSharePointRedirect(req *http.Request, via []*http.Request) error {
if len(via) >= 10 {
return errors.New("stopped after 10 redirects")
}
if req.URL.Scheme != "https" || !isSharePointHost(req.URL.Hostname()) {
return fmt.Errorf("refusing to send a SharePoint token on a redirect to %s://%s", req.URL.Scheme, req.URL.Host)
}
return nil
}
// clientForURL returns the client to call a URL returned by the API with.
func (f *Fs) clientForURL(ctx context.Context, rawURL string) (*rest.Client, error) {
switch f.urlAuthFor(rawURL) {
case urlAuthAPI:
return f.srv, nil
case urlAuthSharePoint:
u, _ := url.Parse(rawURL)
return f.sharePointClient(ctx, u.Host)
}
return f.unAuth, nil
}
// sharePointClient returns a client authenticated for the SharePoint
// host, which upload session and async job monitor URLs need once
// they are no longer pre-authenticated.
//
// The token is got from the existing credentials, which Entra ID
// allows for SharePoint as the user has consented to the Graph file
// scopes.
func (f *Fs) sharePointClient(ctx context.Context, host string) (*rest.Client, error) {
if f.tokenSource == nil || f.sharePoint == nil {
return nil, fmt.Errorf("%w: no credentials to get a SharePoint token with", errPreauthRetired)
}
host = strings.ToLower(host)
f.sharePoint.mu.Lock()
defer f.sharePoint.mu.Unlock()
sp, ok := f.sharePoint.hosts[host]
if !ok {
tokenSource := f.tokenSource.ScopedTokenSource([]string{"https://" + host + "/.default"})
client := oauth2.NewClient(oauthutil.Context(ctx, fshttp.NewClient(ctx)), tokenSource)
client.CheckRedirect = checkSharePointRedirect
sp = sharePointAuth{client: rest.NewClient(client), tokenSource: tokenSource}
f.sharePoint.hosts[host] = sp
fs.Debugf(f, "Using a SharePoint token for %s", host)
}
// Get the token now so failures are reported before the client is used
if _, err := sp.tokenSource.Token(); err != nil {
return nil, fmt.Errorf("%w: failed to get a SharePoint token: %w", errPreauthRetired, err)
}
return sp.client, nil
}
// checkPreauthRetired adds an explanation to a 401 error from a URL
// returned by the API which needs a SharePoint token, as these were
// pre-authenticated until Microsoft retired them.
func (f *Fs) checkPreauthRetired(rawURL string, resp *http.Response, err error) error {
if err == nil || resp == nil || resp.StatusCode != http.StatusUnauthorized ||
f.driveType == driveTypePersonal || errors.Is(err, errPreauthRetired) || f.urlAuthFor(rawURL) != urlAuthSharePoint {
return err
}
return fmt.Errorf("%w: %w", errPreauthRetired, err)
}
func checkUploadChunkSize(cs fs.SizeSuffix) error {
const minChunkSize = fs.SizeSuffixBase
if cs%chunkSizeMultiple != 0 {
@@ -1144,16 +1307,18 @@ func NewFs(ctx context.Context, name, root string, m configmap.Mapper) (fs.Fs, e
ci := fs.GetConfig(ctx)
f := &Fs{
name: name,
root: root,
opt: *opt,
ci: ci,
driveID: opt.DriveID,
driveType: opt.DriveType,
srv: rest.NewClient(oAuthClient).SetRoot(rootURL),
unAuth: rest.NewClient(client).SetRoot(rootURL),
pacer: fs.NewPacer(ctx, pacer.NewDefault(pacer.MinSleep(minSleep), pacer.MaxSleep(maxSleep), pacer.DecayConstant(decayConstant))),
hashType: QuickXorHashType,
name: name,
root: root,
opt: *opt,
ci: ci,
driveID: opt.DriveID,
driveType: opt.DriveType,
srv: rest.NewClient(oAuthClient).SetRoot(rootURL),
tokenSource: ts,
sharePoint: &sharePointClients{hosts: make(map[string]sharePointAuth)},
unAuth: rest.NewClient(client).SetRoot(rootURL),
pacer: fs.NewPacer(ctx, pacer.NewDefault(pacer.MinSleep(minSleep), pacer.MaxSleep(maxSleep), pacer.DecayConstant(decayConstant))),
hashType: QuickXorHashType,
}
f.features = (&fs.Features{
CaseInsensitive: true,
@@ -1725,11 +1890,25 @@ func (f *Fs) waitForJob(ctx context.Context, location string, o *Object) error {
var resp *http.Response
var err error
var body []byte
var redirected bool
err = f.pacer.Call(func() (bool, error) {
resp, err = http.Get(location)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, location, nil)
if err != nil {
return false, err
}
client, err := f.clientForURL(ctx, location)
if err != nil {
return false, err
}
resp, err = client.Do(req)
if err != nil {
return fserrors.ShouldRetry(err), err
}
redirected = resp.Request.URL.String() != req.URL.String()
if resp.StatusCode == http.StatusUnauthorized {
_ = resp.Body.Close()
return false, f.checkPreauthRetired(location, resp, fmt.Errorf("async job monitor: HTTP error %s", resp.Status))
}
body, err = rest.ReadBody(resp)
return fserrors.ShouldRetry(err), err
})
@@ -1742,6 +1921,11 @@ func (f *Fs) waitForJob(ctx context.Context, location string, o *Object) error {
if err != nil {
return fmt.Errorf("async status result not JSON: %q: %w", body, err)
}
// An authenticated monitor redirects to the new item when the
// job has finished instead of returning its status
if status.Status == "" && redirected {
status.Status = "completed"
}
switch status.Status {
case "failed":
@@ -1841,7 +2025,7 @@ func (f *Fs) Copy(ctx context.Context, src fs.Object, remote string) (dst fs.Obj
}
// read location header
location := resp.Header.Get("Location")
location := returnedURL(resp.Header.Get("Location"))
if location == "" {
return nil, errors.New("didn't receive location header in copy response")
}
@@ -1852,6 +2036,10 @@ func (f *Fs) Copy(ctx context.Context, src fs.Object, remote string) (dst fs.Obj
fs.Debugf(src, "Server-side copy failed - file not shared between drives")
return nil, fs.ErrorCantCopy
}
if errors.Is(err, errPreauthRetired) {
fs.Debugf(src, "Server-side copy can't be monitored - copying another way: %v", err)
return nil, fs.ErrorCantCopy
}
if err != nil {
return nil, err
}
@@ -2526,16 +2714,23 @@ func (o *Object) openContentPrefer(ctx context.Context, options ...fs.OpenOption
return o.openWithRedirect(ctx, &opts)
}
// openWithRedirect downloads via /content style endpoints that 302 to a preauthenticated URL.
// openWithRedirect downloads via /content style endpoints that 302 to
// a download URL, which is pre-authenticated or needs a SharePoint
// token.
func (o *Object) openWithRedirect(ctx context.Context, opts *rest.Opts) (in io.ReadCloser, err error) {
var resp *http.Response
// Make a note of the redirect target as we need to call it without Auth
// Make a note of the redirect target as it needs different auth
var redirectReq *http.Request
opts.CheckRedirect = func(req *http.Request, via []*http.Request) error {
if len(via) >= 10 {
return errors.New("stopped after 10 redirects")
}
// Preauthenticated download URLs must not carry the Graph Authorization header.
if stripTempauth {
if u, err := url.Parse(returnedURL(req.URL.String())); err == nil {
req.URL = u
}
}
// Download URLs must not carry the Graph Authorization header.
req.Header.Del("Authorization")
// Keep Prefer on the redirect when forcing infected download; some SharePoint
// endpoints honor it only on the final download request.
@@ -2574,17 +2769,24 @@ func (o *Object) openWithRedirect(ctx context.Context, opts *rest.Opts) (in io.R
return nil, err
}
if redirectReq != nil {
redirectURL := redirectReq.URL.String()
var client *rest.Client
client, err = o.fs.clientForURL(ctx, redirectURL)
if err != nil {
return nil, err
}
err = o.fs.pacer.Call(func() (bool, error) {
resp, err = o.fs.unAuth.Do(redirectReq)
resp, err = client.Do(redirectReq)
if err != nil {
return shouldRetry(ctx, resp, err)
}
// unAuth.Do does not check status; treat non-2xx as failure so a malware
// Do does not check status; treat non-2xx as failure so a malware
// JSON body is not written out as file content.
if resp.StatusCode < 200 || resp.StatusCode > 299 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
_ = resp.Body.Close()
err = fmt.Errorf("HTTP error %d (%s) %s", resp.StatusCode, resp.Status, strings.TrimSpace(string(body)))
err = o.fs.checkPreauthRetired(redirectURL, resp, err)
if strings.Contains(string(body), "malwareDetected") || resp.Header.Get("X-Virus-Infected") != "" {
err = malwareDownloadError(o.fs.opt.AVOverride, err)
}
@@ -2606,7 +2808,19 @@ func (o *Object) openWithRedirect(ctx context.Context, opts *rest.Opts) (in io.R
// Overwrite size with actual size since size readings from Onedrive is unreliable.
o.size = resp.ContentLength
}
return resp.Body, err
return o.checkRange(resp, opts.Options)
}
// checkRange returns the body of a download, checking it is the range
// requested in options.
func (o *Object) checkRange(resp *http.Response, options []fs.OpenOption) (in io.ReadCloser, err error) {
// The size isn't passed as it is unreliable on OneDrive
err = rest.CheckContentRange(resp, options, -1)
if err != nil {
_ = resp.Body.Close()
return nil, err
}
return resp.Body, nil
}
// malwareDownloadError formats an error when the server blocks a malware-flagged file.
@@ -2646,12 +2860,16 @@ func (o *Object) getPosition(ctx context.Context, url string) (pos int64, err er
}
var info api.UploadFragmentResponse
var resp *http.Response
client, err := o.fs.clientForURL(ctx, url)
if err != nil {
return 0, err
}
err = o.fs.pacer.Call(func() (bool, error) {
resp, err = o.fs.srv.CallJSON(ctx, &opts, nil, &info)
resp, err = client.CallJSON(ctx, &opts, nil, &info)
return shouldRetry(ctx, resp, err)
})
if err != nil {
return 0, err
return 0, o.fs.checkPreauthRetired(url, resp, err)
}
if len(info.NextExpectedRanges) != 1 {
return 0, fmt.Errorf("bad number of ranges in upload position: %v", info.NextExpectedRanges)
@@ -2675,6 +2893,10 @@ func (o *Object) uploadFragment(ctx context.Context, url string, start int64, to
var resp *http.Response
var body []byte
skip := int64(0)
client, err := o.fs.clientForURL(ctx, url)
if err != nil {
return nil, err
}
err = o.fs.pacer.Call(func() (bool, error) {
toSend := chunkSize - skip
opts := rest.Opts{
@@ -2686,7 +2908,7 @@ func (o *Object) uploadFragment(ctx context.Context, url string, start int64, to
Options: options,
}
_, _ = chunk.Seek(skip, io.SeekStart)
resp, err = o.fs.unAuth.Call(ctx, &opts)
resp, err = client.Call(ctx, &opts)
if err != nil && resp != nil && resp.StatusCode == http.StatusRequestedRangeNotSatisfiable {
fs.Debugf(o, "Received 416 error - reading current position from server: %v", err)
pos, posErr := o.getPosition(ctx, url)
@@ -2725,7 +2947,7 @@ func (o *Object) uploadFragment(ctx context.Context, url string, start int64, to
}
return false, nil
})
return info, err
return info, o.fs.checkPreauthRetired(url, resp, err)
}
// cancelUploadSession cancels an upload session
@@ -2736,11 +2958,15 @@ func (o *Object) cancelUploadSession(ctx context.Context, url string) (err error
NoResponse: true,
}
var resp *http.Response
client, err := o.fs.clientForURL(ctx, url)
if err != nil {
return err
}
err = o.fs.pacer.Call(func() (bool, error) {
resp, err = o.fs.unAuth.Call(ctx, &opts)
resp, err = client.Call(ctx, &opts)
return shouldRetry(ctx, resp, err)
})
return
return o.fs.checkPreauthRetired(url, resp, err)
}
// uploadMultipart uploads a file using multipart upload
@@ -2758,7 +2984,20 @@ func (o *Object) uploadMultipart(ctx context.Context, in io.Reader, src fs.Objec
if err != nil {
return nil, err
}
uploadURL := session.UploadURL
uploadURL := returnedURL(session.UploadURL)
// Check a session which needs SharePoint authentication can get it
// before reading any data, so it can be uploaded in a single part
// instead if it can't.
if o.fs.urlAuthFor(uploadURL) == urlAuthSharePoint {
if _, err = o.getPosition(ctx, uploadURL); err != nil {
if !errors.Is(err, errPreauthRetired) || size >= int64(maxSinglePartSize) {
return nil, err
}
fs.Debugf(o, "Uploading in a single part as the upload session can't be used: %v", err)
return o.uploadSinglepart(ctx, in, src, options...)
}
}
// Cancel the session if something went wrong
defer atexit.OnError(&err, func() {
@@ -3,6 +3,7 @@ package onedrive
import (
"context"
"encoding/json"
"flag"
"fmt"
"net/http"
"net/http/httptest"
@@ -15,6 +16,7 @@ import (
_ "github.com/rclone/rclone/backend/local"
"github.com/rclone/rclone/backend/onedrive/api"
"github.com/rclone/rclone/fs"
"github.com/rclone/rclone/fs/fshttp"
"github.com/rclone/rclone/fs/operations"
"github.com/rclone/rclone/fstest"
"github.com/rclone/rclone/fstest/fstests"
@@ -43,6 +45,10 @@ const (
// https://learn.microsoft.com/en-us/onedrive/developer/rest-api/api/driveitem_invite?view=odsp-graph-online#http-request-1
)
func init() {
flag.BoolVar(&stripTempauth, "strip-tempauth", false, "Remove tempauth from URLs the API returns to simulate the retirement of pre-authenticated URLs")
}
// TestMain drives the tests
func TestMain(m *testing.M) {
fstest.TestMain(m)
@@ -770,3 +776,96 @@ func TestListDirectorySize(t *testing.T) {
assert.Equal(t, int64(2646204760), dir.Size())
assert.Equal(t, int64(3), dir.Items())
}
// TestWaitForJobUsesConfiguredClient checks that the async job monitor is
// polled with the configured HTTP client and request context rather than
// http.DefaultClient, so proxy, TLS and timeout options and cancellation apply.
func TestWaitForJobUsesConfiguredClient(t *testing.T) {
ctx, ci := fs.AddConfig(context.Background())
ci.UserAgent = "rclone-waitforjob-test"
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "rclone-waitforjob-test", r.UserAgent())
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(api.AsyncOperationStatus{Status: "deleteFailed"})
}))
t.Cleanup(server.Close)
f, _ := newChangeNotifyTestFs(t, http.NotFoundHandler())
f.unAuth = rest.NewClient(fshttp.NewClient(ctx))
err := f.waitForJob(ctx, server.URL+"/monitor", &Object{remote: "file.txt"})
require.Error(t, err)
assert.Contains(t, err.Error(), "deleteFailed")
}
// TestWaitForJobCancel checks that cancelling the context stops a poll of
// the async job monitor which is not answering.
func TestWaitForJobCancel(t *testing.T) {
release := make(chan struct{})
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
select {
case <-r.Context().Done():
case <-release:
}
}))
t.Cleanup(server.Close)
t.Cleanup(func() { close(release) })
f, _ := newChangeNotifyTestFs(t, http.NotFoundHandler())
f.unAuth = rest.NewClient(fshttp.NewClient(context.Background()))
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
done := make(chan error, 1)
go func() { done <- f.waitForJob(ctx, server.URL+"/monitor", &Object{remote: "file.txt"}) }()
select {
case err := <-done:
assert.ErrorIs(t, err, context.DeadlineExceeded)
case <-time.After(5 * time.Second):
t.Fatal("waitForJob did not return after the context was cancelled")
}
}
// TestWaitForJobUnauthorized checks that a monitor URL which refuses
// the request fails straight away instead of polling until the timeout.
func TestWaitForJobUnauthorized(t *testing.T) {
var polls atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
polls.Add(1)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
_ = json.NewEncoder(w).Encode(map[string]any{"error": map[string]any{"code": "unauthenticated", "message": "no token"}})
}))
t.Cleanup(server.Close)
f, _ := newChangeNotifyTestFs(t, http.NotFoundHandler())
f.unAuth = rest.NewClient(fshttp.NewClient(context.Background()))
f.driveType = driveTypeBusiness
err := f.waitForJob(context.Background(), server.URL+"/monitor", &Object{remote: "file.txt"})
require.Error(t, err)
assert.Contains(t, err.Error(), "401")
assert.Equal(t, int32(1), polls.Load())
}
// TestWaitForJobRedirectCompleted checks that a monitor which
// redirects to the new item, as it does when authenticated, is taken
// to mean the job has finished.
func TestWaitForJobRedirectCompleted(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/monitor":
http.Redirect(w, r, "/item", http.StatusSeeOther)
case "/item":
w.Header().Set("Content-Type", "application/json")
_, _ = fmt.Fprint(w, `{"id":"newid","name":"file.txt","size":5,"file":{}}`)
default:
t.Errorf("unexpected request %s", r.URL)
}
}))
t.Cleanup(server.Close)
f, _ := newChangeNotifyTestFs(t, http.NotFoundHandler())
f.unAuth = rest.NewClient(fshttp.NewClient(context.Background()))
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
err := f.waitForJob(ctx, server.URL+"/monitor", &Object{remote: "file.txt", hasMetaData: true})
require.NoError(t, err)
}
+330
View File
@@ -4,6 +4,7 @@ import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
@@ -13,11 +14,14 @@ import (
"time"
"github.com/rclone/rclone/fs"
"github.com/rclone/rclone/fs/config/configmap"
"github.com/rclone/rclone/fs/object"
"github.com/rclone/rclone/lib/oauthutil"
"github.com/rclone/rclone/lib/pacer"
"github.com/rclone/rclone/lib/rest"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/oauth2"
)
// fragmentServer is a fake OneDrive upload session which records the
@@ -66,6 +70,8 @@ func (s *fragmentServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
}
w.WriteHeader(http.StatusAccepted)
_, _ = fmt.Fprintf(w, `{"nextExpectedRanges":["%d-%d"]}`, len(s.received), s.size-1)
case r.Method == "DELETE" && r.URL.Path == "/upload":
w.WriteHeader(http.StatusNoContent)
default:
s.t.Errorf("unexpected request %s %s", r.Method, r.URL.Path)
w.WriteHeader(http.StatusNotFound)
@@ -118,3 +124,327 @@ func TestUploadMultipartSkip(t *testing.T) {
assert.Equal(t, 2, s.fragments)
assert.Equal(t, 3, s.attempts, "expected one 416, a partial re-send and the final fragment")
}
// TestURLAuthFor checks that tokens are only sent to HTTPS URLs on
// the API host or a SharePoint host which aren't pre-authenticated.
func TestURLAuthFor(t *testing.T) {
const tenant = "https://contoso-my.sharepoint.com/_api"
for _, tc := range []struct {
name string
region string
tenantURL string
url string
want urlAuth
}{
{"graph", "global", "", "https://graph.microsoft.com/v1.0/drives/x/items/y/uploadSession", urlAuthAPI},
{"graph case", "global", "", "https://Graph.Microsoft.com/v1.0/monitor/abc", urlAuthAPI},
{"graph tempauth", "global", "", "https://graph.microsoft.com/v1.0/upload?tempauth=abc", urlAuthNone},
{"graph http", "global", "", "http://graph.microsoft.com/v1.0/upload", urlAuthNone},
{"graph other region", "global", "", "https://graph.microsoft.us/v1.0/upload", urlAuthNone},
{"graph us", "us", "", "https://graph.microsoft.us/v1.0/upload", urlAuthAPI},
{"graph lookalike", "global", "", "https://graph.microsoft.com.example.com/upload", urlAuthNone},
{"sharepoint tempauth", "global", "", "https://contoso-my.sharepoint.com/_api/v2.0/upload?guid=x&tempauth=abc", urlAuthNone},
{"sharepoint", "global", "", "https://contoso-my.sharepoint.com/_api/v2.0/upload?guid=x", urlAuthSharePoint},
{"sharepoint case", "global", "", "https://Contoso.SharePoint.com/_api/v2.0/monitor/abc", urlAuthSharePoint},
{"sharepoint us", "us", "", "https://contoso.sharepoint.us/_api/v2.0/upload", urlAuthSharePoint},
{"sharepoint cn", "cn", "", "https://contoso.sharepoint.cn/_api/v2.0/upload", urlAuthSharePoint},
{"sharepoint http", "global", "", "http://contoso-my.sharepoint.com/_api/v2.0/upload", urlAuthNone},
{"sharepoint lookalike", "global", "", "https://contoso.sharepoint.com.example.com/upload", urlAuthNone},
{"sharepoint suffix only", "global", "", "https://evilsharepoint.com/upload", urlAuthNone},
{"personal", "global", "", "https://api.onedrive.com/rup/abc", urlAuthNone},
{"personal upload", "global", "", "https://my.microsoftpersonalcontent.com/personal/abc/_api/v2.0/drive/items/x/uploadSession", urlAuthNone},
{"unparseable", "global", "", "https://graph.microsoft.com/%zz", urlAuthNone},
{"tenant", "global", tenant, "https://contoso-my.sharepoint.com/_api/v2.0/monitor/abc", urlAuthAPI},
{"tenant tempauth", "global", tenant, "https://contoso-my.sharepoint.com/_api/v2.0/upload?tempauth=abc", urlAuthNone},
{"tenant graph", "global", tenant, "https://graph.microsoft.com/v1.0/upload", urlAuthNone},
{"tenant other sharepoint", "global", tenant, "https://contoso.sharepoint.com/_api/v2.0/upload", urlAuthSharePoint},
} {
t.Run(tc.name, func(t *testing.T) {
f := &Fs{}
f.opt.Region = tc.region
f.opt.TenantURL = tc.tenantURL
assert.Equal(t, tc.want, f.urlAuthFor(tc.url))
})
}
}
// TestCheckSharePointRedirect checks the SharePoint token is only
// sent on redirects to SharePoint over HTTPS.
func TestCheckSharePointRedirect(t *testing.T) {
for _, tc := range []struct {
url string
ok bool
}{
{"https://contoso-my.sharepoint.com/download.aspx", true},
{"https://205807-ipv4v6.gr.global.aa-rt.sharepoint.com/download.aspx?tempauth=x", true},
{"https://contoso.sharepoint.us/download.aspx", true},
{"http://contoso-my.sharepoint.com/download.aspx", false},
{"https://contoso.sharepoint.com.example.com/download.aspx", false},
{"https://graph.microsoft.com/v1.0/me", false},
{"https://example.com/", false},
} {
req, err := http.NewRequest("GET", tc.url, nil)
require.NoError(t, err)
err = checkSharePointRedirect(req, nil)
if tc.ok {
assert.NoError(t, err, tc.url)
} else {
assert.Error(t, err, tc.url)
}
}
req, err := http.NewRequest("GET", "https://contoso-my.sharepoint.com/", nil)
require.NoError(t, err)
assert.Error(t, checkSharePointRedirect(req, make([]*http.Request, 10)), "too many redirects")
}
// TestCheckPreauthRetired checks which 401 errors get the explanation.
func TestCheckPreauthRetired(t *testing.T) {
const sharePointURL = "https://contoso-my.sharepoint.com/_api/v2.0/upload?guid=x"
errBase := errors.New("HTTP error 401")
for _, tc := range []struct {
name string
driveType string
url string
status int
want bool
}{
{"sharepoint 401", driveTypeBusiness, sharePointURL, http.StatusUnauthorized, true},
{"library 401", driveTypeSharepoint, sharePointURL, http.StatusUnauthorized, true},
{"personal 401", driveTypePersonal, sharePointURL, http.StatusUnauthorized, false},
{"sharepoint 403", driveTypeBusiness, sharePointURL, http.StatusForbidden, false},
{"tempauth 401", driveTypeBusiness, sharePointURL + "&tempauth=abc", http.StatusUnauthorized, false},
{"graph 401", driveTypeBusiness, "https://graph.microsoft.com/v1.0/upload", http.StatusUnauthorized, false},
} {
t.Run(tc.name, func(t *testing.T) {
f := &Fs{driveType: tc.driveType}
f.opt.Region = "global"
err := f.checkPreauthRetired(tc.url, &http.Response{StatusCode: tc.status}, errBase)
assert.ErrorIs(t, err, errBase)
assert.Equal(t, tc.want, errors.Is(err, errPreauthRetired), "err = %v", err)
})
}
f := &Fs{driveType: driveTypeBusiness}
assert.NoError(t, f.checkPreauthRetired(sharePointURL, &http.Response{StatusCode: http.StatusUnauthorized}, nil))
assert.ErrorIs(t, f.checkPreauthRetired(sharePointURL, nil, errBase), errBase)
}
// TestSharePointClient checks that the client for a SharePoint host
// is authenticated with a token for that host got from the existing
// refresh token, which is fetched once and not stored.
func TestSharePointClient(t *testing.T) {
var scopes []string
var auths []string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/token":
_ = r.ParseForm()
assert.Equal(t, "refresh_token", r.PostForm.Get("grant_type"))
assert.Equal(t, "refresh", r.PostForm.Get("refresh_token"))
scopes = append(scopes, r.PostForm.Get("scope"))
w.Header().Set("Content-Type", "application/json")
_, _ = fmt.Fprint(w, `{"access_token":"sharepoint_token","token_type":"Bearer","expires_in":3600}`)
case "/upload":
auths = append(auths, r.Header.Get("Authorization"))
w.Header().Set("Content-Type", "application/json")
_, _ = fmt.Fprint(w, `{"nextExpectedRanges":["0-"]}`)
case "/redirect":
http.Redirect(w, r, "/upload", http.StatusFound)
default:
t.Errorf("unexpected request %s %s", r.Method, r.URL)
}
}))
t.Cleanup(server.Close)
storedToken := `{"access_token":"graph_token","token_type":"Bearer","refresh_token":"refresh","expiry":"2099-01-01T00:00:00Z"}`
m := configmap.Simple{"token": storedToken}
ctx := context.Background()
_, ts, err := oauthutil.NewClientWithBaseClient(ctx, "remote", m, &oauthutil.Config{
ClientID: "client_id",
ClientSecret: "client_secret",
TokenURL: server.URL + "/token",
}, server.Client())
require.NoError(t, err)
f := &Fs{tokenSource: ts, sharePoint: &sharePointClients{hosts: make(map[string]sharePointAuth)}}
for range 2 {
client, err := f.sharePointClient(ctx, "Contoso-my.sharepoint.com")
require.NoError(t, err)
_, err = client.Call(ctx, &rest.Opts{Method: "GET", RootURL: server.URL + "/upload"})
require.NoError(t, err)
}
assert.Equal(t, []string{"https://contoso-my.sharepoint.com/.default"}, scopes, "token should be fetched once for the host")
assert.Equal(t, []string{"Bearer sharepoint_token", "Bearer sharepoint_token"}, auths)
assert.Equal(t, storedToken, m["token"], "the stored token must not change")
// The token isn't sent on a redirect to a host which isn't SharePoint
client, err := f.sharePointClient(ctx, "contoso-my.sharepoint.com")
require.NoError(t, err)
_, err = client.Call(ctx, &rest.Opts{Method: "GET", RootURL: server.URL + "/redirect"})
assert.ErrorContains(t, err, "refusing to send a SharePoint token")
assert.Len(t, auths, 2)
// Without credentials there is no client and the error explains why
_, err = (&Fs{}).sharePointClient(ctx, "contoso-my.sharepoint.com")
assert.ErrorIs(t, err, errPreauthRetired)
}
// TestUploadSessionFallback checks that an upload session which needs
// SharePoint authentication which can't be got is replaced by a single
// part upload before any data is read.
func TestUploadSessionFallback(t *testing.T) {
content := bytes.Repeat([]byte("onedrive"), 1024)
var requests []string
var uploaded []byte
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests = append(requests, r.Method+" "+r.URL.Path)
w.Header().Set("Content-Type", "application/json")
switch {
case r.Method == "POST" && r.URL.Path == "/root/createUploadSession":
_, _ = fmt.Fprint(w, `{"uploadUrl":"https://contoso-my.sharepoint.com/_api/v2.0/upload?guid=x","nextExpectedRanges":["0-"]}`)
case r.Method == "PUT" && r.URL.Path == "/root/content":
var err error
uploaded, err = io.ReadAll(r.Body)
require.NoError(t, err)
_, _ = fmt.Fprintf(w, `{"id":"fake-id","name":"remote","size":%d,"file":{}}`, len(uploaded))
case r.Method == "PATCH":
_, _ = fmt.Fprintf(w, `{"id":"fake-id","name":"remote","size":%d,"file":{}}`, len(uploaded))
default:
t.Errorf("unexpected request %s %s", r.Method, r.URL)
w.WriteHeader(http.StatusNotFound)
}
}))
t.Cleanup(server.Close)
client := rest.NewClient(server.Client()).SetRoot(server.URL)
f := &Fs{
ci: fs.GetConfig(context.Background()),
srv: client,
unAuth: client,
driveType: driveTypeBusiness,
pacer: fs.NewPacer(context.Background(), pacer.NewDefault(pacer.MinSleep(time.Millisecond), pacer.MaxSleep(10*time.Millisecond))),
}
f.opt.Region = "global"
f.opt.ChunkSize = fs.SizeSuffix(len(content) / 2)
o := &Object{fs: f}
src := object.NewStaticObjectInfo("remote", time.Now(), int64(len(content)), true, nil, nil)
_, err := o.uploadMultipart(context.Background(), bytes.NewReader(content), src)
require.NoError(t, err)
assert.Equal(t, content, uploaded)
assert.Equal(t, []string{"POST /root/createUploadSession", "PUT /root/content"}, requests[:2])
}
// redirectTransport sends every request to the test server, so URLs
// on other hosts can be tested.
type redirectTransport struct {
server *httptest.Server
}
func (t redirectTransport) RoundTrip(req *http.Request) (*http.Response, error) {
req = req.Clone(req.Context())
req.URL.Scheme = "http"
req.URL.Host = t.server.Listener.Addr().String()
return t.server.Client().Transport.RoundTrip(req)
}
// newDownloadTest returns an Object which downloads content from a
// server which handles the /content call with contentHandler and
// serves download.aspx with Range support, recording the
// Authorization header of each download.
func newDownloadTest(t *testing.T, content []byte, contentHandler http.HandlerFunc) (o *Object, auths *[]string) {
auths = new([]string)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/items/fake/content":
contentHandler(w, r)
case "/download.aspx":
*auths = append(*auths, r.Header.Get("Authorization"))
http.ServeContent(w, r, "", time.Time{}, bytes.NewReader(content))
default:
t.Errorf("unexpected request %s %s", r.Method, r.URL)
w.WriteHeader(http.StatusNotFound)
}
}))
t.Cleanup(server.Close)
transport := redirectTransport{server: server}
sharePointToken := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: "sharepoint_token"})
sharePointHTTP := &http.Client{Transport: &oauth2.Transport{Source: sharePointToken, Base: transport}}
f := &Fs{
ci: fs.GetConfig(context.Background()),
srv: rest.NewClient(server.Client()).SetRoot(server.URL),
unAuth: rest.NewClient(&http.Client{Transport: transport}),
driveType: driveTypeBusiness,
tokenSource: &oauthutil.TokenSource{},
sharePoint: &sharePointClients{hosts: map[string]sharePointAuth{
"contoso-my.sharepoint.com": {client: rest.NewClient(sharePointHTTP), tokenSource: sharePointToken},
}},
pacer: fs.NewPacer(context.Background(), pacer.NewDefault(pacer.MinSleep(time.Millisecond), pacer.MaxSleep(10*time.Millisecond))),
}
f.opt.Region = "global"
return &Object{fs: f, id: "fake", size: int64(len(content))}, auths
}
// TestOpenRedirectAuth checks that downloads are redirected to a
// pre-authenticated URL without authentication and to a SharePoint
// URL with a SharePoint token.
func TestOpenRedirectAuth(t *testing.T) {
content := []byte("0123456789abcdefghij")
for _, test := range []struct {
name string
location string
wantAuth string
}{
{"tempauth", "https://contoso-my.sharepoint.com/download.aspx?UniqueId=x&tempauth=v1.secret", ""},
{"sharepoint", "https://contoso-my.sharepoint.com/download.aspx?UniqueId=x", "Bearer sharepoint_token"},
} {
t.Run(test.name, func(t *testing.T) {
o, auths := newDownloadTest(t, content, func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, test.location, http.StatusFound)
})
in, err := o.Open(context.Background(), &fs.RangeOption{Start: 5, End: 9})
require.NoError(t, err)
got, err := io.ReadAll(in)
require.NoError(t, err)
require.NoError(t, in.Close())
assert.Equal(t, "56789", string(got))
assert.Equal(t, []string{test.wantAuth}, *auths)
})
}
// Without credentials the error explains why
o, auths := newDownloadTest(t, content, func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "https://contoso-my.sharepoint.com/download.aspx?UniqueId=x", http.StatusFound)
})
o.fs.tokenSource = nil
_, err := o.Open(context.Background())
assert.ErrorIs(t, err, errPreauthRetired)
assert.Empty(t, *auths)
}
// TestOpenRangeCheck checks that a download which doesn't return the
// requested range is an error rather than wrong data.
func TestOpenRangeCheck(t *testing.T) {
content := []byte("0123456789abcdefghij")
for _, test := range []struct {
name string
handler http.HandlerFunc
wantErr error
}{
{"ignored", func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write(content)
}, fs.ErrorRangeIgnored},
{"wrong range", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Range", "bytes 0-4/20")
w.WriteHeader(http.StatusPartialContent)
_, _ = w.Write(content[:5])
}, nil},
} {
t.Run(test.name, func(t *testing.T) {
o, _ := newDownloadTest(t, content, test.handler)
_, err := o.Open(context.Background(), &fs.RangeOption{Start: 5, End: 9})
require.Error(t, err)
if test.wantErr != nil {
assert.ErrorIs(t, err, test.wantErr)
}
})
}
}
+4
View File
@@ -126,6 +126,10 @@ func shouldRetry(ctx context.Context, resp *http.Response, err error) (bool, err
if ociError.GetCode() == "RequestTimeout" {
return true, err
}
// Parallel part uploads can collide on the same object
if ociError.GetHTTPStatusCode() == http.StatusConflict && ociError.GetCode() == "ConcurrentObjectUpdate" {
return true, err
}
}
// Ok, not an oci error, check for generic failure conditions
return fserrors.ShouldRetry(err) || fserrors.ShouldRetryHTTP(resp, retryErrorCodes), err
@@ -102,3 +102,34 @@ func TestGzipEncoding(t *testing.T) {
assert.Equal(t, "", md5sum)
})
}
// serviceError is a minimal common.ServiceError for shouldRetry
type serviceError struct {
status int
code string
}
func (e serviceError) Error() string { return e.code }
func (e serviceError) GetHTTPStatusCode() int { return e.status }
func (e serviceError) GetMessage() string { return e.code }
func (e serviceError) GetCode() string { return e.code }
func (e serviceError) GetOpcRequestID() string { return "" }
func TestShouldRetryServiceError(t *testing.T) {
ctx := context.Background()
for _, test := range []struct {
status int
code string
want bool
}{
{http.StatusConflict, "ConcurrentObjectUpdate", true},
{http.StatusConflict, "BucketAlreadyExists", false},
{http.StatusRequestTimeout, "RequestTimeout", true},
{http.StatusNotFound, "ObjectNotFound", false},
} {
err := serviceError{status: test.status, code: test.code}
retry, gotErr := shouldRetry(ctx, nil, err)
assert.Equal(t, test.want, retry, test.code)
assert.Equal(t, error(err), gotErr)
}
}
+2
View File
@@ -1537,6 +1537,8 @@ func (o *Object) Update(ctx context.Context, in io.Reader, src fs.ObjectInfo, op
if err != nil {
return fmt.Errorf("failed to make multipart upload for 0 length file: %w", err)
}
// Stop the form writer reading in if the request fails before sending it all
defer func() { _ = formReader.Close() }()
contentLength := overhead + size
+35 -14
View File
@@ -184,7 +184,7 @@ func (f *Fs) patchFile(ctx context.Context, ID string, req *api.File) (info *api
}
// getTask gets api.Task from API for the ID passed
func (f *Fs) getTask(ctx context.Context, ID string, checkPhase bool) (info *api.Task, err error) {
func (f *Fs) getTask(ctx context.Context, ID string) (info *api.Task, err error) {
opts := rest.Opts{
Method: "GET",
Path: "/drive/v1/tasks/" + ID,
@@ -192,27 +192,48 @@ func (f *Fs) getTask(ctx context.Context, ID string, checkPhase bool) (info *api
var resp *http.Response
err = f.pacer.Call(func() (bool, error) {
resp, err = f.rst.CallJSON(ctx, &opts, nil, &info)
if checkPhase {
if err == nil && info.Phase != api.PhaseTypeComplete {
// could be pending right after the task is created
return true, fmt.Errorf("%s (%s) is still in %s", info.Name, info.Type, info.Phase)
}
}
return f.shouldRetry(ctx, resp, err)
})
return
}
// waitTask waits for async tasks to be completed
// waitTask waits up to taskTimeout for the async task ID to complete
func (f *Fs) waitTask(ctx context.Context, ID string) (err error) {
time.Sleep(taskWaitTime)
if info, err := f.getTask(ctx, ID, true); err != nil {
if info == nil {
return fmt.Errorf("can't verify the task is completed: %q", ID)
start := time.Now()
sleep := taskWaitTime
for {
if err := sleepCtx(ctx, sleep); err != nil {
return err
}
return fmt.Errorf("can't verify the task is completed: %#v", info)
info, err := f.getTask(ctx, ID)
if err != nil {
return fmt.Errorf("can't verify the task %q is completed: %w", ID, err)
}
switch info.Phase {
case api.PhaseTypeComplete:
fs.Debugf(f, "task %q (%s) completed after %v", ID, info.Type, time.Since(start))
return nil
case api.PhaseTypeError:
return fmt.Errorf("task failed: %#v", info)
}
// Tasks can stay pending for tens of seconds when the server is busy
if time.Since(start) > taskTimeout {
return fmt.Errorf("can't verify the task is completed after %v: %#v", time.Since(start), info)
}
sleep = min(2*sleep, taskMaxSleep)
}
}
// sleepCtx sleeps for d or until ctx is cancelled
func sleepCtx(ctx context.Context, d time.Duration) error {
timer := time.NewTimer(d)
defer timer.Stop()
select {
case <-timer.C:
return nil
case <-ctx.Done():
return ctx.Err()
}
return
}
// deleteTask remove a task having the specified ID
+10
View File
@@ -72,6 +72,8 @@ const (
minSleep = 100 * time.Millisecond
maxSleep = 2 * time.Second
taskWaitTime = 500 * time.Millisecond
taskMaxSleep = 5 * time.Second
taskTimeout = 5 * time.Minute
decayConstant = 2 // bigger for slower decay, exponential
rootURL = "https://api-drive.mypikpak.com"
@@ -1388,6 +1390,8 @@ func (f *Fs) uploadByForm(ctx context.Context, in io.Reader, name string, size i
if err != nil {
return fmt.Errorf("failed to make multipart upload: %w", err)
}
// Stop the form writer reading in if the request fails before sending it all
defer func() { _ = formReader.Close() }()
contentLength := overhead + size
opts := rest.Opts{
@@ -1946,6 +1950,12 @@ func (o *Object) httpResponse(ctx context.Context, url, method string, options [
}
err = o.fs.pacer.Call(func() (bool, error) {
res, err = o.fs.client.Do(req)
// client.Do does not fail a completed non-2xx response, so the
// HTML error page would be returned as the file. errorHandler
// closes the body; shouldRetry then retries retryErrorCodes.
if err == nil && (res.StatusCode < 200 || res.StatusCode > 299) {
err = errorHandler(res)
}
return o.fs.shouldRetry(ctx, res, err)
})
if err != nil {
+185
View File
@@ -0,0 +1,185 @@
package pikpak
import (
"context"
"io"
"net/http"
"net/http/httptest"
"strconv"
"sync/atomic"
"testing"
"github.com/rclone/rclone/fs"
"github.com/rclone/rclone/lib/pacer"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// closeTracker counts Close calls on a response body.
type closeTracker struct {
io.ReadCloser
closed *atomic.Int32
}
func (b closeTracker) Close() error {
b.closed.Add(1)
return b.ReadCloser.Close()
}
type roundTripperFunc func(*http.Request) (*http.Response, error)
func (f roundTripperFunc) RoundTrip(r *http.Request) (*http.Response, error) {
return f(r)
}
// newDownloadTestObject returns an object whose downloads are served by srv.
// closed counts response bodies closed through the client.
func newDownloadTestObject(t *testing.T, srv *httptest.Server, closed *atomic.Int32) *Object {
t.Helper()
ctx, ci := fs.AddConfig(context.Background())
ci.LowLevelRetries = 3
client := *srv.Client()
base := client.Transport
if base == nil {
base = http.DefaultTransport
}
client.Transport = roundTripperFunc(func(req *http.Request) (*http.Response, error) {
res, err := base.RoundTrip(req)
if err == nil && res != nil && res.Body != nil {
res.Body = closeTracker{ReadCloser: res.Body, closed: closed}
}
return res, err
})
return &Object{
fs: &Fs{
client: &client,
pacer: fs.NewPacer(ctx, &pacer.ZeroDelayCalculator{}),
},
size: 1,
}
}
// TestOpenHTTPStatus checks that a non-2xx download response is an error
// (body closed, retriable statuses retried by the pacer) and that 200 and
// 206 responses are still returned as file content.
func TestOpenHTTPStatus(t *testing.T) {
const htmlBody = "<html><head><title>502 Bad Gateway</title></head><body>502</body></html>"
fileBody := []byte("pikpak-file-bytes-0123456789")
t.Run("502", func(t *testing.T) {
var closed atomic.Int32
var hits atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hits.Add(1)
w.Header().Set("Content-Type", "text/html")
w.WriteHeader(http.StatusBadGateway)
_, _ = io.WriteString(w, htmlBody)
}))
t.Cleanup(srv.Close)
o := newDownloadTestObject(t, srv, &closed)
in, err := o.open(context.Background(), srv.URL)
if err == nil {
got, readErr := io.ReadAll(in)
require.NoError(t, readErr)
require.NoError(t, in.Close())
t.Fatalf("open returned the error page as file data (%d bytes): %q", len(got), got)
}
assert.Nil(t, in)
assert.ErrorContains(t, err, "502")
assert.NotContains(t, err.Error(), htmlBody)
assert.Equal(t, int32(3), hits.Load(), "502 should be retried by the pacer")
assert.Equal(t, hits.Load(), closed.Load(), "error response bodies should be closed")
})
t.Run("404", func(t *testing.T) {
var closed atomic.Int32
var hits atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hits.Add(1)
http.Error(w, "missing", http.StatusNotFound)
}))
t.Cleanup(srv.Close)
o := newDownloadTestObject(t, srv, &closed)
in, err := o.open(context.Background(), srv.URL)
require.Error(t, err)
assert.Nil(t, in)
assert.Equal(t, int32(1), hits.Load(), "404 is not a retry status")
assert.Equal(t, hits.Load(), closed.Load())
})
t.Run("502 then 200", func(t *testing.T) {
var closed atomic.Int32
var hits atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if hits.Add(1) == 1 {
w.Header().Set("Content-Type", "text/html")
w.WriteHeader(http.StatusBadGateway)
_, _ = io.WriteString(w, htmlBody)
return
}
w.Header().Set("Content-Type", "application/octet-stream")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(fileBody)
}))
t.Cleanup(srv.Close)
o := newDownloadTestObject(t, srv, &closed)
in, err := o.open(context.Background(), srv.URL)
require.NoError(t, err)
got, err := io.ReadAll(in)
require.NoError(t, err)
require.NoError(t, in.Close())
assert.Equal(t, fileBody, got)
assert.Equal(t, int32(2), hits.Load())
assert.Equal(t, int32(2), closed.Load())
})
t.Run("200", func(t *testing.T) {
var closed atomic.Int32
var hits atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hits.Add(1)
if r.Header.Get("Range") != "" {
t.Errorf("unexpected Range header %q", r.Header.Get("Range"))
}
w.Header().Set("Content-Type", "application/octet-stream")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(fileBody)
}))
t.Cleanup(srv.Close)
o := newDownloadTestObject(t, srv, &closed)
o.size = int64(len(fileBody))
in, err := o.open(context.Background(), srv.URL)
require.NoError(t, err)
got, err := io.ReadAll(in)
require.NoError(t, err)
assert.Equal(t, int32(0), closed.Load(), "successful body stays open for the caller")
require.NoError(t, in.Close())
assert.Equal(t, fileBody, got)
assert.Equal(t, int32(1), hits.Load())
assert.Equal(t, int32(1), closed.Load())
})
t.Run("206", func(t *testing.T) {
var closed atomic.Int32
var hits atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hits.Add(1)
assert.Equal(t, "bytes=0-3", r.Header.Get("Range"))
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("Content-Range", "bytes 0-3/"+strconv.Itoa(len(fileBody)))
w.WriteHeader(http.StatusPartialContent)
_, _ = w.Write(fileBody[:4])
}))
t.Cleanup(srv.Close)
o := newDownloadTestObject(t, srv, &closed)
o.size = int64(len(fileBody))
in, err := o.open(context.Background(), srv.URL, &fs.RangeOption{Start: 0, End: 3})
require.NoError(t, err)
got, err := io.ReadAll(in)
require.NoError(t, err)
require.NoError(t, in.Close())
assert.Equal(t, fileBody[:4], got)
assert.Equal(t, int32(1), hits.Load())
assert.Equal(t, int32(1), closed.Load())
})
}
+362
View File
@@ -0,0 +1,362 @@
package protondrive
import (
"context"
"errors"
"fmt"
"net/url"
"sync"
"github.com/ProtonMail/gopenpgp/v3/crypto"
"github.com/rclone/rclone/backend/protondrive/api"
"github.com/rclone/rclone/fs"
"github.com/rclone/rclone/lib/rest"
)
// unlockMu is held while unlocking account keys. Unlocking is memory
// hungry, so unlocks for different sessions run one at a time.
var unlockMu sync.Mutex
// address is one of the user's addresses with its unlocked keys.
type address struct {
ID string
Email string
keyRing *crypto.KeyRing // unlocked address keys in the server's order, primary first
primaryKeyID string // ID of the first key of keyRing
}
// account is the signed in user's unlocked keys and addresses.
type account struct {
userKR *crypto.KeyRing // unlocked user keys
addresses []*address // the primary address is first
mu sync.Mutex
foreign map[string]*publicKeyLookup // public keys of other users by emailKey
}
// publicKeyLookup is a fetch of another user's public keys, done when
// done is closed.
type publicKeyLookup struct {
done chan struct{}
keyRing *crypto.KeyRing
err error
}
// loadAccount fetches the user and addresses of the session and unlocks
// their keys with keyPassword.
//
// User keys which don't unlock are skipped, but an error is returned
// if none unlocks or if the primary address has no key which unlocks.
// Other addresses without a key which unlocks are left out.
func loadAccount(ctx context.Context, s *session, keyPassword []byte) (*account, error) {
var user api.UserResponse
if _, err := s.call(ctx, &rest.Opts{Method: "GET", Path: "/core/v4/users"}, nil, &user); err != nil {
return nil, fmt.Errorf("load account: get user: %w", err)
}
var addrs api.AddressesResponse
// The SDK fetches only the first page of 50 addresses.
opts := &rest.Opts{Method: "GET", Path: "/core/v4/addresses", Parameters: url.Values{"Page": {"0"}, "PageSize": {"50"}}}
if _, err := s.call(ctx, opts, nil, &addrs); err != nil {
return nil, fmt.Errorf("load account: get addresses: %w", err)
}
if len(addrs.Addresses) == 0 {
return nil, errors.New("load account: the account has no addresses")
}
unlockMu.Lock()
defer unlockMu.Unlock()
if s.unlockHook != nil {
s.unlockHook()
}
a := &account{foreign: make(map[string]*publicKeyLookup)}
var err error
a.userKR, err = unlockUserKeys(user.User.Keys, keyPassword)
if err != nil {
return nil, fmt.Errorf("load account: unlock user keys: %w", err)
}
var verifyTime int64
if t := s.serverTime(); !t.IsZero() {
verifyTime = t.Unix()
}
for i, addr := range addrs.Addresses {
kr, primaryKeyID, err := unlockAddressKeys(addr.Keys, keyPassword, a.userKR, verifyTime)
if err != nil {
if i == 0 {
a.close()
return nil, fmt.Errorf("load account: unlock keys of primary address: %w", err)
}
fs.Debugf(nil, "protondrive: skipping address %s: %v", addr.ID, err)
continue
}
a.addresses = append(a.addresses, &address{ID: addr.ID, Email: addr.Email, keyRing: kr, primaryKeyID: primaryKeyID})
}
return a, nil
}
// unlockUserKeys returns a key ring of the active user keys which
// unlock with keyPassword.
func unlockUserKeys(keys []api.UserKey, keyPassword []byte) (*crypto.KeyRing, error) {
kr, err := crypto.NewKeyRing(nil)
if err != nil {
return nil, err
}
var lastErr error
for _, k := range keys {
if k.Active == 0 {
continue
}
unlocked, err := unlockKey(k.PrivateKey, keyPassword)
if err == nil {
if err = kr.AddKey(unlocked); err != nil {
unlocked.ClearPrivateParams()
}
}
if err != nil {
fs.Debugf(nil, "protondrive: can't unlock user key %s: %v", k.ID, err)
lastErr = err
}
}
if kr.CountEntities() == 0 {
if lastErr == nil {
return nil, errors.New("no active user key")
}
return nil, fmt.Errorf("no user key unlocks, is the password right?: %w", lastErr)
}
return kr, nil
}
// unlockAddressKeys returns a key ring of the active address keys
// which unlock, in the order given, and the ID of the first of them.
//
// A key with a Token is locked with the passphrase in it, encrypted to
// and signed by the user keys in userKR; the signature is checked as
// at verifyTime (Unix seconds, or now if zero). A key without a Token
// is locked with keyPassword.
func unlockAddressKeys(keys []api.AddressKey, keyPassword []byte, userKR *crypto.KeyRing, verifyTime int64) (kr *crypto.KeyRing, firstID string, err error) {
kr, err = crypto.NewKeyRing(nil)
if err != nil {
return nil, "", err
}
var lastErr error
for _, k := range keys {
if k.Active == 0 {
continue
}
unlocked, err := unlockAddressKey(k, keyPassword, userKR, verifyTime)
if err == nil {
if err = kr.AddKey(unlocked); err != nil {
unlocked.ClearPrivateParams()
}
}
if err != nil {
fs.Debugf(nil, "protondrive: can't unlock address key %s: %v", k.ID, err)
lastErr = err
continue
}
if firstID == "" {
firstID = k.ID
}
}
if kr.CountEntities() == 0 {
if lastErr == nil {
return nil, "", errors.New("no active address key")
}
return nil, "", fmt.Errorf("no address key unlocks: %w", lastErr)
}
return kr, firstID, nil
}
// unlockAddressKey unlocks one address key as unlockAddressKeys does.
func unlockAddressKey(k api.AddressKey, keyPassword []byte, userKR *crypto.KeyRing, verifyTime int64) (*crypto.Key, error) {
if k.Token == "" {
return unlockKey(k.PrivateKey, keyPassword)
}
dh, err := crypto.PGP().Decryption().DecryptionKeys(userKR).New()
if err != nil {
return nil, err
}
res, err := dh.Decrypt([]byte(k.Token), crypto.Armor)
if err != nil {
return nil, fmt.Errorf("decrypt token: %w", err)
}
passphrase := res.Bytes()
defer clear(passphrase)
vb := crypto.PGP().Verify().VerificationKeys(userKR)
if verifyTime != 0 {
vb = vb.VerifyTime(verifyTime)
}
vh, err := vb.New()
if err != nil {
return nil, err
}
// As the SDK does, refuse a token without a valid signature.
verified, err := vh.VerifyDetached(passphrase, []byte(k.Signature), crypto.Armor)
if err == nil {
err = verified.SignatureError()
}
if err != nil {
return nil, fmt.Errorf("verify token: %w", err)
}
return unlockKey(k.PrivateKey, passphrase)
}
// unlockKey returns the armored private key unlocked with passphrase.
func unlockKey(armored string, passphrase []byte) (*crypto.Key, error) {
locked, err := crypto.NewKeyFromArmored(armored)
if err != nil {
return nil, err
}
return locked.Unlock(passphrase)
}
// signingKeyRing returns a key ring holding only the first key of the
// address, which the SDK calls the primary address key.
func (a *address) signingKeyRing() (*crypto.KeyRing, error) {
return crypto.NewKeyRing(a.keyRing.GetKeys()[0])
}
// primary returns the primary address.
func (a *account) primary() *address {
return a.addresses[0]
}
// byID returns the address with the ID id, or nil.
func (a *account) byID(id string) *address {
for _, addr := range a.addresses {
if addr.ID == id {
return addr
}
}
return nil
}
// emailKey returns email with ASCII letters lowercased, which is how
// emails are compared: Proton treats addresses case-insensitively, but
// Unicode case folding would let a non-ASCII letter, such as the Kelvin
// sign, stand for an ASCII one.
func emailKey(email string) string {
b := []byte(email)
for i, c := range b {
if 'A' <= c && c <= 'Z' {
b[i] = c + 'a' - 'A'
}
}
return string(b)
}
// byEmail returns the address email, compared as emailKey does, or
// nil.
func (a *account) byEmail(email string) *address {
key := emailKey(email)
for _, addr := range a.addresses {
if emailKey(addr.Email) == key {
return addr
}
}
return nil
}
// owns reports whether email is one of the account's addresses whose
// keys it holds, compared as emailKey does.
func (a *account) owns(email string) bool {
return a.byEmail(email) != nil
}
// apiCaller makes an API call with the semantics of session.call,
// without returning the response; client.apiCall is one which paces
// and retries.
type apiCaller func(ctx context.Context, opts *rest.Opts, request, response any) error
// publicKeys returns the public keys of the address email, which the
// caller must not modify.
//
// The keys of the user's own addresses, in any ASCII case, come from
// the account, never the server. Other users' keys are fetched once
// per emailKey and kept for the life of the account; an email without
// a Proton address has an empty key ring.
func (a *account) publicKeys(ctx context.Context, call apiCaller, email string) (*crypto.KeyRing, error) {
if addr := a.byEmail(email); addr != nil {
return publicKeyRing(addr.keyRing)
}
key := emailKey(email)
a.mu.Lock()
l := a.foreign[key]
if l == nil {
l = &publicKeyLookup{done: make(chan struct{})}
a.foreign[key] = l
a.mu.Unlock()
l.keyRing, l.err = fetchPublicKeys(ctx, call, email)
if l.err != nil {
// Let the next caller try again.
a.mu.Lock()
delete(a.foreign, key)
a.mu.Unlock()
}
close(l.done)
} else {
a.mu.Unlock()
}
select {
case <-l.done:
return l.keyRing, l.err
case <-ctx.Done():
return nil, ctx.Err()
}
}
// publicKeyRing returns the public parts of the keys in kr.
func publicKeyRing(kr *crypto.KeyRing) (*crypto.KeyRing, error) {
pub, err := crypto.NewKeyRing(nil)
if err != nil {
return nil, err
}
for _, key := range kr.GetKeys() {
pk, err := key.ToPublic()
if err != nil {
return nil, err
}
if err := pub.AddKey(pk); err != nil {
return nil, err
}
}
return pub, nil
}
// fetchPublicKeys fetches the public keys of the Proton address email
// with call.
func fetchPublicKeys(ctx context.Context, call apiCaller, email string) (*crypto.KeyRing, error) {
kr, err := crypto.NewKeyRing(nil)
if err != nil {
return nil, err
}
var out api.PublicKeysResponse
opts := &rest.Opts{Method: "GET", Path: "/core/v4/keys/all", Parameters: url.Values{"Email": {email}, "InternalOnly": {"1"}}}
err = call(ctx, opts, nil, &out)
var apiErr *api.Error
if errors.As(err, &apiErr) && (apiErr.Code == api.CodeAddressMissing || apiErr.Code == api.CodeDomainExternal) {
return kr, nil
}
if err != nil {
return nil, fmt.Errorf("get public keys of %s: %w", email, err)
}
for _, k := range out.Address.Keys {
key, err := crypto.NewKeyFromArmored(k.PublicKey)
if err == nil {
err = kr.AddKey(key)
}
if err != nil {
return nil, fmt.Errorf("public key of %s: %w", email, err)
}
}
return kr, nil
}
// close clears the private key material of the account.
func (a *account) close() {
if a.userKR != nil {
a.userKR.ClearPrivateParams()
}
for _, addr := range a.addresses {
addr.keyRing.ClearPrivateParams()
}
}
@@ -0,0 +1,300 @@
package protondrive
import (
"context"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/ProtonMail/gopenpgp/v3/crypto"
"github.com/rclone/rclone/backend/protondrive/protontest"
"github.com/rclone/rclone/lib/rest"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// newAccountSession returns a session for a new session of u.
func newAccountSession(srv *protontest.Server, u *protontest.User) *session {
tokens := srv.NewSession(u)
store := &memoryStore{creds: credentials{
UID: tokens.UID,
AccessToken: tokens.AccessToken,
RefreshToken: tokens.RefreshToken,
KeyPassword: u.KeyPassword(),
}}
return newSession(srv.Client(), srv.URL(), testAppVersion, store)
}
// sessionCaller returns an apiCaller making unpaced calls with s.
func sessionCaller(s *session) apiCaller {
return func(ctx context.Context, opts *rest.Opts, request, response any) error {
_, err := s.call(ctx, opts, request, response)
return err
}
}
// decryptWith decrypts the armored message with kr.
func decryptWith(t *testing.T, kr *crypto.KeyRing, armored string) string {
t.Helper()
dh, err := crypto.PGP().Decryption().DecryptionKeys(kr).New()
require.NoError(t, err)
res, err := dh.Decrypt([]byte(armored), crypto.Armor)
require.NoError(t, err)
return string(res.Bytes())
}
func TestLoadAccount(t *testing.T) {
t.Parallel()
srv := protontest.New(t)
u := srv.AddUser("user", "pass")
u.AddAddress("alias@proton.test")
s := newAccountSession(srv, u)
a, err := loadAccount(context.Background(), s, []byte(u.KeyPassword()))
require.NoError(t, err)
defer a.close()
require.NotNil(t, a.userKR)
require.Len(t, a.addresses, 2)
primary := a.primary()
require.NotNil(t, primary)
assert.Equal(t, u.Email(), primary.Email)
assert.NotEmpty(t, primary.ID)
assert.Same(t, primary, a.byID(primary.ID))
assert.Same(t, primary, a.byEmail(u.Email()))
assert.Nil(t, a.byID("unknown"))
assert.Nil(t, a.byEmail("unknown@proton.test"))
for _, email := range []string{u.Email(), "alias@proton.test"} {
addr := a.byEmail(email)
require.NotNil(t, addr, email)
assert.Equal(t, "secret for "+email, decryptWith(t, addr.keyRing, u.EncryptToAddress(email, []byte("secret for "+email))))
}
// The address keys are distinct.
dh, err := crypto.PGP().Decryption().DecryptionKeys(primary.keyRing).New()
require.NoError(t, err)
_, err = dh.Decrypt([]byte(u.EncryptToAddress("alias@proton.test", []byte("x"))), crypto.Armor)
assert.Error(t, err, "primary address key decrypted a message for the alias")
}
func TestLoadAccountTwoPassword(t *testing.T) {
t.Parallel()
srv := protontest.New(t)
u := srv.AddTwoPasswordUser("user", "login", "mailbox")
want, err := computeKeyPassword([]byte("mailbox"), u.KeySalt)
require.NoError(t, err)
assert.Equal(t, string(want), u.KeyPassword())
a, err := loadAccount(context.Background(), newAccountSession(srv, u), want)
require.NoError(t, err)
a.close()
}
func TestLoadAccountWrongKeyPassword(t *testing.T) {
t.Parallel()
srv := protontest.New(t)
u := srv.AddUser("user", "pass")
s := newAccountSession(srv, u)
for _, keyPassword := range [][]byte{[]byte("wrong"), nil} {
a, err := loadAccount(context.Background(), s, keyPassword)
require.Error(t, err)
assert.Nil(t, a)
assert.Contains(t, err.Error(), "unlock user keys")
}
}
func TestLoadAccountRotatedUserKey(t *testing.T) {
t.Parallel()
srv := protontest.New(t)
u := srv.AddUser("user", "pass")
u.AddUserKey()
// The old user key no longer unlocks with the key password, which
// must not stop the account loading.
a, err := loadAccount(context.Background(), newAccountSession(srv, u), []byte(u.KeyPassword()))
require.NoError(t, err)
defer a.close()
assert.Equal(t, "msg", decryptWith(t, a.primary().keyRing, u.EncryptToAddress(u.Email(), []byte("msg"))))
}
func TestLoadAccountBadAddressKeySignature(t *testing.T) {
t.Parallel()
for _, test := range []struct {
name string
empty bool
}{
{"impostor", false},
{"empty", true},
} {
t.Run(test.name, func(t *testing.T) {
t.Parallel()
srv := protontest.New(t)
// On the primary address the account doesn't load.
u := srv.AddUser("user", "pass")
srv.CorruptAddressKeySignature(u, u.Email(), test.empty)
a, err := loadAccount(context.Background(), newAccountSession(srv, u), []byte(u.KeyPassword()))
require.Error(t, err)
assert.Nil(t, a)
assert.Contains(t, err.Error(), "verify token")
// A secondary address is left out.
u = srv.AddUser("user2", "pass")
u.AddAddress("alias@proton.test")
srv.CorruptAddressKeySignature(u, "alias@proton.test", test.empty)
a, err = loadAccount(context.Background(), newAccountSession(srv, u), []byte(u.KeyPassword()))
require.NoError(t, err)
defer a.close()
assert.Nil(t, a.byEmail("alias@proton.test"))
assert.Len(t, a.addresses, 1)
assert.Equal(t, u.Email(), a.primary().Email)
})
}
}
func TestLoadAccountSerialised(t *testing.T) {
t.Parallel()
srv := protontest.New(t)
u := srv.AddUser("user", "pass")
s := newAccountSession(srv, u)
var active, peak, calls atomic.Int32
s.unlockHook = func() {
n := active.Add(1)
for {
p := peak.Load()
if n <= p || peak.CompareAndSwap(p, n) {
break
}
}
calls.Add(1)
time.Sleep(5 * time.Millisecond)
active.Add(-1)
}
var wg sync.WaitGroup
for range 16 {
wg.Add(1)
go func() {
defer wg.Done()
a, err := loadAccount(context.Background(), s, []byte(u.KeyPassword()))
if assert.NoError(t, err) {
a.close()
}
}()
}
wg.Wait()
assert.Equal(t, int32(16), calls.Load())
assert.Equal(t, int32(1), peak.Load(), "unlocks overlapped")
}
func TestAccountClose(t *testing.T) {
t.Parallel()
srv := protontest.New(t)
u := srv.AddUser("user", "pass")
a, err := loadAccount(context.Background(), newAccountSession(srv, u), []byte(u.KeyPassword()))
require.NoError(t, err)
a.close()
for _, kr := range []*crypto.KeyRing{a.userKR, a.primary().keyRing} {
for _, key := range kr.GetKeys() {
assert.False(t, key.IsPrivate(), "private key material left after close")
}
}
}
func TestPublicKeysCached(t *testing.T) {
t.Parallel()
srv := protontest.New(t)
u := srv.AddUser("user", "pass")
other := srv.AddUser("other", "pass")
s := newAccountSession(srv, u)
a, err := loadAccount(context.Background(), s, []byte(u.KeyPassword()))
require.NoError(t, err)
defer a.close()
otherAccount, err := loadAccount(context.Background(), newAccountSession(srv, other), []byte(other.KeyPassword()))
require.NoError(t, err)
defer otherAccount.close()
const route = "GET /core/v4/keys/all"
srv.ResetRequests()
for range 5 {
kr, err := a.publicKeys(context.Background(), sessionCaller(s), other.Email())
require.NoError(t, err)
require.Equal(t, 1, kr.CountEntities())
assert.False(t, kr.GetKeys()[0].IsPrivate())
// A message encrypted to the key decrypts with other's address key.
eh, err := crypto.PGP().Encryption().Recipients(kr).New()
require.NoError(t, err)
msg, err := eh.Encrypt([]byte("hello"))
require.NoError(t, err)
armored, err := msg.ArmorBytes()
require.NoError(t, err)
assert.Equal(t, "hello", decryptWith(t, otherAccount.primary().keyRing, string(armored)))
}
assert.Equal(t, 1, srv.Requests()[route])
srv.ResetRequests()
for range 5 {
kr, err := a.publicKeys(context.Background(), sessionCaller(s), u.Email())
require.NoError(t, err)
require.Equal(t, 1, kr.CountEntities())
assert.False(t, kr.GetKeys()[0].IsPrivate())
}
assert.Zero(t, srv.Requests()[route])
// An email without a Proton account has no keys, and that is cached too.
for range 3 {
kr, err := a.publicKeys(context.Background(), sessionCaller(s), "nobody@example.com")
require.NoError(t, err)
assert.Zero(t, kr.CountEntities())
}
assert.Equal(t, 1, srv.Requests()[route])
// Case variants share a lookup, and an own address in any case
// never reaches the server.
srv.ResetRequests()
for _, email := range []string{"OTHER@proton.test", "Other@Proton.Test", strings.ToUpper(u.Email())} {
_, err := a.publicKeys(context.Background(), sessionCaller(s), email)
require.NoError(t, err)
}
assert.Zero(t, srv.Requests()[route])
}
func TestPublicKeysErrorNotCached(t *testing.T) {
t.Parallel()
srv := protontest.New(t)
u := srv.AddUser("user", "pass")
other := srv.AddUser("other", "pass")
s := newAccountSession(srv, u)
a, err := loadAccount(context.Background(), s, []byte(u.KeyPassword()))
require.NoError(t, err)
defer a.close()
srv.Inject(protontest.Fault{Pattern: "/core/v4/keys/all", Times: 1, Status: 500})
_, err = a.publicKeys(context.Background(), sessionCaller(s), other.Email())
require.Error(t, err)
kr, err := a.publicKeys(context.Background(), sessionCaller(s), other.Email())
require.NoError(t, err)
assert.Equal(t, 1, kr.CountEntities())
}
func TestSameEmail(t *testing.T) {
for _, tc := range []struct {
a, b string
same bool
}{
{"user@proton.me", "user@proton.me", true},
{"User@Proton.Me", "uSER@pROTON.mE", true},
{"user@proton.me", "user@proton.mee", false},
// Unicode case folding would match these to ASCII letters.
{"kim@proton.me", "\u212aim@proton.me", false},
{"sam@proton.me", "\u017fam@proton.me", false},
{"élan@proton.me", "Élan@proton.me", false},
} {
assert.Equal(t, tc.same, emailKey(tc.a) == emailKey(tc.b), "%q %q", tc.a, tc.b)
}
}
+661
View File
@@ -0,0 +1,661 @@
// Package api has the type definitions for the Proton Drive API.
package api
import (
"encoding/json"
"fmt"
)
// Response codes returned in the Code field of every API response.
//
// The names follow ErrorCode in the Proton Drive SDK
// (client/js/src/internal/apiService/errorCodes.ts) unless noted.
const (
CodeOK = 1000
CodeOKMany = 1001
CodeOKAsync = 1002
CodeInvalidRequirements = 2000
CodeInvalidValue = 2001
CodeNotEnoughPermissions = 2011
CodeNotEnoughPermissionsToGrantPermissions = 2026
CodeAlreadyExists = 2500
CodeNotExists = 2501
CodeInsufficientQuota = 200001
CodeInsufficientSpace = 200002
CodeMaxFileSizeForFreeUser = 200003
CodeMaxPublicEditModeForFreeUser = 200004
CodeInsufficientVolumeQuota = 200100
CodeInsufficientDeviceQuota = 200101
CodeAlreadyMemberOfShareInVolumeWithAnotherAddress = 200201
CodeTooManyChildren = 200300
CodeNestingTooDeep = 200301
CodeInsufficientInvitationQuota = 200600
CodeInsufficientShareQuota = 200601
CodeInsufficientShareJoinedQuota = 200602
CodeInsufficientBookmarksQuota = 200800
// CodeFeatureBlocked is returned when a feature is disabled,
// rate-limited or blocked because of abuse (driveTypes.ts).
CodeFeatureBlocked = 2028
// CodeAppVersionMissing is returned when the request has no app
// version (go-proton-api AppVersionMissingCode).
CodeAppVersionMissing = 5001
// CodeAppVersionBad is returned when the app version is no longer
// accepted: "You need to update this app" (coreTypes.ts,
// go-proton-api AppVersionBadCode).
CodeAppVersionBad = 5003
// CodeInvalidSection is returned when a request header such as
// x-pm-appversion is malformed.
CodeInvalidSection = 2064
// CodePasswordWrong is returned when the login credentials are
// incorrect (go-proton-api PasswordWrong).
CodePasswordWrong = 8002
// CodeHumanVerification is returned when a CAPTCHA must be solved.
CodeHumanVerification = 9001
// CodeRefreshTokenInvalid is returned when the refresh token is
// rejected and the session must be established again.
CodeRefreshTokenInvalid = 10013
// CodeAddressMissing is returned by GET /core/v4/keys/all when no
// Proton address has the email (account accountApi.ts).
CodeAddressMissing = 33102
// CodeDomainExternal is returned by GET /core/v4/keys/all when the
// email's domain is not hosted by Proton (account accountApi.ts).
CodeDomainExternal = 33103
)
// Error is the body of an unsuccessful API response.
type Error struct {
Status int `json:"-"` // HTTP status of the response
Code int `json:"Code"` // Proton response code
Message string `json:"Error"` // human readable message
Details json.RawMessage `json:"Details,omitempty"` // request specific details
}
// Error returns the message with its code and HTTP status.
func (e *Error) Error() string {
return fmt.Sprintf("%s (Code=%d, Status=%d)", e.Message, e.Code, e.Status)
}
// AuthRefreshRequest is the body of POST /auth/v4/refresh.
type AuthRefreshRequest struct {
ResponseType string `json:"ResponseType"` // always "token"
GrantType string `json:"GrantType"` // always "refresh_token"
RefreshToken string `json:"RefreshToken"`
}
// AuthRefreshResponse is the response to POST /auth/v4/refresh.
type AuthRefreshResponse struct {
Code int `json:"Code"`
UID string `json:"UID"`
AccessToken string `json:"AccessToken"`
RefreshToken string `json:"RefreshToken"`
}
// AuthInfoRequest is the body of POST /core/v4/auth/info.
type AuthInfoRequest struct {
Intent string `json:"Intent"` // always "Proton"
Username string `json:"Username"`
}
// AuthInfoResponse is the response to POST /core/v4/auth/info: the
// server's SRP challenge.
type AuthInfoResponse struct {
Code int `json:"Code"`
Version int `json:"Version"` // SRP password hashing version
Modulus string `json:"Modulus"` // signed base64 SRP modulus
ServerEphemeral string `json:"ServerEphemeral"` // base64
Salt string `json:"Salt"` // base64 SRP salt
SRPSession string `json:"SRPSession"`
Username string `json:"Username"`
}
// AuthRequest is the body of POST /core/v4/auth.
type AuthRequest struct {
Username string `json:"Username"`
ClientEphemeral string `json:"ClientEphemeral"` // base64
ClientProof string `json:"ClientProof"` // base64
SRPSession string `json:"SRPSession"`
PersistentCookies int `json:"PersistentCookies"`
Payload map[string]string `json:"Payload"`
}
// Values of TwoFAInfo.Enabled, which may be combined.
const (
TwoFATOTP = 1
TwoFAFIDO2 = 2
)
// Values of AuthResponse.PasswordMode.
const (
PasswordModeOne = 1 // the login password also unlocks the keys
PasswordModeTwo = 2 // a separate mailbox password unlocks the keys
)
// TwoFAInfo describes the second factors an account requires.
type TwoFAInfo struct {
Enabled int `json:"Enabled"` // bitmask of TwoFATOTP and TwoFAFIDO2
}
// AuthResponse is the response to POST /core/v4/auth.
type AuthResponse struct {
Code int `json:"Code"`
UID string `json:"UID"`
UserID string `json:"UserID"`
AccessToken string `json:"AccessToken"`
RefreshToken string `json:"RefreshToken"`
TokenType string `json:"TokenType"`
ServerProof string `json:"ServerProof"` // base64
Scopes []string `json:"Scopes"`
PasswordMode int `json:"PasswordMode"`
TwoFA TwoFAInfo `json:"2FA"`
}
// Auth2FARequest is the body of POST /core/v4/auth/2fa.
type Auth2FARequest struct {
TwoFactorCode string `json:"TwoFactorCode"`
}
// KeySalt is the salt of one user key's password.
type KeySalt struct {
ID string `json:"ID"` // user key ID
KeySalt string `json:"KeySalt"` // base64
}
// KeySaltsResponse is the response to GET /core/v4/keys/salts.
type KeySaltsResponse struct {
Code int `json:"Code"`
KeySalts []KeySalt `json:"KeySalts"`
}
// ForkInitResponse is the response to GET /auth/v4/sessions/forks.
type ForkInitResponse struct {
Code int `json:"Code"`
Selector string `json:"Selector"`
UserCode string `json:"UserCode"`
}
// ForkStatusResponse is the response to GET
// /auth/v4/sessions/forks/{selector} once the user has signed in.
type ForkStatusResponse struct {
Code int `json:"Code"`
Payload string `json:"Payload"` // base64 AES-256-GCM encrypted key password
UID string `json:"UID"`
AccessToken string `json:"AccessToken"`
RefreshToken string `json:"RefreshToken"`
}
// UserKey is one of the user's keys.
type UserKey struct {
ID string `json:"ID"`
Version int `json:"Version"`
PrivateKey string `json:"PrivateKey"` // armored, locked with the key password
Primary int `json:"Primary"` // 1 for the primary key
Active int `json:"Active"` // 0 for a key which must not be unlocked
}
// User is the account of the signed in user.
type User struct {
ID string `json:"ID"`
Name string `json:"Name"`
Email string `json:"Email"`
Keys []UserKey `json:"Keys"`
UsedSpace int64 `json:"UsedSpace"` // bytes used by all products
MaxSpace int64 `json:"MaxSpace"` // bytes of storage all products share
}
// UserResponse is the response to GET /core/v4/users.
type UserResponse struct {
Code int `json:"Code"`
User User `json:"User"`
}
// AddressKey is one of an address's keys.
type AddressKey struct {
ID string `json:"ID"`
Version int `json:"Version"`
PrivateKey string `json:"PrivateKey"` // armored, locked with the passphrase in Token
// Token is the armored key passphrase encrypted to the user key,
// or empty for a legacy key locked with the key password.
Token string `json:"Token"`
Signature string `json:"Signature"` // armored detached signature of the passphrase by the user key
Primary int `json:"Primary"` // 1 for the primary key
Active int `json:"Active"` // 0 for a key which must not be unlocked
Flags int `json:"Flags"` // bitmap of key usages
}
// Address is one of the user's email addresses.
type Address struct {
ID string `json:"ID"`
Email string `json:"Email"`
Status int `json:"Status"` // 0 disabled, 1 enabled, 2 deleting
Type int `json:"Type"` // 1 original, 2 alias, 3 custom, 4 premium, 5 external
Priority int `json:"Priority"` // order of the addresses, lowest first
Keys []AddressKey `json:"Keys"`
}
// AddressesResponse is the response to GET /core/v4/addresses.
type AddressesResponse struct {
Code int `json:"Code"`
Addresses []Address `json:"Addresses"`
}
// PublicKey is a public key of an address.
type PublicKey struct {
PublicKey string `json:"PublicKey"` // armored
Primary int `json:"Primary"` // 1 for the primary key
Flags int `json:"Flags"` // bitmap of key usages
Source int `json:"Source"` // 0 for internal keys
}
// PublicKeysResponse is the response to GET /core/v4/keys/all.
type PublicKeysResponse struct {
Code int `json:"Code"`
Address struct {
Keys []PublicKey `json:"Keys"`
} `json:"Address"`
}
// Link types, as Link.Type encodes them.
const (
LinkTypeFolder = 1
LinkTypeFile = 2
LinkTypeAlbum = 3
)
// Link states, as Link.State encodes them.
const (
LinkStateDraft = 0
LinkStateActive = 1
LinkStateTrashed = 2
)
// Volume is the summary of a volume in PrimaryRootShareResponseDto.
type Volume struct {
VolumeID string `json:"VolumeID"`
UsedSpace int64 `json:"UsedSpace"`
}
// Share is a share with its key, as ShareDto.
type Share struct {
ShareID string `json:"ShareID"`
CreatorEmail string `json:"CreatorEmail"`
Key string `json:"Key"` // armored share key, locked with the passphrase
Passphrase string `json:"Passphrase"` // armored, encrypted to the address key
PassphraseSignature string `json:"PassphraseSignature"` // armored detached signature by the creator
AddressID string `json:"AddressID"` // address whose key decrypts the passphrase
}
// Link is the metadata every node has, as LinkDto. Strings the API
// sends as null are empty.
type Link struct {
LinkID string `json:"LinkID"`
Type int `json:"Type"` // LinkTypeFolder, LinkTypeFile or LinkTypeAlbum
ParentLinkID string `json:"ParentLinkID"` // empty for a root
State int `json:"State"` // LinkStateDraft, LinkStateActive or LinkStateTrashed
CreateTime int64 `json:"CreateTime"` // Unix seconds
ModifyTime int64 `json:"ModifyTime"` // Unix seconds
TrashTime int64 `json:"TrashTime"` // Unix seconds, 0 unless trashed
Name string `json:"Name"` // armored, encrypted to the parent key
NameHash string `json:"NameHash"` // lower-case hex lookup hash; empty for a root
NodeKey string `json:"NodeKey"` // armored, locked with the node passphrase
NodePassphrase string `json:"NodePassphrase"`
NodePassphraseSignature string `json:"NodePassphraseSignature"`
SignatureEmail string `json:"SignatureEmail"` // signer of the key and attributes; empty if anonymous
NameSignatureEmail string `json:"NameSignatureEmail"` // signer of the name; empty if anonymous
ThirdParty bool `json:"ThirdParty"`
Sdk bool `json:"Sdk"`
}
// Folder is the folder part of a node's metadata, as FolderDto.
type Folder struct {
NodeHashKey string `json:"NodeHashKey"` // armored, encrypted to the node key
XAttr string `json:"XAttr"` // armored extended attributes; may be empty
IsImported bool `json:"IsImported"`
}
// ActiveRevision is the summary of a file's active revision, as
// ActiveRevisionDto.
type ActiveRevision struct {
RevisionID string `json:"RevisionID"`
CreateTime int64 `json:"CreateTime"` // Unix seconds
EncryptedSize int64 `json:"EncryptedSize"`
ChecksumVerified bool `json:"ChecksumVerified"`
ManifestSignature string `json:"ManifestSignature"` // armored
XAttr string `json:"XAttr"` // armored extended attributes; may be empty
SignatureEmail string `json:"SignatureEmail"` // signer of the revision; empty if anonymous
IsImported bool `json:"IsImported"`
ThirdParty bool `json:"ThirdParty"`
Sdk bool `json:"Sdk"`
}
// File is the file part of a node's metadata, as FileDto.
type File struct {
ActiveRevision *ActiveRevision `json:"ActiveRevision"` // nil for a draft
TotalEncryptedSize int64 `json:"TotalEncryptedSize"`
ContentKeyPacket string `json:"ContentKeyPacket"` // base64
MediaType string `json:"MediaType"`
ContentKeyPacketSignature string `json:"ContentKeyPacketSignature"` // armored; may be empty
}
// LinkDetails is a node's metadata, as FileDetailsDto and
// FolderDetailsDto.
type LinkDetails struct {
Link Link `json:"Link"`
Folder *Folder `json:"Folder"` // set for a folder
File *File `json:"File"` // set for a file
}
// MyFilesResponse is the response to GET /drive/v2/shares/my-files, as
// PrimaryRootShareResponseDto.
type MyFilesResponse struct {
Code int `json:"Code"`
Volume Volume `json:"Volume"`
Share Share `json:"Share"`
Link LinkDetails `json:"Link"` // the root folder
}
// CreateVolumeRequest is the body of POST /drive/volumes, as
// CreateVolumeRequestDto without the deprecated names.
type CreateVolumeRequest struct {
AddressID string `json:"AddressID"`
AddressKeyID string `json:"AddressKeyID"` // the primary key of AddressID
ShareKey string `json:"ShareKey"`
SharePassphrase string `json:"SharePassphrase"`
SharePassphraseSignature string `json:"SharePassphraseSignature"`
FolderName string `json:"FolderName"`
FolderKey string `json:"FolderKey"`
FolderPassphrase string `json:"FolderPassphrase"`
FolderPassphraseSignature string `json:"FolderPassphraseSignature"`
FolderHashKey string `json:"FolderHashKey"`
}
// ShareReference identifies the main share of a volume and its root,
// as ShareReferenceResponseDto.
type ShareReference struct {
ShareID string `json:"ShareID"`
LinkID string `json:"LinkID"`
}
// VolumeDetails is a volume, as VolumeResponseDto.
type VolumeDetails struct {
ID string `json:"ID"`
VolumeID string `json:"VolumeID"`
UsedSpace int64 `json:"UsedSpace"`
State int `json:"State"`
Share ShareReference `json:"Share"`
}
// VolumeResponse is the response to POST /drive/volumes and GET
// /drive/volumes/{volumeID}, as GetVolumeResponseDto.
type VolumeResponse struct {
Code int `json:"Code"`
Volume VolumeDetails `json:"Volume"`
}
// LinksRequest is the body of POST /drive/v2/volumes/{volumeID}/links
// and /drive/v2/volumes/{volumeID}/trash_multiple, as LinkIDsRequestDto.
type LinksRequest struct {
LinkIDs []string `json:"LinkIDs"`
}
// LinksResponse is the response to POST
// /drive/v2/volumes/{volumeID}/links, as LoadLinkDetailsResponseDto.
// Unknown IDs are left out.
type LinksResponse struct {
Code int `json:"Code"`
Links []LinkDetails `json:"Links"`
}
// ChildrenResponse is the response to GET
// /drive/v2/volumes/{volumeID}/folders/{linkID}/children, as
// ListChildrenResponseDto.
type ChildrenResponse struct {
Code int `json:"Code"`
LinkIDs []string `json:"LinkIDs"`
AnchorID string `json:"AnchorID"` // pass as AnchorID for the next page
More bool `json:"More"`
}
// CreateFolderRequest is the body of POST
// /drive/v2/volumes/{volumeID}/folders, as CreateFolderDto with the
// SignatureEmail the SDK sends in place of SignatureAddress.
type CreateFolderRequest struct {
ParentLinkID string `json:"ParentLinkID"`
NodeKey string `json:"NodeKey"`
NodeHashKey string `json:"NodeHashKey"`
NodePassphrase string `json:"NodePassphrase"`
NodePassphraseSignature string `json:"NodePassphraseSignature"`
SignatureEmail string `json:"SignatureEmail"`
Name string `json:"Name"`
Hash string `json:"Hash"`
XAttr string `json:"XAttr,omitempty"`
}
// CreateFolderResponse is the response to POST
// /drive/v2/volumes/{volumeID}/folders, as CreateFolderResponseDto.
type CreateFolderResponse struct {
Code int `json:"Code"`
Folder struct {
ID string `json:"ID"`
} `json:"Folder"`
}
// RenameRequest is the body of PUT
// /drive/v2/volumes/{volumeID}/links/{linkID}/rename, as
// RenameLinkRequestDto.
type RenameRequest struct {
Name string `json:"Name"` // reusing the session key of the current name
Hash string `json:"Hash"`
NameSignatureEmail string `json:"NameSignatureEmail"`
OriginalHash string `json:"OriginalHash"` // current name hash, which must match
}
// MoveRequest is the body of PUT
// /drive/v2/volumes/{volumeID}/links/{linkID}/move, as
// MoveLinkV2RequestDto.
//
// NodePassphraseSignature and SignatureEmail are given if and only if
// the node's key is anonymous (has no SignatureEmail).
type MoveRequest struct {
ParentLinkID string `json:"ParentLinkID"`
NodePassphrase string `json:"NodePassphrase"` // reusing the session key of the current passphrase
NodePassphraseSignature string `json:"NodePassphraseSignature,omitempty"`
SignatureEmail string `json:"SignatureEmail,omitempty"`
Name string `json:"Name"` // reusing the session key of the current name
NameSignatureEmail string `json:"NameSignatureEmail"`
Hash string `json:"Hash"`
OriginalHash string `json:"OriginalHash"` // current name hash, which must match
}
// CopyRequest is the body of POST
// /drive/volumes/{volumeID}/links/{linkID}/copy, as CopyLinkRequestDto.
// The server copies everything else of the node.
//
// NodePassphraseSignature and SignatureEmail are given if and only if
// the node's key is anonymous (has no SignatureEmail).
type CopyRequest struct {
Name string `json:"Name"` // reusing the session key of the current name
NodePassphrase string `json:"NodePassphrase"` // reusing the session key of the current passphrase
Hash string `json:"Hash"`
TargetVolumeID string `json:"TargetVolumeID"`
TargetParentLinkID string `json:"TargetParentLinkID"`
NameSignatureEmail string `json:"NameSignatureEmail"`
NodePassphraseSignature string `json:"NodePassphraseSignature,omitempty"`
SignatureEmail string `json:"SignatureEmail,omitempty"`
}
// CopyResponse is the response to POST
// /drive/volumes/{volumeID}/links/{linkID}/copy, as CopyLinkResponseDto.
type CopyResponse struct {
Code int `json:"Code"`
LinkID string `json:"LinkID"` // the new node
}
// LinkResult is the outcome for one link of a request on many.
type LinkResult struct {
LinkID string `json:"LinkID"`
Response struct {
Code int `json:"Code"`
Error string `json:"Error"`
} `json:"Response"`
}
// MultiLinkResponse is the response to a request on many links, such
// as POST /drive/v2/volumes/{volumeID}/trash_multiple, as
// MultiResponsesPerLinkFactory.
type MultiLinkResponse struct {
Code int `json:"Code"`
Responses []LinkResult `json:"Responses"`
}
// RevisionResponse is the response to GET
// /drive/v2/volumes/{volumeID}/files/{linkID}/revisions/{revisionID},
// as GetRevisionResponseDto.
type RevisionResponse struct {
Code int `json:"Code"`
Revision Revision `json:"Revision"`
}
// Revision is a revision of a file with a page of its blocks, as
// DetailedRevisionResponseDto.
type Revision struct {
ID string `json:"ID"`
ManifestSignature string `json:"ManifestSignature"` // armored
Size int64 `json:"Size"` // encrypted size
State int `json:"State"`
XAttr string `json:"XAttr"` // armored extended attributes; may be empty
Blocks []Block `json:"Blocks"`
Thumbnails []Thumbnail `json:"Thumbnails"`
SignatureEmail string `json:"SignatureEmail"` // signer of the manifest; empty if anonymous
CreateTime int64 `json:"CreateTime"` // Unix seconds
}
// Block is a block of a revision with the URL to download it, as
// BlockResponseDto.
type Block struct {
Index int `json:"Index"` // from 1
Hash string `json:"Hash"` // base64 SHA-256 of the encrypted block
Token string `json:"Token"` // send as pm-storage-token
BareURL string `json:"BareURL"` // storage URL of the block
}
// Thumbnail is a thumbnail of a revision, as ThumbnailResponseDto.
type Thumbnail struct {
ThumbnailID string `json:"ThumbnailID"`
Type int `json:"Type"`
Hash string `json:"Hash"` // base64 SHA-256 of the encrypted thumbnail
Size int64 `json:"Size"`
}
// ConflictDetails are the Details of a CodeAlreadyExists error from
// creating a node, naming the node which has the name.
type ConflictDetails struct {
ConflictLinkID string `json:"ConflictLinkID"`
ConflictRevisionID string `json:"ConflictRevisionID"` // active revision; empty for a draft or folder
ConflictDraftRevisionID string `json:"ConflictDraftRevisionID"` // draft revision; empty if none
ConflictDraftClientUID string `json:"ConflictDraftClientUID"` // ClientUID of the draft revision's creator
}
// CreateFileRequest is the body of POST
// /drive/v2/volumes/{volumeID}/files, as CreateFileDto: a draft file.
type CreateFileRequest struct {
ParentLinkID string `json:"ParentLinkID"`
Name string `json:"Name"`
Hash string `json:"Hash"`
MIMEType string `json:"MIMEType"`
ClientUID *string `json:"ClientUID"` // identifies the creator's drafts; null if none
IntendedUploadSize *int64 `json:"IntendedUploadSize"` // rounded size for an early quota check; null if unknown
NodeKey string `json:"NodeKey"`
NodePassphrase string `json:"NodePassphrase"`
NodePassphraseSignature string `json:"NodePassphraseSignature"`
ContentKeyPacket string `json:"ContentKeyPacket"` // base64
ContentKeyPacketSignature string `json:"ContentKeyPacketSignature"` // armored, by the node key
SignatureAddress string `json:"SignatureAddress"` // signer of the name and passphrase
}
// CreateFileResponse is the response to POST
// /drive/v2/volumes/{volumeID}/files, as CreateDraftFileResponseDto.
type CreateFileResponse struct {
Code int `json:"Code"`
File struct {
ID string `json:"ID"`
RevisionID string `json:"RevisionID"` // the draft revision
} `json:"File"`
}
// CreateRevisionRequest is the body of POST
// /drive/v2/volumes/{volumeID}/files/{linkID}/revisions, as
// CreateRevisionRequestDto: a draft revision of an active file.
type CreateRevisionRequest struct {
CurrentRevisionID string `json:"CurrentRevisionID"` // the active revision, which must match
ClientUID *string `json:"ClientUID"`
IntendedUploadSize *int64 `json:"IntendedUploadSize"`
}
// CreateRevisionResponse is the response to POST
// /drive/v2/volumes/{volumeID}/files/{linkID}/revisions.
type CreateRevisionResponse struct {
Code int `json:"Code"`
Revision struct {
ID string `json:"ID"`
} `json:"Revision"`
}
// VerificationResponse is the response to GET
// /drive/v2/volumes/{volumeID}/links/{linkID}/revisions/{revisionID}/verification,
// as VerificationData.
type VerificationResponse struct {
Code int `json:"Code"`
VerificationCode string `json:"VerificationCode"` // base64
ContentKeyPacket string `json:"ContentKeyPacket"` // base64
}
// BlockUploadRequest is the body of POST /drive/blocks, as
// RequestUploadInput without its deprecated fields.
type BlockUploadRequest struct {
AddressID string `json:"AddressID"` // address signing the blocks
VolumeID string `json:"VolumeID"`
LinkID string `json:"LinkID"`
RevisionID string `json:"RevisionID"`
BlockList []BlockUploadInfo `json:"BlockList"`
ThumbnailList []struct{} `json:"ThumbnailList"`
}
// BlockUploadInfo is a block to upload, as RequestUploadBlockInput
// without its deprecated Size and Hash, which the SDK doesn't send.
type BlockUploadInfo struct {
Index int `json:"Index"` // from 1, consecutive
EncSignature string `json:"EncSignature"` // armored signature of the plaintext, encrypted
Verifier BlockVerifier `json:"Verifier"`
}
// BlockVerifier proves a block was encrypted with the right key.
type BlockVerifier struct {
Token string `json:"Token"` // base64 verification code XOR the start of the encrypted block
}
// BlockUploadResponse is the response to POST /drive/blocks, as
// RequestUploadResponse.
type BlockUploadResponse struct {
Code int `json:"Code"`
UploadLinks []UploadLink `json:"UploadLinks"`
}
// UploadLink is the storage URL to upload a block to, as BlockURL.
type UploadLink struct {
Index int `json:"Index"`
BareURL string `json:"BareURL"`
Token string `json:"Token"` // send as pm-storage-token
}
// CommitRevisionRequest is the body of PUT
// /drive/v2/volumes/{volumeID}/files/{linkID}/revisions/{revisionID},
// as CommitRevisionDto without its deprecated fields.
type CommitRevisionRequest struct {
ManifestSignature string `json:"ManifestSignature"` // armored, of the block hashes in order
SignatureAddress string `json:"SignatureAddress"` // signer of the manifest, blocks and attributes
XAttr string `json:"XAttr"` // armored extended attributes
ChecksumVerified bool `json:"ChecksumVerified"` // whether the SHA1 matched the source's
Photo *struct{} `json:"Photo"` // always null outside the photo volume
}
+333
View File
@@ -0,0 +1,333 @@
package protondrive_test
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/rclone/rclone/backend/protondrive/protontest"
"github.com/rclone/rclone/fs"
"github.com/rclone/rclone/fs/config"
"github.com/rclone/rclone/fs/object"
"github.com/rclone/rclone/fs/operations"
fssync "github.com/rclone/rclone/fs/sync"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// Routes counted by the request budgets.
const (
linksRoute = "POST /drive/v2/volumes/{volumeID}/links"
revisionDraftRoute = "POST /drive/v2/volumes/{volumeID}/files/{linkID}/revisions"
verifierRoute = "GET /drive/v2/volumes/{volumeID}/links/{linkID}/revisions/{revisionID}/verification"
blockURLsRoute = "POST /drive/blocks"
blockPutRoute = "POST /storage/upload/{token}"
commitRoute = "PUT /drive/v2/volumes/{volumeID}/files/{linkID}/revisions/{revisionID}"
createFolderRoute = "POST /drive/v2/volumes/{volumeID}/folders"
)
// TestMain runs the tests, then fails the run if any of them, through
// any fake server, requested a removed share-scoped route.
//
// The config file is RCLONE_CONFIG's, as on rclone's command line,
// whichever tests run: only fstest.Initialise would apply it, so the
// default config, which may hold a real Proton remote, would be read.
func TestMain(m *testing.M) {
if path, ok := os.LookupEnv("RCLONE_CONFIG"); ok {
if err := config.SetConfigPath(path); err != nil {
fmt.Fprintf(os.Stderr, "FAIL: RCLONE_CONFIG: %v\n", err)
os.Exit(1)
}
}
code := m.Run()
if legacy := protontest.LegacyRequests(); len(legacy) > 0 {
fmt.Fprintf(os.Stderr, "FAIL: TestBudgetNoLegacyRoutes: removed share-scoped routes were requested: %v\n", legacy)
code = 1
}
os.Exit(code)
}
// totalRequests returns the sum of the counts in requests.
func totalRequests(requests map[string]int) int {
n := 0
for _, count := range requests {
n += count
}
return n
}
// ceilDiv returns n/d rounded up.
func ceilDiv(n, d int) int {
return (n + d - 1) / d
}
// childPages returns the number of child ID pages the fake returns for
// a folder of n children with page size pageSize: at least one.
func childPages(n, pageSize int) int {
return max(1, ceilDiv(n, pageSize))
}
// seedFiles seeds n small files into folder parentID.
func seedFiles(u *protontest.User, parentID string, n int) {
for i := range n {
u.SeedFile(parentID, fmt.Sprintf("file%03d.txt", i), []byte(fmt.Sprintf("file %d", i)), time.Now())
}
}
// budgetSizes are the folder sizes the listing budgets are checked at:
// around the metadata batch of 100 and the child page of 150.
var budgetSizes = []int{0, 1, 100, 101, 250}
// budgetPageSize is the child ID page size the listing budgets use.
const budgetPageSize = 150
// seedBudgetFolders seeds a folder "nN" of N files for each of sizes.
func seedBudgetFolders(srv *protontest.Server, u *protontest.User, sizes []int) {
srv.SetChildrenPageSize(budgetPageSize)
for _, n := range sizes {
seedFiles(u, u.SeedFolder(u.RootID(), fmt.Sprintf("n%d", n)), n)
}
}
// TestBudgetList checks listing a folder of N files costs one request
// per page of child IDs and one per batch of 100 nodes, within spec
// 9.4's P + ceil(N/100) + 1. The Fs is rooted at the folder, so finding
// it is part of NewFs.
//
// A second listing costs nothing with enable_caching at its default,
// and with it off costs the same as the first, no more. The latter is
// checked at the sizes which differ in kind - empty, one batch, several
// pages and batches - to save time.
//
// Measured, per route (children pages + links batches):
// N=0: 1+0, N=1: 1+1, N=100: 1+1, N=101: 1+2, N=250: 2+3 - exactly
// P + ceil(N/100), so the bound is that, without the spec's +1. Warm: 0
// at every N; with caching off the second listing repeats the first.
func TestBudgetList(t *testing.T) {
ctx := context.Background()
remote, srv, u := setupFakeRemote(t)
seedBudgetFolders(srv, u, budgetSizes)
list := func(t *testing.T, f fs.Fs, n int) map[string]int {
t.Helper()
srv.ResetRequests()
entries, err := f.List(ctx, "")
require.NoError(t, err)
require.Len(t, entries, n)
return srv.Requests()
}
for _, n := range budgetSizes {
t.Run(fmt.Sprint(n), func(t *testing.T) {
root := fmt.Sprintf("n%d", n)
f := newFakeFs(t, remote, root)
cold := list(t, f, n)
pages, batches := childPages(n, budgetPageSize), ceilDiv(n, 100)
assert.LessOrEqual(t, cold[childrenRoute], pages, "%v", cold)
assert.LessOrEqual(t, cold[linksRoute], batches, "%v", cold)
assert.LessOrEqual(t, totalRequests(cold), pages+batches, "%v", cold)
assert.Empty(t, list(t, f, n), "warm listing")
if n == 100 || n == 101 {
return
}
t.Setenv(fakeEnvPrefix+"ENABLE_CACHING", "false")
uncached := newFakeFs(t, remote, root)
assert.Equal(t, cold, list(t, uncached, n), "first listing without caching")
assert.Equal(t, cold, list(t, uncached, n), "second listing without caching")
})
}
}
// TestBudgetNoopSync checks a cold sync of a tree already in sync, by
// size and modification time and by --checksum, lists each folder once
// and transfers nothing: SHA-1s come from the metadata.
//
// For D = 6 folders (the root and 5 below it) and N = 200 files (40 per
// folder), the plan's bound is 2*D + ceil(N/100) + 5 = 19. Measured, per
// route, either way: 6 children pages + 6 links batches = 12, one page
// and one batch per folder, as no folder has more than 100 children.
// The bound is that: 2*D.
func TestBudgetNoopSync(t *testing.T) {
const (
folders = 5
filesPerFolder = 40
budget = 2 * (folders + 1)
)
remote, srv, u := setupFakeRemote(t)
local := t.TempDir()
modTime := time.Now().Add(-time.Hour).Truncate(time.Second)
for d := range folders {
dir := fmt.Sprintf("dir%d", d)
id := u.SeedFolder(u.RootID(), dir)
require.NoError(t, os.Mkdir(filepath.Join(local, dir), 0o777))
for i := range filesPerFolder {
name := fmt.Sprintf("file%02d.txt", i)
content := []byte(fmt.Sprintf("content of %s/%s", dir, name))
u.SeedFile(id, name, content, modTime)
p := filepath.Join(local, dir, name)
require.NoError(t, os.WriteFile(p, content, 0o666))
require.NoError(t, os.Chtimes(p, modTime, modTime))
}
}
for _, checksum := range []bool{false, true} {
t.Run(fmt.Sprintf("checksum=%v", checksum), func(t *testing.T) {
ctx, ci := fs.AddConfig(context.Background())
ci.CheckSum = checksum
src, err := fs.NewFs(ctx, local)
require.NoError(t, err)
f := newFakeFs(t, remote, "")
srv.ResetRequests()
require.NoError(t, fssync.Sync(ctx, f, src, false))
got := srv.Requests()
assert.LessOrEqual(t, totalRequests(got), budget, "%v", got)
assert.Zero(t, blockTransfers(got), "%v", got)
assert.Zero(t, got[draftRoute]+got[revisionDraftRoute], "drafts created: %v", got)
for route := range got {
assert.NotContains(t, route, "/revisions/", "file content read: %v", got)
}
})
}
}
// TestBudgetNewObject checks finding a file three folders deep, cold,
// costs one listing, a children page and a links batch, per folder on
// its path.
//
// The plan's bound is 2 per level + 1 = 7 for three levels. Measured
// for a/b/c/file.txt, per route: 4 children pages + 4 links batches =
// 8, as the root is listed too - it is a fourth folder on the path,
// and Proton has no route to look a child up by name. The bound is 2
// per folder listed: 2*4.
func TestBudgetNewObject(t *testing.T) {
ctx := context.Background()
remote, srv, u := setupFakeRemote(t)
parent := u.RootID()
for _, name := range []string{"a", "b", "c"} {
seedFiles(u, parent, 3)
parent = u.SeedFolder(parent, name)
}
u.SeedFile(parent, "file.txt", []byte("deep"), time.Now())
f := newFakeFs(t, remote, "")
srv.ResetRequests()
o, err := f.NewObject(ctx, "a/b/c/file.txt")
require.NoError(t, err)
assert.Equal(t, int64(4), o.Size())
got := srv.Requests()
const listed = 4 // the root, a, b and c
assert.LessOrEqual(t, got[childrenRoute], listed, "%v", got)
assert.LessOrEqual(t, got[linksRoute], listed, "%v", got)
assert.LessOrEqual(t, totalRequests(got), 2*listed, "%v", got)
}
// TestBudgetUpload checks the requests of uploading new files to a
// folder: one listing of the folder to check the name is free, the
// first time only, then per file a draft, its verification code, at
// most one block URL request per block, one PUT per block, the commit
// and one read of the committed node.
//
// Measured, per route, into a folder of 3 files:
//
// small.txt, cold: children 1 + links 1 (the listing), draft 1,
// verifier 1, block URLs 1, block PUTs 1, commit 1, links 1 = 8
// big.bin, 3 blocks: draft 1, verifier 1, block URLs 1-3 (as blocks
// are ready), block PUTs 3, commit 1, links 1 = 8-10
// again.txt: as small.txt without the listing = 6
func TestBudgetUpload(t *testing.T) {
ctx := context.Background()
remote, srv, u := setupFakeRemote(t)
seedFiles(u, u.SeedFolder(u.RootID(), "dir"), 3)
f := newFakeFs(t, remote, "dir")
for _, test := range []struct {
name string
size int
blocks int
listed bool // the folder is listed to check the name is free
}{
{"small.txt", 5, 1, true},
{"big.bin", 2*blockSize + 5, 3, false},
{"again.txt", 5, 1, false},
} {
t.Run(test.name, func(t *testing.T) {
srv.ResetRequests()
content := patternedContent(test.size, 1)
_, err := operations.Copy(ctx, f, nil, test.name, object.NewMemoryObject(test.name, time.Now(), content))
require.NoError(t, err)
got := srv.Requests()
listing := 0
if test.listed {
listing = 2 // a children page and a links batch
assert.Equal(t, 1, got[childrenRoute], "%v", got)
} else {
assert.Zero(t, got[childrenRoute], "folder listed again: %v", got)
}
assert.Equal(t, 1, got[draftRoute], "%v", got)
assert.Equal(t, 1, got[verifierRoute], "%v", got)
assert.GreaterOrEqual(t, got[blockURLsRoute], 1, "%v", got)
assert.LessOrEqual(t, got[blockURLsRoute], test.blocks, "%v", got)
assert.Equal(t, test.blocks, got[blockPutRoute], "%v", got)
assert.Equal(t, 1, got[commitRoute], "%v", got)
assert.Equal(t, listing/2+1, got[linksRoute], "%v", got)
assert.LessOrEqual(t, totalRequests(got), listing+4+2*test.blocks, "%v", got)
})
}
}
// TestBudgetMkdir checks making folders side by side lists their
// parent once, to check the first name is free, and then costs one
// request per folder.
//
// Measured, per route, into a folder of 3 files: x: children 1 +
// links 1 + create 1 = 3; y and z: create 1 each.
func TestBudgetMkdir(t *testing.T) {
ctx := context.Background()
remote, srv, u := setupFakeRemote(t)
seedFiles(u, u.SeedFolder(u.RootID(), "dir"), 3)
f := newFakeFs(t, remote, "dir")
srv.ResetRequests()
for _, name := range []string{"x", "y", "z"} {
require.NoError(t, f.Mkdir(ctx, name))
}
got := srv.Requests()
assert.Equal(t, map[string]int{childrenRoute: 1, linksRoute: 1, createFolderRoute: 3}, got)
entries, err := f.List(ctx, "")
require.NoError(t, err)
assert.Len(t, entries, 6, "the cached listing holds the new folders")
}
// TestBudgetNoLegacyRoutes checks a run of everyday operations requests
// no removed share-scoped route. TestMain checks the same of every
// test in the package, through every fake server.
func TestBudgetNoLegacyRoutes(t *testing.T) {
ctx := context.Background()
remote, srv, u := setupFakeRemote(t)
seedFiles(u, u.SeedFolder(u.RootID(), "dir"), 3)
f := newFakeFs(t, remote, "")
o, err := operations.Copy(ctx, f, nil, "dir/new.txt", object.NewMemoryObject("dir/new.txt", time.Now(), []byte("new")))
require.NoError(t, err)
assert.Equal(t, "new", readObject(t, o))
_, err = operations.Move(ctx, f, nil, "moved/new.txt", o)
require.NoError(t, err)
require.NoError(t, operations.Purge(ctx, f, "dir"))
_, err = f.List(ctx, "moved")
require.NoError(t, err)
_, err = f.Features().About(ctx)
require.NoError(t, err)
for route := range srv.Requests() {
if strings.Contains(route, "/drive/shares/") {
assert.Equal(t, "GET /drive/shares/{shareID}", route)
}
}
assert.Empty(t, protontest.LegacyRequests())
}
+221
View File
@@ -0,0 +1,221 @@
package protondrive
import (
"slices"
"sync"
)
// nodeCache holds decrypted nodes and folder listings in memory.
//
// A nil nodeCache, as newNodeCache returns when caching is disabled,
// holds nothing: its lookups miss and its updates do nothing. Cached
// nodes are shared and must not be modified.
type nodeCache struct {
mu sync.Mutex
nodes map[string]*node // by ID
listings map[string][]string // IDs of the complete listing of a folder, by folder ID
fetching map[string]*fetchState // folders being listed from the server, by folder ID
}
// fetchState tracks the listings of a folder being fetched, so that
// one which began before a change to the folder's children isn't
// cached over it.
type fetchState struct {
count int // listings in progress
changes uint64 // changes to the folder's children since the first began
}
// newNodeCache returns an empty cache, or nil if enabled is false.
func newNodeCache(enabled bool) *nodeCache {
if !enabled {
return nil
}
return &nodeCache{
nodes: make(map[string]*node),
listings: make(map[string][]string),
fetching: make(map[string]*fetchState),
}
}
// get returns the node id, or nil if it isn't cached.
func (c *nodeCache) get(id string) *node {
if c == nil {
return nil
}
c.mu.Lock()
defer c.mu.Unlock()
return c.nodes[id]
}
// put caches n, replacing any node with its ID. If the cached node had
// another parent, the listings of both parents are forgotten.
func (c *nodeCache) put(n *node) {
if c == nil {
return
}
c.mu.Lock()
defer c.mu.Unlock()
if old := c.nodes[n.ID]; old != nil && old.ParentID != n.ParentID {
c.childrenChangedLocked(old.ParentID)
c.childrenChangedLocked(n.ParentID)
}
c.nodes[n.ID] = n
}
// children returns the cached listing of folder parentID. It reports
// false if the listing, or any node in it, isn't cached.
func (c *nodeCache) children(parentID string) ([]*node, bool) {
if c == nil {
return nil, false
}
c.mu.Lock()
defer c.mu.Unlock()
ids, ok := c.listings[parentID]
if !ok {
return nil, false
}
out := make([]*node, 0, len(ids))
for _, id := range ids {
n := c.nodes[id]
if n == nil {
return nil, false
}
out = append(out, n)
}
return out, true
}
// setChildren caches nodes as the complete listing of folder parentID,
// and each of the nodes.
func (c *nodeCache) setChildren(parentID string, nodes []*node) {
if c == nil {
return
}
c.mu.Lock()
defer c.mu.Unlock()
c.setChildrenLocked(parentID, nodes)
}
// setChildrenLocked is setChildren with c.mu held.
func (c *nodeCache) setChildrenLocked(parentID string, nodes []*node) {
ids := make([]string, len(nodes))
for i, n := range nodes {
ids[i] = n.ID
c.nodes[n.ID] = n
}
c.listings[parentID] = ids
}
// startFetch records that a listing of folder parentID is being read
// from the server and returns the token to pass to endFetch, which
// must be called.
func (c *nodeCache) startFetch(parentID string) (token uint64) {
if c == nil {
return 0
}
c.mu.Lock()
defer c.mu.Unlock()
st := c.fetching[parentID]
if st == nil {
st = &fetchState{}
c.fetching[parentID] = st
}
st.count++
return st.changes
}
// endFetch ends a listing of folder parentID begun by startFetch with
// token. If complete is set, nodes are cached as the listing, unless
// the folder's children changed since it began, when what it read may
// be out of date.
func (c *nodeCache) endFetch(parentID string, token uint64, nodes []*node, complete bool) {
if c == nil {
return
}
c.mu.Lock()
defer c.mu.Unlock()
st := c.fetching[parentID]
if complete && st.changes == token {
c.setChildrenLocked(parentID, nodes)
}
st.count--
if st.count == 0 {
delete(c.fetching, parentID)
}
}
// childrenChangedLocked forgets the listing of folder parentID, and
// marks its listings being fetched as out of date. c.mu must be held.
func (c *nodeCache) childrenChangedLocked(parentID string) {
delete(c.listings, parentID)
if st := c.fetching[parentID]; st != nil {
st.changes++
}
}
// add caches n, a node new to its parent, and adds it to the cached
// listing of its parent, if there is one.
func (c *nodeCache) add(n *node) {
if c == nil {
return
}
c.mu.Lock()
defer c.mu.Unlock()
if old := c.nodes[n.ID]; old != nil && old.ParentID != n.ParentID {
c.childrenChangedLocked(old.ParentID)
c.childrenChangedLocked(n.ParentID)
}
c.nodes[n.ID] = n
if st := c.fetching[n.ParentID]; st != nil {
st.changes++
}
if ids, ok := c.listings[n.ParentID]; ok && !slices.Contains(ids, n.ID) {
c.listings[n.ParentID] = append(ids, n.ID)
}
}
// invalidate forgets the node id, its listing if it is a folder, and
// the listing of its parent.
func (c *nodeCache) invalidate(id string) {
if c == nil {
return
}
c.mu.Lock()
defer c.mu.Unlock()
if n := c.nodes[id]; n != nil {
c.childrenChangedLocked(n.ParentID)
} else {
// Its parent is unknown, and any listing being fetched may
// hold it.
for _, st := range c.fetching {
st.changes++
}
}
delete(c.nodes, id)
c.childrenChangedLocked(id)
}
// invalidateChildren forgets the listing of folder parentID, but not
// the nodes in it.
func (c *nodeCache) invalidateChildren(parentID string) {
if c == nil {
return
}
c.mu.Lock()
defer c.mu.Unlock()
c.childrenChangedLocked(parentID)
}
// clear forgets everything.
func (c *nodeCache) clear() {
if c == nil {
return
}
c.mu.Lock()
defer c.mu.Unlock()
clear(c.nodes)
clear(c.listings)
for _, st := range c.fetching {
st.changes++
}
}
+184
View File
@@ -0,0 +1,184 @@
package protondrive
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestNodeCacheDisabled(t *testing.T) {
c := newNodeCache(false)
require.Nil(t, c)
n := &node{ID: "a", ParentID: "root"}
c.put(n)
c.setChildren("root", []*node{n})
assert.Nil(t, c.get("a"))
_, ok := c.children("root")
assert.False(t, ok)
c.invalidate("a")
c.invalidateChildren("root")
c.clear()
}
func TestNodeCache(t *testing.T) {
c := newNodeCache(true)
dir := &node{ID: "dir", ParentID: "root", IsFolder: true}
file := &node{ID: "file", ParentID: "dir"}
assert.Nil(t, c.get("dir"))
_, ok := c.children("root")
assert.False(t, ok)
c.setChildren("root", []*node{dir})
c.setChildren("dir", []*node{file})
assert.Same(t, dir, c.get("dir"))
assert.Same(t, file, c.get("file"))
got, ok := c.children("dir")
require.True(t, ok)
assert.Equal(t, []*node{file}, got)
// An empty listing is a listing.
c.setChildren("empty", nil)
got, ok = c.children("empty")
assert.True(t, ok)
assert.Empty(t, got)
// put replaces the node in the listing.
renamed := &node{ID: "file", ParentID: "dir", Name: "new"}
c.put(renamed)
got, ok = c.children("dir")
require.True(t, ok)
assert.Equal(t, []*node{renamed}, got)
// put of a node with another parent forgets both listings.
c.setChildren("other", nil)
moved := &node{ID: "file", ParentID: "other"}
c.put(moved)
assert.Same(t, moved, c.get("file"))
_, ok = c.children("dir")
assert.False(t, ok)
_, ok = c.children("other")
assert.False(t, ok)
_, ok = c.children("root")
assert.True(t, ok)
c.setChildren("dir", []*node{renamed})
// invalidate forgets the node and its parent's listing.
c.invalidate("file")
assert.Nil(t, c.get("file"))
_, ok = c.children("dir")
assert.False(t, ok)
_, ok = c.children("root")
assert.True(t, ok)
// and a folder's own listing.
c.setChildren("dir", []*node{file})
c.invalidate("dir")
_, ok = c.children("dir")
assert.False(t, ok)
_, ok = c.children("root")
assert.False(t, ok)
assert.Same(t, file, c.get("file"))
c.setChildren("dir", []*node{file})
c.invalidateChildren("dir")
_, ok = c.children("dir")
assert.False(t, ok)
assert.Same(t, file, c.get("file"))
c.setChildren("dir", []*node{file})
c.clear()
assert.Nil(t, c.get("file"))
_, ok = c.children("dir")
assert.False(t, ok)
}
func TestNodeCacheAdd(t *testing.T) {
c := newNodeCache(true)
a := &node{ID: "a", ParentID: "dir"}
b := &node{ID: "b", ParentID: "dir"}
// Without a listing of its parent, only the node is cached.
c.add(a)
assert.Same(t, a, c.get("a"))
_, ok := c.children("dir")
assert.False(t, ok)
c.setChildren("dir", []*node{a})
c.add(b)
got, ok := c.children("dir")
require.True(t, ok)
assert.Equal(t, []*node{a, b}, got)
// A node already listed is replaced, not listed twice.
b2 := &node{ID: "b", ParentID: "dir", Name: "new"}
c.add(b2)
got, ok = c.children("dir")
require.True(t, ok)
assert.Equal(t, []*node{a, b2}, got)
newNodeCache(false).add(a)
}
func TestNodeCacheFetch(t *testing.T) {
a := &node{ID: "a", ParentID: "dir"}
listed := func(c *nodeCache) bool {
_, ok := c.children("dir")
return ok
}
c := newNodeCache(true)
token := c.startFetch("dir")
c.endFetch("dir", token, []*node{a}, true)
assert.True(t, listed(c), "unchanged")
assert.Empty(t, c.fetching)
c = newNodeCache(true)
token = c.startFetch("dir")
c.endFetch("dir", token, []*node{a}, false)
assert.False(t, listed(c), "incomplete")
assert.Empty(t, c.fetching)
for name, change := range map[string]func(c *nodeCache){
"add": func(c *nodeCache) { c.add(&node{ID: "b", ParentID: "dir"}) },
"invalidate child": func(c *nodeCache) { c.invalidate("a") },
"invalidate unknown": func(c *nodeCache) { c.invalidate("unknown") },
"invalidate folder": func(c *nodeCache) { c.invalidate("dir") },
"invalidateChildren": func(c *nodeCache) { c.invalidateChildren("dir") },
"put moved in": func(c *nodeCache) { c.put(&node{ID: "other", ParentID: "dir"}) },
"put moved out": func(c *nodeCache) { c.put(&node{ID: "a", ParentID: "elsewhere"}) },
"clear": func(c *nodeCache) { c.clear() },
} {
t.Run(name, func(t *testing.T) {
c := newNodeCache(true)
c.put(a)
c.put(&node{ID: "other", ParentID: "elsewhere"})
first := c.startFetch("dir")
second := c.startFetch("dir")
change(c)
c.endFetch("dir", first, []*node{a}, true)
assert.False(t, listed(c), "first listing cached after a change")
// A fetch begun after the change is cached.
third := c.startFetch("dir")
c.endFetch("dir", second, []*node{a}, true)
assert.False(t, listed(c), "second listing cached after a change")
c.endFetch("dir", third, []*node{a}, true)
assert.True(t, listed(c))
assert.Empty(t, c.fetching, "fetch state kept")
})
}
// Changes elsewhere don't matter.
c = newNodeCache(true)
c.put(&node{ID: "x", ParentID: "other"})
token = c.startFetch("dir")
c.add(&node{ID: "y", ParentID: "other"})
c.invalidate("x")
c.invalidateChildren("other")
c.endFetch("dir", token, []*node{a}, true)
assert.True(t, listed(c))
var disabled *nodeCache
disabled.endFetch("dir", disabled.startFetch("dir"), nil, true)
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+786
View File
@@ -0,0 +1,786 @@
package protondrive
import (
"bytes"
"context"
"crypto/sha256"
"encoding/base64"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"slices"
"strconv"
"sync"
"github.com/ProtonMail/gopenpgp/v3/crypto"
"github.com/rclone/rclone/backend/protondrive/api"
"github.com/rclone/rclone/fs"
"github.com/rclone/rclone/lib/pool"
"github.com/rclone/rclone/lib/rest"
)
var (
// errIntegrity is returned when a file's content doesn't match
// what its uploader signed or claimed.
errIntegrity = errors.New("integrity check failed")
// errBlockHash is returned when a block doesn't match its hash
// however many times it is downloaded.
errBlockHash = errors.New("block hash mismatch")
// errDownloadClosed is returned by Read after Close.
errDownloadClosed = errors.New("download closed")
)
// signatureHint ends an error for a signature which fails, telling the
// user how to read the file anyway.
const signatureHint = "set ignore_signature_errors to read the file anyway"
const (
// defaultBlockSize is the plaintext size of every block but the
// last of a file the client uploads.
defaultBlockSize = 4 << 20
// blocksPerPage is the most blocks one revision request lists.
blocksPerPage = 20
// maxBlockSize is the largest block accepted, encrypted or
// decrypted.
maxBlockSize = 8 << 20
// blockAttempts is how many times a block whose hash doesn't match
// is downloaded.
blockAttempts = 3
// downloadAhead is the most blocks of one download fetched or
// waiting to be read, besides the one being read.
downloadAhead = 4
// minUnclaimedBlockSize bounds the blocks of a file which doesn't
// record its block sizes: there are at most its encrypted size over
// this, plus one.
minUnclaimedBlockSize = 1 << 20
// maxFileBlocks is the most blocks a file may list: 2 TiB in
// 4 MiB blocks, beyond any one file the largest Proton plan, of a
// few TB in all, could hold. A full read holds the metadata of
// every block until the manifest is checked, so this bounds how
// many whatever the server claims: about 250-350 MiB at the cap
// with realistic tokens and URLs. Their length isn't bounded.
maxFileBlocks = 1 << 19
// contentKeyFallbackUntil is the Unix time until which a content
// key packet may be signed by any of the account's address keys,
// as in the SDK.
contentKeyFallbackUntil = 1672531200 // 2023-01-01
)
// open returns a reader of limit bytes of the content of file n from
// offset, or to the end if limit < 0.
//
// A read from offset 0 to the end lists every block and checks the
// manifest signature before releasing any byte: if it fails, the first
// Read returns errIntegrity, unless IgnoreSignatureErrors is set, when
// it is logged. A ranged read checks each block against its listed
// hash but can't check the manifest. A node key or content key
// signature which fails is errIntegrity from open, on the same terms.
// The reader must be closed.
func (c *client) open(ctx context.Context, n *node, offset, limit int64) (io.ReadCloser, error) {
return c.openRead(ctx, n, offset, limit, offset == 0 && limit < 0)
}
// openWhole is open for limit bytes from offset 0 of a range which
// covers the whole file, checked as a full read is.
func (c *client) openWhole(ctx context.Context, n *node, limit int64) (io.ReadCloser, error) {
return c.openRead(ctx, n, 0, limit, true)
}
// openRead is open, listing every block and checking the manifest
// before releasing any byte if full is set, which needs offset 0.
func (c *client) openRead(ctx context.Context, n *node, offset, limit int64, full bool) (io.ReadCloser, error) {
if n.IsFolder || n.link == nil || n.link.File == nil || n.RevisionID == "" {
return nil, fmt.Errorf("open %q: not a file with content", n.Name)
}
if offset < 0 {
return nil, fmt.Errorf("open %q: negative offset %d", n.Name, offset)
}
sk, err := c.contentKey(n, "reading")
if err != nil {
return nil, fmt.Errorf("open %q: %w", n.Name, err)
}
var sizes []int64
if n.X != nil {
sizes = n.X.BlockSizes
}
full = full && offset == 0
if len(sizes) > c.maxFileBlocks {
return nil, fmt.Errorf("open %q: %w: %d blocks recorded, more than %d blocks", n.Name, errIntegrity, len(sizes), c.maxFileBlocks)
}
if sizes != nil {
var total int64
for i, size := range sizes {
// Bounding each size bounds the total too.
if size > maxBlockSize {
return nil, fmt.Errorf("open %q: %w: block %d recorded as %d bytes", n.Name, errIntegrity, i+1, size)
}
total += size
}
switch {
case full:
// A full read always lists the blocks, to reach the
// manifest.
case offset >= total:
limit = 0
case limit >= 0:
limit = min(limit, total-offset)
}
}
if limit == 0 {
return io.NopCloser(bytes.NewReader(nil)), nil
}
// Without recorded block sizes only the sizes learnt by earlier
// reads tell which block holds offset, as old clients didn't all
// write 4 MiB blocks, so the read starts at the last block they
// reach and discards the bytes before offset.
first, skip, last := 1, offset, -1
var startHash []byte
switch {
case sizes != nil:
first, skip = blockAt(sizes, offset)
if limit > 0 && !full {
last, _ = blockAt(sizes, offset+limit-1)
}
case !full:
first, skip, last, startHash = c.learnt.locate(n.RevisionID, offset, limit)
}
maxBlocks := len(sizes)
if sizes == nil {
maxBlocks = int(min(n.EncryptedSize/minUnclaimedBlockSize+1, 1<<30))
}
ctx, cancel := context.WithCancel(ctx)
r := &blockReader{
c: c,
n: n,
sk: sk,
ctx: ctx,
cancel: cancel,
slots: make(chan chan blockResult, downloadAhead),
sizes: sizes,
startHash: startHash,
skip: skip,
remaining: limit,
}
r.wg.Add(1)
go r.list(first, last, maxBlocks, full)
return r, nil
}
// blockAt returns the index, from 1, of the block holding plaintext
// offset pos of a file with block sizes sizes, and the offset of pos in
// it.
func blockAt(sizes []int64, pos int64) (index int, offset int64) {
for i, size := range sizes {
if pos < size {
return i + 1, pos
}
pos -= size
}
return len(sizes) + 1, pos
}
// contentKey returns the content session key of file n for op,
// "reading" or "updating", checking its signature as the SDK's
// decryptContentKeyPacket does, except that a missing signature fails
// too: anyone with the node's public key can make a content key packet.
//
// An unverified node key or content key is errIntegrity unless
// IgnoreSignatureErrors is set, when it is logged once per node and op:
// whoever made the key could read what is written with it and make up
// the content read.
func (c *client) contentKey(n *node, op string) (*crypto.SessionKey, error) {
hint := signatureHint
if op != "reading" {
hint = "set ignore_signature_errors to update the file anyway"
}
if n.keys.unverified != nil {
if !c.opt.IgnoreSignatureErrors {
return nil, fmt.Errorf("node key: %w: %w: %s", errIntegrity, n.keys.unverified, hint)
}
c.reportUnverified(op, n, "")
}
f := n.link.File
now := c.s.serverTime()
sk, verifyErr, err := decryptContentKey(f.ContentKeyPacket, f.ContentKeyPacketSignature, n.keys.keyRing, n.keyVerifyKR, now)
if err != nil {
return nil, err
}
if verifyErr != nil && !isNotSigned(verifyErr) && n.CreateTime.Unix() <= contentKeyFallbackUntil {
// Old clients signed with an address key, and a rename or
// move could change the node's signer without signing the
// content key packet again.
krs := make([]*crypto.KeyRing, 0, len(c.acct.addresses))
for _, addr := range c.acct.addresses {
krs = append(krs, addr.keyRing)
}
if allKR, err := joinKeyRings(krs...); err == nil && verifyDetached(sk.Key, f.ContentKeyPacketSignature, allKR, now) == nil {
fs.Debugf(nil, "protondrive: %q (ID %s): content key signed by another address of the account", n.Name, n.ID)
verifyErr = nil
}
}
if verifyErr != nil {
verifyErr = fmt.Errorf("content key: %w: %w", errIntegrity, verifyErr)
if !c.opt.IgnoreSignatureErrors {
return nil, fmt.Errorf("%w: %s", verifyErr, hint)
}
c.reportOnce("content key "+op, n.ID, func() {
fs.Errorf(nil, "protondrive: %s %q (ID %s): %v", op, n.Name, n.ID, verifyErr)
})
}
return sk, nil
}
// revisionBlocks returns the active revision of file n with at most
// pageSize of its blocks from block index from.
func (c *client) revisionBlocks(ctx context.Context, n *node, from, pageSize int) (*api.Revision, error) {
opts := &rest.Opts{
Method: "GET",
Path: c.volumePath("/drive/v2/volumes", "files", n.ID, "revisions", n.RevisionID),
Parameters: url.Values{
"FromBlockIndex": {strconv.Itoa(from)},
"PageSize": {strconv.Itoa(pageSize)},
},
}
var resp api.RevisionResponse
if err := c.apiCall(ctx, opts, nil, &resp); err != nil {
return nil, fmt.Errorf("list blocks: %w", err)
}
return &resp.Revision, nil
}
// blockURL returns block index of file n with a new download URL.
func (c *client) blockURL(ctx context.Context, n *node, index int) (api.Block, error) {
rev, err := c.revisionBlocks(ctx, n, index, 1)
if err != nil {
return api.Block{}, err
}
if len(rev.Blocks) == 0 || rev.Blocks[0].Index != index {
return api.Block{}, fmt.Errorf("list blocks: block %d is missing", index)
}
return rev.Blocks[0], nil
}
// downloadBlock returns the encrypted block at bareURL, which token
// unlocks, and its SHA-256. The block must be closed.
func (c *client) downloadBlock(ctx context.Context, bareURL, token string) (enc *pool.RW, sum []byte, err error) {
c.downloadTokens.Get()
defer c.downloadTokens.Put()
opts := &rest.Opts{
Method: "GET",
RootURL: bareURL,
// The storage host gets the block's token, never the
// session's credentials, which only session.call adds.
ExtraHeaders: map[string]string{"pm-storage-token": token},
}
err = c.pacedCall(ctx, c.storagePacer, func() (*http.Response, error) {
if enc != nil {
_ = enc.Close()
}
enc = pool.NewRW(pool.Global())
resp, err := c.s.srv.Call(ctx, opts)
if err != nil {
return resp, err
}
defer func() { _ = resp.Body.Close() }()
if resp.ContentLength > maxBlockSize {
return nil, fmt.Errorf("block is larger than %d bytes", maxBlockSize)
}
h := sha256.New()
size, err := enc.ReadFrom(io.TeeReader(io.LimitReader(resp.Body, maxBlockSize+1), h))
if err != nil {
return resp, err
}
if size > maxBlockSize {
return nil, fmt.Errorf("block is larger than %d bytes", maxBlockSize)
}
sum = h.Sum(nil)
return resp, nil
})
if err != nil {
if enc != nil {
_ = enc.Close()
}
return nil, nil, err
}
return enc, sum, nil
}
// blockResult is the outcome of a block of a download, or its end.
type blockResult struct {
index int
hash []byte // the block's SHA-256
plain *pool.RW // the decrypted block; nil on error or at the end
err error
end bool // no more blocks follow; err is the verdict on the file
}
// blockReader reads a range of a file, fetching blocks ahead of the
// reader in the background.
type blockReader struct {
c *client
n *node
sk *crypto.SessionKey
ctx context.Context
cancel context.CancelFunc
slots chan chan blockResult // results to read, in order
wg sync.WaitGroup // the lister and the fetches
sizes []int64 // claimed plaintext block sizes; nil if not recorded
// startHash is the hash the first block had when its size was
// learnt, if it was.
startHash []byte
mu sync.Mutex // held by Read and Close
cur *pool.RW // block being read
skip int64 // bytes to discard before the range, from the first block on
remaining int64 // bytes left to read; negative for all
err error // returned by every Read once set
closed bool
}
// list lists the blocks first to last (to the end if last < 0) and
// fetches them, then sends the end of the download. If verify is set,
// the list having started at the first block, it lists every block and
// checks the manifest before fetching any.
//
// A list of more than maxBlocks blocks is an error.
func (r *blockReader) list(first, last, maxBlocks int, verify bool) {
defer r.wg.Done()
end := make(chan blockResult, 1)
end <- blockResult{end: true, err: r.listBlocks(first, last, maxBlocks, verify)}
select {
case r.slots <- end:
case <-r.ctx.Done():
}
}
// listedBlock is a block of the list with its decoded hash.
type listedBlock struct {
block api.Block
hash []byte
}
// listBlocks does the work of list, returning the verdict on the file.
func (r *blockReader) listBlocks(first, last, maxBlocks int, verify bool) error {
var (
hashes [][]byte
listed []listedBlock // blocks waiting for the manifest check, if verify
manifest string
signer string
index = first
firstPage = true
)
for {
pageSize := blocksPerPage
if last > 0 {
pageSize = min(pageSize, last-index+1)
}
rev, err := r.c.revisionBlocks(r.ctx, r.n, index, pageSize)
if err != nil {
return err
}
if firstPage && verify {
manifest, signer = rev.ManifestSignature, rev.SignatureEmail
// As in the SDK, the manifest starts with the thumbnails.
for _, th := range rev.Thumbnails {
hash, err := decodeBlockHash(th.Hash)
if err != nil {
return fmt.Errorf("thumbnail: %w", err)
}
hashes = append(hashes, hash)
}
}
firstPage = false
if len(rev.Blocks) == 0 {
break
}
for _, b := range rev.Blocks {
switch {
case index > r.c.maxFileBlocks:
return fmt.Errorf("%w: more than %d blocks listed, more than any file can have", errIntegrity, r.c.maxFileBlocks)
case b.Index != index:
return fmt.Errorf("%w: block %d listed where block %d belongs", errIntegrity, b.Index, index)
case index > maxBlocks && r.sizes != nil:
return fmt.Errorf("%w: more blocks listed than the %d the file records", errIntegrity, maxBlocks)
case index > maxBlocks:
return fmt.Errorf("%w: more blocks listed than the file's encrypted size of %d allows", errIntegrity, r.n.EncryptedSize)
}
hash, err := decodeBlockHash(b.Hash)
if err != nil {
return fmt.Errorf("block %d: %w", index, err)
}
if index == first && r.startHash != nil && !bytes.Equal(hash, r.startHash) {
// The sizes placing the range came from another block
// list of the same revision.
return fmt.Errorf("%w: block %d listed with another hash than when it was read before", errIntegrity, index)
}
if verify {
// Nothing is released before the manifest vouches for
// every block's hash and place.
hashes = append(hashes, hash)
listed = append(listed, listedBlock{block: b, hash: hash})
} else if !r.fetch(b, hash) {
return r.ctx.Err()
}
if index == last {
return nil
}
index++
}
}
if r.sizes != nil && index <= len(r.sizes) {
return fmt.Errorf("%w: %d blocks listed of the %d the file records", errIntegrity, index-1, len(r.sizes))
}
if !verify {
return nil
}
if err := r.verifyManifest(hashes, manifest, signer); err != nil {
return err
}
for _, lb := range listed {
if !r.fetch(lb.block, lb.hash) {
return r.ctx.Err()
}
}
return nil
}
// decodeBlockHash returns the base64 SHA-256 hash.
func decodeBlockHash(b64 string) ([]byte, error) {
hash, err := base64.StdEncoding.DecodeString(b64)
if err == nil && len(hash) != sha256.Size {
err = fmt.Errorf("%d bytes long", len(hash))
}
if err != nil {
return nil, fmt.Errorf("bad hash: %w", err)
}
return hash, nil
}
// verifyManifest checks the manifest signature of the file's block
// hashes against the keys of signer, or the node key if it is empty.
func (r *blockReader) verifyManifest(hashes [][]byte, manifest, signer string) error {
verifyKR, err := r.c.verifyKeys(r.ctx, signer, r.n.keys.keyRing)
if err != nil {
return fmt.Errorf("verify manifest: %w", err)
}
err = verifyManifest(hashes, manifest, verifyKR, r.c.s.serverTime())
if err == nil {
return nil
}
err = fmt.Errorf("%w: %w", errIntegrity, err)
if !r.c.opt.IgnoreSignatureErrors {
return fmt.Errorf("%w: %s", err, signatureHint)
}
fs.Errorf(nil, "protondrive: %q (ID %s): %v", r.n.Name, r.n.ID, err)
return nil
}
// fetch queues block b, whose SHA-256 is hash, for the reader and
// fetches it in the background. It returns false if the download is
// cancelled first.
func (r *blockReader) fetch(b api.Block, hash []byte) bool {
slot := make(chan blockResult, 1)
select {
case r.slots <- slot:
case <-r.ctx.Done():
return false
}
r.wg.Add(1)
go func() {
defer r.wg.Done()
slot <- r.fetchBlock(b, hash)
}()
return true
}
// fetchBlock downloads and decrypts block b, whose SHA-256 is hash,
// requesting a new URL after a 404 (once) or a hash mismatch.
func (r *blockReader) fetchBlock(b api.Block, hash []byte) blockResult {
index := b.Index
refetched := false
for attempt := 1; ; {
plain, err := r.fetchOnce(b, hash)
var apiErr *api.Error
switch {
case err == nil:
return blockResult{index: index, hash: hash, plain: plain}
case r.ctx.Err() != nil:
return blockResult{index: index, err: r.ctx.Err()}
case errors.As(err, &apiErr) && apiErr.Status == http.StatusNotFound && !refetched:
// The URL may have expired.
refetched = true
case errors.Is(err, errBlockHash) && attempt < blockAttempts:
// The bytes may have been damaged on the way.
attempt++
default:
return blockResult{index: index, err: fmt.Errorf("block %d: %w", index, err)}
}
fs.Debugf(nil, "protondrive: %q (ID %s): block %d: %v: retrying with a new URL", r.n.Name, r.n.ID, index, err)
// hash stays the listed one, which the manifest covers.
if b, err = r.c.blockURL(r.ctx, r.n, index); err != nil {
return blockResult{index: index, err: fmt.Errorf("block %d: %w", index, err)}
}
if relisted, err := decodeBlockHash(b.Hash); err != nil || !bytes.Equal(relisted, hash) {
return blockResult{index: index, err: fmt.Errorf("block %d: %w: listed again with another hash", index, errIntegrity)}
}
}
}
// fetchOnce downloads block b and checks it against hash and its
// claimed size, returning it decrypted.
func (r *blockReader) fetchOnce(b api.Block, hash []byte) (*pool.RW, error) {
enc, sum, err := r.c.downloadBlock(r.ctx, b.BareURL, b.Token)
if err != nil {
return nil, err
}
defer func() { _ = enc.Close() }()
if !bytes.Equal(sum, hash) {
return nil, errBlockHash
}
dec, err := blockDecrypter(enc, r.sk)
if err != nil {
return nil, err
}
plain := pool.NewRW(pool.Global())
size, err := plain.ReadFrom(io.LimitReader(dec, maxBlockSize+1))
switch {
case err != nil:
err = fmt.Errorf("decrypt block: %w", err)
case size > maxBlockSize:
err = fmt.Errorf("block decrypts to more than %d bytes", maxBlockSize)
case r.sizes != nil && size != r.sizes[b.Index-1]:
err = fmt.Errorf("%w: block is %d bytes, the file records %d", errIntegrity, size, r.sizes[b.Index-1])
}
if err != nil {
_ = plain.Close()
return nil, err
}
return plain, nil
}
// Read reads the range of the file.
func (r *blockReader) Read(p []byte) (int, error) {
r.mu.Lock()
defer r.mu.Unlock()
if len(p) == 0 {
return 0, nil
}
for {
switch {
case r.closed:
return 0, errDownloadClosed
case r.err != nil:
return 0, r.err
case r.remaining == 0:
return 0, io.EOF
}
if r.cur == nil {
if err := r.next(); err != nil {
r.err = err
r.cancel()
}
continue
}
if r.remaining > 0 && int64(len(p)) > r.remaining {
p = p[:r.remaining]
}
n, err := r.cur.Read(p)
if n > 0 {
if r.remaining > 0 {
r.remaining -= int64(n)
}
return n, nil
}
if err != nil && err != io.EOF {
r.err = err
continue
}
_ = r.cur.Close()
r.cur = nil
}
}
// next makes the next block holding bytes of the range current,
// returning io.EOF at the end.
func (r *blockReader) next() error {
for {
var slot chan blockResult
select {
case slot = <-r.slots:
case <-r.ctx.Done():
return r.ctx.Err()
}
res := <-slot
switch {
case res.err != nil && r.ctx.Err() != nil:
return r.ctx.Err()
case res.err != nil:
return res.err
case res.end:
return io.EOF
}
if r.sizes == nil {
if err := r.c.learnt.learn(r.n.RevisionID, res.index, res.hash, res.plain.Size()); err != nil {
_ = res.plain.Close()
return err
}
}
if size := res.plain.Size(); r.skip > 0 && r.skip >= size {
r.skip -= size
_ = res.plain.Close()
continue
}
if r.skip > 0 {
if _, err := res.plain.Seek(r.skip, io.SeekStart); err != nil {
_ = res.plain.Close()
return err
}
r.skip = 0
}
r.cur = res.plain
return nil
}
}
// Close stops the download and frees its buffers. It may be called
// more than once.
func (r *blockReader) Close() error {
r.cancel()
r.mu.Lock()
defer r.mu.Unlock()
if r.closed {
return nil
}
r.closed = true
r.wg.Wait()
if r.cur != nil {
_ = r.cur.Close()
r.cur = nil
}
// Every queued slot has its result now.
for {
select {
case slot := <-r.slots:
if res := <-slot; res.plain != nil {
_ = res.plain.Close()
}
default:
return nil
}
}
}
// check interfaces
var _ io.ReadCloser = (*blockReader)(nil)
const (
// learntRevisions and learntBlocks bound the block sizes a client
// learns: of at most this many revisions, and this many blocks in
// all, about 2.5 MiB.
learntRevisions = 16
learntBlocks = 1 << 16
)
// learntBlock is the plaintext size of a block read, with its hash.
type learntBlock struct {
hash [sha256.Size]byte
size int64
}
// blockSizeCache holds the plaintext sizes of the blocks, from the
// first on, read of revisions which don't record their block sizes, so
// a later ranged read, as each chunk of a multi-thread copy is, can
// start at the block holding its offset. The zero value is empty.
type blockSizeCache struct {
mu sync.Mutex
revs map[string][]learntBlock // by revision ID
order []string // revision IDs, oldest first
blocks int // learnt in all
}
// locate returns where a read of limit bytes (to the end if negative)
// from offset of revision id starts, as for blockReader: the first
// block, the bytes of it to skip, the last block or -1 if not known,
// and the hash the first block had when its size was learnt, or nil if
// it wasn't.
func (b *blockSizeCache) locate(id string, offset, limit int64) (first int, skip int64, last int, startHash []byte) {
b.mu.Lock()
defer b.mu.Unlock()
blocks := b.revs[id]
first, skip, last = len(blocks)+1, offset, -1
for i, lb := range blocks {
if skip < lb.size {
first = i + 1
startHash = slices.Clone(lb.hash[:])
break
}
skip -= lb.size
}
if first > len(blocks) || limit <= 0 {
return first, skip, last, startHash
}
end := skip + limit - 1
for i := first - 1; i < len(blocks); i++ {
if end < blocks[i].size {
return first, skip, i + 1, startHash
}
end -= blocks[i].size
}
return first, skip, last, startHash
}
// learn records that block index of revision id, whose SHA-256 is hash,
// is size bytes. A block learnt before with another hash or size is
// errIntegrity: the server listed two blocks for one place.
func (b *blockSizeCache) learn(id string, index int, hash []byte, size int64) error {
b.mu.Lock()
defer b.mu.Unlock()
blocks, ok := b.revs[id]
if index <= len(blocks) {
if lb := blocks[index-1]; !bytes.Equal(lb.hash[:], hash) || lb.size != size {
return fmt.Errorf("%w: block %d listed with another hash than when it was read before", errIntegrity, index)
}
return nil
}
if index != len(blocks)+1 || len(hash) != sha256.Size {
return nil
}
if !ok {
if b.revs == nil {
b.revs = make(map[string][]learntBlock)
}
for len(b.order) >= learntRevisions {
b.forgetOldest()
}
b.order = append(b.order, id)
}
for b.blocks >= learntBlocks && len(b.order) > 1 && b.order[0] != id {
b.forgetOldest()
}
if b.blocks >= learntBlocks {
return nil
}
lb := learntBlock{size: size}
copy(lb.hash[:], hash)
b.revs[id] = append(blocks, lb)
b.blocks++
return nil
}
// forgetOldest drops the revision learnt first.
func (b *blockSizeCache) forgetOldest() {
b.blocks -= len(b.revs[b.order[0]])
delete(b.revs, b.order[0])
b.order = b.order[1:]
}
@@ -0,0 +1,852 @@
package protondrive
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"io"
"math"
"math/rand/v2"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"runtime"
"strconv"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/ProtonMail/go-crypto/openpgp/packet"
"github.com/rclone/rclone/backend/protondrive/protontest"
"github.com/rclone/rclone/lib/pool"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
const (
storagePattern = "GET /storage/blocks/{id}"
revisionPattern = "GET /drive/v2/volumes/{volumeID}/files/{linkID}/revisions/{revisionID}"
// bigSize is the size of a file of three blocks, the last partial.
bigSize = 9<<20 + 123
)
// testContent returns n bytes of reproducible random content.
func testContent(n int) []byte {
r := rand.New(rand.NewPCG(uint64(n), 1))
b := make([]byte, n)
for i := range b {
b[i] = byte(r.Uint32())
}
return b
}
// seedFile seeds file name with content in the root of u and returns
// its node as c reads it.
func seedFile(t *testing.T, c *client, u *protontest.User, name string, content []byte) *node {
t.Helper()
return getTestNode(t, c, u.SeedFile(u.RootID(), name, content, time.Now()))
}
// getTestNode returns node id as c reads it.
func getTestNode(t *testing.T, c *client, id string) *node {
t.Helper()
n, err := c.getNode(context.Background(), id)
require.NoError(t, err)
return n
}
// withoutBlockSizes returns a copy of n whose extended attributes
// don't record its block sizes, as some old clients wrote them.
func withoutBlockSizes(n *node) *node {
cp := *n
cp.X = nil
return &cp
}
// readRange opens n at offset for limit bytes and reads it to the end.
func readRange(t *testing.T, c *client, n *node, offset, limit int64) ([]byte, error) {
t.Helper()
rc, err := c.open(context.Background(), n, offset, limit)
if err != nil {
return nil, err
}
data, err := io.ReadAll(rc)
require.NoError(t, rc.Close())
return data, err
}
// roundTripFunc is an http.RoundTripper calling itself.
type roundTripFunc func(*http.Request) (*http.Response, error)
// RoundTrip calls f.
func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) {
return f(r)
}
// newTestClientTransport returns a client for a new session of u whose
// requests go through the transport wrap returns.
func newTestClientTransport(t *testing.T, srv *protontest.Server, u *protontest.User, opt clientOptions, wrap func(next http.RoundTripper) http.RoundTripper) *client {
t.Helper()
base := newAccountSession(srv, u)
s := newSession(&http.Client{Transport: wrap(srv.Client().Transport)}, srv.URL(), testAppVersion, base.store)
c, err := newClient(context.Background(), s, opt)
require.NoError(t, err)
t.Cleanup(c.close)
return c
}
// leakBaseline returns the goroutine count and pool buffers in use with
// no idle connections, for requireNoLeaks.
func leakBaseline(srv *protontest.Server) (goroutines, buffers int) {
srv.Client().CloseIdleConnections()
return runtime.NumGoroutine(), pool.Global().InUse()
}
// requireNoLeaks fails the test unless the goroutine count returns to
// goroutines within 2s and the pool buffers in use are back to buffers.
func requireNoLeaks(t *testing.T, srv *protontest.Server, goroutines, buffers int) {
t.Helper()
require.Eventually(t, func() bool {
srv.Client().CloseIdleConnections()
return runtime.NumGoroutine() <= goroutines
}, 2*time.Second, 20*time.Millisecond, "goroutines left running")
assert.Equal(t, buffers, pool.Global().InUse(), "pool buffers not returned")
}
func TestDownloadSizes(t *testing.T) {
t.Parallel()
c, _, u := newTestClient(t)
for _, size := range []int{0, 1, 4<<20 - 1, 4 << 20, 4<<20 + 1, 8 << 20, bigSize} {
t.Run(strconv.Itoa(size), func(t *testing.T) {
content := testContent(size)
n := seedFile(t, c, u, "f"+strconv.Itoa(size), content)
data, err := readRange(t, c, n, 0, -1)
require.NoError(t, err)
assert.True(t, bytes.Equal(content, data), "content differs")
})
}
}
func TestDownloadRanges(t *testing.T) {
t.Parallel()
c, srv, u := newTestClient(t)
content := testContent(bigSize)
claimed := seedFile(t, c, u, "big", content)
unrecorded := withoutBlockSizes(claimed)
for _, tc := range []struct {
offset, limit int64
blocks int // blocks fetched with the block sizes claimed
}{
{0, -1, 3},
{0, 1, 1},
{0, 0, 0},
{4<<20 - 1, 2, 2},
{4 << 20, -1, 2},
{bigSize - 1, -1, 1},
{bigSize, -1, 0},
{8 << 20, 100, 1},
{bigSize - 10, 100, 1},
} {
want := content[tc.offset:]
if tc.limit >= 0 {
want = want[:min(tc.limit, int64(len(want)))]
}
for _, v := range []struct {
name string
n *node
}{{"claimed", claimed}, {"unrecorded", unrecorded}} {
t.Run(v.name+"/"+strconv.FormatInt(tc.offset, 10)+"+"+strconv.FormatInt(tc.limit, 10), func(t *testing.T) {
srv.ResetRequests()
data, err := readRange(t, c, v.n, tc.offset, tc.limit)
require.NoError(t, err)
assert.True(t, bytes.Equal(want, data), "got %d bytes, want %d", len(data), len(want))
fetched := srv.Requests()[storagePattern]
if v.n == claimed {
assert.Equal(t, tc.blocks, fetched, "blocks fetched")
return
}
// The block sizes learnt by the first, full, read
// place the range; blocks past it may be fetched ahead
// before the reader is closed.
assert.GreaterOrEqual(t, fetched, tc.blocks, "blocks fetched")
assert.LessOrEqual(t, fetched, 3, "blocks fetched")
})
}
}
}
func TestDownloadRangesUnrecordedBlockSizes(t *testing.T) {
t.Parallel()
c, _, u := newTestClient(t)
const size = 6<<20 + 5
content := testContent(size)
// Blocks of 1 MiB, which a reader assuming 4 MiB blocks would
// place wrongly.
n := getTestNode(t, c, u.SeedFileInBlocks(u.RootID(), "small-blocks", content, 1<<20))
require.Nil(t, n.X.BlockSizes)
for _, tc := range []struct{ offset, limit int64 }{
{0, -1},
{0, 3 << 20},
{10, 100},
{1<<20 - 3, 10},
{4<<20 + 10, 100},
{4<<20 + 10, -1},
{2<<20 + 7, 3 << 20},
{size - 1, -1},
{size, -1},
} {
t.Run(strconv.FormatInt(tc.offset, 10)+"+"+strconv.FormatInt(tc.limit, 10), func(t *testing.T) {
want := content[tc.offset:]
if tc.limit >= 0 {
want = want[:min(tc.limit, int64(len(want)))]
}
data, err := readRange(t, c, n, tc.offset, tc.limit)
require.NoError(t, err)
assert.True(t, bytes.Equal(want, data), "got %d bytes, want %d", len(data), len(want))
})
}
}
func TestDownloadChunksUnrecordedBlockSizes(t *testing.T) {
t.Parallel()
c, srv, u := newTestClient(t)
const (
blocks = 32
chunk = 4 << 20
blockBytes = 1 << 20
)
content := testContent(blocks * blockBytes)
id := u.SeedFileInBlocks(u.RootID(), "chunks", content, blockBytes)
n := getTestNode(t, c, id)
require.Nil(t, n.X.BlockSizes)
// A multi-thread copy opens each chunk as a range of its own.
srv.ResetRequests()
for offset := int64(0); offset < int64(len(content)); offset += chunk {
data, err := readRange(t, c, n, offset, chunk)
require.NoError(t, err)
require.True(t, bytes.Equal(content[offset:offset+chunk], data), "chunk at %d", offset)
}
fetched := srv.Requests()[storagePattern]
t.Logf("%d blocks fetched for %d chunks of a file of %d blocks", fetched, len(content)/chunk, blocks)
// Reading each chunk from the first block would fetch about
// blocks^2/(2*blocks per chunk) = 128; each chunk may fetch up to
// downloadAhead blocks past its end.
assert.LessOrEqual(t, fetched, blocks+len(content)/chunk*downloadAhead, "chunks read from the first block")
// Once the sizes are known, a range fetches only its own blocks.
srv.ResetRequests()
data, err := readRange(t, c, n, 10*blockBytes+5, 2*blockBytes)
require.NoError(t, err)
assert.Equal(t, content[10*blockBytes+5:12*blockBytes+5], data)
assert.Equal(t, 3, srv.Requests()[storagePattern], "blocks fetched")
// A block list which changed since then is an integrity error.
u.Corrupt(id, "swap-blocks")
_, err = readRange(t, c, n, 5, 10)
require.ErrorIs(t, err, errIntegrity)
assert.Contains(t, err.Error(), "listed with another hash")
}
func TestDownloadLegacyFile(t *testing.T) {
t.Parallel()
c, _, u := newTestClient(t)
content := testContent(5<<20 + 7)
n := getTestNode(t, c, u.SeedLegacyFile(u.RootID(), "legacy", content))
data, err := readRange(t, c, n, 0, -1)
require.NoError(t, err)
assert.True(t, bytes.Equal(content, data), "content differs")
data, err = readRange(t, c, n, 4<<20+3, 10)
require.NoError(t, err)
assert.Equal(t, content[4<<20+3:4<<20+13], data)
}
func TestDownloadManifestSigners(t *testing.T) {
t.Parallel()
c, srv, u := newTestClient(t)
other := srv.AddUser("other", "pass")
content := testContent(1000)
for name, id := range map[string]string{
"other user": u.SeedFileSignedBy(other, u.RootID(), "other", content, time.Now()),
"anonymous": u.SeedAnonymousFile(u.RootID(), "anonymous", content, time.Now()),
} {
t.Run(name, func(t *testing.T) {
data, err := readRange(t, c, getTestNode(t, c, id), 0, -1)
require.NoError(t, err)
assert.Equal(t, content, data)
})
}
}
func TestDownloadContentKey(t *testing.T) {
t.Parallel()
srv := protontest.New(t)
u := srv.AddUser("user", "pass")
const second = "second@proton.test"
u.AddAddress(second)
c := newTestClientFor(t, srv, u, clientOptions{})
ignoring := newTestClientFor(t, srv, u, clientOptions{IgnoreSignatureErrors: true})
before2023 := time.Date(2022, 6, 1, 0, 0, 0, 0, time.UTC)
content := testContent(1000)
for _, tc := range []struct {
name string
signer string
created time.Time
ok bool
}{
{"unsigned", "", time.Now(), false},
{"unsigned before 2023", "", before2023, false},
{"signer address", u.Email(), time.Now(), true},
{"other address before 2023", second, before2023, true},
{"other address after 2023", second, time.Now(), false},
} {
t.Run(tc.name, func(t *testing.T) {
id := u.SeedFileContentKeySignedBy(tc.signer, tc.created, u.RootID(), tc.name, content)
data, err := readRange(t, c, getTestNode(t, c, id), 0, -1)
if !tc.ok {
require.ErrorIs(t, err, errIntegrity)
assert.Contains(t, err.Error(), "ignore_signature_errors")
data, err = readRange(t, ignoring, getTestNode(t, ignoring, id), 0, -1)
}
require.NoError(t, err)
assert.Equal(t, content, data)
})
}
}
func TestDownloadNodeKeySignature(t *testing.T) {
t.Parallel()
c, srv, u := newTestClient(t)
content := testContent(1000)
id := u.SeedFile(u.RootID(), "f", content, time.Now())
u.Corrupt(id, "passphrase-signature")
n := getTestNode(t, c, id)
data, err := readRange(t, c, n, 0, -1)
require.ErrorIs(t, err, errIntegrity)
assert.Contains(t, err.Error(), "ignore_signature_errors")
assert.Empty(t, data)
_, err = readRange(t, c, n, 10, 10)
require.ErrorIs(t, err, errIntegrity, "a ranged read")
ignoring := newTestClientFor(t, srv, u, clientOptions{IgnoreSignatureErrors: true})
data, err = readRange(t, ignoring, getTestNode(t, ignoring, id), 0, -1)
require.NoError(t, err)
assert.Equal(t, content, data)
}
func TestDownloadBlockHashMismatch(t *testing.T) {
t.Parallel()
c, srv, u := newTestClient(t)
n := seedFile(t, c, u, "f", testContent(1<<20))
u.Corrupt(n.ID, "block-hash")
srv.ResetRequests()
data, err := readRange(t, c, n, 0, -1)
require.ErrorIs(t, err, errBlockHash)
assert.Empty(t, data)
assert.Equal(t, 3, srv.Requests()[storagePattern], "block attempts")
// The listing page, the empty page ending the list and a new URL
// for each retry.
assert.Equal(t, 4, srv.Requests()[revisionPattern])
}
func TestDownloadSwappedBlocks(t *testing.T) {
t.Parallel()
c, _, u := newTestClient(t)
n := seedFile(t, c, u, "f", testContent(bigSize))
u.Corrupt(n.ID, "swap-blocks")
_, err := readRange(t, c, n, 0, -1)
require.ErrorIs(t, err, errIntegrity)
}
func TestDownloadTruncatedBlocks(t *testing.T) {
t.Parallel()
c, _, u := newTestClient(t)
n := seedFile(t, c, u, "f", testContent(bigSize))
u.Corrupt(n.ID, "truncate-blocks")
_, err := readRange(t, c, n, 0, -1)
require.ErrorIs(t, err, errIntegrity)
// Without block sizes, only the manifest shows the loss.
_, err = readRange(t, c, withoutBlockSizes(n), 0, -1)
require.ErrorIs(t, err, errIntegrity)
}
func TestDownloadBadManifest(t *testing.T) {
t.Parallel()
c, srv, u := newTestClient(t)
content := testContent(bigSize)
n := seedFile(t, c, u, "f", content)
u.Corrupt(n.ID, "manifest")
data, err := readRange(t, c, n, 0, -1)
require.ErrorIs(t, err, errIntegrity)
assert.Contains(t, err.Error(), "ignore_signature_errors")
assert.Empty(t, data, "no byte comes before the verdict")
// A ranged read can't check the manifest.
data, err = readRange(t, c, n, 0, 100)
require.NoError(t, err)
assert.Equal(t, content[:100], data)
ignoring := newTestClientFor(t, srv, u, clientOptions{IgnoreSignatureErrors: true})
data, err = readRange(t, ignoring, getTestNode(t, ignoring, n.ID), 0, -1)
require.NoError(t, err)
assert.True(t, bytes.Equal(content, data), "content differs")
}
func TestDownloadEmptyFileBadManifest(t *testing.T) {
t.Parallel()
c, _, u := newTestClient(t)
n := seedFile(t, c, u, "empty", nil)
u.Corrupt(n.ID, "manifest")
_, err := readRange(t, c, n, 0, -1)
require.ErrorIs(t, err, errIntegrity)
}
func TestDownloadStorage404Refetch(t *testing.T) {
t.Parallel()
c, srv, u := newTestClient(t)
content := testContent(1 << 20)
n := seedFile(t, c, u, "f", content)
srv.Inject(protontest.Fault{Pattern: "/storage/blocks/{id}", Status: http.StatusNotFound, Times: 1})
srv.ResetRequests()
data, err := readRange(t, c, n, 0, -1)
require.NoError(t, err)
assert.Equal(t, content, data)
assert.Equal(t, 2, srv.Requests()[storagePattern])
// The listing page, the new URL and the empty page ending the list.
assert.Equal(t, 3, srv.Requests()[revisionPattern])
// The URL is requested again only once.
srv.Inject(protontest.Fault{Pattern: "/storage/blocks/{id}", Status: http.StatusNotFound})
srv.ResetRequests()
_, err = readRange(t, c, n, 0, -1)
require.Error(t, err)
assert.Equal(t, 2, srv.Requests()[storagePattern])
}
func TestDownloadRefetchOtherHash(t *testing.T) {
t.Parallel()
c, srv, u := newTestClient(t)
n := seedFile(t, c, u, "f", testContent(1<<20))
// The block's URL expires, and the server lists it again with
// another block, which the checked manifest doesn't cover.
srv.Inject(protontest.Fault{Pattern: "/storage/blocks/{id}", Status: http.StatusNotFound, Times: 1, Delay: 300 * time.Millisecond})
done := make(chan struct{})
var data []byte
var err error
go func() {
defer close(done)
data, err = readRange(t, c, n, 0, -1)
}()
require.Eventually(t, func() bool { return srv.Requests()[storagePattern] == 1 }, 5*time.Second, 5*time.Millisecond)
u.Corrupt(n.ID, "foreign-block")
<-done
require.ErrorIs(t, err, errIntegrity)
assert.Contains(t, err.Error(), "listed again with another hash")
assert.Empty(t, data)
}
func TestDownloadRetries5xx(t *testing.T) {
t.Parallel()
c, srv, u := newTestClient(t)
content := testContent(1 << 20)
n := seedFile(t, c, u, "f", content)
srv.Inject(protontest.Fault{Pattern: "/storage/blocks/{id}", Status: http.StatusServiceUnavailable, Times: 2})
srv.Inject(protontest.Fault{Pattern: "/drive/v2/volumes/{volumeID}/files/{linkID}/revisions/{revisionID}", Status: http.StatusBadGateway, Times: 1})
srv.ResetRequests()
data, err := readRange(t, c, n, 0, -1)
require.NoError(t, err)
assert.Equal(t, content, data)
assert.Equal(t, 3, srv.Requests()[storagePattern])
}
func TestDownloadStorageRetryAfter(t *testing.T) {
t.Parallel()
c, srv, u := newTestClient(t)
content := testContent(1000)
n := seedFile(t, c, u, "f", content)
srv.Inject(protontest.Fault{Pattern: "/storage/blocks/{id}", Status: http.StatusTooManyRequests, RetryAfter: 1, Times: 1})
start := time.Now()
data, err := readRange(t, c, n, 0, -1)
require.NoError(t, err)
assert.Equal(t, content, data)
assert.GreaterOrEqual(t, time.Since(start), time.Second)
}
func TestDownloadStorageHeaders(t *testing.T) {
t.Parallel()
srv := protontest.New(t)
u := srv.AddUser("user", "pass")
var (
mu sync.Mutex
headers []http.Header
)
c := newTestClientTransport(t, srv, u, clientOptions{}, func(next http.RoundTripper) http.RoundTripper {
return roundTripFunc(func(r *http.Request) (*http.Response, error) {
if strings.HasPrefix(r.URL.Path, "/storage/") {
mu.Lock()
headers = append(headers, r.Header.Clone())
mu.Unlock()
}
return next.RoundTrip(r)
})
})
n := seedFile(t, c, u, "f", testContent(4<<20+1))
_, err := readRange(t, c, n, 0, -1)
require.NoError(t, err)
require.Len(t, headers, 2)
for _, h := range headers {
assert.Empty(t, h.Get("Authorization"))
assert.Empty(t, h.Get("x-pm-uid"))
assert.NotEmpty(t, h.Get("pm-storage-token"))
assert.Equal(t, testAppVersion, h.Get("x-pm-appversion"))
}
// Block URLs on another host, as the real storage host is, get the
// same headers. The other host passes requests on to the fake.
var other []http.Header
storage := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
other = append(other, r.Header.Clone())
mu.Unlock()
req, err := http.NewRequestWithContext(r.Context(), "GET", srv.URL()+r.URL.Path, nil)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
for _, k := range []string{"pm-storage-token", "x-pm-appversion"} {
req.Header.Set(k, r.Header.Get(k))
}
resp, err := srv.Client().Do(req)
if err != nil {
http.Error(w, err.Error(), http.StatusBadGateway)
return
}
defer func() { _ = resp.Body.Close() }()
w.WriteHeader(resp.StatusCode)
_, _ = io.Copy(w, resp.Body)
}))
t.Cleanup(storage.Close)
require.NotEqual(t, srv.URL(), storage.URL)
moved := newTestClientTransport(t, srv, u, clientOptions{}, func(next http.RoundTripper) http.RoundTripper {
return roundTripFunc(func(r *http.Request) (*http.Response, error) {
resp, err := next.RoundTrip(r)
if err != nil || !strings.Contains(r.URL.Path, "/revisions/") {
return resp, err
}
body, err := io.ReadAll(resp.Body)
_ = resp.Body.Close()
if err != nil {
return nil, err
}
body = bytes.ReplaceAll(body, []byte(srv.URL()+"/storage/"), []byte(storage.URL+"/storage/"))
resp.Body = io.NopCloser(bytes.NewReader(body))
resp.ContentLength = int64(len(body))
resp.Header.Del("Content-Length")
return resp, nil
})
})
_, err = readRange(t, moved, getTestNode(t, moved, n.ID), 0, -1)
require.NoError(t, err)
require.Len(t, other, 2)
for _, h := range other {
assert.Empty(t, h.Get("Authorization"))
assert.Empty(t, h.Get("x-pm-uid"))
assert.NotEmpty(t, h.Get("pm-storage-token"))
}
}
func TestDownloadBlockTooLarge(t *testing.T) {
t.Parallel()
srv := protontest.New(t)
u := srv.AddUser("user", "pass")
var fetches atomic.Int32
c := newTestClientTransport(t, srv, u, clientOptions{}, func(next http.RoundTripper) http.RoundTripper {
return roundTripFunc(func(r *http.Request) (*http.Response, error) {
if !strings.HasPrefix(r.URL.Path, "/storage/") {
return next.RoundTrip(r)
}
fetches.Add(1)
// An endless body of unknown length.
return &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{},
Body: io.NopCloser(zeroReader{}),
ContentLength: -1,
Request: r,
}, nil
})
})
n := seedFile(t, c, u, "f", testContent(1000))
_, err := readRange(t, c, n, 0, -1)
require.ErrorContains(t, err, "larger than")
assert.NotErrorIs(t, err, errBlockHash)
assert.Equal(t, int32(1), fetches.Load(), "an oversized block is not fetched again")
}
// zeroReader reads zeros forever.
type zeroReader struct{}
// Read fills p with zeros.
func (zeroReader) Read(p []byte) (int, error) {
clear(p)
return len(p), nil
}
// endlessBlockList returns a transport wrapper whose revision pages are
// all full of copies of the file's first block, renumbered, counting
// the pages in pages.
func endlessBlockList(pages *atomic.Int32) func(next http.RoundTripper) http.RoundTripper {
var (
mu sync.Mutex
first map[string]any
)
return func(next http.RoundTripper) http.RoundTripper {
return roundTripFunc(func(r *http.Request) (*http.Response, error) {
resp, err := next.RoundTrip(r)
if err != nil || !strings.Contains(r.URL.Path, "/revisions/") {
return resp, err
}
pages.Add(1)
var body map[string]any
err = json.NewDecoder(resp.Body).Decode(&body)
_ = resp.Body.Close()
if err != nil {
return nil, err
}
rev := body["Revision"].(map[string]any)
mu.Lock()
if blocks := rev["Blocks"].([]any); first == nil && len(blocks) > 0 {
first = blocks[0].(map[string]any)
}
from, _ := strconv.Atoi(r.URL.Query().Get("FromBlockIndex"))
size, _ := strconv.Atoi(r.URL.Query().Get("PageSize"))
var blocks []any
for i := range size {
b := make(map[string]any, len(first))
for k, v := range first {
b[k] = v
}
b["Index"] = from + i
blocks = append(blocks, b)
}
mu.Unlock()
rev["Blocks"] = blocks
data, err := json.Marshal(body)
if err != nil {
return nil, err
}
resp.Body = io.NopCloser(bytes.NewReader(data))
resp.ContentLength = int64(len(data))
resp.Header.Del("Content-Length")
return resp, nil
})
}
}
func TestDownloadEndlessBlockList(t *testing.T) {
t.Parallel()
srv := protontest.New(t)
u := srv.AddUser("user", "pass")
var pages atomic.Int32
c := newTestClientTransport(t, srv, u, clientOptions{EnableCaching: true}, endlessBlockList(&pages))
n := seedFile(t, c, u, "f", testContent(4<<20+1))
for name, n := range map[string]*node{"claimed": n, "unrecorded": withoutBlockSizes(n)} {
t.Run(name, func(t *testing.T) {
pages.Store(0)
data, err := readRange(t, c, n, 0, -1)
require.ErrorIs(t, err, errIntegrity)
assert.LessOrEqual(t, len(data), 5*(4<<20))
assert.LessOrEqual(t, pages.Load(), int32(2))
})
}
}
func TestDownloadBlockCap(t *testing.T) {
t.Parallel()
srv := protontest.New(t)
u := srv.AddUser("user", "pass")
var pages atomic.Int32
c := newTestClientTransport(t, srv, u, clientOptions{EnableCaching: true}, endlessBlockList(&pages))
c.maxFileBlocks = 45
n := seedFile(t, c, u, "f", testContent(4<<20+1))
// The encrypted size, which nothing signs, claims room for many
// more blocks than the cap.
huge := withoutBlockSizes(n)
huge.EncryptedSize = 1 << 50
data, err := readRange(t, c, huge, 0, -1)
require.ErrorIs(t, err, errIntegrity)
assert.Contains(t, err.Error(), "more than 45 blocks")
assert.Empty(t, data)
assert.LessOrEqual(t, pages.Load(), int32(3), "listed past the cap")
// Recorded block sizes beyond the cap fail before any request.
claims := *n
x := *n.X
x.BlockSizes = make([]int64, 46)
claims.X = &x
pages.Store(0)
_, err = readRange(t, c, &claims, 0, -1)
require.ErrorIs(t, err, errIntegrity)
assert.Zero(t, pages.Load())
}
func TestDownloadCloseEarly(t *testing.T) {
c, srv, u := newTestClient(t)
n := seedFile(t, c, u, "f", testContent(8<<20))
goroutines, buffers := leakBaseline(srv)
rc, err := c.open(context.Background(), n, 0, -1)
require.NoError(t, err)
buf := make([]byte, 10)
_, err = io.ReadFull(rc, buf)
require.NoError(t, err)
require.NoError(t, rc.Close())
require.NoError(t, rc.Close())
_, err = rc.Read(buf)
require.Error(t, err)
requireNoLeaks(t, srv, goroutines, buffers)
}
func TestDownloadContextCancel(t *testing.T) {
c, srv, u := newTestClient(t)
n := seedFile(t, c, u, "f", testContent(8<<20))
// One of the blocks takes far longer than the test.
srv.Inject(protontest.Fault{Pattern: "/storage/blocks/{id}", Skip: 1, Times: 1, Delay: time.Minute})
goroutines, buffers := leakBaseline(srv)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
rc, err := c.open(ctx, n, 0, -1)
require.NoError(t, err)
time.AfterFunc(200*time.Millisecond, cancel)
start := time.Now()
data, err := io.ReadAll(rc)
require.ErrorIs(t, err, context.Canceled)
assert.Less(t, len(data), 8<<20)
assert.Less(t, time.Since(start), 10*time.Second)
require.NoError(t, rc.Close())
requireNoLeaks(t, srv, goroutines, buffers)
}
func TestDownloadHostileBlockSizes(t *testing.T) {
t.Parallel()
c, _, u := newTestClient(t)
small := testContent(1000)
big := testContent(4<<20 + 1)
for _, tc := range []struct {
name string
content []byte
sizes []int64
}{
{"overflowing", small, []int64{math.MaxInt64, 1}},
{"above the cap", small, []int64{maxBlockSize + 1}},
{"sum too small", big, []int64{1000}},
{"too few blocks", big, []int64{4 << 20}},
} {
t.Run(tc.name, func(t *testing.T) {
n := getTestNode(t, c, u.SeedFileClaimingBlockSizes(u.RootID(), tc.name, tc.content, tc.sizes))
require.Equal(t, tc.sizes, n.X.BlockSizes)
_, err := readRange(t, c, n, 0, -1)
require.ErrorIs(t, err, errIntegrity)
})
}
}
// frozenFileDir holds files the old library (go-proton-api v1.0.4 and
// Proton-API-Bridge v1.0.5) wrote, as the server stored them, one
// directory per format: "legacy" (v3 PKESK content key, SEIPDv1 blocks)
// and "v6" (v6 PKESK content key, SEIPDv2 blocks). The old library only
// writes v6 content as a revision of a file another client created, so
// in "v6" the node key and content key packet with its signature are
// SDK-style stand-ins made with plain gopenpgp.
const frozenFileDir = "testdata/oldlib/file"
// frozenFile is a file a client holding Keys stored, with what it
// encrypted. In a directory, file.json holds it but Stored, which is
// in link.json, revision.json and block-1.bin, block-2.bin...
type frozenFile struct {
Comment string
Email string // address which signed everything, the owner's primary address
Keys protontest.FixedKeys
Name string
ModTime string // as the writer recorded it in the extended attributes
Content []byte
Stored protontest.StoredFile `json:"-"`
}
// frozenFileParts returns the names of the files of f in a directory
// and the values they hold.
func frozenFileParts(f *frozenFile) map[string]any {
return map[string]any{"file.json": f, "link.json": &f.Stored.Link, "revision.json": &f.Stored.Revision}
}
// blockFileName returns the name of the file holding block i, from 0.
func blockFileName(i int) string {
return "block-" + strconv.Itoa(i+1) + ".bin"
}
// loadFrozenFile returns the file in dir.
func loadFrozenFile(t testing.TB, dir string) frozenFile {
t.Helper()
var f frozenFile
for name, v := range frozenFileParts(&f) {
data, err := os.ReadFile(filepath.Join(dir, name))
require.NoError(t, err)
require.NoError(t, json.Unmarshal(data, v), name)
}
for i := range f.Stored.Revision.Blocks {
data, err := os.ReadFile(filepath.Join(dir, blockFileName(i)))
require.NoError(t, err)
f.Stored.Revision.Blocks[i].Data = data
}
return f
}
// checkFrozenFile stores f for a user holding its keys and checks that
// the client reads its metadata, verifying every signature, and
// downloads its content.
func checkFrozenFile(t *testing.T, f frozenFile) {
t.Helper()
srv := protontest.New(t)
u := srv.AddUserWithKeys(f.Email, "pass", f.Keys)
c := newTestClientFor(t, srv, u, clientOptions{})
n := getTestNode(t, c, u.SeedStoredFile(u.RootID(), f.Stored))
assert.Equal(t, f.Name, n.Name)
require.NotNil(t, n.X, "extended attributes")
modTime, err := time.Parse("2006-01-02T15:04:05-0700", f.ModTime)
require.NoError(t, err)
assert.True(t, modTime.Equal(n.modTime()), "modification time %v, want %v", n.modTime(), modTime)
assert.Equal(t, int64(len(f.Content)), n.size(true))
assert.Equal(t, sha1Of(f.Content), n.sha1())
data, err := readRange(t, c, n, 0, -1)
require.NoError(t, err)
assert.True(t, bytes.Equal(f.Content, data), "content differs: %d bytes, want %d", len(data), len(f.Content))
assert.Empty(t, c.reported, "problems reported")
}
func TestFrozenFileFixture(t *testing.T) {
for format, want := range map[string]struct{ pkesk, seipd int }{"legacy": {3, 1}, "v6": {6, 2}} {
t.Run(format, func(t *testing.T) {
f := loadFrozenFile(t, filepath.Join(frozenFileDir, format))
// The fixture is in the format its directory names.
keyPacket, err := base64.StdEncoding.DecodeString(f.Stored.Link.ContentKeyPacket)
require.NoError(t, err)
assert.Equal(t, want.pkesk, readPackets(t, keyPacket, 1)[0].(*packet.EncryptedKey).Version, "content key packet")
require.NotEmpty(t, f.Stored.Revision.Blocks)
for i, b := range f.Stored.Revision.Blocks {
assert.Equal(t, want.seipd, readPackets(t, b.Data, 1)[0].(*packet.SymmetricallyEncrypted).Version, "block %d", i+1)
}
checkFrozenFile(t, f)
})
}
}
+25
View File
@@ -0,0 +1,25 @@
package protondrive
import (
"context"
"fmt"
"time"
"github.com/rclone/rclone/fs"
"github.com/rclone/rclone/fs/config/configmap"
)
// ConfigBrowserSignIn runs the browser sign-in of rclone config for
// remote name on m, handing the sign-in page to approve instead of
// printing it.
func ConfigBrowserSignIn(ctx context.Context, name string, m configmap.Mapper, approve func(signInURL string)) error {
out, err := configure(ctx, name, m, fs.ConfigIn{State: "browser"}, signInOptions{
showURL: approve,
pollInterval: 10 * time.Millisecond,
pollTimeout: 10 * time.Second,
})
if err == nil && out != nil {
err = fmt.Errorf("config stopped in state %q", out.State)
}
return err
}
+75
View File
@@ -0,0 +1,75 @@
package protondrive_test
import (
"encoding/base64"
"net/http"
"os"
"testing"
"github.com/rclone/rclone/backend/protondrive"
"github.com/rclone/rclone/backend/protondrive/protontest"
"github.com/rclone/rclone/fstest"
"github.com/rclone/rclone/fstest/fstests"
"github.com/rclone/rclone/lib/random"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// fakeEnvPrefix starts the environment variables configuring the
// TestProtonDriveFake: remote.
const fakeEnvPrefix = "RCLONE_CONFIG_TESTPROTONDRIVEFAKE_"
// setupFakeRemote starts a fake Proton server with one user and
// configures the TestProtonDriveFake: remote, through environment
// variables, with a saved session of that user.
func setupFakeRemote(t *testing.T) (remoteName string, srv *protontest.Server, user *protontest.User) {
srv = protontest.New(t)
user = srv.AddUser("user", "pass")
sess := srv.NewSession(user)
t.Setenv(fakeEnvPrefix+"TYPE", "protondrive")
t.Setenv(fakeEnvPrefix+"ENDPOINT", srv.URL())
t.Setenv(fakeEnvPrefix+"CLIENT_UID", sess.UID)
t.Setenv(fakeEnvPrefix+"CLIENT_ACCESS_TOKEN", sess.AccessToken)
t.Setenv(fakeEnvPrefix+"CLIENT_REFRESH_TOKEN", sess.RefreshToken)
// The config holds the salted key password base64 encoded.
t.Setenv(fakeEnvPrefix+"CLIENT_SALTED_KEY_PASS", base64.StdEncoding.EncodeToString([]byte(user.KeyPassword())))
t.Setenv(fakeEnvPrefix+"DRAFT_CLIENT_UID", random.String(32))
return "TestProtonDriveFake:", srv, user
}
// TestSetupFakeRemote checks the configured session is accepted by the
// fake server.
func TestSetupFakeRemote(t *testing.T) {
remoteName, srv, user := setupFakeRemote(t)
assert.Equal(t, "TestProtonDriveFake:", remoteName)
assert.Equal(t, "protondrive", os.Getenv(fakeEnvPrefix+"TYPE"))
assert.Equal(t, srv.URL(), os.Getenv(fakeEnvPrefix+"ENDPOINT"))
assert.NotEmpty(t, os.Getenv(fakeEnvPrefix+"DRAFT_CLIENT_UID"))
keyPass, err := base64.StdEncoding.DecodeString(os.Getenv(fakeEnvPrefix + "CLIENT_SALTED_KEY_PASS"))
require.NoError(t, err)
assert.Equal(t, user.KeyPassword(), string(keyPass))
require.NotEmpty(t, os.Getenv(fakeEnvPrefix+"CLIENT_REFRESH_TOKEN"))
req, err := http.NewRequest("GET", os.Getenv(fakeEnvPrefix+"ENDPOINT")+"/core/v4/users", nil)
require.NoError(t, err)
req.Header.Set("x-pm-appversion", "external-drive-rclone@1.0.0-stable")
req.Header.Set("x-pm-uid", os.Getenv(fakeEnvPrefix+"CLIENT_UID"))
req.Header.Set("Authorization", "Bearer "+os.Getenv(fakeEnvPrefix+"CLIENT_ACCESS_TOKEN"))
resp, err := srv.Client().Do(req)
require.NoError(t, err)
_ = resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
}
// TestIntegrationFake runs integration tests against the fake server.
func TestIntegrationFake(t *testing.T) {
if *fstest.RemoteName != "" {
t.Skip("skipping as -remote is set")
}
remoteName, _, _ := setupFakeRemote(t)
fstests.Run(t, &fstests.Opt{
RemoteName: remoteName,
NilObject: (*protondrive.Object)(nil),
QuickTestOK: true,
})
}
+367
View File
@@ -0,0 +1,367 @@
package protondrive
import (
"bytes"
"context"
"crypto/aes"
"crypto/cipher"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
"time"
"unicode/utf16"
"unicode/utf8"
"github.com/ProtonMail/gopenpgp/v3/crypto"
"github.com/rclone/rclone/backend/protondrive/api"
"github.com/rclone/rclone/backend/protondrive/protontest"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// Fuzz targets for everything which parses bytes the server controls.
// Each is seeded from the old library's fixtures in testdata/oldlib and
// from payloads the fake server makes, and checks invariants of what
// is accepted, not only that nothing panics.
// oldlibFixture is the part of testdata/oldlib/file.json and
// folder.json the fuzz targets use.
type oldlibFixture struct {
NodeKey string
NodePassphrasePlain string
NamePlain string
ChildName string
XAttr string
}
// loadOldlibFixture returns testdata/oldlib/name.
func loadOldlibFixture(t testing.TB, name string) oldlibFixture {
t.Helper()
data, err := os.ReadFile(filepath.Join("testdata/oldlib", name))
require.NoError(t, err)
var fx oldlibFixture
require.NoError(t, json.Unmarshal(data, &fx))
return fx
}
// fuzzLinks returns a client for a user of a fake server holding the
// keys of the old library's v6 file fixture, the root folder's keys,
// and the JSON of a link of each kind the client reads: the fixture
// itself, and nodes the fake makes as SDK clients would.
func fuzzLinks(f *testing.F) (*client, *nodeKeys, [][]byte) {
frozen := loadFrozenFile(f, filepath.Join(frozenFileDir, "v6"))
srv := protontest.New(f)
u := srv.AddUserWithKeys(frozen.Email, "pass", frozen.Keys)
c := newTestClientFor(f, srv, u, clientOptions{})
root := u.RootID()
ids := []string{
u.SeedStoredFile(root, frozen.Stored),
u.SeedFolder(root, "folder"),
u.SeedFile(root, "file.txt", []byte("content"), time.Now()),
u.SeedFile(root, "empty", nil, time.Now()),
u.SeedLegacyFile(root, "legacy", []byte("legacy")),
u.SeedAnonymousFile(root, "anonymous", []byte("anonymous"), time.Now()),
u.SeedAnonymousFolder(root, "anonymous folder"),
u.SeedFileClaimingBlockSizes(root, "claims", []byte("claims"), []int64{1, 2, 3}),
u.SeedDoc(root, "doc"),
}
ctx := context.Background()
links, err := c.fetchLinks(ctx, ids)
require.NoError(f, err)
require.Len(f, links, len(ids))
var seeds [][]byte
for _, d := range links {
data, err := json.Marshal(d)
require.NoError(f, err)
seeds = append(seeds, data)
}
keys, err := c.folderKeysOf(ctx, root)
require.NoError(f, err)
return c, keys, seeds
}
func FuzzValidateName(f *testing.F) {
for _, name := range []string{
loadOldlibFixture(f, "file.json").NamePlain,
loadOldlibFixture(f, "folder.json").NamePlain,
loadOldlibFixture(f, "folder.json").ChildName,
"file.txt", ".", "..", "...", "", "a/b", "/", "a\x00b", " ", "é", "é",
strings.Repeat("a", maxNameLength), strings.Repeat("a", maxNameLength+1),
strings.Repeat("\U0001F600", maxNameLength/2), strings.Repeat("\U0001F600", maxNameLength/2+1),
"\xff\xfe",
} {
f.Add(name)
}
f.Fuzz(func(t *testing.T, name string) {
if validateName(name) != nil {
return
}
assert.NotEmpty(t, name)
assert.True(t, utf8.ValidString(name), "%q", name)
assert.NotEqual(t, ".", name)
assert.NotEqual(t, "..", name)
assert.NotContains(t, name, "/")
assert.NotContains(t, name, "\x00")
assert.LessOrEqual(t, len(utf16.Encode([]rune(name))), maxNameLength)
})
}
func FuzzDecodeLink(f *testing.F) {
c, rootKeys, seeds := fuzzLinks(f)
for _, seed := range seeds {
f.Add(seed)
// Hostile values in links which otherwise decrypt.
var d api.LinkDetails
require.NoError(f, json.Unmarshal(seed, &d))
for _, change := range []func(d *api.LinkDetails){
func(d *api.LinkDetails) { d.Link.LinkID = "" },
func(d *api.LinkDetails) { d.Link.Type = 3 - d.Link.Type },
func(d *api.LinkDetails) {
if d.File != nil && d.File.ActiveRevision != nil {
d.File.ActiveRevision.EncryptedSize = -5
}
},
} {
changed := d
changed.File = cloneFile(d.File)
change(&changed)
data, err := json.Marshal(changed)
require.NoError(f, err)
f.Add(data)
}
}
// Shapes the client can't decrypt.
for _, format := range []string{"legacy", "v6"} {
data, err := os.ReadFile(filepath.Join(frozenFileDir, format, "link.json"))
require.NoError(f, err)
f.Add(data)
}
f.Add([]byte(`{}`))
f.Add([]byte(`{"Link":{"LinkID":"x","Type":1,"ParentLinkID":"p"},"Folder":{}}`))
ctx := context.Background()
f.Fuzz(func(t *testing.T, data []byte) {
var d api.LinkDetails
if json.Unmarshal(data, &d) != nil {
return
}
n, unreadable, err := c.decryptNode(ctx, &d, rootKeys)
if err != nil || unreadable != nil {
assert.Nil(t, n)
return
}
require.NotNil(t, n)
assert.NotEmpty(t, n.ID)
assert.Equal(t, d.Link.LinkID, n.ID)
assert.True(t, utf8.ValidString(n.Name), "%q", n.Name)
if d.Link.ParentLinkID != "" {
assert.NoError(t, validateName(n.Name))
}
assert.True(t, n.IsFolder == (d.Link.Type == api.LinkTypeFolder))
for _, original := range []bool{true, false} {
assert.GreaterOrEqual(t, n.size(original), int64(0), "original=%v", original)
}
if n.X != nil {
checkXAttrs(t, n.X)
}
})
}
// cloneFile returns a copy of file, sharing nothing changeable with it.
func cloneFile(file *api.File) *api.File {
if file == nil {
return nil
}
out := *file
if file.ActiveRevision != nil {
rev := *file.ActiveRevision
out.ActiveRevision = &rev
}
return &out
}
// checkXAttrs checks the invariants of parsed extended attributes.
//
// That the block sizes add up to the size isn't one: as in the SDK
// neither is checked against the other, and a read checks each block
// against its recorded size, so a file whose attributes disagree fails
// to read rather than reading wrong.
func checkXAttrs(t *testing.T, x *xattrs) {
t.Helper()
assert.GreaterOrEqual(t, x.Size, int64(0))
if x.NoSize {
assert.Zero(t, x.Size)
}
if x.BlockSizes != nil {
assert.NotEmpty(t, x.BlockSizes)
}
for i, size := range x.BlockSizes {
assert.Positive(t, size, "block %d", i+1)
}
if !x.ModTime.IsZero() {
assert.Equal(t, time.UTC, x.ModTime.Location())
// RFC 3339 years, moved by a zone offset of under a day.
assert.True(t, x.ModTime.Year() >= -1 && x.ModTime.Year() <= 10000, "%v", x.ModTime)
}
assert.NotContains(t, x.Extra, "Common")
}
func FuzzCheckKeyS2K(f *testing.F) {
f.Add(unarmor(f, loadOldlibFixture(f, "file.json").NodeKey))
now := time.Now()
parent, signer := newTestKeyRing(f, now), newTestKeyRing(f, now)
_, _, _, keys := testNodeKeys(f, parent, signer, true, now)
for _, memExp := range []byte{3, 17, 18, 31} {
f.Add(unarmor(f, argon2Locked(f, keys, 1, 1, memExp)))
}
f.Fuzz(func(t *testing.T, data []byte) {
// Whatever the bytes, the check never runs a KDF and never
// panics; a key it lets through still parses or fails to.
if checkKeyS2K(data) == nil {
_, _ = crypto.NewKey(data)
}
})
}
func FuzzDecryptXAttrs(f *testing.F) {
fx := loadOldlibFixture(f, "file.json")
key, err := unlockKey(fx.NodeKey, []byte(fx.NodePassphrasePlain))
require.NoError(f, err)
nodeKR, err := crypto.NewKeyRing(key)
require.NoError(f, err)
now := time.Now()
// The old library's attributes, encrypted and in plain text.
res, _, _, err := decryptMessage(fx.XAttr, nodeKR, nil, now, maxXAttrsSize)
require.NoError(f, err)
f.Add(fx.XAttr, res.Bytes())
// Attributes as the fake writes them for SDK clients.
c, _, seeds := fuzzLinks(f)
for _, seed := range seeds {
var d api.LinkDetails
require.NoError(f, json.Unmarshal(seed, &d))
if d.File == nil || d.File.ActiveRevision == nil || d.File.ActiveRevision.XAttr == "" {
continue
}
n, err := c.getNode(context.Background(), d.Link.LinkID)
require.NoError(f, err)
res, _, _, err := decryptMessage(d.File.ActiveRevision.XAttr, n.keys.keyRing, nil, now, maxXAttrsSize)
require.NoError(f, err)
f.Add("", res.Bytes())
}
for _, plain := range []string{
`{"Common":{"ModificationTime":"2021-09-16T07:40:54+0000","Size":-1,"BlockSizes":[4194304,-1],"Digests":{"SHA1":"x"}}}`,
`{"Common":{"Size":1152921504606846976,"BlockSizes":[1152921504606846976,1]}}`,
`{"Common":{"ModificationTime":"99999-01-01T00:00:00Z"}}`,
`{"Common":{"ModificationTime":"0000-01-01T00:00:00+23:59"}}`,
`{"Common":{"BlockSizes":[]},"Media":{"Width":1}}`,
`{"Common":{"BlockSizes":[` + strings.Repeat("4194304,", 10000) + `1]}}`,
strings.Repeat(`{"Common":`, 20000) + strings.Repeat("}", 20000),
`{"Common":` + strings.Repeat("[", 100000) + strings.Repeat("]", 100000) + `}`,
} {
f.Add("", []byte(plain))
}
f.Fuzz(func(t *testing.T, enc string, plain []byte) {
if x, _, err := decryptXAttrs(enc, nodeKR, nodeKR, now); err == nil {
checkXAttrs(t, x)
}
if len(plain) > maxXAttrsSize/2 {
return
}
enc, err := encryptArmored(plain, nil, nodeKR, nodeKR, true, now)
require.NoError(t, err)
x, verifyErr, err := decryptXAttrs(enc, nodeKR, nodeKR, now)
if !utf8.Valid(plain) {
assert.Error(t, err)
return
}
require.NoError(t, err)
require.NoError(t, verifyErr)
checkXAttrs(t, x)
})
}
func FuzzDecodeAPIError(f *testing.F) {
for _, seed := range []struct {
status int
body string
retryAfter string
}{
{422, `{"Code":2500,"Error":"A file or folder with that name already exists","Details":{"ConflictLinkID":"abc","ConflictRevisionID":"def"}}`, ""},
{422, `{"Code":2501,"Error":"File or folder not found"}`, ""},
{429, `{"Code":85131,"Error":"Too many recent API requests"}`, "17"},
{429, ``, "Wed, 21 Oct 2015 07:28:00 GMT"},
{429, ``, "99999999999999999999999"},
{401, `{"Code":401,"Error":"Invalid access token"}`, ""},
{400, `{"Code":2064,"Error":"protontest: invalid x-pm-appversion \"x\""}`, ""},
{500, `<html>Internal Server Error</html>`, ""},
{200, `{"Code":"1000"}`, ""},
{0, `{"Error":null,"Details":[1,2,3]}`, "-5"},
} {
f.Add(seed.status, []byte(seed.body), seed.retryAfter)
}
f.Fuzz(func(t *testing.T, status int, body []byte, retryAfterValue string) {
resp := &http.Response{
StatusCode: status,
Status: fmt.Sprintf("%d %s", status, http.StatusText(status)),
Header: http.Header{"Retry-After": {retryAfterValue}},
Body: io.NopCloser(bytes.NewReader(body)),
}
err := errorHandler(resp)
var apiErr *api.Error
require.ErrorAs(t, err, &apiErr)
assert.Equal(t, status, apiErr.Status)
assert.NotEmpty(t, apiErr.Message)
assert.NotEmpty(t, err.Error())
assert.Equal(t, apiErr.Code, apiErrorCode(err))
wait := retryAfter(resp)
assert.Positive(t, wait)
assert.LessOrEqual(t, wait, maxRetryAfter)
})
}
func FuzzDecryptForkPayload(f *testing.F) {
key := bytes.Repeat([]byte{7}, 32)
for _, seed := range []struct{ plain, aad string }{
{`{"type":"default","keyPassword":"the-key-password"}`, forkPayloadAAD},
{`{"keyPassword":""}`, forkPayloadAAD},
{`{"keyPassword":null}`, forkPayloadAAD},
{`{"type":"default"}`, forkPayloadAAD},
{`not json`, forkPayloadAAD},
{`{"keyPassword":"wrong additional data"}`, "other"},
} {
f.Add(sealForkPayload(f, key, seed.plain, seed.aad))
}
f.Add("")
f.Add("not base64!")
f.Add(base64.StdEncoding.EncodeToString(make([]byte, 12)))
f.Fuzz(func(t *testing.T, payload string) {
keyPassword, err := decryptForkPayload(payload, key)
if err != nil {
assert.Empty(t, keyPassword)
return
}
// Success only for a payload sealed with key: open it here
// independently.
blob, err := base64.StdEncoding.DecodeString(payload)
require.NoError(t, err)
block, err := aes.NewCipher(key)
require.NoError(t, err)
gcm, err := cipher.NewGCM(block)
require.NoError(t, err)
require.GreaterOrEqual(t, len(blob), gcm.NonceSize())
plain, err := gcm.Open(nil, blob[:gcm.NonceSize()], blob[gcm.NonceSize():], []byte(forkPayloadAAD))
require.NoError(t, err, "accepted a payload which doesn't authenticate")
var p struct{ KeyPassword *string }
require.NoError(t, json.Unmarshal(plain, &p))
require.NotNil(t, p.KeyPassword)
assert.Equal(t, *p.KeyPassword, keyPassword)
})
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+661 -80
View File
@@ -1,97 +1,678 @@
package protondrive
import (
"bytes"
"context"
"encoding/base64"
"errors"
"fmt"
"regexp"
"io"
"maps"
"strings"
"testing"
"time"
"github.com/rclone/go-proton-api"
"github.com/pquerna/otp/totp"
"github.com/rclone/rclone/backend/protondrive/api"
"github.com/rclone/rclone/backend/protondrive/protontest"
"github.com/rclone/rclone/fs"
"github.com/rclone/rclone/fs/config/configmap"
"github.com/rclone/rclone/fs/config/obscure"
"github.com/rclone/rclone/fs/fserrors"
"github.com/rclone/rclone/fs/object"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
var protonDriveAppVersionPattern = regexp.MustCompile(`(?i)^external-drive(-[a-z_]+)+@[0-9]+\.[0-9]+\.[0-9]+(\.[0-9]+)?-((stable|beta|RC|alpha)(([.-]?\d+)*)?)?([.-]?dev)?(\+.*)?$`)
func TestTranslateError(t *testing.T) {
apiErr := func(code int) error {
return fmt.Errorf("call: %w", &api.Error{Status: 422, Code: code, Message: "proton says no"})
}
notFound := fmt.Errorf("get node: %w", errNotFound)
exists := fmt.Errorf("create: %w", errExists)
other := errors.New("other")
func TestProtonDriveAppVersionFromRcloneVersion(t *testing.T) {
testCases := []struct {
name string
rcloneVersion string
want string
for _, test := range []struct {
name string
err error
op errorOp
want error // compared with ==, if set
check func(t *testing.T, got error)
}{
{
name: "release",
rcloneVersion: "v1.73.5",
want: "external-drive-rclone@1.73.5-stable",
},
{
name: "dev build",
rcloneVersion: "v1.74.0-DEV",
want: "external-drive-rclone@1.74.0-dev",
},
{
name: "beta build with extra metadata",
rcloneVersion: "v1.74.0-beta.9519.990f33f2a.fix-protondrive-sdk-2026",
want: "external-drive-rclone@1.74.0-beta.9519+990f33f2a.fix-protondrive-sdk-2026",
},
{
name: "beta build with unsanitized branch name",
rcloneVersion: "v1.74.0-beta.9519.990f33f2a.fix/protondrive-sdk-2026",
want: "external-drive-rclone@1.74.0-beta.9519+990f33f2a.fix-protondrive-sdk-2026",
},
{
name: "invalid version falls back to stable",
rcloneVersion: "not-a-version",
want: "external-drive-rclone@1.0.0-stable",
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
got := protonDriveAppVersionFromRcloneVersion(testCase.rcloneVersion)
if got != testCase.want {
t.Fatalf("unexpected app version: got %q, want %q", got, testCase.want)
}
if !protonDriveAppVersionPattern.MatchString(got) {
t.Fatalf("app version %q does not match Proton pattern", got)
}
})
}
}
func TestShouldRetry(t *testing.T) {
ctx := context.Background()
cancelledCtx, cancel := context.WithCancel(ctx)
cancel()
apiErr := func(status int, code proton.Code) error {
return &proton.APIError{Status: status, Code: code, Message: "test"}
}
for _, tc := range []struct {
name string
ctx context.Context
err error
wantRetry bool
}{
{"nil error", ctx, nil, false},
{"cancelled context", cancelledCtx, errors.New("some error"), false},
{"permanent validation error Code=200501 Status=422 (not retried)", ctx, apiErr(422, 200501), false},
{"transient storage block error Code=200501 Status=500 (retried)", ctx, apiErr(500, 200501), true},
{"server error Status=500", ctx, apiErr(500, 0), true},
{"server error Status=502", ctx, apiErr(502, 0), true},
{"server error Status=504", ctx, apiErr(504, 0), true},
{"server error Status=503 (handled by SDK, not retried here)", ctx, apiErr(503, 0), false},
{"rate limit Status=429 (handled by SDK, not retried here)", ctx, apiErr(429, 0), false},
{"client error Status=400", ctx, apiErr(400, 0), false},
{"client error Status=404", ctx, apiErr(404, 0), false},
{"wrapped API error retried via errors.As", ctx, fmt.Errorf("wrapped: %w", apiErr(500, 0)), true},
{"non-API error falls back to fserrors.ShouldRetry", ctx, errors.New("plain error"), false},
{name: "nil", err: nil, op: opObject, want: nil},
{name: "not found object", err: notFound, op: opObject, want: fs.ErrorObjectNotFound},
{name: "not found dir", err: notFound, op: opDir, want: fs.ErrorDirNotFound},
{name: "code 2501 object", err: apiErr(api.CodeNotExists), op: opObject, want: fs.ErrorObjectNotFound},
{name: "code 2501 dir", err: apiErr(api.CodeNotExists), op: opDir, want: fs.ErrorDirNotFound},
{name: "exists mkdir", err: exists, op: opMkdir, want: nil},
{name: "exists move", err: exists, op: opMove, want: fs.ErrorCantMove},
{name: "exists copy", err: exists, op: opCopy, want: fs.ErrorCantCopy},
{name: "exists dirmove", err: exists, op: opDirMove, want: fs.ErrorDirExists},
{name: "exists object", err: exists, op: opObject, check: func(t *testing.T, got error) {
assert.ErrorIs(t, got, errExists)
}},
{name: "other unchanged", err: other, op: opObject, want: other},
} {
t.Run(tc.name, func(t *testing.T) {
gotRetry, _ := shouldRetry(tc.ctx, tc.err)
assert.Equal(t, tc.wantRetry, gotRetry)
t.Run(test.name, func(t *testing.T) {
got := translateError(test.err, test.op)
if test.check != nil {
test.check(t, got)
return
}
assert.Equal(t, test.want, got)
})
}
for _, code := range []int{
api.CodeInsufficientQuota, api.CodeInsufficientSpace, api.CodeMaxFileSizeForFreeUser,
api.CodeMaxPublicEditModeForFreeUser, api.CodeTooManyChildren, api.CodeNestingTooDeep,
} {
t.Run(fmt.Sprintf("code %d no retry", code), func(t *testing.T) {
got := translateError(apiErr(code), opObject)
assert.True(t, fserrors.IsNoRetryError(got))
assert.False(t, fserrors.IsFatalError(got))
assert.Contains(t, got.Error(), "proton says no")
})
}
for _, code := range []int{api.CodeAppVersionMissing, api.CodeAppVersionBad} {
t.Run(fmt.Sprintf("code %d fatal", code), func(t *testing.T) {
got := translateError(apiErr(code), opObject)
assert.True(t, fserrors.IsFatalError(got))
assert.Contains(t, got.Error(), "upgrade rclone")
assert.Contains(t, got.Error(), "proton says no")
})
}
t.Run("code 2000 no retry", func(t *testing.T) {
got := translateError(apiErr(api.CodeInvalidRequirements), opMove)
assert.True(t, fserrors.IsNoRetryError(got))
assert.False(t, fserrors.IsFatalError(got))
assert.NotContains(t, got.Error(), "upgrade rclone")
assert.Contains(t, got.Error(), "proton says no")
})
t.Run("code 2028 not fatal", func(t *testing.T) {
got := translateError(apiErr(api.CodeFeatureBlocked), opObject)
assert.False(t, fserrors.IsFatalError(got))
assert.False(t, fserrors.IsNoRetryError(got))
assert.NotContains(t, got.Error(), "upgrade rclone")
assert.Contains(t, got.Error(), "proton says no")
})
for _, err := range []error{errIntegrity, errUploadMismatch, errUploadSuperseded} {
t.Run(err.Error()+" no retry", func(t *testing.T) {
got := translateError(fmt.Errorf("op: %w", err), opObject)
assert.True(t, fserrors.IsNoRetryError(got))
assert.ErrorIs(t, got, err)
})
}
for _, err := range []error{errUploadOutcomeUnknown, errUploadUnconfirmed} {
t.Run(err.Error()+" no low level retry", func(t *testing.T) {
got := translateError(fmt.Errorf("op: %w", err), opObject)
assert.True(t, fserrors.IsNoLowLevelRetryError(got))
assert.False(t, fserrors.IsNoRetryError(got))
assert.ErrorIs(t, got, err)
})
}
}
// sessionConfig returns the config keys of a v1.76 remote holding a new
// saved session of u, pointed at srv.
func sessionConfig(srv *protontest.Server, u *protontest.User) configmap.Simple {
sess := srv.NewSession(u)
return configmap.Simple{
"type": "protondrive",
"username": "",
"password": "",
"mailbox_password": "",
"2fa": "",
"otp_secret_key": "",
"client_uid": sess.UID,
"client_access_token": sess.AccessToken,
"client_refresh_token": sess.RefreshToken,
"client_salted_key_pass": base64.StdEncoding.EncodeToString([]byte(u.KeyPassword())),
"encoding": "Slash,LeftSpace,RightSpace,InvalidUtf8,Dot",
"original_file_size": "true",
"app_version": "",
"replace_existing_draft": "false",
"enable_caching": "true",
// Not a v1.76 key, but the only way to reach the fake.
"endpoint": srv.URL(),
}
}
// newTestFs returns an Fs for the config m, shut down when the test ends.
func newTestFs(t *testing.T, m configmap.Mapper) *Fs {
t.Helper()
f, err := NewFs(context.Background(), "test", "", m)
require.NoError(t, err)
t.Cleanup(func() { _ = f.(*Fs).Shutdown(context.Background()) })
return f.(*Fs)
}
// authRequests returns the auth requests made to srv.
func authRequests(srv *protontest.Server) []string {
var out []string
for route := range srv.Requests() {
if strings.Contains(route, "/auth") {
out = append(out, route)
}
}
return out
}
func TestOptionsBackwardsCompatible(t *testing.T) {
srv := protontest.New(t)
u := srv.AddUser("user", "pass")
m := sessionConfig(srv, u)
want := m["client_access_token"]
f := newTestFs(t, m)
_, err := f.List(context.Background(), "")
require.NoError(t, err)
assert.Empty(t, authRequests(srv), "a saved session must be used without signing in")
assert.Equal(t, want, m["client_access_token"])
assert.Len(t, m["draft_client_uid"], 32, "draft_client_uid is generated and saved")
assert.True(t, f.opt.ReportOriginalSize)
assert.True(t, f.opt.EnableCaching)
}
// unsavedMap is a config which saves nothing, as one from environment
// variables or a connection string.
type unsavedMap struct{ configmap.Simple }
// Set does nothing.
func (unsavedMap) Set(key, value string) {}
func TestDraftClientUIDStableWhenUnsaved(t *testing.T) {
ctx := context.Background()
srv := protontest.New(t)
u := srv.AddUser("user", "pass")
uid := func(name string) string {
m := sessionConfig(srv, u)
f, err := NewFs(ctx, name, "", unsavedMap{m})
require.NoError(t, err)
t.Cleanup(func() { _ = f.(*Fs).Shutdown(ctx) })
got := f.(*Fs).c.opt.DraftClientUID
assert.Len(t, got, 32)
return got
}
first := uid("env")
assert.Equal(t, first, uid("env"), "a later run of the same remote has another draft client ID")
assert.NotEqual(t, first, uid("other"), "another remote of the account has the same draft client ID")
}
func TestNewFsPasswordFallback(t *testing.T) {
srv := protontest.New(t)
u := srv.AddUser("user", "pass")
m := sessionConfig(srv, u)
m["username"] = "user"
m["password"] = obscure.MustObscure("pass")
oldUID := m["client_uid"]
srv.RevokeSession(oldUID)
f := newTestFs(t, m)
_, err := f.List(context.Background(), "")
require.NoError(t, err)
assert.NotEqual(t, oldUID, m["client_uid"])
assert.NotEmpty(t, m["client_access_token"])
assert.NotEmpty(t, m["client_refresh_token"])
keyPass, err := base64.StdEncoding.DecodeString(m["client_salted_key_pass"])
require.NoError(t, err)
assert.Equal(t, u.KeyPassword(), string(keyPass))
assert.Equal(t, 1, srv.Requests()["POST /core/v4/auth"])
}
func TestNewFsPasswordFallbackTOTP(t *testing.T) {
srv := protontest.New(t)
u := srv.AddUser("user", "pass")
secret := u.EnableTOTP()
m := sessionConfig(srv, u)
for _, k := range []string{"client_uid", "client_access_token", "client_refresh_token", "client_salted_key_pass"} {
m[k] = ""
}
m["username"] = "user"
m["password"] = obscure.MustObscure("pass")
m["otp_secret_key"] = obscure.MustObscure(secret)
newTestFs(t, m)
assert.NotEmpty(t, m["client_uid"])
assert.Equal(t, 1, srv.Requests()["POST /core/v4/auth/2fa"])
}
func TestNewFsNoCredentials(t *testing.T) {
srv := protontest.New(t)
u := srv.AddUser("user", "pass")
t.Run("none saved", func(t *testing.T) {
m := configmap.Simple{"endpoint": srv.URL()}
_, err := NewFs(context.Background(), "myremote", "", m)
require.Error(t, err)
assert.Contains(t, err.Error(), `rclone config reconnect myremote:`)
})
t.Run("revoked", func(t *testing.T) {
m := sessionConfig(srv, u)
srv.RevokeSession(m["client_uid"])
_, err := NewFs(context.Background(), "myremote", "", m)
require.Error(t, err)
assert.Contains(t, err.Error(), `rclone config reconnect myremote:`)
})
t.Run("undecodable key password", func(t *testing.T) {
m := sessionConfig(srv, u)
m["client_salted_key_pass"] = "not base64!"
_, err := NewFs(context.Background(), "myremote", "", m)
require.Error(t, err)
assert.Contains(t, err.Error(), `rclone config reconnect myremote:`)
})
}
func TestTwoRemotesIsolated(t *testing.T) {
ctx := context.Background()
srv := protontest.New(t)
u1 := srv.AddUser("user1", "pass1")
u2 := srv.AddUser("user2", "pass2")
m1, m2 := sessionConfig(srv, u1), sessionConfig(srv, u2)
uid1, uid2 := m1["client_uid"], m2["client_uid"]
f1, f2 := newTestFs(t, m1), newTestFs(t, m2)
require.NoError(t, f1.Mkdir(ctx, "one"))
require.NoError(t, f2.Mkdir(ctx, "two"))
// Make both sessions refresh, saving new tokens.
access1, access2 := m1["client_access_token"], m2["client_access_token"]
srv.ResetRequests()
srv.ExpireAccessToken(uid1)
srv.ExpireAccessToken(uid2)
f1.DirCacheFlush()
f2.DirCacheFlush()
entries1, err := f1.List(ctx, "")
require.NoError(t, err)
entries2, err := f2.List(ctx, "")
require.NoError(t, err)
require.Len(t, entries1, 1)
assert.Equal(t, "one", entries1[0].Remote())
require.Len(t, entries2, 1)
assert.Equal(t, "two", entries2[0].Remote())
assert.Equal(t, 2, srv.Requests()["POST /auth/v4/refresh"])
assert.NotEqual(t, access1, m1["client_access_token"], "remote 1 saved its refreshed token")
assert.NotEqual(t, access2, m2["client_access_token"], "remote 2 saved its refreshed token")
assert.Equal(t, uid1, m1["client_uid"])
assert.Equal(t, uid2, m2["client_uid"])
for _, k := range []string{"client_uid", "client_access_token", "client_refresh_token", "client_salted_key_pass", "draft_client_uid"} {
assert.NotEqual(t, m1[k], m2[k], k)
}
}
// configAnswers drives configure through its states, answering each
// question by its state from answers, and returns the questions asked.
func configAnswers(t *testing.T, m configmap.Mapper, signIn signInOptions, answers map[string]string) []*fs.ConfigOut {
t.Helper()
var asked []*fs.ConfigOut
in := fs.ConfigIn{}
for range 10 {
out, err := configure(context.Background(), "test", m, in, signIn)
require.NoError(t, err)
if out == nil {
return asked
}
require.Empty(t, out.Error)
in = fs.ConfigIn{State: out.State}
if out.Option != nil {
asked = append(asked, out)
answer, ok := answers[out.State]
require.True(t, ok, "unexpected question in state %q: %s", out.State, out.Option.Help)
in.Result = answer
}
}
t.Fatal("config didn't finish")
return nil
}
// requireSession checks m holds a working session of u on srv.
func requireSession(t *testing.T, srv *protontest.Server, m configmap.Simple, u *protontest.User) {
t.Helper()
keyPass, err := base64.StdEncoding.DecodeString(m["client_salted_key_pass"])
require.NoError(t, err)
assert.Equal(t, u.KeyPassword(), string(keyPass))
srv.ResetRequests()
newTestFs(t, m)
assert.Empty(t, authRequests(srv))
}
func TestConfigBrowser(t *testing.T) {
srv := protontest.New(t)
u := srv.AddUser("user", "pass")
m := configmap.Simple{"endpoint": srv.URL()}
var shown string
signIn := signInOptions{
pollInterval: 10 * time.Millisecond,
pollTimeout: 10 * time.Second,
showURL: func(signInURL string) {
shown = signInURL
require.NoError(t, srv.ApproveFork(signInURL, u))
},
}
asked := configAnswers(t, m, signIn, map[string]string{"choose": "true"})
require.Len(t, asked, 1)
assert.Contains(t, asked[0].Option.Help, "rclone is a third-party application not officially supported by Proton.")
assert.Equal(t, true, asked[0].Option.Default)
assert.True(t, strings.HasPrefix(shown, forkSignInURL), shown)
requireSession(t, srv, m, u)
}
func TestConfigPassword(t *testing.T) {
srv := protontest.New(t)
t.Run("no 2fa", func(t *testing.T) {
u := srv.AddUser("user", "pass")
m := configmap.Simple{"endpoint": srv.URL(), "username": "user", "password": obscure.MustObscure("pass")}
asked := configAnswers(t, m, signInOptions{}, map[string]string{"choose": "false"})
require.Len(t, asked, 1)
assert.Equal(t, false, asked[0].Option.Default, "password sign-in is the default when a password is set")
requireSession(t, srv, m, u)
})
t.Run("2fa asked", func(t *testing.T) {
u := srv.AddUser("user2fa", "pass")
secret := u.EnableTOTP()
m := configmap.Simple{"endpoint": srv.URL(), "username": "user2fa", "password": obscure.MustObscure("pass")}
code, err := totp.GenerateCode(secret, time.Now())
require.NoError(t, err)
asked := configAnswers(t, m, signInOptions{}, map[string]string{"choose": "false", "password_2fa": code})
require.Len(t, asked, 2)
requireSession(t, srv, m, u)
assert.Empty(t, m["2fa"], "the 2FA code is not saved")
})
t.Run("captcha", func(t *testing.T) {
u := srv.AddUser("usercaptcha", "pass")
srv.RequireCaptcha(u)
m := configmap.Simple{"endpoint": srv.URL(), "username": "usercaptcha", "password": obscure.MustObscure("pass")}
out, err := configure(context.Background(), "test", m, fs.ConfigIn{State: "password"}, signInOptions{})
require.NoError(t, err)
require.NotNil(t, out)
assert.Contains(t, out.Error, "web browser")
assert.Equal(t, "method", out.State, "goes back to the choice of sign-in")
assert.Empty(t, m["client_uid"])
})
t.Run("no password", func(t *testing.T) {
m := configmap.Simple{"endpoint": srv.URL()}
out, err := configure(context.Background(), "test", m, fs.ConfigIn{State: "password"}, signInOptions{})
require.NoError(t, err)
require.NotNil(t, out)
assert.NotEmpty(t, out.Error)
})
}
func TestConfigStore(t *testing.T) {
creds := credentials{UID: "uid", AccessToken: "access", RefreshToken: "refresh", KeyPassword: "key\x00pass"}
t.Run("round trip", func(t *testing.T) {
m := configmap.Simple{}
s := &configStore{m: m}
s.Save(creds)
assert.Equal(t, base64.StdEncoding.EncodeToString([]byte(creds.KeyPassword)), m[clientSaltedKeyPassKey])
assert.Equal(t, creds, s.Load())
// Another user of the config saves newer tokens.
m[clientRefreshTokenKey] = "newer"
assert.Equal(t, "newer", s.Load().RefreshToken)
})
t.Run("overridden", func(t *testing.T) {
// Values from the environment or the command line take
// precedence over the config file the store saves to.
env := configmap.Simple{clientUIDKey: "old uid", clientAccessTokenKey: "old access", clientRefreshTokenKey: "old refresh"}
file := configmap.Simple{}
m := configmap.New().AddGetter(env, configmap.PriorityNormal).AddGetter(file, configmap.PriorityConfig).AddSetter(file)
s := &configStore{m: m}
assert.Equal(t, "old refresh", s.Load().RefreshToken)
s.Save(creds)
assert.Equal(t, creds, s.Load(), "saved credentials must not be shadowed by spent ones")
})
}
// putFile uploads content to f at remote and returns the object.
func putFile(t *testing.T, f fs.Fs, remote string, content []byte) fs.Object {
t.Helper()
src := object.NewStaticObjectInfo(remote, time.Now(), int64(len(content)), true, nil, nil)
o, err := f.Put(context.Background(), bytes.NewReader(content), src)
require.NoError(t, err)
return o
}
// readObject returns the content of o read with options.
func readObject(t *testing.T, o fs.Object, options ...fs.OpenOption) ([]byte, error) {
t.Helper()
rc, err := o.Open(context.Background(), options...)
if err != nil {
return nil, err
}
defer func() { require.NoError(t, rc.Close()) }()
return io.ReadAll(rc)
}
func TestRmdirRefusesHiddenChildren(t *testing.T) {
ctx := context.Background()
srv := protontest.New(t)
u := srv.AddUser("user", "pass")
f := newTestFs(t, sessionConfig(srv, u))
require.NoError(t, f.Mkdir(ctx, "docs"))
docsID, err := f.dirCache.FindDir(ctx, "docs", false)
require.NoError(t, err)
u.SeedDoc(docsID, "notes")
assert.Equal(t, fs.ErrorDirectoryNotEmpty, f.Rmdir(ctx, "docs"))
// Filled by another client after an empty listing was cached.
require.NoError(t, f.Mkdir(ctx, "later"))
entries, err := f.List(ctx, "later")
require.NoError(t, err)
require.Empty(t, entries)
laterID, err := f.dirCache.FindDir(ctx, "later", false)
require.NoError(t, err)
u.SeedFile(laterID, "new.txt", []byte("x"), time.Now())
assert.Equal(t, fs.ErrorDirectoryNotEmpty, f.Rmdir(ctx, "later"))
require.NoError(t, f.Mkdir(ctx, "empty"))
require.NoError(t, f.Rmdir(ctx, "empty"))
paths := map[string]bool{}
for _, d := range u.Dump() {
paths[d.Path] = d.State == api.LinkStateActive
}
assert.True(t, paths["docs/notes"])
assert.True(t, paths["later/new.txt"])
}
func TestConfigKeepsSession(t *testing.T) {
srv := protontest.New(t)
u := srv.AddUser("user", "pass")
ri, err := fs.Find("protondrive")
require.NoError(t, err)
t.Run("default", func(t *testing.T) {
m := sessionConfig(srv, u)
want := maps.Clone(m)
srv.ResetRequests()
asked := configAnswers(t, m, signInOptions{}, map[string]string{"reauth": "false"})
require.Len(t, asked, 1)
assert.Equal(t, false, asked[0].Option.Default)
assert.Equal(t, "config_refresh_token", asked[0].Option.Name)
assert.Empty(t, srv.Requests())
assert.Equal(t, want, m)
})
t.Run("config update", func(t *testing.T) {
// As "rclone config update remote enable_caching=false" runs it.
m := sessionConfig(srv, u)
srv.ResetRequests()
out, err := fs.BackendConfig(context.Background(), "test", m, ri, configmap.Simple{"config_refresh_token": "false"}, fs.ConfigIn{})
require.NoError(t, err)
assert.Nil(t, out)
assert.Empty(t, srv.Requests())
})
}
func TestConfigSignInAgain(t *testing.T) {
srv := protontest.New(t)
u := srv.AddUser("user", "pass")
m := sessionConfig(srv, u)
m["username"] = "user"
m["password"] = obscure.MustObscure("pass")
old := maps.Clone(m)
configAnswers(t, m, signInOptions{}, map[string]string{"reauth": "true", "choose": "false"})
assert.NotEqual(t, old["client_uid"], m["client_uid"])
assert.Equal(t, 1, srv.Requests()["DELETE /core/v4/auth"], "the old session is signed out")
requireSession(t, srv, m, u)
// The old session no longer works.
old["username"], old["password"] = "", ""
_, err := NewFs(context.Background(), "test", "", old)
require.Error(t, err)
}
func TestShutdownKeepsFsUsable(t *testing.T) {
ctx := context.Background()
srv := protontest.New(t)
u := srv.AddUser("user", "pass")
f := newTestFs(t, sessionConfig(srv, u))
o := putFile(t, f, "dir/a.txt", []byte("hello"))
require.NoError(t, f.Shutdown(ctx))
require.NoError(t, f.Shutdown(ctx))
got, err := readObject(t, o)
require.NoError(t, err)
assert.Equal(t, "hello", string(got))
putFile(t, f, "dir/b.txt", []byte("world"))
entries, err := f.List(ctx, "dir")
require.NoError(t, err)
assert.Len(t, entries, 2)
o, err = f.NewObject(ctx, "dir/b.txt")
require.NoError(t, err)
got, err = readObject(t, o)
require.NoError(t, err)
assert.Equal(t, "world", string(got))
}
func TestDisconnect(t *testing.T) {
ctx := context.Background()
srv := protontest.New(t)
u := srv.AddUser("user", "pass")
m := sessionConfig(srv, u)
f := newTestFs(t, m)
o := putFile(t, f, "a.txt", []byte("hello"))
require.NoError(t, f.Disconnect(ctx))
assert.Empty(t, m["client_access_token"])
assert.Empty(t, m["client_refresh_token"])
_, err := f.List(ctx, "")
require.Error(t, err)
assert.Contains(t, err.Error(), `rclone config reconnect test:`)
_, err = readObject(t, o)
require.Error(t, err)
assert.Contains(t, err.Error(), `rclone config reconnect test:`)
_, err = f.Put(ctx, bytes.NewReader([]byte("x")), object.NewStaticObjectInfo("b.txt", time.Now(), 1, true, nil, nil))
require.Error(t, err)
assert.Contains(t, err.Error(), `rclone config reconnect test:`)
}
func TestSessionExpiredNamesRemote(t *testing.T) {
ctx := context.Background()
srv := protontest.New(t)
u := srv.AddUser("user", "pass")
m := sessionConfig(srv, u)
f := newTestFs(t, m)
f.c.s.adoptWait = 0
srv.RevokeSession(m["client_uid"])
_, err := f.List(ctx, "")
require.ErrorIs(t, err, errSessionExpired)
assert.Contains(t, err.Error(), `rclone config reconnect test:`)
assert.NotContains(t, err.Error(), "remote:")
}
func TestTrashedBehindTheFsBack(t *testing.T) {
ctx := context.Background()
srv := protontest.New(t)
u := srv.AddUser("user", "pass")
f := newTestFs(t, sessionConfig(srv, u))
other := newTestFs(t, sessionConfig(srv, u))
o := putFile(t, f, "a.txt", []byte("hello"))
require.NoError(t, f.Mkdir(ctx, "dir"))
_, err := f.List(ctx, "")
require.NoError(t, err)
trash := func(remote string) {
otherObj, err := other.NewObject(ctx, remote)
if err == nil {
require.NoError(t, otherObj.Remove(ctx))
return
}
require.NoError(t, other.Purge(ctx, remote))
}
trash("a.txt")
trash("dir")
src := object.NewStaticObjectInfo("a.txt", time.Now(), 1, true, nil, nil)
assert.Equal(t, fs.ErrorObjectNotFound, o.Update(ctx, bytes.NewReader([]byte("x")), src))
_, err = f.Move(ctx, o, "b.txt")
assert.Equal(t, fs.ErrorObjectNotFound, err)
_, err = f.Copy(ctx, o, "c.txt")
assert.Equal(t, fs.ErrorObjectNotFound, err)
assert.Equal(t, fs.ErrorDirNotFound, f.DirMove(ctx, f, "dir", "dir2"))
for _, d := range u.Dump() {
assert.NotContains(t, []string{"b.txt", "c.txt", "dir2"}, d.Path)
}
}
func TestOpenRange(t *testing.T) {
srv := protontest.New(t)
u := srv.AddUser("user", "pass")
content := []byte("0123456789abcdefghijklmnopqrstuvwxyz")
// A file without extended attributes, whose plaintext size only
// its content tells.
seeded := u.SeedFile(u.RootID(), "tmp", content, time.Now())
stored := u.StoredFile(seeded)
stored.Revision.XAttr = ""
m := sessionConfig(srv, u)
cleanup := newTestFs(t, m)
o, err := cleanup.NewObject(context.Background(), "tmp")
require.NoError(t, err)
require.NoError(t, o.Remove(context.Background()))
u.SeedStoredFile(u.RootID(), stored)
for _, original := range []bool{true, false} {
t.Run(fmt.Sprint("original_file_size=", original), func(t *testing.T) {
m := sessionConfig(srv, u)
m["original_file_size"] = fmt.Sprint(original)
f := newTestFs(t, m)
put := putFile(t, f, fmt.Sprintf("put-%v.txt", original), content)
bare, err := f.NewObject(context.Background(), "tmp")
require.NoError(t, err)
for _, o := range []fs.Object{put, bare} {
for _, test := range []struct {
opt fs.OpenOption
want string
}{
{&fs.RangeOption{Start: -1, End: 5}, "vwxyz"},
{&fs.RangeOption{Start: 30, End: -1}, "uvwxyz"},
{&fs.RangeOption{Start: 30, End: 1000}, "uvwxyz"},
{&fs.RangeOption{Start: 2, End: 4}, "234"},
{&fs.SeekOption{Offset: 33}, "xyz"},
{&fs.RangeOption{Start: -1, End: 1000}, string(content)},
} {
got, err := readObject(t, o, test.opt)
require.NoError(t, err)
assert.Equal(t, test.want, string(got), "%s %v", o.Remote(), test.opt)
}
}
})
}
}
// TestPutNestedReadBackLag puts a file into new nested directories
// while the server takes a moment to show new nodes.
func TestPutNestedReadBackLag(t *testing.T) {
srv := protontest.New(t)
u := srv.AddUser("user", "pass")
f := newTestFs(t, sessionConfig(srv, u))
srv.SetReadLag(2, protontest.LagMissing)
o := putFile(t, f, "A1/B1/C1/one", []byte("aaa"))
assert.Equal(t, int64(3), o.Size())
}
+8
View File
@@ -1,6 +1,7 @@
package protondrive_test
import (
"flag"
"testing"
"github.com/rclone/rclone/backend/protondrive"
@@ -9,6 +10,13 @@ import (
// TestIntegration runs integration tests against the remote
func TestIntegration(t *testing.T) {
// A golden capture runs with the real config, where this test would
// write to and delete from the live remote if -run let it through.
for _, name := range []string{"capture", "capture-remote"} {
if f := flag.Lookup(name); f != nil && f.Value.String() != "" {
t.Skipf("-%s is set: not running integration tests during a golden capture", name)
}
}
fstests.Run(t, &fstests.Opt{
RemoteName: "TestProtonDrive:",
NilObject: (*protondrive.Object)(nil),
+340
View File
@@ -0,0 +1,340 @@
package protontest
import (
"encoding/base64"
"encoding/hex"
"fmt"
"net/http"
"github.com/ProtonMail/go-srp"
"github.com/ProtonMail/gopenpgp/v3/crypto"
"github.com/rclone/rclone/backend/protondrive/api"
)
// userKey is a user key with the password salt which locks it.
type userKey struct {
id string
salt string // base64
keyPassword string // derived from the mailbox password and salt
armored string // private key locked with keyPassword
key *crypto.Key
}
// address is an email address of a user.
type address struct {
id string
email string
keys []*addressKey // the primary key is first
}
// addressKey is an address key, locked with a random passphrase which
// is stored encrypted to and signed by the primary user key.
type addressKey struct {
id string
passphrase []byte
armored string // private key locked with passphrase
token string // armored passphrase encrypted to the user key
signature string // armored detached signature of passphrase by the user key
key *crypto.Key
}
// mustCrypto panics with a message naming what failed if err is not nil.
//
// Key operations on keys the fake generated itself fail only on a bug.
func mustCrypto(what string, err error) {
if err != nil {
panic(fmt.Sprintf("protontest: %s: %v", what, err))
}
}
// generateKey returns a new unlocked Curve25519 key for email, which
// is fast to generate unlike RSA.
func generateKey(name, email string) *crypto.Key {
key, err := crypto.PGP().KeyGeneration().AddUserId(name, email).GenerationTime(signTime()).New().GenerateKey()
mustCrypto("generate key", err)
return key
}
// lockKey returns key armored and locked with passphrase.
func lockKey(key *crypto.Key, passphrase []byte) string {
locked, err := crypto.PGP().LockKey(key, passphrase)
mustCrypto("lock key", err)
armored, err := locked.Armor()
mustCrypto("armor key", err)
return armored
}
// newUserKey returns a new user key for u locked with a key password
// derived from u's mailbox password and a new salt.
func newUserKey(u *User) *userKey {
rawSalt := randomBytes(16)
hashed, err := srp.MailboxPassword([]byte(u.MailboxPassword), rawSalt)
mustCrypto("hash mailbox password", err)
k := &userKey{
id: randomID(),
salt: base64.StdEncoding.EncodeToString(rawSalt),
keyPassword: string(hashed[len(hashed)-31:]),
key: generateKey(u.Username, u.Username),
}
k.armored = lockKey(k.key, []byte(k.keyPassword))
return k
}
// newAddress returns a new address with one address key whose
// passphrase is encrypted to userKey.
func newAddress(email string, userKey *userKey) *address {
passphrase := []byte(hex.EncodeToString(randomBytes(32)))
key := generateKey(email, email)
k := &addressKey{
id: randomID(),
passphrase: passphrase,
armored: lockKey(key, passphrase),
key: key,
}
k.sealPassphrase(userKey)
return &address{id: randomID(), email: email, keys: []*addressKey{k}}
}
// sealPassphrase sets the token and signature of k to its passphrase
// encrypted to and signed by userKey, as Proton stores it.
func (k *addressKey) sealPassphrase(userKey *userKey) {
eh, err := crypto.PGP().Encryption().Recipient(userKey.key).New()
mustCrypto("address key token", err)
msg, err := eh.Encrypt(k.passphrase)
mustCrypto("address key token", err)
token, err := msg.ArmorBytes()
mustCrypto("address key token", err)
sh, err := crypto.PGP().Sign().SigningKey(userKey.key).Detached().SignTime(signTime()).New()
mustCrypto("address key signature", err)
sig, err := sh.Sign(k.passphrase, crypto.Armor)
mustCrypto("address key signature", err)
k.token, k.signature = string(token), string(sig)
}
// KeyPassword returns the password which unlocks u's primary user key:
// the last 31 characters of the bcrypt hash of the mailbox password
// and the key salt, as a client derives it.
func (u *User) KeyPassword() string {
u.srv.mu.Lock()
defer u.srv.mu.Unlock()
return u.keyPassword
}
// Email returns u's primary address.
func (u *User) Email() string {
u.srv.mu.Lock()
defer u.srv.mu.Unlock()
return u.addresses[0].email
}
// AddAddress adds an address with a new address key to u.
func (u *User) AddAddress(email string) {
u.srv.mu.Lock()
primary := u.keys[len(u.keys)-1]
u.srv.mu.Unlock()
addr := newAddress(email, primary)
u.srv.mu.Lock()
defer u.srv.mu.Unlock()
u.addresses = append(u.addresses, addr)
}
// AddUserKey gives u a new primary user key with a new salt, so
// KeySalt and KeyPassword change.
//
// The old keys are listed before the new one and stay locked with
// their old key password, as after a key password change on an account
// with legacy keys. The address key passphrases are re-encrypted to the
// new key.
func (u *User) AddUserKey() {
key := newUserKey(u)
u.srv.mu.Lock()
defer u.srv.mu.Unlock()
u.keys = append(u.keys, key)
u.KeySalt, u.keyPassword = key.salt, key.keyPassword
for _, addr := range u.addresses {
for _, k := range addr.keys {
k.sealPassphrase(key)
}
}
}
// CorruptAddressKeySignature replaces the Signature of every key of u's
// address email, as a malicious server would: with a valid signature
// by an unrelated key, or with nothing if empty is true. AddUserKey
// restores the signatures.
func (s *Server) CorruptAddressKeySignature(u *User, email string, empty bool) {
impostor := generateKey("impostor", "impostor@proton.test")
s.mu.Lock()
defer s.mu.Unlock()
addr := u.addressByEmailLocked(email)
if addr == nil {
panic("protontest: no address " + email)
}
for _, k := range addr.keys {
if empty {
k.signature = ""
continue
}
// Sign the real passphrase, so only the signer is wrong.
sh, err := crypto.PGP().Sign().SigningKey(impostor).Detached().SignTime(signTime()).New()
mustCrypto("impostor signature", err)
sig, err := sh.Sign(k.passphrase, crypto.Armor)
mustCrypto("impostor signature", err)
k.signature = string(sig)
}
}
// EncryptToAddress returns plaintext as an armored message encrypted to
// the primary key of u's address email.
func (u *User) EncryptToAddress(email string, plaintext []byte) string {
u.srv.mu.Lock()
addr := u.addressByEmailLocked(email)
u.srv.mu.Unlock()
if addr == nil {
panic("protontest: no address " + email)
}
eh, err := crypto.PGP().Encryption().Recipient(addr.keys[0].key).New()
mustCrypto("encrypt to address", err)
msg, err := eh.Encrypt(plaintext)
mustCrypto("encrypt to address", err)
armored, err := msg.ArmorBytes()
mustCrypto("encrypt to address", err)
return string(armored)
}
// addressByEmailLocked returns u's address email or nil. srv.mu must
// be held.
func (u *User) addressByEmailLocked(email string) *address {
for _, addr := range u.addresses {
if addr.email == email {
return addr
}
}
return nil
}
// boolInt returns 1 for true and 0 for false, as the API encodes flags.
func boolInt(b bool) int {
if b {
return 1
}
return 0
}
// registerAccount registers the user, address and public key routes.
func (s *Server) registerAccount() {
s.handleAuth("GET", "/core/v4/users", s.handleUser)
s.handleAuth("GET", "/core/v4/addresses", s.handleAddresses)
s.handleAuth("GET", "/core/v4/keys/all", s.handlePublicKeys)
}
// userWithQuota is api.User with the quota fields of Proton's User
// schema (coreTypes.ts).
type userWithQuota struct {
api.User
UsedSpace int64
MaxSpace int64
ProductUsedSpace productUsedSpace
}
// productUsedSpace is the UserUsage schema: used space per product.
type productUsedSpace struct {
Calendar int64
Contact int64
Drive int64
Mail int64
Pass int64
Lumo int64
}
// handleUser returns the user, its keys and its quota, all used space
// being Drive's.
func (s *Server) handleUser(w http.ResponseWriter, r *http.Request, u *User) {
s.mu.Lock()
user := userWithQuota{
User: api.User{ID: u.ID, Name: u.Username, Email: u.addresses[0].email},
MaxSpace: DefaultMaxSpace,
}
for i, key := range u.keys {
user.Keys = append(user.Keys, api.UserKey{
ID: key.id,
Version: 3,
PrivateKey: key.armored,
Primary: boolInt(i == len(u.keys)-1),
Active: 1,
})
}
if u.drive != nil {
user.UsedSpace = u.drive.usedSpace()
user.ProductUsedSpace.Drive = user.UsedSpace
}
s.mu.Unlock()
writeJSON(w, http.StatusOK, &struct {
Code int
User userWithQuota
}{Code: api.CodeOK, User: user})
}
// handleAddresses returns the user's addresses with their keys.
func (s *Server) handleAddresses(w http.ResponseWriter, r *http.Request, u *User) {
s.mu.Lock()
resp := &api.AddressesResponse{Code: api.CodeOK}
for i, addr := range u.addresses {
out := api.Address{ID: addr.id, Email: addr.email, Status: 1, Type: 1, Priority: i + 1}
for j, key := range addr.keys {
out.Keys = append(out.Keys, api.AddressKey{
ID: key.id,
Version: 3,
PrivateKey: key.armored,
Token: key.token,
Signature: key.signature,
Primary: boolInt(j == 0),
Active: 1,
Flags: 3,
})
}
resp.Addresses = append(resp.Addresses, out)
}
s.mu.Unlock()
writeJSON(w, http.StatusOK, resp)
}
// handlePublicKeys returns the public keys of the address in the Email
// query parameter, or HTTP 422 code 33102 if no user has it.
func (s *Server) handlePublicKeys(w http.ResponseWriter, r *http.Request, _ *User) {
email := r.URL.Query().Get("Email")
if email == "" {
writeError(w, http.StatusBadRequest, api.CodeInvalidValue, "protontest: no Email")
return
}
s.mu.Lock()
var addr *address
if owner := s.keysAs[email]; owner != nil {
addr = owner.addresses[0]
}
for _, u := range s.users {
if addr != nil {
break
}
addr = u.addressByEmailLocked(email)
}
var keys []*addressKey
if addr != nil {
keys = addr.keys
}
s.mu.Unlock()
if addr == nil {
writeError(w, http.StatusUnprocessableEntity, api.CodeAddressMissing, "protontest: address does not exist")
return
}
resp := &api.PublicKeysResponse{Code: api.CodeOK}
for i, key := range keys {
pub, err := key.key.GetArmoredPublicKey()
if err != nil {
writeError(w, http.StatusInternalServerError, 0, "protontest: public key: "+err.Error())
return
}
resp.Address.Keys = append(resp.Address.Keys, api.PublicKey{PublicKey: pub, Primary: boolInt(i == 0), Flags: 3})
}
writeJSON(w, http.StatusOK, resp)
}
+555
View File
@@ -0,0 +1,555 @@
package protontest
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"strings"
"sync"
"github.com/ProtonMail/go-srp"
"github.com/pquerna/otp/totp"
"github.com/rclone/rclone/backend/protondrive/api"
)
// signedModulus is a 2048-bit SRP modulus signed by Proton's modulus
// key, from go-srp's tests.
const signedModulus = `-----BEGIN PGP SIGNED MESSAGE-----
Hash: SHA256
W2z5HBi8RvsfYzZTS7qBaUxxPhsfHJFZpu3Kd6s1JafNrCCH9rfvPLrfuqocxWPgWDH2R8neK7PkNvjxto9TStuY5z7jAzWRvFWN9cQhAKkdWgy0JY6ywVn22+HFpF4cYesHrqFIKUPDMSSIlWjBVmEJZ/MusD44ZT29xcPrOqeZvwtCffKtGAIjLYPZIEbZKnDM1Dm3q2K/xS5h+xdhjnndhsrkwm9U9oyA2wxzSXFL+pdfj2fOdRwuR5nW0J2NFrq3kJjkRmpO/Genq1UW+TEknIWAb6VzJJJA244K/H8cnSx2+nSNZO3bbo6Ys228ruV9A8m6DhxmS+bihN3ttQ==
-----BEGIN PGP SIGNATURE-----
Version: ProtonMail
Comment: https://protonmail.com
wl4EARYIABAFAlwB1j0JEDUFhcTpUY8mAAD8CgEAnsFnF4cF0uSHKkXa1GIa
GO86yMV4zDZEZcDSJo0fgr8A/AlupGN9EdHlsrZLmTA1vhIx+rOgxdEff28N
kvNM7qIK
=q6vu
-----END PGP SIGNATURE-----`
// srpBits is the bit length of the SRP modulus.
const srpBits = 2048
// forkClientID is the client ID rclone's sign-in URL must carry.
const forkClientID = "external-drive"
// User is an account on the fake server.
type User struct {
ID string
Username string
Password string // login password, checked with SRP
MailboxPassword string // derives the key password
KeySalt string // base64 salt of the primary user key's password
srv *Server
srpSalt []byte
// Guarded by srv.mu once the user is added.
keyPassword string
keys []*userKey // the primary key is last
addresses []*address // the primary address is first
drive *drive // nil until the user has a volume
verifierOnce sync.Once
verifier []byte
verifierErr error
// Guarded by srv.mu.
totpSecret string
captcha bool
corruptProof bool
}
// srpVerifier returns the SRP verifier of u's login password, computing
// it on first use since it costs a bcrypt hash.
func (u *User) srpVerifier() ([]byte, error) {
u.verifierOnce.Do(func() {
var auth *srp.Auth
auth, u.verifierErr = srp.NewAuthForVerifier([]byte(u.Password), signedModulus, u.srpSalt)
if u.verifierErr == nil {
u.verifier, u.verifierErr = auth.GenerateVerifier(srpBits)
}
})
return u.verifier, u.verifierErr
}
// EnableTOTP turns on TOTP two-factor authentication for u and returns
// the base32 secret to generate codes from.
func (u *User) EnableTOTP() (secret string) {
key, err := totp.Generate(totp.GenerateOpts{Issuer: "protontest", AccountName: u.Username})
if err != nil {
panic(fmt.Sprintf("protontest: generate TOTP secret: %v", err))
}
u.srv.mu.Lock()
defer u.srv.mu.Unlock()
u.totpSecret = key.Secret()
return u.totpSecret
}
// srpLogin is an SRP challenge issued by POST /core/v4/auth/info.
type srpLogin struct {
user *User
server *srp.Server
}
// fork is a session fork requested by GET /auth/v4/sessions/forks.
type fork struct {
userCode string
key []byte // encryption key from the sign-in URL, once approved
approved *session // child session, once approved
}
// Session holds the tokens of a signed in session.
type Session struct {
UID string
AccessToken string
RefreshToken string
}
// session is the server side state of a Session.
type session struct {
Session
user *User
expired bool // access token no longer accepted
locked bool // waiting for the second factor
}
// randomBytes returns n random bytes.
func randomBytes(n int) []byte {
b := make([]byte, n)
_, _ = rand.Read(b)
return b
}
// randomID returns a random hex string.
func randomID() string {
return hex.EncodeToString(randomBytes(16))
}
// registerAuth registers the session routes.
func (s *Server) registerAuth() {
s.handle("POST", "/auth/v4/refresh", s.handleRefresh)
s.handle("POST", "/core/v4/auth/info", s.handleAuthInfo)
s.handle("POST", "/core/v4/auth", s.handleLogin)
s.handleSession("POST", "/core/v4/auth/2fa", s.handle2FA)
s.handleSession("DELETE", "/core/v4/auth", s.handleLogout)
s.handleAuth("GET", "/core/v4/keys/salts", s.handleKeySalts)
s.handle("GET", "/auth/v4/sessions/forks", s.handleForkInit)
s.handle("GET", "/auth/v4/sessions/forks/{selector}", s.handleForkStatus)
s.handleAuth("GET", "/tests/auth", func(w http.ResponseWriter, r *http.Request, u *User) {
writeJSON(w, http.StatusOK, map[string]any{"Code": api.CodeOK, "UserID": u.ID})
})
s.handleAuth("POST", "/tests/echo", func(w http.ResponseWriter, r *http.Request, u *User) {
var body struct{ Echo string }
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, api.CodeInvalidValue, "protontest: "+err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"Code": api.CodeOK, "Echo": body.Echo})
})
}
// AddUser creates an account whose mailbox password is its password.
func (s *Server) AddUser(username, password string) *User {
return s.AddTwoPasswordUser(username, password, password)
}
// AddTwoPasswordUser creates an account which signs in with password
// and unlocks its keys with mailboxPassword.
//
// The account has a user key and one address, username itself if it
// contains "@" or else username@proton.test, with an address key. Its
// drive has a volume, a main share and an empty root folder.
func (s *Server) AddTwoPasswordUser(username, password, mailboxPassword string) *User {
u := newUser(s, username, password, mailboxPassword)
u.drive = s.newDrive(u.addresses[0])
s.addUser(u)
return u
}
// AddUserWithoutDrive creates an account like AddUser but without a
// volume, as a brand-new Proton account is: its my-files route answers
// HTTP 422 code 2501 until a client creates the volume.
func (s *Server) AddUserWithoutDrive(username, password string) *User {
u := newUser(s, username, password, password)
s.addUser(u)
return u
}
// newUser returns an account with a user key and one address, not yet
// added to s.
func newUser(s *Server, username, password, mailboxPassword string) *User {
u := &User{
ID: randomID(),
Username: username,
Password: password,
MailboxPassword: mailboxPassword,
srv: s,
srpSalt: randomBytes(10),
}
key := newUserKey(u)
u.keys = []*userKey{key}
u.KeySalt, u.keyPassword = key.salt, key.keyPassword
email := username
if !strings.Contains(email, "@") {
email += "@proton.test"
}
u.addresses = []*address{newAddress(email, key)}
return u
}
// addUser adds u to s.
func (s *Server) addUser(u *User) {
s.mu.Lock()
defer s.mu.Unlock()
s.users[u.Username] = u
}
// RequireCaptcha makes password sign-in for u fail with HTTP 422 code
// 9001, as when Proton asks for human verification.
func (s *Server) RequireCaptcha(u *User) {
s.mu.Lock()
defer s.mu.Unlock()
u.captcha = true
}
// CorruptServerProof makes password sign-in for u return a server
// proof with one byte changed, as an impostor server would.
func (s *Server) CorruptServerProof(u *User) {
s.mu.Lock()
defer s.mu.Unlock()
u.corruptProof = true
}
// NewSession signs u in and returns the new session's tokens.
func (s *Server) NewSession(u *User) Session {
s.mu.Lock()
defer s.mu.Unlock()
return s.newSessionLocked(u, false).Session
}
// newSessionLocked creates a session for u. s.mu must be held.
func (s *Server) newSessionLocked(u *User, locked bool) *session {
sess := &session{
Session: Session{UID: randomID(), AccessToken: s.newSecretLocked(SessionToken), RefreshToken: s.newSecretLocked(SessionToken)},
user: u,
locked: locked,
}
s.sessions[sess.UID] = sess
return sess
}
// ApproveFork completes the session fork whose sign-in URL is
// signInURL as if u had signed in on Proton's page: the next poll of
// the fork returns a new session for u and u's key password encrypted
// with the key from the URL.
func (s *Server) ApproveFork(signInURL string, u *User) error {
parsed, err := url.Parse(signInURL)
if err != nil {
return fmt.Errorf("protontest: parse sign-in URL: %w", err)
}
payload, ok := strings.CutPrefix(parsed.Fragment, "payload=")
parts := strings.Split(payload, ":")
if !ok || len(parts) != 4 || parts[0] != "0" || parts[3] != forkClientID {
return fmt.Errorf("protontest: bad sign-in URL payload %q", payload)
}
key, err := base64.StdEncoding.DecodeString(parts[2])
if err != nil || len(key) != 32 {
return fmt.Errorf("protontest: bad key in sign-in URL payload %q", payload)
}
s.mu.Lock()
defer s.mu.Unlock()
for _, f := range s.forks {
if f.userCode == parts[1] {
f.key = key
f.approved = s.newSessionLocked(u, false)
return nil
}
}
return fmt.Errorf("protontest: no fork with user code %q", parts[1])
}
// ExpireAccessToken makes the access token of session uid return 401
// until the session is refreshed.
func (s *Server) ExpireAccessToken(uid string) {
s.mu.Lock()
defer s.mu.Unlock()
if sess := s.sessions[uid]; sess != nil {
sess.expired = true
}
}
// RevokeSession ends session uid, so neither its access token nor its
// refresh token is accepted.
func (s *Server) RevokeSession(uid string) {
s.mu.Lock()
defer s.mu.Unlock()
delete(s.sessions, uid)
}
// handleAuth registers h for an authenticated route. Requests without
// a valid x-pm-uid and bearer access token get HTTP 401, and requests
// from a session waiting for its second factor get HTTP 403.
func (s *Server) handleAuth(method, pattern string, h func(w http.ResponseWriter, r *http.Request, u *User)) {
s.handleSession(method, pattern, func(w http.ResponseWriter, r *http.Request, sess *session) {
s.mu.Lock()
locked := sess.locked
s.mu.Unlock()
if locked {
writeError(w, http.StatusForbidden, 0, "protontest: second factor required")
return
}
h(w, r, sess.user)
})
}
// handleSession registers h for a route which needs a session, which
// may be waiting for its second factor. Requests without a valid
// x-pm-uid and bearer access token get HTTP 401.
func (s *Server) handleSession(method, pattern string, h func(w http.ResponseWriter, r *http.Request, sess *session)) {
s.handle(method, pattern, func(w http.ResponseWriter, r *http.Request) {
token, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ")
s.mu.Lock()
sess := s.sessions[r.Header.Get("x-pm-uid")]
valid := ok && sess != nil && !sess.expired && sess.AccessToken == token
s.mu.Unlock()
if !valid {
writeError(w, http.StatusUnauthorized, 401, "protontest: invalid access token")
return
}
h(w, r, sess)
})
}
// handleRefresh rotates the tokens of the session named by x-pm-uid.
// Refresh tokens are single use.
func (s *Server) handleRefresh(w http.ResponseWriter, r *http.Request) {
var req api.AuthRefreshRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.ResponseType != "token" || req.GrantType != "refresh_token" {
writeError(w, http.StatusBadRequest, api.CodeInvalidValue, "protontest: bad refresh request")
return
}
s.mu.Lock()
defer s.mu.Unlock()
sess := s.sessions[r.Header.Get("x-pm-uid")]
if sess == nil || req.RefreshToken == "" || sess.RefreshToken != req.RefreshToken {
writeError(w, http.StatusUnprocessableEntity, api.CodeRefreshTokenInvalid, "protontest: invalid refresh token")
return
}
sess.AccessToken = s.newSecretLocked(SessionToken)
sess.RefreshToken = s.newSecretLocked(SessionToken)
sess.expired = false
writeJSON(w, http.StatusOK, &api.AuthRefreshResponse{
Code: api.CodeOK,
UID: sess.UID,
AccessToken: sess.AccessToken,
RefreshToken: sess.RefreshToken,
})
}
// handleAuthInfo issues an SRP challenge for the user named in the
// request.
func (s *Server) handleAuthInfo(w http.ResponseWriter, r *http.Request) {
var req api.AuthInfoRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Intent != "Proton" {
writeError(w, http.StatusBadRequest, api.CodeInvalidValue, "protontest: bad auth info request")
return
}
s.mu.Lock()
u := s.users[req.Username]
s.mu.Unlock()
if u == nil {
writeError(w, http.StatusUnprocessableEntity, api.CodePasswordWrong, "protontest: unknown user")
return
}
verifier, err := u.srpVerifier()
if err != nil {
writeError(w, http.StatusInternalServerError, 0, "protontest: SRP verifier: "+err.Error())
return
}
server, err := srp.NewServerFromSigned(signedModulus, verifier, srpBits)
if err != nil {
writeError(w, http.StatusInternalServerError, 0, "protontest: SRP server: "+err.Error())
return
}
challenge, err := server.GenerateChallenge()
if err != nil {
writeError(w, http.StatusInternalServerError, 0, "protontest: SRP challenge: "+err.Error())
return
}
srpSession := randomID()
s.mu.Lock()
s.srp[srpSession] = &srpLogin{user: u, server: server}
s.mu.Unlock()
writeJSON(w, http.StatusOK, &api.AuthInfoResponse{
Code: api.CodeOK,
Version: 4,
Modulus: signedModulus,
ServerEphemeral: base64.StdEncoding.EncodeToString(challenge),
Salt: base64.StdEncoding.EncodeToString(u.srpSalt),
SRPSession: srpSession,
Username: u.Username,
})
}
// handleLogin checks the SRP proof for a challenge and creates a
// session, locked until the second factor if the user has TOTP.
// Challenges are single use.
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
var req api.AuthRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, api.CodeInvalidValue, "protontest: bad auth request")
return
}
ephemeral, err1 := base64.StdEncoding.DecodeString(req.ClientEphemeral)
proof, err2 := base64.StdEncoding.DecodeString(req.ClientProof)
if err := errors.Join(err1, err2); err != nil {
writeError(w, http.StatusBadRequest, api.CodeInvalidValue, "protontest: bad auth request: "+err.Error())
return
}
s.mu.Lock()
defer s.mu.Unlock()
login := s.srp[req.SRPSession]
delete(s.srp, req.SRPSession)
if login == nil || login.user.Username != req.Username {
writeError(w, http.StatusUnprocessableEntity, api.CodeInvalidValue, "protontest: unknown SRP session")
return
}
u := login.user
if u.captcha {
writeError(w, http.StatusUnprocessableEntity, api.CodeHumanVerification, "protontest: human verification required")
return
}
serverProof, err := login.server.VerifyProofs(ephemeral, proof)
if err != nil {
writeError(w, http.StatusUnprocessableEntity, api.CodePasswordWrong, "protontest: incorrect login credentials")
return
}
if u.corruptProof {
serverProof[0] ^= 1
}
resp := &api.AuthResponse{
Code: api.CodeOK,
UserID: u.ID,
TokenType: "Bearer",
ServerProof: base64.StdEncoding.EncodeToString(serverProof),
Scopes: []string{"full"},
PasswordMode: api.PasswordModeOne,
}
if u.MailboxPassword != u.Password {
resp.PasswordMode = api.PasswordModeTwo
}
if u.totpSecret != "" {
resp.TwoFA.Enabled = api.TwoFATOTP
resp.Scopes = []string{"twofactor"}
}
sess := s.newSessionLocked(u, u.totpSecret != "")
resp.UID, resp.AccessToken, resp.RefreshToken = sess.UID, sess.AccessToken, sess.RefreshToken
writeJSON(w, http.StatusOK, resp)
}
// handle2FA unlocks a session with a TOTP code.
func (s *Server) handle2FA(w http.ResponseWriter, r *http.Request, sess *session) {
var req api.Auth2FARequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, api.CodeInvalidValue, "protontest: bad 2FA request")
return
}
s.mu.Lock()
defer s.mu.Unlock()
secret := sess.user.totpSecret
if secret == "" || !totp.Validate(req.TwoFactorCode, secret) {
writeError(w, http.StatusUnprocessableEntity, api.CodeInvalidValue, "protontest: incorrect 2FA code")
return
}
sess.locked = false
writeJSON(w, http.StatusOK, map[string]any{"Code": api.CodeOK, "Scopes": []string{"full"}})
}
// handleLogout ends the session.
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request, sess *session) {
s.mu.Lock()
delete(s.sessions, sess.UID)
s.mu.Unlock()
writeJSON(w, http.StatusOK, map[string]any{"Code": api.CodeOK})
}
// handleKeySalts returns the password salt of each user key.
func (s *Server) handleKeySalts(w http.ResponseWriter, r *http.Request, u *User) {
resp := &api.KeySaltsResponse{Code: api.CodeOK}
s.mu.Lock()
for _, key := range u.keys {
resp.KeySalts = append(resp.KeySalts, api.KeySalt{ID: key.id, KeySalt: key.salt})
}
s.mu.Unlock()
writeJSON(w, http.StatusOK, resp)
}
// handleForkInit starts a session fork for a client to sign in with.
func (s *Server) handleForkInit(w http.ResponseWriter, r *http.Request) {
f := &fork{userCode: strings.ToUpper(hex.EncodeToString(randomBytes(4)))}
s.mu.Lock()
selector := s.newSecretLocked(ForkSelector)
s.forks[selector] = f
s.mu.Unlock()
writeJSON(w, http.StatusOK, &api.ForkInitResponse{Code: api.CodeOK, Selector: selector, UserCode: f.userCode})
}
// handleForkStatus returns the child session of an approved fork, once,
// or HTTP 422 while the fork is waiting for approval.
func (s *Server) handleForkStatus(w http.ResponseWriter, r *http.Request) {
selector := r.PathValue("selector")
s.mu.Lock()
f := s.forks[selector]
if f == nil || f.approved == nil {
s.mu.Unlock()
writeError(w, http.StatusUnprocessableEntity, api.CodeInvalidValue, "protontest: fork not ready")
return
}
delete(s.forks, selector)
sess := f.approved.Session
user := f.approved.user
s.mu.Unlock()
payload, err := sealForkPayload(f.key, user.KeyPassword())
if err != nil {
writeError(w, http.StatusInternalServerError, 0, "protontest: fork payload: "+err.Error())
return
}
writeJSON(w, http.StatusOK, &api.ForkStatusResponse{
Code: api.CodeOK,
Payload: payload,
UID: sess.UID,
AccessToken: sess.AccessToken,
RefreshToken: sess.RefreshToken,
})
}
// sealForkPayload encrypts keyPassword for a fork as Proton's sign-in
// page does: base64 of nonce, AES-256-GCM ciphertext and tag, with
// additional data "fork".
func sealForkPayload(key []byte, keyPassword string) (string, error) {
plain, err := json.Marshal(map[string]string{"keyPassword": keyPassword})
if err != nil {
return "", err
}
block, err := aes.NewCipher(key)
if err != nil {
return "", err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
nonce := randomBytes(gcm.NonceSize())
return base64.StdEncoding.EncodeToString(gcm.Seal(nonce, nonce, plain, []byte("fork"))), nil
}
+139
View File
@@ -0,0 +1,139 @@
package protontest
import (
"bytes"
"encoding/base64"
"encoding/json"
"io"
"net/http"
"testing"
"time"
"github.com/ProtonMail/go-srp"
"github.com/pquerna/otp/totp"
"github.com/rclone/rclone/backend/protondrive/api"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// refresh calls POST /auth/v4/refresh for uid with refreshToken.
func refresh(t *testing.T, s *Server, uid, refreshToken string) (*http.Response, []byte) {
t.Helper()
body, err := json.Marshal(&api.AuthRefreshRequest{ResponseType: "token", GrantType: "refresh_token", RefreshToken: refreshToken})
require.NoError(t, err)
req, err := http.NewRequest("POST", s.URL()+"/auth/v4/refresh", bytes.NewReader(body))
require.NoError(t, err)
req.Header.Set("x-pm-appversion", testAppVersion)
req.Header.Set("x-pm-uid", uid)
resp, err := s.Client().Do(req)
require.NoError(t, err)
defer func() { _ = resp.Body.Close() }()
out, err := io.ReadAll(resp.Body)
require.NoError(t, err)
return resp, out
}
func authed(t *testing.T, s *Server, sess Session) int {
t.Helper()
resp, _ := do(t, s, "GET", "/tests/auth", map[string]string{
"x-pm-appversion": testAppVersion,
"x-pm-uid": sess.UID,
"Authorization": "Bearer " + sess.AccessToken,
})
return resp.StatusCode
}
func TestAuth(t *testing.T) {
s := New(t)
u := s.AddUser("user", "pass")
assert.Equal(t, "pass", u.MailboxPassword)
sess := s.NewSession(u)
assert.Equal(t, http.StatusOK, authed(t, s, sess))
assert.Equal(t, http.StatusUnauthorized, authed(t, s, Session{UID: sess.UID, AccessToken: "wrong"}))
assert.Equal(t, http.StatusUnauthorized, authed(t, s, Session{UID: "unknown", AccessToken: sess.AccessToken}))
s.ExpireAccessToken(sess.UID)
assert.Equal(t, http.StatusUnauthorized, authed(t, s, sess))
resp, body := refresh(t, s, sess.UID, sess.RefreshToken)
require.Equal(t, http.StatusOK, resp.StatusCode, "body: %s", body)
var out api.AuthRefreshResponse
require.NoError(t, json.Unmarshal(body, &out))
assert.Equal(t, sess.UID, out.UID)
next := Session{UID: out.UID, AccessToken: out.AccessToken, RefreshToken: out.RefreshToken}
assert.Equal(t, http.StatusOK, authed(t, s, next))
assert.Equal(t, http.StatusUnauthorized, authed(t, s, sess), "old access token")
// Refresh tokens are single use.
resp, body = refresh(t, s, sess.UID, sess.RefreshToken)
assert.Equal(t, http.StatusUnprocessableEntity, resp.StatusCode)
assert.Equal(t, api.CodeRefreshTokenInvalid, decodeError(t, body).Code)
s.RevokeSession(sess.UID)
assert.Equal(t, http.StatusUnauthorized, authed(t, s, next))
resp, body = refresh(t, s, next.UID, next.RefreshToken)
assert.Equal(t, http.StatusUnprocessableEntity, resp.StatusCode)
assert.Equal(t, api.CodeRefreshTokenInvalid, decodeError(t, body).Code)
}
// postJSON sends in as JSON to path with the given extra headers and
// decodes the response into out, returning the HTTP status.
func postJSON(t *testing.T, s *Server, path string, header map[string]string, in, out any) int {
t.Helper()
body, err := json.Marshal(in)
require.NoError(t, err)
req, err := http.NewRequest("POST", s.URL()+path, bytes.NewReader(body))
require.NoError(t, err)
req.Header.Set("x-pm-appversion", testAppVersion)
for k, v := range header {
req.Header.Set(k, v)
}
resp, err := s.Client().Do(req)
require.NoError(t, err)
defer func() { _ = resp.Body.Close() }()
require.NoError(t, json.NewDecoder(resp.Body).Decode(out))
return resp.StatusCode
}
func TestLoginTOTPLocksSession(t *testing.T) {
s := New(t)
u := s.AddUser("user", "pass")
secret := u.EnableTOTP()
var info api.AuthInfoResponse
require.Equal(t, http.StatusOK, postJSON(t, s, "/core/v4/auth/info", nil, &api.AuthInfoRequest{Intent: "Proton", Username: "user"}, &info))
auth, err := srp.NewAuth(info.Version, "user", []byte("pass"), info.Salt, info.Modulus, info.ServerEphemeral)
require.NoError(t, err)
proofs, err := auth.GenerateProofs(srpBits)
require.NoError(t, err)
var out api.AuthResponse
require.Equal(t, http.StatusOK, postJSON(t, s, "/core/v4/auth", nil, &api.AuthRequest{
Username: "user",
ClientEphemeral: base64.StdEncoding.EncodeToString(proofs.ClientEphemeral),
ClientProof: base64.StdEncoding.EncodeToString(proofs.ClientProof),
SRPSession: info.SRPSession,
}, &out))
assert.Equal(t, base64.StdEncoding.EncodeToString(proofs.ExpectedServerProof), out.ServerProof)
assert.Equal(t, api.TwoFATOTP, out.TwoFA.Enabled)
sess := Session{UID: out.UID, AccessToken: out.AccessToken, RefreshToken: out.RefreshToken}
assert.Equal(t, http.StatusForbidden, authed(t, s, sess))
headers := map[string]string{"x-pm-uid": sess.UID, "Authorization": "Bearer " + sess.AccessToken}
var res api.Error
assert.Equal(t, http.StatusUnprocessableEntity, postJSON(t, s, "/core/v4/auth/2fa", headers, &api.Auth2FARequest{TwoFactorCode: "000000x"}, &res))
assert.Equal(t, http.StatusForbidden, authed(t, s, sess))
code, err := totp.GenerateCode(secret, time.Now())
require.NoError(t, err)
assert.Equal(t, http.StatusOK, postJSON(t, s, "/core/v4/auth/2fa", headers, &api.Auth2FARequest{TwoFactorCode: code}, &res))
assert.Equal(t, http.StatusOK, authed(t, s, sess))
// SRP challenges are single use.
assert.Equal(t, http.StatusUnprocessableEntity, postJSON(t, s, "/core/v4/auth", nil, &api.AuthRequest{
Username: "user",
ClientEphemeral: base64.StdEncoding.EncodeToString(proofs.ClientEphemeral),
ClientProof: base64.StdEncoding.EncodeToString(proofs.ClientProof),
SRPSession: info.SRPSession,
}, &res))
}
@@ -0,0 +1,57 @@
// Command protonfake serves a fake Proton Drive API for integration
// tests.
//
// It prints the rclone config of a signed in user with a Drive volume
// as key=value lines on stdout, then serves until interrupted.
//
// go run ./backend/protondrive/protontest/cmd/protonfake -addr 127.0.0.1:0
package main
import (
"encoding/base64"
"flag"
"fmt"
"io"
"os"
"os/signal"
"syscall"
"github.com/rclone/rclone/backend/protondrive/protontest"
)
func main() {
addr := flag.String("addr", "127.0.0.1:0", "address to listen on")
flag.Parse()
stop := make(chan struct{})
sig := make(chan os.Signal, 1)
signal.Notify(sig, os.Interrupt, syscall.SIGTERM)
go func() {
<-sig
close(stop)
}()
if err := run(*addr, os.Stdout, stop); err != nil {
fmt.Fprintln(os.Stderr, "protonfake:", err)
os.Exit(1)
}
}
// run serves a fake server on addr until stop is closed, after writing
// its endpoint and a fresh session's config to out.
func run(addr string, out io.Writer, stop <-chan struct{}) error {
srv, err := protontest.NewStandalone(addr)
if err != nil {
return err
}
defer srv.Close()
user := srv.AddUser("protonfake", "protonfake")
sess := srv.NewSession(user)
// The config holds the salted key password base64 encoded.
keyPass := base64.StdEncoding.EncodeToString([]byte(user.KeyPassword()))
_, err = fmt.Fprintf(out, "endpoint=%s\nclient_uid=%s\nclient_access_token=%s\nclient_refresh_token=%s\nclient_salted_key_pass=%s\ntype=protondrive\n",
srv.URL(), sess.UID, sess.AccessToken, sess.RefreshToken, keyPass)
if err != nil {
return fmt.Errorf("write config: %w", err)
}
<-stop
return nil
}
@@ -0,0 +1,65 @@
package main
import (
"bufio"
"encoding/base64"
"io"
"net/http"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestRun(t *testing.T) {
pr, pw := io.Pipe()
stop := make(chan struct{})
done := make(chan error, 1)
go func() {
err := run("127.0.0.1:0", pw, stop)
_ = pw.Close()
done <- err
}()
wantKeys := []string{"endpoint", "client_uid", "client_access_token", "client_refresh_token", "client_salted_key_pass", "type"}
values := make(map[string]string)
lines := bufio.NewScanner(pr)
for _, key := range wantKeys {
require.True(t, lines.Scan(), "missing line for %s", key)
k, v, ok := strings.Cut(lines.Text(), "=")
require.True(t, ok, "line %q is not key=value", lines.Text())
require.Equal(t, key, k)
require.NotEmpty(t, v, key)
values[k] = v
}
assert.Equal(t, "protondrive", values["type"])
assert.False(t, strings.HasSuffix(values["endpoint"], ":0"), "endpoint %q must carry the bound port", values["endpoint"])
keyPass, err := base64.StdEncoding.DecodeString(values["client_salted_key_pass"])
require.NoError(t, err)
assert.Len(t, keyPass, 31, "a key password is the last 31 characters of a bcrypt hash")
req, err := http.NewRequest("GET", values["endpoint"]+"/core/v4/users", nil)
require.NoError(t, err)
req.Header.Set("x-pm-appversion", "external-drive-rclone@1.0.0-stable")
req.Header.Set("x-pm-uid", values["client_uid"])
req.Header.Set("Authorization", "Bearer "+values["client_access_token"])
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
_ = resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
close(stop)
extra := lines.Scan()
assert.False(t, extra, "unexpected output %q", lines.Text())
require.NoError(t, <-done)
_, err = http.Get(values["endpoint"] + "/tests/ping")
assert.Error(t, err, "server still answering after stop")
}
func TestRunBadAddress(t *testing.T) {
var out strings.Builder
err := run("not an address", &out, make(chan struct{}))
assert.Error(t, err)
assert.Empty(t, out.String())
}
+339
View File
@@ -0,0 +1,339 @@
package protontest
// The Drive cryptography of the fake, written from Proton's Drive SDK
// (client/js/src/crypto/driveCrypto.ts and the nodes, shares and upload
// crypto services) independently of the protondrive backend, so a bug
// in one is unlikely to be mirrored in the other.
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"errors"
"fmt"
"time"
"github.com/ProtonMail/go-crypto/openpgp/packet"
"github.com/ProtonMail/gopenpgp/v3/constants"
"github.com/ProtonMail/gopenpgp/v3/crypto"
"github.com/ProtonMail/gopenpgp/v3/profile"
)
// driveKeyName is the user ID of every node and share key, as the SDK
// generates them.
const driveKeyName = "Drive key"
// aeadPGP returns a handle whose keys advertise SEIPDv2 and whose
// session-key encryption uses AES-256 GCM, as the SDK's file content
// does (openpgp.js aeadProtect with Proton's GCM preference).
//
// Everything that is not file content goes through encryptArmored,
// which never produces SEIPDv2 whatever the recipient advertises.
func aeadPGP() *crypto.PGPHandle {
p := profile.Default()
p.AeadEncryption = &packet.AEADConfig{DefaultMode: packet.AEADModeGCM}
return crypto.PGPWithProfile(p)
}
// signTime returns when the fake generates keys and makes signatures: a
// day before the local clock, so that what it writes is valid at the
// server clock whatever skew within a day a test sets.
func signTime() int64 {
return time.Now().Add(-24 * time.Hour).Unix()
}
// signTimeOf returns when the fake signs with signer: signTime, or
// signer's creation time if later, as for a key a test supplied which
// was generated less than a day ago.
func signTimeOf(signer *crypto.Key) int64 {
return max(signTime(), signer.GetEntity().PrimaryKey.CreationTime.Unix())
}
// newPassphrase returns base64 of 32 random bytes, the SDK's
// generatePassphrase.
func newPassphrase() []byte {
return []byte(base64.StdEncoding.EncodeToString(randomBytes(32)))
}
// lockedKey is a generated key with its passphrase and armored locked
// form.
type lockedKey struct {
key *crypto.Key
passphrase []byte
armored string
}
// newDriveKey generates a node or share key locked with a new
// passphrase. aead makes the key advertise SEIPDv2, as file node keys
// do; folder and share keys do not.
func newDriveKey(aead bool) lockedKey {
pgp := crypto.PGP()
if aead {
pgp = aeadPGP()
}
key, err := pgp.KeyGeneration().AddUserId(driveKeyName, "").GenerationTime(signTime()).New().GenerateKey()
mustCrypto("generate drive key", err)
passphrase := newPassphrase()
return lockedKey{key: key, passphrase: passphrase, armored: lockKey(key, passphrase)}
}
// sealedPassphrase is a node or share passphrase as the server stores it.
type sealedPassphrase struct {
armored string // encrypted to the parent key
signature string // armored detached signature of the plaintext
}
// sealPassphrase encrypts passphrase to parent without AEAD and signs it
// with signer in a detached signature, the SDK's encryptPassphrase.
func sealPassphrase(passphrase []byte, parent, signer *crypto.Key) sealedPassphrase {
return sealedPassphrase{
armored: encryptArmored(passphrase, parent, nil, false),
signature: signArmored(passphrase, signer),
}
}
// encryptArmored encrypts data to key without AEAD, signed inline by
// signer if not nil, compressed if compress is set.
//
// Encrypting to recipients picks SEIPDv2 whenever every recipient key
// advertises it, as file node keys do, so the session key is generated
// and wrapped separately: the SDK's ignoreSEIPDv2FeatureFlag.
func encryptArmored(data []byte, key, signer *crypto.Key, compress bool) string {
sk, err := crypto.GenerateSessionKeyAlgo(constants.AES256)
mustCrypto("session key", err)
kh, err := crypto.PGP().Encryption().Recipient(key).New()
mustCrypto("encrypt session key", err)
keyPacket, err := kh.EncryptSessionKey(sk)
mustCrypto("encrypt session key", err)
b := crypto.PGP().Encryption().SessionKey(sk)
if signer != nil {
b = b.SigningKey(signer).SignTime(signTimeOf(signer))
}
if compress {
b = b.Compress()
}
eh, err := b.New()
mustCrypto("encrypt", err)
msg, err := eh.Encrypt(data)
mustCrypto("encrypt", err)
armored, err := crypto.NewPGPMessage(append(keyPacket, msg.DataPacket...)).Armor()
mustCrypto("armor message", err)
return armored
}
// signArmored returns an armored detached signature of data by signer.
func signArmored(data []byte, signer *crypto.Key) string {
sh, err := crypto.PGP().Sign().SigningKey(signer).Detached().SignTime(signTimeOf(signer)).New()
mustCrypto("sign", err)
sig, err := sh.Sign(data, crypto.Armor)
mustCrypto("sign", err)
return string(sig)
}
// signBinary returns a binary detached signature of data by signer.
func signBinary(data []byte, signer *crypto.Key) []byte {
sh, err := crypto.PGP().Sign().SigningKey(signer).Detached().SignTime(signTimeOf(signer)).New()
mustCrypto("sign", err)
sig, err := sh.Sign(data, crypto.Bytes)
mustCrypto("sign", err)
return sig
}
// newHashKey returns a new folder hash key and its armored form,
// encrypted to and signed by nodeKey, the SDK's generateHashKey.
func newHashKey(nodeKey *crypto.Key) (hashKey []byte, armored string) {
hashKey = newPassphrase()
return hashKey, encryptArmored(hashKey, nodeKey, nodeKey, false)
}
// lookupHash returns the hex HMAC-SHA256 of name keyed with the parent
// folder's hash key, the SDK's generateLookupHash.
func lookupHash(parentHashKey []byte, name string) string {
mac := hmac.New(sha256.New, parentHashKey)
mac.Write([]byte(name))
return hex.EncodeToString(mac.Sum(nil))
}
// contentKey is a file's content session key with its key packet.
type contentKey struct {
sk *crypto.SessionKey
packet string // base64 PKESK to the node key
signature string // armored detached signature of the session key by the node key
aead bool
}
// newContentKey generates a content session key for nodeKey, the SDK's
// generateContentKey. With aead the key packet is a v6 PKESK and blocks
// are SEIPDv2; without, the legacy v3 PKESK and SEIPDv1.
func newContentKey(nodeKey *crypto.Key, aead bool) contentKey {
token := randomBytes(32)
sk := crypto.NewSessionKeyFromTokenWithAead(token, constants.AES256, aead)
eh, err := crypto.PGP().Encryption().Recipient(nodeKey).New()
mustCrypto("content key packet", err)
keyPacket, err := eh.EncryptSessionKey(sk)
mustCrypto("content key packet", err)
return contentKey{
sk: sk,
packet: base64.StdEncoding.EncodeToString(keyPacket),
signature: signArmored(sk.Key, nodeKey),
aead: aead,
}
}
// pgp returns the handle for encrypting with ck.
func (ck contentKey) pgp() *crypto.PGPHandle {
if ck.aead {
return aeadPGP()
}
return crypto.PGP()
}
// encryptBlock encrypts one block of plaintext with the content key and
// returns the data packet and the armored block signature: a detached
// signature by signer encrypted to nodeKey with the content key, the
// SDK's encryptBlock.
func (ck contentKey) encryptBlock(plain []byte, nodeKey, signer *crypto.Key) (data []byte, encSignature string) {
eh, err := ck.pgp().Encryption().SessionKey(ck.sk).New()
mustCrypto("encrypt block", err)
msg, err := eh.Encrypt(plain)
mustCrypto("encrypt block", err)
sigHandle, err := ck.pgp().Encryption().Recipient(nodeKey).SessionKey(ck.sk).New()
mustCrypto("encrypt block signature", err)
sigMsg, err := sigHandle.Encrypt(signBinary(plain, signer))
mustCrypto("encrypt block signature", err)
encSignature, err = sigMsg.Armor()
mustCrypto("armor block signature", err)
return msg.DataPacket, encSignature
}
// keyRing returns a key ring of keys, which must not be empty.
func keyRing(keys ...*crypto.Key) *crypto.KeyRing {
kr, err := crypto.NewKeyRing(nil)
mustCrypto("key ring", err)
for _, k := range keys {
mustCrypto("key ring", kr.AddKey(k))
}
return kr
}
// errSignature wraps every signature verification failure.
var errSignature = errors.New("protontest: bad signature")
// decryptArmored decrypts an armored message with key. If verify is not
// nil the embedded signature must be valid for one of its keys at time
// at, or now if at is zero.
func decryptArmored(armored string, key *crypto.Key, verify *crypto.KeyRing, at time.Time) ([]byte, error) {
b := crypto.PGP().Decryption().DecryptionKey(key)
if verify != nil {
b = b.VerificationKeys(verify)
if !at.IsZero() {
b = b.VerifyTime(at.Unix())
}
}
dh, err := b.New()
if err != nil {
return nil, err
}
res, err := dh.Decrypt([]byte(armored), crypto.Armor)
if err != nil {
return nil, err
}
if verify != nil {
if err := res.SignatureError(); err != nil {
return res.Bytes(), fmt.Errorf("%w: %v", errSignature, err)
}
}
return res.Bytes(), nil
}
// verifyDetached checks a detached signature, armored or binary per
// encoding, of data against verify at time at, or now if at is zero.
func verifyDetached(data, sig []byte, encoding int8, verify *crypto.KeyRing, at time.Time) error {
b := crypto.PGP().Verify().VerificationKeys(verify)
if !at.IsZero() {
b = b.VerifyTime(at.Unix())
}
vh, err := b.New()
if err != nil {
return err
}
res, err := vh.VerifyDetached(data, sig, encoding)
if err != nil {
return fmt.Errorf("%w: %v", errSignature, err)
}
if err := res.SignatureError(); err != nil {
return fmt.Errorf("%w: %v", errSignature, err)
}
return nil
}
// unlockFunc unlocks an armored private key with a passphrase.
type unlockFunc func(armoredKey string, passphrase []byte) (*crypto.Key, error)
// unlockDriveKey decrypts a node or share key: the passphrase with
// parent, its detached signature against verify, then the key itself
// with unlock. A bad signature is returned alongside the unlocked key.
// Signatures are checked at time at, or now if it is zero.
func unlockDriveKey(armoredKey string, sealed sealedPassphrase, parent *crypto.Key, verify *crypto.KeyRing, unlock unlockFunc, at time.Time) (*crypto.Key, error) {
passphrase, err := decryptArmored(sealed.armored, parent, nil, at)
if err != nil {
return nil, fmt.Errorf("decrypt passphrase: %w", err)
}
key, err := unlock(armoredKey, passphrase)
if err != nil {
return nil, fmt.Errorf("unlock key: %w", err)
}
if err := verifyDetached(passphrase, []byte(sealed.signature), crypto.Armor, verify, at); err != nil {
return key, fmt.Errorf("passphrase: %w", err)
}
return key, nil
}
// decryptContentKey decrypts a base64 content key packet with nodeKey
// and checks its signature against nodeKey and verify at time at.
func decryptContentKey(packet64, signature string, nodeKey *crypto.Key, verify *crypto.KeyRing, at time.Time) (*crypto.SessionKey, error) {
keyPacket, err := base64.StdEncoding.DecodeString(packet64)
if err != nil {
return nil, fmt.Errorf("content key packet: %w", err)
}
dh, err := crypto.PGP().Decryption().DecryptionKey(nodeKey).New()
if err != nil {
return nil, err
}
sk, err := dh.DecryptSessionKey(keyPacket)
if err != nil {
return nil, fmt.Errorf("content key packet: %w", err)
}
kr := keyRing(nodeKey)
for _, k := range verify.GetKeys() {
mustCrypto("key ring", kr.AddKey(k))
}
if err := verifyDetached(sk.Key, []byte(signature), crypto.Armor, kr, at); err != nil {
return sk, fmt.Errorf("content key packet: %w", err)
}
return sk, nil
}
// decryptBlock decrypts a block's data packet with sk and checks its
// encrypted signature, decrypted with nodeKey, against verify at time
// at.
func decryptBlock(data []byte, encSignature string, sk *crypto.SessionKey, nodeKey *crypto.Key, verify *crypto.KeyRing, at time.Time) ([]byte, error) {
dh, err := crypto.PGP().Decryption().SessionKey(sk).New()
if err != nil {
return nil, err
}
res, err := dh.Decrypt(data, crypto.Bytes)
if err != nil {
return nil, fmt.Errorf("decrypt block: %w", err)
}
sig, err := decryptArmored(encSignature, nodeKey, nil, at)
if err != nil {
return nil, fmt.Errorf("decrypt block signature: %w", err)
}
if err := verifyDetached(res.Bytes(), sig, crypto.Bytes, verify, at); err != nil {
return res.Bytes(), fmt.Errorf("block: %w", err)
}
return res.Bytes(), nil
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,716 @@
package protontest
import (
"bytes"
"crypto/sha1"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"strings"
"testing"
"time"
"github.com/ProtonMail/go-crypto/openpgp/armor"
"github.com/ProtonMail/go-crypto/openpgp/packet"
"github.com/ProtonMail/gopenpgp/v3/crypto"
"github.com/rclone/rclone/backend/protondrive/api"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// sessionHeader returns the headers of an authenticated request.
func sessionHeader(sess Session) map[string]string {
return map[string]string{
"x-pm-appversion": testAppVersion,
"x-pm-uid": sess.UID,
"Authorization": "Bearer " + sess.AccessToken,
}
}
// getJSON calls GET path as sess, decodes the response into out if it
// is not nil and returns the HTTP status and body.
func getJSON(t *testing.T, s *Server, sess Session, path string, out any) (int, []byte) {
t.Helper()
resp, body := do(t, s, "GET", path, sessionHeader(sess))
if out != nil && resp.StatusCode == http.StatusOK {
require.NoError(t, json.Unmarshal(body, out), "body: %s", body)
}
return resp.StatusCode, body
}
// patterned returns content of n bytes which differs per block.
func patterned(n int) []byte {
b := make([]byte, n)
for i := range b {
b[i] = byte(i*7 + i/blockSize)
}
return b
}
func sha1Hex(b []byte) string {
sum := sha1.Sum(b)
return hex.EncodeToString(sum[:])
}
func TestSeedAndDump(t *testing.T) {
s := New(t)
u := s.AddUser("user", "pass")
modTime := time.Date(2024, 5, 6, 7, 8, 9, 123e6, time.FixedZone("x", 3600))
docs := u.SeedFolder(u.RootID(), "Documents")
sub := u.SeedFolder(docs, "Sub")
empty := u.SeedFile(u.RootID(), "empty.txt", nil, modTime)
small := u.SeedFile(docs, "small.bin", []byte("hello"), modTime)
exact := u.SeedFile(sub, "exact.bin", patterned(blockSize), modTime)
big := u.SeedFile(sub, "big.bin", patterned(2*blockSize+123), modTime)
before := time.Now().Truncate(time.Second)
legacy := u.SeedLegacyFile(docs, "legacy.txt", []byte("old format"))
doc := u.SeedDoc(u.RootID(), "Notes")
type want struct {
id, path string
folder bool
content []byte
hasTime bool
}
wants := []want{
{docs, "Documents", true, nil, false},
{sub, "Documents/Sub", true, nil, false},
{big, "Documents/Sub/big.bin", false, patterned(2*blockSize + 123), true},
{exact, "Documents/Sub/exact.bin", false, patterned(blockSize), true},
{legacy, "Documents/legacy.txt", false, []byte("old format"), true},
{small, "Documents/small.bin", false, []byte("hello"), true},
{doc, "Notes", false, nil, false},
{empty, "empty.txt", false, nil, true},
}
dump := u.Dump()
require.Len(t, dump, len(wants))
for i, w := range wants {
n := dump[i]
assert.NoError(t, n.Err, w.path)
assert.Equal(t, w.id, n.ID, w.path)
assert.Equal(t, w.path, n.Path)
assert.Equal(t, w.folder, n.IsFolder, w.path)
assert.Equal(t, linkStateActive, n.State, w.path)
assert.True(t, bytes.Equal(w.content, n.Content), "%s: content", w.path)
switch {
case !w.hasTime:
assert.True(t, n.ModTime.IsZero(), w.path)
assert.Empty(t, n.SHA1, w.path)
case w.id == legacy:
assert.False(t, n.ModTime.Before(before), w.path)
assert.False(t, n.ModTime.After(time.Now()), w.path)
assert.Equal(t, sha1Hex(w.content), n.SHA1, w.path)
default:
assert.True(t, modTime.Equal(n.ModTime), "%s: modtime %v", w.path, n.ModTime)
assert.Equal(t, sha1Hex(w.content), n.SHA1, w.path)
}
}
assert.Empty(t, u.Drafts())
}
// packetVersion returns the tag and version byte of the first packet
// of a binary OpenPGP message.
func packetVersion(t *testing.T, msg []byte) (tag packet.Packet, version byte) {
t.Helper()
p, err := packet.NewReader(bytes.NewReader(msg)).Next()
require.NoError(t, err)
switch p := p.(type) {
case *packet.EncryptedKey:
return p, byte(p.Version)
case *packet.SymmetricallyEncrypted:
return p, byte(p.Version)
}
t.Fatalf("unexpected packet %T", p)
return nil, 0
}
// dataPacketVersion returns the SEIPD version of an armored message.
func dataPacketVersion(t *testing.T, armored string) byte {
t.Helper()
block, err := armor.Decode(strings.NewReader(armored))
require.NoError(t, err)
packets := packet.NewReader(block.Body)
for {
p, err := packets.Next()
require.NoError(t, err)
if p, ok := p.(*packet.SymmetricallyEncrypted); ok {
return byte(p.Version)
}
}
}
func TestSeedFormats(t *testing.T) {
s := New(t)
u := s.AddUser("user", "pass")
current := u.SeedFile(u.RootID(), "current", []byte("new"), time.Now())
legacy := u.SeedLegacyFile(u.RootID(), "legacy", []byte("old"))
folder := u.SeedFolder(u.RootID(), "folder")
v := u.view()
for _, tc := range []struct {
id string
pkesk byte
seipd byte
aeadKey bool
mediaType string
blockCount int
}{
{current, 6, 2, true, "application/octet-stream", 1},
{legacy, 3, 1, false, "application/octet-stream", 1},
} {
l := v.d.links[tc.id]
keyPacket, err := base64.StdEncoding.DecodeString(l.contentKeyPacket)
require.NoError(t, err)
_, version := packetVersion(t, keyPacket)
assert.Equal(t, tc.pkesk, version, "content key packet version")
rev := l.activeRevision()
require.Len(t, rev.blocks, tc.blockCount)
_, version = packetVersion(t, rev.blocks[0].data)
assert.Equal(t, tc.seipd, version, "block SEIPD version")
assert.Equal(t, tc.seipd, dataPacketVersion(t, rev.blocks[0].encSignature), "block signature SEIPD version")
assert.Equal(t, tc.mediaType, l.mediaType)
key, err := crypto.NewKeyFromArmored(l.nodeKey)
require.NoError(t, err)
sig, err := key.GetEntity().PrimarySelfSignature(time.Now(), nil)
require.NoError(t, err)
assert.Equal(t, tc.aeadKey, sig.SEIPDv2, "node key advertises SEIPDv2")
// Everything but content avoids AEAD whatever the node key
// advertises.
assert.Equal(t, byte(1), dataPacketVersion(t, l.name), "name")
assert.Equal(t, byte(1), dataPacketVersion(t, l.nodePass.armored), "passphrase")
assert.Equal(t, byte(1), dataPacketVersion(t, rev.xattr), "extended attributes")
}
l := v.d.links[folder]
key, err := crypto.NewKeyFromArmored(l.nodeKey)
require.NoError(t, err)
sig, err := key.GetEntity().PrimarySelfSignature(time.Now(), nil)
require.NoError(t, err)
assert.False(t, sig.SEIPDv2, "folder key advertises SEIPDv2")
assert.Equal(t, byte(1), dataPacketVersion(t, l.hashKey), "hash key")
}
func TestCorrupt(t *testing.T) {
for _, what := range []string{"name", "name-signature", "passphrase-signature", "block-hash", "manifest", "swap-blocks", "truncate-blocks", "block-rollback", "foreign-block"} {
t.Run(what, func(t *testing.T) {
s := New(t)
u := s.AddUser("user", "pass")
victim := u.SeedFile(u.RootID(), "victim", patterned(blockSize+10), time.Now())
if what == "block-rollback" {
u.SeedRevision(victim, patterned(blockSize+20), time.Now())
}
bystander := u.SeedFile(u.RootID(), "bystander", []byte("fine"), time.Now())
u.Corrupt(victim, what)
for _, n := range u.Dump() {
switch n.ID {
case victim:
assert.Error(t, n.Err)
case bystander:
assert.NoError(t, n.Err)
}
}
})
}
}
func TestBootstrapRoutes(t *testing.T) {
s := New(t)
u := s.AddUser("user", "pass")
sess := s.NewSession(u)
var myFiles myFilesResponse
status, body := getJSON(t, s, sess, "/drive/v2/shares/my-files", &myFiles)
require.Equal(t, http.StatusOK, status, "body: %s", body)
assert.Equal(t, u.VolumeID(), myFiles.Volume.VolumeID)
assert.Equal(t, u.ShareID(), myFiles.Share.ShareID)
assert.Equal(t, u.RootID(), myFiles.Link.Link.LinkID)
assert.Equal(t, u.Email(), myFiles.Share.CreatorEmail)
assert.Nil(t, myFiles.Link.Link.ParentLinkID)
var volume getVolumeResponse
status, body = getJSON(t, s, sess, "/drive/volumes/"+u.VolumeID(), &volume)
require.Equal(t, http.StatusOK, status, "body: %s", body)
assert.Equal(t, u.ShareID(), volume.Volume.Share.ShareID)
assert.Equal(t, u.RootID(), volume.Volume.Share.LinkID)
var share shareResponse
status, body = getJSON(t, s, sess, "/drive/shares/"+u.ShareID(), &share)
require.Equal(t, http.StatusOK, status, "body: %s", body)
assert.Equal(t, u.RootID(), share.LinkID)
assert.Equal(t, myFiles.Share.Key, share.Key)
require.NotNil(t, share.AddressID)
assert.Equal(t, myFiles.Share.AddressID, *share.AddressID)
// Another user's IDs are not found.
other := s.AddUser("other", "pass")
status, body = getJSON(t, s, sess, "/drive/volumes/"+other.VolumeID(), nil)
assert.Equal(t, http.StatusUnprocessableEntity, status)
assert.Equal(t, api.CodeNotExists, decodeError(t, body).Code)
status, _ = getJSON(t, s, sess, "/drive/shares/"+other.ShareID(), nil)
assert.Equal(t, http.StatusUnprocessableEntity, status)
}
// keysOf returns the keys of a JSON object.
func keysOf(t *testing.T, v any) []string {
t.Helper()
m, ok := v.(map[string]any)
require.True(t, ok, "not an object: %v", v)
var keys []string
for k := range m {
keys = append(keys, k)
}
return keys
}
// TestWireFieldNames checks the link metadata response carries the
// fields the SDK's nodes/apiService.ts reads, by name.
func TestWireFieldNames(t *testing.T) {
s := New(t)
u := s.AddUser("user", "pass")
sess := s.NewSession(u)
folder := u.SeedFolder(u.RootID(), "folder")
file := u.SeedFile(u.RootID(), "file", []byte("x"), time.Now())
var resp struct{ Links []map[string]any }
status := postJSON(t, s, "/drive/v2/volumes/"+u.VolumeID()+"/links", sessionHeader(sess), &linksRequest{LinkIDs: []string{folder, file}}, &resp)
require.Equal(t, http.StatusOK, status)
require.Len(t, resp.Links, 2)
for _, l := range resp.Links {
assert.Subset(t, keysOf(t, l), []string{"Link", "Folder", "File", "Sharing", "Membership"})
assert.Subset(t, keysOf(t, l["Link"]), []string{
"LinkID", "Type", "ParentLinkID", "State", "CreateTime", "ModifyTime", "TrashTime",
"Name", "NameHash", "NodeKey", "NodePassphrase", "NodePassphraseSignature",
"SignatureEmail", "NameSignatureEmail", "OwnedBy", "ThirdParty", "Sdk",
})
if l["File"] == nil {
assert.Subset(t, keysOf(t, l["Folder"]), []string{"NodeHashKey", "XAttr", "IsImported"})
continue
}
f := l["File"]
assert.Subset(t, keysOf(t, f), []string{"ActiveRevision", "TotalEncryptedSize", "ContentKeyPacket", "MediaType", "ContentKeyPacketSignature"})
assert.Subset(t, keysOf(t, f.(map[string]any)["ActiveRevision"]), []string{
"RevisionID", "CreateTime", "EncryptedSize", "SignatureEmail", "XAttr", "IsImported",
"Thumbnails", "ThirdParty", "Sdk", "ManifestSignature",
})
}
}
func TestChildrenPaging(t *testing.T) {
s := New(t)
u := s.AddUser("user", "pass")
sess := s.NewSession(u)
s.SetChildrenPageSize(100)
parent := u.SeedFolder(u.RootID(), "parent")
want := make(map[string]bool)
for i := range 250 {
want[u.SeedFolder(parent, fmt.Sprintf("child %03d", i))] = true
}
trashed := u.SeedFolder(parent, "trashed")
s.mu.Lock()
u.drive.links[trashed].state = linkStateTrashed
s.mu.Unlock()
s.ResetRequests()
got := make(map[string]bool)
anchor, pages := "", 0
for {
path := "/drive/v2/volumes/" + u.VolumeID() + "/folders/" + parent + "/children"
if anchor != "" {
path += "?AnchorID=" + anchor
}
var page childrenResponse
status, body := getJSON(t, s, sess, path, &page)
require.Equal(t, http.StatusOK, status, "body: %s", body)
pages++
for _, id := range page.LinkIDs {
assert.False(t, got[id], "duplicate %s", id)
got[id] = true
}
if !page.More {
assert.Nil(t, page.AnchorID)
break
}
require.NotNil(t, page.AnchorID)
require.Len(t, page.LinkIDs, 100)
anchor = *page.AnchorID
}
assert.Equal(t, 3, pages)
assert.Equal(t, want, got)
assert.Equal(t, 3, s.Requests()["GET /drive/v2/volumes/{volumeID}/folders/{linkID}/children"])
}
func TestLinksBatchLimit(t *testing.T) {
s := New(t)
u := s.AddUser("user", "pass")
sess := s.NewSession(u)
a := u.SeedFolder(u.RootID(), "a")
b := u.SeedFile(u.RootID(), "b", []byte("b"), time.Now())
path := "/drive/v2/volumes/" + u.VolumeID() + "/links"
ids := []string{a, b}
for len(ids) < maxLinksPerRequest {
ids = append(ids, randomID())
}
var resp linksResponse
require.Equal(t, http.StatusOK, postJSON(t, s, path, sessionHeader(sess), &linksRequest{LinkIDs: ids}, &resp))
require.Len(t, resp.Links, 2, "unknown IDs are left out")
assert.Equal(t, a, resp.Links[0].Link.LinkID)
assert.NotNil(t, resp.Links[0].Folder)
assert.Equal(t, b, resp.Links[1].Link.LinkID)
require.NotNil(t, resp.Links[1].File)
assert.NotNil(t, resp.Links[1].File.ActiveRevision)
var apiErr api.Error
ids = append(ids, randomID())
assert.Equal(t, http.StatusUnprocessableEntity, postJSON(t, s, path, sessionHeader(sess), &linksRequest{LinkIDs: ids}, &apiErr))
assert.Equal(t, api.CodeInvalidValue, apiErr.Code)
}
// getBlock fetches a block URL with the given storage token, sent
// unless empty.
func getBlock(t *testing.T, s *Server, bareURL, token string) (int, []byte) {
t.Helper()
header := map[string]string{"x-pm-appversion": testAppVersion}
if token != "" {
header["pm-storage-token"] = token
}
require.True(t, strings.HasPrefix(bareURL, s.URL()+"/"), "block URL %s is not on the test server", bareURL)
resp, body := do(t, s, "GET", strings.TrimPrefix(bareURL, s.URL()), header)
return resp.StatusCode, body
}
func TestBlockDownloadNeedsToken(t *testing.T) {
s := New(t)
u := s.AddUser("user", "pass")
sess := s.NewSession(u)
content := patterned(blockSize + 100)
file := u.SeedFile(u.RootID(), "file", content, time.Now())
var links linksResponse
require.Equal(t, http.StatusOK, postJSON(t, s, "/drive/v2/volumes/"+u.VolumeID()+"/links", sessionHeader(sess), &linksRequest{LinkIDs: []string{file}}, &links))
revisionPath := "/drive/v2/volumes/" + u.VolumeID() + "/files/" + file + "/revisions/" + links.Links[0].File.ActiveRevision.RevisionID
var rev revisionResponse
status, body := getJSON(t, s, sess, revisionPath+"?NoBlockUrls=true", &rev)
require.Equal(t, http.StatusOK, status, "body: %s", body)
require.Len(t, rev.Revision.Blocks, 2)
for _, b := range rev.Revision.Blocks {
assert.Nil(t, b.Token)
assert.Nil(t, b.BareURL)
}
require.NotNil(t, rev.Revision.ManifestSignature)
status, body = getJSON(t, s, sess, revisionPath+"?PageSize=1&FromBlockIndex=2", &rev)
require.Equal(t, http.StatusOK, status, "body: %s", body)
require.Len(t, rev.Revision.Blocks, 1)
b := rev.Revision.Blocks[0]
assert.Equal(t, 2, b.Index)
require.NotNil(t, b.Token)
require.NotNil(t, b.BareURL)
status, _ = getBlock(t, s, *b.BareURL, "")
assert.Equal(t, http.StatusForbidden, status)
status, _ = getBlock(t, s, *b.BareURL, "wrong")
assert.Equal(t, http.StatusForbidden, status)
status, data := getBlock(t, s, *b.BareURL, *b.Token)
require.Equal(t, http.StatusOK, status)
sum := sha256.Sum256(data)
assert.Equal(t, b.Hash, base64.StdEncoding.EncodeToString(sum[:]))
status, _ = getBlock(t, s, s.URL()+"/storage/blocks/unknown", *b.Token)
assert.Equal(t, http.StatusNotFound, status)
}
func TestQuota(t *testing.T) {
s := New(t)
u := s.AddUser("user", "pass")
sess := s.NewSession(u)
u.SeedFile(u.RootID(), "a", patterned(1000), time.Now())
u.SeedFile(u.RootID(), "b", patterned(10), time.Now())
var want int64
v := u.view()
for _, l := range v.d.links {
for _, r := range l.revisions {
want += r.size()
}
}
require.NotZero(t, want)
var resp struct {
User struct {
UsedSpace int64
MaxSpace int64
ProductUsedSpace struct{ Drive int64 }
}
}
status, body := getJSON(t, s, sess, "/core/v4/users", &resp)
require.Equal(t, http.StatusOK, status, "body: %s", body)
assert.Equal(t, want, resp.User.UsedSpace)
assert.Equal(t, want, resp.User.ProductUsedSpace.Drive)
assert.Equal(t, DefaultMaxSpace, resp.User.MaxSpace)
}
// volumeBootstrap returns a create volume request for the primary
// address of u made as the SDK's generateVolumeBootstrap does.
func volumeBootstrap(u *User) *createVolumeRequest {
addr := u.addresses[0]
addrKey := addr.keys[0].key
share := newDriveKey(false)
sharePass := sealPassphrase(share.passphrase, addrKey, addrKey)
root := newDriveKey(false)
rootPass := sealPassphrase(root.passphrase, share.key, addrKey)
_, hashKey := newHashKey(root.key)
return &createVolumeRequest{
AddressID: addr.id,
AddressKeyID: &addr.keys[0].id,
ShareKey: share.armored,
SharePassphrase: sharePass.armored,
SharePassphraseSignature: sharePass.signature,
FolderName: encryptArmored([]byte("root"), share.key, addrKey, false),
FolderKey: root.armored,
FolderPassphrase: rootPass.armored,
FolderPassphraseSignature: rootPass.signature,
FolderHashKey: hashKey,
}
}
func TestAccountWithoutDrive(t *testing.T) {
s := New(t)
u := s.AddUserWithoutDrive("new", "pass")
sess := s.NewSession(u)
status, body := getJSON(t, s, sess, "/drive/v2/shares/my-files", nil)
assert.Equal(t, http.StatusUnprocessableEntity, status)
assert.Equal(t, api.CodeNotExists, decodeError(t, body).Code)
assert.Equal(t, "Volume not found", decodeError(t, body).Message)
impostor := generateKey("impostor", "impostor@proton.test")
for name, spoil := range map[string]func(req *createVolumeRequest){
"share passphrase to another key": func(req *createVolumeRequest) {
req.SharePassphrase = encryptArmored([]byte("x"), impostor, nil, false)
},
"share passphrase signed by another key": func(req *createVolumeRequest) {
other := volumeBootstrap(u)
req.SharePassphraseSignature = other.SharePassphraseSignature
},
"folder name missing": func(req *createVolumeRequest) {
req.FolderName = ""
},
"hash key missing": func(req *createVolumeRequest) {
req.FolderHashKey = ""
},
"unknown address": func(req *createVolumeRequest) {
req.AddressID = "unknown"
},
} {
req := volumeBootstrap(u)
spoil(req)
var apiErr api.Error
assert.Equal(t, http.StatusUnprocessableEntity, postJSON(t, s, "/drive/volumes", sessionHeader(sess), req, &apiErr), name)
assert.Equal(t, api.CodeInvalidValue, apiErr.Code, name)
}
var created getVolumeResponse
status = postJSON(t, s, "/drive/volumes", sessionHeader(sess), volumeBootstrap(u), &created)
require.Equal(t, http.StatusOK, status)
assert.Equal(t, u.VolumeID(), created.Volume.ID)
assert.Equal(t, u.ShareID(), created.Volume.Share.ShareID)
assert.Equal(t, u.RootID(), created.Volume.Share.LinkID)
var apiErr api.Error
assert.Equal(t, http.StatusUnprocessableEntity, postJSON(t, s, "/drive/volumes", sessionHeader(sess), volumeBootstrap(u), &apiErr))
assert.Equal(t, api.CodeAlreadyExists, apiErr.Code)
var myFiles myFilesResponse
status, body = getJSON(t, s, sess, "/drive/v2/shares/my-files", &myFiles)
require.Equal(t, http.StatusOK, status, "body: %s", body)
assert.Equal(t, u.RootID(), myFiles.Link.Link.LinkID)
assert.Empty(t, u.Dump())
id := u.SeedFile(u.RootID(), "after", []byte("works"), time.Now())
dump := u.Dump()
require.Len(t, dump, 1)
assert.Equal(t, id, dump[0].ID)
assert.Equal(t, "after", dump[0].Path)
assert.NoError(t, dump[0].Err)
}
// TestBlockBodyIsReadable checks a downloaded block is the exact data
// packet that decrypts with the content key.
func TestBlockBodyIsReadable(t *testing.T) {
s := New(t)
u := s.AddUser("user", "pass")
sess := s.NewSession(u)
file := u.SeedFile(u.RootID(), "file", []byte("block body"), time.Now())
v := u.view()
l := v.d.links[file]
key, err := v.unlock(file)
require.NoError(t, err)
sk, err := decryptContentKey(l.contentKeyPacket, l.contentKeyPacketSignature, key, keyRing(key), time.Time{})
require.NoError(t, err)
var rev revisionResponse
status, body := getJSON(t, s, sess, "/drive/v2/volumes/"+u.VolumeID()+"/files/"+file+"/revisions/"+l.activeRevision().id, &rev)
require.Equal(t, http.StatusOK, status, "body: %s", body)
b := rev.Revision.Blocks[0]
status, data := getBlock(t, s, *b.BareURL, *b.Token)
require.Equal(t, http.StatusOK, status)
plain, err := decryptBlock(data, *b.EncSignature, sk, key, keyRing(u.addresses[0].keys[0].key), time.Time{})
require.NoError(t, err)
assert.Equal(t, "block body", string(plain))
}
func TestSeedRevision(t *testing.T) {
s := New(t)
u := s.AddUser("user", "pass")
ids := map[string]string{
"current": u.SeedFile(u.RootID(), "current", []byte("old"), time.Now()),
"legacy": u.SeedLegacyFile(u.RootID(), "legacy", []byte("old")),
}
content := patterned(blockSize + 5)
for name, id := range ids {
old := u.view().d.links[id].activeRevision().id
rev := u.SeedRevision(id, content, time.Now())
l := u.view().d.links[id]
assert.Equal(t, rev, l.activeRevision().id, name)
require.Len(t, l.revisions, 2, name)
assert.Equal(t, old, l.revisions[0].id, name)
assert.Equal(t, revisionStateObsolete, l.revisions[0].state, name)
}
dump := u.Dump()
require.Len(t, dump, 2)
for _, d := range dump {
assert.NoError(t, d.Err, d.Path)
assert.True(t, bytes.Equal(content, d.Content), d.Path)
}
}
func TestSeedForgedFolder(t *testing.T) {
s := New(t)
u := s.AddUser("user", "pass")
forged := u.SeedForgedFolder(u.RootID(), "forged")
nested := u.SeedAnonymousFolder(forged, "nested")
file := u.SeedAnonymousFile(nested, "file", []byte("forged content"), time.Now())
for _, n := range u.Dump() {
switch n.ID {
case forged:
assert.Error(t, n.Err, "the forged key's signature verifies")
case nested, file:
assert.NoError(t, n.Err, n.Path)
}
if n.ID == file {
assert.Equal(t, "forged/nested/file", n.Path)
assert.Equal(t, "forged content", string(n.Content))
}
}
}
func TestForgeShareAndRoot(t *testing.T) {
s := New(t)
u := s.AddUser("user", "pass")
other := s.AddUser("other", "pass")
u.ForgeShareCreator(other, other.Email())
u.ForgeRootSigner(other, other.Email())
v := u.view()
assert.Equal(t, other.Email(), v.d.creatorEmail)
_, err := v.unlockShare()
assert.NoError(t, err, "the share verifies against the other user's key")
root := v.d.links[v.d.rootID]
assert.Equal(t, other.Email(), root.signatureEmail)
_, err = v.unlock(v.d.rootID)
assert.NoError(t, err, "the root verifies against the other user's key")
id := u.SeedFile(u.RootID(), "file", []byte("x"), time.Now())
for _, n := range u.Dump() {
if n.ID == id {
assert.NoError(t, n.Err)
}
}
}
func TestServeKeysAs(t *testing.T) {
s := New(t)
u := s.AddUser("user", "pass")
other := s.AddUser("other", "pass")
sess := s.NewSession(u)
s.ServeKeysAs("USER@proton.test", other)
var resp api.PublicKeysResponse
status, body := getJSON(t, s, sess, "/core/v4/keys/all?Email=USER@proton.test", &resp)
require.Equal(t, http.StatusOK, status, "body: %s", body)
require.NotEmpty(t, resp.Address.Keys)
want, err := other.addresses[0].keys[0].key.GetArmoredPublicKey()
require.NoError(t, err)
assert.Equal(t, want, resp.Address.Keys[0].PublicKey)
}
func TestNodeSecrets(t *testing.T) {
s := New(t)
u := s.AddUser("user", "pass")
id := u.SeedFile(u.RootID(), "file", []byte("secret"), time.Now())
passphrase, sessionKey := u.NodeSecrets(id)
v := u.view()
l := v.d.links[id]
key, err := crypto.NewPrivateKeyFromArmored(l.nodeKey, passphrase)
require.NoError(t, err, "the passphrase unlocks the node key")
sk, err := decryptContentKey(l.contentKeyPacket, l.contentKeyPacketSignature, key, keyRing(key), time.Time{})
require.NoError(t, err)
assert.Equal(t, sk.Key, sessionKey)
}
func TestIssuedSecrets(t *testing.T) {
s := New(t)
u := s.AddUser("user", "pass")
sess := s.NewSession(u)
file := u.SeedFile(u.RootID(), "file", []byte("x"), time.Now())
assert.Equal(t, []string{sess.AccessToken, sess.RefreshToken}, s.IssuedSecrets())
var rev revisionResponse
status, body := getJSON(t, s, sess, "/drive/v2/volumes/"+u.VolumeID()+"/files/"+file+"/revisions/"+u.view().d.links[file].activeRevision().id, &rev)
require.Equal(t, http.StatusOK, status, "body: %s", body)
token := *rev.Revision.Blocks[0].Token
assert.Equal(t, []string{token}, s.IssuedSecrets(StorageToken))
assert.Equal(t, []string{sess.AccessToken, sess.RefreshToken}, s.IssuedSecrets(SessionToken))
assert.Equal(t, []string{sess.AccessToken, sess.RefreshToken, token}, s.IssuedSecrets())
assert.Empty(t, s.IssuedSecrets(ForkSelector))
}
func TestStoredFileRoundTrip(t *testing.T) {
s := New(t)
addrPass := newPassphrase()
root := newDriveKey(false)
_, hashKey := newHashKey(root.key)
keys := FixedKeys{
AddressKey: lockKey(generateKey("fixed", "fixed@proton.test"), addrPass),
AddressKeyPassphrase: string(addrPass),
RootKey: root.armored,
RootKeyPassphrase: string(root.passphrase),
RootHashKey: hashKey,
}
// Two users holding the same keys can read each other's stored files.
one := s.AddUserWithKeys("one", "pass", keys)
two := s.AddUserWithKeys("two", "pass", keys)
content := patterned(blockSize + 5)
id := one.SeedFile(one.RootID(), "f", content, time.Now())
f := one.StoredFile(id)
assert.Equal(t, "one@proton.test", f.Link.SignatureEmail)
require.Len(t, f.Revision.Blocks, 2)
for _, b := range f.Revision.Blocks {
sum := sha256.Sum256(b.Data)
assert.Equal(t, sum[:], b.Hash)
}
two.SeedStoredFile(two.RootID(), f)
for _, u := range []*User{one, two} {
dump := u.Dump()
require.Len(t, dump, 1)
assert.NoError(t, dump[0].Err)
assert.Equal(t, "f", dump[0].Path)
assert.True(t, bytes.Equal(content, dump[0].Content))
}
}
+760
View File
@@ -0,0 +1,760 @@
package protontest
import (
"bytes"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"slices"
"sort"
"strings"
"time"
"github.com/ProtonMail/go-crypto/openpgp/armor"
"github.com/ProtonMail/go-crypto/openpgp/packet"
"github.com/ProtonMail/go-crypto/openpgp/s2k"
"github.com/rclone/rclone/backend/protondrive/api"
)
// OpenPGP packet tags checked in client-written values.
const (
tagPKESK = 1
tagSignature = 2
tagSecretKey = 5
tagSecretSubkey = 7
tagSEIPD = 18
)
// s2kUsageSHA1 is the S2K usage octet of a secret key locked with CFB
// and a SHA-1 check, as ordinary (not AEAD) key locking writes.
const s2kUsageSHA1 = 254
// Wire types of the mutating routes, named after and shaped like the
// schemas in the SDK's driveTypes.ts.
type createFolderRequest struct {
ParentLinkID string
NodeKey string
NodeHashKey string
NodePassphrase string
NodePassphraseSignature string
SignatureEmail *string
Name string
Hash string
XAttr *string
}
type folderIDJSON struct {
ID string
}
type createFolderResponse struct {
Folder folderIDJSON
Code int
}
type renameRequest struct {
Name string
Hash *string
NameSignatureEmail *string
OriginalHash *string
}
type moveRequest struct {
Name string
NodePassphrase string
Hash string
ParentLinkID string
OriginalHash string
NameSignatureEmail *string
ContentHash *string
NodePassphraseSignature *string
SignatureEmail *string
}
type copyRequest struct {
Name string
NodePassphrase string
Hash string
TargetVolumeID string
TargetParentLinkID string
NameSignatureEmail *string
NodePassphraseSignature *string
SignatureEmail *string
}
type copyResponse struct {
LinkID string
Code int
}
type codeJSON struct {
Code int
Error string `json:",omitempty"`
}
type linkResultJSON struct {
LinkID string
Response codeJSON
}
type multiResponse struct {
Code int
Responses []linkResultJSON
}
type checkHashesRequest struct {
Hashes []string
ClientUID []string
}
type pendingHashJSON struct {
Hash string
RevisionID string
LinkID string
ClientUID *string
}
type availableHashesResponse struct {
AvailableHashes []string
PendingHashes []pendingHashJSON
Code int
}
type conflictDetailsJSON struct {
ConflictLinkID string
ConflictRevisionID *string
ConflictDraftRevisionID *string
ConflictDraftClientUID *string
}
type conflictResponse struct {
Code int
Error string
Details conflictDetailsJSON
}
// registerMutations registers the routes which change a drive.
func (s *Server) registerMutations() {
s.handleDrive("POST", "/drive/v2/volumes/{volumeID}/folders", s.handleCreateFolder)
s.handleDrive("PUT", "/drive/v2/volumes/{volumeID}/links/{linkID}/rename", s.handleRename)
s.handleDrive("PUT", "/drive/v2/volumes/{volumeID}/links/{linkID}/move", s.handleMove)
s.handleDrive("POST", "/drive/volumes/{volumeID}/links/{linkID}/copy", s.handleCopy)
s.handleDrive("POST", "/drive/v2/volumes/{volumeID}/trash_multiple", s.handleTrash)
s.handleDrive("DELETE", "/drive/volumes/{volumeID}/trash", s.handleEmptyTrash)
s.handleDrive("POST", "/drive/v2/volumes/{volumeID}/links/{linkID}/checkAvailableHashes", s.handleCheckAvailableHashes)
}
// decodeRequest decodes the JSON body of r into v, or answers HTTP 400
// and returns false.
func decodeRequest(w http.ResponseWriter, r *http.Request, v any) bool {
if err := json.NewDecoder(r.Body).Decode(v); err != nil {
writeError(w, http.StatusBadRequest, api.CodeInvalidValue, "protontest: bad request body: "+err.Error())
return false
}
return true
}
// writeInvalid answers HTTP 422 code 2001 for a request failing check.
func writeInvalid(w http.ResponseWriter, err error) {
writeError(w, http.StatusUnprocessableEntity, api.CodeInvalidValue, "protontest: "+err.Error())
}
// writeConflict answers HTTP 422 code 2500 for a request whose name
// hash is taken by l.
func writeConflict(w http.ResponseWriter, l *link) {
details := conflictDetailsJSON{ConflictLinkID: l.id}
for _, r := range l.revisions {
switch r.state {
case revisionStateActive:
details.ConflictRevisionID = nullable(r.id)
case revisionStateDraft:
details.ConflictDraftRevisionID = nullable(r.id)
details.ConflictDraftClientUID = nullable(r.clientUID)
}
}
writeJSON(w, http.StatusUnprocessableEntity, &conflictResponse{
Code: api.CodeAlreadyExists,
Error: "A file or folder with that name already exists",
Details: details,
})
}
// activeLink returns the active link id, or nil.
func (d *drive) activeLink(id string) *link {
if l := d.links[id]; l != nil && l.state == linkStateActive {
return l
}
return nil
}
// activeFolder returns the active folder id, or nil.
func (d *drive) activeFolder(id string) *link {
if l := d.activeLink(id); l != nil && l.typ == linkTypeFolder {
return l
}
return nil
}
// conflict returns the link other than except in folder parentID
// which is not trashed and has the name hash hash, or nil.
func (d *drive) conflict(parentID, hash, except string) *link {
for _, l := range d.links {
if l.parentID == parentID && l.nameHash == hash && l.state != linkStateTrashed && l.id != except {
return l
}
}
return nil
}
// within reports whether link id is ancestorID or below it.
func (d *drive) within(id, ancestorID string) bool {
for l := d.links[id]; l != nil; l = d.links[l.parentID] {
if l.id == ancestorID {
return true
}
}
return false
}
// inTrash reports whether l or one of its ancestors is trashed.
func (d *drive) inTrash(l *link) bool {
for ; l != nil; l = d.links[l.parentID] {
if l.state == linkStateTrashed {
return true
}
}
return false
}
// ownsEmailLocked reports whether email is one of u's addresses.
// srv.mu must be held.
func (u *User) ownsEmailLocked(email string) bool {
return u.addressByEmailLocked(email) != nil
}
// checkHash returns an error unless hash is a lower-case hex SHA-256.
func checkHash(hash string) error {
if len(hash) != 64 || strings.ToLower(hash) != hash {
return fmt.Errorf("hash %q is not lower-case hex SHA-256", hash)
}
if _, err := hex.DecodeString(hash); err != nil {
return fmt.Errorf("hash %q is not lower-case hex SHA-256", hash)
}
return nil
}
// checkSigner returns an error unless email is one of u's addresses.
func checkSigner(u *User, field string, email *string) error {
if email == nil || !u.ownsEmailLocked(*email) {
return fmt.Errorf("%s must be an address of the user", field)
}
return nil
}
// checkMessages checks every named armored message with checkMessage.
func checkMessages(messages map[string]string) error {
for field, armored := range messages {
if err := checkMessage(armored); err != nil {
return fmt.Errorf("%s: %w", field, err)
}
}
return nil
}
// checkMessage returns an error unless armored is an OpenPGP message of
// v3 PKESKs followed by one SEIPDv1 packet.
//
// Official clients write names, passphrases, hash keys and extended
// attributes in that format and not all of them read SEIPDv2 or v6
// PKESKs there, whatever the recipient key accepts.
func checkMessage(armored string) error {
if armored == "" {
return errors.New("missing")
}
block, err := armor.Decode(strings.NewReader(armored))
if err != nil {
return fmt.Errorf("not armored: %w", err)
}
if block.Type != "PGP MESSAGE" {
return fmt.Errorf("armor type %q, want PGP MESSAGE", block.Type)
}
packets := packet.NewOpaqueReader(block.Body)
keyPackets, dataPackets := 0, 0
for {
op, err := packets.Next()
if err == io.EOF {
break
}
if err != nil {
return fmt.Errorf("malformed packet: %w", err)
}
if dataPackets > 0 {
return errors.New("packet after the encrypted data")
}
version := -1
if len(op.Contents) > 0 {
version = int(op.Contents[0])
}
switch op.Tag {
case tagPKESK:
if version != 3 {
return fmt.Errorf("v%d PKESK, want v3", version)
}
keyPackets++
case tagSEIPD:
if version != 1 {
return fmt.Errorf("SEIPDv%d, want SEIPDv1", version)
}
dataPackets++
default:
return fmt.Errorf("unexpected packet with tag %d", op.Tag)
}
}
if keyPackets == 0 || dataPackets == 0 {
return errors.New("want PKESKs and an encrypted data packet")
}
return nil
}
// checkFolderKey returns an error unless armored is a v4 secret key
// whose signatures don't advertise AEAD, with every secret key packet
// locked with CFB and an iterated and salted S2K, as official clients
// generate folder keys. The official web app can't use a node key
// locked with Argon2 or AEAD.
func checkFolderKey(armored string) error {
return checkNodeKey(armored, false)
}
// checkFileKey returns an error unless armored is a v4 secret key
// locked as checkFolderKey requires. Unlike a folder key, a file key
// advertises AEAD, for its content.
func checkFileKey(armored string) error {
return checkNodeKey(armored, true)
}
// checkNodeKey does the work of checkFolderKey and checkFileKey; aead
// allows the key to advertise AEAD.
func checkNodeKey(armored string, aead bool) error {
block, err := armor.Decode(strings.NewReader(armored))
if err != nil {
return fmt.Errorf("not armored: %w", err)
}
if block.Type != "PGP PRIVATE KEY BLOCK" {
return fmt.Errorf("armor type %q, want PGP PRIVATE KEY BLOCK", block.Type)
}
packets := packet.NewOpaqueReader(block.Body)
secretKeys := 0
for {
op, err := packets.Next()
if err == io.EOF {
break
}
if err != nil {
return fmt.Errorf("malformed packet: %w", err)
}
switch op.Tag {
case tagSecretKey, tagSecretSubkey:
if err := checkSecretKeyPacket(op); err != nil {
return err
}
secretKeys++
case tagSignature:
p, err := op.Parse()
sig, ok := p.(*packet.Signature)
if err != nil || !ok {
return fmt.Errorf("malformed signature: %v", err)
}
if !aead && (sig.SEIPDv2 || len(sig.PreferredCipherSuites) > 0) {
return errors.New("key advertises AEAD (SEIPDv2)")
}
}
}
if secretKeys == 0 {
return errors.New("no secret key")
}
return nil
}
// checkSecretKeyPacket checks one secret key or subkey packet for
// checkNodeKey.
func checkSecretKeyPacket(op *packet.OpaquePacket) error {
if len(op.Contents) == 0 || op.Contents[0] != 4 {
return errors.New("not a v4 key")
}
p, err := op.Parse()
pk, ok := p.(*packet.PrivateKey)
if err != nil || !ok {
return fmt.Errorf("malformed secret key: %v", err)
}
// The packet starts with the public key body, which a v4 key hashes
// after 0x99 and a two-octet length.
var pub bytes.Buffer
if err := pk.PublicKey.SerializeForHash(&pub); err != nil {
return fmt.Errorf("malformed secret key: %w", err)
}
rest := op.Contents[pub.Len()-3:]
if len(rest) < 3 {
return errors.New("malformed secret key: truncated")
}
switch usage := rest[0]; usage {
case s2kUsageSHA1:
case 0:
return errors.New("secret key is not locked")
case 253:
return errors.New("secret key is locked with AEAD")
default:
return fmt.Errorf("secret key S2K usage %d, want %d", usage, s2kUsageSHA1)
}
if mode := s2k.Mode(rest[2]); mode != s2k.IteratedSaltedS2K {
return fmt.Errorf("secret key S2K mode %d, want iterated and salted", mode)
}
return nil
}
// keySignature returns the node passphrase signature and its signer
// that l has once moved or copied, given the request's sig and email.
//
// As the SDK's moveNode and copyNode, a request gives them if and only
// if l's key is anonymous: an anonymous key is then signed by the
// user, and a signed one keeps its signature, as the passphrase is
// unchanged.
func keySignature(u *User, l *link, sig, email *string) (string, string, error) {
if l.signatureEmail != "" {
if (sig != nil && *sig != "") || (email != nil && *email != "") {
return "", "", errors.New("NodePassphraseSignature and SignatureEmail must not be given for a signed node")
}
return l.nodePass.signature, l.signatureEmail, nil
}
if sig == nil || *sig == "" {
return "", "", errors.New("NodePassphraseSignature is required for an anonymous node")
}
if err := checkSigner(u, "SignatureEmail", email); err != nil {
return "", "", err
}
return *sig, *email, nil
}
// handleCreateFolder creates a folder from key material the client
// generated.
func (s *Server) handleCreateFolder(w http.ResponseWriter, r *http.Request, u *User, d *drive) {
var req createFolderRequest
if !decodeRequest(w, r, &req) {
return
}
parent := d.activeFolder(req.ParentLinkID)
if parent == nil {
writeError(w, http.StatusUnprocessableEntity, api.CodeNotExists, "Parent folder not found")
return
}
messages := map[string]string{
"Name": req.Name,
"NodePassphrase": req.NodePassphrase,
"NodeHashKey": req.NodeHashKey,
}
if req.XAttr != nil {
messages["XAttr"] = *req.XAttr
}
err := errors.Join(
checkHash(req.Hash),
checkSigner(u, "SignatureEmail", req.SignatureEmail),
checkMessages(messages),
)
if err == nil && req.NodePassphraseSignature == "" {
err = errors.New("NodePassphraseSignature is required")
}
if err == nil {
err = annotateField("NodeKey", checkFolderKey(req.NodeKey))
}
if err != nil {
writeInvalid(w, err)
return
}
if other := d.conflict(parent.id, req.Hash, ""); other != nil {
writeConflict(w, other)
return
}
now := time.Now().Unix()
l := &link{
id: randomID(),
parentID: parent.id,
typ: linkTypeFolder,
state: linkStateActive,
createTime: now,
modifyTime: now,
name: req.Name,
nameHash: req.Hash,
nodeKey: req.NodeKey,
nodePass: sealedPassphrase{armored: req.NodePassphrase, signature: req.NodePassphraseSignature},
signatureEmail: *req.SignatureEmail,
nameSignatureEmail: *req.SignatureEmail,
hashKey: req.NodeHashKey,
}
if req.XAttr != nil {
l.xattr = *req.XAttr
}
d.addLink(l)
s.lagLocked(l.id, nil)
writeJSON(w, http.StatusOK, &createFolderResponse{Folder: folderIDJSON{ID: l.id}, Code: api.CodeOK})
}
// annotateField returns err prefixed with field, or nil.
func annotateField(field string, err error) error {
if err == nil {
return nil
}
return fmt.Errorf("%s: %w", field, err)
}
// handleRename renames a link within its folder.
func (s *Server) handleRename(w http.ResponseWriter, r *http.Request, u *User, d *drive) {
var req renameRequest
if !decodeRequest(w, r, &req) {
return
}
l := d.activeLink(r.PathValue("linkID"))
switch {
case l == nil:
writeError(w, http.StatusUnprocessableEntity, api.CodeNotExists, "File or folder not found")
return
case l.parentID == "":
writeError(w, http.StatusUnprocessableEntity, api.CodeInvalidRequirements, "protontest: the root can't be renamed")
return
case req.OriginalHash == nil || *req.OriginalHash != l.nameHash:
writeError(w, http.StatusUnprocessableEntity, api.CodeInvalidRequirements, "protontest: OriginalHash doesn't match: the node changed")
return
}
hash := ""
if req.Hash != nil {
hash = *req.Hash
}
err := errors.Join(
checkHash(hash),
checkSigner(u, "NameSignatureEmail", req.NameSignatureEmail),
checkMessages(map[string]string{"Name": req.Name}),
)
if err != nil {
writeInvalid(w, err)
return
}
if other := d.conflict(l.parentID, hash, l.id); other != nil {
writeConflict(w, other)
return
}
l.name, l.nameHash, l.nameSignatureEmail = req.Name, hash, *req.NameSignatureEmail
l.modifyTime = time.Now().Unix()
writeJSON(w, http.StatusOK, &codeJSON{Code: api.CodeOK})
}
// handleMove moves a link to another folder, possibly renaming it.
func (s *Server) handleMove(w http.ResponseWriter, r *http.Request, u *User, d *drive) {
var req moveRequest
if !decodeRequest(w, r, &req) {
return
}
l := d.activeLink(r.PathValue("linkID"))
parent := d.activeFolder(req.ParentLinkID)
switch {
case l == nil:
writeError(w, http.StatusUnprocessableEntity, api.CodeNotExists, "File or folder not found")
return
case l.parentID == "":
writeError(w, http.StatusUnprocessableEntity, api.CodeInvalidRequirements, "protontest: the root can't be moved")
return
case parent == nil:
writeError(w, http.StatusUnprocessableEntity, api.CodeNotExists, "Parent folder not found")
return
case d.within(parent.id, l.id):
writeError(w, http.StatusUnprocessableEntity, api.CodeInvalidRequirements, "protontest: a folder can't be moved into itself")
return
case req.OriginalHash != l.nameHash:
writeError(w, http.StatusUnprocessableEntity, api.CodeInvalidRequirements, "protontest: OriginalHash doesn't match: the node changed")
return
}
err := errors.Join(
checkHash(req.Hash),
checkSigner(u, "NameSignatureEmail", req.NameSignatureEmail),
checkMessages(map[string]string{"Name": req.Name, "NodePassphrase": req.NodePassphrase}),
)
if err != nil {
writeInvalid(w, err)
return
}
sig, signer, err := keySignature(u, l, req.NodePassphraseSignature, req.SignatureEmail)
if err != nil {
writeInvalid(w, err)
return
}
if other := d.conflict(parent.id, req.Hash, l.id); other != nil {
writeConflict(w, other)
return
}
l.parentID, l.name, l.nameHash, l.nameSignatureEmail = parent.id, req.Name, req.Hash, *req.NameSignatureEmail
l.nodePass = sealedPassphrase{armored: req.NodePassphrase, signature: sig}
l.signatureEmail = signer
l.modifyTime = time.Now().Unix()
writeJSON(w, http.StatusOK, &codeJSON{Code: api.CodeOK})
}
// handleCopy copies a link, and a folder's active descendants, into a
// folder. Only the name and passphrase of the copy come from the
// client; its key, content and descendants are the server's copies.
func (s *Server) handleCopy(w http.ResponseWriter, r *http.Request, u *User, d *drive) {
var req copyRequest
if !decodeRequest(w, r, &req) {
return
}
l := d.activeLink(r.PathValue("linkID"))
parent := d.activeFolder(req.TargetParentLinkID)
switch {
case l == nil:
writeError(w, http.StatusUnprocessableEntity, api.CodeNotExists, "File or folder not found")
return
case l.parentID == "":
writeError(w, http.StatusUnprocessableEntity, api.CodeInvalidRequirements, "protontest: the root can't be copied")
return
case req.TargetVolumeID != d.volumeID:
writeError(w, http.StatusUnprocessableEntity, api.CodeNotExists, "Volume not found")
return
case parent == nil:
writeError(w, http.StatusUnprocessableEntity, api.CodeNotExists, "Parent folder not found")
return
case d.within(parent.id, l.id):
writeError(w, http.StatusUnprocessableEntity, api.CodeInvalidRequirements, "protontest: a folder can't be copied into itself")
return
}
err := errors.Join(
checkHash(req.Hash),
checkSigner(u, "NameSignatureEmail", req.NameSignatureEmail),
checkMessages(map[string]string{"Name": req.Name, "NodePassphrase": req.NodePassphrase}),
)
if err != nil {
writeInvalid(w, err)
return
}
sig, signer, err := keySignature(u, l, req.NodePassphraseSignature, req.SignatureEmail)
if err != nil {
writeInvalid(w, err)
return
}
if other := d.conflict(parent.id, req.Hash, ""); other != nil {
writeConflict(w, other)
return
}
c := d.copyLink(l, parent.id, time.Now().Unix())
c.name, c.nameHash, c.nameSignatureEmail = req.Name, req.Hash, *req.NameSignatureEmail
c.nodePass = sealedPassphrase{armored: req.NodePassphrase, signature: sig}
c.signatureEmail = signer
s.lagLocked(c.id, nil)
writeJSON(w, http.StatusOK, &copyResponse{LinkID: c.id, Code: api.CodeOK})
}
// copyLink stores and returns a copy of l in folder parentID with its
// active revision and, for a folder, copies of its active descendants.
// Blocks are shared, as they are immutable.
func (d *drive) copyLink(l *link, parentID string, now int64) *link {
c := *l
c.id, c.parentID = randomID(), parentID
c.state, c.createTime, c.modifyTime, c.trashTime = linkStateActive, now, now, 0
c.revisions = nil
if r := l.activeRevision(); r != nil {
rc := *r
rc.id = randomID()
rc.blocks = slices.Clone(r.blocks)
c.revisions = []*revision{&rc}
}
var children []*link
for _, child := range d.links {
if child.parentID == l.id && child.state == linkStateActive {
children = append(children, child)
}
}
sort.Slice(children, func(i, j int) bool { return children[i].seq < children[j].seq })
d.addLink(&c)
for _, child := range children {
d.copyLink(child, c.id, now)
}
return &c
}
// handleTrash trashes up to 100 links, answering for each.
func (s *Server) handleTrash(w http.ResponseWriter, r *http.Request, u *User, d *drive) {
var req linksRequest
if !decodeRequest(w, r, &req) {
return
}
if len(req.LinkIDs) > maxLinksPerRequest {
writeError(w, http.StatusUnprocessableEntity, api.CodeInvalidValue, fmt.Sprintf("protontest: %d link IDs, at most %d allowed", len(req.LinkIDs), maxLinksPerRequest))
return
}
now := time.Now().Unix()
resp := &multiResponse{Code: api.CodeOKMany, Responses: []linkResultJSON{}}
for _, id := range req.LinkIDs {
result := linkResultJSON{LinkID: id, Response: codeJSON{Code: api.CodeOK}}
l := d.activeLink(id)
switch {
case l == nil:
result.Response = codeJSON{Code: api.CodeNotExists, Error: "File or folder not found"}
case l.parentID == "":
result.Response = codeJSON{Code: api.CodeInvalidRequirements, Error: "protontest: the root can't be trashed"}
default:
l.state, l.trashTime, l.modifyTime = linkStateTrashed, now, now
}
resp.Responses = append(resp.Responses, result)
}
writeJSON(w, http.StatusOK, resp)
}
// handleEmptyTrash deletes every trashed link with its descendants.
func (s *Server) handleEmptyTrash(w http.ResponseWriter, r *http.Request, u *User, d *drive) {
var doomed []string
for id, l := range d.links {
if d.inTrash(l) {
doomed = append(doomed, id)
}
}
for _, id := range doomed {
delete(d.links, id)
}
writeJSON(w, http.StatusOK, &codeJSON{Code: api.CodeOK})
}
// handleCheckAvailableHashes reports which name hashes are free in a
// folder, and the drafts holding the others unless they belong to one
// of the given ClientUIDs.
func (s *Server) handleCheckAvailableHashes(w http.ResponseWriter, r *http.Request, u *User, d *drive) {
var req checkHashesRequest
if !decodeRequest(w, r, &req) {
return
}
parent := d.activeFolder(r.PathValue("linkID"))
if parent == nil {
writeError(w, http.StatusUnprocessableEntity, api.CodeNotExists, "Folder not found")
return
}
resp := &availableHashesResponse{AvailableHashes: []string{}, PendingHashes: []pendingHashJSON{}, Code: api.CodeOK}
for _, hash := range req.Hashes {
other := d.conflict(parent.id, hash, "")
if other == nil {
resp.AvailableHashes = append(resp.AvailableHashes, hash)
continue
}
if other.state != linkStateDraft {
continue
}
for _, rev := range other.revisions {
if rev.state == revisionStateDraft && !slices.Contains(req.ClientUID, rev.clientUID) {
resp.PendingHashes = append(resp.PendingHashes, pendingHashJSON{
Hash: hash,
RevisionID: rev.id,
LinkID: other.id,
ClientUID: nullable(rev.clientUID),
})
}
}
}
writeJSON(w, http.StatusOK, resp)
}
Loaded 100 of 551 files, more files were not shown because too many files have changed in this diff. Show more