Compare commits

..
469 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
wjcandchaogeshuodiannao 4983d291e6 filelu: encode multipart init paths - fixes #10073
Co-authored-by: chaogeshuodiannao <chaogeshuodiannao@users.noreply.github.com>
2026-10-13 12:19:46 +01:00
Satvik Mishra d8640943b8 docs: fix broken links and anchors
Several links on rclone.org pointed at anchors or pages that don't exist:

- bisync: --color and --dry-run anchors, relative commands/rclone_check/ links
- ftp: on-the-fly link pointed at a local anchor instead of /docs/
- faq: --sftp-http-proxy linked to the ftp page instead of sftp
- index: Arvan Cloud and Liara provider config anchors
- rc, sftp: Hugo drops leading dashes from heading ids (#--rc-no-auth -> #rc-no-auth)
- onedrive: webdav #sharepoint -> #sharepoint-online
- webdav: relative commands/rclone_serve_webdav/ link
- s3: https:/// typo in the China Mobile Ecloud link
2026-10-13 12:17:17 +01:00
GingerAdonis 2aea045979 onedrive: show the size of directories in listings
Directory listings showed every directory with a size of -1, so
`rclone lsd` and `operations/list` couldn't say how big a directory
was, and finding out meant walking it with `rclone size`.

Microsoft Graph already returns each folder's `size` as the total size
of its contents, and the backend read it but dropped it. Directories
now carry that size, as they already carry the item count. Listing a
directory's parent is enough to get the size of all its subdirectories,
without listing the subdirectories themselves.

OneDrive updates a folder's size in the background, so for a few
minutes after a change inside it the size may be out of date.

Fixes #7502
2026-10-13 12:14:25 +01:00
phatlc 102ef16e4b lib/batcher: log when committing uploads has finished - fixes #5564
On shutdown the batcher logged "Committing uploads - please wait..."
but nothing once the commit had completed, so the log looked as if
rclone had stopped part way through. Log "Committing uploads - done"
when the pending batches have been committed.

This affects the backends which use the batcher, dropbox and
googlephotos.
2026-10-13 12:10:00 +01:00
fmterrors 927b6afd04 chore: fix some comments
Signed-off-by: fmterrors <fmterrors@outlook.com>
2026-10-13 12:08:20 +01:00
Ludovic Bondon ac9afc4f23 azureblob: fix sync at the root failing on soft deleted containers - fixes #10044
When container soft delete is enabled, listing the containers at the
root of the account returned the soft deleted ones too, because the
listing asked for them with Include Deleted. rclone treated them as
normal containers and then failed to list each of them with
"directory not found". With at least one error, sync refused to delete
anything on the destination ("not deleting files as there were IO
errors") and ended with exit code 3.

The listing now asks for live containers only, as it did before the
port to the new SDK in a131da2c3. Nothing else in the backend used the
deleted flag of the containers.
2026-10-13 12:06:54 +01:00
Alessio Margelli 6c45cde870 s3: add IT-MIL region to Scaleway 2026-10-13 12:03:32 +01:00
drakeo338 811a1de985 webdav: fix Nextcloud public share access by sending X-Requested-With - fixes #10039
Nextcloud public shares served from public.php/dav/files reject requests
that lack an X-Requested-With header. Send "XMLHttpRequest" for these
URLs when vendor is nextcloud, unless the user already set the header
with --webdav-headers.
2026-10-13 12:00:50 +01:00
kevin9327 f91c9d2d11 lib/encoder: fix decoding a trailing character after an escaped quote 2026-10-13 11:30:54 +01:00
nielash 81d7b6f299 bisync: check that listings match the paths after each run
This change adds a step to the bisync tests that checks that after a successful
run, the files named in each listing are exactly the files on that path. This
ensures that nothing is silently missing from the listings.
2026-10-13 10:34:18 +01:00
nielash 1fd50dae9c bisync: fix --conflict-resolve comparing a zero size or modtime
Before this change, when an existing file had changed on both paths, the
conflict winner was chosen using only the attributes that had changed on each
side. An attribute that hadn't changed was treated as zero, so --conflict-resolve
larger could pick the smaller file (for example, when one side only had its
modtime updated), and --conflict-resolve newer could fail to pick a winner when one
side's modtime hadn't changed.

After this change, the current size, modtime, and checksum (to the extent
enabled with compare settings) are now recorded for every changed file.
2026-10-13 10:34:18 +01:00
nielash 9682abe88e bisync: fix conflict winner missing from listings when Path2 wins
Before this change, when --conflict-resolve picked Path2 as the winner, the winning
file was copied to Path1 correctly but then removed from both listings while
updating them for the renamed (or deleted) Path1 copy. The next run saw the file
as new on both paths and had to check it again, and a change to it on one path
in that run was handled as if the file had never been synced. When Path1 won,
the listings happened to be updated in an order that avoided this.

This change fixes the issue by adding a guard to remove the file from the
listing only when dstNewName != dstOldName.

See #9939
2026-10-13 10:34:18 +01:00
Nick Craig-Wood 4f19e9cd6f docs: update RELEASE.md instructions for making releases and point releases
- add the release freeze rule, GUI update and security advisory steps
- list the extra software and remotes needed
- explain what make tag, make doc and make retag do
- check integration.rclone.org instead of running make test
- describe how to run the integration tests on the stable branch
- spell out the differences when making a point release
- note that stable branches are abandoned once a new release is out
2026-10-09 12:41:47 +01:00
Nick Craig-Wood d03990188f build: make startdev work out the next version on master or a stable branch
make startdev now starts the next minor version on master and the next
patch version on a v*-stable branch, so the same target works for
releases and point releases. It refuses to run on other branches or if
the current version hasn't been tagged yet, which catches running it
twice. make startstable is kept as an alias.
2026-10-09 12:41:47 +01:00
Nick Craig-Wood 16d355a046 Changelog updates from Version v1.75.2 2026-10-09 12:06:35 +01:00
Nick Craig-Wood d3db33c6b7 build: fix lint errors after golang.org/x/net update
golang.org/x/net v0.60.0 deprecates its HTTP/2 server and transport in
favour of net/http.

Make the h2c test use http.Transport with unencrypted HTTP/2. serve
restic --stdio still needs http2.Server to serve HTTP/2 on a single
connection as http.Server has no way of doing that.
2026-10-08 23:51:15 +01:00
Nick Craig-Wood e4a3adb94c build: update golang.org/x/net to v0.60.0 to fix multiple CVEs
Upgrade to v0.60.0 of golang.org/x/net in order to address:

- CVE-2026-78659: net/http: HTTP/2 server memory exhaustion due to Trailer headers
- CVE-2026-78669: net/http: excessive CPU consumption from repeated initial window changes
- CVE-2026-78663: net/http: double flow control refund on HTTP/2 server streams
- CVE-2026-97032: net/http: HTTP/2 server crash due to HPACK encoder race

This also updates golang.org/x/crypto, sync, sys, term and text to the
versions golang.org/x/net v0.60.0 requires.
2026-10-08 23:51:15 +01:00
Nick Craig-Wood f440573c3e rest: fix TestStripHeadersOnCrossHostRedirectFn failing on go1.26
Before go1.27 net/http compares hosts case sensitively when deciding
whether to keep Authorization on a redirect, so it dropped the header
itself on the localhost -> LOCALHOST redirect in the
SameHostDifferentCase test, before StripHeadersOnCrossHostRedirectFn
was consulted. The other headers still show the function treats the
hosts as the same.
2026-10-08 23:20:27 +01:00
Nick Craig-Wood c2ce9a0bfb serve s3: apply --metadata-max-objects to each user separately
The metadata of the objects of all users was kept in one store, so with
--auth-proxy or several --auth-key credentials one user storing the
metadata of many objects could make the server forget that of every
other user.

Each user (each access key) now has a store of their own.
2026-10-08 22:49:33 +01:00
Nick Craig-Wood b1657092d3 serve s3: apply the multipart upload limits to each user separately
--multipart-max-uploads and --multipart-streaming-buffer-total limited
the uploads of all users together, so with --auth-proxy or several
--auth-key credentials one user could use up the limits and stop every
other user uploading.

They now limit the uploads of each user (each access key) separately.
2026-10-08 22:49:33 +01:00
Nick Craig-Wood 5379bd5409 serve s3: fix multipart upload corruption if the backend write of an early part fails
A multipart part which arrives before the parts ahead of it is buffered
and acknowledged to the client, then written to the backend when its
turn comes. If that write failed the part was dropped, possibly with
some of it already written, but the upload carried on as if nothing
had happened, so it could be completed with the part missing, or with
a resend of the part appended to the partial data.

Such an upload is now failed, so the client starts it again.
2026-10-08 22:49:33 +01:00
Nick Craig-Wood c3b582ccdf serve s3: fix presigned URL being turned into a copy of any object GHSA-w3h7-jxxq-vfrp CVE-PENDING
The SigV4 verification in gofakes3 only checked the headers the
client listed as signed and never looked at the other headers which
arrived. A presigned PUT URL signs only the host header, so a holder
of one could add an unsigned x-amz-copy-source header and have the
server copy any object it can reach - in any bucket, and in any
remote when serving a cloud backend - into the object the URL names,
then read it back with the same URL. This affected both --auth-key
and --auth-proxy. AWS refuses such a request with AccessDenied, as
it requires every x-amz-* header to be signed.

This updates gofakes3 to a version which refuses requests carrying
an x-amz-* header not in their signed headers list with AccessDenied,
and adds tests that a presigned PUT URL can't be turned into a copy
in either auth mode. Real S3 clients always sign x-amz-* headers so
they are unaffected.
2026-10-08 22:49:33 +01:00
Nick Craig-Wood d120b5fe0b serve s3: test multipart buffer limits can't be bypassed by overflowing part sizes GHSA-qjw8-8hmp-c3m6
In v1.75 the multipart reorder buffer admitted the next part the stream
needed however big, adding its client-declared length to the running
total unchecked. Two concurrent requests for the next part declaring
huge lengths overflowed the total negative, after which out-of-order
parts were admitted past --multipart-streaming-buffer-limit.

The next part is now streamed straight to the backend without being
charged to the buffer, and a buffered part is only admitted if it fits
in what is left of the limits, so this can no longer happen. Add a
test of the attack against both the per-upload and server-wide limits.
2026-10-08 22:49:33 +01:00
Nick Craig-Wood da71a52714 serve s3: document memory use and bring the docs up to date
Add a section on the memory serve s3 uses and how to limit it, and
update the docs for the recent changes to multipart uploads, listings
and metadata.
2026-10-08 22:49:33 +01:00
Nick Craig-Wood 221398e2d0 serve s3: fix stale metadata being returned for objects changed elsewhere
serve s3 keeps the metadata of objects uploaded through it in memory.
If the object was then changed some other way - by another --auth-proxy
user sharing the backend, another server or directly on the backend -
serve s3 went on returning the old metadata, including the old
modification time, for the new object.

The size and modification time of the object are now stored with its
metadata, which is ignored if they no longer match.
2026-10-08 22:49:33 +01:00
Nick Craig-Wood 0edd92134e serve s3: fix uploads failing when the auth proxy expires the user's backend
With --auth-proxy the backend of each user is shut down once it has
been unused for 5 minutes. A request didn't count as using it, so a
long upload or download could have its backend shut down under it and
fail. A multipart upload used the backend it started on throughout, so
failed if the client paused for more than 5 minutes between parts, or
the auth proxy changed the user's secret.

Each request and multipart upload now holds the backend it uses until
it is finished with it. In-progress multipart uploads are aborted when
the server is shut down so their backends are released.
2026-10-08 22:49:33 +01:00
Nick Craig-Wood 32b08f0237 serve s3: stop auth proxy users listing or using each other's multipart uploads GHSA-35g6-9fjv-rmx5 CVE-PENDING
With --auth-proxy each access key ID may be mapped to a different
backend, but gofakes3 kept a single record of multipart uploads for
everyone. Any user could list the uploads of every other user with
ListMultipartUploads (including their upload IDs) and the parts of
them with ListParts.

With --disable-multipart-streaming the uploads are held by gofakes3,
whose upload IDs are sequential so easy to guess. Any user could
upload parts to, abort, or complete another user's upload - which
wrote it to the backend of the user completing it.

This updates gofakes3 to a version which can keep each user's uploads
private and uses the access key ID as the owner.
2026-10-08 22:49:33 +01:00
Nick Craig-Wood a16a901de7 serve s3: stop auth proxy users using each other's multipart uploads and metadata GHSA-35g6-9fjv-rmx5 CVE-PENDING
With --auth-proxy each access key ID may be mapped to a different
backend, but serve s3 found a streaming multipart upload by its upload
ID alone and stored object metadata by path alone.

A user who learnt another user's upload ID (for example from
ListMultipartUploads on a bucket of the same name) could upload parts
to, complete or abort that upload on the other user's backend. A user
with an object at the same bucket and key as another user was returned
the other user's metadata by HeadObject and GetObject and could
overwrite it.

Keep the uploads and metadata of each access key ID apart.
2026-10-08 22:49:33 +01:00
Nick Craig-Wood 2385e27cfd serve s3: keep the state remembered for each user in one place
Refactor the state serve s3 keeps between requests - in-flight
multipart uploads and object metadata - so that it is reached through
a tenant struct for the user making the request. This makes it
possible to keep users' state apart when they are serving different
backends.

There is only one tenant for now, so there is no change in behaviour.
2026-10-08 22:49:33 +01:00
Nick Craig-Wood 77ed639805 serve s3: add --metadata-max-objects to limit the metadata kept in memory
serve s3 keeps the metadata of the objects uploaded to it in memory,
with no limit on the number of objects, so memory use grew with every
object uploaded, and a client could use a lot by uploading many empty
objects with large metadata.

The new --metadata-max-objects (default 100000, 0 for no limit) limits
the number of objects whose metadata is kept, forgetting the metadata of
the least recently used first. As the metadata is only kept in memory
it is already lost when the server restarts; the modification time is
stored on the object itself so is never lost.
2026-10-08 22:49:33 +01:00
Nick Craig-Wood 0ce74ffaac serve s3: forget expired multipart uploads completely
When an abandoned multipart upload expired after --multipart-expiry,
gofakes3's record of it was left behind, so it stayed in memory and in
ListMultipartUploads until the server was restarted.

That record is now removed too.
2026-10-08 22:49:33 +01:00
Nick Craig-Wood f4c1268f33 serve s3: update gofakes3 to limit the memory requests can use
This picks up these fixes to serve s3 from gofakes3:

- XML request bodies (CompleteMultipartUpload, DeleteObjects) bigger
  than 10 MiB are rejected rather than read into memory.
- DeleteObjects requests with more than 1000 keys are rejected.
- CompleteMultipartUpload checks the upload exists before reading the
  request body.
- With --disable-multipart-streaming, parts are no longer allocated at
  their declared size before the data arrives.
- Browser POST uploads hold at most 1 MiB in memory and are limited to
  5 GB.

It also brings a SlowDown error, so the SlowDown replies serve s3 sends
when a part waits too long for buffer space, or too many multipart
uploads are in progress, now have status 503 as S3 does rather than
500.
2026-10-08 22:49:33 +01:00
Nick Craig-Wood d24568e3e6 serve s3: add --multipart-max-uploads to limit uploads in progress
There was no limit on the number of multipart uploads in progress, and
each one holds server resources until it completes or expires - once it
has data, a VFS upload and the buffers of the remote's own upload - so a
client could use unbounded memory by starting many uploads.

The new --multipart-max-uploads (default 1000, 0 for no limit) limits
the streamed multipart uploads in progress. Starting another fails with
SlowDown, which S3 clients retry, before anything is created on the
remote.

With --multipart-expiry 0 nothing ever cleans up abandoned uploads, so
they go on counting towards the limit until no new upload can be
started. A warning is logged at startup when both are set.
2026-10-08 22:49:33 +01:00
Nick Craig-Wood 7d2c16269d serve s3: start the upload to the backend on the first multipart part
Starting a multipart upload opened its VFS upload straight away, which
allocates a 100 KiB buffer (--streaming-upload-cutoff) and starts a
goroutine and a transfer, all held until the upload finished or was
expired after --multipart-expiry (default 24h). A client could make the
server hold about 100 KiB per tiny CreateMultipartUpload request.

The VFS upload is now only opened when the first part is written to it,
so an upload with no parts holds none of these.
2026-10-08 22:49:33 +01:00
Nick Craig-Wood 87d81fcb2e serve s3: add --multipart-streaming-buffer-total to limit memory across uploads
--multipart-streaming-buffer-limit only limits the memory buffered for
parts arriving out of order in each upload, so a client could use as
much memory as it liked by starting more uploads.

The new --multipart-streaming-buffer-total (default 1G, 0 for no limit)
limits the memory buffered by all uploads together. A part which would
take either over its limit waits as before. Since the part the stream
needs next is never buffered, every upload can still make progress.
2026-10-08 22:49:33 +01:00
Nick Craig-Wood 7773bb6487 serve s3: stream in-order multipart parts without buffering them
Every multipart part was buffered in memory in full before being written
to the backend, even the part the stream needed next. There is no limit
on part size, and several concurrent requests for the current part were
each admitted, so a client could make the server hold as much memory as
it sent, whatever --multipart-streaming-buffer-limit was set to.

The part the stream needs next is now written straight through to the
backend as it arrives, so only parts which arrive ahead of their turn
are buffered, and those always within the limit - a part bigger than
the limit waits until it is the next part rather than being admitted
when the buffer is empty. Further requests for the part being streamed
wait, holding no memory, and are then checked against it.

Since what reaches the backend can't be taken back, a part whose body
fails part way records how far it got, and the client's retry of the
part, which must have the same content, carries on from there. Such an
upload can't be completed without the rest of the part.

The body is read to its end, so that an error it only reports there -
as the Content-MD5 check does - fails the part rather than being
missed, and a body longer than declared is rejected. A part rejected
once it has been streamed leaves data which can be neither completed
nor replaced, so the whole upload is failed and cleaned up and the
client starts again.
2026-10-08 22:49:33 +01:00
Nick Craig-Wood 603e6238eb serve s3: time out multipart parts waiting for buffer space
A part waiting for room in the out-of-order buffer waited forever,
ignoring the request being cancelled, and a waiting part stopped its
upload being expired, so a client could hold requests, goroutines and
uploads open indefinitely.

The wait now ends when the request is cancelled, and after a minute the
part fails with SlowDown so the client retries it later.
2026-10-08 22:49:33 +01:00
Nick Craig-Wood 122b4e8df2 serve s3: don't buffer re-uploads of multipart parts already streamed
A part uploaded again after it had been streamed to the backend (a
client retrying after a timeout) was buffered in memory in full just to
compare it with the original, and the buffer limit didn't apply to it.

Such a re-upload is now hashed as it is received and compared with the
original without being buffered.
2026-10-08 22:49:33 +01:00
Nick Craig-Wood 4aa3c41aa1 serve s3: count buffered multipart parts in whole memory pages
The out-of-order multipart buffer was charged the declared length of
each part, but a buffered part occupies whole 1 MiB pool pages however
small it is. A client could therefore send up to 10000 one-byte parts
ahead of the part the stream needed and pin about 10 GiB of memory in a
single upload while --multipart-streaming-buffer-limit saw only 10 KB.

Each buffered part is now charged the whole pages it occupies.
2026-10-08 22:49:33 +01:00
Nick Craig-Wood 224a515d68 webdav: fix headers option leaking to other hosts on redirect GHSA-3rqf-7h69-gg5v CVE-PENDING
The headers set with the `headers` option were sent to any host the
server redirected to, as net/http only strips Authorization and Cookie
on a cross domain redirect and copies every other header onto each
hop. A server which could be made to redirect to another host would
therefore receive any credential carried in a custom header such as
X-Auth-Token.

Strip the configured headers from a redirect hop once the chain has
left the configured host (a different hostname, subdomain or port)
using rest.StripHeadersOnCrossHostRedirectFn. This logs at debug level
when it happens so a 401 from the redirect target can be diagnosed.
The `auth_redirect` option is unchanged as it is an explicit opt-in to
keep credentials across redirects.

The Authorization header set from `user`, `pass` and `bearer_token` is
deliberately left to net/http's policy, which strips it on a change of
domain but keeps it for a subdomain or a different port, as servers
which redirect to a subdomain rely on that today.
2026-10-08 22:49:33 +01:00
Nick Craig-Wood 204adfeedc docs: fix release verification instructions trusting unsigned hashes
The manual release verification steps checked the signature with gpg
--verify and then checked the binaries against the whole SHA256SUMS
file. gpg only verifies the clearsigned part, so an unsigned hash line
added outside it would be used by sha256sum -c or by rclone hashsum -C.

The instructions now extract the signed hashes with gpg --decrypt and
check the binaries against those only.

See: GHSA-w33h-3qgq-8mv8
2026-10-08 22:49:33 +01:00
Nick Craig-Wood 672bbf75ec selfupdate: fix hash check trusting unsigned data in the signed SHA256SUMS GHSA-w33h-3qgq-8mv8 CVE-PENDING
rclone selfupdate checked the PGP signature on the clearsigned block
of SHA256SUMS but then looked up the archive hash in the whole
downloaded file. Unsigned data can appear in that file without
invalidating the signature. A hash line placed before or after the
signed text was accepted, allowing whoever controlled the download to
install an arbitrary unsigned archive.

The hash is now only looked up in the signed text.

Affects stable (non --beta) selfupdate since v1.55.0. --beta updates
are not signature checked.
2026-10-08 22:49:33 +01:00
Nick Craig-Wood bc1050bc8a fshttp: drop --header values over HTTP after HTTPS downgrade GHSA-rrwf-2qr6-p832 CVE-PENDING
The transport strips the headers set with --header once a redirect
chain leaves the originally requested host, but the host comparison
ignores the URL scheme. A server which redirects https://host:8443/
to http://host:8443/ keeps the same host and port, so the headers
were added to the plaintext request and could be read by anyone on
the network path.

The redirect chain walk now also treats a hop which downgrades the
original HTTPS request to HTTP as leaving the host, so the headers are
removed from that hop and every one after it.

The transport also logs at debug level when it removes the headers,
for either reason, so that a resulting 401 from the redirect target
can be diagnosed.
2026-10-08 22:49:33 +01:00
Nick Craig-Wood 3739ad9c35 vfs: fix serve nfs path traversal by refusing unsafe file names GHSA-44pm-2q5p-fm63 CVE-PENDING
Dir.Create, Dir.Mkdir and Dir.Rename joined whatever name they were
given onto the directory's path. A name of "..", "." or one containing
a "/" isn't a child of the directory, and at the VFS root
path.Join("", "..") preserves the ".." so the new node's path pointed
outside the root. Backends which join the Fs root with the remote then
resolved it to a location above the directory the VFS was created on.

serve nfs was exploitable through this. The go-nfs library refuses a
name of exactly "." or ".." in MKDIR only, doesn't check the name in
CREATE, RENAME or SYMLINK, and never refuses a name containing a "/".

serve webdav was exploitable in the same way and has its own fix. The
other serve front-ends rely on their own or their protocol library's
path cleaning to keep such names out of the VFS.

Reject such names with EINVAL in the VFS itself, so that every
front-end and mount is protected regardless of what the layer above it
checks. This is the fix for serve nfs and defence in depth for the
others. The serve webdav check is kept as the front-end is the trust
boundary and gives the client a clear error. The source name of a
rename is only used to look the node up so it isn't checked.

VFS.Mkdir of the root, which previously went through Dir.Mkdir with an
empty name and added a nameless child node to the root, now makes the
root directory on the backend directly, still honouring --read-only.
serve webdav and serve ftp made directories through Dir.Mkdir rather
than VFS.Mkdir, so they are switched to VFS.Mkdir to keep "MKCOL /" and
"MKD /" working.
2026-10-08 22:49:33 +01:00
Nick Craig-Wood c266a63ced serve webdav: fix path traversal above the served directory GHSA-44pm-2q5p-fm63 CVE-PENDING
A request path with a ".." element, such as "MKCOL /..", was passed
straight through to the VFS. At the VFS root path.Join("", "..")
preserves the "..", so a directory node with the path ".." was added
under the root and every path built beneath it started "../". Backends
which join the Fs root with the remote before encoding it - such as
webdav, ftp, sftp, http and memory - resolved that "../" away, letting
an unauthenticated client create, overwrite, copy, move and delete
objects and directories outside the directory rclone was told to serve.
Moving a directory or file from outside the served tree into it also
made it readable.

Reject any request with a "." or ".." element in the request path, or
in the Destination header of COPY and MOVE, with 400 Bad Request. The
check is made once in ServeHTTP rather than in each webdav.FileSystem
method so that the client gets a consistent error. Rejected requests
are logged at INFO level like other requests.

The webdav handler strips --baseurl from the Destination as a string
prefix, so "/base../x" reaches the FileSystem as "../x". The Destination
is therefore checked after stripping the prefix in the same way.
2026-10-08 22:49:33 +01:00
Nick Craig-Wood c8dfd98b0c netstorage: stop a listing resume token moving signed requests to another host GHSA-xqmq-v4wj-rw5m CVE-PENDING
The resume start value returned by the NetStorage list API is joined
onto the endpoint URL as a URL reference to build the next listing
request. A value beginning with `//` is a network-path reference, so a
server (or an on-path attacker when `protocol = http`) which returns
`//other.example/path` could make rclone send the next request, signed
with the account secret, to a host other than the configured one.

Use rest.URLJoinRoot so the resume token can only change the path.
2026-10-08 22:49:33 +01:00
Nick Craig-Wood 4f28f4492c netstorage: stop a path starting with // moving the remote to another host GHSA-xqmq-v4wj-rw5m CVE-PENDING
The netstorage backend joins the path after `remote:` onto the
configured host URL according to the URL standard. A path beginning
with `//` is a network-path reference under that standard, so a path
such as `//other.example/dir/` replaced the configured host (and port
and userinfo) rather than being treated as a path. Every request,
including the initial stat, was then signed with the account secret and
sent to the substituted host. The signature covers the request path and
action but not the host, so the substituted host received signatures
valid for replay against the real NetStorage endpoint for the duration
of their validity.

Use rest.URLJoinRoot, which refuses any root that changes the scheme,
host or userinfo of the configured URL.
2026-10-08 22:49:33 +01:00
Nick Craig-Wood ecd821243f http: stop a path starting with // moving the remote to another host GHSA-xqmq-v4wj-rw5m CVE-PENDING
The http backend resolves the path after `remote:` against the
configured url according to the URL standard. A path beginning with
`//` is a network-path reference under that standard, so a path such as
`//other.example/dir/` replaced the configured host (and port and
userinfo) rather than being treated as a path. The configured headers
were then sent to the substituted host on the initial HEAD request and
every request after it.

Use rest.URLJoinRoot, which refuses any root carrying a scheme, host
or userinfo of its own, to join the path onto the configured url.
2026-10-08 22:49:33 +01:00
Nick Craig-Wood f33fa3872f fstests: check a root starting with // can't move the remote to another host GHSA-xqmq-v4wj-rw5m CVE-PENDING
Add an integration test which starts a listener on localhost, makes
the remote with roots naming that listener and checks that nothing
connects to it. The listener replies with junk so a backend which does
connect fails at once rather than retrying a closed connection.
2026-10-08 22:49:33 +01:00
Nick Craig-Wood 28562c8a79 rest: add URLJoinRoot to join a path onto a URL without changing its host GHSA-xqmq-v4wj-rw5m CVE-PENDING
rest.URLJoin resolves its path as a URL reference according to the URL
standard, so a path beginning with `//` is a network-path reference
which replaces the host (and port and userinfo) of the base URL. That
is often the wrong behaviour.

Add rest.URLJoinRoot which joins an unescaped root onto a base URL and
refuses any root which parses with a scheme, host or userinfo of its own.
2026-10-08 22:49:33 +01:00
Nick Craig-Wood 25707f2dc6 lib/http: require authentication for OPTIONS requests GHSA-gj73-fh6v-92fj CVE-PENDING
Before this change the basic, htpasswd and auth proxy authentication
middlewares let every OPTIONS request through without checking
credentials so that browser CORS preflights, which can't carry
credentials, would succeed.

However in serve webdav the OPTIONS request is answered by the WebDAV
handler whose Allow header depends on whether the path is an existing
file, an existing directory or doesn't exist, so an unauthenticated
client could discover the existence and type of any path on the served
remote even with --user/--pass or --htpasswd set.

After this change he authentication middlewares no longer special-case
OPTIONS. Instead the CORS middleware, which runs before authentication
and owns the --allow-origin setting, answers genuine CORS preflights
itself with an empty 200 carrying the CORS headers only when
--allow-origin is set.

All other OPTIONS requests need credentials like any other method, so
clients which probe with OPTIONS before authenticating will get a 401
challenge and must retry with credentials.
2026-10-08 22:49:33 +01:00
Nick Craig-Wood 3760671b36 pcloud: only accept pCloud hosts from the hostname in the OAuth callback
When pCloud redirects the browser back to rclone's local OAuth
callback server it adds a `hostname` parameter saying which regional
API host (api.pcloud.com or eapi.pcloud.com) the account lives on.
rclone used this for the token exchange and saved it in the config as
the host for all of the remote's later traffic, without checking it.

This only accepts a well formed hostname within pCloud's own domain,
lower cased, failing the config with an error otherwise, and adds a
unit test for the check. This is defence in depth for GHSA-hvpr-p4pv-4f46
2026-10-08 22:49:33 +01:00
Nick Craig-Wood a53fdac5ec pcloud: fix OAuth config accepting callbacks without state GHSA-hvpr-p4pv-4f46 CVE-PENDING
The local web server which receives the OAuth callback during
`rclone config` and `rclone authorize` checks that the `state`
parameter matches the random value rclone generated when starting the
flow. The pcloud backend opted out of this check with the
`StateBlankOK` option in lib/oauthutil, which made the local server
accept a callback with no state at all.

The callback URL (http://127.0.0.1:53682/) is fixed and well known,
so while a pcloud OAuth flow was in progress any web page open in the
user's browser could complete it with a single cross-site request
carrying an attacker's authorization code. This is a CSRF
(CWE-352) on the auth callback.

This commit removes the `StateBlankOK` option so that every callback
must carry the correct state and adds a test that the local auth
server rejects callbacks with a blank, missing or wrong state.

The option was added in May 2020 (#4210) because pCloud briefly
omitted the state parameter from its redirect. The callbacks recorded
five weeks later when EU region support was added show both
api.pcloud.com and eapi.pcloud.com returning `state=` again, so the
exemption has been unnecessary since then.

See: GHSA-hvpr-p4pv-4f46
2026-10-08 22:49:33 +01:00
Nick Craig-Wood 93ba2d40f0 vfs: stop --links objects called "...rclonelink" appearing as ".." in directory listings
When reading a directory with --links the VFS skipped entries called "."
and ".." before removing the ".rclonelink" suffix, so an object called
"...rclonelink" (or "..rclonelink" or ".rclonelink") produced a directory
entry called ".." (or "." or "") whose path resolved to the parent
directory. The same happened for names added back from the VFS cache.

The check is now done after the suffix is removed.

See GHSA-55qp-jrwr-x827
2026-10-08 22:49:33 +01:00
Nick Craig-Wood 4d1bd4c71d local: fix .rclonelink names escaping the root with --links GHSA-55qp-jrwr-x827 CVE-PENDING
In --links mode the local backend checked that a source name stayed inside
the destination root and only then removed the ".rclonelink" suffix from the
resulting OS path. A source entry called "...rclonelink" passed the check and
was then trimmed to "..", so the object addressed the parent of the
destination the user chose.

Writing the content of such an entry was already refused, as it is done
through an os.Root, but metadata was not: with --metadata rclone rewrote the
timestamps, ownership and extended attributes of the directory above the
destination from values supplied by the source.

The suffix is now removed from the remote name before it is joined to the
root, so the containment check applies to the name that is actually used. A
translated link must also leave a real native file name once the suffix is
removed.

A regular file whose name is exactly ".rclonelink" (or "..rclonelink" or
"...rclonelink") is not a valid translated link and is now skipped in
directory listings.
2026-10-08 22:49:33 +01:00
Nick Craig-Wood a7ef4c9064 local: fix directories named *.rclonelink being treated as links with --links
In --links mode the local backend removed the ".rclonelink" suffix from
directory names as well as file names when building the OS path. Only files
are ever translated into symlinks, so a directory called "sub.rclonelink"
addressed the sibling "sub" instead: listing it logged "Failed to read link
size", its metadata was read from the wrong place, and setting metadata on
it with --metadata failed or landed on the sibling.

Directories now use their name as is, whatever suffix it has, in the same
way DirSetModTime already does.
2026-10-08 22:49:33 +01:00
Nick Craig-Wood 30009599e4 b2: fix account token and SSE-C key leaking on redirects from --b2-download-url GHSA-gpw7-77cq-9f72 CVE-PENDING
Every file download sends the live account authorization token and,
when configured, the SSE-C customer key headers to the download host.
With --b2-download-url that host is a CDN or proxy the user does not
operate, and the http client followed its redirects with Go's default
policy:

- Go only strips Authorization when the redirect target is not the same
  host or a subdomain, and never looks at the scheme, so a same-host
  HTTPS to HTTP redirect replayed the full-capability B2 token in
  cleartext.
- Go has no idea that the X-Bz-Server-Side-Encryption-Customer-* headers
  carry the customer encryption key, so it forwarded them to any
  redirect target including a different host.

The B2 client now uses rest.StripHeadersOnCrossHostRedirectFn, so it
refuses to follow a redirect which downgrades HTTPS to HTTP and strips
the Authorization and SSE-C headers from any redirect which leaves the
host and port of the original request, matching the protection already
given to the S3, WebDAV and HTTP backends.

Fixes GHSA-gpw7-77cq-9f72
2026-10-08 22:49:31 +01:00
Nick Craig-Wood cae10713e8 rest: add StripHeadersOnCrossHostRedirectFn and use it in the s3 and http backends
The s3 and http backends each carried their own copy of the same
CheckRedirect policy. This moves it into lib/rest as
StripHeadersOnCrossHostRedirectFn, so that any backend which sends
credentials in headers to a host it does not control can use the same
policy. It logs at debug level when it strips headers so that a
resulting 401 from the redirect target can be diagnosed. A header
starting with "*" is matched without canonicalising, as that is how
SetHeader and Opts.ExtraHeaders send it.

This changes the s3 policy in two edge cases:

- It compared the raw URL host string, so a redirect to the same host
  in different case or with an explicit default port stripped the
  credentials. It now uses SameHost like the http backend, which treats
  those as the same host.
- The downgrade check compared each hop with the previous one. It now
  compares with the original request like the other backends, so a
  chain which starts on plaintext HTTP may return to it.

The behaviour tests move to lib/rest. The backend tests which check the
policy is wired into each client are kept.
2026-10-08 22:38:27 +01:00
Nick Craig-Wood dab9ecd9fc build: fix multiple CVEs by upgrading to go1.27.2
- CVE-2026-97032: net/http: HTTP/2 server crash due to HPACK encoder race
- CVE-2026-78659: net/http: HTTP/2 server memory exhaustion due to Trailer headers
- CVE-2026-97031: crypto/tls: reject malformed ECH outer extension references
- CVE-2026-94444: cmd/go: checksum bypass for golang.org/fips140
- CVE-2026-94447: cmd/go: checksum database bypass for golang.org/toolchain
- CVE-2026-94448: html/template: reset context tracking on consecutive template expressions
- CVE-2026-97030: html/template: recognize |yield| as regexp preceder keyword
- CVE-2026-94440: net/textproto, mime/multipart: memory limit bypass when parsing MIME headers
- CVE-2026-56866: net/http: HTTP/1 client connection desynchronization after CONNECT rejection
- CVE-2026-94439: net/http: HTTP/1 server connection desynchronization after 2xx CONNECT response
- CVE-2026-78669: net/http: excessive CPU consumption from repeated initial window changes
- CVE-2026-78660: net/http: HTTP/2 transport accepts malformed framing-related headers
- CVE-2026-56857: os: Root.Mkdir(All) can follow junctions out of the root on Windows
- CVE-2026-78667: net/http: lack of limit on size of parsed Range headers
- CVE-2026-78663: net/http: double flow control refund on HTTP/2 server streams

This also updates the go1.26 test job to go1.26.9 which contains the
same fixes.
2026-10-08 22:33:05 +01:00
Nick Craig-Wood 91ab88aeb1 gui: update embedded release to 1.1.13 2026-10-08 16:51:58 +01:00
Nick Craig-Wood 1646dbb949 Add 9 new contributors
- mygrexit
- Kaya
- somaz
- xiaozou-wine
- Roman Butor
- kaii9
- Stochastic
- James Truitt
- firatkaya
2026-10-08 16:51:58 +01:00
James Truitt 2debe0ae6a docs: fix formatting error in Google Drive docs 2026-10-08 17:16:24 +02:00
kaii9 9823661bb8 serve restic: fix repositories named data - fixes #6086 2026-10-08 15:18:14 +01:00
Stochastic 039b69adb8 webdav: retry Nextcloud chunk uploads individually - fixes #10005
Nextcloud chunk uploads currently return a retryable error when a
chunk PUT fails. The outer low-level retry then restarts the whole
upload, removing the temporary upload directory and sending previously
accepted chunks again.

Retry each chunk in place using the configured low-level retry budget,
rewinding its repeatable body before each attempt. Once that budget is
exhausted, prevent the outer retry from restarting the entire object.
2026-10-08 15:12:02 +01:00
Demiao Huang f01af78486 cmd: complete enum flag values from their choices - fixes #5847 2026-10-08 15:07:41 +01:00
kaii9 aa308e3d7c accounting: keep speed averaging active when transfers overlap 2026-10-08 14:58:24 +01:00
Roman Butor a68480093c s3: speed up multi-thread uploads by checksumming chunks in 1 MiB reads
WriteChunk checksums the chunk with io.Copy before sending it. For a
reader that implements neither WriterTo nor a memory backed Read - a
local file opened by a multi-thread copy - io.Copy falls back to its own
32 KiB buffer, so the source is read in 32 KiB pieces and the disk sees
thousands of requests per second where it should see hundreds.

Hand io.CopyBuffer a 1 MiB buffer instead. It is allocated for the
checksum pass rather than taken from the pool, where it could wait for
ever on a --max-buffer-memory below the page size. Readers that do
implement WriterTo, such as pool.RW, keep their fast path and don't get
a buffer.
2026-10-08 14:55:14 +01:00
Roman Butor c79a1d9d49 fstests: write chunks of the size the chunk writer asks for
FsOpenChunkWriter wrote three chunks of its own fixed sizes whatever
ChunkWriterInfo.ChunkSize the backend returned. That happened to be
the size the s3 backend uses by default, so the test passed there, but
a chunk writer which works out where a chunk goes from its number needs
every chunk but the last to be exactly the size it asked for - as a
multi-thread copy writes them - and the test could neither exercise nor
catch that.

Cut the file into chunks of info.ChunkSize, the last one short, still
written out of order. Where the backend lets the test set its chunk
size, ask for 5 MiB first so the file takes several chunks. Where it
doesn't and the chunks leave the file in fewer than three, start again
with a file of two full chunks and a short one, for chunks up to
128 MiB.
2026-10-08 14:45:45 +01:00
xiaozou-wine fa2b56200e onedrive: keep the delta token when a ChangeNotify poll fails - fixes #10009
A failed delta poll used to wipe the stored resume token: changeNotifyRunner
returned a named nextDeltaToken which stayed empty on error, and the caller
stored it unconditionally. Later polls then asked for /root/delta?token=
instead of resuming, so notifications stopped until the mount was restarted.

changeNotifyNextChange also called the API without the pacer, so a single
transient 503 was enough to trigger this.

While fixing that, walk @odata.nextLink as well. A change set spanning more
than one page returns nextLink without a deltaLink, and the token parsed from
the missing deltaLink was empty - changes were dropped even when no error
occurred.

Fixes #10009
2026-10-08 14:38:14 +01:00
somaz 4532a88838 mount: fix --daemon failing when --rc is enabled - fixes #9196
Before this change the parent process kept the remote control and
metrics servers it started in initConfig listening, so the daemon child
could not bind the same addresses and exited.

After this change the parent shuts those servers down just before
daemonizing, and the child starts them as usual.
2026-10-08 14:19:18 +01:00
Lev Devaev c5112be66b archive: read symlinks stored in zip and squashfs archives
A symlink in an archive was listed as an ordinary file holding the link
target by the zip backend, and dropped entirely by the squashfs one.

Symlinks are now skipped unless -l/--links is in use, as they are on the
other backends.  With that flag they are listed with the .rclonelink
suffix and read back as the path they point at, so they can be copied to
a destination which supports symlinks.

This changes what the zip backend lists by default: a symlink used to
appear as a small file whose contents were the link target, and is now
skipped with a NOTICE naming the flag.

Fixes #9569
2026-10-08 14:15:46 +01:00
Nick Craig-Wood b63e80251c docs: update pending CVE markers 2026-10-08 11:16:10 +01:00
Nick Craig-Wood fffb923e36 test_all: add integration tests for azureblob with hierarchical namespace 2026-10-08 10:53:05 +01:00
Nick Craig-Wood a49c1f8b30 walk: fix duplicate directories with --fast-list when directories are listed after their contents
If a backend's ListR returned a directory after objects inside it,
the directory was listed twice when using --max-depth or filters. This
happens with azureblob on storage accounts with a hierarchical
namespace.
2026-10-08 10:53:05 +01:00
Nick Craig-Wood 6a0f081380 fs: stop writing config for on the fly and environment variable remotes into the config file
When a backend saved config, for example a refreshed OAuth token, for
a remote which wasn't in the config file, a section with just that
value was created in the config file for environment variable remotes
and the config file was rewritten for on the fly remotes.

Config is now only saved if the remote is in the config file.
2026-10-08 10:53:05 +01:00
Nick Craig-Wood d597972297 azureblob: fix listing a file as a directory on hierarchical namespace accounts
Listing "dir/" on a storage account with a hierarchical namespace
returns the file "dir" if it exists, so a file was listed as a
directory containing itself.
2026-10-08 10:53:05 +01:00
Nick Craig-Wood 9b5252d7d9 docs: azureblob: document hierarchical namespace support 2026-10-08 10:53:05 +01:00
Nick Craig-Wood cb867c01f9 azureblob: skip blob tag tests on storage accounts with hierarchical namespace 2026-10-08 10:53:05 +01:00
Nick Craig-Wood 90f3e8e5c0 azureblob: fix NewObject on a directory returning "is not a regular file"
On storage accounts with a hierarchical namespace, directories can be
read as blobs so NewObject on a directory returned fs.ErrorNotAFile
rather than fs.ErrorObjectNotFound.
2026-10-08 10:53:05 +01:00
Nick Craig-Wood 93c2787dc8 azureblob: fix directories on storage accounts with hierarchical namespace - fixes #7047 #9577
Storage accounts with a hierarchical namespace (ADLS Gen2) have real
directories. Rclone could not remove these - with directory_markers it
failed with "InvalidUri" and without it rmdir silently did nothing.
Directories are also left behind when the last file in them is
deleted.

This adds the hns option which is read from the storage account if
not set and saved in the config file. If set, rclone:

- supports empty directories whatever the directory_markers setting
- creates directories with Mkdir
- removes directories with Rmdir
- doesn't create directory markers for parent directories on upload
2026-10-08 10:53:05 +01:00
Nick Craig-Wood 2e9c06c84d serve: fix --auth-proxy backends never being shut down after simultaneous logins
With --auth-proxy, when several logins with the same credentials
arrived together and none was in the cache yet, as when an FTP client
opens several connections at once, each one ran the auth proxy program
and took its own reference to the backend. Only one of them was
remembered, so the others were never given back and the backend was
never shut down.

Logins which arrive together now share one run of the auth proxy
program and one reference to the backend.
2026-10-08 10:36:00 +01:00
Nick Craig-Wood 0caf24af50 serve: fix --auth-proxy stopping VFS cache uploads after 5 minutes
With --auth-proxy the backend of each user is shut down once it has
been unused for 5 minutes. With --vfs-cache-mode writes or full, files
are uploaded to the backend after the transfer which wrote them has
finished, which didn't count as a use. So an upload which hadn't
finished 5 minutes after the user's last command (or disconnection for
serve sftp) was stopped, leaving the file in the VFS cache but not on
the backend.

The backend is now kept until it has no files open for write and
nothing in the VFS cache waiting to be uploaded.

This was introduced in v1.75.1 by

f425f8d46 serve: refactor VFS and proxy handling into Provider
2026-10-08 10:36:00 +01:00
Nick Craig-Wood 4fa2a5b02c vfs: add Busy to tell if a VFS still has data to write
Busy returns true if the VFS has files open for write or files in the
VFS cache which are open or waiting to be uploaded. It is for code
which wants to shut a VFS down only once that wouldn't lose any work.
2026-10-08 10:36:00 +01:00
Nick Craig-Wood 49d8566ec5 lib/cache: add SetCanExpire to keep entries which are still busy
An entry is expired when it hasn't been used for a while, but the user
of the cache may know it is still doing something in the background.
SetCanExpire sets a function which is asked before expiring an entry
and can keep it in the cache until it is ready to go.

The function is called without the cache locked as it may take a
while, so an entry which is used in the meantime isn't expired.
2026-10-08 10:36:00 +01:00
Nick Craig-Wood 7e6d6fa838 serve webdav: fix transfers failing after 5 minutes with --auth-proxy
With --auth-proxy the backend of each user is shut down once it has
been unused for 5 minutes. Only the start of each request counted as a
use, so an upload or download lasting longer than that had its backend
shut down under it and failed.

Each request now holds the backend it uses until it has been served.

This was introduced in v1.75.1 by

f425f8d46 serve: refactor VFS and proxy handling into Provider
2026-10-08 10:36:00 +01:00
Nick Craig-Wood 8457da3c8a serve http: fix downloads failing after 5 minutes with --auth-proxy
With --auth-proxy the backend of each user is shut down once it has
been unused for 5 minutes. Only the start of each request counted as a
use, so a download lasting longer than that had its backend shut down
under it and failed.

Each request now holds the backend it uses until it has been served.

This was introduced in v1.75.1 by

f425f8d46 serve: refactor VFS and proxy handling into Provider
2026-10-08 10:36:00 +01:00
Nick Craig-Wood ee0a70f875 serve ftp: fix transfers failing after 5 minutes with --auth-proxy
With --auth-proxy the backend of each user is shut down once it has
been unused for 5 minutes. Only the start of each FTP command counted
as a use, so an upload or download lasting longer than that had its
backend shut down under it and failed with "context canceled".

Each FTP command now holds the backend it uses until it has finished,
which for a download is when the file is closed.

This was introduced in v1.75.1 by

f425f8d46 serve: refactor VFS and proxy handling into Provider
2026-10-08 10:36:00 +01:00
Kayaandfiratkaya 408cc55171 Add new backend dosya for dosya.dev cloud storage
Add a new backend for dosya.dev, a cloud file storage, sharing, and
synchronization platform with workspace-based multi-tenancy.

Features:
- Server-side copy, move, rename (files and directories)
- Purge (recursive directory delete)
- Recursive listing (ListR) for --fast-list
- Multipart upload for large files (>10MB)
- Public link sharing via rclone link
- About (workspace storage quota)
- MIME type detection
- Empty directory support
- Workspace-based multi-tenancy (one remote per workspace)
- Download via presigned R2 URLs with Range header support

Co-authored-by: firatkaya <firat@netiket.com.tr>
2026-10-07 12:16:42 +01:00
mygrexit aeb98f9316 s3: add RelAix.Storage provider
RelAix.Storage is S3 compatible object storage from RelAix Networks in
Aachen, Germany, with two locations (Aachen-1 and Aachen-2).
2026-10-06 12:23:02 +01:00
Nick Craig-Wood d545615c16 sftp: fix failed uploads leaving disk space in use on the server - fixes #10033
When an upload failed during the transfer, the partial file was
removed but its handle was never closed. The server kept the deleted
file open, so its space stayed allocated until the pooled connection
was closed, which in rcd, mount or serve could be until rclone exited.

This closes the handle before removing the partial file.
2026-10-06 10:12:50 +01:00
Nick Craig-Wood 169a0c6118 docs: note in AGENTS.md that core tests run against all backends
Tests in fs/operations, fs/sync, cmd/bisync, cmd/gitannex and vfs are
run by the integration tests against every backend, so they need to
cope with backends with limited functionality, ideally by skipping on
the Fs.Features() flags.
2026-10-05 17:44:55 +01:00
Nick Craig-Wood ed92e4446e filescom: document that names with / or \ in can't be stored and ignore their tests
Files.com now treats the replacement characters rclone uses for / and
\ in file names (/ and \) as if they were the originals, and the
names . and .. as if they were . and .., and rejects them all with
"Invalid path". There is no other way of encoding these names, so
document the restriction and ignore the integration tests for them.
2026-10-05 17:39:34 +01:00
Nick Craig-Wood 9f0b40c7d2 hidrive: fix truncated file left behind when an upload hits --max-transfer
When an upload was stopped part way through by --max-transfer, HiDrive
kept the part of the file it had received, leaving a truncated file at
the destination, or replacing an existing file with a truncated one.

This was introduced in e8f421d28 which accounted the buffered start of
each upload as it was sent rather than as it was read, so the transfer
limit could abort the request which creates the file half way through.

Account the requests which create or replace a file in one go as they
are buffered once more, so the limit is hit before anything is sent.
The chunks of larger uploads are still accounted as they are sent.
2026-10-05 17:39:34 +01:00
Nick Craig-Wood f217c8a2aa webdav: fix "XML syntax error" on paths which don't exist with ownCloud 10.16
ownCloud 10.16 answers a PROPFIND for a path which doesn't exist with
a 207 Multi-Status response, rather than a 404, with a body which
starts a multistatus document then appends a Sabre NotFound error to
it. This isn't valid XML so rclone failed with

    read metadata failed: XML syntax error on line 2: expected attribute name in element

whenever it was pointed at a directory which didn't exist yet.

Detect the NotFound error in the response and treat it as a 404.
2026-10-05 17:39:34 +01:00
Nick Craig-Wood be8b238033 test_all: ignore index and files-from tests on cloudinary as search is eventually consistent
These tests look up objects straight after uploading them, which
fails with "object not found" on cloudinary until its search API
catches up.
2026-10-05 17:39:34 +01:00
Nick Craig-Wood c8a03f1f8e test_all: ignore index tests which download HTML on koofr and opendrive
Koofr refuses to download the HTML listings written by the index tests
with "FileBlocked: File download restricted due to possible dangerous
content" and OpenDrive refuses with 403 Forbidden, so the tests can't
read back what they wrote. The index tests which write other formats
still run.
2026-10-05 17:39:34 +01:00
Nick Craig-Wood 475997b6de chunker: fix panic listing a directory after an interrupted upload with meta_format none
With meta_format = none, listing a directory which contained the
temporary chunks of an interrupted upload, but no completed chunks for
that file, panicked with "invalid chunked object".

The listing made a placeholder object for the file on seeing a
temporary chunk, which then had no chunks in it when it was validated.
Only make the placeholder on seeing a data chunk, so files which have
nothing but temporary chunks are ignored as they are when metadata is
in use.
2026-10-05 17:39:34 +01:00
Nick Craig-Wood e14337ebef operations: fix index tests failing in the integration tests
- Only check the MIME type of the listings on backends which declare
  both ReadMimeType and WriteMimeType, and ignore its parameters, as
  many backends report a MIME type they chose themselves or drop the
  charset.
- Write the test file before turning --dry-run on. Chunker finishes
  an upload with operations.Move, which honours --dry-run, so it left
  temporary chunks behind which stopped the test directory being
  removed and made the tests following fail.
- Don't check the transfer and delete counts on chunker, as moving
  its chunks into place is counted in the same stats.
- Don't expect a new file to change the time of the directories above
  it on backends which can't set modification times, as it may get the
  same time as the files already there.
2026-10-05 17:39:34 +01:00
Nick Craig-Wood 3b9f0a05b5 azureblob: use the released Azure SDK for Apache Arrow listing
The Blob Listing with Apache Arrow feature is now public and shipped
in azblob v1.8.1, so the temporary backend/azureblob/arrowlist package
which implemented it on top of the previous SDK has been removed and
the backend now uses the SDK's own listing pager with ResponseFormat
set to Arrow.

As the SDK client handles every credential type this also makes Arrow
and parallel listing work with connection_string auth, and the
use_arrow_list and list_parallelism options are no longer hidden.
2026-10-05 11:42:24 +01:00
Nick Craig-Wood e3553ef8b3 Add 8 new contributors
- interested.tortoise
- hsdfat
- Dirk Petersen
- TastyHeadphones
- Neil Cawse
- Lev Devaev
- GhostCoder6969
- bounty-agent
2026-10-05 11:42:24 +01:00
GhostCoder6969andbounty-agent 7ebef790b4 docs: fix typo in speed test help text
Co-authored-by: bounty-agent <bounty@example.com>
2026-10-04 09:14:02 +02:00
Nick Craig-Wood a82c965ba1 build: move beta.downloads.org site to cloudflare 2026-10-03 16:27:30 +01:00
Nick Craig-Wood 9e27583e80 build: index the downloads site after uploading a new release 2026-10-02 15:44:25 +01:00
Nick Craig-Wood 67afe55a83 archive: fix a squashfs path to a single file listing its whole directory
Pointing the archive backend at a file inside a squashfs image, for
example `rclone cat :archive:image.sqfs/dir/file.txt`, listed every
file in the directory containing it instead of just that file. The zip
archiver already behaved correctly.

The squashfs archiver now remembers the file the root points at and
only exposes that one, as zip does. Its Root() includes the file so
that the fs cache does not hand back the Fs for the whole directory in
its place.
2026-10-02 15:44:25 +01:00
Nick Craig-Wood 0be5c88394 local: limit the size of symlink targets read from .rclonelink files
With -l/--links a .rclonelink object is buffered in memory to become
the target of a symlink. The read was unbounded, so a hostile or
corrupt source serving a large .rclonelink object made rclone use that
much memory and then log the whole body in the resulting error.

A symlink target can never be longer than a path, so the read now stops
at 128 KiB and anything longer is refused without retrying.
2026-10-02 15:44:25 +01:00
Lev Devaev 0b8e9c4ccd ncdu: show progress and the item count when deleting a selection
Deleting a selection finished with "Successfully deleted all items!",
which claimed more than had happened - only the selected entries were
removed.  It now names the number of items deleted.

The screen was not redrawn until the whole selection had been deleted,
which made the UI look hung, so a progress box is drawn before each
deletion.

Fixes #9516
2026-09-30 17:57:47 +01:00
Rohit Behera d8794f8192 fs: fix durations like 1d12h being rejected
The docs describe a duration as a sequence of numbers each with a unit
suffix, including d, w, M and y, but a sequence that used one of those
units, such as --max-age 1d12h, failed with a confusing error about
parsing it as a date. Only a single number with one of them, like 1.5d,
or a sequence of the time.ParseDuration units was accepted.

Parse such sequences before falling back to dates, rejecting ones too
long to represent.
2026-09-30 17:55:19 +01:00
Morax 49158b2d9f bisync: make --max-delete aware of --track-renames
Add an opt-in preflight that excludes only guaranteed tracked renames
from the max-delete count. Reuse normal bisync listing metadata,
validate strategy and flag conflicts early, and skip preflight work
when --force bypasses the safety check.

The max_delete_track_renames scenario uses the default hash
track-renames strategy, which requires a hash common to both paths.
Skip it on remote combinations without one, such as crypt, instead of
failing the integration tests.

Fixes #8685
2026-09-30 17:53:20 +01:00
Morax 015f9cd127 sync: export --track-renames matching logic
Move strategy parsing, capability checks, and matcher behavior into a
reusable package without changing sync behavior. Add a lightweight
candidate API so callers can count guaranteed matches without
retaining filesystem objects.
2026-09-30 17:53:20 +01:00
Neil Cawse 9380daed0d vfs: stop setting a file's modtime from discarding its cached data
Setting the modtime of a closed file refreshed the cache item's
fingerprint before the new modtime had been applied to the remote
object. The next open then saw a fingerprint mismatch, logged "removed
cache file as stale (remote is different)" and downloaded the whole
file again, even though only the modtime had changed.

Refresh the cache fingerprint again once the remote modtime has been
set.
2026-09-30 17:51:49 +01:00
Neil Cawse e0fb01084d vfs: fix reads returning zeros after a file changes during handle caching
When a file was reopened within the --vfs-handle-caching grace period
(default 5s) after its remote fingerprint changed - for example after a
touch or any other modtime change - _checkObject removed the stale
cache file and open recreated an empty one, but nothing sized it. The
next GetSize stat'd the empty file and set the item size to 0, so
ReadAt's _ensure clamped the request to nothing and skipped the
download, then the size check zero-extended the file and returned the
zeros. Reads through a mount returned the correct length but zeros for
the first read (128 KiB through FUSE).

This is easy to hit with git on a --vfs-cache-mode full mount: git
freshens pack files with utime, and a concurrent reader then sees
"not a GIT packfile".

Size the recreated cache file from the object before opening it, as a
normal open does.
2026-09-30 17:51:49 +01:00
TastyHeadphones 80e462d48c oauthutil: fix Renew.Shutdown nil timer panic and race
Shutdown was calling expiryTimer.Stop without holding ts.mu, so it could
panic if OnExpiry had not created the timer yet, and raced the write in
OnExpiry. Hold the lock and skip Stop when the timer is still nil.

Fixes #9980
2026-09-30 17:34:51 +01:00
Dirk Petersen 6ed7b9d775 s3: report the real storage class when an object needs restoring
Reading an object in an archive storage class failed with "Object in
GLACIER, restore first" even when the object was in DEEP_ARCHIVE or in
an Intelligent-Tiering archive access tier.

Use the storage class and access tier from the InvalidObjectState
error. The storage class is optional in that error, so fall back to
the storage class from the listing or HEAD, and then to GLACIER as
before.
2026-09-30 17:33:04 +01:00
Rohit Behera e497464b60 docs: fix the expanded --bwlimit timetable example
It was missing a space between two entries, so it didn't parse.
2026-09-30 17:30:06 +01:00
Rohit Behera 124f3bd830 fs: fix --bwlimit timetables written out of order using the wrong limit
Until the first time slot of the week, the timetable used its last entry
as the limit carried over from the week before. That is only the latest
slot of the week when the entries are in order, so a timetable written
weekend first, like "Sat-00:00,off Mon-00:00,1M", limited Sunday to 1M
instead of leaving it unlimited.

Carry over the latest time slot of the week instead.
2026-09-30 17:30:06 +01:00
Dirk Petersen e5eaf414f0 s3: fix storage class missing from --s3-versions listings
When listing with --s3-versions or --s3-version-at the storage class
of each object was dropped, so it read as STANDARD unless the object's
metadata was fetched separately. This meant backend restore skipped
objects in GLACIER or DEEP_ARCHIVE with "Not GLACIER or DEEP_ARCHIVE
or INTELLIGENT_TIERING storage class", and lsf --format T showed the
wrong tier.

This happened because ObjectVersion.StorageClass has a different type
from Object.StorageClass so the generated setFrom helper does not copy
it. Convert it explicitly after the setFrom call.
2026-09-30 17:23:07 +01:00
hsdfat 3cdf855547 docker: fix files uploaded with the wrong mime type - fixes #6384
The Docker image had no /etc/mime.types, so MIME types came only from
Go's built-in table plus rclone's small extra list, and many
extensions uploaded as application/octet-stream.

This installs Alpine's mailcap package to fix the problem.
2026-09-30 17:16:47 +01:00
interested.tortoise 9daa4ccfeb s3: Add the md5 value to debug message for multipart chunk for S3
Adding md5 information to debug message for multipart upload as this is useful when testing.
2026-09-30 17:13:04 +01:00
Nick Craig-Wood 8cd43746a3 selfupdate: fix --version X.Y depending on ./ in the download site links
To find the latest patch release of a minor version, for example
`rclone selfupdate --version 1.75`, selfupdate searched the listing of
downloads.rclone.org for href="./vX.Y.Z/". The leading ./ is a detail
of how Caddy's file server writes its links. A listing which linked to
the same directories as "vX.Y.Z/", which is an equally valid relative
URL, made selfupdate fail with "could not find the minor release".

This makes the ./ optional in the pattern, so selfupdate no longer
depends on which program wrote the listing, and takes the version from
a capture group rather than from fixed offsets into the match.

The listings written by rclone index now include the ./ for the
benefit of rclone versions without this fix, so this is to remove the
dependency for the future.
2026-09-30 12:14:28 +01:00
Nick Craig-Wood 89521d875d lib/http: prefix links in directory listings with ./ so selfupdate works
downloads.rclone.org is about to be served from static listings
written by rclone index instead of by Caddy's file server. Released
versions of rclone selfupdate find the latest patch release of a minor
version by searching that listing for href="./vX.Y.Z/", as written by
Caddy. rclone's listings linked to "vX.Y.Z/" without the ./ so `rclone
selfupdate --version X.Y` would have failed with "could not find the
minor release" for every rclone already installed.

Caddy prefixes every link with ./ so that a name with a colon in its
first path segment is not read as an absolute URL with a scheme (RFC
3986 section 4.2). rclone was already safe from that as url.URL.String
adds the ./ but only to names which need it, so links to plain names
had no prefix.

This prefixes all the links with ./ as Caddy does. It changes the
output of serve http, serve webdav and the rc server as well as rclone
index, since they share the code. The links resolve identically, and
it keeps the listings consistent with each other.

Now every URL has the prefix, the caddy.json template no longer needs
to add it.
2026-09-30 12:12:33 +01:00
Nick Craig-Wood 666d67f0eb index: add partial runs which only re-index changed directories
After a sync which changed a known set of files there is no need to
walk the whole remote. --changed PATH, --changed-from FILE and
--changed-combined FILE tell rclone index what changed, and it
re-indexes only the directories containing those paths and their
ancestors, each with one non-recursive listing.
2026-09-29 18:21:23 +01:00
Nick Craig-Wood 72a6d36cb2 serve http,webdav: redesign the directory listing page
The listing now shows the folder path with clickable breadcrumbs
above a card containing the entries:

- A summary of the directories, files and total size
- The directory and file counts are toggles
- The search box sits beside the Name heading (focussable with /)
- Sizes right aligned, empty columns are gone and the icons are simpler.
- The colours are CSS variables

The template data is unchanged so custom templates still work, and
the zip download links and ?sort= parameters work as before.
2026-09-29 18:21:23 +01:00
Nick Craig-Wood 1897074971 index: add a command to write static directory listings into a remote
This makes directory listings for buckets and other remotes served as
static websites, for example S3 website endpoints or R2 behind
Cloudflare, so they can be browsed without directory listing support
on the host.
2026-09-29 18:21:23 +01:00
Nick Craig-Wood ae9a50cacd operations: add Index to write static directory listings into a remote
Index writes a directory listing (e.g. index.html) into every
directory of a remote so it can be browsed when served as a static
website. It is also available over the rc as operations/index.

It works like sync. It walks the remote once, renders the listings in
memory and compares them with the existing ones by size and hash from
the listing, or by reading them where the backend has no hash.
Listings in directories which contain nothing else are deleted on
backends which can't have empty directories.

Listings can be written in the serve http HTML format, the lsjson
format, Caddy's browse JSON format, or from a user template. A second
rule set (--index-include and friends) controls which directories get
listings, --dir-time controls the time shown for directories and
--no-modtime avoids reading modification times altogether.
2026-09-29 18:21:23 +01:00
Nick Craig-Wood dcd5cf0640 lib/http: add static rendering support to the directory listing
- .Static hides the "up" link at the root
- .SetLinkIndex makes directory links point at an index doc
- .Render writes the listing to an io.Writer
- .Path, .IsRoot, .UpLink, .NumDirs, .NumFiles, .TotalSize and .MimeType.
- Sorting is now stable so the rendered output is deterministic
2026-09-29 18:21:23 +01:00
Nick Craig-Wood 4a3ce883b1 serve http,webdav: sort directory listings in the browser
Clicking the Name, Size or Modified heading now sorts the listing in
the browser and clicking again reverses it, rather than reloading the
page with ?sort= and ?order= parameters. Names sort naturally so
v1.9.0 comes before v1.10.0, and directories stay first when sorting
by name.

The ?sort= and ?order= parameters still work for the initial order and
are shown in the heading, so existing links and custom templates are
unaffected.
2026-09-29 18:21:23 +01:00
Nick Craig-Wood c81298473b Add 4 new contributors
- Mattias Michaux
- jxj
- Harsh Raj Singhania
- Roland
2026-09-29 18:21:23 +01:00
Nick Craig-Wood 9dc8b71ae9 serve s3: fix TestEtagHashAuto on Windows
The temporary directory contains a drive letter colon so it needs
quoting in the crypt connection string, otherwise the password
parameter is dropped.
2026-09-26 14:39:07 +01:00
Nick Craig-Wood 881cedd348 build: fix lint errors from golangci-lint v2.14.0
The newer revive flags an exported function returning an unexported
type and a redundant type in a var declaration.
2026-09-26 12:51:07 +01:00
Nick Craig-Wood 220c65f81d build: fix TestS3Minio by switching to pgsty/minio as quay.io image is gone
MinIO no longer publishes images on quay.io (nor Docker Hub or ghcr.io)
so pulling quay.io/minio/minio returns "unauthorized". pgsty/minio is a
maintained community fork with the same entrypoint layout.
2026-09-26 12:50:45 +01:00
nielash 94e319d2fa fs: retry "network is unreachable" and "network is down" errors
ENETUNREACH and ENETDOWN were not in the list of retriable errors on
non-Windows platforms, so a single failed connection aborted the
transfer instead of being retried as a low level retry. The Windows
list already includes the equivalent WSAENETUNREACH and WSAENETDOWN.

This is easy to hit on a host with IPv6 enabled but no IPv6 route: if
the A lookup fails transiently while the AAAA lookup succeeds, the only
address to dial is IPv6 and the connect fails with ENETUNREACH. A retry
resolves again and normally succeeds.
2026-09-25 17:11:08 +01:00
Roland 60bdc6d454 fs: fix bandwidth limits below 1 KiB being multiplied by 1024 - fixes #9958
BwPair.String printed a sub-KiB bandwidth as a bare number, and Set
reads a bare number as KiB, so a rate under 1 KiB grew by a factor of
1024 whenever it went through a string. rc core/bwlimit reports the
current rate in that format and accepts it back, so reading the limit
and setting it again raised it.

63c4fef27 fixed the same corruption for config values by suffixing bare
numbers with B inside Option.String. Move that into a SizeSuffix method
and use it from BwPair.String as well.
2026-09-25 17:09:52 +01:00
Harsh Raj Singhania 14a1359c96 serve webdav: escape filename in zip download Content-Disposition header
Use mime.FormatMediaType instead of unescaped string concatenation so a
directory name containing a double quote cannot inject extra disposition
parameters.

Fixes #9962
2026-09-25 17:09:33 +01:00
Harsh Raj Singhania 661484f36c serve http: escape filename in zip download Content-Disposition header
Use mime.FormatMediaType instead of unescaped string concatenation so a
directory name containing a double quote cannot inject extra disposition
parameters.

Fixes #9962
2026-09-25 17:09:33 +01:00
Nick Craig-Wood ce5351c52e serve s3: fix CopyObject of a missing object not returning NoSuchKey
Copying an object onto itself, as clients do to replace its metadata,
reported success when the object didn't exist, and copying a missing
object to a different key failed with an internal error. Both now fail
with NoSuchKey as S3 does.
2026-09-24 10:36:10 +01:00
Nick Craig-Wood 746eac73ef serve s3: fix --etag-hash auto crashing or using the wrong hash with --auth-proxy
With --etag-hash auto the hash for the ETags was chosen once from the
remote serve s3 was started with. With --auth-proxy there is no such
remote so serve s3 crashed on startup, and when started by the rc it
was the wrong remote, so users whose backends lacked that hash got no
ETags.

The hash is now chosen from the backend of the user making each
request.
2026-09-24 10:36:06 +01:00
Nick Craig-Wood c350f8ebbf serve s3: forget the metadata of deleted objects
The metadata of every object uploaded was kept in memory for as long as
the server ran, even after the object was deleted, so it grew without
limit and the metadata of a deleted object reappeared on any object
later created at the same key other than through serve s3.

Deleting an object now forgets its metadata.
2026-09-24 10:35:30 +01:00
Nick Craig-Wood 04697cc02a serve s3: list objects lazily so paging a deep hierarchy is fast - fixes #9855
Listing a prefix with no delimiter walked the entire subtree below it into
memory and only then sliced out the requested page, so every page of a
listing cost a full traversal of the tree.

Walk the tree lazily instead, stopping as soon as the page is full and
skipping the subtrees an earlier page already returned. A page now costs a
number of directory reads proportional to the keys it returns rather than to
the size of the subtree, and ETags are computed only for the objects that
are actually returned.

Entries are emitted in the order their keys have in a flat keyspace, with a
directory sorting as if it carried its trailing slash, so that "a.txt" comes
before "a/b" as it does in a real S3 bucket. Without this a resumed listing
would silently skip keys across a page boundary.
2026-09-24 10:35:30 +01:00
Nick Craig-Wood c62aa2adc9 vfs: fix a VFS being reused while it is being shut down
When the last user of a VFS shut it down at the same time as a new
user of the same remote asked for one, the new user could be given the
VFS being shut down, with its cache and background tasks stopped.

Only a VFS which is still in use is now reused, otherwise a new one is
made.
2026-09-24 10:32:27 +01:00
Nick Craig-Wood 1dab0f3abd vfs: add Hold to keep a VFS from being shut down while in use
A VFS is shut down when the last of the references to it from New is
given back with Shutdown. Hold takes another reference, for code using
a VFS it didn't create itself, which may otherwise be shut down under
it.
2026-09-24 10:32:27 +01:00
Nick Craig-Wood 3ac301eff1 onedrive: make --onedrive-delta quicker when not listing from the drive root
Rclone always asked for the delta listing of the whole drive and threw
away the items which weren't under the directory being listed.

The delta API now works on any folder, so ask for the delta listing of
the directory being listed instead, falling back to listing from the
root of the drive for drives which only support delta there.
2026-09-23 16:04:38 +01:00
Nick Craig-Wood ee9c228012 operations: fix TestMultithreadCopy leaving files behind when multipart upload is skipped
When a backend rejected the multipart upload as too small, the subtest
skipped without removing its local source file, so the following
upload subtests failed their local listing checks.
2026-09-23 16:00:59 +01:00
Nick Craig-Wood 0c52b183d0 internxt: fix server-side directory move failing after a gateway timeout
Moving a directory can take longer than the API gateway allows, so the
request fails with a 520 or 502 error even though the move completes.
The retry then failed with "Folder ... was already moved to that
location (status 409)".

Treat that error as success.
2026-09-23 16:00:46 +01:00
Nick Craig-Wood 7ff5da8517 internxt: fix "directory not empty" and stale directories after moves and deletes
The Internxt API serves listings from read replicas which lag behind
writes, so for a short while after files or directories are moved or
deleted they can still be listed in their old location.

This caused removing a directory which had just been emptied to fail
with "directory not empty" (eg when moving a directory without server
side directory moves or purging a directory) and a directory which had
just been moved to be found in its old location.

Remember the directories this process has moved or deleted and ignore
directory and file entries which contradict that when listing, finding
directories and checking a directory is empty before removing it.
2026-09-23 16:00:26 +01:00
Nick Craig-Wood ee26daecd8 internxt: fix server-side moves failing with "Not Found" or "already exists"
Moving a file into a directory which had just been created failed with
"Not Found (status 404)", and moving a file over one which had just been
deleted (as sync does with --backup-dir and --suffix) failed with "A file
with the same name already exists in destination folder (status 409)".

The API checks moves against read replicas which lag behind writes, so
retry these errors until the replicas catch up.
2026-09-23 16:00:14 +01:00
Nick Craig-Wood f068abd607 internxt: fix sync with --backup-dir or --suffix deleting the backed up file
The Internxt API serves lookups and listings from read replicas which
lag behind writes, so for a short while after a file is moved it can
still be returned from its old location.

When sync moved a file into the backup location and then uploaded its
replacement, the upload could find the moved file under its old name
and overwrite it. Overwriting renames the existing file by UUID and
deletes it once the upload succeeds, so the file that had just been
moved into the backup location was deleted.

Remember the files this process has moved or deleted for a minute and
ignore lookups and listing entries which contradict that.
2026-09-23 15:59:50 +01:00
Nick Craig-Wood a2e2b73725 drime: fix deleted files and directories still being listed with hard_delete
With hard_delete set, deleted files and directories carried on being
listed for about a second afterwards because the Drime server doesn't
invalidate its cache of the parent folder listing when entries are
deleted forever. This made removing a directory straight after
emptying it fail with "directory not empty".

Moving an entry to the trash does invalidate the cache, so with
hard_delete set rclone now moves the entry to the trash first and then
deletes it forever.
2026-09-23 15:59:35 +01:00
Nick Craig-Wood eb10c48a17 drime: fix server-side copy over an existing file leaving a "name (1)" copy
When server-side copying to a destination which already existed, the
Drime server gave the copy the name "name (1)" and rclone only renamed
it if the source and destination leaf names differed. The existing file
was then deleted leaving the copy under the wrong name.

The server refuses to rename an entry to a name which is already in
use, so this removes the existing file straight after the copy, then
renames the copy whenever its name differs from the destination name.
2026-09-23 15:59:13 +01:00
Nick Craig-Wood 7c18e1eb86 premiumizeme: fix uploading files with ";" in their names
The premiumize.me upload server has started truncating the multipart
file name at the first ";", so uploading "a;b.txt" created a file
called "a" and the upload then failed with "object not found".

Directory creation and renames still accept ";", so files whose names
contain ";" are now uploaded under a temporary name and renamed into
place. The encoding is left unchanged so existing files and
directories containing ";" remain accessible.
2026-09-23 15:58:36 +01:00
Nick Craig-Wood dca68c9adc test_all: ignore TestMoveFileImmutable on cloudinary as search is eventually consistent 2026-09-23 15:58:26 +01:00
Nick Craig-Wood 034ac6275f vfs: fix TestDirMetadataExtension on remotes which can't upload empty files 2026-09-23 15:58:14 +01:00
Nick Craig-Wood 8c38d3068a onedrive: document that shared with me shortcuts may fail to list on personal
Shortcuts to shared items (and Personal Vault) are stored as remote
items pointing at another drive and listing them can fail with
"The provided drive id appears to be malformed". This aborts a
--fast-list listing of the whole drive.
2026-09-23 12:39:41 +01:00
Nick Craig-Wood 90e67915c8 rest: limit the size of HTTP response bodies read into memory
rest.ReadBody read the whole response body into memory with no limit.
It is used by the default error handler and by many backends' error
handlers and small API calls, so a server which answered with an error
status and then streamed an endless body could make rclone allocate
memory until it was killed.

ReadBody now reads at most 10 MiB (the same limit drainAndClose
already uses to discard unread bodies) and returns an error if the
body is bigger than that. Every caller reads small API responses -
error bodies, status documents and upload tokens - so no legitimate
response is affected.

Reported by @manus-pi
2026-09-23 11:16:37 +01:00
jxj cfb90e3ebe s3: fix version-at listings with URL encoded keys
When S3 returns URL-encoded keys from ListObjectVersions, URL encoding
can change their lexical order. This could make mergeDeleteMarkers
place a delete marker after older versions of the same key, so
--s3-version-at reported deleted objects as live.

Compare decoded keys while merging, while preserving the encoded keys
for the existing listing decode path.

Fixes #9948
2026-09-22 17:44:58 +01:00
Nick Craig-Wood ff02636fe4 onedrive: update docs for versions, links and time precision on personal accounts
Testing against OneDrive personal (free) and OneDrive for Business shows

- personal accounts now create versions on setting the modification
  time and can delete them, so --onedrive-no-versions and rclone
  cleanup work there
- --onedrive-link-password works on OneDrive for Business
- personal free accounts can't set a link password or --expire
- --onedrive-link-type embed only works on OneDrive personal
- personal accounts store times with 1s precision, not mS

See #9917
2026-09-22 15:12:29 +01:00
Nick Craig-Wood 7a2d7c766d vfs: fix crash reading the metadata of a file which is being written
With --vfs-metadata-extension set, looking up the metadata file of a
file which was open for write and not yet uploaded caused a nil pointer
panic. Such a file has no object to read the modification time from.

Use the modification time of the VFS node instead, which is valid
whether or not the file has been uploaded.
2026-09-22 15:01:44 +01:00
Nick Craig-Wood ff958c999f operations: fix TestDeleteFatalError, TestDirMoveMoveError and TestDirMoveContext on remotes
TestDeleteFatalError set --max-delete before writing its files, so on
chunker, which deletes while uploading, the setup failed.

TestDirMoveMoveError and TestDirMoveContext test the core DirMove logic
with a wrapping Fs. This fails on remotes without Move (eg s3, memory)
and on those whose objects don't belong to the wrapped Fs (eg archive),
so they now only run on local.
2026-09-22 14:58:12 +01:00
Nick Craig-Wood 1e92520076 iclouddrive: fix potential crash looking up items
findItem read the response status code when the lookup failed, so a
failure with no HTTP response panicked.

Thanks to @manus-pi for finding this problem.
2026-09-22 11:03:16 +01:00
Nick Craig-Wood 35abcadfc7 shade: fix potential crash in directory move
DirMove discarded the error from the destination check and read the
response status code, so a failure with no HTTP response panicked.
Other errors were reported as the destination existing; they are now
returned.

Thanks to @manus-pi for finding this problem.
2026-09-22 11:03:16 +01:00
Nick Craig-Wood e2cd9a5dfb imagekit: fix potential crash in rmdir and purge
Rmdir and Purge read the response status code before checking for an
error, so a failed DeleteFolder call with no HTTP response (a network
error, or purging the root which fails validation) panicked.

Thanks to @manus-pi for finding this problem.
2026-09-22 11:03:16 +01:00
Leon Brocard f21bb97383 s3: add Fastly EU Central 1 region
Fastly Object Storage now provides the eu-central-1 region. Add the
region and endpoint to the configuration choices so users do not need
to enter them manually.

https://community.fastly.com/t/new-fastly-object-storage-capabilities-and-a-new-storage-region/4493
2026-09-22 10:34:03 +01:00
Mattias Michaux 4e7a21bead onedrive: add configurable SharePoint tenant API version 2026-09-21 18:19:15 +01:00
phatlc 556940ea44 serve dlna: log unescaped paths - fixes #7370
The request log printed the escaped URL, so non-ASCII file names showed
up as long runs of %XX escapes. Log the unescaped URL path instead, as
the other serve commands do.
2026-09-21 18:11:07 +01:00
phatlc ebb1cc1221 vfs: fix AddVirtual ignoring isDir
VFS.AddVirtual always added a file entry to the directory cache, even
when called with isDir set, so a virtual directory showed up as a file
and nothing could be added inside it.

See #9310
2026-09-21 18:10:18 +01:00
phatlc b6beea4b27 local: stop --copy-links following symlink loops - fixes #4402
With -L/--copy-links a symlink pointing to one of its parent
directories was followed until the OS gave up with "too many levels
of symbolic links" 40 levels deep. As every looping symlink multiplies
the listing, a small tree with three such symlinks listed millions of
entries.

When a followed symlink points to a directory, compare it with the
directory being listed and each of its parents using os.SameFile and
if it matches report an error and skip it, the same way as a circular
symlink. A symlink to a sibling directory is still followed. Loops
excluded by the directory filters are skipped quietly as before.
2026-09-21 18:08:05 +01:00
phatlc 3c51b96774 smb: save the user name in the config even if it matches the current user - fixes #9356
The default for --smb-user was the name of the user running rclone
config, and rclone does not write defaults to the config file, so
entering your own user name left it out. A remote made that way then
logged in as whoever ran it later, e.g. root under systemd.

Make the default blank and look up the current user when the remote is
used, as the ftp backend does. Existing configs work as before.
2026-09-21 17:27:57 +01:00
phatlc dc98c216f2 operations: stop --copy-dest replacing files with --immutable
When the source matched a file in --copy-dest, copyDest server-side
copied it over an existing destination which differed, so sync, copy
and copyto with --copy-dest could modify a file --immutable should have
protected.

Leave such a destination for the caller, which rejects it with
ErrorImmutableModified as for any other modified file.
2026-09-21 17:26:51 +01:00
phatlc 575633cf6c operations: make --immutable work with copyto, moveto and single file copy - fixes #4921
--immutable was only enforced by sync, so copyto, moveto, copy and move
of a single file, and the operations/copyfile and operations/movefile rc
calls overwrote a destination which differed from the source.

Fail these with the same ErrorImmutableModified as sync, and reject
--no-check-dest with --immutable as sync does, since without checking
the destination it cannot be protected.
2026-09-21 17:26:51 +01:00
FTCHD 788e252d72 gui: clarify url param in docs 2026-09-21 14:09:21 +01:00
733 changed files with 99650 additions and 73929 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)"
]
}
}
+4
View File
@@ -2,9 +2,13 @@
go.mod text eol=lf
go.sum text eol=lf
# Embedded templates must render the same on every OS
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.0'
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.0'
go: '~1.27.3'
goarch: 386
gotags: cmount
quicktest: true
- job_name: mac_amd64
os: macos-latest
go: '~1.27.0'
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.0'
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.0'
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.0'
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.6'
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.0'
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.0'
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/
+15
View File
@@ -20,6 +20,8 @@ The core parts of rclone under `fs` and `vfs` need to work with all backends and
Make sure added tests **actually test the code you have written** and test the intention behind the change. If you are fixing a problem, write the tests first to reproduce the problem before starting on the fix.
Tests in `fs/operations`, `fs/sync`, `cmd/bisync`, `cmd/gitannex` and `vfs` (the definitive list is in `fstest/test_all/config.yaml`) are run on the integration test machine against **all** the backends, so they **must be prepared to run on backends with limited functionality**. Ideally tests, or parts of tests, should be skipped using the `Fs.Features()` flags on backends which can't support them.
## Build and Test Commands
```bash
@@ -132,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.
+1
View File
@@ -42,6 +42,7 @@ RUN echo "**** Install Dependencies ****" && \
apk add --no-cache \
ca-certificates \
fuse3 \
mailcap \
tzdata && \
echo "Enable user_allow_other in fuse" && \
echo "user_allow_other" >> /etc/fuse.conf
+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
+25 -14
View File
@@ -38,6 +38,9 @@ BETA_PATH := $(BRANCH_PATH)$(TAG)$(BETA_SUBDIR)
BETA_URL := https://beta.rclone.org/$(BETA_PATH)/
BETA_UPLOAD_ROOT := beta.rclone.org:
BETA_UPLOAD := $(BETA_UPLOAD_ROOT)/$(BETA_PATH)
# Update the beta site's directory listings after an upload - add
# --changed for the uploaded directories
BETA_INDEX := --config bin/ci.rclone.conf -v index $(BETA_UPLOAD_ROOT) --use-server-modtime --checkers 16 --output index.html=html --output index.json=caddy --header-upload "Cache-Control: public, max-age=60" --exclude /favicon.ico
# Pass in GOTAGS=xyz on the make command line to set build tags
ifdef GOTAGS
BUILDTAGS=-tags "$(GOTAGS)"
@@ -234,6 +237,7 @@ check_sign:
upload:
rclone -P copy build/ downloads.rclone.org:/$(TAG)
rclone lsf build --files-only --include '*.{zip,deb,rpm}' --include version.txt | xargs -i bash -c 'i={}; j="$$i"; [[ $$i =~ (.*)(-v[0-9\.]+-)(.*) ]] && j=$${BASH_REMATCH[1]}-current-$${BASH_REMATCH[3]}; rclone copyto -v "downloads.rclone.org:/$(TAG)/$$i" "downloads.rclone.org:/$$j"'
rclone -P index downloads.rclone.org: --changed /$(TAG)/ --checkers 16 --output index.html=html --output index.json=caddy --header-upload "Cache-Control: public, max-age=60" --exclude /favicon.ico
upload_github:
./bin/upload-github $(TAG)
@@ -266,6 +270,7 @@ ci_upload:
ifeq ($(or $(BRANCH_PATH),$(RELEASE_TAG)),)
./rclone --no-check-dest --config bin/ci.rclone.conf -v copy build/ $(BETA_UPLOAD_ROOT)/test/testbuilds-latest
endif
./rclone $(BETA_INDEX) --changed $(BETA_PATH)/testbuilds/ $(if $(or $(BRANCH_PATH),$(RELEASE_TAG)),,--changed test/testbuilds-latest/)
@echo Beta release ready at $(BETA_URL)/testbuilds
ci_beta:
@@ -275,11 +280,12 @@ ci_beta:
ifeq ($(or $(BRANCH_PATH),$(RELEASE_TAG)),)
rclone --no-check-dest --config bin/ci.rclone.conf -v copy --include '*beta-latest*' --include version.txt build/ $(BETA_UPLOAD_ROOT)$(BETA_SUBDIR)
endif
rclone $(BETA_INDEX) --changed $(BETA_PATH)/ $(if $(or $(BRANCH_PATH),$(RELEASE_TAG)),,$(if $(BETA_SUBDIR),--changed $(BETA_SUBDIR)/))
@echo Beta release ready at $(BETA_URL)
# 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
@@ -296,21 +302,26 @@ retag:
@echo "Version is $(VERSION)"
git tag -f -s -m "Version $(VERSION)" $(VERSION)
# Start development of the next version after a release: the next
# minor version on master or the next patch version on a stable branch.
startdev:
@echo "Version is $(VERSION)"
@echo "Next version is $(NEXT_VERSION)"
echo -e "package fs\n\n// VersionTag of rclone\nvar VersionTag = \"$(NEXT_VERSION)\"\n" | gofmt > fs/versiontag.go
echo -n "$(NEXT_VERSION)" > docs/layouts/partials/version.html
echo "$(NEXT_VERSION)" > VERSION
git commit -m "Start $(NEXT_VERSION)-DEV development" fs/versiontag.go VERSION docs/layouts/partials/version.html
@branch=$$(git rev-parse --abbrev-ref HEAD); \
case "$$branch" in \
master) next="$(NEXT_VERSION)" ;; \
v*-stable) next="$(NEXT_PATCH_VERSION)" ;; \
*) echo "Branch $$branch isn't master or a stable branch" >&2; exit 1 ;; \
esac; \
if ! git rev-parse -q --verify "refs/tags/$(VERSION)" >/dev/null; then \
echo "$(VERSION) hasn't been tagged yet - has development already been started?" >&2; exit 1; \
fi; \
echo "Version is $(VERSION)"; \
echo "Next version is $$next"; \
echo -e "package fs\n\n// VersionTag of rclone\nvar VersionTag = \"$$next\"\n" | gofmt > fs/versiontag.go; \
echo -n "$$next" > docs/layouts/partials/version.html; \
echo "$$next" > VERSION; \
git commit -m "Start $$next-DEV development" fs/versiontag.go VERSION docs/layouts/partials/version.html
startstable:
@echo "Version is $(VERSION)"
@echo "Next stable version is $(NEXT_PATCH_VERSION)"
echo -e "package fs\n\n// VersionTag of rclone\nvar VersionTag = \"$(NEXT_PATCH_VERSION)\"\n" | gofmt > fs/versiontag.go
echo -n "$(NEXT_PATCH_VERSION)" > docs/layouts/partials/version.html
echo "$(NEXT_PATCH_VERSION)" > VERSION
git commit -m "Start $(NEXT_PATCH_VERSION)-DEV development" fs/versiontag.go VERSION docs/layouts/partials/version.html
startstable: startdev
winzip:
zip -9 rclone-$(TAG).zip rclone.exe
+9
View File
@@ -24,21 +24,25 @@ 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)
- DigitalOcean Spaces [:page_facing_up:](https://rclone.org/s3/#digitalocean-spaces)
- Digi Storage [:page_facing_up:](https://rclone.org/koofr/#digi-storage)
- dosya.dev [:page_facing_up:](https://rclone.org/dosya/)
- Dreamhost [:page_facing_up:](https://rclone.org/s3/#dreamhost)
- Drime [:page_facing_up:](https://rclone.org/s3/#drime)
- Dropbox [:page_facing_up:](https://rclone.org/dropbox/)
@@ -70,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)
@@ -87,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/)
@@ -110,6 +118,7 @@ directories to and from different cloud storage providers.
- Quatrix [:page_facing_up:](https://rclone.org/quatrix/)
- Rackspace Cloud Files [:page_facing_up:](https://rclone.org/swift/)
- RackCorp Object Storage [:page_facing_up:](https://rclone.org/s3/#RackCorp)
- RelAix.Storage [:page_facing_up:](https://rclone.org/s3/#relaix)
- rsync.net [:page_facing_up:](https://rclone.org/sftp/#rsync-net)
- Scaleway [:page_facing_up:](https://rclone.org/s3/#scaleway)
- Scality (RING / ARTESCA) [:page_facing_up:](https://rclone.org/s3/#scality)
+83 -24
View File
@@ -6,27 +6,41 @@ This file describes how to make the various kinds of releases
- [gh the github cli](https://github.com/cli/cli) for uploading packages
- pandoc for making the html and man pages
- hugo for building the website
- golangci-lint and docker for `make check` (markdownlint runs in docker)
- a gpg key for signing the tag and the checksums
- rclone remotes `beta.rclone.org:`, `downloads.rclone.org:`,
`www.rclone.org:` and `test-rclone-org:`
- the sponsor logos - see [Sponsor logos](#sponsor-logos)
## Making a release
- Enable the [release freeze rule](https://github.com/rclone/rclone/settings/rules/24751862)
- git checkout master # see below for stable branch
- git pull # IMPORTANT
- make fetch-gui-and-commit # bump the embedded GUI if rclone-web has a newer release
- git status - make sure everything is checked in
- Check GitHub actions build for master is Green
- make test # see integration test server or run locally
- make tag
- edit docs/content/changelog.md # duplicate logs from point rels removed automatically
releases
- Check the integration tests at <https://integration.rclone.org>
- cat VERSION # check this is the version being released
- make tag # signed tag on HEAD + new changelog entry + docs
- edit docs/content/changelog.md # entries already in point releases are
removed automatically
- make tidy
- make doc
- git status - to check for new man pages - git add them
- make doc # again to pick up the changelog edits
- git status - to check for new command docs in docs/content/commands - git add them
- git commit -a -v -m "Version v1.XX.0"
- make check
- make retag
- git push origin # without --follow-tags so it doesn't push the tag if it fails
- 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 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...
- \# 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
- make vendorball
@@ -35,19 +49,31 @@ This file describes how to make the various kinds of releases
- make upload
- make upload_test_website # check sponsors are correct/unchanged
- make upload_website
- make upload_github
- make startdev # make startstable for stable branch
- \# announce with forum post, twitter post, and post to annoucements list
- make upload_github # this triggers the docker plugin and winget workflows
- Check the docker image, docker plugin and winget GitHub actions succeeded
- make startdev # works out the next version for master or a stable branch
- git push
- Disable the release freeze rule
- Release any security advisories
- request a CVE for each advisory if not done already
- set the patched version on each advisory and publish it
- when the CVEs are assigned replace `CVE-PENDING` in the changelog on
master and the stable branch then `make upload_website`
- \# announce with forum post, twitter post, and post to announcements list
## Update dependencies
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
@@ -190,20 +216,53 @@ First make the release branch. If this is a second point release then
this will be done already.
- git co -b ${BASE_TAG}-stable ${BASE_TAG}.0
- make startstable
- make startdev # sets the version to ${BASE_TAG}.1
- git push -u origin ${BASE_TAG}-stable
Now
- git co ${BASE_TAG}-stable
- `git cherry-pick -x` any fixes
- make startstable
- Do the steps as above
- Enable release freeze rule
- git co master
- `#` cherry pick the changes to the changelog - check the diff to make sure it
is correct
- git checkout ${BASE_TAG}-stable docs/content/changelog.md
- git commit -a -v -m "Changelog updates from Version ${NEW_TAG}"
- git pull --rebase
- make fetch-gui-and-commit # update the GUI on master first
- git push
- git co ${BASE_TAG}-stable
- git pull
- cat VERSION # check this is ${NEW_TAG} - if not run make startdev
- `git cherry-pick -x` any fixes, including the GUI update if there was one
- for security releases consider updating the Go patch version in
`.github/workflows/build.yml` and dependencies with CVE fixes
- fixes may depend on commits which weren't picked so push to CI
and check it is green before tagging
- Do the steps in "Making a release" above, except
- stay on ${BASE_TAG}-stable - don't checkout master or run
make fetch-gui-and-commit
- check the GitHub actions build for ${BASE_TAG}-stable not master
- run the integration tests on ${BASE_TAG}-stable as described below
- the release freeze rule is already enabled - leave it until the end
- git co master
- git pull --rebase
- git checkout ${BASE_TAG}-stable docs/content/changelog.md
- git diff --cached # check only the ${NEW_TAG} entry has changed
- git commit -v -m "Changelog updates from Version ${NEW_TAG}" docs/content/changelog.md
- git push
- Disable release freeze rule
Point releases are only made for the latest release. Once v1.(XX+1).0
is out the ${BASE_TAG}-stable branch is abandoned.
### Running the integration tests on the stable branch
The integration tests at <https://integration.rclone.org> test master.
To test the stable branch push it to the testing server and run the
tests there in `screen` as they take a while.
```console
git push -f rclone@rclone-testing:private-rclone.git ${BASE_TAG}-stable
ssh rclone@rclone-testing
screen
cd ~/integration-test && go run integration-test.go -repo /home/rclone/private-rclone.git -branch v1.XX-stable
```
## Sponsor logos
+1 -1
View File
@@ -1 +1 @@
v1.76.0
v1.77.0
+2
View File
@@ -16,6 +16,7 @@ import (
_ "github.com/rclone/rclone/backend/compress"
_ "github.com/rclone/rclone/backend/crypt"
_ "github.com/rclone/rclone/backend/doi"
_ "github.com/rclone/rclone/backend/dosya"
_ "github.com/rclone/rclone/backend/drime"
_ "github.com/rclone/rclone/backend/drive"
_ "github.com/rclone/rclone/backend/dropbox"
@@ -38,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
+159
View File
@@ -12,16 +12,19 @@ import (
"os/exec"
"path"
"path/filepath"
"sort"
"strconv"
"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"
"github.com/rclone/rclone/fs/cache"
"github.com/rclone/rclone/fs/filter"
"github.com/rclone/rclone/fs/operations"
"github.com/rclone/rclone/fs/sync"
"github.com/rclone/rclone/fstest"
"github.com/rclone/rclone/fstest/fstests"
"github.com/stretchr/testify/assert"
@@ -175,6 +178,18 @@ func testArchive(t *testing.T, archiveName string, archiveFn func(t *testing.T,
checkTree(ctx, "SubDir", t, ":archive:"+zipFile+"/"+subDir, filepath.Join(input, subDir), 0)
// Now check a single file
//
// The filter below applies to both sides, so check first that the
// archive exposes only that file without it
Fsingle, err := cache.Get(ctx, ":archive:"+zipFile+"/"+aFile)
require.NoError(t, err)
entries, err := Fsingle.List(ctx, "")
require.NoError(t, err)
require.Len(t, entries, 1)
_, err = Fsingle.List(ctx, subDir)
assert.Equal(t, fs.ErrorDirNotFound, err)
_, err = Fsingle.NewObject(ctx, subDir)
assert.Equal(t, fs.ErrorObjectNotFound, err)
fiCtx, fi := filter.AddConfig(ctx)
require.NoError(t, fi.AddRule("+ "+aFile))
require.NoError(t, fi.AddRule("- *"))
@@ -309,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
@@ -403,3 +462,103 @@ func TestIsDirectChild(t *testing.T) {
assert.Equal(t, test.want, isDirectChild(test.dir, test.remote), "dir=%q remote=%q", test.dir, test.remote)
}
}
// A symlink in a squashfs image is skipped unless -l/--links is in use,
// as it is on the other backends. With the flag it is exposed under its
// name with the link suffix and reads back as its target.
//
// Note that this uses mksquashfs as an external binary.
func TestArchiveSquashfsSymlink(t *testing.T) {
fstest.Initialise()
skipIfNoExe(t, "mksquashfs")
ctx := context.Background()
dir := t.TempDir()
input := filepath.Join(dir, "input")
require.NoError(t, os.Mkdir(input, 0777))
require.NoError(t, os.WriteFile(filepath.Join(input, "file.txt"), []byte("hello"), 0600))
require.NoError(t, os.Symlink("file.txt", filepath.Join(input, "link.txt")))
// a regular file which happens to carry the link suffix must stay
// readable under the flag, not be taken for a symlink
require.NoError(t, os.WriteFile(filepath.Join(input, "note"+fs.LinkSuffix), []byte("plain"), 0600))
image := filepath.Join(dir, "test.sqfs")
run(t, "mksquashfs", input, image)
remotes := func(t *testing.T, f fs.Fs) []string {
entries, err := f.List(ctx, "")
require.NoError(t, err)
names := make([]string, 0, len(entries))
for _, entry := range entries {
names = append(names, entry.Remote())
}
sort.Strings(names)
return names
}
read := func(t *testing.T, f fs.Fs, remote string) string {
o, err := f.NewObject(ctx, remote)
require.NoError(t, err)
rc, err := o.Open(ctx)
require.NoError(t, err)
contents, err := io.ReadAll(rc)
require.NoError(t, err)
require.NoError(t, rc.Close())
assert.Equal(t, o.Size(), int64(len(contents)))
return string(contents)
}
f, err := fs.NewFs(ctx, ":archive:"+image)
require.NoError(t, err)
assert.Equal(t, []string{"file.txt", "note" + fs.LinkSuffix}, remotes(t, f))
ctx, ci := fs.AddConfig(ctx)
ci.Links = true
f, err = fs.NewFs(ctx, ":archive:"+image)
require.NoError(t, err)
assert.Equal(t, []string{"file.txt", "link.txt" + fs.LinkSuffix, "note" + fs.LinkSuffix}, remotes(t, f))
assert.Equal(t, "file.txt", read(t, f, "link.txt"+fs.LinkSuffix))
assert.Equal(t, "plain", read(t, f, "note"+fs.LinkSuffix))
}
// A hostile archive must not be able to write outside the destination.
// A symlink pointing above the root with a member inside it is the
// classic way to try it, and the refusal comes from the local backend,
// so pin it from end to end here rather than trusting it stays.
func TestArchiveZipSymlinkEscape(t *testing.T) {
fstest.Initialise()
ctx := context.Background()
ctx, ci := fs.AddConfig(ctx)
ci.Links = true
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
hdr := &zip.FileHeader{Name: "a"}
hdr.SetMode(os.ModeSymlink | 0777)
w, err := zw.CreateHeader(hdr)
require.NoError(t, err)
_, err = w.Write([]byte("../outside"))
require.NoError(t, err)
w, err = zw.Create("a/pwned.txt")
require.NoError(t, err)
_, err = w.Write([]byte("pwned"))
require.NoError(t, err)
require.NoError(t, zw.Close())
dir := t.TempDir()
zipPath := filepath.Join(dir, "evil.zip")
require.NoError(t, os.WriteFile(zipPath, buf.Bytes(), 0600))
dst := filepath.Join(dir, "dst")
require.NoError(t, os.Mkdir(dst, 0777))
fsrc, err := fs.NewFs(ctx, ":archive:"+zipPath)
require.NoError(t, err)
fdst, err := fs.NewFs(ctx, dst)
require.NoError(t, err)
assert.Error(t, sync.CopyDir(ctx, fdst, fsrc, false))
_, err = os.Stat(filepath.Join(dir, "outside", "pwned.txt"))
assert.True(t, os.IsNotExist(err), "the archive wrote outside the destination")
}
+93 -2
View File
@@ -42,6 +42,8 @@ type Fs struct {
prefix string // position for objects
prefixSlash string // position for objects with a slash on
root string // position to read from within the archive
file string // remote of the single file root points at, if any
links bool // read symlinks as .rclonelink files
}
// recoverParsePanic converts a panic raised while parsing a squashfs image
@@ -98,6 +100,7 @@ func New(ctx context.Context, wrappedFs fs.Fs, remote, prefix, root string) (_ f
c: c,
remote: remote,
root: strings.Trim(root, "/"),
links: fs.GetConfig(ctx).Links,
prefix: prefix,
prefixSlash: prefix + "/",
}
@@ -115,10 +118,13 @@ func New(ctx context.Context, wrappedFs fs.Fs, remote, prefix, root string) (_ f
_, err := f.newObjectNative(native)
if err == nil {
// If it pointed to a file, find the directory above
// and remember the file so that only it is listed
f.root = path.Dir(f.root)
if f.root == "." || f.root == "/" {
f.root = ""
}
f.file = f.prefixSlash + path.Base(native)
singleObject = true
}
}
}
@@ -150,7 +156,9 @@ func (f *Fs) Name() string {
// Root of the remote (as passed into NewFs)
func (f *Fs) Root() string {
return f.root
// Include the file so the fs cache can tell this Fs apart from
// the one for the directory containing it
return path.Join(f.root, f.file)
}
// Features returns the optional features of this Fs
@@ -214,6 +222,30 @@ func (f *Fs) objectFromFileInfo(nativeDir string, item os.FileInfo) *Object {
}
}
// Convert a symlink entry into an Object holding its target
func (f *Fs) objectFromLink(nativeDir string, item os.FileInfo, target string) *Object {
return &Object{
fs: f,
remote: f.fromNative(nativeDir, item.Name()) + fs.LinkSuffix,
size: int64(len(target)),
modTime: item.ModTime(),
isLink: true,
link: target,
}
}
// readlink returns the target of the symlink the info describes.
//
// A symlink may carry an empty target, so the caller decides what is a
// symlink from the mode rather than from the target being set.
func readlink(info os.FileInfo) (string, error) {
st, ok := info.Sys().(*squashfs.StatT)
if !ok {
return "", fmt.Errorf("squashfs entry %q has no stat information", info.Name())
}
return st.LinkTarget, nil
}
// List the objects and directories in dir into entries. The
// entries can be returned in any order but should be for a
// complete directory.
@@ -227,6 +259,10 @@ func (f *Fs) List(ctx context.Context, dir string) (entries fs.DirEntries, err e
defer log.Trace(f, "dir=%q", dir)("entries=%v, err=%v", &entries, &err)
defer recoverParsePanic(&err)
if f.file != "" && dir != "" {
return nil, fs.ErrorDirNotFound
}
nativeDir, err := f.toNative(dir)
if err != nil {
return nil, err
@@ -239,6 +275,7 @@ func (f *Fs) List(ctx context.Context, dir string) (entries fs.DirEntries, err e
entries = make(fs.DirEntries, 0, len(items))
skipped := 0
skippedLinks := 0
for _, item := range items {
// fs.Debugf(item.Name(), "entry = %#v", item)
// Skip entry names that aren't safe
@@ -261,10 +298,27 @@ func (f *Fs) List(ctx context.Context, dir string) (entries fs.DirEntries, err e
return nil, fmt.Errorf("error reading item %q: %w", item.Name(), err)
}
entry = f.objectFromFileInfo(nativeDir, info)
} else if item.Type()&os.ModeSymlink != 0 {
if !f.links {
skippedLinks++
continue
}
info, err := item.Info()
if err != nil {
return nil, fmt.Errorf("error reading item %q: %w", item.Name(), err)
}
target, err := readlink(info)
if err != nil {
return nil, err
}
entry = f.objectFromLink(nativeDir, info, target)
} else {
fs.Debugf(item.Name(), "FIXME Not regular file - skipping")
continue
}
if f.file != "" && entry.Remote() != f.file {
continue
}
entries = append(entries, entry)
}
@@ -273,6 +327,9 @@ func (f *Fs) List(ctx context.Context, dir string) (entries fs.DirEntries, err e
if skipped > 0 {
fs.Logf(f, "Skipped %d squashfs entries in %q whose names escape the archive", skipped, dir)
}
if skippedLinks > 0 {
fs.Logf(f, "Skipped %d symlinks in %q - use -l/--links to read them", skippedLinks, dir)
}
// fs.Debugf(f, "dir=%q, entries=%v", dir, entries)
return entries, nil
@@ -294,7 +351,17 @@ func (f *Fs) newObjectNative(nativePath string) (o fs.Object, err error) {
}
for _, fi := range fis {
if fi.Name() == leaf {
// Name the entry as List does, so that the two agree on
// which names exist
isLink := fi.Type()&os.ModeSymlink != 0
if isLink && !f.links {
continue
}
name := fi.Name()
if isLink {
name += fs.LinkSuffix
}
if name == leaf {
if fi.IsDir() {
return nil, fs.ErrorNotAFile
}
@@ -302,6 +369,14 @@ func (f *Fs) newObjectNative(nativePath string) (o fs.Object, err error) {
if err != nil {
return nil, fmt.Errorf("error reading item %q: %w", fi.Name(), err)
}
if isLink {
target, err := readlink(info)
if err != nil {
return nil, err
}
o = f.objectFromLink(dir, info, target)
break
}
o = f.objectFromFileInfo(dir, info)
break
}
@@ -316,6 +391,9 @@ func (f *Fs) newObjectNative(nativePath string) (o fs.Object, err error) {
func (f *Fs) NewObject(ctx context.Context, remote string) (o fs.Object, err error) {
defer log.Trace(f, "remote=%q", remote)("obj=%v, err=%v", &o, &err)
if f.file != "" && remote != f.file {
return nil, fs.ErrorObjectNotFound
}
nativePath, err := f.toNative(remote)
if err != nil {
return nil, err
@@ -377,6 +455,8 @@ type Object struct {
remote string
size int64
modTime time.Time
isLink bool // whether this is a symlink
link string // target of the symlink
}
// Fs returns read only access to the Fs that this object is part of
@@ -448,6 +528,17 @@ func (o *Object) Open(ctx context.Context, options ...fs.OpenOption) (rc io.Read
}
}
if o.isLink {
// Clamp offset into range to avoid panic
if offset < 0 {
offset = 0
}
if offset > int64(len(o.link)) {
offset = int64(len(o.link))
}
return readers.NewLimitedReadCloser(io.NopCloser(strings.NewReader(o.link[offset:])), limit), nil
}
remote, err := o.fs.toNative(o.remote)
if err != nil {
return nil, err
+15
View File
@@ -43,6 +43,7 @@ type Fs struct {
prefix string // position for objects
prefixSlash string // position for objects with a slash on
root string // position to read from within the archive
links bool // read symlinks as .rclonelink files
dt dirtree.DirTree // read from zipfile
}
@@ -67,6 +68,7 @@ func New(ctx context.Context, wrappedFs fs.Fs, remote, prefix, root string) (fs.
node: node,
remote: remote,
root: root,
links: fs.GetConfig(ctx).Links,
prefix: prefix,
prefixSlash: prefix + "/",
}
@@ -138,6 +140,7 @@ func (f *Fs) readZip() (singleObject bool, err error) {
}
dt := dirtree.New()
skipped := 0
skippedLinks := 0
for _, file := range zr.File {
// Skip entries whose name escapes the archive's own namespace
remote, err := sanitize.Path(file.Name)
@@ -152,6 +155,15 @@ func (f *Fs) readZip() (singleObject bool, err error) {
skipped++
continue
}
if !isDir && file.Mode()&os.ModeSymlink != 0 {
if !f.links {
skippedLinks++
continue
}
// The member holds the link target, so it reads back
// like a .rclonelink file
remote += fs.LinkSuffix
}
remote = path.Join(f.prefix, remote)
if f.root != "" {
// Ignore all files outside the root, requiring a path
@@ -191,6 +203,9 @@ func (f *Fs) readZip() (singleObject bool, err error) {
if skipped > 0 {
fs.Logf(f, "Skipped %d zip entries which escape the archive", skipped)
}
if skippedLinks > 0 {
fs.Logf(f, "Skipped %d symlinks - use -l/--links to read them", skippedLinks)
}
dt.CheckParents("")
dt.Sort()
f.dt = dt
+55
View File
@@ -5,6 +5,7 @@ import (
"bytes"
"context"
"fmt"
"io"
"os"
"path/filepath"
"sort"
@@ -36,6 +37,26 @@ func writeZip(t *testing.T, dir, name string, names ...string) string {
return name
}
// writeZipSymlink builds a zip holding file and a symlink entry link
// pointing at target, and returns its leaf name.
func writeZipSymlink(t *testing.T, dir, name, file, link, target string) string {
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
w, err := zw.Create(file)
require.NoError(t, err)
_, err = w.Write([]byte("data for " + file))
require.NoError(t, err)
hdr := &zip.FileHeader{Name: link}
hdr.SetMode(os.ModeSymlink | 0777)
w, err = zw.CreateHeader(hdr)
require.NoError(t, err)
_, err = w.Write([]byte(target))
require.NoError(t, err)
require.NoError(t, zw.Close())
require.NoError(t, os.WriteFile(filepath.Join(dir, name), buf.Bytes(), 0600))
return name
}
// allRemotes returns every Object remote in the mounted archive's dirtree.
func allRemotes(t *testing.T, f fs.Fs) []string {
zf, ok := f.(*Fs)
@@ -170,3 +191,37 @@ func TestListCacheNotAliased(t *testing.T) {
}
assert.Equal(t, []string{"a.txt", "b.txt", "c.txt"}, remotes)
}
// A symlink stored in the archive is skipped unless -l/--links is in
// use, as it is on the other backends. With the flag it is exposed
// under its name with the link suffix and reads back as its target.
func TestReadZipSymlink(t *testing.T) {
ctx := context.Background()
dir := t.TempDir()
name := writeZipSymlink(t, dir, "links.zip", "file.txt", "link.txt", "file.txt")
localFs, err := cache.Get(ctx, dir)
require.NoError(t, err)
f, err := New(ctx, localFs, name, "", "")
require.NoError(t, err)
assert.Equal(t, []string{"file.txt"}, allRemotes(t, f))
ctx, ci := fs.AddConfig(ctx)
ci.Links = true
f, err = New(ctx, localFs, name, "", "")
require.NoError(t, err)
assert.Equal(t, []string{"file.txt", "link.txt" + fs.LinkSuffix}, allRemotes(t, f))
o, err := f.NewObject(ctx, "link.txt"+fs.LinkSuffix)
require.NoError(t, err)
assert.Equal(t, int64(len("file.txt")), o.Size())
rc, err := o.Open(ctx)
require.NoError(t, err)
target, err := io.ReadAll(rc)
require.NoError(t, err)
require.NoError(t, rc.Close())
assert.Equal(t, "file.txt", string(target))
}
+11 -18
View File
@@ -18,24 +18,20 @@ import (
"github.com/Azure/azure-sdk-for-go/sdk/azcore"
"github.com/Azure/azure-sdk-for-go/sdk/storage/azblob/container"
"github.com/rclone/rclone/backend/azureblob/arrowlist"
"github.com/rclone/rclone/fs"
"golang.org/x/sync/errgroup"
)
// arrowContentType is the Content-Type of an Apache Arrow IPC stream listing
// response, used to tell whether the service honoured an Arrow request.
const arrowContentType = "application/vnd.apache.arrow.stream"
// listArrowParallel lists (containerName, directory) by splitting the blob name
// keyspace into shards and listing them concurrently. opts must already request
// the Arrow format; it is cloned per shard with StartFrom/EndBefore set. It
// returns the number of raw items seen. directory/prefix are already normalised
// by the caller.
func (f *Fs) listArrowParallel(ctx context.Context, containerName, directory, prefix string, addContainer bool, opts *arrowlist.ListBlobsHierarchyOptions, delimiter string, fn listFn) (int, error) {
// Parallel listing needs the arrowlist client (server-side endBefore is
// only honoured on the Arrow path) - without it fall back to sequential.
if _, err := f.arrowCntSVC(containerName); err != nil {
fs.Debugf(f, "Not using parallel Arrow listing: %v", err)
return f.listBlobsPager(ctx, containerName, directory, prefix, addContainer, opts, delimiter, fn)
}
func (f *Fs) listArrowParallel(ctx context.Context, containerName, directory, prefix string, addContainer bool, opts *container.ListBlobsHierarchyOptions, delimiter string, fn listFn) (int, error) {
// Split the keyspace by a single-case character ladder (see
// arrowLadderBoundaries for why single-case matters).
points := arrowLadderBoundaries(directory, arrowShardTarget(f.opt.ListParallelism))
@@ -115,16 +111,13 @@ func (f *Fs) listArrowParallel(ctx context.Context, containerName, directory, pr
// isEndBeforeUnsupported reports whether err means the service can't honour
// the endBefore listing bound, so sharded parallel listing must not be used.
//
// This happens two ways: the arrowlist pager's ErrEndBeforeXMLFallback (the
// server answered XML with endBefore set), or the service rejecting the
// endBefore parameter with 400 OperationNotSupportedWithFeatureMissing "The
// requested operation is not allowed as EndBefore parameter support is
// missing on this account" - endBefore is only supported on the Arrow
// listing path of accounts with Blob Listing with Apache Arrow enabled.
// The service rejects the endBefore parameter with 400
// OperationNotSupportedWithFeatureMissing "The requested operation is not
// allowed as EndBefore parameter support is missing on this account" -
// endBefore is only supported on the Arrow listing path of accounts with
// Blob Listing with Apache Arrow enabled. (The SDK pager would silently
// accept an XML page with endBefore set, but the service never sends one.)
func isEndBeforeUnsupported(err error) bool {
if errors.Is(err, arrowlist.ErrEndBeforeXMLFallback) {
return true
}
var respErr *azcore.ResponseError
return errors.As(err, &respErr) && respErr.ErrorCode == "OperationNotSupportedWithFeatureMissing"
}
+1 -3
View File
@@ -10,7 +10,6 @@ import (
"testing"
"github.com/Azure/azure-sdk-for-go/sdk/azcore"
"github.com/rclone/rclone/backend/azureblob/arrowlist"
"github.com/rclone/rclone/fs"
"github.com/rclone/rclone/fstest"
"github.com/rclone/rclone/fstest/fstests"
@@ -212,9 +211,8 @@ func TestArrowLadderBoundaries(t *testing.T) {
}
func TestIsEndBeforeUnsupported(t *testing.T) {
assert.True(t, isEndBeforeUnsupported(arrowlist.ErrEndBeforeXMLFallback))
assert.True(t, isEndBeforeUnsupported(fmt.Errorf("wrapped: %w", arrowlist.ErrEndBeforeXMLFallback)))
assert.True(t, isEndBeforeUnsupported(&azcore.ResponseError{ErrorCode: "OperationNotSupportedWithFeatureMissing"}))
assert.True(t, isEndBeforeUnsupported(fmt.Errorf("wrapped: %w", &azcore.ResponseError{ErrorCode: "OperationNotSupportedWithFeatureMissing"})))
assert.False(t, isEndBeforeUnsupported(&azcore.ResponseError{ErrorCode: "ContainerNotFound"}))
assert.False(t, isEndBeforeUnsupported(errors.New("some other error")))
assert.False(t, isEndBeforeUnsupported(nil))
-421
View File
@@ -1,421 +0,0 @@
//go:build !plan9 && !js
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License. See License.txt in the project root for license information.
//
// Adapted from internal/arrow/arrow.go in the Azure SDK for Go
// github.com/Azure/azure-sdk-for-go/sdk/storage/azblob at commit
// c6fa341ca22b (branch feature/storage/bifrost), retyped onto the SDK's
// public type aliases so it compiles against the released azblob module.
package arrowlist
import (
"encoding/base64"
"fmt"
"io"
"net/http"
"strconv"
"time"
"github.com/Azure/azure-sdk-for-go/sdk/azcore"
"github.com/Azure/azure-sdk-for-go/sdk/storage/azblob/blob"
"github.com/Azure/azure-sdk-for-go/sdk/storage/azblob/container"
"github.com/Azure/azure-sdk-for-go/sdk/storage/azblob/lease"
"github.com/apache/arrow-go/v18/arrow"
"github.com/apache/arrow-go/v18/arrow/array"
"github.com/apache/arrow-go/v18/arrow/ipc"
"github.com/apache/arrow-go/v18/arrow/memory"
)
const (
// ArrowContentType is the Content-Type for Apache Arrow IPC stream responses.
ArrowContentType = "application/vnd.apache.arrow.stream"
// ArrowAcceptHeader is the Accept header value to request Arrow format.
ArrowAcceptHeader = ArrowContentType
// resourceTypeBlobPrefix is the ResourceType value that identifies a virtual directory prefix
// in hierarchy listing responses. In XML, prefixes are separate <BlobPrefix> elements;
// in Arrow, all rows share the same schema and are distinguished by this field value.
resourceTypeBlobPrefix = "blobprefix"
)
// HandleFlatListResponse parses an Arrow IPC stream response into a ContainerClientListBlobFlatSegmentResponse.
func HandleFlatListResponse(resp *http.Response) (container.ListBlobsFlatResponse, error) {
result := container.ListBlobsFlatResponse{}
// Extract response headers
extractResponseHeaders(resp, &result.ClientRequestID, &result.ContentType, &result.Date, &result.RequestID, &result.Version)
// Parse Arrow IPC stream
items, nextMarker, err := parseArrowStream(resp.Body)
if err != nil {
return result, fmt.Errorf("failed to parse Arrow IPC response: %w", err)
}
result.ListBlobsFlatSegmentResponse = container.ListBlobsFlatSegmentResponse{
Segment: &container.BlobFlatListSegment{
BlobItems: items,
},
NextMarker: nextMarker,
}
return result, nil
}
// HandleHierarchyListResponse parses an Arrow IPC stream response into a ContainerClientListBlobHierarchySegmentResponse.
func HandleHierarchyListResponse(resp *http.Response) (container.ListBlobsHierarchyResponse, error) {
result := container.ListBlobsHierarchyResponse{}
extractResponseHeaders(resp, &result.ClientRequestID, &result.ContentType, &result.Date, &result.RequestID, &result.Version)
items, nextMarker, err := parseArrowStream(resp.Body)
if err != nil {
return result, fmt.Errorf("failed to parse Arrow IPC response: %w", err)
}
// Separate blob items from blob prefixes based on ResourceType field.
var blobItems []*container.BlobItem
var blobPrefixes []*container.BlobPrefix
for _, item := range items {
if item.Properties != nil && item.Properties.ResourceType != nil && *item.Properties.ResourceType == resourceTypeBlobPrefix {
blobPrefixes = append(blobPrefixes, &container.BlobPrefix{
Name: item.Name,
Properties: item.Properties,
})
} else {
blobItems = append(blobItems, item)
}
}
result.ListBlobsHierarchySegmentResponse = container.ListBlobsHierarchySegmentResponse{
Segment: &container.BlobHierarchyListSegment{
BlobItems: blobItems,
BlobPrefixes: blobPrefixes,
},
NextMarker: nextMarker,
}
return result, nil
}
func extractResponseHeaders(resp *http.Response, clientRequestID, contentType **string, date **time.Time, requestID, version **string) {
if val := resp.Header.Get("x-ms-client-request-id"); val != "" {
*clientRequestID = &val
}
if val := resp.Header.Get("Content-Type"); val != "" {
*contentType = &val
}
if val := resp.Header.Get("Date"); val != "" {
if t, err := time.Parse(time.RFC1123, val); err == nil {
*date = &t
}
}
if val := resp.Header.Get("x-ms-request-id"); val != "" {
*requestID = &val
}
if val := resp.Header.Get("x-ms-version"); val != "" {
*version = &val
}
}
// parseArrowStream reads an Arrow IPC stream and converts it to BlobItems.
func parseArrowStream(body io.Reader) ([]*container.BlobItem, *string, error) {
reader, err := ipc.NewReader(body, ipc.WithAllocator(memory.DefaultAllocator))
if err != nil {
return nil, nil, fmt.Errorf("failed to create Arrow IPC reader: %w", err)
}
defer reader.Release()
// Extract NextMarker from schema metadata
var nextMarker *string
var numRecords int
if md := reader.Schema().Metadata(); md.Len() > 0 {
if idx := md.FindKey("NextMarker"); idx >= 0 {
val := md.Values()[idx]
if val != "" {
nextMarker = &val
}
}
if idx := md.FindKey("NumberOfRecords"); idx >= 0 {
if n, err := strconv.Atoi(md.Values()[idx]); err == nil {
numRecords = n
}
}
}
// Build column index for the schema
schema := reader.Schema()
colIndex := buildColumnIndex(schema)
// Pre-allocate if we know the count
items := make([]*container.BlobItem, 0, numRecords)
// Iterate over record batches
for reader.Next() {
rec := reader.RecordBatch()
rows := int(rec.NumRows())
for row := range rows {
item := &container.BlobItem{
Properties: &container.BlobProperties{},
}
populateBlobItem(item, rec, row, colIndex)
items = append(items, item)
}
}
if err := reader.Err(); err != nil {
return nil, nil, fmt.Errorf("error reading Arrow record batches: %w", err)
}
return items, nextMarker, nil
}
// columnIndex maps column names to their indices in the record batch for O(1) lookup.
type columnIndex map[string]int
func buildColumnIndex(schema *arrow.Schema) columnIndex {
idx := make(columnIndex, len(schema.Fields()))
for i, f := range schema.Fields() {
idx[f.Name] = i
}
return idx
}
// populateBlobItem fills a BlobItem from a single row across all columns.
func populateBlobItem(item *container.BlobItem, rec arrow.RecordBatch, row int, colIdx columnIndex) {
// Top-level BlobItem fields
item.Name = getStringField(rec, row, colIdx, "Name")
item.Deleted = getBoolField(rec, row, colIdx, "Deleted")
item.Snapshot = getStringField(rec, row, colIdx, "Snapshot")
item.VersionID = getStringField(rec, row, colIdx, "VersionId")
item.IsCurrentVersion = getBoolField(rec, row, colIdx, "IsCurrentVersion")
item.HasVersionsOnly = getBoolField(rec, row, colIdx, "HasVersionsOnly")
// Metadata maps
item.Metadata = getMapField(rec, row, colIdx, "Metadata")
item.OrMetadata = getMapField(rec, row, colIdx, "OrMetadata")
// Tags
if tags := getMapField(rec, row, colIdx, "Tags"); tags != nil {
var blobTags []*container.BlobTag
for k, v := range tags {
key := k
blobTags = append(blobTags, &container.BlobTag{Key: &key, Value: v})
}
item.BlobTags = &container.BlobTags{BlobTagSet: blobTags}
}
// BlobProperties fields
p := item.Properties
p.CreationTime = getTimestampField(rec, row, colIdx, "Creation-Time")
p.LastModified = getTimestampField(rec, row, colIdx, "Last-Modified")
p.ETag = getETagField(rec, row, colIdx, "Etag")
p.ContentLength = getInt64Field(rec, row, colIdx, "Content-Length")
p.ContentType = getStringField(rec, row, colIdx, "Content-Type")
p.ContentEncoding = getStringField(rec, row, colIdx, "Content-Encoding")
p.ContentLanguage = getStringField(rec, row, colIdx, "Content-Language")
p.ContentDisposition = getStringField(rec, row, colIdx, "Content-Disposition")
p.CacheControl = getStringField(rec, row, colIdx, "Cache-Control")
p.ContentMD5 = getBase64BytesField(rec, row, colIdx, "Content-MD5")
p.BlobType = getEnumField[container.BlobType](rec, row, colIdx, "BlobType")
p.AccessTier = getEnumField[container.AccessTier](rec, row, colIdx, "AccessTier")
p.AccessTierInferred = getBoolField(rec, row, colIdx, "AccessTierInferred")
p.AccessTierChangeTime = getTimestampField(rec, row, colIdx, "AccessTierChangeTime")
p.LeaseState = getEnumField[lease.StateType](rec, row, colIdx, "LeaseState")
p.LeaseStatus = getEnumField[lease.StatusType](rec, row, colIdx, "LeaseStatus")
p.LeaseDuration = getEnumField[lease.DurationType](rec, row, colIdx, "LeaseDuration")
p.ServerEncrypted = getBoolField(rec, row, colIdx, "ServerEncrypted")
p.CustomerProvidedKeySHA256 = getStringField(rec, row, colIdx, "CustomerProvidedKeySha256")
p.EncryptionScope = getStringField(rec, row, colIdx, "EncryptionScope")
p.IncrementalCopy = getBoolField(rec, row, colIdx, "IncrementalCopy")
p.IsSealed = getBoolField(rec, row, colIdx, "Sealed")
p.ArchiveStatus = getEnumField[container.ArchiveStatus](rec, row, colIdx, "ArchiveStatus")
p.RehydratePriority = getEnumField[blob.RehydratePriority](rec, row, colIdx, "RehydratePriority")
p.CopyID = getStringField(rec, row, colIdx, "CopyId")
p.CopyStatus = getEnumField[blob.CopyStatusType](rec, row, colIdx, "CopyStatus")
p.CopySource = getStringField(rec, row, colIdx, "CopySource")
p.CopyProgress = getStringField(rec, row, colIdx, "CopyProgress")
p.CopyCompletionTime = getTimestampField(rec, row, colIdx, "CopyCompletionTime")
p.CopyStatusDescription = getStringField(rec, row, colIdx, "CopyStatusDescription")
p.DestinationSnapshot = getStringField(rec, row, colIdx, "CopyDestinationSnapshot")
p.ImmutabilityPolicyExpiresOn = getTimestampField(rec, row, colIdx, "ImmutabilityPolicyUntilDate")
p.ImmutabilityPolicyMode = getEnumField[container.ImmutabilityPolicyMode](rec, row, colIdx, "ImmutabilityPolicyMode")
p.LegalHold = getBoolField(rec, row, colIdx, "LegalHold")
p.DeletedTime = getTimestampField(rec, row, colIdx, "DeletedTime")
p.RemainingRetentionDays = getInt32Field(rec, row, colIdx, "RemainingRetentionDays")
p.LastAccessedOn = getTimestampField(rec, row, colIdx, "LastAccessTime")
p.TagCount = getInt32Field(rec, row, colIdx, "TagCount")
p.BlobSequenceNumber = getInt64Field(rec, row, colIdx, "x-ms-blob-sequence-number")
p.ResourceType = getStringField(rec, row, colIdx, "ResourceType")
// Content-CRC64 is a string field in Arrow but not directly mapped to BlobProperties.
// The field exists in the Arrow schema but has no corresponding field in the Go SDK BlobProperties.
// It is silently ignored for forward compatibility.
}
// Field accessor helpers that safely handle missing columns and null values.
func getStringField(rec arrow.RecordBatch, row int, colIdx columnIndex, name string) *string {
idx, ok := colIdx[name]
if !ok {
return nil
}
col := rec.Column(idx)
if col.IsNull(row) {
return nil
}
if arr, ok := col.(*array.String); ok {
val := arr.Value(row)
return &val
}
return nil
}
func getBoolField(rec arrow.RecordBatch, row int, colIdx columnIndex, name string) *bool {
idx, ok := colIdx[name]
if !ok {
return nil
}
col := rec.Column(idx)
if col.IsNull(row) {
return nil
}
if arr, ok := col.(*array.Boolean); ok {
val := arr.Value(row)
return &val
}
return nil
}
func getTimestampField(rec arrow.RecordBatch, row int, colIdx columnIndex, name string) *time.Time {
idx, ok := colIdx[name]
if !ok {
return nil
}
col := rec.Column(idx)
if col.IsNull(row) {
return nil
}
if arr, ok := col.(*array.Timestamp); ok {
val := arr.Value(row)
// Arrow timestamps are stored as int64 with a unit; convert to time.Time
dt := val.ToTime(arr.DataType().(*arrow.TimestampType).Unit)
return &dt
}
return nil
}
func getInt64Field(rec arrow.RecordBatch, row int, colIdx columnIndex, name string) *int64 {
idx, ok := colIdx[name]
if !ok {
return nil
}
col := rec.Column(idx)
if col.IsNull(row) {
return nil
}
if arr, ok := col.(*array.Uint64); ok {
val := int64(arr.Value(row))
return &val
}
if arr, ok := col.(*array.Int64); ok {
val := arr.Value(row)
return &val
}
return nil
}
func getInt32Field(rec arrow.RecordBatch, row int, colIdx columnIndex, name string) *int32 {
idx, ok := colIdx[name]
if !ok {
return nil
}
col := rec.Column(idx)
if col.IsNull(row) {
return nil
}
if arr, ok := col.(*array.Uint64); ok {
val := int32(arr.Value(row))
return &val
}
if arr, ok := col.(*array.Int32); ok {
val := arr.Value(row)
return &val
}
return nil
}
func getETagField(rec arrow.RecordBatch, row int, colIdx columnIndex, name string) *azcore.ETag {
s := getStringField(rec, row, colIdx, name)
if s == nil {
return nil
}
etag := azcore.ETag(*s)
return &etag
}
func getBase64BytesField(rec arrow.RecordBatch, row int, colIdx columnIndex, name string) []byte {
s := getStringField(rec, row, colIdx, name)
if s == nil {
return nil
}
decoded, err := base64.StdEncoding.DecodeString(*s)
if err != nil {
return nil
}
return decoded
}
// getEnumField returns a pointer to a string-typed enum value.
type stringEnum interface {
~string
}
func getEnumField[T stringEnum](rec arrow.RecordBatch, row int, colIdx columnIndex, name string) *T {
s := getStringField(rec, row, colIdx, name)
if s == nil {
return nil
}
val := T(*s)
return &val
}
func getMapField(rec arrow.RecordBatch, row int, colIdx columnIndex, name string) map[string]*string {
idx, ok := colIdx[name]
if !ok {
return nil
}
col := rec.Column(idx)
if col.IsNull(row) {
return nil
}
mapArr, ok := col.(*array.Map)
if !ok {
return nil
}
// Get the offsets for this row's map entries
start := int(mapArr.Offsets()[row])
end := int(mapArr.Offsets()[row+1])
if start == end {
return nil
}
keys := mapArr.Keys().(*array.String)
values := mapArr.Items().(*array.String)
result := make(map[string]*string, end-start)
for i := start; i < end; i++ {
k := keys.Value(i)
if values.IsNull(i) {
result[k] = nil
} else {
v := values.Value(i)
result[k] = &v
}
}
return result
}
-374
View File
@@ -1,374 +0,0 @@
//go:build !plan9 && !js
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License. See License.txt in the project root for license information.
//
// Adapted from internal/arrow/arrow_test.go in the Azure SDK for Go
// github.com/Azure/azure-sdk-for-go/sdk/storage/azblob at commit
// c6fa341ca22b (branch feature/storage/bifrost).
package arrowlist
import (
"bytes"
"io"
"net/http"
"testing"
"github.com/apache/arrow-go/v18/arrow"
arrowArray "github.com/apache/arrow-go/v18/arrow/array"
"github.com/apache/arrow-go/v18/arrow/ipc"
"github.com/apache/arrow-go/v18/arrow/memory"
"github.com/stretchr/testify/require"
)
// coreSchema returns a basic Arrow schema for testing with common blob fields.
func coreSchema(md *arrow.Metadata) *arrow.Schema {
fields := []arrow.Field{
{Name: "Name", Type: arrow.BinaryTypes.String, Nullable: false},
{Name: "Creation-Time", Type: &arrow.TimestampType{Unit: arrow.Microsecond}, Nullable: true},
{Name: "Last-Modified", Type: &arrow.TimestampType{Unit: arrow.Microsecond}, Nullable: true},
{Name: "BlobType", Type: arrow.BinaryTypes.String, Nullable: true},
{Name: "Etag", Type: arrow.BinaryTypes.String, Nullable: true},
{Name: "Content-Length", Type: arrow.PrimitiveTypes.Uint64, Nullable: true},
{Name: "Content-Type", Type: arrow.BinaryTypes.String, Nullable: true},
{Name: "ServerEncrypted", Type: arrow.FixedWidthTypes.Boolean, Nullable: true},
{Name: "AccessTier", Type: arrow.BinaryTypes.String, Nullable: true},
{Name: "LeaseState", Type: arrow.BinaryTypes.String, Nullable: true},
{Name: "LeaseStatus", Type: arrow.BinaryTypes.String, Nullable: true},
}
return arrow.NewSchema(fields, md)
}
// buildArrowStream constructs a synthetic Arrow IPC stream from a schema and a populate function.
func buildArrowStream(t *testing.T, schema *arrow.Schema, populate func(builder *arrowArray.RecordBuilder)) []byte {
t.Helper()
alloc := memory.DefaultAllocator
var buf bytes.Buffer
w := ipc.NewWriter(&buf, ipc.WithSchema(schema), ipc.WithAllocator(alloc))
builder := arrowArray.NewRecordBuilder(alloc, schema)
defer builder.Release()
populate(builder)
rec := builder.NewRecordBatch()
defer rec.Release()
require.NoError(t, w.Write(rec))
require.NoError(t, w.Close())
return buf.Bytes()
}
func makeHTTPResponse(data []byte, headers map[string]string) *http.Response {
h := http.Header{}
h.Set("Content-Type", ArrowContentType)
for k, v := range headers {
h.Set(k, v)
}
return &http.Response{
StatusCode: http.StatusOK,
Header: h,
Body: io.NopCloser(bytes.NewReader(data)),
}
}
func TestHandleFlatListResponse_BasicParsing(t *testing.T) {
md := arrow.NewMetadata([]string{"NextMarker", "NumberOfRecords"}, []string{"marker123", "2"})
schema := coreSchema(&md)
data := buildArrowStream(t, schema, func(b *arrowArray.RecordBuilder) {
b.Field(0).(*arrowArray.StringBuilder).Append("blob1.txt")
b.Field(1).(*arrowArray.TimestampBuilder).Append(arrow.Timestamp(1700000000000000))
b.Field(2).(*arrowArray.TimestampBuilder).Append(arrow.Timestamp(1700000000000000))
b.Field(3).(*arrowArray.StringBuilder).Append("BlockBlob")
b.Field(4).(*arrowArray.StringBuilder).Append("0x1234")
b.Field(5).(*arrowArray.Uint64Builder).Append(1024)
b.Field(6).(*arrowArray.StringBuilder).Append("application/octet-stream")
b.Field(7).(*arrowArray.BooleanBuilder).Append(true)
b.Field(8).(*arrowArray.StringBuilder).Append("Hot")
b.Field(9).(*arrowArray.StringBuilder).Append("available")
b.Field(10).(*arrowArray.StringBuilder).Append("unlocked")
b.Field(0).(*arrowArray.StringBuilder).Append("blob2.txt")
b.Field(1).(*arrowArray.TimestampBuilder).Append(arrow.Timestamp(1700000001000000))
b.Field(2).(*arrowArray.TimestampBuilder).Append(arrow.Timestamp(1700000001000000))
b.Field(3).(*arrowArray.StringBuilder).Append("BlockBlob")
b.Field(4).(*arrowArray.StringBuilder).Append("0x5678")
b.Field(5).(*arrowArray.Uint64Builder).Append(2048)
b.Field(6).(*arrowArray.StringBuilder).Append("text/plain")
b.Field(7).(*arrowArray.BooleanBuilder).Append(false)
b.Field(8).(*arrowArray.StringBuilder).Append("Cool")
b.Field(9).(*arrowArray.StringBuilder).Append("available")
b.Field(10).(*arrowArray.StringBuilder).Append("unlocked")
})
resp := makeHTTPResponse(data, nil)
result, err := HandleFlatListResponse(resp)
require.NoError(t, err)
require.NotNil(t, result.NextMarker)
require.Equal(t, "marker123", *result.NextMarker)
require.Len(t, result.Segment.BlobItems, 2)
item := result.Segment.BlobItems[0]
require.Equal(t, "blob1.txt", *item.Name)
require.Equal(t, "BlockBlob", string(*item.Properties.BlobType))
require.Equal(t, int64(1024), *item.Properties.ContentLength)
require.Equal(t, "Hot", string(*item.Properties.AccessTier))
}
func TestHandleFlatListResponse_EmptyRecordBatch(t *testing.T) {
md := arrow.NewMetadata([]string{"NextMarker", "NumberOfRecords"}, []string{"", "0"})
schema := coreSchema(&md)
data := buildArrowStream(t, schema, func(b *arrowArray.RecordBuilder) {
// No rows added
})
resp := makeHTTPResponse(data, nil)
result, err := HandleFlatListResponse(resp)
require.NoError(t, err)
require.Nil(t, result.NextMarker)
require.Empty(t, result.Segment.BlobItems)
}
func TestHandleFlatListResponse_NullFields(t *testing.T) {
md := arrow.NewMetadata([]string{"NextMarker"}, []string{""})
schema := coreSchema(&md)
data := buildArrowStream(t, schema, func(b *arrowArray.RecordBuilder) {
b.Field(0).(*arrowArray.StringBuilder).Append("blob.txt")
b.Field(1).(*arrowArray.TimestampBuilder).AppendNull()
b.Field(2).(*arrowArray.TimestampBuilder).AppendNull()
b.Field(3).(*arrowArray.StringBuilder).AppendNull()
b.Field(4).(*arrowArray.StringBuilder).AppendNull()
b.Field(5).(*arrowArray.Uint64Builder).AppendNull()
b.Field(6).(*arrowArray.StringBuilder).AppendNull()
b.Field(7).(*arrowArray.BooleanBuilder).AppendNull()
b.Field(8).(*arrowArray.StringBuilder).AppendNull()
b.Field(9).(*arrowArray.StringBuilder).AppendNull()
b.Field(10).(*arrowArray.StringBuilder).AppendNull()
})
resp := makeHTTPResponse(data, nil)
result, err := HandleFlatListResponse(resp)
require.NoError(t, err)
require.Len(t, result.Segment.BlobItems, 1)
item := result.Segment.BlobItems[0]
require.Equal(t, "blob.txt", *item.Name)
require.Nil(t, item.Properties.CreationTime)
require.Nil(t, item.Properties.BlobType)
require.Nil(t, item.Properties.ETag)
require.Nil(t, item.Properties.ContentLength)
require.Nil(t, item.Properties.ContentType)
require.Nil(t, item.Properties.ServerEncrypted)
}
func TestHandleFlatListResponse_UnknownColumnsIgnored(t *testing.T) {
md := arrow.NewMetadata([]string{"NextMarker"}, []string{""})
fields := []arrow.Field{
{Name: "Name", Type: arrow.BinaryTypes.String, Nullable: false},
{Name: "FutureField", Type: arrow.BinaryTypes.String, Nullable: true},
}
schema := arrow.NewSchema(fields, &md)
data := buildArrowStream(t, schema, func(b *arrowArray.RecordBuilder) {
b.Field(0).(*arrowArray.StringBuilder).Append("blob.txt")
b.Field(1).(*arrowArray.StringBuilder).Append("some-future-value")
})
resp := makeHTTPResponse(data, nil)
result, err := HandleFlatListResponse(resp)
require.NoError(t, err)
require.Len(t, result.Segment.BlobItems, 1)
require.Equal(t, "blob.txt", *result.Segment.BlobItems[0].Name)
}
func TestHandleFlatListResponse_MultiRecordBatch(t *testing.T) {
alloc := memory.DefaultAllocator
md := arrow.NewMetadata([]string{"NextMarker", "NumberOfRecords"}, []string{"", "3"})
schema := coreSchema(&md)
var buf bytes.Buffer
w := ipc.NewWriter(&buf, ipc.WithSchema(schema), ipc.WithAllocator(alloc))
// First batch: 2 rows
builder := arrowArray.NewRecordBuilder(alloc, schema)
for range 2 {
builder.Field(0).(*arrowArray.StringBuilder).Append("batch1_blob")
for j := 1; j < 11; j++ {
builder.Field(j).AppendNull()
}
}
rec := builder.NewRecordBatch()
require.NoError(t, w.Write(rec))
rec.Release()
builder.Release()
// Second batch: 1 row
builder = arrowArray.NewRecordBuilder(alloc, schema)
builder.Field(0).(*arrowArray.StringBuilder).Append("batch2_blob")
for j := 1; j < 11; j++ {
builder.Field(j).AppendNull()
}
rec = builder.NewRecordBatch()
require.NoError(t, w.Write(rec))
rec.Release()
builder.Release()
require.NoError(t, w.Close())
resp := makeHTTPResponse(buf.Bytes(), nil)
result, err := HandleFlatListResponse(resp)
require.NoError(t, err)
require.Len(t, result.Segment.BlobItems, 3)
}
func TestHandleHierarchyListResponse_SeparatesBlobsAndPrefixes(t *testing.T) {
md := arrow.NewMetadata([]string{"NextMarker"}, []string{""})
fields := []arrow.Field{
{Name: "Name", Type: arrow.BinaryTypes.String, Nullable: false},
{Name: "ResourceType", Type: arrow.BinaryTypes.String, Nullable: true},
}
schema := arrow.NewSchema(fields, &md)
data := buildArrowStream(t, schema, func(b *arrowArray.RecordBuilder) {
// A regular blob
b.Field(0).(*arrowArray.StringBuilder).Append("folder/file.txt")
b.Field(1).(*arrowArray.StringBuilder).AppendNull()
// A blob prefix (virtual directory)
b.Field(0).(*arrowArray.StringBuilder).Append("folder/subfolder/")
b.Field(1).(*arrowArray.StringBuilder).Append("blobprefix")
})
resp := makeHTTPResponse(data, nil)
result, err := HandleHierarchyListResponse(resp)
require.NoError(t, err)
require.Len(t, result.Segment.BlobItems, 1)
require.Equal(t, "folder/file.txt", *result.Segment.BlobItems[0].Name)
require.Len(t, result.Segment.BlobPrefixes, 1)
require.Equal(t, "folder/subfolder/", *result.Segment.BlobPrefixes[0].Name)
}
func TestHandleFlatListResponse_ResponseHeaders(t *testing.T) {
md := arrow.NewMetadata([]string{"NextMarker"}, []string{""})
fields := []arrow.Field{
{Name: "Name", Type: arrow.BinaryTypes.String, Nullable: false},
}
schema := arrow.NewSchema(fields, &md)
data := buildArrowStream(t, schema, func(b *arrowArray.RecordBuilder) {
b.Field(0).(*arrowArray.StringBuilder).Append("blob.txt")
})
resp := makeHTTPResponse(data, map[string]string{
"x-ms-request-id": "req-123",
"x-ms-version": "2026-10-06",
"x-ms-client-request-id": "client-req-456",
"Date": "Mon, 01 Jan 2024 00:00:00 GMT",
})
result, err := HandleFlatListResponse(resp)
require.NoError(t, err)
require.Equal(t, "req-123", *result.RequestID)
require.Equal(t, "2026-10-06", *result.Version)
require.Equal(t, "client-req-456", *result.ClientRequestID)
require.NotNil(t, result.Date)
}
func TestHandleFlatListResponse_MapFields(t *testing.T) {
md := arrow.NewMetadata([]string{"NextMarker"}, []string{""})
fields := []arrow.Field{
{Name: "Name", Type: arrow.BinaryTypes.String, Nullable: false},
{Name: "Metadata", Type: arrow.MapOf(arrow.BinaryTypes.String, arrow.BinaryTypes.String), Nullable: true},
{Name: "Tags", Type: arrow.MapOf(arrow.BinaryTypes.String, arrow.BinaryTypes.String), Nullable: true},
}
schema := arrow.NewSchema(fields, &md)
data := buildArrowStream(t, schema, func(b *arrowArray.RecordBuilder) {
b.Field(0).(*arrowArray.StringBuilder).Append("blob.txt")
metaBuilder := b.Field(1).(*arrowArray.MapBuilder)
metaBuilder.Append(true)
metaBuilder.KeyBuilder().(*arrowArray.StringBuilder).Append("key1")
metaBuilder.ItemBuilder().(*arrowArray.StringBuilder).Append("value1")
tagBuilder := b.Field(2).(*arrowArray.MapBuilder)
tagBuilder.Append(true)
tagBuilder.KeyBuilder().(*arrowArray.StringBuilder).Append("env")
tagBuilder.ItemBuilder().(*arrowArray.StringBuilder).Append("prod")
})
resp := makeHTTPResponse(data, nil)
result, err := HandleFlatListResponse(resp)
require.NoError(t, err)
require.Len(t, result.Segment.BlobItems, 1)
item := result.Segment.BlobItems[0]
require.NotNil(t, item.Metadata)
require.Equal(t, "value1", *item.Metadata["key1"])
require.NotNil(t, item.BlobTags)
require.Len(t, item.BlobTags.BlobTagSet, 1)
}
func TestHandleFlatListResponse_BooleanFields(t *testing.T) {
md := arrow.NewMetadata([]string{"NextMarker"}, []string{""})
fields := []arrow.Field{
{Name: "Name", Type: arrow.BinaryTypes.String, Nullable: false},
{Name: "Deleted", Type: arrow.FixedWidthTypes.Boolean, Nullable: true},
{Name: "IsCurrentVersion", Type: arrow.FixedWidthTypes.Boolean, Nullable: true},
{Name: "ServerEncrypted", Type: arrow.FixedWidthTypes.Boolean, Nullable: true},
}
schema := arrow.NewSchema(fields, &md)
data := buildArrowStream(t, schema, func(b *arrowArray.RecordBuilder) {
b.Field(0).(*arrowArray.StringBuilder).Append("blob.txt")
b.Field(1).(*arrowArray.BooleanBuilder).Append(true)
b.Field(2).(*arrowArray.BooleanBuilder).Append(false)
b.Field(3).(*arrowArray.BooleanBuilder).Append(true)
})
resp := makeHTTPResponse(data, nil)
result, err := HandleFlatListResponse(resp)
require.NoError(t, err)
item := result.Segment.BlobItems[0]
require.True(t, *item.Deleted)
require.False(t, *item.IsCurrentVersion)
require.True(t, *item.Properties.ServerEncrypted)
}
func TestHandleFlatListResponse_VersionAndSnapshot(t *testing.T) {
md := arrow.NewMetadata([]string{"NextMarker"}, []string{""})
fields := []arrow.Field{
{Name: "Name", Type: arrow.BinaryTypes.String, Nullable: false},
{Name: "VersionId", Type: arrow.BinaryTypes.String, Nullable: true},
{Name: "Snapshot", Type: arrow.BinaryTypes.String, Nullable: true},
{Name: "IsCurrentVersion", Type: arrow.FixedWidthTypes.Boolean, Nullable: true},
}
schema := arrow.NewSchema(fields, &md)
data := buildArrowStream(t, schema, func(b *arrowArray.RecordBuilder) {
b.Field(0).(*arrowArray.StringBuilder).Append("versioned-blob.txt")
b.Field(1).(*arrowArray.StringBuilder).Append("2024-01-01T00:00:00.0000000Z")
b.Field(2).(*arrowArray.StringBuilder).Append("snap-123")
b.Field(3).(*arrowArray.BooleanBuilder).Append(true)
})
resp := makeHTTPResponse(data, nil)
result, err := HandleFlatListResponse(resp)
require.NoError(t, err)
item := result.Segment.BlobItems[0]
require.Equal(t, "2024-01-01T00:00:00.0000000Z", *item.VersionID)
require.Equal(t, "snap-123", *item.Snapshot)
require.True(t, *item.IsCurrentVersion)
}
-259
View File
@@ -1,259 +0,0 @@
//go:build !plan9 && !js
// Package arrowlist implements the experimental "Blob Listing with Apache
// Arrow" feature on top of the released Azure azblob SDK.
//
// The Azure SDK for Go has support for Arrow listing on the unreleased
// feature/storage/bifrost branch (commit c6fa341ca22b) where the listing
// pagers decode Arrow IPC stream responses into the ordinary ListBlobs
// response types. Until that ships in a tagged azblob release this package
// fills the gap: it exposes the same options and pager interface as the
// experimental SDK, built only on the public API of the released SDK plus
// copies of two small internal auth policies (see sharedkey.go and
// challenge_policy.go).
//
// This package is temporary. When Arrow listing ships in a released azblob
// the whole package should be deleted and its callers pointed back at
// container.Client.NewListBlobsHierarchyPager - the option and response
// types here are deliberately source compatible with the SDK's to make that
// a mechanical change.
package arrowlist
import (
"context"
"errors"
"net/http"
"strconv"
"strings"
"github.com/Azure/azure-sdk-for-go/sdk/azcore"
"github.com/Azure/azure-sdk-for-go/sdk/azcore/policy"
"github.com/Azure/azure-sdk-for-go/sdk/azcore/runtime"
"github.com/Azure/azure-sdk-for-go/sdk/storage/azblob/container"
)
const (
// moduleName and moduleVersion identify this package in the azcore
// telemetry policy's User-Agent fragment.
moduleName = "github.com/rclone/rclone/backend/azureblob/arrowlist"
moduleVersion = "v0.0.1"
// serviceVersion is the x-ms-version sent with listing requests. Arrow
// responses and the startFrom/endBefore parameters need at least
// 2026-06-06.
serviceVersion = "2026-06-06"
// tokenScope is the OAuth scope for Azure Storage, as used by the SDK's
// own clients.
tokenScope = "https://storage.azure.com/.default"
)
// ErrEndBeforeXMLFallback is returned by the pager when the server answered
// with XML instead of Arrow while EndBefore was set. The service only honours
// endBefore on the Arrow listing path, so an XML page could silently contain
// blobs outside the requested range and cannot be used.
var ErrEndBeforeXMLFallback = errors.New("arrow listing fell back to XML with endBefore set")
// ListBlobsHierarchyOptions provides the configuration for
// Client.NewListBlobsHierarchyPager.
//
// It mirrors the experimental SDK's container.ListBlobsHierarchyOptions:
// the released SDK's options are embedded (providing Include, Marker,
// MaxResults, Prefix and StartFrom by promotion) with the two fields the
// released SDK lacks added alongside.
type ListBlobsHierarchyOptions struct {
container.ListBlobsHierarchyOptions
// EndBefore limits listing to blobs whose full container path is
// lexically before it (exclusive). Only honoured on Arrow responses.
EndBefore *string
// UseArrowFormat requests the response as an Apache Arrow IPC stream.
UseArrowFormat *bool
}
// ClientOptions contains the optional parameters for client creation.
type ClientOptions struct {
azcore.ClientOptions
}
// Client issues Arrow listing requests to a single container.
type Client struct {
endpoint string
azClient *azcore.Client
}
// newClient creates a Client for the container at containerURL sending
// requests through a pipeline with the given auth policy (which may be nil
// for anonymous or SAS access).
func newClient(containerURL string, authPolicy policy.Policy, options *ClientOptions) (*Client, error) {
if options == nil {
options = &ClientOptions{}
}
plOpts := runtime.PipelineOptions{}
if authPolicy != nil {
plOpts.PerRetry = []policy.Policy{authPolicy}
}
azClient, err := azcore.NewClient(moduleName, moduleVersion, plOpts, &options.ClientOptions)
if err != nil {
return nil, err
}
return &Client{
endpoint: containerURL,
azClient: azClient,
}, nil
}
// NewClient creates a Client authenticating with a token credential.
func NewClient(containerURL string, cred azcore.TokenCredential, options *ClientOptions) (*Client, error) {
return newClient(containerURL, NewStorageChallengePolicy(cred, tokenScope, false), options)
}
// NewClientWithNoCredential creates a Client with no authentication, for
// anonymous access or a containerURL carrying a SAS token in its query.
func NewClientWithNoCredential(containerURL string, options *ClientOptions) (*Client, error) {
return newClient(containerURL, nil, options)
}
// NewClientWithSharedKeyCredential creates a Client signing requests with the
// account's shared key.
func NewClientWithSharedKeyCredential(containerURL string, cred *SharedKeyCredential, options *ClientOptions) (*Client, error) {
return newClient(containerURL, NewSharedKeyCredPolicy(cred), options)
}
// NewListBlobsHierarchyPager returns a pager over the container's blobs,
// requesting Arrow responses when o.UseArrowFormat is set and returning the
// same response type as container.Client.NewListBlobsHierarchyPager.
//
// If the service does not support Arrow listing (the feature not enabled on
// the account, or an HNS account) it answers with XML, which is decoded
// transparently - unless EndBefore is set, in which case the fetcher returns
// ErrEndBeforeXMLFallback (see there).
func (c *Client) NewListBlobsHierarchyPager(delimiter string, o *ListBlobsHierarchyOptions) *runtime.Pager[container.ListBlobsHierarchyResponse] {
// Copy the options so advancing the Marker doesn't mutate the caller's struct.
opts := ListBlobsHierarchyOptions{}
if o != nil {
opts = *o
}
useArrow := opts.UseArrowFormat != nil && *opts.UseArrowFormat
return runtime.NewPager(runtime.PagingHandler[container.ListBlobsHierarchyResponse]{
More: func(page container.ListBlobsHierarchyResponse) bool {
return page.NextMarker != nil && len(*page.NextMarker) > 0
},
Fetcher: func(ctx context.Context, page *container.ListBlobsHierarchyResponse) (container.ListBlobsHierarchyResponse, error) {
if page != nil {
opts.Marker = page.NextMarker
}
req, err := c.listCreateRequest(ctx, delimiter, &opts, useArrow)
if err != nil {
return container.ListBlobsHierarchyResponse{}, err
}
resp, err := c.azClient.Pipeline().Do(req)
if err != nil {
return container.ListBlobsHierarchyResponse{}, err
}
if !runtime.HasStatusCode(resp, http.StatusOK) {
return container.ListBlobsHierarchyResponse{}, runtime.NewResponseError(resp)
}
if useArrow && resp.Header.Get("Content-Type") == ArrowContentType {
return HandleHierarchyListResponse(resp)
}
if opts.EndBefore != nil {
return container.ListBlobsHierarchyResponse{}, ErrEndBeforeXMLFallback
}
return handleXMLResponse(resp)
},
})
}
// listCreateRequest builds the ListBlobs request, mirroring the generated
// request builders in the SDK.
func (c *Client) listCreateRequest(ctx context.Context, delimiter string, opts *ListBlobsHierarchyOptions, useArrow bool) (*policy.Request, error) {
req, err := runtime.NewRequest(ctx, http.MethodGet, c.endpoint)
if err != nil {
return nil, err
}
// Reading the existing query first preserves any SAS token in the endpoint.
reqQP := req.Raw().URL.Query()
reqQP.Set("comp", "list")
reqQP.Set("restype", "container")
reqQP.Set("delimiter", delimiter)
if include := formatInclude(opts.Include); include != "" {
reqQP.Set("include", include)
}
if opts.Marker != nil {
reqQP.Set("marker", *opts.Marker)
}
if opts.MaxResults != nil {
reqQP.Set("maxresults", strconv.FormatInt(int64(*opts.MaxResults), 10))
}
if opts.Prefix != nil {
reqQP.Set("prefix", *opts.Prefix)
}
if opts.StartFrom != nil {
reqQP.Set("startFrom", *opts.StartFrom)
}
if useArrow && opts.EndBefore != nil {
reqQP.Set("endBefore", *opts.EndBefore)
}
req.Raw().URL.RawQuery = strings.ReplaceAll(reqQP.Encode(), "+", "%20")
if useArrow {
// Stream the Arrow body rather than buffering it in the pipeline.
runtime.SkipBodyDownload(req)
req.Raw().Header["Accept"] = []string{ArrowAcceptHeader}
} else {
req.Raw().Header["Accept"] = []string{"application/xml"}
}
req.Raw().Header["x-ms-version"] = []string{serviceVersion}
return req, nil
}
// handleXMLResponse decodes an XML ListBlobs response, mirroring the SDK's
// generated ListBlobHierarchySegmentHandleResponse.
func handleXMLResponse(resp *http.Response) (container.ListBlobsHierarchyResponse, error) {
result := container.ListBlobsHierarchyResponse{}
extractResponseHeaders(resp, &result.ClientRequestID, &result.ContentType, &result.Date, &result.RequestID, &result.Version)
if err := runtime.UnmarshalAsXML(resp, &result.ListBlobsHierarchySegmentResponse); err != nil {
return container.ListBlobsHierarchyResponse{}, err
}
return result, nil
}
// formatInclude renders the include datasets as the comma separated query
// parameter value, in the same order as the SDK's unexported
// ListBlobsInclude.format.
func formatInclude(l container.ListBlobsInclude) string {
var include []string
if l.Copy {
include = append(include, "copy")
}
if l.Deleted {
include = append(include, "deleted")
}
if l.DeletedWithVersions {
include = append(include, "deletedwithversions")
}
if l.ImmutabilityPolicy {
include = append(include, "immutabilitypolicy")
}
if l.LegalHold {
include = append(include, "legalhold")
}
if l.Metadata {
include = append(include, "metadata")
}
if l.Snapshots {
include = append(include, "snapshots")
}
if l.Tags {
include = append(include, "tags")
}
if l.UncommittedBlobs {
include = append(include, "uncommittedblobs")
}
if l.Versions {
include = append(include, "versions")
}
if l.Permissions {
include = append(include, "permissions")
}
return strings.Join(include, ",")
}
@@ -1,198 +0,0 @@
//go:build !plan9 && !js
package arrowlist
import (
"context"
"encoding/base64"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/Azure/azure-sdk-for-go/sdk/storage/azblob/bloberror"
"github.com/Azure/azure-sdk-for-go/sdk/storage/azblob/container"
"github.com/apache/arrow-go/v18/arrow"
arrowArray "github.com/apache/arrow-go/v18/arrow/array"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// arrowPage builds a one row Arrow IPC listing page with the given blob name
// and NextMarker.
func arrowPage(t *testing.T, name, nextMarker string) []byte {
md := arrow.NewMetadata([]string{"NextMarker", "NumberOfRecords"}, []string{nextMarker, "1"})
fields := []arrow.Field{
{Name: "Name", Type: arrow.BinaryTypes.String, Nullable: false},
{Name: "Content-Length", Type: arrow.PrimitiveTypes.Uint64, Nullable: true},
}
schema := arrow.NewSchema(fields, &md)
return buildArrowStream(t, schema, func(b *arrowArray.RecordBuilder) {
b.Field(0).(*arrowArray.StringBuilder).Append(name)
b.Field(1).(*arrowArray.Uint64Builder).Append(42)
})
}
func TestPagerArrow(t *testing.T) {
var requests []*http.Request
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests = append(requests, r.Clone(context.Background()))
w.Header().Set("Content-Type", ArrowContentType)
if r.URL.Query().Get("marker") == "" {
_, _ = w.Write(arrowPage(t, "blob1.txt", "marker1"))
} else {
_, _ = w.Write(arrowPage(t, "blob2.txt", ""))
}
}))
defer srv.Close()
// SAS style query on the endpoint must be preserved in requests.
client, err := NewClientWithNoCredential(srv.URL+"/testcontainer?sv=fakesas", nil)
require.NoError(t, err)
opts := &ListBlobsHierarchyOptions{
ListBlobsHierarchyOptions: container.ListBlobsHierarchyOptions{
Include: container.ListBlobsInclude{Metadata: true, Tags: true},
Prefix: new("dir/"),
MaxResults: new(int32(1000)),
StartFrom: new("testcontainer/dir/a"),
},
EndBefore: new("testcontainer/dir/n"),
UseArrowFormat: new(true),
}
pager := client.NewListBlobsHierarchyPager("/", opts)
var names []string
for pager.More() {
page, err := pager.NextPage(context.Background())
require.NoError(t, err)
assert.Equal(t, ArrowContentType, *page.ContentType)
for _, item := range page.Segment.BlobItems {
names = append(names, *item.Name)
assert.Equal(t, int64(42), *item.Properties.ContentLength)
}
}
assert.Equal(t, []string{"blob1.txt", "blob2.txt"}, names)
require.Len(t, requests, 2)
q := requests[0].URL.Query()
assert.Equal(t, "list", q.Get("comp"))
assert.Equal(t, "container", q.Get("restype"))
assert.Equal(t, "/", q.Get("delimiter"))
assert.Equal(t, "metadata,tags", q.Get("include"))
assert.Equal(t, "dir/", q.Get("prefix"))
assert.Equal(t, "1000", q.Get("maxresults"))
assert.Equal(t, "testcontainer/dir/a", q.Get("startFrom"))
assert.Equal(t, "testcontainer/dir/n", q.Get("endBefore"))
assert.Equal(t, "fakesas", q.Get("sv"))
assert.Empty(t, q.Get("marker"))
assert.Equal(t, ArrowAcceptHeader, requests[0].Header.Get("Accept"))
assert.Equal(t, serviceVersion, requests[0].Header.Get("x-ms-version"))
assert.Equal(t, "marker1", requests[1].URL.Query().Get("marker"))
// The caller's options must not have been mutated by paging.
assert.Nil(t, opts.Marker)
}
const xmlListResponse = `<?xml version="1.0" encoding="utf-8"?>
<EnumerationResults ServiceEndpoint="https://example.com/" ContainerName="testcontainer">
<Blobs>
<Blob>
<Name>file.txt</Name>
<Properties>
<Last-Modified>Mon, 01 Jan 2024 00:00:00 GMT</Last-Modified>
<Etag>0x8D1</Etag>
<Content-Length>7</Content-Length>
<BlobType>BlockBlob</BlobType>
</Properties>
</Blob>
<BlobPrefix>
<Name>subdir/</Name>
</BlobPrefix>
</Blobs>
<NextMarker />
</EnumerationResults>`
func TestPagerXMLFallback(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/xml")
_, _ = w.Write([]byte(xmlListResponse))
}))
defer srv.Close()
client, err := NewClientWithNoCredential(srv.URL+"/testcontainer", nil)
require.NoError(t, err)
pager := client.NewListBlobsHierarchyPager("/", &ListBlobsHierarchyOptions{
UseArrowFormat: new(true),
})
page, err := pager.NextPage(context.Background())
require.NoError(t, err)
assert.False(t, pager.More())
assert.Equal(t, "application/xml", *page.ContentType)
require.Len(t, page.Segment.BlobItems, 1)
assert.Equal(t, "file.txt", *page.Segment.BlobItems[0].Name)
assert.Equal(t, int64(7), *page.Segment.BlobItems[0].Properties.ContentLength)
require.Len(t, page.Segment.BlobPrefixes, 1)
assert.Equal(t, "subdir/", *page.Segment.BlobPrefixes[0].Name)
}
func TestPagerXMLFallbackWithEndBefore(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/xml")
_, _ = w.Write([]byte(xmlListResponse))
}))
defer srv.Close()
client, err := NewClientWithNoCredential(srv.URL+"/testcontainer", nil)
require.NoError(t, err)
pager := client.NewListBlobsHierarchyPager("/", &ListBlobsHierarchyOptions{
EndBefore: new("testcontainer/n"),
UseArrowFormat: new(true),
})
_, err = pager.NextPage(context.Background())
require.ErrorIs(t, err, ErrEndBeforeXMLFallback)
}
func TestPagerSharedKeyAuth(t *testing.T) {
var gotAuth, gotDate string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotAuth = r.Header.Get("Authorization")
gotDate = r.Header.Get("x-ms-date")
w.Header().Set("Content-Type", ArrowContentType)
_, _ = w.Write(arrowPage(t, "blob.txt", ""))
}))
defer srv.Close()
cred, err := NewSharedKeyCredential("testaccount", base64.StdEncoding.EncodeToString([]byte("testkey")))
require.NoError(t, err)
client, err := NewClientWithSharedKeyCredential(srv.URL+"/testcontainer", cred, nil)
require.NoError(t, err)
pager := client.NewListBlobsHierarchyPager("/", &ListBlobsHierarchyOptions{
UseArrowFormat: new(true),
})
_, err = pager.NextPage(context.Background())
require.NoError(t, err)
assert.True(t, strings.HasPrefix(gotAuth, "SharedKey testaccount:"), "Authorization = %q", gotAuth)
assert.NotEmpty(t, gotDate)
}
func TestPagerResponseError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("x-ms-error-code", string(bloberror.ContainerNotFound))
w.WriteHeader(http.StatusNotFound)
}))
defer srv.Close()
client, err := NewClientWithNoCredential(srv.URL+"/testcontainer", nil)
require.NoError(t, err)
pager := client.NewListBlobsHierarchyPager("/", &ListBlobsHierarchyOptions{
UseArrowFormat: new(true),
})
_, err = pager.NextPage(context.Background())
require.Error(t, err)
assert.True(t, bloberror.HasCode(err, bloberror.ContainerNotFound), "expected ContainerNotFound, got %v", err)
}
@@ -1,121 +0,0 @@
//go:build !plan9 && !js
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License. See License.txt in the project root for license information.
//
// Adapted from internal/shared/challenge_policy.go in the Azure SDK for Go
// github.com/Azure/azure-sdk-for-go/sdk/storage/azblob at v1.8.0. The
// released SDK does not export its storage bearer-challenge policy, which is
// needed for token authentication with the same semantics as the SDK's own
// clients.
package arrowlist
import (
"errors"
"net/http"
"strings"
"github.com/Azure/azure-sdk-for-go/sdk/azcore"
"github.com/Azure/azure-sdk-for-go/sdk/azcore/policy"
"github.com/Azure/azure-sdk-for-go/sdk/azcore/runtime"
)
type storageAuthorizer struct {
scopes []string
tenantID string
}
// NewStorageChallengePolicy returns a bearer token policy for cred which
// handles the storage service's authentication challenges.
func NewStorageChallengePolicy(cred azcore.TokenCredential, audience string, allowHTTP bool) policy.Policy {
s := storageAuthorizer{scopes: []string{audience}}
return runtime.NewBearerTokenPolicy(cred, []string{audience}, &policy.BearerTokenOptions{
AuthorizationHandler: policy.AuthorizationHandler{
OnRequest: s.onRequest,
OnChallenge: s.onChallenge,
},
InsecureAllowCredentialWithHTTP: allowHTTP,
})
}
func (s *storageAuthorizer) onRequest(req *policy.Request, authNZ func(policy.TokenRequestOptions) error) error {
return authNZ(policy.TokenRequestOptions{Scopes: s.scopes})
}
func (s *storageAuthorizer) onChallenge(req *policy.Request, resp *http.Response, authNZ func(policy.TokenRequestOptions) error) error {
// parse the challenge
err := s.parseChallenge(resp)
if err != nil {
return err
}
// TODO: Set tenantID when policy.TokenRequestOptions supports it. https://github.com/Azure/azure-sdk-for-go/issues/19841
return authNZ(policy.TokenRequestOptions{Scopes: s.scopes})
}
type challengePolicyError struct {
err error
}
func (c *challengePolicyError) Error() string {
return c.err.Error()
}
func (*challengePolicyError) NonRetriable() {
// marker method
}
func (c *challengePolicyError) Unwrap() error {
return c.err
}
// parses Tenant ID from auth challenge
// https://login.microsoftonline.com/00000000-0000-0000-0000-000000000000/oauth2/authorize
func parseTenant(url string) string {
if url == "" {
return ""
}
parts := strings.Split(url, "/")
if len(parts) >= 3 {
tenant := parts[3]
tenant = strings.ReplaceAll(tenant, ",", "")
return tenant
}
return ""
}
func (s *storageAuthorizer) parseChallenge(resp *http.Response) error {
authHeader := resp.Header.Get("WWW-Authenticate")
if authHeader == "" {
return &challengePolicyError{err: errors.New("response has no WWW-Authenticate header for challenge authentication")}
}
// Strip down to auth and resource
// Format is "Bearer authorization_uri=\"<site>\" resource_id=\"<site>\""
authHeader = strings.ReplaceAll(authHeader, "Bearer ", "")
parts := strings.Split(authHeader, " ")
vals := map[string]string{}
for _, part := range parts {
subParts := strings.Split(part, "=")
if len(subParts) == 2 {
stripped := strings.ReplaceAll(subParts[1], "\"", "")
stripped = strings.TrimSuffix(stripped, ",")
vals[subParts[0]] = stripped
}
}
s.tenantID = parseTenant(vals["authorization_uri"])
scope := vals["resource_id"]
if scope == "" {
return &challengePolicyError{err: errors.New("could not find a valid resource in the WWW-Authenticate header")}
}
if !strings.HasSuffix(scope, "/.default") {
scope += "/.default"
}
s.scopes = []string{scope}
return nil
}
-318
View File
@@ -1,318 +0,0 @@
//go:build !plan9 && !js
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License. See License.txt in the project root for license information.
//
// Adapted from internal/exported/shared_key_credential.go in the Azure SDK
// for Go github.com/Azure/azure-sdk-for-go/sdk/storage/azblob at v1.8.0,
// with the internal header constants inlined. The released SDK does not
// export its shared key signing policy, so a copy is needed to sign the
// Arrow listing requests this package sends through its own pipeline.
package arrowlist
import (
"bytes"
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"fmt"
"net/http"
"net/url"
"sort"
"strings"
"sync/atomic"
"time"
"github.com/Azure/azure-sdk-for-go/sdk/azcore/policy"
"github.com/rclone/rclone/fs"
)
// NewSharedKeyCredential creates an immutable SharedKeyCredential containing the
// storage account's name and either its primary or secondary key.
func NewSharedKeyCredential(accountName string, accountKey string) (*SharedKeyCredential, error) {
c := SharedKeyCredential{accountName: accountName}
if err := c.SetAccountKey(accountKey); err != nil {
return nil, err
}
return &c, nil
}
// SharedKeyCredential contains an account's name and its primary or secondary key.
type SharedKeyCredential struct {
// Only the NewSharedKeyCredential method should set these; all other methods should treat them as read-only
accountName string
accountKey atomic.Value // []byte
}
// AccountName returns the Storage account's name.
func (c *SharedKeyCredential) AccountName() string {
return c.accountName
}
// SetAccountKey replaces the existing account key with the specified account key.
func (c *SharedKeyCredential) SetAccountKey(accountKey string) error {
_bytes, err := base64.StdEncoding.DecodeString(accountKey)
if err != nil {
return fmt.Errorf("decode account key: %w", err)
}
c.accountKey.Store(_bytes)
return nil
}
// ComputeHMACSHA256 generates a hash signature for an HTTP request or for a SAS.
func (c *SharedKeyCredential) computeHMACSHA256(message string) (string, error) {
h := hmac.New(sha256.New, c.accountKey.Load().([]byte))
_, err := h.Write([]byte(message))
return base64.StdEncoding.EncodeToString(h.Sum(nil)), err
}
func (c *SharedKeyCredential) buildStringToSign(req *http.Request) (string, error) {
// https://docs.microsoft.com/en-us/rest/api/storageservices/authentication-for-the-azure-storage-services
headers := req.Header
contentLength := getHeader("Content-Length", headers)
if contentLength == "0" {
contentLength = ""
}
canonicalizedResource, err := c.buildCanonicalizedResource(req.URL)
if err != nil {
return "", err
}
stringToSign := strings.Join([]string{
req.Method,
getHeader("Content-Encoding", headers),
getHeader("Content-Language", headers),
contentLength,
getHeader("Content-MD5", headers),
getHeader("Content-Type", headers),
"", // Empty date because x-ms-date is expected (as per web page above)
getHeader("If-Modified-Since", headers),
getHeader("If-Match", headers),
getHeader("If-None-Match", headers),
getHeader("If-Unmodified-Since", headers),
getHeader("Range", headers),
c.buildCanonicalizedHeader(headers),
canonicalizedResource,
}, "\n")
return stringToSign, nil
}
func getHeader(key string, headers map[string][]string) string {
if headers == nil {
return ""
}
if v, ok := headers[key]; ok {
if len(v) > 0 {
return v[0]
}
}
return ""
}
func getWeightTables() [][]int {
tableLv0 := [...]int{
0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0,
0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0,
0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x71c, 0x0, 0x71f, 0x721, 0x723, 0x725,
0x0, 0x0, 0x0, 0x72d, 0x803, 0x0, 0x0, 0x733, 0x0, 0xd03, 0xd1a, 0xd1c, 0xd1e,
0xd20, 0xd22, 0xd24, 0xd26, 0xd28, 0xd2a, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0,
0xe02, 0xe09, 0xe0a, 0xe1a, 0xe21, 0xe23, 0xe25, 0xe2c, 0xe32, 0xe35, 0xe36, 0xe48, 0xe51,
0xe70, 0xe7c, 0xe7e, 0xe89, 0xe8a, 0xe91, 0xe99, 0xe9f, 0xea2, 0xea4, 0xea6, 0xea7, 0xea9,
0x0, 0x0, 0x0, 0x743, 0x744, 0x748, 0xe02, 0xe09, 0xe0a, 0xe1a, 0xe21, 0xe23, 0xe25,
0xe2c, 0xe32, 0xe35, 0xe36, 0xe48, 0xe51, 0xe70, 0xe7c, 0xe7e, 0xe89, 0xe8a, 0xe91, 0xe99,
0xe9f, 0xea2, 0xea4, 0xea6, 0xea7, 0xea9, 0x0, 0x74c, 0x0, 0x750, 0x0,
}
tableLv2 := [...]int{
0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0,
0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0,
0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x8012, 0x0, 0x0, 0x0, 0x0, 0x0, 0x8212, 0x0, 0x0,
0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0,
0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0,
0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0,
0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0,
0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0,
}
tables := [][]int{tableLv0[:], tableLv2[:]}
return tables
}
// NewHeaderStringComparer performs a multi-level, weight-based comparison of two strings
func compareHeaders(lhs, rhs string, tables [][]int) int {
currLevel, i, j := 0, 0, 0
n := len(tables)
lhsLen := len(lhs)
rhsLen := len(rhs)
for currLevel < n {
if currLevel == (n-1) && i != j {
if i > j {
return -1
}
if i < j {
return 1
}
return 0
}
var w1, w2 int
// Check bounds before accessing lhs[i]
if i < lhsLen {
w1 = tables[currLevel][lhs[i]]
} else {
w1 = 0x1
}
// Check bounds before accessing rhs[j]
if j < rhsLen {
w2 = tables[currLevel][rhs[j]]
} else {
w2 = 0x1
}
if w1 == 0x1 && w2 == 0x1 {
i = 0
j = 0
currLevel++
} else if w1 == w2 {
i++
j++
} else if w1 == 0 {
i++
} else if w2 == 0 {
j++
} else {
if w1 < w2 {
return -1
}
if w1 > w2 {
return 1
}
return 0
}
}
return 0
}
func (c *SharedKeyCredential) buildCanonicalizedHeader(headers http.Header) string {
cm := map[string][]string{}
for k, v := range headers {
headerName := strings.TrimSpace(strings.ToLower(k))
if strings.HasPrefix(headerName, "x-ms-") {
cm[headerName] = v // NOTE: the value must not have any whitespace around it.
}
}
if len(cm) == 0 {
return ""
}
keys := make([]string, 0, len(cm))
for key := range cm {
keys = append(keys, key)
}
tables := getWeightTables()
// Sort the keys using the custom comparator
sort.Slice(keys, func(i, j int) bool {
return compareHeaders(keys[i], keys[j], tables) < 0
})
ch := bytes.NewBufferString("")
for i, key := range keys {
if i > 0 {
ch.WriteRune('\n')
}
ch.WriteString(key)
ch.WriteRune(':')
ch.WriteString(strings.Join(cm[key], ","))
}
return ch.String()
}
func (c *SharedKeyCredential) buildCanonicalizedResource(u *url.URL) (string, error) {
// https://docs.microsoft.com/en-us/rest/api/storageservices/authentication-for-the-azure-storage-services
cr := bytes.NewBufferString("/")
cr.WriteString(c.accountName)
if len(u.Path) > 0 {
// Any portion of the CanonicalizedResource string that is derived from
// the resource's URI should be encoded exactly as it is in the URI.
// -- https://msdn.microsoft.com/en-gb/library/azure/dd179428.aspx
cr.WriteString(u.EscapedPath())
} else {
// a slash is required to indicate the root path
cr.WriteString("/")
}
// params is a map[string][]string; param name is key; params values is []string
params, err := url.ParseQuery(u.RawQuery) // Returns URL decoded values
if err != nil {
return "", fmt.Errorf("failed to parse query params: %w", err)
}
if len(params) > 0 { // There is at least 1 query parameter
var paramNames []string // We use this to sort the parameter key names
for paramName := range params {
paramNames = append(paramNames, paramName) // paramNames must be lowercase
}
sort.Strings(paramNames)
for _, paramName := range paramNames {
paramValues := params[paramName]
sort.Strings(paramValues)
// Join the sorted key values separated by ','
// Then prepend "keyName:"; then add this string to the buffer
cr.WriteString("\n" + strings.ToLower(paramName) + ":" + strings.Join(paramValues, ","))
}
}
return cr.String(), nil
}
// ComputeHMACSHA256 is a helper for computing the signed string outside of this package.
func ComputeHMACSHA256(cred *SharedKeyCredential, message string) (string, error) {
return cred.computeHMACSHA256(message)
}
// SharedKeyCredPolicy is a pipeline policy that signs each request with the
// account's shared key.
type SharedKeyCredPolicy struct {
cred *SharedKeyCredential
}
// NewSharedKeyCredPolicy creates a SharedKeyCredPolicy signing with cred.
func NewSharedKeyCredPolicy(cred *SharedKeyCredential) *SharedKeyCredPolicy {
return &SharedKeyCredPolicy{cred: cred}
}
// Do signs the request and sends it via the next policy in the pipeline.
func (s *SharedKeyCredPolicy) Do(req *policy.Request) (*http.Response, error) {
// skip adding the authorization header if no SharedKeyCredential was provided.
// this prevents a panic that might be hard to diagnose and allows testing
// against http endpoints that don't require authentication.
if s.cred == nil {
return req.Next()
}
if d := getHeader("x-ms-date", req.Raw().Header); d == "" {
req.Raw().Header.Set("x-ms-date", time.Now().UTC().Format(http.TimeFormat))
}
stringToSign, err := s.cred.buildStringToSign(req.Raw())
if err != nil {
return nil, err
}
signature, err := s.cred.computeHMACSHA256(stringToSign)
if err != nil {
return nil, err
}
authHeader := strings.Join([]string{"SharedKey ", s.cred.AccountName(), ":", signature}, "")
req.Raw().Header.Set("Authorization", authHeader)
response, err := req.Next()
if err != nil && response != nil && response.StatusCode == http.StatusForbidden {
// Service failed to authenticate request, log it
fs.Debugf(nil, "azureblob arrowlist: HTTP Forbidden status, String-to-Sign: %q", stringToSign)
}
return response, err
}
+206 -136
View File
@@ -19,6 +19,7 @@ import (
"path"
"slices"
"sort"
"strconv"
"strings"
"sync"
"sync/atomic"
@@ -26,14 +27,12 @@ import (
"github.com/Azure/azure-sdk-for-go/sdk/azcore"
"github.com/Azure/azure-sdk-for-go/sdk/azcore/policy"
"github.com/Azure/azure-sdk-for-go/sdk/azcore/runtime"
"github.com/Azure/azure-sdk-for-go/sdk/storage/azblob/blob"
"github.com/Azure/azure-sdk-for-go/sdk/storage/azblob/bloberror"
"github.com/Azure/azure-sdk-for-go/sdk/storage/azblob/blockblob"
"github.com/Azure/azure-sdk-for-go/sdk/storage/azblob/container"
"github.com/Azure/azure-sdk-for-go/sdk/storage/azblob/sas"
"github.com/Azure/azure-sdk-for-go/sdk/storage/azblob/service"
"github.com/rclone/rclone/backend/azureblob/arrowlist"
"github.com/rclone/rclone/backend/azureblob/auth"
"github.com/rclone/rclone/fs"
"github.com/rclone/rclone/fs/chunksize"
@@ -241,40 +240,38 @@ avoid the time out.`,
Advanced: true,
}, {
Name: "use_arrow_list",
Help: `Use the experimental Apache Arrow listing format.
Help: `Use the Apache Arrow listing format.
If set, directory listings are fetched using the ListBlobs Apache
Arrow response format instead of XML. This can be faster for very
large containers.
Arrow response format instead of XML. Arrow responses are smaller and
much cheaper to parse, making listings of large containers several
times faster. Combine with "list_parallelism" for the biggest gains.
This is EXPERIMENTAL and requires the "Blob Listing with Apache Arrow"
preview feature to be enabled on the storage account. It is NOT
supported on accounts with a hierarchical namespace (ADLS Gen2) -
those return a 409 error. If the feature is not enabled the server
returns XML and the listing transparently falls back to the normal XML
path (logged at debug level).
Not supported with connection_string auth - falls back to normal
listing.`,
"Blob Listing with Apache Arrow" is in public preview at Microsoft and
is only supported on flat namespace accounts. On accounts with a
hierarchical namespace (ADLS Gen2), or where the feature is otherwise
unavailable, the server returns XML and the listing transparently
falls back to the normal XML path (logged at debug level).`,
Default: false,
Advanced: true,
Hide: fs.OptionHideBoth,
}, {
Name: "list_parallelism",
Help: `Number of parallel shards to list a directory with.
EXPERIMENTAL. If set greater than 1, the blob name keyspace of each
directory is split into this many ranges which are listed concurrently
using the Arrow startFrom/endBefore range parameters. This can
dramatically speed up listing containers with millions of objects, for
both recursive (ListR) and single directory listings.
If set greater than 1, the blob name keyspace of each directory is
split into ranges which are listed concurrently using the Arrow
startFrom/endBefore range parameters. This can dramatically speed up
listing containers with millions of objects, for both recursive
(ListR) and single directory listings. Speed keeps improving up to a
parallelism of around 30.
This has no effect unless "use_arrow_list" is also set, as Arrow is the
only listing path that supports server-side name ranges. The default of
0 (or 1) lists sequentially.`,
only listing path that supports server-side name ranges. If the
account does not support range listing (e.g. it has a hierarchical
namespace) the listing falls back to sequential. The default of 0 (or
1) lists sequentially.`,
Default: 0,
Advanced: true,
Hide: fs.OptionHideBoth,
}, {
Name: "access_tier",
Help: `Access tier of blob: hot, cool, cold or archive.
@@ -368,7 +365,22 @@ creates an empty object ending with "/", to persist the folder.
This object also has the metadata "` + dirMetaKey + ` = ` + dirMetaValue + `" to conform to
the Microsoft standard.
This is not needed on storage accounts with a hierarchical namespace
(see the hns option) as these always support empty directories.
`,
}, {
Name: "hns",
Default: fs.Tristate{},
Advanced: true,
Help: `Set if the storage account has a hierarchical namespace.
Storage accounts with hierarchical namespace enabled (Azure Data Lake
Storage Gen2) have real directories which can be empty.
If this is unset then rclone will read it from the storage account
and save it in the config file.
`,
}, {
Name: "no_check_container",
Help: `If set, don't attempt to check the container exists or create it.
@@ -436,6 +448,7 @@ type Options struct {
Enc encoder.MultiEncoder `config:"encoding"`
PublicAccess string `config:"public_access"`
DirectoryMarkers bool `config:"directory_markers"`
HNS fs.Tristate `config:"hns"`
NoCheckContainer bool `config:"no_check_container"`
NoHeadObject bool `config:"no_head_object"`
DeleteSnapshots string `config:"delete_snapshots"`
@@ -449,10 +462,8 @@ type Fs struct {
opt Options // parsed config options
ci *fs.ConfigInfo // global config
features *fs.Features // optional features
cntSVCcacheMu sync.Mutex // mutex to protect cntSVCcache and arrowCntSVCcache
cntSVCcacheMu sync.Mutex // mutex to protect cntSVCcache
cntSVCcache map[string]*container.Client // reference to containerClient per container
arrowCntSVCcache map[string]*arrowlist.Client // reference to arrowlist client per container
arrowClientOpts *arrowlist.ClientOptions // client options for the arrowlist clients
arrowXMLFallback atomic.Bool // set once the server answers XML so parallel listing is not retried
svc *service.Client // client to access azblob
cred azcore.TokenCredential // how to generate tokens (may be nil)
@@ -461,6 +472,7 @@ type Fs struct {
rootContainer string // container part of root (if any)
rootDirectory string // directory part of root (if any)
isLimited bool // if limited to one container
hns bool // if the storage account has a hierarchical namespace
cache *bucket.Cache // cache for container creation status
pacer *fs.Pacer // To pace and retry the API calls
uploadToken *pacer.TokenDispenser // control concurrency
@@ -527,7 +539,7 @@ func parsePath(path string) (root string) {
// relative to f.root
func (f *Fs) split(rootRelativePath string) (containerName, containerPath string) {
containerName, containerPath = bucket.Split(bucket.Join(f.root, rootRelativePath))
if f.opt.DirectoryMarkers && strings.HasSuffix(containerPath, "//") {
if f.dirMarkers() && strings.HasSuffix(containerPath, "//") {
containerPath = containerPath[:len(containerPath)-1]
}
return f.opt.Enc.FromStandardName(containerName), f.opt.Enc.FromStandardPath(containerPath)
@@ -670,15 +682,14 @@ func NewFs(ctx context.Context, name, root string, m configmap.Mapper) (fs.Fs, e
ci := fs.GetConfig(ctx)
f := &Fs{
name: name,
opt: *opt,
ci: ci,
pacer: fs.NewPacer(ctx, pacer.NewS3(pacer.MinSleep(minSleep), pacer.MaxSleep(maxSleep), pacer.DecayConstant(decayConstant))),
uploadToken: pacer.NewTokenDispenser(ci.Transfers),
copyToken: pacer.NewTokenDispenser(opt.CopyTotalConcurrency),
cache: bucket.NewCache(),
cntSVCcache: make(map[string]*container.Client, 1),
arrowCntSVCcache: make(map[string]*arrowlist.Client, 1),
name: name,
opt: *opt,
ci: ci,
pacer: fs.NewPacer(ctx, pacer.NewS3(pacer.MinSleep(minSleep), pacer.MaxSleep(maxSleep), pacer.DecayConstant(decayConstant))),
uploadToken: pacer.NewTokenDispenser(ci.Transfers),
copyToken: pacer.NewTokenDispenser(opt.CopyTotalConcurrency),
cache: bucket.NewCache(),
cntSVCcache: make(map[string]*container.Client, 1),
}
f.publicAccess = container.PublicAccessType(opt.PublicAccess)
f.setRoot(root)
@@ -695,10 +706,6 @@ func NewFs(ctx context.Context, name, root string, m configmap.Mapper) (fs.Fs, e
ServerSideAcrossConfigs: true,
DoubleSlash: true,
}).Fill(ctx, f)
if opt.DirectoryMarkers {
f.features.CanHaveEmptyDirectories = true
fs.Debugf(f, "Using directory markers")
}
conf := auth.NewClientOpts[service.Client, service.ClientOptions, service.SharedKeyCredential]{
DefaultBaseURL: storageDefaultBaseURL,
@@ -725,21 +732,34 @@ func NewFs(ctx context.Context, name, root string, m configmap.Mapper) (fs.Fs, e
f.usingSharedKeyCred = res.UsingSharedKeyCred
f.anonymous = res.Anonymous
// Client options for the arrowlist clients, using the same transport and
// gzip policy as the SDK clients built above.
f.arrowClientOpts = &arrowlist.ClientOptions{
ClientOptions: azcore.ClientOptions{
Transport: auth.Transporter(ctx),
PerCallPolicies: []policy.Policy{setAcceptEncodingGzip{}},
},
}
// if using Container level SAS put the container client into the cache
if opt.SASURL != "" && res.Container != "" {
_ = f.cntSVC(res.Container)
f.isLimited = true
}
if opt.HNS.Valid {
f.hns = opt.HNS.Value
} else if !f.anonymous {
containerName := f.rootContainer
if containerName == "" {
containerName = res.Container
}
f.hns, err = f.readHNS(ctx, containerName)
if err != nil {
fs.Debugf(f, "Failed to read whether storage account has a hierarchical namespace - assuming not: %v", err)
} else {
m.Set("hns", strconv.FormatBool(f.hns))
}
}
if f.hns {
fs.Debugf(f, "Storage account has a hierarchical namespace")
}
if f.dirMarkers() {
f.features.CanHaveEmptyDirectories = true
fs.Debugf(f, "Using directory markers")
}
if f.rootContainer != "" && f.rootDirectory != "" {
// Check to see if the (container,directory) is actually an existing file
oldRoot := f.root
@@ -760,6 +780,39 @@ func NewFs(ctx context.Context, name, root string, m configmap.Mapper) (fs.Fs, e
return f, nil
}
// readHNS reads whether the storage account has a hierarchical namespace
//
// If containerName is set then the container client is used which works
// with credentials limited to that container.
func (f *Fs) readHNS(ctx context.Context, containerName string) (hns bool, err error) {
var isHNS *bool
err = f.pacer.Call(func() (bool, error) {
var err error
if containerName != "" {
var resp container.GetAccountInfoResponse
resp, err = f.cntSVC(containerName).GetAccountInfo(ctx, nil)
isHNS = resp.IsHierarchicalNamespaceEnabled
} else {
var resp service.GetAccountInfoResponse
resp, err = f.svc.GetAccountInfo(ctx, nil)
isHNS = resp.IsHierarchicalNamespaceEnabled
}
return f.shouldRetry(ctx, err)
})
if err != nil {
return false, err
}
if isHNS == nil {
return false, errors.New("not returned by server")
}
return *isHNS, nil
}
// dirMarkers returns true if directories are persisted with directory markers
func (f *Fs) dirMarkers() bool {
return f.opt.DirectoryMarkers || f.hns
}
// return the container client for the container passed in
func (f *Fs) cntSVC(containerName string) (containerClient *container.Client) {
f.cntSVCcacheMu.Lock()
@@ -772,44 +825,6 @@ func (f *Fs) cntSVC(containerName string) (containerClient *container.Client) {
return containerClient
}
// errArrowAuthUnsupported is returned by arrowCntSVC when the configured
// credentials can't be used for Arrow listing.
var errArrowAuthUnsupported = errors.New("credentials not supported for Arrow listing (connection_string auth is not supported)")
// return the arrowlist client for the container passed in
//
// Returns errArrowAuthUnsupported if the configured credentials can't be
// reused for the arrowlist client's pipeline.
func (f *Fs) arrowCntSVC(containerName string) (client *arrowlist.Client, err error) {
// The container URL includes any SAS token in its query
url := f.cntSVC(containerName).URL()
f.cntSVCcacheMu.Lock()
defer f.cntSVCcacheMu.Unlock()
if client, ok := f.arrowCntSVCcache[containerName]; ok {
return client, nil
}
switch {
case f.usingSharedKeyCred:
// Covers account+key and the emulator (auth fills in Account/Key)
var cred *arrowlist.SharedKeyCredential
cred, err = arrowlist.NewSharedKeyCredential(f.opt.Account, f.opt.Key)
if err == nil {
client, err = arrowlist.NewClientWithSharedKeyCredential(url, cred, f.arrowClientOpts)
}
case f.cred != nil:
client, err = arrowlist.NewClient(url, f.cred, f.arrowClientOpts)
case f.anonymous || f.opt.SASURL != "":
client, err = arrowlist.NewClientWithNoCredential(url, f.arrowClientOpts)
default:
return nil, errArrowAuthUnsupported
}
if err != nil {
return nil, err
}
f.arrowCntSVCcache[containerName] = client
return client, nil
}
// Return an Object from a path
//
// If it can't be found it returns the error fs.ErrorObjectNotFound.
@@ -835,7 +850,12 @@ func (f *Fs) newObjectWithInfo(ctx context.Context, remote string, info *contain
// NewObject finds the Object at remote. If it can't be found
// it returns the error fs.ErrorObjectNotFound.
func (f *Fs) NewObject(ctx context.Context, remote string) (fs.Object, error) {
return f.newObjectWithInfo(ctx, remote, nil)
o, err := f.newObjectWithInfo(ctx, remote, nil)
if err == fs.ErrorNotAFile {
// The blob is a directory
return nil, fs.ErrorObjectNotFound
}
return o, err
}
// getBlobSVC creates a blob client
@@ -1149,18 +1169,33 @@ func isDirectoryMarker(size int64, metadata map[string]*string, remote string) b
if endsWithSlash || remote == "" {
return true
}
// Note that metadata with hdi_isfolder = true seems to be a
// defacto standard for marking blobs as directories.
// Note also that the metadata hasn't been normalised to lower case yet
for k, v := range metadata {
if v != nil && strings.EqualFold(k, dirMetaKey) && *v == dirMetaValue {
return true
}
return hasDirMetadata(metadata)
}
return false
}
// Returns whether the metadata marks the blob as a directory
func hasDirMetadata(metadata map[string]*string) bool {
// Note that metadata with hdi_isfolder = true seems to be a
// defacto standard for marking blobs as directories.
// Note also that the metadata hasn't been normalised to lower case yet
for k, v := range metadata {
if v != nil && strings.EqualFold(k, dirMetaKey) && *v == dirMetaValue {
return true
}
}
return false
}
// Returns whether props are for a directory on a storage account with
// a hierarchical namespace
//
// These ignore a trailing "/" on the name so a file can be returned
// when looking for a directory.
func isHNSDirectory(props *blob.GetPropertiesResponse) bool {
return props.ContentLength != nil && *props.ContentLength == 0 && hasDirMetadata(props.Metadata)
}
// listFn is called from list to handle an object
type listFn func(remote string, object *container.BlobItem, isDirectory bool) error
@@ -1186,27 +1221,25 @@ func (f *Fs) list(ctx context.Context, containerName, directory, prefix string,
delimiter = "/"
}
opts := &arrowlist.ListBlobsHierarchyOptions{
ListBlobsHierarchyOptions: container.ListBlobsHierarchyOptions{
// Copy, Metadata, Snapshots, UncommittedBlobs, Deleted, Tags, Versions, LegalHold, ImmutabilityPolicy, DeletedWithVersions bool
Include: container.ListBlobsInclude{
Copy: false,
Metadata: true,
Snapshots: false,
UncommittedBlobs: false,
Deleted: false,
},
Prefix: &directory,
MaxResults: &maxResults,
opts := &container.ListBlobsHierarchyOptions{
// Copy, Metadata, Snapshots, UncommittedBlobs, Deleted, Tags, Versions, LegalHold, ImmutabilityPolicy, DeletedWithVersions bool
Include: container.ListBlobsInclude{
Copy: false,
Metadata: true,
Snapshots: false,
UncommittedBlobs: false,
Deleted: false,
},
Prefix: &directory,
MaxResults: &maxResults,
}
// Experimental: request the Apache Arrow listing format. The arrowlist
// pager requests an Arrow IPC stream and decodes it, falling back to XML
// if the account doesn't have Arrow listing enabled. Skip the
// maxResults==1 probe (isEmpty) which doesn't benefit.
// Request the Apache Arrow listing format. The SDK pager requests an
// Arrow IPC stream and decodes it, falling back to XML if the account
// doesn't have Arrow listing enabled. Skip the maxResults==1 probe
// (isEmpty) which doesn't benefit.
useArrow := f.opt.UseArrowList && maxResults != 1
if useArrow {
opts.UseArrowFormat = new(true)
opts.ResponseFormat = container.StorageResponseFormatArrow
}
var foundItems int
@@ -1221,15 +1254,18 @@ func (f *Fs) list(ctx context.Context, containerName, directory, prefix string,
if err != nil {
return err
}
if f.opt.DirectoryMarkers && foundItems == 0 && directory != "" {
if f.dirMarkers() && foundItems == 0 && directory != "" {
// Determine whether the directory exists or not by whether it has a marker
_, err := f.readMetaData(ctx, containerName, directory)
props, err := f.readMetaData(ctx, containerName, directory)
if err != nil {
if err == fs.ErrorObjectNotFound {
return fs.ErrorDirNotFound
}
return err
}
if f.hns && !isHNSDirectory(props) {
return fs.ErrorDirNotFound
}
}
return nil
}
@@ -1237,25 +1273,11 @@ func (f *Fs) list(ctx context.Context, containerName, directory, prefix string,
// listBlobsPager runs the hierarchy listing described by opts, calling fn for
// each blob and subdirectory, and returns the number of raw items seen.
// delimiter selects flat (recurse) vs hierarchical listing. If opts requests
// the Apache Arrow format the listing goes through the arrowlist pager
// (falling back to the SDK pager if the credentials don't support it); if the
// service then answers with XML (Arrow listing not enabled) a debug message
// is logged.
func (f *Fs) listBlobsPager(ctx context.Context, containerName, directory, prefix string, addContainer bool, opts *arrowlist.ListBlobsHierarchyOptions, delimiter string, fn listFn) (foundItems int, err error) {
useArrow := opts.UseArrowFormat != nil && *opts.UseArrowFormat
var pager *runtime.Pager[container.ListBlobsHierarchyResponse]
if useArrow {
arrowSVC, err := f.arrowCntSVC(containerName)
if err != nil {
fs.Debugf(f, "Not using Arrow listing: %v", err)
useArrow = false
} else {
pager = arrowSVC.NewListBlobsHierarchyPager(delimiter, opts)
}
}
if pager == nil {
pager = f.cntSVC(containerName).NewListBlobsHierarchyPager(delimiter, &opts.ListBlobsHierarchyOptions)
}
// the Apache Arrow format and the service answers with XML (Arrow listing
// not enabled) a debug message is logged.
func (f *Fs) listBlobsPager(ctx context.Context, containerName, directory, prefix string, addContainer bool, opts *container.ListBlobsHierarchyOptions, delimiter string, fn listFn) (foundItems int, err error) {
useArrow := opts.ResponseFormat == container.StorageResponseFormatArrow
pager := f.cntSVC(containerName).NewListBlobsHierarchyPager(delimiter, opts)
checkedArrow := false
for pager.More() {
var response container.ListBlobsHierarchyResponse
@@ -1271,7 +1293,7 @@ func (f *Fs) listBlobsPager(ctx context.Context, containerName, directory, prefi
// but not accelerated.
if useArrow && !checkedArrow && err == nil {
checkedArrow = true
if response.ContentType == nil || !strings.HasPrefix(*response.ContentType, arrowlist.ArrowContentType) {
if response.ContentType == nil || !strings.HasPrefix(*response.ContentType, arrowContentType) {
fs.Debugf(f, "Apache Arrow listing requested but server returned XML - Blob Listing with Apache Arrow may not be enabled on this account")
}
}
@@ -1296,6 +1318,13 @@ func (f *Fs) listBlobsPager(ctx context.Context, containerName, directory, prefi
fs.Debugf(f, "Nil name received")
continue
}
// Storage accounts with a hierarchical namespace return
// the file "dir" when listing "dir/"
if !strings.HasPrefix(*file.Name, directory) {
fs.Debugf(f, "Ignoring %q not in directory %q", *file.Name, directory)
foundItems--
continue
}
remote := f.opt.Enc.ToStandardPath(*file.Name)
if !strings.HasPrefix(remote, prefix) {
fs.Debugf(f, "Odd name received %q", remote)
@@ -1557,7 +1586,7 @@ type listContainerFn func(Name string, LastModified time.Time) error
func (f *Fs) listContainersToFn(fn listContainerFn) error {
max := int32(f.opt.ListChunkSize)
pager := f.svc.NewListContainersPager(&service.ListContainersOptions{
Include: service.ListContainersInclude{Metadata: true, Deleted: true},
Include: service.ListContainersInclude{Metadata: true},
MaxResults: &max,
})
ctx := context.Background()
@@ -1607,7 +1636,7 @@ func (f *Fs) PutStream(ctx context.Context, in io.Reader, src fs.ObjectInfo, opt
// Create directory marker file and parents
func (f *Fs) createDirectoryMarker(ctx context.Context, container, dir string) error {
if !f.opt.DirectoryMarkers || container == "" {
if !f.dirMarkers() || container == "" {
return nil
}
@@ -1664,6 +1693,11 @@ func (f *Fs) Mkdir(ctx context.Context, dir string) error {
// mkdirParent creates the parent bucket/directory if it doesn't exist
func (f *Fs) mkdirParent(ctx context.Context, remote string) error {
if f.hns {
// Parent directories are created by the upload
container, _ := f.split(remote)
return f.makeContainer(ctx, container)
}
remote, _ = strings.CutSuffix(remote, "/")
dir := path.Dir(remote)
if dir == "/" || dir == "." {
@@ -1769,6 +1803,9 @@ func (f *Fs) deleteContainer(ctx context.Context, containerName string) error {
// Returns an error if it isn't empty
func (f *Fs) Rmdir(ctx context.Context, dir string) error {
container, directory := f.split(dir)
if f.hns && container != "" && directory != "" {
return f.rmdirHNS(ctx, container, directory)
}
// Remove directory marker file
if f.opt.DirectoryMarkers && container != "" && directory != "" {
o := &Object{
@@ -1791,6 +1828,39 @@ func (f *Fs) Rmdir(ctx context.Context, dir string) error {
return f.deleteContainer(ctx, container)
}
// rmdirHNS removes the directory on a storage account with a
// hierarchical namespace
//
// The directory must be deleted without a trailing "/" and the server
// refuses to delete it if it isn't empty.
func (f *Fs) rmdirHNS(ctx context.Context, container, directory string) error {
directory, _ = strings.CutSuffix(directory, "/")
// Check it is a directory as deleting a file would succeed
props, err := f.readMetaData(ctx, container, directory)
if err == fs.ErrorObjectNotFound {
return fs.ErrorDirNotFound
} else if err != nil {
return err
}
if !isHNSDirectory(props) {
return fs.ErrorIsFile
}
blb := f.getBlobSVC(container, directory)
err = f.pacer.Call(func() (bool, error) {
_, err := blb.Delete(ctx, nil)
return f.shouldRetry(ctx, err)
})
if storageErr, ok := errors.AsType[*azcore.ResponseError](err); ok {
if storageErr.ErrorCode == "DirectoryIsNotEmpty" {
return fs.ErrorDirectoryNotEmpty
}
if storageErr.StatusCode == http.StatusNotFound {
return fs.ErrorDirNotFound
}
}
return err
}
// Precision of the remote
func (f *Fs) Precision() time.Duration {
return time.Nanosecond
@@ -325,6 +325,14 @@ func assertHeadersAndMetadata(t *testing.T, props *blob.GetPropertiesResponse, w
}
// helper to read blob tags for an object
// skipIfHNS skips the test if the storage account has a hierarchical
// namespace as these don't support blob tags
func skipIfHNS(t *testing.T, f *Fs) {
if f.hns {
t.Skip("blob tags not supported with hierarchical namespace")
}
}
func getTagsMap(ctx context.Context, t *testing.T, o fs.Object) map[string]string {
ao := o.(*Object)
blb := ao.getBlobSVC()
@@ -410,6 +418,7 @@ func (f *Fs) testMetadataPaths(t *testing.T) {
// Tags: Singlepart upload
t.Run("PutSinglepartTags", func(t *testing.T) {
skipIfHNS(t, f)
contents := random.String(int(f.opt.ChunkSize / 2))
item := fstest.NewItem("tags-single.txt", contents, fstest.Time("2001-05-06T04:05:06.499999999Z"))
meta := fs.Metadata{
@@ -425,6 +434,7 @@ func (f *Fs) testMetadataPaths(t *testing.T) {
// Tags: Multipart upload
t.Run("PutMultipartTags", func(t *testing.T) {
skipIfHNS(t, f)
contents := random.String(int(f.opt.ChunkSize + 2048))
item := fstest.NewItem("tags-multipart.txt", contents, fstest.Time("2001-05-06T04:05:06.499999999Z"))
meta := fs.Metadata{
@@ -612,6 +622,7 @@ func (f *Fs) testMetadataPaths(t *testing.T) {
// Tags: Singlepart copy
t.Run("CopySinglepartTags", func(t *testing.T) {
skipIfHNS(t, f)
// create small source
contents := random.String(int(f.opt.ChunkSize / 2))
srcItem := fstest.NewItem("tags-copy-single-src.txt", contents, fstest.Time("2001-05-06T04:05:06.499999999Z"))
@@ -637,6 +648,7 @@ func (f *Fs) testMetadataPaths(t *testing.T) {
// Tags: Multipart copy
t.Run("CopyMultipartTags", func(t *testing.T) {
skipIfHNS(t, f)
// create large source to force multipart
contents := random.String(int(f.opt.CopyCutoff + 4096))
srcItem := fstest.NewItem("tags-copy-multi-src.txt", contents, fstest.Time("2001-05-06T04:05:06.499999999Z"))
+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,
+27 -3
View File
@@ -191,6 +191,11 @@ If the custom endpoint rewrites the requests for authentication,
e.g., in Cloudflare Workers, this header needs to be handled properly.
Leave blank if you want to use the endpoint provided by Backblaze.
Rclone will not follow a redirect from this endpoint which downgrades
HTTPS to HTTP, and will not send the "Authorization" header or the
SSE-C key headers once a redirect has left the host and port of this
endpoint.
The URL provided here SHOULD have the protocol and SHOULD NOT have
a trailing slash or specify the /file/bucket subpath as rclone will
request files with "{download_url}/file/{bucket_name}/{path}".
@@ -272,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: "",
@@ -283,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: "",
@@ -529,6 +534,25 @@ func (f *Fs) setRoot(root string) {
f.rootBucket, f.rootDirectory = bucket.Split(f.root)
}
// redirectSecretHeaders are the request headers carrying secrets that
// must not be forwarded when a redirect leaves the original host.
var redirectSecretHeaders = []string{
"Authorization",
sseAlgorithmHeader,
sseKeyHeader,
sseMd5Header,
}
// newClient makes the http client used for all B2 requests.
//
// The download_url option sends the requests to a host the user does
// not control so it strips headers on cross host redirects.
func newClient(ctx context.Context) *http.Client {
client := fshttp.NewClient(ctx)
client.CheckRedirect = rest.StripHeadersOnCrossHostRedirectFn(redirectSecretHeaders...)
return client
}
// NewFs constructs an Fs from the path, bucket:path
func NewFs(ctx context.Context, name, root string, m configmap.Mapper) (fs.Fs, error) {
// Parse config into Options struct
@@ -581,7 +605,7 @@ func NewFs(ctx context.Context, name, root string, m configmap.Mapper) (fs.Fs, e
name: name,
opt: *opt,
ci: ci,
srv: rest.NewClient(fshttp.NewClient(ctx)).SetErrorHandler(errorHandler),
srv: rest.NewClient(newClient(ctx)).SetErrorHandler(errorHandler),
cache: bucket.NewCache(),
_bucketID: make(map[string]string, 1),
_bucketType: make(map[string]string, 1),
+59
View File
@@ -4,6 +4,8 @@ import (
"context"
"crypto/sha1"
"fmt"
"net/http"
"net/http/httptest"
"path"
"sort"
"strings"
@@ -617,3 +619,60 @@ func (f *Fs) InternalTest(t *testing.T) {
}
var _ fstests.InternalTester = (*Fs)(nil)
// redirectTestHeaders is a deliberately literal copy of
// redirectSecretHeaders so that a header dropped from the production
// list fails TestRedirectSecretHeaders rather than silently losing
// test coverage.
var redirectTestHeaders = map[string]string{
"Authorization": "secret-token",
"X-Bz-Server-Side-Encryption-Customer-Algorithm": "AES256",
"X-Bz-Server-Side-Encryption-Customer-Key": "secret-key",
"X-Bz-Server-Side-Encryption-Customer-Key-Md5": "secret-md5",
}
func TestRedirectSecretHeaders(t *testing.T) {
want := make([]string, 0, len(redirectTestHeaders))
for header := range redirectTestHeaders {
want = append(want, header)
}
sort.Strings(want)
got := append([]string(nil), redirectSecretHeaders...)
sort.Strings(got)
assert.Equal(t, want, got)
}
// redirectTestRequest makes a GET request to rawURL carrying the
// secret headers plus a harmless marker header.
func redirectTestRequest(t *testing.T, rawURL string) *http.Request {
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, rawURL, nil)
require.NoError(t, err)
for header, value := range redirectTestHeaders {
req.Header.Set(header, value)
}
req.Header.Set("X-Bz-Test-Mode", "marker")
return req
}
func TestClientStripsSecretHeadersOnCrossHostRedirect(t *testing.T) {
client := newClient(context.Background())
redirectServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
for header := range redirectTestHeaders {
assert.Empty(t, r.Header.Get(header), "%s should have been stripped", header)
}
assert.Equal(t, "marker", r.Header.Get("X-Bz-Test-Mode"))
w.WriteHeader(http.StatusOK)
}))
defer redirectServer.Close()
initialServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, redirectServer.URL, http.StatusTemporaryRedirect)
}))
defer initialServer.Close()
resp, err := client.Do(redirectTestRequest(t, initialServer.URL))
require.NoError(t, err)
assert.Equal(t, http.StatusOK, resp.StatusCode)
assert.NoError(t, resp.Body.Close())
}
+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)
}
+30 -6
View File
@@ -801,9 +801,12 @@ func (f *Fs) processEntries(ctx context.Context, origEntries fs.DirEntries, dirP
fs.Debugf(f, "skip orphan data chunk %q", remote)
break
}
if mainObject == nil && !f.useMeta {
if mainObject == nil && !f.useMeta && !isSpecial {
// this is the "nometa" case
// create dummy chunked object without metadata
//
// not for temporary or control chunks as they may be all
// that is left of an interrupted upload
mainObject = f.newObject(mainRemote, nil, nil)
byRemote[mainRemote] = mainObject
if !badEntry[mainRemote] {
@@ -1499,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)
}
}
}
@@ -1684,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
}
@@ -1755,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
}
+121
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"
@@ -627,6 +628,22 @@ func testChunkNumberOverflow(t *testing.T, f *Fs) {
_ = file.Remove(ctx)
}
// testOrphanTempChunks checks that temporary chunks left behind by an
// interrupted upload are not listed and don't break the listing
func testOrphanTempChunks(t *testing.T, f *Fs) {
const dir = "orphan"
ctx := context.Background()
defer func() {
_ = operations.Purge(ctx, f.base, dir)
}()
modTime := fstest.Time("2001-02-03T04:05:06.499999999Z")
item := fstest.Item{Path: f.makeChunkName(path.Join(dir, "interrupted"), 0, "", "abc123"), ModTime: modTime}
fstests.PutTestContents(ctx, t, f.base, &item, "data", true)
fstest.CheckListingWithRoot(t, f, dir, nil, nil, f.Precision())
}
func testMetadataInput(t *testing.T, f *Fs) {
const minChunkForTest = 50
if f.opt.ChunkSize < minChunkForTest {
@@ -861,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()
@@ -928,9 +1043,15 @@ func (f *Fs) InternalTest(t *testing.T) {
t.Run("BackwardsCompatibility", func(t *testing.T) {
testBackwardsCompatibility(t, f)
})
t.Run("OrphanTempChunks", func(t *testing.T) {
testOrphanTempChunks(t, f)
})
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)
})
+5 -5
View File
@@ -118,8 +118,8 @@ func (mode NameEncryptionMode) String() (out string) {
return out
}
// fileNameEncoding are the encoding methods dealing with encrypted file names
type fileNameEncoding interface {
// FileNameEncoding is the interface for the encoding methods dealing with encrypted file names
type FileNameEncoding interface {
EncodeToString(src []byte) string
DecodeString(s string) ([]byte, error)
}
@@ -154,7 +154,7 @@ func (caseInsensitiveBase32Encoding) DecodeString(s string) ([]byte, error) {
}
// NewNameEncoding creates a NameEncoding from a string
func NewNameEncoding(s string) (enc fileNameEncoding, err error) {
func NewNameEncoding(s string) (enc FileNameEncoding, err error) {
s = strings.ToLower(s)
switch s {
case "base32":
@@ -176,7 +176,7 @@ type Cipher struct {
nameTweak [nameCipherBlockSize]byte // used to tweak the name crypto
block gocipher.Block
mode NameEncryptionMode
fileNameEnc fileNameEncoding
fileNameEnc FileNameEncoding
buffers sync.Pool // encrypt/decrypt buffers
cryptoRand io.Reader // read crypto random numbers from here
dirNameEncrypt bool
@@ -185,7 +185,7 @@ type Cipher struct {
}
// newCipher initialises the cipher. If salt is "" then it uses a built in salt val
func newCipher(mode NameEncryptionMode, password, salt string, dirNameEncrypt bool, enc fileNameEncoding) (*Cipher, error) {
func newCipher(mode NameEncryptionMode, password, salt string, dirNameEncrypt bool, enc FileNameEncoding) (*Cipher, error) {
c := &Cipher{
mode: mode,
fileNameEnc: enc,
+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)
+259
View File
@@ -0,0 +1,259 @@
// Package api has type definitions for dosya.dev
package api
import "fmt"
// Response is the base response wrapper
type Response struct {
OK bool `json:"ok"`
Error string `json:"error,omitempty"`
}
// Error is an error response from the API.
//
// StatusCode and Status are filled in from the HTTP response. The API
// sets ErrorCode on the errors a client is expected to act on rather
// than just report, e.g. "concurrent_upload_limit".
type Error struct {
StatusCode int `json:"-"`
Status string `json:"-"`
OK bool `json:"ok"`
Message string `json:"error"`
ErrorCode string `json:"error_code,omitempty"`
MaxConcurrentUploads int `json:"max_concurrent_uploads,omitempty"`
}
// Error satisfies the error interface
func (e *Error) Error() string {
out := fmt.Sprintf("HTTP error %d (%s)", e.StatusCode, e.Status)
if e.Message != "" {
out += ": " + e.Message
}
if e.ErrorCode != "" {
out += " (" + e.ErrorCode + ")"
}
return out
}
// FileItem represents a file from the dosya API
type FileItem struct {
ID string `json:"id"`
Name string `json:"name"`
SizeBytes int64 `json:"size_bytes"`
MimeType string `json:"mime_type"`
Extension string `json:"extension"`
Region string `json:"region"`
CreatedAt float64 `json:"created_at"`
UpdatedAt float64 `json:"updated_at"`
FolderID *string `json:"folder_id"`
IsSynced int `json:"is_synced"`
// ContentHash is the lowercase-hex SHA-256 of the file's bytes, or "" when
// the server has none (e.g. a file uploaded as an R2 multipart, whose parts
// are never re-read to hash the whole object - the same limitation S3 has).
ContentHash string `json:"content_hash"`
}
// FolderItem represents a folder from the dosya API
type FolderItem struct {
ID string `json:"id"`
Name string `json:"name"`
ParentID *string `json:"parent_id"`
CreatedAt float64 `json:"created_at"`
FileCount int `json:"file_count"`
IsSynced int `json:"is_synced"`
}
// ListResponse is the response from GET /api/files
type ListResponse struct {
Response
Files []FileItem `json:"files"`
Folders []FolderItem `json:"folders"`
Pagination Pagination `json:"pagination"`
}
// Pagination describes which page of files a ListResponse holds
type Pagination struct {
Page int `json:"page"`
PerPage int `json:"per_page"`
TotalFiles int `json:"total_files"`
TotalPages int `json:"total_pages"`
}
// CreateFolderRequest is the request body for POST /api/folders
type CreateFolderRequest struct {
WorkspaceID string `json:"workspace_id"`
ParentID *string `json:"parent_id"`
Name string `json:"name"`
}
// CreateFolderResponse is the response from POST /api/folders
type CreateFolderResponse struct {
Response
Folder struct {
ID string `json:"id"`
Name string `json:"name"`
ParentID *string `json:"parent_id"`
Workspace string `json:"workspace_id"`
} `json:"folder"`
}
// DeleteFolderResponse is the response from DELETE /api/folders/:id
type DeleteFolderResponse struct {
Response
Permanent bool `json:"permanent"`
Complete bool `json:"complete"`
Remaining int `json:"remaining"`
FilesAffected int `json:"files_affected"`
FoldersRemoved int `json:"folders_removed"`
}
// RenameFolderRequest is the request body for PUT /api/folders/:id/rename
type RenameFolderRequest struct {
Name string `json:"name"`
}
// MoveFolderRequest is the request body for PUT /api/folders/:id/move
type MoveFolderRequest struct {
ParentID *string `json:"parent_id"`
Name string `json:"name,omitempty"`
}
// MoveFolderResponse is the response from PUT /api/folders/:id/move
type MoveFolderResponse struct {
Response
Name string `json:"name"`
}
// UploadInitRequest is the request body for POST /api/upload/init
type UploadInitRequest struct {
WorkspaceID string `json:"workspace_id"`
FileName string `json:"file_name"`
FileSize int64 `json:"file_size"`
MimeType string `json:"mime_type"`
Region string `json:"region,omitempty"`
FolderID *string `json:"folder_id"`
FileID *string `json:"file_id,omitempty"`
}
// UploadInitResponse is the response from POST /api/upload/init
type UploadInitResponse struct {
Response
SessionID string `json:"session_id"`
UploadURL string `json:"upload_url"`
WorkspaceID string `json:"workspace_id"`
FileName string `json:"file_name"`
FileSize int64 `json:"file_size"`
MimeType string `json:"mime_type"`
Extension string `json:"extension"`
Region string `json:"region"`
Resumable *struct {
PartSize int64 `json:"part_size"`
TotalParts int `json:"total_parts"`
PartUploadURL string `json:"part_upload_url"`
CompleteURL string `json:"complete_url"`
StatusURL string `json:"status_url"`
} `json:"resumable"`
}
// UploadCompleteResponse is the response from PUT /api/upload/:sessionId or POST /api/upload/:sessionId/complete
type UploadCompleteResponse struct {
Response
File struct {
ID string `json:"id"`
Name string `json:"name"`
SizeBytes int64 `json:"size_bytes"`
MimeType string `json:"mime_type"`
Extension string `json:"extension"`
Region string `json:"region"`
Version int `json:"version"`
CreatedAt float64 `json:"created_at"`
// SHA-256 of the uploaded bytes for a single-shot (small-file) upload;
// "" for a multipart upload, which the server does not hash.
ContentHash string `json:"content_hash"`
} `json:"file"`
}
// UploadPartResponse is the response from PUT /api/upload/:sessionId/part/:partNumber
type UploadPartResponse struct {
Response
PartNumber int `json:"part_number"`
ETag string `json:"etag"`
BytesUploaded int64 `json:"bytes_uploaded"`
PartsCompleted int `json:"parts_completed"`
TotalParts int `json:"total_parts"`
}
// DeleteFileResponse is the response from DELETE /api/files/:id
type DeleteFileResponse struct {
Response
Permanent bool `json:"permanent"`
}
// RenameFileRequest is the request body for PUT /api/files/:id/rename
type RenameFileRequest struct {
Name string `json:"name"`
}
// RenameFileResponse is the response from PUT /api/files/:id/rename
type RenameFileResponse struct {
Response
Name string `json:"name"`
}
// MoveFileRequest is the request body for PUT /api/files/:id/move
type MoveFileRequest struct {
FolderID *string `json:"folder_id"`
Name *string `json:"name,omitempty"`
}
// CopyFileRequest is the request body for POST /api/files/:id/copy
type CopyFileRequest struct {
FolderID *string `json:"folder_id"`
Name string `json:"name,omitempty"`
}
// CopyFileResponse is the response from POST /api/files/:id/copy
type CopyFileResponse struct {
Response
FileID string `json:"file_id"`
Name string `json:"name"`
}
// ShareLinkRequest is the request body for POST /api/files/:id/share
type ShareLinkRequest struct {
ExpiresInDays *int `json:"expires_in_days,omitempty"`
}
// ShareLinkResponse is the response from POST /api/files/:id/share
type ShareLinkResponse struct {
Response
Link struct {
ID string `json:"id"`
Token string `json:"token"`
URL string `json:"url"`
ExpiresAt *int64 `json:"expires_at"`
CreatedAt float64 `json:"created_at"`
} `json:"link"`
}
// Workspace is one entry of the workspace list
type Workspace struct {
ID string `json:"id"`
Name string `json:"name"`
}
// WorkspaceListResponse is the response from GET /api/workspaces
type WorkspaceListResponse struct {
Response
Workspaces []Workspace `json:"workspaces"`
}
// WorkspaceInfoResponse is the response from GET /api/workspaces/:id
type WorkspaceInfoResponse struct {
Response
Storage struct {
Used int64 `json:"used"`
Total int64 `json:"total"`
Free int64 `json:"free"`
} `json:"storage"`
}
+772
View File
@@ -0,0 +1,772 @@
// Package dosya provides an interface to the dosya.dev cloud storage system.
package dosya
import (
"context"
"errors"
"fmt"
"io"
"strings"
"time"
"github.com/rclone/rclone/backend/dosya/api"
"github.com/rclone/rclone/fs"
"github.com/rclone/rclone/fs/config"
"github.com/rclone/rclone/fs/config/configmap"
"github.com/rclone/rclone/fs/config/configstruct"
"github.com/rclone/rclone/fs/fshttp"
"github.com/rclone/rclone/fs/hash"
"github.com/rclone/rclone/lib/dircache"
"github.com/rclone/rclone/lib/encoder"
"github.com/rclone/rclone/lib/pacer"
"github.com/rclone/rclone/lib/random"
"github.com/rclone/rclone/lib/rest"
)
const (
rootID = "root"
defaultBaseURL = "https://api.dosya.dev"
minSleep = 100 * time.Millisecond
maxSleep = 2 * time.Second
decayConstant = 2
attackConstant = 0
)
func init() {
fs.Register(&fs.RegInfo{
Name: "dosya",
Description: "dosya.dev",
NewFs: NewFs,
Options: []fs.Option{{
Help: "Your API Key, get it from https://dosya.dev/profile under \"API keys\".",
Name: "api_key",
Sensitive: true,
Required: true,
}, {
Name: "workspace_id",
Help: `Your workspace ID.
Leave this blank to use the one workspace the API key can reach, which
is what a key restricted to a single workspace does. If the key can
reach several, rclone will refuse and list their IDs for you to choose
from.`,
}, {
Help: "API base URL.",
Name: "api_url",
Default: defaultBaseURL,
Advanced: true,
}, {
Name: "upload_concurrency",
Help: `Concurrency for multipart uploads.
This is the number of parts of the same file that are uploaded
concurrently. The first part is always uploaded on its own, because
the server creates the upload when it receives it.
Each part in flight is held in memory, so memory use grows with
this value times the part size the server chooses.
If you are uploading large files over high-speed links and these
uploads do not fully utilize your bandwidth, then increasing this
may help to speed up the transfers.`,
Default: 4,
Advanced: true,
}, {
Name: config.ConfigEncoding,
Help: config.ConfigEncodingHelp,
Advanced: true,
Default: (encoder.Display |
encoder.EncodeBackSlash |
encoder.EncodeSingleQuote |
encoder.EncodeDoubleQuote |
encoder.EncodeLtGt |
encoder.EncodeCtl |
encoder.EncodeLeftSpace |
encoder.EncodeRightSpace |
encoder.EncodeInvalidUtf8),
}},
})
}
// Options defines the configuration for this backend
type Options struct {
APIKey string `config:"api_key"`
WorkspaceID string `config:"workspace_id"`
APIURL string `config:"api_url"`
UploadConcurrency int `config:"upload_concurrency"`
Enc encoder.MultiEncoder `config:"encoding"`
}
// Fs represents a remote dosya.dev filesystem
type Fs struct {
name string
root string
opt Options
features *fs.Features
rest *rest.Client
pacer *fs.Pacer
dirCache *dircache.DirCache
}
// Object represents a dosya.dev file
type Object struct {
fs *Fs
remote string
file api.FileItem
}
// findWorkspace fills in the workspace ID when the config leaves it blank.
//
// The ID is not shown anywhere in the dosya.dev web interface, so requiring
// it meant asking people to find it in a developer console. A key restricted
// to one workspace can only mean that one, and an account with a single
// workspace has only one possible answer; anything else genuinely has to be
// chosen, so list the choices in the error.
func (f *Fs) findWorkspace(ctx context.Context) error {
workspaces, err := f.listWorkspaces(ctx)
if err != nil {
return err
}
switch len(workspaces) {
case 0:
return errors.New("no workspaces found for this API key")
case 1:
f.opt.WorkspaceID = workspaces[0].ID
fs.Debugf(f, "Using workspace %q (%s)", workspaces[0].Name, workspaces[0].ID)
return nil
}
var choices strings.Builder
for _, w := range workspaces {
_, _ = fmt.Fprintf(&choices, "\n %s (%s)", w.ID, w.Name)
}
return fmt.Errorf("this API key can reach %d workspaces, so workspace_id must be set to one of:%s",
len(workspaces), choices.String())
}
// FindLeaf finds a directory of name leaf in the folder with ID pathID
func (f *Fs) FindLeaf(ctx context.Context, pathID, leaf string) (pathIDOut string, found bool, err error) {
listing, err := f.listFilesAndFolders(ctx, pathID)
if err != nil {
return "", false, err
}
for _, folder := range listing.Folders {
if f.opt.Enc.ToStandardName(folder.Name) == leaf {
return folder.ID, true, nil
}
}
return "", false, nil
}
// CreateDir makes a directory with pathID as parent and name leaf
func (f *Fs) CreateDir(ctx context.Context, pathID, leaf string) (newID string, err error) {
name := f.opt.Enc.FromStandardName(leaf)
resp, err := f.createFolder(ctx, name, pathID)
if err != nil {
return "", err
}
return resp.Folder.ID, nil
}
// Name of the remote (as passed into NewFs)
func (f *Fs) Name() string {
return f.name
}
// Root of the remote (as passed into NewFs)
func (f *Fs) Root() string {
return f.root
}
// String returns a description of the FS
func (f *Fs) String() string {
return fmt.Sprintf("dosya.dev root '%s'", f.root)
}
// Precision of the ModTimes in this Fs
func (f *Fs) Precision() time.Duration {
// Modification times are stored and returned to the second (the API
// keeps them as Unix seconds, sent on upload via the source-mtime header).
return time.Second
}
// Hashes returns the supported hash types of the filesystem
func (f *Fs) Hashes() hash.Set {
// The server computes and stores the SHA-256 of a single-shot upload's
// bytes; multipart uploads have none (see Object.Hash).
return hash.NewHashSet(hash.SHA256)
}
// Features returns the optional features of this Fs
func (f *Fs) Features() *fs.Features {
return f.features
}
// NewFs makes a new Fs object from the path
func NewFs(ctx context.Context, name string, root string, config configmap.Mapper) (fs.Fs, error) {
opt := new(Options)
err := configstruct.Set(config, opt)
if err != nil {
return nil, err
}
root = strings.Trim(root, "/")
f := &Fs{
name: name,
root: root,
opt: *opt,
pacer: fs.NewPacer(ctx, pacer.NewDefault(pacer.MinSleep(minSleep), pacer.MaxSleep(maxSleep), pacer.DecayConstant(decayConstant), pacer.AttackConstant(attackConstant))),
}
// No DuplicateFiles: an upload that names no existing file but whose
// (workspace, folder, name) is already taken becomes a new VERSION of
// that file rather than a second row, so a name is unique within a
// folder and the same upload twice returns the same file id.
f.features = (&fs.Features{
CaseInsensitive: false,
CanHaveEmptyDirectories: true,
ReadMimeType: true,
}).Fill(ctx, f)
client := fshttp.NewClient(ctx)
f.rest = rest.NewClient(client).SetRoot(strings.TrimSuffix(opt.APIURL, "/")).SetErrorHandler(errorHandler)
f.rest.SetHeader("Authorization", "Bearer "+f.opt.APIKey)
if f.opt.WorkspaceID == "" {
err = f.findWorkspace(ctx)
if err != nil {
return nil, err
}
}
f.dirCache = dircache.New(root, rootID, f)
// Find the current root
err = f.dirCache.FindRoot(ctx, false)
if err != nil {
// Assume it is a file
newRoot, remote := dircache.SplitPath(root)
tempF := *f
tempF.dirCache = dircache.New(newRoot, rootID, &tempF)
tempF.root = newRoot
// Make new Fs which is the parent
err = tempF.dirCache.FindRoot(ctx, false)
if err != nil {
// No root so return old f
return f, nil
}
_, err := tempF.NewObject(ctx, remote)
if err != nil {
if err == fs.ErrorObjectNotFound {
// File doesn't exist so return old f
return f, nil
}
return nil, err
}
f.features.Fill(ctx, &tempF)
f.dirCache = tempF.dirCache
f.root = tempF.root
return f, fs.ErrorIsFile
}
return f, nil
}
// List the objects and directories in dir into entries
func (f *Fs) List(ctx context.Context, dir string) (entries fs.DirEntries, err error) {
directoryID, err := f.dirCache.FindDir(ctx, dir, false)
if err != nil {
return nil, err
}
listing, err := f.listFilesAndFolders(ctx, directoryID)
if err != nil {
return nil, err
}
for _, folder := range listing.Folders {
folderName := f.opt.Enc.ToStandardName(folder.Name)
fullPath := getRemote(dir, folderName)
modTime := time.Unix(int64(folder.CreatedAt), 0)
d := fs.NewDir(fullPath, modTime).SetID(folder.ID)
entries = append(entries, d)
f.dirCache.Put(fullPath, folder.ID)
}
for _, file := range listing.Files {
fileName := f.opt.Enc.ToStandardName(file.Name)
remote := getRemote(dir, fileName)
o := &Object{
fs: f,
remote: remote,
file: file,
}
entries = append(entries, o)
}
return entries, nil
}
// NewObject finds the Object at remote
func (f *Fs) NewObject(ctx context.Context, remote string) (fs.Object, error) {
leaf, directoryID, err := f.dirCache.FindPath(ctx, remote, false)
if err != nil {
if err == fs.ErrorDirNotFound {
return nil, fs.ErrorObjectNotFound
}
return nil, err
}
listing, err := f.listFilesAndFolders(ctx, directoryID)
if err != nil {
return nil, err
}
for _, file := range listing.Files {
if f.opt.Enc.ToStandardName(file.Name) == leaf {
return &Object{
fs: f,
remote: remote,
file: file,
}, nil
}
}
return nil, fs.ErrorObjectNotFound
}
// Put uploads a new file
func (f *Fs) Put(ctx context.Context, in io.Reader, src fs.ObjectInfo, options ...fs.OpenOption) (fs.Object, error) {
existingObj, err := f.NewObject(ctx, src.Remote())
switch err {
case nil:
return existingObj, existingObj.Update(ctx, in, src, options...)
case fs.ErrorObjectNotFound:
return f.put(ctx, in, src, options...)
default:
return nil, err
}
}
// put uploads src as a new file
func (f *Fs) put(ctx context.Context, in io.Reader, src fs.ObjectInfo, options ...fs.OpenOption) (fs.Object, error) {
remote := src.Remote()
_, directoryID, err := f.dirCache.FindPath(ctx, remote, true)
if err != nil {
return nil, err
}
modTime := src.ModTime(ctx)
resp, err := f.uploadFile(ctx, in, remote, src.Size(), modTime, fs.MimeType(ctx, src), directoryID, nil)
if err != nil {
return nil, err
}
return &Object{
fs: f,
remote: remote,
file: fileFromUpload(resp, modTime),
}, nil
}
// fileFromUpload builds the FileItem for the object an upload just created.
// The single-shot upload response omits updated_at, so the modification time
// is taken from what was sent (modTime) rather than defaulting to the create
// time - it is the time the server stored via the source-mtime header. The
// SHA-256 comes straight from the response (empty for a multipart upload).
func fileFromUpload(resp *api.UploadCompleteResponse, modTime time.Time) api.FileItem {
updatedAt := resp.File.CreatedAt
if !modTime.IsZero() {
updatedAt = float64(modTime.Unix())
}
return api.FileItem{
ID: resp.File.ID,
Name: resp.File.Name,
SizeBytes: resp.File.SizeBytes,
MimeType: resp.File.MimeType,
Extension: resp.File.Extension,
Region: resp.File.Region,
CreatedAt: resp.File.CreatedAt,
UpdatedAt: updatedAt,
ContentHash: resp.File.ContentHash,
}
}
// Mkdir makes the directory (container, bucket)
func (f *Fs) Mkdir(ctx context.Context, dir string) error {
_, err := f.dirCache.FindDir(ctx, dir, true)
return err
}
// Rmdir removes the directory (container, bucket) if empty
func (f *Fs) Rmdir(ctx context.Context, dir string) error {
directoryID, err := f.dirCache.FindDir(ctx, dir, false)
if err != nil {
return err
}
// The server checks emptiness itself (?if_empty=1) and answers
// fs.ErrorDirectoryNotEmpty's 409 without a separate listing call.
err = f.removeFolder(ctx, directoryID, true)
if err != nil {
return err
}
f.dirCache.FlushDir(dir)
return nil
}
// Move src to this remote using server-side move operations
func (f *Fs) Move(ctx context.Context, src fs.Object, remote string) (fs.Object, error) {
srcObj, ok := src.(*Object)
if !ok {
fs.Debugf(src, "Can't move - not same remote type")
return nil, fs.ErrorCantMove
}
srcLeaf := f.opt.Enc.ToStandardName(srcObj.file.Name)
_, srcDirID, err := srcObj.fs.dirCache.FindPath(ctx, srcObj.remote, false)
if err != nil {
return nil, err
}
// Find destination directory
dstLeaf, dstDirID, err := f.dirCache.FindPath(ctx, remote, true)
if err != nil {
return nil, err
}
needsMove := srcDirID != dstDirID
needsRename := srcLeaf != dstLeaf
// Moving and renaming in the same request (rather than a moveFile call
// followed by a separate renameFile call) matters when both are needed:
// a two-step move-then-rename can transiently place the file's CURRENT
// name into a destination folder that already holds an unrelated file
// with that name, even though the FINAL name is free - the server only
// allows one active name per folder, so that intermediate state 409s.
if needsMove || needsRename {
newName := ""
if needsRename {
newName = f.opt.Enc.FromStandardName(dstLeaf)
}
err = f.moveFile(ctx, srcObj.file.ID, dstDirID, newName)
if err != nil {
return nil, err
}
}
// Return new object. file.Name must reflect the destination leaf, not
// the source's - a caller that reuses this object as the src of a
// SECOND Move (e.g. operations.RemoveExisting's restore-on-error path)
// recomputes srcLeaf from file.Name; a stale name there makes that
// second move see srcLeaf == dstLeaf and skip the rename entirely,
// silently leaving the object under the wrong name.
newFile := srcObj.file
newFile.Name = f.opt.Enc.FromStandardName(dstLeaf)
return &Object{
fs: f,
remote: remote,
file: newFile,
}, nil
}
// DirMove moves src, srcRemote to this remote at dstRemote
func (f *Fs) DirMove(ctx context.Context, src fs.Fs, srcRemote, dstRemote string) error {
srcFs, ok := src.(*Fs)
if !ok {
fs.Debugf(srcFs, "Can't move directory - not same remote type")
return fs.ErrorCantDirMove
}
srcID, srcDirectoryID, _, dstDirectoryID, dstLeaf, err := f.dirCache.DirMove(ctx, srcFs.dirCache, srcFs.root, srcRemote, f.root, dstRemote)
if err != nil {
return err
}
srcLeaf := srcRemote
if idx := strings.LastIndex(srcRemote, "/"); idx >= 0 {
srcLeaf = srcRemote[idx+1:]
}
needsRename := srcLeaf != dstLeaf
newName := ""
if needsRename {
newName = f.opt.Enc.FromStandardName(dstLeaf)
}
// Move folder to new parent only if parent changed. The new name goes
// in the same request, as for files in Move: a move followed by a
// separate rename can collide with an unrelated folder sharing the
// CURRENT name in the destination even though the final name is free.
if srcDirectoryID != dstDirectoryID {
renamed, err := f.moveFolder(ctx, srcID, dstDirectoryID, newName)
if err != nil {
return fmt.Errorf("couldn't move directory: %w", err)
}
if renamed {
needsRename = false
}
}
if needsRename {
err = f.renameFolder(ctx, srcID, newName)
if err != nil {
return fmt.Errorf("couldn't rename directory: %w", err)
}
}
srcFs.dirCache.FlushDir(srcRemote)
return nil
}
// Copy src to this remote using server-side copy operations
func (f *Fs) Copy(ctx context.Context, src fs.Object, remote string) (fs.Object, error) {
srcObj, ok := src.(*Object)
if !ok {
fs.Debugf(src, "Can't copy - not same remote type")
return nil, fs.ErrorCantCopy
}
dstLeaf, dstDirID, err := f.dirCache.FindPath(ctx, remote, true)
if err != nil {
return nil, err
}
// The copy API creates a new row named after the source file when
// copying across folders, which collides with one already at the
// destination. Copy under a temporary name, then delete the old
// destination, then move the copy onto the name - so a copy that fails
// leaves the destination as it was. Deleting it up front lost it
// (prior art: backend/box).
existingObj, err := f.NewObject(ctx, remote)
switch err {
case nil:
tempRemote := remote + "-rclone-copy-" + random.String(8)
fs.Debugf(remote, "dst already exists, copying to temp name %v", tempRemote)
tempObj, err := f.Copy(ctx, src, tempRemote)
if err != nil {
return nil, err
}
fs.Debugf(tempRemote, "moving to real name %v", remote)
if err := existingObj.Remove(ctx); err != nil {
return nil, fmt.Errorf("couldn't remove existing destination file: %w", err)
}
return f.Move(ctx, tempObj, remote)
case fs.ErrorObjectNotFound:
// nothing at the destination, so copy straight onto the name
default:
return nil, err
}
dstName := f.opt.Enc.FromStandardName(dstLeaf)
resp, err := f.copyFile(ctx, srcObj.file.ID, dstDirID, dstName)
if err != nil {
return nil, fmt.Errorf("couldn't copy file: %w", err)
}
// Servers that predate the name field ignore it and name the copy after
// the source, or "Copy of <original>" in the same folder. Rename to the
// requested destination name if it differs.
if resp.Name != dstName {
err = f.renameFile(ctx, resp.FileID, dstName)
if err != nil {
return nil, fmt.Errorf("couldn't rename copied file: %w", err)
}
resp.Name = dstName
}
// A server-side copy is byte-identical to the source and the API
// preserves the source's modification time and content hash onto the
// copy, so the returned object must report the SOURCE's modtime and hash,
// not the copy's wall-clock time. Reading "now" here made the object
// rclone holds in memory disagree with the one read back from the remote
// (its Fingerprint check compares the two exactly).
return &Object{
fs: f,
remote: remote,
file: api.FileItem{
ID: resp.FileID,
Name: resp.Name,
SizeBytes: srcObj.file.SizeBytes,
MimeType: srcObj.file.MimeType,
Extension: srcObj.file.Extension,
Region: srcObj.file.Region,
CreatedAt: srcObj.file.CreatedAt,
UpdatedAt: srcObj.file.UpdatedAt,
ContentHash: srcObj.file.ContentHash,
},
}, nil
}
// Purge deletes all the files and the container
func (f *Fs) Purge(ctx context.Context, dir string) error {
directoryID, err := f.dirCache.FindDir(ctx, dir, false)
if err != nil {
return err
}
// DELETE /api/folders/:id?permanent=1 recursively deletes all contents
err = f.removeFolder(ctx, directoryID, false)
if err != nil {
return err
}
f.dirCache.FlushDir(dir)
return nil
}
// PublicLink generates a public link to the remote path
func (f *Fs) PublicLink(ctx context.Context, remote string, expire fs.Duration, unlink bool) (string, error) {
o, err := f.NewObject(ctx, remote)
if err != nil {
return "", err
}
obj := o.(*Object)
resp, err := f.createShareLink(ctx, obj.file.ID, expire)
if err != nil {
return "", err
}
return resp.Link.URL, nil
}
// DirCacheFlush resets the directory cache - used in testing as an
// optional interface
func (f *Fs) DirCacheFlush() {
f.dirCache.ResetRoot()
}
// About gets quota information from the Fs
func (f *Fs) About(ctx context.Context) (*fs.Usage, error) {
info, err := f.getWorkspaceInfo(ctx)
if err != nil {
return nil, err
}
usage := &fs.Usage{
Used: fs.NewUsageValue(info.Storage.Used),
Total: fs.NewUsageValue(info.Storage.Total),
Free: fs.NewUsageValue(info.Storage.Free),
}
return usage, nil
}
// ------------------------------------------------------------
// Object methods
// ------------------------------------------------------------
// String returns a description of the Object
func (o *Object) String() string {
return o.remote
}
// Remote returns the remote path
func (o *Object) Remote() string {
return o.remote
}
// ModTime returns the modification date of the file
func (o *Object) ModTime(ctx context.Context) time.Time {
if o.file.UpdatedAt > 0 {
return time.Unix(int64(o.file.UpdatedAt), 0)
}
return time.Unix(int64(o.file.CreatedAt), 0)
}
// Size returns the size of the file
func (o *Object) Size() int64 {
return o.file.SizeBytes
}
// Fs returns read only access to the Fs that this object is part of
func (o *Object) Fs() fs.Info {
return o.fs
}
// Hash returns the selected checksum of the file
func (o *Object) Hash(ctx context.Context, t hash.Type) (string, error) {
if t != hash.SHA256 {
return "", hash.ErrUnsupported
}
// May be "" - a multipart-uploaded object carries no server-side hash,
// which rclone treats as "hash unavailable for this object" (as it does
// for S3 multipart objects).
return o.file.ContentHash, nil
}
// Storable says whether this object can be stored
func (o *Object) Storable() bool {
return true
}
// SetModTime sets the metadata on the object to set the modification date
func (o *Object) SetModTime(ctx context.Context, t time.Time) error {
return fs.ErrorCantSetModTime
}
// Open opens the file for read
func (o *Object) Open(ctx context.Context, options ...fs.OpenOption) (io.ReadCloser, error) {
fs.FixRangeOption(options, o.file.SizeBytes)
return o.fs.downloadFile(ctx, o.file.ID, options)
}
// Update replaces the file content
func (o *Object) Update(ctx context.Context, in io.Reader, src fs.ObjectInfo, options ...fs.OpenOption) error {
// Find the directory ID for this file
_, directoryID, err := o.fs.dirCache.FindPath(ctx, o.remote, false)
if err != nil {
return err
}
// Upload as new version of existing file
fileID := o.file.ID
modTime := src.ModTime(ctx)
resp, err := o.fs.uploadFile(ctx, in, o.remote, src.Size(), modTime, fs.MimeType(ctx, src), directoryID, &fileID)
if err != nil {
return err
}
// Update object metadata
o.file = fileFromUpload(resp, modTime)
return nil
}
// Remove removes this object
func (o *Object) Remove(ctx context.Context) error {
return o.fs.deleteFile(ctx, o.file.ID)
}
// MimeType of an Object if known, "" otherwise
func (o *Object) MimeType(ctx context.Context) string {
return o.file.MimeType
}
// ID returns the ID of the Object if known, or "" if not
func (o *Object) ID() string {
return o.file.ID
}
func getRemote(dir, fileName string) string {
if dir == "" {
return fileName
}
return dir + "/" + fileName
}
// Check the interfaces are satisfied
var (
_ fs.Fs = (*Fs)(nil)
_ fs.Mover = (*Fs)(nil)
_ fs.DirMover = (*Fs)(nil)
_ fs.Copier = (*Fs)(nil)
_ fs.Purger = (*Fs)(nil)
_ fs.PublicLinker = (*Fs)(nil)
_ fs.Abouter = (*Fs)(nil)
_ fs.DirCacheFlusher = (*Fs)(nil)
_ dircache.DirCacher = (*Fs)(nil)
_ fs.Object = (*Object)(nil)
_ fs.MimeTyper = (*Object)(nil)
_ fs.IDer = (*Object)(nil)
)
+880
View File
@@ -0,0 +1,880 @@
package dosya
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"path"
"strconv"
"strings"
"time"
"github.com/rclone/rclone/backend/dosya/api"
"github.com/rclone/rclone/fs"
"github.com/rclone/rclone/fs/fserrors"
"github.com/rclone/rclone/lib/pacer"
"github.com/rclone/rclone/lib/readers"
"github.com/rclone/rclone/lib/rest"
"golang.org/x/sync/errgroup"
)
// sourceMTimeHeader is the request header the upload endpoints read to set a
// file's modification time (Unix seconds); see lib/upload/source-timestamps.ts.
const sourceMTimeHeader = "x-dosya-source-mtime"
// modTimeHeaders returns the ExtraHeaders that carry a source modification time
// on an upload, or nil when there is nothing plausible to send. The server
// clamps implausible values itself, but a zero time is never worth a header.
func modTimeHeaders(modTime time.Time) map[string]string {
if modTime.IsZero() {
return nil
}
return map[string]string{sourceMTimeHeader: strconv.FormatInt(modTime.Unix(), 10)}
}
// workspaceHintHeader names the workspace a by-id request is about. The API
// maps an id to its workspace through an index the workspace fills about a
// second after a write, so an id this client was handed moments ago can still
// miss; the hint lets the server look for it in the named workspace instead.
// It only locates: every route still checks membership against the workspace
// it resolved, and a workspace-pinned key's hint is ignored unless it matches.
const workspaceHintHeader = "X-Dosya-Workspace"
// byIDHeaders returns the ExtraHeaders every /api/files/:id and
// /api/folders/:id call carries, or nil when no workspace is configured.
func (f *Fs) byIDHeaders() map[string]string {
if f.opt.WorkspaceID == "" {
return nil
}
return map[string]string{workspaceHintHeader: f.opt.WorkspaceID}
}
// retryErrorCodes is a slice of error codes that we will retry
var retryErrorCodes = []int{
429, // Too Many Requests
500, // Internal Server Error
502, // Bad Gateway
503, // Service Unavailable
504, // Gateway Timeout
}
// shouldRetry returns a boolean as to whether this resp and err
// deserve to be retried
func shouldRetry(ctx context.Context, resp *http.Response, err error) (bool, error) {
if fserrors.ContextError(ctx, &err) {
return false, err
}
retry := fserrors.ShouldRetry(err) || fserrors.ShouldRetryHTTP(resp, retryErrorCodes)
if retry {
if d, ok := retryAfter(resp); ok {
// The API answers 503 + Retry-After while a workspace is being
// moved between storage homes, and Cloudflare sends it with 429.
// The pacer's own backoff tops out at maxSleep, far short of that.
fs.Debugf(nil, "Server asked to retry after %v", d)
err = pacer.RetryAfterError(err, d)
}
}
return retry, err
}
// maxRetryAfter caps how long a single Retry-After is honoured for
const maxRetryAfter = 5 * time.Minute
// retryAfter reads a 429 or 503 response's Retry-After header, which the API
// sends as whole seconds
func retryAfter(resp *http.Response) (time.Duration, bool) {
if resp == nil || (resp.StatusCode != http.StatusTooManyRequests && resp.StatusCode != http.StatusServiceUnavailable) {
return 0, false
}
seconds, err := strconv.Atoi(strings.TrimSpace(resp.Header.Get("Retry-After")))
if err != nil || seconds <= 0 {
return 0, false
}
return min(time.Duration(seconds)*time.Second, maxRetryAfter), true
}
// errorHandler decodes a non-2xx response into an *api.Error so callers can
// act on the machine-readable error_code rather than the message text.
func errorHandler(resp *http.Response) error {
body, err := rest.ReadBody(resp)
if err != nil {
return fmt.Errorf("error reading error out of body: %w", err)
}
apiErr := &api.Error{
StatusCode: resp.StatusCode,
Status: resp.Status,
}
if json.Unmarshal(body, apiErr) != nil || apiErr.Message == "" {
// Not the API's JSON shape (e.g. an edge response that never
// reached the API) - keep the body text so it isn't lost.
apiErr.ErrorCode = ""
apiErr.Message = strings.TrimSpace(string(body))
}
return apiErr
}
// The workspace caps the number of in-flight uploads per user. The API
// refuses an upload/init past that cap with a 400 carrying
// error_code "concurrent_upload_limit", and documents it as an error to
// wait on rather than give up on: slots free as uploads finish, and the
// server marks sessions idle for 10 minutes as failed, so waiting up to
// that long guarantees progress unless the workspace really is saturated.
// A 400 is deliberately outside the pacer's retry codes, so the wait
// lives in initUpload instead of shouldRetry.
var (
concurrentUploadRetryDelay = 5 * time.Second
concurrentUploadWaitMax = 10 * time.Minute
)
// abortUploadTimeout bounds the abort of a session the client is giving up on,
// which runs outside the transfer's context so a cancelled transfer still
// sends it
const abortUploadTimeout = 10 * time.Second
// isConcurrentUploadLimit reports whether err is the API refusing an
// upload because the workspace's concurrent upload limit is reached
func isConcurrentUploadLimit(err error) bool {
var apiErr *api.Error
return errors.As(err, &apiErr) && apiErr.ErrorCode == "concurrent_upload_limit"
}
// listPageSize is the largest page GET /api/files will serve
const listPageSize = 500
// listFilesAndFolders lists files and folders in a directory
//
// The API pages files (at most listPageSize per page) but returns every
// folder on each page, so the files from all pages are gathered and the
// folders are taken from the first page only. Pages are ordered by name,
// which the API tie-breaks on id, so offsets are stable between requests.
func (f *Fs) listFilesAndFolders(ctx context.Context, folderID string) (*api.ListResponse, error) {
var listing *api.ListResponse
for page := 1; ; page++ {
opts := rest.Opts{
Method: "GET",
Path: "/api/files",
Parameters: map[string][]string{
"workspace_id": {f.opt.WorkspaceID},
"per_page": {strconv.Itoa(listPageSize)},
"page": {strconv.Itoa(page)},
"sort": {"name"},
"dir": {"asc"},
},
}
if folderID != "" && folderID != rootID {
opts.Parameters["folder_id"] = []string{folderID}
}
var result api.ListResponse
err := f.pacer.Call(func() (bool, error) {
resp, err := f.rest.CallJSON(ctx, &opts, nil, &result)
return shouldRetry(ctx, resp, err)
})
if err != nil {
return nil, fmt.Errorf("couldn't list files: %w", err)
}
if !result.OK {
return nil, fmt.Errorf("API error: %s", result.Error)
}
if listing == nil {
listing = &result
} else {
listing.Files = append(listing.Files, result.Files...)
}
if page >= result.Pagination.TotalPages || len(result.Files) == 0 {
return listing, nil
}
}
}
// createFolder creates a folder
func (f *Fs) createFolder(ctx context.Context, name string, parentID string) (*api.CreateFolderResponse, error) {
request := api.CreateFolderRequest{
WorkspaceID: f.opt.WorkspaceID,
Name: name,
}
if parentID != "" && parentID != rootID {
request.ParentID = &parentID
}
opts := rest.Opts{
Method: "POST",
Path: "/api/folders",
}
var result api.CreateFolderResponse
err := f.pacer.Call(func() (bool, error) {
resp, err := f.rest.CallJSON(ctx, &opts, &request, &result)
return shouldRetry(ctx, resp, err)
})
if err != nil {
return nil, fmt.Errorf("couldn't create folder: %w", err)
}
if !result.OK {
return nil, fmt.Errorf("API error: %s", result.Error)
}
return &result, nil
}
// removeFolder deletes a folder
//
// ?permanent=1 trashes and purges a live folder in one request on today's
// API. ifEmpty also sends ?if_empty=1, which asks the server to refuse with
// HTTP 409 "Folder is not empty" instead of purging a folder that still
// holds a live file or a live child folder; that 409 is turned into
// fs.ErrorDirectoryNotEmpty here rather than treated as a generic API
// error, and it is never retried (409 is not one of retryErrorCodes). A big
// subtree is purged in bounded passes (202, complete=false), so keep
// calling until the purge is complete. An older server that ignores
// permanent=1 only trashes on the first call (answering permanent=false),
// so the loop's second pass sends the plain DELETE that purges it.
func (f *Fs) removeFolder(ctx context.Context, folderID string, ifEmpty bool) error {
parameters := map[string][]string{"permanent": {"1"}}
if ifEmpty {
parameters["if_empty"] = []string{"1"}
}
opts := rest.Opts{
Method: "DELETE",
Path: "/api/folders/" + folderID,
Parameters: parameters,
ExtraHeaders: f.byIDHeaders(),
}
for pass := 0; ; pass++ {
var result api.DeleteFolderResponse
var statusCode int
err := f.pacer.Call(func() (bool, error) {
resp, err := f.rest.CallJSON(ctx, &opts, nil, &result)
if resp != nil {
statusCode = resp.StatusCode
}
return shouldRetry(ctx, resp, err)
})
if err != nil {
if ifEmpty && statusCode == http.StatusConflict {
return fs.ErrorDirectoryNotEmpty
}
if pass == 0 {
return fmt.Errorf("couldn't remove folder: %w", err)
}
return fmt.Errorf("couldn't permanently remove folder: %w", err)
}
if !result.OK {
return fmt.Errorf("API error: %s", result.Error)
}
if result.Permanent && result.Complete {
return nil
}
if pass >= maxFolderPurgePasses {
return fmt.Errorf("couldn't permanently remove folder: purge still incomplete after %d passes", pass)
}
}
}
// maxFolderPurgePasses bounds the DELETE calls spent purging one folder
const maxFolderPurgePasses = 1000
// renameFolder renames a folder
func (f *Fs) renameFolder(ctx context.Context, folderID string, newName string) error {
request := api.RenameFolderRequest{Name: newName}
opts := rest.Opts{
Method: "PUT",
Path: "/api/folders/" + folderID + "/rename",
ExtraHeaders: f.byIDHeaders(),
}
var result api.Response
err := f.pacer.Call(func() (bool, error) {
resp, err := f.rest.CallJSON(ctx, &opts, &request, &result)
return shouldRetry(ctx, resp, err)
})
if err != nil {
return fmt.Errorf("couldn't rename folder: %w", err)
}
if !result.OK {
return fmt.Errorf("API error: %s", result.Error)
}
return nil
}
// moveFolder moves a folder to a new parent
func (f *Fs) moveFolder(ctx context.Context, folderID string, newParentID string, newName string) (renamed bool, err error) {
request := api.MoveFolderRequest{Name: newName}
if newParentID != "" && newParentID != rootID {
request.ParentID = &newParentID
}
opts := rest.Opts{
Method: "PUT",
Path: "/api/folders/" + folderID + "/move",
ExtraHeaders: f.byIDHeaders(),
}
var result api.MoveFolderResponse
err = f.pacer.Call(func() (bool, error) {
resp, err := f.rest.CallJSON(ctx, &opts, &request, &result)
return shouldRetry(ctx, resp, err)
})
if err != nil {
return false, fmt.Errorf("couldn't move folder: %w", err)
}
if !result.OK {
return false, fmt.Errorf("API error: %s", result.Error)
}
// Servers that predate the name field ignore it and answer without a
// name, leaving the caller to rename separately
return newName != "" && result.Name == newName, nil
}
// deleteFile deletes a file
//
// ?permanent=1 trashes and purges it in one request on today's API. An
// older server that ignores the flag only soft-deletes it, answering
// permanent=false, so a second DELETE on the now soft-deleted file purges
// it from the database and R2, as it always has.
func (f *Fs) deleteFile(ctx context.Context, fileID string) error {
opts := rest.Opts{
Method: "DELETE",
Path: "/api/files/" + fileID,
Parameters: map[string][]string{"permanent": {"1"}},
ExtraHeaders: f.byIDHeaders(),
}
var result api.DeleteFileResponse
err := f.pacer.Call(func() (bool, error) {
resp, err := f.rest.CallJSON(ctx, &opts, nil, &result)
return shouldRetry(ctx, resp, err)
})
if err != nil {
return fmt.Errorf("couldn't delete file: %w", err)
}
if !result.OK {
return fmt.Errorf("API error: %s", result.Error)
}
// Answered into its own value so a field the second response omits
// can't be read off the first.
if !result.Permanent {
var permanent api.DeleteFileResponse
err = f.pacer.Call(func() (bool, error) {
resp, err := f.rest.CallJSON(ctx, &opts, nil, &permanent)
return shouldRetry(ctx, resp, err)
})
if err != nil {
return fmt.Errorf("couldn't permanently delete file: %w", err)
}
if !permanent.OK {
return fmt.Errorf("API error: %s", permanent.Error)
}
}
return nil
}
// renameFile renames a file
func (f *Fs) renameFile(ctx context.Context, fileID string, newName string) error {
request := api.RenameFileRequest{Name: newName}
opts := rest.Opts{
Method: "PUT",
Path: "/api/files/" + fileID + "/rename",
ExtraHeaders: f.byIDHeaders(),
}
var result api.RenameFileResponse
err := f.pacer.Call(func() (bool, error) {
resp, err := f.rest.CallJSON(ctx, &opts, &request, &result)
return shouldRetry(ctx, resp, err)
})
if err != nil {
return fmt.Errorf("couldn't rename file: %w", err)
}
if !result.OK {
return fmt.Errorf("API error: %s", result.Error)
}
return nil
}
// moveFile moves a file to a new folder and, if newName is non-empty,
// renames it in the same request
func (f *Fs) moveFile(ctx context.Context, fileID string, folderID string, newName string) error {
request := api.MoveFileRequest{}
if folderID != "" && folderID != rootID {
request.FolderID = &folderID
}
if newName != "" {
request.Name = &newName
}
opts := rest.Opts{
Method: "PUT",
Path: "/api/files/" + fileID + "/move",
ExtraHeaders: f.byIDHeaders(),
}
var result api.Response
err := f.pacer.Call(func() (bool, error) {
resp, err := f.rest.CallJSON(ctx, &opts, &request, &result)
return shouldRetry(ctx, resp, err)
})
if err != nil {
return fmt.Errorf("couldn't move file: %w", err)
}
if !result.OK {
return fmt.Errorf("API error: %s", result.Error)
}
return nil
}
// copyFile copies a file to a folder
func (f *Fs) copyFile(ctx context.Context, fileID string, folderID string, name string) (*api.CopyFileResponse, error) {
request := api.CopyFileRequest{Name: name}
if folderID != "" && folderID != rootID {
request.FolderID = &folderID
}
opts := rest.Opts{
Method: "POST",
Path: "/api/files/" + fileID + "/copy",
ExtraHeaders: f.byIDHeaders(),
}
var result api.CopyFileResponse
err := f.pacer.Call(func() (bool, error) {
resp, err := f.rest.CallJSON(ctx, &opts, &request, &result)
return shouldRetry(ctx, resp, err)
})
if err != nil {
return nil, fmt.Errorf("couldn't copy file: %w", err)
}
if !result.OK {
return nil, fmt.Errorf("API error: %s", result.Error)
}
return &result, nil
}
// downloadFile downloads a file by its ID
//
// The download endpoint answers 302 with a presigned R2 URL in Location, so
// this is two calls. Both go through the rest client, which is built on
// fshttp, so --timeout, --contimeout, --ca-cert, --no-check-certificate,
// --user-agent, --bind, --dump and the proxy settings apply to downloads as
// they do to every other call, and errorHandler and shouldRetry decide the
// errors and the retries uniformly.
func (f *Fs) downloadFile(ctx context.Context, fileID string, options []fs.OpenOption) (io.ReadCloser, error) {
// NoRedirect stops the client following the 302 with the API's
// Authorization header attached. A 302 is not a 2xx, so Call reports it
// as an error (having read and closed the body); the Location header
// is what actually matters, and finding it clears the error, as in
// backend/linkbox.
opts := rest.Opts{
Method: "GET",
Path: "/api/files/" + fileID + "/download",
ExtraHeaders: f.byIDHeaders(),
NoRedirect: true,
}
var downloadURL string
err := f.pacer.Call(func() (bool, error) {
resp, err := f.rest.Call(ctx, &opts)
if resp != nil {
if location := resp.Header.Get("Location"); location != "" {
downloadURL, err = location, nil
}
}
return shouldRetry(ctx, resp, err)
})
if err != nil {
return nil, fmt.Errorf("couldn't get download URL: %w", err)
}
if downloadURL == "" {
return nil, errors.New("no download URL in redirect response")
}
// Fetch the object from the presigned URL. It carries its own
// credentials and R2 refuses a request that also has an Authorization
// header, so the client-level bearer token is removed by setting the
// header empty: the rest client skips a header with an empty value.
opts = rest.Opts{
Method: "GET",
RootURL: downloadURL,
Options: options,
ExtraHeaders: map[string]string{"Authorization": ""},
}
var resp *http.Response
err = f.pacer.Call(func() (bool, error) {
var err error
resp, err = f.rest.Call(ctx, &opts)
return shouldRetry(ctx, resp, err)
})
if err != nil {
return nil, fmt.Errorf("couldn't download file: %w", err)
}
return resp.Body, nil
}
// uploadSmallFile uploads a file smaller than 10MB in a single request
func (f *Fs) uploadSmallFile(ctx context.Context, in io.Reader, sessionID string, size int64, modTime time.Time) (*api.UploadCompleteResponse, error) {
opts := rest.Opts{
Method: "PUT",
Path: "/api/upload/" + sessionID,
Body: in,
ContentLength: &size,
ContentType: "application/octet-stream",
ExtraHeaders: modTimeHeaders(modTime),
}
// The body is a stream which can't be rewound, so a low level retry
// would resend whatever is left of it - nothing - and the server
// would store that as an empty file. Don't retry here; rclone's high
// level retries redo the whole transfer instead.
var result api.UploadCompleteResponse
err := f.pacer.CallNoRetry(func() (bool, error) {
resp, err := f.rest.CallJSON(ctx, &opts, nil, &result)
return shouldRetry(ctx, resp, err)
})
if err != nil {
return nil, fmt.Errorf("couldn't upload file: %w", err)
}
if !result.OK {
return nil, fmt.Errorf("API error: %s", result.Error)
}
return &result, nil
}
// uploadPart uploads a single part of a multipart upload
func (f *Fs) uploadPart(ctx context.Context, part []byte, sessionID string, partNumber int) (*api.UploadPartResponse, error) {
size := int64(len(part))
opts := rest.Opts{
Method: "PUT",
Path: "/api/upload/" + sessionID + "/part/" + strconv.Itoa(partNumber),
ContentLength: &size,
ContentType: "application/octet-stream",
}
var result api.UploadPartResponse
err := f.pacer.Call(func() (bool, error) {
// A fresh reader per attempt so a retry resends the whole part
opts.Body = bytes.NewReader(part)
resp, err := f.rest.CallJSON(ctx, &opts, nil, &result)
return shouldRetry(ctx, resp, err)
})
if err != nil {
return nil, fmt.Errorf("couldn't upload part %d: %w", partNumber, err)
}
if !result.OK {
return nil, fmt.Errorf("API error: %s", result.Error)
}
return &result, nil
}
// completeUpload finalizes a multipart upload
func (f *Fs) completeUpload(ctx context.Context, sessionID string, modTime time.Time) (*api.UploadCompleteResponse, error) {
opts := rest.Opts{
Method: "POST",
Path: "/api/upload/" + sessionID + "/complete",
ExtraHeaders: modTimeHeaders(modTime),
}
var result api.UploadCompleteResponse
err := f.pacer.Call(func() (bool, error) {
resp, err := f.rest.CallJSON(ctx, &opts, nil, &result)
return shouldRetry(ctx, resp, err)
})
if err != nil {
return nil, fmt.Errorf("couldn't complete upload: %w", err)
}
if !result.OK {
return nil, fmt.Errorf("API error: %s", result.Error)
}
return &result, nil
}
// initUpload initializes a new upload session
func (f *Fs) initUpload(ctx context.Context, fileName string, fileSize int64, mimeType string, folderID string, fileID *string) (*api.UploadInitResponse, error) {
request := api.UploadInitRequest{
WorkspaceID: f.opt.WorkspaceID,
FileName: fileName,
FileSize: fileSize,
MimeType: mimeType,
FileID: fileID,
}
if folderID != "" && folderID != rootID {
request.FolderID = &folderID
}
opts := rest.Opts{
Method: "POST",
Path: "/api/upload/init",
}
var result api.UploadInitResponse
deadline := time.Now().Add(concurrentUploadWaitMax)
for {
result = api.UploadInitResponse{}
err := f.pacer.Call(func() (bool, error) {
resp, err := f.rest.CallJSON(ctx, &opts, &request, &result)
return shouldRetry(ctx, resp, err)
})
if err == nil {
break
}
if !isConcurrentUploadLimit(err) || time.Now().After(deadline) {
return nil, fmt.Errorf("couldn't init upload: %w", err)
}
fs.Debugf(f, "Upload slots in use, waiting %v before retrying %q: %v", concurrentUploadRetryDelay, fileName, err)
select {
case <-ctx.Done():
return nil, fmt.Errorf("couldn't init upload: %w", ctx.Err())
case <-time.After(concurrentUploadRetryDelay):
}
}
if !result.OK {
return nil, fmt.Errorf("API error: %s", result.Error)
}
return &result, nil
}
// uploadParts reads the source part by part and uploads the parts.
//
// Part 1 goes on its own: the server creates the R2 multipart upload (and a
// new file's id and key) when it receives the first part, so parts sent
// before that has finished would each start an upload of their own. The
// rest go out opt.UploadConcurrency at a time. A part is only read from the
// source once a buffer is free, so at most that many parts are held in
// memory, and a short source is still found by the reader before the part
// it could not fill is sent.
func (f *Fs) uploadParts(ctx context.Context, in *readers.CountingReader, sessionID string, size, partSize int64, totalParts int) error {
readPart := func(buf []byte, partNum int) ([]byte, error) {
currentPartSize := partSize
if partNum == totalParts {
currentPartSize = size - partSize*int64(totalParts-1)
}
part := buf[:currentPartSize]
if _, err := io.ReadFull(in, part); err != nil {
if err == io.EOF || err == io.ErrUnexpectedEOF {
return nil, fmt.Errorf("expected %d bytes in input, but got %d: %w", size, in.BytesRead(), io.ErrUnexpectedEOF)
}
return nil, fmt.Errorf("failed to read part %d: %w", partNum, err)
}
return part, nil
}
concurrency := max(f.opt.UploadConcurrency, 1)
buffers := make(chan []byte, concurrency)
buffers <- make([]byte, partSize)
allocated := 1
first := <-buffers
part, err := readPart(first, 1)
if err != nil {
return err
}
if _, err := f.uploadPart(ctx, part, sessionID, 1); err != nil {
return err
}
buffers <- first
// A failed read has to take the parts already in flight down with it,
// or they upload to completion while the error waits on Wait.
ctx, cancel := context.WithCancel(ctx)
defer cancel()
g, gCtx := errgroup.WithContext(ctx)
var readErr error
for partNum := 2; partNum <= totalParts; partNum++ {
// Take a free buffer, allocating one while under the limit.
var buf []byte
select {
case buf = <-buffers:
default:
if allocated < concurrency {
buf = make([]byte, partSize)
allocated++
} else {
select {
case buf = <-buffers:
case <-gCtx.Done():
}
}
}
if buf == nil {
break // a part failed; its error comes back from Wait
}
part, err := readPart(buf, partNum)
if err != nil {
readErr = err
cancel()
break
}
g.Go(func() error {
defer func() { buffers <- buf }()
_, err := f.uploadPart(gCtx, part, sessionID, partNum)
return err
})
}
err = g.Wait()
if readErr != nil {
return readErr
}
return err
}
// abortUpload retires an upload session the client is giving up on.
//
// Best effort: the server reclaims an abandoned session itself, so a failure
// here - including an older server with no such route - costs the slot the
// wait it would have cost anyway and is not worth failing an upload over.
// Sent on a context of its own so a cancelled transfer still gives its slot
// back rather than leaving it to the sweep.
func (f *Fs) abortUpload(ctx context.Context, sessionID string) {
ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), abortUploadTimeout)
defer cancel()
opts := rest.Opts{
Method: "DELETE",
Path: "/api/upload/" + sessionID,
NoResponse: true,
}
err := f.pacer.CallNoRetry(func() (bool, error) {
resp, err := f.rest.Call(ctx, &opts)
return shouldRetry(ctx, resp, err)
})
if err != nil {
fs.Debugf(f, "Couldn't abort upload session %q: %v", sessionID, err)
}
}
// uploadFile handles the full upload flow (small or multipart)
func (f *Fs) uploadFile(ctx context.Context, in io.Reader, remote string, size int64, modTime time.Time, mimeType string, folderID string, fileID *string) (resp *api.UploadCompleteResponse, err error) {
// Both refusals live here so a new file and a new version of an existing
// one answer the same way: upload/init needs a byte count up front, and
// the server stores no zero-length object.
if size < 0 {
return nil, errors.New("can't upload files with unknown size")
}
if size == 0 {
return nil, fs.ErrorCantUploadEmptyFiles
}
leaf := f.opt.Enc.FromStandardName(path.Base(remote))
initResp, err := f.initUpload(ctx, leaf, size, mimeType, folderID, fileID)
if err != nil {
return nil, err
}
// A session abandoned here holds one of the workspace's upload slots
// until the server's sweep retires it ten minutes later, and rclone's
// high-level retry opens a fresh session rather than resuming this one -
// so without this, a few failed attempts use up every slot.
defer func() {
if err != nil {
f.abortUpload(ctx, initResp.SessionID)
}
}()
// Count what the source actually supplies, so a source which ends
// early is reported as an error rather than stored as a truncated
// file.
counter := readers.NewCountingReader(in)
// Small file upload (single PUT, no resumable)
if initResp.Resumable == nil {
resp, err = f.uploadSmallFile(ctx, counter, initResp.SessionID, size, modTime)
if err != nil {
return nil, err
}
if got := int64(counter.BytesRead()); got != size {
return nil, fmt.Errorf("expected %d bytes in input, but got %d: %w", size, got, io.ErrUnexpectedEOF)
}
return resp, nil
}
// Multipart upload. Each part is read fully into memory before it is
// sent, so a short source is caught before the part goes out and a
// part which has to be retried is resent with all of its bytes.
partSize := initResp.Resumable.PartSize
totalParts := initResp.Resumable.TotalParts
// Both bounds matter. partSize*(totalParts-1) >= size means a part would
// have nothing to carry; size > partSize*totalParts means the last part
// is bigger than partSize, and readPart would slice past its buffer and
// panic.
if partSize <= 0 || totalParts <= 0 || partSize*int64(totalParts-1) >= size || size > partSize*int64(totalParts) {
return nil, fmt.Errorf("invalid multipart layout from server: %d parts of %d bytes for %d bytes", totalParts, partSize, size)
}
if err = f.uploadParts(ctx, counter, initResp.SessionID, size, partSize, totalParts); err != nil {
return nil, err
}
return f.completeUpload(ctx, initResp.SessionID, modTime)
}
// createShareLink creates a public share link for a file
func (f *Fs) createShareLink(ctx context.Context, fileID string, expire fs.Duration) (*api.ShareLinkResponse, error) {
request := api.ShareLinkRequest{}
if expire < fs.DurationOff {
days := int(time.Duration(expire).Hours() / 24)
if days < 1 {
days = 1
}
request.ExpiresInDays = &days
}
opts := rest.Opts{
Method: "POST",
Path: "/api/files/" + fileID + "/share",
ExtraHeaders: f.byIDHeaders(),
}
var result api.ShareLinkResponse
err := f.pacer.Call(func() (bool, error) {
resp, err := f.rest.CallJSON(ctx, &opts, &request, &result)
return shouldRetry(ctx, resp, err)
})
if err != nil {
return nil, fmt.Errorf("couldn't create share link: %w", err)
}
if !result.OK {
return nil, fmt.Errorf("API error: %s", result.Error)
}
return &result, nil
}
// getWorkspaceInfo fetches workspace storage info
// listWorkspaces lists the workspaces this credential can reach. An API key
// pinned to a workspace sees exactly one: its own.
func (f *Fs) listWorkspaces(ctx context.Context) ([]api.Workspace, error) {
opts := rest.Opts{
Method: "GET",
Path: "/api/workspaces",
}
var result api.WorkspaceListResponse
err := f.pacer.Call(func() (bool, error) {
resp, err := f.rest.CallJSON(ctx, &opts, nil, &result)
return shouldRetry(ctx, resp, err)
})
if err != nil {
return nil, fmt.Errorf("couldn't list workspaces: %w", err)
}
if !result.OK {
return nil, fmt.Errorf("API error: %s", result.Error)
}
return result.Workspaces, nil
}
func (f *Fs) getWorkspaceInfo(ctx context.Context) (*api.WorkspaceInfoResponse, error) {
opts := rest.Opts{
Method: "GET",
Path: "/api/workspaces/" + f.opt.WorkspaceID,
}
var result api.WorkspaceInfoResponse
err := f.pacer.Call(func() (bool, error) {
resp, err := f.rest.CallJSON(ctx, &opts, nil, &result)
return shouldRetry(ctx, resp, err)
})
if err != nil {
return nil, fmt.Errorf("couldn't get workspace info: %w", err)
}
if !result.OK {
return nil, fmt.Errorf("API error: %s", result.Error)
}
return &result, nil
}
+235
View File
@@ -0,0 +1,235 @@
package dosya
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"testing"
"time"
"github.com/rclone/rclone/fs"
"github.com/rclone/rclone/lib/dircache"
"github.com/rclone/rclone/lib/pacer"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestShouldRetryHonoursRetryAfter(t *testing.T) {
ctx := context.Background()
for _, tc := range []struct {
name string
status int
header string
want time.Duration
ok bool
}{
{"503 during a workspace move", http.StatusServiceUnavailable, "30", 30 * time.Second, true},
{"429 from the edge", http.StatusTooManyRequests, "7", 7 * time.Second, true},
{"capped", http.StatusServiceUnavailable, "86400", maxRetryAfter, true},
{"missing header", http.StatusServiceUnavailable, "", 0, false},
{"HTTP date is not seconds", http.StatusServiceUnavailable, "Wed, 21 Oct 2015 07:28:00 GMT", 0, false},
{"502 ignores the header", http.StatusBadGateway, "30", 0, false},
} {
t.Run(tc.name, func(t *testing.T) {
resp := jsonResp(tc.status, `{"ok":false,"error":"busy"}`)
if tc.header != "" {
resp.Header.Set("Retry-After", tc.header)
}
retry, err := shouldRetry(ctx, resp, errorHandler(resp))
assert.True(t, retry)
d, ok := pacer.IsRetryAfter(err)
assert.Equal(t, tc.ok, ok)
if tc.ok {
assert.Equal(t, tc.want, d)
}
})
}
}
func TestShouldRetryDoesNotRetryConflict(t *testing.T) {
resp := jsonResp(http.StatusConflict, `{"ok":false,"error":"A folder with this name already exists"}`)
resp.Header.Set("Retry-After", "30")
retry, err := shouldRetry(context.Background(), resp, errorHandler(resp))
assert.False(t, retry)
_, ok := pacer.IsRetryAfter(err)
assert.False(t, ok)
}
func TestRemoveFolderPurgesPermanently(t *testing.T) {
// a big subtree purges in bounded passes: 202 complete=false repeats
// the same request until complete=true
answers := []struct {
status int
body string
}{
{http.StatusAccepted, `{"ok":true,"permanent":true,"complete":false,"remaining":1}`},
{http.StatusOK, `{"ok":true,"permanent":true,"complete":true,"remaining":0}`},
}
calls := 0
f := newFakeFs(rtFunc(func(r *http.Request) (*http.Response, error) {
require.Equal(t, "DELETE", r.Method)
require.Equal(t, "/api/folders/fld_1", r.URL.Path)
assert.Equal(t, "1", r.URL.Query().Get("permanent"))
assert.Equal(t, "", r.URL.Query().Get("if_empty"))
require.Less(t, calls, len(answers), "DELETE sent after the purge completed")
a := answers[calls]
calls++
return jsonResp(a.status, a.body), nil
}))
require.NoError(t, f.removeFolder(context.Background(), "fld_1", false))
assert.Equal(t, len(answers), calls)
}
func TestRemoveFolderStopsOnPurgeError(t *testing.T) {
calls := 0
f := newFakeFs(rtFunc(func(r *http.Request) (*http.Response, error) {
calls++
return jsonResp(http.StatusForbidden, `{"ok":false,"error":"This folder contains a locked file and cannot be purged"}`), nil
}))
err := f.removeFolder(context.Background(), "fld_1", false)
require.Error(t, err)
assert.Contains(t, err.Error(), "locked file")
assert.Equal(t, 1, calls)
}
func TestRemoveFolderIfEmptyRejectsNonEmptyWithoutRetry(t *testing.T) {
calls := 0
f := newFakeFs(rtFunc(func(r *http.Request) (*http.Response, error) {
calls++
assert.Equal(t, "/api/folders/fld_1", r.URL.Path)
assert.Equal(t, "1", r.URL.Query().Get("permanent"))
assert.Equal(t, "1", r.URL.Query().Get("if_empty"))
return jsonResp(http.StatusConflict, `{"ok":false,"error":"Folder is not empty"}`), nil
}))
err := f.removeFolder(context.Background(), "fld_1", true)
assert.ErrorIs(t, err, fs.ErrorDirectoryNotEmpty)
assert.Equal(t, 1, calls, "a 409 must not be retried by the pacer")
}
func TestRemoveFolderIfEmptyPurgesEmptyFolderInOneCall(t *testing.T) {
calls := 0
f := newFakeFs(rtFunc(func(r *http.Request) (*http.Response, error) {
calls++
assert.Equal(t, "1", r.URL.Query().Get("permanent"))
assert.Equal(t, "1", r.URL.Query().Get("if_empty"))
return jsonResp(http.StatusOK, `{"ok":true,"permanent":true,"complete":true}`), nil
}))
require.NoError(t, f.removeFolder(context.Background(), "fld_1", true))
assert.Equal(t, 1, calls)
}
func TestRmdirMakesOneRequest(t *testing.T) {
calls := 0
f := newFakeFs(rtFunc(func(r *http.Request) (*http.Response, error) {
calls++
assert.Equal(t, "DELETE", r.Method)
assert.Equal(t, "/api/folders/fld_1", r.URL.Path)
assert.Equal(t, "1", r.URL.Query().Get("permanent"))
assert.Equal(t, "1", r.URL.Query().Get("if_empty"))
return jsonResp(http.StatusOK, `{"ok":true,"permanent":true,"complete":true}`), nil
}))
f.dirCache = dircache.New("", rootID, f)
require.NoError(t, f.dirCache.FindRoot(context.Background(), false))
f.dirCache.Put("sub", "fld_1")
require.NoError(t, f.Rmdir(context.Background(), "sub"))
assert.Equal(t, 1, calls, "Rmdir must not list the directory before removing it")
}
func TestRmdirMapsNonEmptyConflict(t *testing.T) {
f := newFakeFs(rtFunc(func(r *http.Request) (*http.Response, error) {
return jsonResp(http.StatusConflict, `{"ok":false,"error":"Folder is not empty"}`), nil
}))
f.dirCache = dircache.New("", rootID, f)
require.NoError(t, f.dirCache.FindRoot(context.Background(), false))
f.dirCache.Put("sub", "fld_1")
err := f.Rmdir(context.Background(), "sub")
assert.ErrorIs(t, err, fs.ErrorDirectoryNotEmpty)
}
func TestDeleteFileSendsPermanentFlag(t *testing.T) {
for _, tc := range []struct {
name string
permanent bool
wantCalls int
}{
{"today's API purges in one call", true, 1},
{"an older server ignores the flag and still gets a second DELETE", false, 2},
} {
t.Run(tc.name, func(t *testing.T) {
calls := 0
f := newFakeFs(rtFunc(func(r *http.Request) (*http.Response, error) {
calls++
assert.Equal(t, "DELETE", r.Method)
assert.Equal(t, "/api/files/fil_1", r.URL.Path)
assert.Equal(t, "1", r.URL.Query().Get("permanent"))
return jsonResp(http.StatusOK, fmt.Sprintf(`{"ok":true,"permanent":%t}`, tc.permanent)), nil
}))
require.NoError(t, f.deleteFile(context.Background(), "fil_1"))
assert.Equal(t, tc.wantCalls, calls)
})
}
}
// decodeBody reads a JSON request body into a map
func decodeBody(t *testing.T, r *http.Request) map[string]any {
b, err := io.ReadAll(r.Body)
require.NoError(t, err)
var m map[string]any
require.NoError(t, json.Unmarshal(b, &m))
return m
}
func TestMoveFolderSendsName(t *testing.T) {
for _, tc := range []struct {
name string
newName string
respBody string
wantRenamed bool
}{
{"server honours name", "dst", `{"ok":true,"name":"dst"}`, true},
{"older server ignores name", "dst", `{"ok":true}`, false},
{"no rename requested", "", `{"ok":true,"name":"src"}`, false},
} {
t.Run(tc.name, func(t *testing.T) {
f := newFakeFs(rtFunc(func(r *http.Request) (*http.Response, error) {
assert.Equal(t, "/api/folders/fld_1/move", r.URL.Path)
body := decodeBody(t, r)
assert.Equal(t, "fld_parent", body["parent_id"])
if tc.newName == "" {
assert.NotContains(t, body, "name")
} else {
assert.Equal(t, tc.newName, body["name"])
}
return jsonResp(http.StatusOK, tc.respBody), nil
}))
renamed, err := f.moveFolder(context.Background(), "fld_1", "fld_parent", tc.newName)
require.NoError(t, err)
assert.Equal(t, tc.wantRenamed, renamed)
})
}
}
func TestCopyFileSendsName(t *testing.T) {
f := newFakeFs(rtFunc(func(r *http.Request) (*http.Response, error) {
assert.Equal(t, "/api/files/fil_1/copy", r.URL.Path)
body := decodeBody(t, r)
assert.Equal(t, "fld_dst", body["folder_id"])
assert.Equal(t, "final.txt", body["name"])
return jsonResp(http.StatusCreated, `{"ok":true,"file_id":"fil_2","name":"final.txt"}`), nil
}))
resp, err := f.copyFile(context.Background(), "fil_1", "fld_dst", "final.txt")
require.NoError(t, err)
assert.Equal(t, "fil_2", resp.FileID)
assert.Equal(t, "final.txt", resp.Name)
}
+171
View File
@@ -0,0 +1,171 @@
package dosya
import (
"context"
"io"
"net/http"
"strconv"
"strings"
"testing"
"time"
"github.com/rclone/rclone/backend/dosya/api"
"github.com/rclone/rclone/fs/hash"
"github.com/rclone/rclone/lib/dircache"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestFsAdvertisesSHA256AndSecondPrecision(t *testing.T) {
f := &Fs{}
assert.Equal(t, time.Second, f.Precision())
assert.True(t, f.Hashes().Contains(hash.SHA256), "SHA-256 must be advertised")
// Exactly SHA-256, nothing else.
assert.Equal(t, hash.NewHashSet(hash.SHA256), f.Hashes())
}
func TestObjectHashReturnsContentHash(t *testing.T) {
o := &Object{file: api.FileItem{ContentHash: "abc123"}}
got, err := o.Hash(context.Background(), hash.SHA256)
require.NoError(t, err)
assert.Equal(t, "abc123", got)
// A multipart object with no server-side hash yields "" and no error,
// exactly as an S3 multipart object does.
empty := &Object{file: api.FileItem{ContentHash: ""}}
got, err = empty.Hash(context.Background(), hash.SHA256)
require.NoError(t, err)
assert.Equal(t, "", got)
// Any other algorithm is unsupported.
_, err = o.Hash(context.Background(), hash.MD5)
assert.ErrorIs(t, err, hash.ErrUnsupported)
}
func TestObjectModTimeUsesUpdatedAt(t *testing.T) {
mt := time.Date(2003, 2, 3, 4, 5, 6, 0, time.UTC)
o := &Object{file: api.FileItem{UpdatedAt: float64(mt.Unix()), CreatedAt: 1}}
assert.True(t, mt.Equal(o.ModTime(context.Background())))
}
func TestModTimeHeaders(t *testing.T) {
assert.Nil(t, modTimeHeaders(time.Time{}), "a zero time sends no header")
mt := time.Date(2003, 2, 3, 4, 5, 6, 499, time.UTC)
h := modTimeHeaders(mt)
require.NotNil(t, h)
assert.Equal(t, strconv.FormatInt(mt.Unix(), 10), h[sourceMTimeHeader])
}
func TestFileFromUploadKeepsModTimeAndHash(t *testing.T) {
resp := &api.UploadCompleteResponse{}
resp.File.ID = "fil_1"
resp.File.Name = "x.txt"
resp.File.SizeBytes = 10
resp.File.CreatedAt = 1_000
resp.File.ContentHash = "deadbeef"
mt := time.Date(2004, 3, 3, 4, 5, 6, 0, time.UTC)
item := fileFromUpload(resp, mt)
assert.Equal(t, float64(mt.Unix()), item.UpdatedAt, "modtime is what we sent, not the create time")
assert.Equal(t, "deadbeef", item.ContentHash)
assert.Equal(t, float64(1_000), item.CreatedAt)
// A zero modtime falls back to the create time.
item = fileFromUpload(resp, time.Time{})
assert.Equal(t, float64(1_000), item.UpdatedAt)
}
// TestUploadSendsSourceMTimeHeader drives a real single-PUT upload through a
// fake transport and asserts the source-mtime header actually rides on the PUT.
func TestUploadSendsSourceMTimeHeader(t *testing.T) {
var putMTime string
rt := rtFunc(func(r *http.Request) (*http.Response, error) {
if r.Body != nil {
_, _ = io.ReadAll(r.Body) // drain so the upload's counting reader advances
}
switch {
case r.Method == "POST" && r.URL.Path == "/api/upload/init":
return jsonResp(http.StatusCreated, `{"ok":true,"session_id":"upl_1"}`), nil
case r.Method == "PUT" && r.URL.Path == "/api/upload/upl_1":
putMTime = r.Header.Get(sourceMTimeHeader)
return jsonResp(http.StatusCreated, `{"ok":true,"file":{"id":"fil_1","name":"file.txt","size_bytes":5,"content_hash":"h"}}`), nil
}
t.Fatalf("unexpected %s %s", r.Method, r.URL.Path)
return nil, nil
})
f := newFakeFs(rt)
mt := time.Date(2003, 2, 3, 4, 5, 6, 0, time.UTC)
resp, err := f.uploadFile(context.Background(), strings.NewReader("hello"), "file.txt", 5, mt, "text/plain; charset=utf-8", "", nil)
require.NoError(t, err)
assert.Equal(t, "h", resp.File.ContentHash)
assert.Equal(t, strconv.FormatInt(mt.Unix(), 10), putMTime, "the PUT must carry the source-mtime header")
}
// TestMultipartCompleteSendsSourceMTimeHeader asserts the header also rides on
// the completion request for a multipart upload (the server stores the modtime
// there, not on the individual parts).
func TestMultipartCompleteSendsSourceMTimeHeader(t *testing.T) {
var completeMTime string
rt := rtFunc(func(r *http.Request) (*http.Response, error) {
if r.Body != nil {
_, _ = io.ReadAll(r.Body)
}
switch {
case r.URL.Path == "/api/upload/init":
return jsonResp(http.StatusCreated, `{"ok":true,"session_id":"upl_1","resumable":{"part_size":4,"total_parts":3}}`), nil
case strings.HasPrefix(r.URL.Path, "/api/upload/upl_1/part/"):
return jsonResp(http.StatusOK, `{"ok":true}`), nil
case r.URL.Path == "/api/upload/upl_1/complete":
completeMTime = r.Header.Get(sourceMTimeHeader)
return jsonResp(http.StatusOK, `{"ok":true,"file":{"id":"fil_1","name":"file.txt","size_bytes":10}}`), nil
}
t.Fatalf("unexpected %s %s", r.Method, r.URL.Path)
return nil, nil
})
f := newFakeFs(rt)
mt := time.Date(2003, 2, 3, 4, 5, 6, 0, time.UTC)
_, err := f.uploadFile(context.Background(), strings.NewReader("0123456789"), "file.txt", 10, mt, "text/plain; charset=utf-8", "", nil)
require.NoError(t, err)
assert.Equal(t, strconv.FormatInt(mt.Unix(), 10), completeMTime, "the complete call must carry the source-mtime header")
}
// TestCopyReturnsSourceModTimeAndHash guards the fingerprint of the object a
// server-side Copy returns: it must report the SOURCE's modtime and hash (the
// copy is byte-identical and the API preserves both), not the copy's wall
// clock. Reading "now" here made rclone's in-memory object disagree with the
// one read back from the remote and failed FsCopy's fingerprint check.
func TestCopyReturnsSourceModTimeAndHash(t *testing.T) {
rt := rtFunc(func(r *http.Request) (*http.Response, error) {
switch {
case r.Method == "GET" && r.URL.Path == "/api/files":
// NewObject's existence probe on the destination: nothing there.
return jsonResp(http.StatusOK, `{"ok":true,"files":[],"folders":[]}`), nil
case r.Method == "POST" && strings.HasSuffix(r.URL.Path, "/copy"):
return jsonResp(http.StatusOK, `{"ok":true,"file_id":"fil_copy","name":"f-copy.txt"}`), nil
}
t.Fatalf("unexpected %s %s", r.Method, r.URL.Path)
return nil, nil
})
f := newFakeFs(rt)
f.dirCache = dircache.New("", rootID, f)
mt := time.Date(2001, 2, 3, 4, 5, 10, 0, time.UTC)
ct := time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC)
src := &Object{fs: f, remote: "orig.txt", file: api.FileItem{
ID: "fil_src", Name: "orig.txt", SizeBytes: 100, MimeType: "text/plain",
CreatedAt: float64(ct.Unix()), UpdatedAt: float64(mt.Unix()), ContentHash: "srchash",
}}
dst, err := f.Copy(context.Background(), src, "f-copy.txt")
require.NoError(t, err)
assert.True(t, mt.Equal(dst.ModTime(context.Background())), "copy keeps the source modtime")
assert.Equal(t, int64(100), dst.Size())
got, err := dst.Hash(context.Background(), hash.SHA256)
require.NoError(t, err)
assert.Equal(t, "srchash", got, "copy keeps the source hash")
}
+156
View File
@@ -0,0 +1,156 @@
package dosya
import (
"context"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/rclone/rclone/backend/dosya/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 concurrentLimitBody = `{"ok":false,"error":"You have 5 uploads in progress. Wait for them to finish before starting more.","error_code":"concurrent_upload_limit","max_concurrent_uploads":5}`
func TestErrorHandlerDecodesAPIError(t *testing.T) {
resp := &http.Response{
StatusCode: http.StatusBadRequest,
Status: "400 Bad Request",
Body: io.NopCloser(strings.NewReader(concurrentLimitBody)),
}
err := errorHandler(resp)
var apiErr *api.Error
require.ErrorAs(t, err, &apiErr)
assert.Equal(t, http.StatusBadRequest, apiErr.StatusCode)
assert.Equal(t, "concurrent_upload_limit", apiErr.ErrorCode)
assert.Equal(t, 5, apiErr.MaxConcurrentUploads)
assert.Contains(t, err.Error(), "uploads in progress")
assert.Contains(t, err.Error(), "concurrent_upload_limit")
}
func TestErrorHandlerNonJSONBody(t *testing.T) {
// e.g. an edge (Cloudflare) response that never reached the API
resp := &http.Response{
StatusCode: http.StatusTooManyRequests,
Status: "429 Too Many Requests",
Body: io.NopCloser(strings.NewReader("error code: 1015\n")),
}
err := errorHandler(resp)
var apiErr *api.Error
require.ErrorAs(t, err, &apiErr)
assert.Equal(t, http.StatusTooManyRequests, apiErr.StatusCode)
assert.Equal(t, "", apiErr.ErrorCode)
assert.Contains(t, err.Error(), "429")
assert.Contains(t, err.Error(), "error code: 1015")
}
func TestShouldRetryAPIErrors(t *testing.T) {
ctx := context.Background()
// The concurrent upload limit is handled by initUpload's slot wait,
// not by the pacer's low level retries.
limitErr := &api.Error{StatusCode: http.StatusBadRequest, ErrorCode: "concurrent_upload_limit"}
retry, err := shouldRetry(ctx, &http.Response{StatusCode: http.StatusBadRequest}, limitErr)
assert.False(t, retry)
assert.Equal(t, limitErr, err)
retry, _ = shouldRetry(ctx, &http.Response{StatusCode: http.StatusTooManyRequests}, &api.Error{StatusCode: http.StatusTooManyRequests})
assert.True(t, retry)
retry, _ = shouldRetry(ctx, &http.Response{StatusCode: http.StatusBadRequest}, &api.Error{StatusCode: http.StatusBadRequest, ErrorCode: "something_else"})
assert.False(t, retry)
}
// swapWaitTimings shortens the slot wait timings for a test and returns a
// function which restores them
func swapWaitTimings(delay, max time.Duration) func() {
oldDelay, oldMax := concurrentUploadRetryDelay, concurrentUploadWaitMax
concurrentUploadRetryDelay, concurrentUploadWaitMax = delay, max
return func() { concurrentUploadRetryDelay, concurrentUploadWaitMax = oldDelay, oldMax }
}
// newTestFs makes an Fs which talks to srv
func newTestFs(srv *httptest.Server) *Fs {
ctx := context.Background()
f := &Fs{
name: "test",
opt: Options{WorkspaceID: "ws_test"},
pacer: fs.NewPacer(ctx, pacer.NewDefault(
pacer.MinSleep(time.Millisecond),
pacer.MaxSleep(time.Millisecond),
pacer.DecayConstant(decayConstant),
pacer.AttackConstant(attackConstant),
)),
}
f.rest = rest.NewClient(srv.Client()).SetRoot(srv.URL).SetErrorHandler(errorHandler)
return f
}
// limitServer answers the first n init calls with the concurrent upload
// limit error and the rest with success
func limitServer(t *testing.T, n int32, calls *atomic.Int32) *httptest.Server {
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/upload/init", r.URL.Path)
w.Header().Set("Content-Type", "application/json")
if calls.Add(1) <= n {
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(concurrentLimitBody))
return
}
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(`{"ok":true}`))
}))
}
func TestInitUploadWaitsForFreeSlot(t *testing.T) {
defer swapWaitTimings(5*time.Millisecond, 10*time.Second)()
var calls atomic.Int32
srv := limitServer(t, 2, &calls)
defer srv.Close()
f := newTestFs(srv)
resp, err := f.initUpload(context.Background(), "file.txt", 10, "text/plain", "", nil)
require.NoError(t, err)
assert.True(t, resp.OK)
assert.Equal(t, int32(3), calls.Load())
}
func TestInitUploadGivesUpWhenSlotsStayFull(t *testing.T) {
defer swapWaitTimings(5*time.Millisecond, 40*time.Millisecond)()
var calls atomic.Int32
srv := limitServer(t, 1<<30, &calls)
defer srv.Close()
f := newTestFs(srv)
_, err := f.initUpload(context.Background(), "file.txt", 10, "text/plain", "", nil)
require.Error(t, err)
var apiErr *api.Error
require.ErrorAs(t, err, &apiErr)
assert.Equal(t, "concurrent_upload_limit", apiErr.ErrorCode)
assert.Contains(t, err.Error(), "couldn't init upload")
assert.GreaterOrEqual(t, calls.Load(), int32(2), "should have retried at least once")
}
func TestInitUploadStopsWaitingWhenContextCancelled(t *testing.T) {
defer swapWaitTimings(time.Minute, time.Hour)()
var calls atomic.Int32
srv := limitServer(t, 1<<30, &calls)
defer srv.Close()
f := newTestFs(srv)
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
defer cancel()
start := time.Now()
_, err := f.initUpload(ctx, "file.txt", 10, "text/plain", "", nil)
require.Error(t, err)
assert.ErrorIs(t, err, context.DeadlineExceeded)
assert.Less(t, time.Since(start), 5*time.Second)
}
+68
View File
@@ -0,0 +1,68 @@
package dosya
import (
"context"
"fmt"
"net/http"
"strconv"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// fakeListServer serves GET /api/files the way the API does: files paged at
// per_page, every folder repeated on each page
func fakeListServer(t *testing.T, totalFiles int, pages *[]int) rtFunc {
return func(r *http.Request) (*http.Response, error) {
require.Equal(t, "/api/files", r.URL.Path)
q := r.URL.Query()
assert.Equal(t, "name", q.Get("sort"))
assert.Equal(t, "asc", q.Get("dir"))
page, _ := strconv.Atoi(q.Get("page"))
perPage, _ := strconv.Atoi(q.Get("per_page"))
require.Positive(t, page)
require.Positive(t, perPage)
*pages = append(*pages, page)
var files []string
for i := (page - 1) * perPage; i < page*perPage && i < totalFiles; i++ {
files = append(files, fmt.Sprintf(`{"id":"fil_%d","name":"f%05d.txt","size":1}`, i, i))
}
totalPages := max(1, (totalFiles+perPage-1)/perPage)
body := fmt.Sprintf(`{"ok":true,"files":[%s],"folders":[{"id":"fld_1","name":"sub"}],`+
`"pagination":{"page":%d,"per_page":%d,"total_files":%d,"total_pages":%d}}`,
strings.Join(files, ","), page, perPage, totalFiles, totalPages)
return jsonResp(http.StatusOK, body), nil
}
}
func TestListFilesAndFoldersFollowsPages(t *testing.T) {
var pages []int
f := newFakeFs(fakeListServer(t, 2*listPageSize+7, &pages))
listing, err := f.listFilesAndFolders(context.Background(), "fld_parent")
require.NoError(t, err)
assert.Equal(t, []int{1, 2, 3}, pages)
require.Len(t, listing.Files, 2*listPageSize+7)
assert.Equal(t, "f01006.txt", listing.Files[len(listing.Files)-1].Name)
// folders repeat on every page and must not be duplicated
assert.Len(t, listing.Folders, 1)
}
func TestListFilesAndFoldersSinglePage(t *testing.T) {
for _, total := range []int{0, 1, listPageSize} {
t.Run(strconv.Itoa(total), func(t *testing.T) {
var pages []int
f := newFakeFs(fakeListServer(t, total, &pages))
listing, err := f.listFilesAndFolders(context.Background(), rootID)
require.NoError(t, err)
assert.Equal(t, []int{1}, pages)
assert.Len(t, listing.Files, total)
})
}
}
+153
View File
@@ -0,0 +1,153 @@
package dosya
import (
"context"
"fmt"
"io"
"net/http"
"strings"
"sync"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// parallelUploadServer answers the multipart endpoints without holding a
// lock across a request, so parts really do overlap, and records what the
// ordering rules below are about: how many parts were in flight at once, and
// whether any part started before part 1 had finished.
//
// Part 1 matters because the API creates the R2 multipart upload (and, for a
// new file, its id and key) on the first part it sees. Parts that race ahead
// of part 1 would each create their own upload, and only one of those would
// be completed.
type parallelUploadServer struct {
t *testing.T
resumable string
partDelay time.Duration
failPart int // answer this part with a 400, 0 for none
mu sync.Mutex
inFlight int
maxFlight int
part1Done bool
earlyParts []int // parts that started before part 1 finished
parts map[int]string
completes int
aborts int
}
func (s *parallelUploadServer) roundTrip(r *http.Request) (*http.Response, error) {
var body []byte
if r.Body != nil {
var err error
body, err = io.ReadAll(r.Body)
require.NoError(s.t, err)
}
path := r.URL.Path
switch {
case r.Method == "POST" && path == "/api/upload/init":
return jsonResp(http.StatusCreated, `{"ok":true,"session_id":"upl_1","resumable":`+s.resumable+`}`), nil
case r.Method == "PUT" && strings.HasPrefix(path, "/api/upload/upl_1/part/"):
var n int
_, err := fmt.Sscanf(strings.TrimPrefix(path, "/api/upload/upl_1/part/"), "%d", &n)
require.NoError(s.t, err)
s.mu.Lock()
if n != 1 && !s.part1Done {
s.earlyParts = append(s.earlyParts, n)
}
s.inFlight++
s.maxFlight = max(s.maxFlight, s.inFlight)
s.mu.Unlock()
time.Sleep(s.partDelay)
s.mu.Lock()
s.inFlight--
if n == 1 {
s.part1Done = true
}
s.parts[n] = string(body)
fail := n == s.failPart
s.mu.Unlock()
if fail {
return jsonResp(http.StatusBadRequest, `{"ok":false,"error":"part refused"}`), nil
}
return jsonResp(http.StatusCreated, fmt.Sprintf(`{"ok":true,"part_number":%d,"etag":"e%d"}`, n, n)), nil
case r.Method == "POST" && path == "/api/upload/upl_1/complete":
s.mu.Lock()
s.completes++
s.mu.Unlock()
return jsonResp(http.StatusOK, `{"ok":true,"file":{"id":"fil_1","name":"file.txt","size_bytes":20}}`), nil
case r.Method == "DELETE" && path == "/api/upload/upl_1":
s.mu.Lock()
s.aborts++
s.mu.Unlock()
return jsonResp(http.StatusOK, `{"ok":true}`), nil
}
s.t.Fatalf("unexpected request %s %s", r.Method, path)
return nil, nil
}
func newParallelServer(t *testing.T, resumable string) *parallelUploadServer {
return &parallelUploadServer{t: t, resumable: resumable, partDelay: 50 * time.Millisecond, parts: map[int]string{}}
}
const tenParts = `{"part_size":2,"total_parts":10}`
func TestUploadMultipartSendsPartsConcurrently(t *testing.T) {
srv := newParallelServer(t, tenParts)
f := newFakeFs(rtFunc(srv.roundTrip))
f.opt.UploadConcurrency = 4
content := "aabbccddeeffgghhiijj"
_, err := f.uploadFile(context.Background(), strings.NewReader(content), "file.txt", 20, time.Time{}, "text/plain; charset=utf-8", "", nil)
require.NoError(t, err)
assert.Equal(t, 4, srv.maxFlight, "parts after the first should use the whole concurrency")
assert.Empty(t, srv.earlyParts, "no part may start before part 1 has finished")
assert.Equal(t, 1, srv.completes)
for i := 1; i <= 10; i++ {
assert.Equal(t, content[2*(i-1):2*i], srv.parts[i], "part %d carries its own bytes", i)
}
}
func TestUploadMultipartConcurrencyOneIsSequential(t *testing.T) {
srv := newParallelServer(t, tenParts)
f := newFakeFs(rtFunc(srv.roundTrip))
f.opt.UploadConcurrency = 1
_, err := f.uploadFile(context.Background(), strings.NewReader("aabbccddeeffgghhiijj"), "file.txt", 20, time.Time{}, "text/plain; charset=utf-8", "", nil)
require.NoError(t, err)
assert.Equal(t, 1, srv.maxFlight)
assert.Equal(t, 1, srv.completes)
}
func TestUploadMultipartFailedPartIsNotCompleted(t *testing.T) {
srv := newParallelServer(t, tenParts)
srv.failPart = 6
f := newFakeFs(rtFunc(srv.roundTrip))
f.opt.UploadConcurrency = 4
_, err := f.uploadFile(context.Background(), strings.NewReader("aabbccddeeffgghhiijj"), "file.txt", 20, time.Time{}, "text/plain; charset=utf-8", "", nil)
require.Error(t, err)
assert.Contains(t, err.Error(), "part 6")
assert.Equal(t, 0, srv.completes, "an upload with a failed part must not be completed")
}
func TestUploadMultipartParallelRejectsShortSource(t *testing.T) {
// 20 bytes declared, 15 supplied: the short read is found by the
// reader, whichever parts are already in flight, and nothing completes.
srv := newParallelServer(t, tenParts)
f := newFakeFs(rtFunc(srv.roundTrip))
f.opt.UploadConcurrency = 4
_, err := f.uploadFile(context.Background(), strings.NewReader("aabbccddeeffggh"), "file.txt", 20, time.Time{}, "text/plain; charset=utf-8", "", nil)
require.Error(t, err)
assert.ErrorIs(t, err, io.ErrUnexpectedEOF)
assert.Equal(t, 0, srv.completes)
assert.Empty(t, srv.parts[8], "a part the source never filled must not be sent")
}
+15
View File
@@ -0,0 +1,15 @@
// Test Dosya filesystem interface
package dosya
import (
"testing"
"github.com/rclone/rclone/fstest/fstests"
)
// TestIntegration runs integration tests against the remote
func TestIntegration(t *testing.T) {
fstests.Run(t, &fstests.Opt{
RemoteName: "TestDosya:",
})
}
+247
View File
@@ -0,0 +1,247 @@
package dosya
import (
"context"
"fmt"
"io"
"net/http"
"strings"
"sync"
"testing"
"time"
"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"
)
// rtFunc adapts a function into an http.RoundTripper so the upload path
// can be driven without a network. Unlike a real server it always reads
// the whole request body and answers, which is exactly the misbehaviour
// (accepting a short body as a complete upload) these tests guard against.
type rtFunc func(*http.Request) (*http.Response, error)
func (f rtFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
func jsonResp(status int, body string) *http.Response {
return &http.Response{
StatusCode: status,
Status: fmt.Sprintf("%d %s", status, http.StatusText(status)),
Header: http.Header{"Content-Type": {"application/json"}},
Body: io.NopCloser(strings.NewReader(body)),
ContentLength: int64(len(body)),
}
}
func newFakeFs(rt http.RoundTripper) *Fs {
ctx := context.Background()
f := &Fs{
name: "test",
opt: Options{WorkspaceID: "ws_test"},
pacer: fs.NewPacer(ctx, pacer.NewDefault(
pacer.MinSleep(time.Millisecond),
pacer.MaxSleep(time.Millisecond),
pacer.DecayConstant(decayConstant),
pacer.AttackConstant(attackConstant),
)),
}
f.rest = rest.NewClient(&http.Client{Transport: rt}).SetRoot("https://fake.invalid").SetErrorHandler(errorHandler)
return f
}
// fakeUploadServer records what an upload sends and lets a test inject
// failures per request
type fakeUploadServer struct {
t *testing.T
mu sync.Mutex
resumable string // JSON for the init "resumable" field, "" for a single PUT
puts [][]byte // bodies of PUT /api/upload/<id>
parts map[int][][]byte // bodies of PUT /api/upload/<id>/part/<n>, per part, per attempt
completes int
aborts int // DELETE /api/upload/<id>
failFirst map[string]bool // path -> answer the first request with a 502
seenBefore map[string]bool
}
func newFakeUploadServer(t *testing.T, resumable string) *fakeUploadServer {
return &fakeUploadServer{
t: t,
resumable: resumable,
parts: map[int][][]byte{},
failFirst: map[string]bool{},
seenBefore: map[string]bool{},
}
}
func (s *fakeUploadServer) roundTrip(r *http.Request) (*http.Response, error) {
s.mu.Lock()
defer s.mu.Unlock()
var body []byte
if r.Body != nil {
var err error
body, err = io.ReadAll(r.Body)
require.NoError(s.t, err)
}
path := r.URL.Path
// Bodies are recorded for every attempt, including the ones answered
// with a 502, so tests can see exactly what each attempt carried.
fail := s.failFirst[path] && !s.seenBefore[path]
s.seenBefore[path] = true
bad := jsonResp(http.StatusBadGateway, `{"ok":false,"error":"bad gateway"}`)
switch {
case r.Method == "POST" && path == "/api/upload/init":
if s.resumable != "" {
return jsonResp(http.StatusCreated, `{"ok":true,"session_id":"upl_1","resumable":`+s.resumable+`}`), nil
}
return jsonResp(http.StatusCreated, `{"ok":true,"session_id":"upl_1"}`), nil
case r.Method == "PUT" && path == "/api/upload/upl_1":
s.puts = append(s.puts, body)
if fail {
return bad, nil
}
return jsonResp(http.StatusCreated, fmt.Sprintf(`{"ok":true,"file":{"id":"fil_1","name":"file.txt","size_bytes":%d}}`, len(body))), nil
case r.Method == "PUT" && strings.HasPrefix(path, "/api/upload/upl_1/part/"):
var n int
_, err := fmt.Sscanf(strings.TrimPrefix(path, "/api/upload/upl_1/part/"), "%d", &n)
require.NoError(s.t, err)
s.parts[n] = append(s.parts[n], body)
if fail {
return bad, nil
}
return jsonResp(http.StatusOK, fmt.Sprintf(`{"ok":true,"part_number":%d,"bytes_uploaded":%d}`, n, len(body))), nil
case r.Method == "POST" && path == "/api/upload/upl_1/complete":
s.completes++
return jsonResp(http.StatusOK, `{"ok":true,"file":{"id":"fil_1","name":"file.txt","size_bytes":10}}`), nil
case r.Method == "DELETE" && path == "/api/upload/upl_1":
s.aborts++
return jsonResp(http.StatusOK, `{"ok":true}`), nil
}
s.t.Fatalf("unexpected request %s %s", r.Method, path)
return nil, nil
}
func TestUploadSmallSendsWholeBody(t *testing.T) {
srv := newFakeUploadServer(t, "")
f := newFakeFs(rtFunc(srv.roundTrip))
content := strings.Repeat("a", 100)
resp, err := f.uploadFile(context.Background(), strings.NewReader(content), "file.txt", 100, time.Time{}, "text/plain; charset=utf-8", "", nil)
require.NoError(t, err)
assert.Equal(t, "fil_1", resp.File.ID)
require.Len(t, srv.puts, 1)
assert.Equal(t, content, string(srv.puts[0]))
}
func TestUploadSmallRejectsShortSource(t *testing.T) {
// The source declares 100 bytes but only supplies 60. A server which
// accepts that as a complete upload must not turn into a success.
srv := newFakeUploadServer(t, "")
f := newFakeFs(rtFunc(srv.roundTrip))
_, err := f.uploadFile(context.Background(), strings.NewReader(strings.Repeat("a", 60)), "file.txt", 100, time.Time{}, "text/plain; charset=utf-8", "", nil)
require.Error(t, err)
assert.ErrorIs(t, err, io.ErrUnexpectedEOF)
assert.Contains(t, err.Error(), "expected 100 bytes in input, but got 60")
}
func TestUploadSmallDoesNotResendDrainedBody(t *testing.T) {
// A retried PUT would resend an already-consumed reader, i.e. an
// empty body, which the server would store as an empty file. The
// single PUT therefore must not be retried at the low level.
srv := newFakeUploadServer(t, "")
srv.failFirst["/api/upload/upl_1"] = true
f := newFakeFs(rtFunc(srv.roundTrip))
content := strings.Repeat("a", 100)
_, err := f.uploadFile(context.Background(), strings.NewReader(content), "file.txt", 100, time.Time{}, "text/plain; charset=utf-8", "", nil)
require.Error(t, err)
require.Len(t, srv.puts, 1, "the PUT must not be retried with a drained body")
assert.Equal(t, content, string(srv.puts[0]))
}
const threeParts = `{"part_size":4,"total_parts":3}`
func TestUploadMultipartAssemblesAllBytes(t *testing.T) {
srv := newFakeUploadServer(t, threeParts)
f := newFakeFs(rtFunc(srv.roundTrip))
content := "0123456789"
resp, err := f.uploadFile(context.Background(), strings.NewReader(content), "file.txt", 10, time.Time{}, "text/plain; charset=utf-8", "", nil)
require.NoError(t, err)
assert.Equal(t, "fil_1", resp.File.ID)
assert.Equal(t, 1, srv.completes)
require.Len(t, srv.parts, 3)
assert.Equal(t, "0123", string(srv.parts[1][0]))
assert.Equal(t, "4567", string(srv.parts[2][0]))
assert.Equal(t, "89", string(srv.parts[3][0]))
}
func TestUploadMultipartRejectsShortSource(t *testing.T) {
// 10 bytes declared, 8 supplied: the last part must never be sent
// and the upload must not be completed.
srv := newFakeUploadServer(t, threeParts)
f := newFakeFs(rtFunc(srv.roundTrip))
_, err := f.uploadFile(context.Background(), strings.NewReader("01234567"), "file.txt", 10, time.Time{}, "text/plain; charset=utf-8", "", nil)
require.Error(t, err)
assert.ErrorIs(t, err, io.ErrUnexpectedEOF)
assert.Contains(t, err.Error(), "expected 10 bytes in input, but got 8")
assert.Empty(t, srv.parts[3], "short final part must not be uploaded")
assert.Equal(t, 0, srv.completes, "upload must not be completed")
}
func TestUploadMultipartRetriesPartWithFullBody(t *testing.T) {
// A part which fails transiently must be resent with all its bytes,
// not with whatever is left of a consumed reader.
srv := newFakeUploadServer(t, threeParts)
srv.failFirst["/api/upload/upl_1/part/2"] = true
f := newFakeFs(rtFunc(srv.roundTrip))
_, err := f.uploadFile(context.Background(), strings.NewReader("0123456789"), "file.txt", 10, time.Time{}, "text/plain; charset=utf-8", "", nil)
require.NoError(t, err)
require.Len(t, srv.parts[2], 2, "part 2 should have been sent twice")
assert.Equal(t, "4567", string(srv.parts[2][0]))
assert.Equal(t, "4567", string(srv.parts[2][1]))
assert.Equal(t, "89", string(srv.parts[3][0]))
assert.Equal(t, 1, srv.completes)
}
// An upload that fails after init has to hand its session back: the session
// holds one of the workspace's upload slots for ten minutes otherwise, and
// rclone's high-level retry opens a new one rather than resuming it.
func TestUploadAbortsTheSessionOnFailure(t *testing.T) {
for _, tc := range []struct {
name string
resumable string
content string
size int64
}{
{"small file short source", "", "0123", 10},
{"multipart short source", `{"part_size":5,"total_parts":4}`, "aabbccddeeffggh", 20},
} {
t.Run(tc.name, func(t *testing.T) {
srv := newFakeUploadServer(t, tc.resumable)
f := newFakeFs(rtFunc(srv.roundTrip))
_, err := f.uploadFile(context.Background(), strings.NewReader(tc.content),
"file.txt", tc.size, time.Time{}, "text/plain; charset=utf-8", "", nil)
require.Error(t, err)
assert.Equal(t, 1, srv.aborts)
})
}
}
// A successful upload must not abort anything
func TestUploadDoesNotAbortOnSuccess(t *testing.T) {
srv := newFakeUploadServer(t, "")
f := newFakeFs(rtFunc(srv.roundTrip))
_, err := f.uploadFile(context.Background(), strings.NewReader(strings.Repeat("a", 10)),
"file.txt", 10, time.Time{}, "text/plain; charset=utf-8", "", nil)
require.NoError(t, err)
assert.Equal(t, 0, srv.aborts)
}
@@ -0,0 +1,73 @@
package dosya
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// Every by-id call names the workspace, so the API can find an id it minted
// moments ago before its file_home index row lands.
func TestByIDCallsSendWorkspaceHint(t *testing.T) {
seen := map[string]string{}
f := newFakeFs(rtFunc(func(r *http.Request) (*http.Response, error) {
seen[r.Method+" "+r.URL.Path] = r.Header.Get("X-Dosya-Workspace")
switch r.URL.Path {
case "/api/folders/fld_1":
return jsonResp(http.StatusOK, `{"ok":true,"permanent":true,"complete":true}`), nil
case "/api/files/fil_1":
return jsonResp(http.StatusOK, `{"ok":true,"permanent":true}`), nil
case "/api/folders/fld_1/move":
return jsonResp(http.StatusOK, `{"ok":true,"name":"n"}`), nil
default:
return jsonResp(http.StatusOK, `{"ok":true}`), nil
}
}))
ctx := context.Background()
require.NoError(t, f.removeFolder(ctx, "fld_1", false))
require.NoError(t, f.renameFolder(ctx, "fld_1", "n"))
_, err := f.moveFolder(ctx, "fld_1", "fld_2", "n")
require.NoError(t, err)
require.NoError(t, f.deleteFile(ctx, "fil_1"))
require.NoError(t, f.renameFile(ctx, "fil_1", "n"))
require.NoError(t, f.moveFile(ctx, "fil_1", "fld_2", "n"))
_, err = f.copyFile(ctx, "fil_1", "fld_2", "n")
require.NoError(t, err)
_, err = f.createShareLink(ctx, "fil_1", 0)
require.NoError(t, err)
for call, header := range seen {
assert.Equal(t, "ws_test", header, call)
}
assert.Len(t, seen, 8)
}
// The download's API request carries the hint; the presigned R2 fetch must not.
func TestDownloadSendsWorkspaceHintOnlyToTheAPI(t *testing.T) {
var apiHint, blobHint string
var srv *httptest.Server
srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/api/files/fil_1/download" {
apiHint = r.Header.Get("X-Dosya-Workspace")
http.Redirect(w, r, srv.URL+"/blob", http.StatusFound)
return
}
blobHint = r.Header.Get("X-Dosya-Workspace")
_, _ = w.Write([]byte("bytes"))
}))
defer srv.Close()
f := newTestFs(srv)
f.opt.APIURL = srv.URL
rc, err := f.downloadFile(context.Background(), "fil_1", nil)
require.NoError(t, err)
_ = rc.Close()
assert.Equal(t, "ws_test", apiHint)
assert.Equal(t, "", blobHint)
}
+52
View File
@@ -0,0 +1,52 @@
package dosya
import (
"context"
"net/http"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// fakeWorkspaceServer answers GET /api/workspaces with the given body
func fakeWorkspaceServer(t *testing.T, body string, calls *int) rtFunc {
return func(r *http.Request) (*http.Response, error) {
require.Equal(t, "/api/workspaces", r.URL.Path)
*calls++
return jsonResp(http.StatusOK, body), nil
}
}
func TestFindWorkspaceAdoptsTheOnlyOne(t *testing.T) {
calls := 0
f := newFakeFs(fakeWorkspaceServer(t, `{"ok":true,"workspaces":[{"id":"ws_only","name":"Mine"}]}`, &calls))
f.opt.WorkspaceID = ""
require.NoError(t, f.findWorkspace(context.Background()))
assert.Equal(t, "ws_only", f.opt.WorkspaceID)
assert.Equal(t, 1, calls)
}
func TestFindWorkspaceListsTheChoices(t *testing.T) {
calls := 0
f := newFakeFs(fakeWorkspaceServer(t, `{"ok":true,"workspaces":[`+
`{"id":"ws_one","name":"One"},{"id":"ws_two","name":"Two"}]}`, &calls))
f.opt.WorkspaceID = ""
err := f.findWorkspace(context.Background())
require.Error(t, err)
// the ids have to be in the message: they are the only place the user
// can read them, which is the whole point of listing them
assert.Contains(t, err.Error(), "ws_one (One)")
assert.Contains(t, err.Error(), "ws_two (Two)")
assert.Empty(t, f.opt.WorkspaceID)
}
func TestFindWorkspaceWithNone(t *testing.T) {
calls := 0
f := newFakeFs(fakeWorkspaceServer(t, `{"ok":true,"workspaces":[]}`, &calls))
f.opt.WorkspaceID = ""
require.ErrorContains(t, f.findWorkspace(context.Background()), "no workspaces found")
}
+42 -18
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 {
@@ -692,13 +700,27 @@ func (f *Fs) Mkdir(ctx context.Context, dir string) error {
// deleteObject removes an object by ID
func (f *Fs) deleteObject(ctx context.Context, id string) error {
if f.opt.HardDelete {
// Deleting forever doesn't invalidate the server's cache of
// the parent folder listing, so the entry keeps being listed
// for about a second. Moving to the trash first does.
err := f.deleteEntry(ctx, id, false)
if err != nil {
return err
}
}
return f.deleteEntry(ctx, id, f.opt.HardDelete)
}
// deleteEntry moves the entry with ID to the trash or deletes it forever
func (f *Fs) deleteEntry(ctx context.Context, id string, deleteForever bool) error {
opts := rest.Opts{
Method: "POST",
Path: "/file-entries/delete",
}
request := api.DeleteRequest{
EntryIDs: []string{id},
DeleteForever: f.opt.HardDelete,
DeleteForever: deleteForever,
}
var result api.DeleteResponse
err := f.pacer.Call(func() (bool, error) {
@@ -1040,17 +1062,28 @@ func (f *Fs) copy(ctx context.Context, id, newDirID string) (item *api.Item, err
}
// copy and rename a file or folder to directoryID with leaf
func (f *Fs) copyTo(ctx context.Context, srcID, srcLeaf, dstLeaf, dstDirectoryID string) (info *api.Item, err error) {
// Can have duplicates so don't have to be careful here
//
// If existing is set it is removed once the copy has succeeded.
func (f *Fs) copyTo(ctx context.Context, srcID, dstLeaf, dstDirectoryID string, existing fs.Object) (info *api.Item, err error) {
// Copy to dstDirectoryID first
info, err = f.copy(ctx, srcID, dstDirectoryID)
if err != nil {
return nil, err
}
// The server names the copy "name (1)" if the destination
// directory already has an entry called "name" and refuses to
// rename it to "name" until that entry is removed.
if existing != nil {
fs.Debugf(existing, "Server side copy: removing existing object after successful copy")
err = existing.Remove(ctx)
if err != nil {
return nil, err
}
}
// Rename if required
if srcLeaf != dstLeaf {
if info.Name != f.opt.Enc.FromStandardName(dstLeaf) {
info, err = f.rename(ctx, info.ID.String(), dstLeaf)
if err != nil {
return nil, err
@@ -1074,8 +1107,6 @@ func (f *Fs) Copy(ctx context.Context, src fs.Object, remote string) (dst fs.Obj
fs.Debugf(src, "Can't copy - not same remote type")
return nil, fs.ErrorCantCopy
}
srcLeaf := path.Base(srcObj.remote)
srcPath := srcObj.fs.rootSlash() + srcObj.remote
dstPath := f.rootSlash() + remote
if srcPath == dstPath {
@@ -1084,15 +1115,8 @@ func (f *Fs) Copy(ctx context.Context, src fs.Object, remote string) (dst fs.Obj
// Find existing object
existingObj, err := f.NewObject(ctx, remote)
if err == nil {
defer func() {
// Don't remove existing object if returning an error
if err != nil {
return
}
fs.Debugf(existingObj, "Server side copy: removing existing object after successful copy")
err = existingObj.Remove(ctx)
}()
if err != nil {
existingObj = nil
}
// Create temporary object
@@ -1102,7 +1126,7 @@ func (f *Fs) Copy(ctx context.Context, src fs.Object, remote string) (dst fs.Obj
}
// Copy the object
info, err := f.copyTo(ctx, srcObj.id, srcLeaf, dstLeaf, dstDirectoryID)
info, err := f.copyTo(ctx, srcObj.id, dstLeaf, dstDirectoryID, existingObj)
if err != nil {
return nil, err
}
+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()
}
+5 -2
View File
@@ -16,13 +16,16 @@ import (
// multipartInit starts a new multipart upload and returns server details.
func (f *Fs) multipartInit(ctx context.Context, folderPath, filename string) (*api.MultipartInitResponse, error) {
encodedFolderPath := f.fromStandardPath(folderPath)
encodedFilename := f.fromStandardPath(filename)
opts := rest.Opts{
Method: "GET",
Path: "/multipart/init",
Parameters: url.Values{
"key": {f.opt.Key},
"filename": {filename},
"folder_path": {folderPath},
"filename": {encodedFilename},
"folder_path": {encodedFolderPath},
},
}
+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.
+16 -7
View File
@@ -642,7 +642,7 @@ func (f *Fs) updateFileChunked(ctx context.Context, path string, content io.Read
chunkReader *pool.RW
bytesRead int64
)
chunkReader, bytesRead, readErr = readerForChunk(content, int64(chunkSize))
chunkReader, bytesRead, readErr = readerForChunk(content, int64(chunkSize), true)
if bytesRead < int64(chunkSize) {
startMoreTransfers = false
}
@@ -869,14 +869,23 @@ func unwrapAccounting(reader io.Reader) (unwrapped io.Reader, acc *accounting.Ac
// along with the number of bytes that have been read from reader.
// Reaching the end of reader early is not an error.
//
// Any accounting on reader is applied when the buffer is read instead,
// so the upload is what gets accounted.
// If accountUpload is set, any accounting on reader is applied
// when the buffer is read instead, so the upload is what gets accounted.
//
// This must not be set for a buffer which creates or replaces a file
// in a single request. Accounting can fail part way through the upload,
// for example when --max-transfer is reached, and HiDrive keeps
// what it has received of an aborted request as the file.
//
// The caller must Close the buffer when done with it.
func readerForChunk(reader io.Reader, length int64) (chunkReader *pool.RW, bytesRead int64, err error) {
reader, acc := unwrapAccounting(reader)
func readerForChunk(reader io.Reader, length int64, accountUpload bool) (chunkReader *pool.RW, bytesRead int64, err error) {
chunkReader = multipart.NewRW()
if acc != nil {
chunkReader.SetAccounting(acc.AccountRead)
if accountUpload {
var acc *accounting.Account
reader, acc = unwrapAccounting(reader)
if acc != nil {
chunkReader.SetAccounting(acc.AccountRead)
}
}
bytesRead, err = io.CopyN(chunkReader, reader, length)
+2 -2
View File
@@ -522,7 +522,7 @@ func (f *Fs) PutUnchecked(ctx context.Context, in io.Reader, src fs.ObjectInfo,
if size := src.Size(); size >= 0 && size < prefixSize {
prefixSize = size
}
cutoffReader, bytesRead, err := readerForChunk(in, prefixSize)
cutoffReader, bytesRead, err := readerForChunk(in, prefixSize, false)
if err != nil {
return nil, err
}
@@ -980,7 +980,7 @@ func (o *Object) Update(ctx context.Context, in io.Reader, src fs.ObjectInfo, op
info, metaErr = o.fs.fetchMetadataForPath(ctx, resolvedPath, api.HiDriveObjectWithMetadataFields)
} else {
var content *pool.RW
content, _, err = readerForChunk(in, src.Size())
content, _, err = readerForChunk(in, src.Size(), false)
if err == nil {
info, err = o.fs.overwriteFile(ctx, resolvedPath, content, modTime)
_ = content.Close()
+53 -40
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
@@ -283,14 +310,15 @@ func (f *Fs) httpConnection(ctx context.Context, opt *Options) (isFile bool, err
if err != nil {
return false, err
}
u, err := rest.URLJoin(base, rest.URLPathEscape(f.root))
u, err := rest.URLJoinRoot(base, f.root)
if err != nil {
return false, 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
@@ -526,40 +556,20 @@ func addHeaders(req *http.Request, opt *Options) {
// rest.ErrHTTPSDowngrade and strips the configured headers when the
// redirect chain has left the originally requested host at any point.
func checkRedirect(opt *Options) func(req *http.Request, via []*http.Request) error {
headers := make([]string, 0, len(opt.Headers)/2)
for i := 0; i < len(opt.Headers); i += 2 {
headers = append(headers, opt.Headers[i])
}
stripHeaders := rest.StripHeadersOnCrossHostRedirectFn(headers...)
return func(req *http.Request, via []*http.Request) error {
if err := rest.RefuseHTTPSDowngradeRedirectFn(req, via); err != nil {
if errors.Is(err, rest.ErrHTTPSDowngrade) {
err = fmt.Errorf("%w (the configured headers would be sent to the plaintext target)", err)
}
return err
err := stripHeaders(req, via)
if errors.Is(err, rest.ErrHTTPSDowngrade) {
err = fmt.Errorf("%w (the configured headers would be sent to the plaintext target)", err)
}
if redirectLeavesHost(req, via) {
for i := 0; i < len(opt.Headers); i += 2 {
req.Header.Del(opt.Headers[i])
}
}
return nil
return err
}
}
// redirectLeavesHost reports whether any hop in the redirect chain
// via plus the pending request req is to a different host from the
// original request via[0].
//
// net/http copies the headers afresh from the original request for
// every hop, so once the chain has visited another host the headers
// must be stripped from every subsequent hop, even one back to the
// original host, as the other host chose the URL.
func redirectLeavesHost(req *http.Request, via []*http.Request) bool {
origin := via[0].URL
for _, hop := range via {
if !rest.SameHost(hop.URL, origin) {
return true
}
}
return !rest.SameHost(req.URL, origin)
}
// Adds the configured headers to the request if any
func (f *Fs) addHeaders(req *http.Request) {
addHeaders(req, &f.opt)
@@ -737,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
}
+96 -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)
@@ -760,3 +821,34 @@ func TestRedirectStripsHeadersOnHostChange(t *testing.T) {
assert.Empty(t, got.Values(headers[i]), "header %q leaked to redirect target", headers[i])
}
}
func TestRootCannotChangeHost(t *testing.T) {
// A root starting with "//" is a network-path reference under the
// URL standard. It must not be able to move the remote (and the
// configured headers) to a host other than the configured one.
var requests int
other := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests++
_, _ = w.Write([]byte("hello"))
}))
defer other.Close()
m := prepareServer(t)
otherHost := strings.TrimPrefix(other.URL, "http://")
for _, root := range []string{
"//" + otherHost + "/",
"//" + otherHost + "/one%.txt",
"//" + otherHost,
"//user:pass@" + otherHost + "/",
} {
f, err := NewFs(context.Background(), remoteName, root, m)
assert.ErrorContains(t, err, "must not change the scheme, host or user", root)
assert.Nil(t, f, root)
}
assert.Equal(t, 0, requests, "request sent to the other host")
// A same-origin absolute path must still work
f, err := NewFs(context.Background(), remoteName, "/", m)
require.NoError(t, err)
assert.Equal(t, m["url"]+"/", f.String())
}
+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
+2 -1
View File
@@ -92,7 +92,7 @@ func (f *Fs) findItem(ctx context.Context, dir string) (item *api.DriveItem, fou
item, resp, err = f.service.GetItemByPath(ctx, path.Join(f.root, dir))
return shouldRetry(ctx, resp, err)
}); err != nil {
if item == nil && resp.StatusCode == 404 {
if item == nil && resp != nil && resp.StatusCode == 404 {
return nil, false, nil
}
return nil, false, err
@@ -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",
+2 -2
View File
@@ -477,7 +477,7 @@ func (f *Fs) Rmdir(ctx context.Context, dir string) (err error) {
FolderPath: f.EncodePath(path.Join(f.root, dir)),
})
if res.StatusCode == http.StatusNotFound {
if res != nil && res.StatusCode == http.StatusNotFound {
return false, fs.ErrorDirNotFound
}
@@ -502,7 +502,7 @@ func (f *Fs) Purge(ctx context.Context, dir string) (err error) {
FolderPath: f.EncodePath(remote),
})
if res.StatusCode == http.StatusNotFound {
if res != nil && res.StatusCode == http.StatusNotFound {
return false, fs.ErrorDirNotFound
}
@@ -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{
+110 -5
View File
@@ -474,8 +474,10 @@ func (f *Fs) Rmdir(ctx context.Context, dir string) error {
if err != nil {
return err
}
if len(childFolders) > 0 {
return fs.ErrorDirectoryNotEmpty
for _, e := range childFolders {
if !recent.stale(e.UUID, id, e.PlainName) {
return fs.ErrorDirectoryNotEmpty
}
}
var childFiles []folders.File
@@ -487,8 +489,10 @@ func (f *Fs) Rmdir(ctx context.Context, dir string) error {
if err != nil {
return err
}
if len(childFiles) > 0 {
return fs.ErrorDirectoryNotEmpty
for _, e := range childFiles {
if !recent.stale(e.UUID, id, joinNameExt(e.PlainName, e.Type)) {
return fs.ErrorDirectoryNotEmpty
}
}
// Delete the directory
@@ -502,6 +506,7 @@ func (f *Fs) Rmdir(ctx context.Context, dir string) error {
if err != nil {
return err
}
recent.deleted(id)
f.dirCache.FlushDir(dir)
return nil
@@ -520,6 +525,9 @@ func (f *Fs) FindLeaf(ctx context.Context, pathID, leaf string) (string, bool, e
return "", false, err
}
for _, e := range entries {
if recent.stale(e.UUID, pathID, e.PlainName) {
continue
}
if f.opt.Encoding.ToStandardName(e.PlainName) == leaf {
return e.UUID, true, nil
}
@@ -592,6 +600,73 @@ func existenceCheck(name, ext string) files.FileExistenceCheck {
}
}
// staleWindow is how long the API may carry on returning an item from
// where it was before this process moved or deleted it.
const staleWindow = time.Minute
// location is where this process last put an item. An empty parentUUID
// means the item was deleted.
type location struct {
parentUUID string
name string // encoded name, including any extension
expires time.Time
}
// recentChanges holds the files and folders this process has moved or
// deleted recently, keyed by UUID.
//
// The API serves lookups and listings from read replicas which lag behind
// writes, so an item can be returned from its old location for a while after
// it was moved or deleted. Acting on such a stale entry can be destructive,
// eg overwriting it would delete the file from where it was moved to.
type recentChanges struct {
mu sync.Mutex
items map[string]location
lastPrune time.Time
}
// recent is shared by all the Fs in the process as UUIDs are global.
var recent = recentChanges{items: map[string]location{}}
// set records the new location of uuid, pruning expired entries.
func (r *recentChanges) set(uuid string, loc location) {
r.mu.Lock()
defer r.mu.Unlock()
now := time.Now()
if now.Sub(r.lastPrune) > staleWindow {
for k, v := range r.items {
if now.After(v.expires) {
delete(r.items, k)
}
}
r.lastPrune = now
}
loc.expires = now.Add(staleWindow)
r.items[uuid] = loc
}
// moved records that uuid was moved to name in parentUUID.
func (r *recentChanges) moved(uuid, parentUUID, name string) {
r.set(uuid, location{parentUUID: parentUUID, name: name})
}
// deleted records that uuid was deleted.
func (r *recentChanges) deleted(uuid string) {
r.set(uuid, location{})
}
// stale reports whether an API result saying uuid is called name in
// parentUUID is out of date because of a change made by this process.
func (r *recentChanges) stale(uuid, parentUUID, name string) bool {
r.mu.Lock()
defer r.mu.Unlock()
loc, ok := r.items[uuid]
if !ok || time.Now().After(loc.expires) {
return false
}
return loc.parentUUID != parentUUID || loc.name != name
}
// findFile looks up a single file by name within directoryID. Returns
// (nil, nil) when the file does not exist; surfaces transport/API errors.
func (f *Fs) findFile(ctx context.Context, leaf, directoryID string) (*folders.File, error) {
@@ -623,6 +698,10 @@ func (f *Fs) findFile(ctx context.Context, leaf, directoryID string) (*folders.F
if !result.FileExists() || result.UUID == "" {
continue
}
if recent.stale(result.UUID, directoryID, joinNameExt(result.PlainName, result.Type)) {
fs.Debugf(f, "Ignoring stale lookup of %q (UUID: %s) which was moved or deleted", leaf, result.UUID)
continue
}
if joinNameExt(result.PlainName, result.Type) == encodedName {
uuid = result.UUID
break
@@ -682,6 +761,9 @@ func (f *Fs) List(ctx context.Context, dir string) (fs.DirEntries, error) {
return nil, err
}
for _, e := range foldersList {
if recent.stale(e.UUID, dirID, e.PlainName) {
continue
}
remote := path.Join(dir, f.opt.Encoding.ToStandardName(e.PlainName))
out = append(out, fs.NewDir(remote, e.ModificationTime))
}
@@ -699,6 +781,9 @@ func (f *Fs) List(ctx context.Context, dir string) (fs.DirEntries, error) {
if len(e.Type) > 0 {
remote += "." + e.Type
}
if recent.stale(e.UUID, dirID, remote) {
continue
}
remote = path.Join(dir, f.opt.Encoding.ToStandardName(remote))
out = append(out, newObjectWithFile(f, remote, &e))
}
@@ -771,6 +856,7 @@ func (f *Fs) Remove(ctx context.Context, remote string) error {
if err != nil {
return err
}
recent.deleted(dirID)
f.dirCache.FlushDir(remote)
return nil
}
@@ -900,11 +986,18 @@ func (f *Fs) Move(ctx context.Context, src fs.Object, remote string) (fs.Object,
// Move the file server-side
err = f.pacer.Call(func() (bool, error) {
err := files.MoveFile(ctx, f.cfg, srcObj.uuid, directoryID, newName, newType)
// The move is checked against lagging read replicas so it can
// fail with 404 if the destination directory was just created
// or 409 if a file at the destination was just deleted.
if err != nil && isStaleReadError(err, true) {
return true, err
}
return f.shouldRetry(ctx, err)
})
if err != nil {
return nil, err
}
recent.moved(srcObj.uuid, directoryID, joinNameExt(newName, newType))
dstObj := &Object{
f: f,
@@ -940,11 +1033,17 @@ func (f *Fs) DirMove(ctx context.Context, src fs.Fs, srcRemote, dstRemote string
encodedLeaf := f.opt.Encoding.FromStandardName(dstLeaf)
err = f.pacer.Call(func() (bool, error) {
err := folders.MoveFolder(ctx, f.cfg, srcID, dstDirectoryID, encodedLeaf)
// Moving a large folder can outlast the gateway timeout (520/502)
// yet still complete, in which case the retry reports this.
if err != nil && isConflictError(err) && strings.Contains(err.Error(), "already moved to that location") {
return false, nil
}
return f.shouldRetry(ctx, err)
})
if err != nil {
return err
}
recent.moved(srcID, dstDirectoryID, encodedLeaf)
srcFs.dirCache.FlushDir(srcRemote)
return nil
@@ -1124,6 +1223,7 @@ func (o *Object) Update(ctx context.Context, in io.Reader, src fs.ObjectInfo, op
backupName, backupType, backupUUID, err)
// Don't fail the upload just because backup deletion failed
} else {
recent.deleted(backupUUID)
fs.Debugf(o.f, "Successfully deleted backup file")
}
}
@@ -1229,8 +1329,13 @@ func (o *Object) restoreBackupFile(ctx context.Context, backupUUID, origName, or
// Remove deletes a file
func (o *Object) Remove(ctx context.Context) error {
return o.f.pacer.Call(func() (bool, error) {
err := o.f.pacer.Call(func() (bool, error) {
err := files.DeleteFile(ctx, o.f.cfg, o.uuid)
return o.f.shouldRetry(ctx, err)
})
if err != nil {
return err
}
recent.deleted(o.uuid)
return nil
}
@@ -165,3 +165,27 @@ func TestFindFile(t *testing.T) {
})
}
}
func TestFindFileIgnoresStale(t *testing.T) {
ctx := context.Background()
server := existenceServer(t, []storedFile{{"uuid-moved", "foo", "txt"}, {"uuid-deleted", "bar", "txt"}, {"uuid-renamed", "baz", "txt"}})
defer server.Close()
f := testFs(t, server)
recent.moved("uuid-moved", "other-dir-uuid", "foo.txt.bak")
recent.deleted("uuid-deleted")
recent.moved("uuid-renamed", "dir-uuid", "baz.txt")
file, err := f.findFile(ctx, "foo.txt", "dir-uuid")
require.NoError(t, err)
assert.Nil(t, file, "moved away")
file, err = f.findFile(ctx, "bar.txt", "dir-uuid")
require.NoError(t, err)
assert.Nil(t, file, "deleted")
file, err = f.findFile(ctx, "baz.txt", "dir-uuid")
require.NoError(t, err)
require.NotNil(t, file, "moved here")
assert.Equal(t, "uuid-renamed", file.UUID)
}
+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
+164 -43
View File
@@ -12,6 +12,7 @@ import (
"path"
"path/filepath"
"runtime"
"slices"
"strings"
"sync"
"sync/atomic"
@@ -36,6 +37,10 @@ import (
const (
devUnset = 0xdeadbeefcafebabe // a device id meaning it is unset
useReadDir = (runtime.GOOS == "windows" || runtime.GOOS == "plan9") // these OSes read FileInfos directly
// maxLinkTargetSize is the largest symlink target accepted when
// translating a .rclonelink object, comfortably above any OS path
// limit (Windows allows 32767 UTF-16 units).
maxLinkTargetSize = 128 * 1024
)
// timeType allows the user to choose what exactly ModTime() returns
@@ -82,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.
`,
},
@@ -247,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,
@@ -438,6 +452,9 @@ var (
errLinksAndCopyLinks = errors.New("can't use -l/--links with -L/--copy-links")
errLinksNeedsSuffix = errors.New("need \"" + fs.LinkSuffix + "\" suffix to refer to symlink when using -l/--links")
errPathEscapes = errors.New("file name is not a path within the local root - check the encoding")
errSymlinkLoop = errors.New("loop detected: points to a parent directory")
errLinkTargetTooLong = errors.New("symlink target is too long to be a path")
errLinkNameInvalid = errors.New("translated link needs a file name before the \"" + fs.LinkSuffix + "\" suffix")
)
// NewFs constructs an Fs from the path
@@ -565,30 +582,26 @@ func (f *Fs) caseInsensitive() bool {
return runtime.GOOS == "windows" || runtime.GOOS == "darwin"
}
// translateLink checks whether the remote is a translated link
// and returns a new path, removing the suffix as needed,
// It also returns whether this is a translated link at all
//
// for regular files, localPath is returned unchanged
func translateLink(remote, localPath string) (newLocalPath string, isTranslatedLink bool) {
isTranslatedLink = strings.HasSuffix(remote, fs.LinkSuffix)
newLocalPath = strings.TrimSuffix(localPath, fs.LinkSuffix)
return newLocalPath, isTranslatedLink
}
// newObject makes a half completed Object
func (f *Fs) newObject(remote string) (*Object, error) {
translatedLink := false
localPath, err := f.localPath(remote)
name, translatedLink := remote, false
if f.opt.TranslateSymlinks {
name, translatedLink = strings.CutSuffix(remote, fs.LinkSuffix)
}
if translatedLink {
// Check this makes a valid native file name
native := f.nativePath(name)
switch native[strings.LastIndex(native, string(filepath.Separator))+1:] {
case "", ".", "..":
return nil, fserrors.NoRetryError(fmt.Errorf("%q: %w", remote, errLinkNameInvalid))
}
}
// Check the name after any suffix removal
localPath, err := f.localPath(name)
if err != nil {
return nil, err
}
if f.opt.TranslateSymlinks {
// Possibly receive a new name for localPath
localPath, translatedLink = translateLink(remote, localPath)
}
return &Object{
fs: f,
remote: remote,
@@ -637,15 +650,23 @@ func (f *Fs) NewObject(ctx context.Context, remote string) (fs.Object, error) {
}
// Create new directory object from the info passed in
//
// Directories are never translated links, so the name is used as is
// whatever suffix it has.
func (f *Fs) newDirectory(dir string, fi os.FileInfo) (*Directory, error) {
o, err := f.newObject(dir)
localPath, err := f.localPath(dir)
if err != nil {
return nil, err
}
o.setMetadata(fi)
return &Directory{
Object: *o,
}, nil
d := &Directory{
Object: Object{
fs: f,
remote: dir,
path: localPath,
},
}
d.setMetadata(fi)
return d, nil
}
// List the objects and directories in dir into entries. The
@@ -668,6 +689,7 @@ func (f *Fs) List(ctx context.Context, dir string) (entries fs.DirEntries, err e
if err != nil {
return nil, fs.ErrorDirNotFound
}
var parents []os.FileInfo // fsDirPath and its parents, read on first use
fd, err := os.Open(fsDirPath)
if err != nil {
@@ -748,11 +770,28 @@ func (f *Fs) List(ctx context.Context, dir string) (entries fs.DirEntries, err e
if f.opt.FollowSymlinks && (mode&symlinkFlag) != 0 {
localPath := filepath.Join(fsDirPath, name)
fi, err = os.Stat(localPath)
// A directory symlink pointing to a parent would be followed
// until the OS returns ELOOP, which can take a very long time
if err == nil && fi.IsDir() {
if parents == nil {
parents = statParents(fsDirPath)
}
if slices.ContainsFunc(parents, func(parent os.FileInfo) bool { return os.SameFile(fi, parent) }) {
// Quietly skip loops the directory filters exclude as
// the layer above wouldn't recurse into them
if useFilter {
if include, dirErr := filter.IncludeDirectory(ctx, f)(newRemote); dirErr == nil && !include {
continue
}
}
err = errSymlinkLoop
}
}
// Quietly skip errors on excluded files and directories
if err != nil && useFilter && !filter.IncludeRemote(newRemote) {
continue
}
if os.IsNotExist(err) || isCircularSymlinkError(err) {
if os.IsNotExist(err) || isCircularSymlinkError(err) || errors.Is(err, errSymlinkLoop) {
// Skip bad symlinks and circular symlinks
err = fserrors.NoRetryError(fmt.Errorf("symlink: %w", err))
fs.Errorf(newRemote, "Listing error: %v", err)
@@ -785,6 +824,12 @@ func (f *Fs) List(ctx context.Context, dir string) (entries fs.DirEntries, err e
continue
}
fso, err := f.newObjectWithInfo(newRemote, fi)
if errors.Is(err, errLinkNameInvalid) {
// Skip a badly named ".rclonelink" file
fs.Errorf(newRemote, "Listing error: %v", err)
_ = accounting.Stats(ctx).Error(err)
continue
}
if err != nil {
return nil, err
}
@@ -797,6 +842,21 @@ func (f *Fs) List(ctx context.Context, dir string) (entries fs.DirEntries, err e
return entries, nil
}
// statParents returns the info for dirPath and each of its parents up
// to the root of the file system, skipping any which can't be read.
func statParents(dirPath string) (parents []os.FileInfo) {
for {
if fi, err := os.Stat(dirPath); err == nil {
parents = append(parents, fi)
}
parent := filepath.Dir(dirPath)
if parent == dirPath {
return parents
}
dirPath = parent
}
}
func (f *Fs) cleanRemote(dir, filename string) (remote string) {
if f.opt.UTFNorm {
filename = norm.NFC.String(filename)
@@ -814,14 +874,19 @@ func (f *Fs) cleanRemote(dir, filename string) (remote string) {
return
}
// nativePath returns name in the OS encoding with OS path separators,
// relative to f.root, without cleaning it.
func (f *Fs) nativePath(name string) string {
return filepath.FromSlash(f.opt.Enc.FromStandardPath(name))
}
// localPath returns the OS path for the object called name, which is
// always underneath f.root.
//
// It returns errPathEscapes if name resolves outside f.root which can
// happen depending on the encoding.
func (f *Fs) localPath(name string) (string, error) {
native := filepath.FromSlash(f.opt.Enc.FromStandardPath(name))
localPath := filepath.Join(f.root, native)
localPath := filepath.Join(f.root, f.nativePath(name))
rel, err := filepath.Rel(f.root, localPath)
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
return "", fserrors.NoRetryError(fmt.Errorf("%q: %w", name, errPathEscapes))
@@ -895,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)
}
@@ -933,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
@@ -1123,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
}
@@ -1542,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)
@@ -1614,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))
@@ -1701,6 +1816,9 @@ func (o *Object) Update(ctx context.Context, in io.Reader, src fs.ObjectInfo, op
}
out = f
} else {
// The target is buffered in memory, so stop reading just past
// the longest acceptable one rather than trust the source
in = io.LimitReader(in, maxLinkTargetSize+1)
out = nopWriterCloser{&symlinkData}
}
@@ -1717,6 +1835,9 @@ func (o *Object) Update(ctx context.Context, in io.Reader, src fs.ObjectInfo, op
}
if o.translatedLink {
if err == nil && symlinkData.Len() > maxLinkTargetSize {
err = fserrors.NoRetryError(errLinkTargetTooLong)
}
if err == nil {
// Use the contents of the copied object to create a symlink,
// without following or creating it through a planted symlink
@@ -1732,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
@@ -1880,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
+425
View File
@@ -3,6 +3,7 @@ package local
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"math"
@@ -23,6 +24,7 @@ import (
"github.com/rclone/rclone/fs/hash"
"github.com/rclone/rclone/fs/object"
"github.com/rclone/rclone/fs/operations"
"github.com/rclone/rclone/fs/walk"
"github.com/rclone/rclone/fstest"
"github.com/rclone/rclone/lib/encoder"
"github.com/rclone/rclone/lib/file"
@@ -97,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
@@ -270,6 +297,29 @@ func linksMode(f *Fs) {
f.lstat = os.Lstat
}
// TestSymlinkTargetTooLong checks that a .rclonelink object whose body is
// far bigger than any path is refused without being buffered in memory,
// and that nothing is created at the destination.
func TestSymlinkTargetTooLong(t *testing.T) {
skipIfNoSymlinks(t)
ctx := context.Background()
r := fstest.NewRun(t)
f := r.Flocal.(*Fs)
linksMode(f)
// A source which never ends, so the read must be bounded
src := object.NewStaticObjectInfo("big"+fs.LinkSuffix, fstest.Time("2001-02-03T04:05:10Z"), -1, true, nil, nil)
in := readers.NewCountingReader(readers.NewPatternReader(1 << 40))
_, err := f.Put(ctx, in, src)
require.ErrorIs(t, err, errLinkTargetTooLong)
assert.True(t, fserrors.IsNoRetryError(err))
assert.LessOrEqual(t, in.BytesRead(), uint64(maxLinkTargetSize+1), "read more of the body than needed")
_, err = os.Lstat(filepath.Join(f.root, "big"))
assert.True(t, os.IsNotExist(err), "nothing should be created for a refused symlink")
}
// TestSymlinkEscapeWriteThroughBlocked mirrors the GHSA-cf44-9pgv-m4xc PoC: a
// malicious --links source serves "pwn.rclonelink" whose body is a path outside
// the destination, plus a sibling "pwn/authkeys" that sorts after it and would
@@ -510,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) {
@@ -621,6 +780,154 @@ func TestLocalPath(t *testing.T) {
}
}
// TestLinkSuffixEscapeBlocked checks that in --links mode a name which
// passes the root check with its ".rclonelink" suffix can't escape the
// destination once the suffix is removed: "...rclonelink" must not
// become "..", the parent of the root.
func TestLinkSuffixEscapeBlocked(t *testing.T) {
skipIfNoSymlinks(t)
ctx := context.Background()
outer := t.TempDir()
newLinksFs := func(dir, encoding string) *Fs {
fRaw, err := NewFs(ctx, "local", filepath.Join(outer, dir), configmap.Simple{"encoding": encoding})
require.NoError(t, err)
f := fRaw.(*Fs)
linksMode(f)
require.NoError(t, f.Mkdir(ctx, ""))
return f
}
f := newLinksFs("dst", encoder.OS.String())
s := newLinksFs("slash", "Slash")
// Stamp the parent of the destinations so a write to it shows up.
before := fstest.Time("2011-12-25T12:59:59Z")
require.NoError(t, os.Chtimes(outer, before, before))
parentUnchanged := func() {
t.Helper()
fi, err := os.Lstat(outer)
require.NoError(t, err)
assert.True(t, fi.ModTime().Equal(before), "the parent of the destination was modified")
}
// A directory called "...rclonelink" is created under its own name
// and its metadata goes there, not to the parent of the root which
// trimming the suffix off its path would address.
remote := ".." + fs.LinkSuffix
metadata := fs.Metadata{"mtime": "2001-02-03T04:05:06Z"}
_, err := f.MkdirMetadata(ctx, remote, metadata)
require.NoError(t, err)
parentUnchanged()
// A file called "...rclonelink" is a link called "..", which the
// default encoding stores as fullwidth dots like any other name.
require.NoError(t, putFile(ctx, f, remote, "target"))
fi, err := os.Lstat(filepath.Join(f.root, ".."))
require.NoError(t, err)
assert.NotZero(t, fi.Mode()&os.ModeSymlink)
parentUnchanged()
// Ordinary translated links still resolve to the file without the suffix.
o, err := f.newObject("sub/link" + fs.LinkSuffix)
require.NoError(t, err)
assert.True(t, o.translatedLink)
assert.Equal(t, filepath.Join(f.root, "sub", "link"), o.path)
// A name which merely contains the suffix is a regular file.
o, err = f.newObject("sub/link" + fs.LinkSuffix + ".txt")
require.NoError(t, err)
assert.False(t, o.translatedLink)
assert.Equal(t, filepath.Join(f.root, "sub", "link"+fs.LinkSuffix+".txt"), o.path)
// An encoding without Dot leaves ".." as a real ".." once the suffix
// is gone, so the name is refused instead.
require.ErrorIs(t, putFile(ctx, s, remote, "PWNED"), errLinkNameInvalid)
_, err = s.MkdirMetadata(ctx, remote, metadata)
require.NoError(t, err)
parentUnchanged()
// As is any other name which doesn't leave a file name once the
// suffix is removed and the encoding applied.
for _, name := range []string{
remote,
"sub/.." + fs.LinkSuffix,
"sub/." + fs.LinkSuffix,
"sub/.." + fs.LinkSuffix,
"sub/sub2/.." + fs.LinkSuffix,
"sub/." + fs.LinkSuffix,
"sub/" + fs.LinkSuffix,
fs.LinkSuffix,
} {
_, err = s.newObject(name)
assert.ErrorIs(t, err, errLinkNameInvalid, name)
assert.True(t, fserrors.IsNoRetryError(err), name)
}
// The root check still applies to the name of the link.
_, err = s.newObject("../link" + fs.LinkSuffix)
require.ErrorIs(t, err, errPathEscapes)
if runtime.GOOS == "windows" {
// A backslash is a path separator on Windows, so an encoding
// without BackSlash can hide ".." behind one.
w := newLinksFs("win", "Slash,Dot")
_, err = w.newObject(`sub\..` + fs.LinkSuffix)
assert.ErrorIs(t, err, errLinkNameInvalid)
}
}
// TestLinkSuffixListSkipsInvalidName checks that with --links a regular
// file whose name is just the suffix is reported as an error and skipped
// rather than failing the listing of its whole directory.
func TestLinkSuffixListSkipsInvalidName(t *testing.T) {
ctx := context.Background()
r := fstest.NewRun(t)
f := r.Flocal.(*Fs)
linksMode(f)
when := fstest.Time("2001-02-03T04:05:06Z")
r.WriteFile("sub/"+fs.LinkSuffix, "not a link", when)
r.WriteFile("sub/file.txt", "hello", when)
accounting.Stats(ctx).ResetErrors()
entries, err := f.List(ctx, "sub")
require.NoError(t, err)
require.Len(t, entries, 1)
assert.Equal(t, "sub/file.txt", entries[0].Remote())
assert.Equal(t, int64(1), accounting.Stats(ctx).GetErrors())
accounting.Stats(ctx).ResetErrors()
}
// TestLinkSuffixDirectory checks that in --links mode a directory whose
// name ends in ".rclonelink" is an ordinary directory. Only files are
// translated into links, so the suffix must stay in its path rather
// than making it address a sibling.
func TestLinkSuffixDirectory(t *testing.T) {
ctx := context.Background()
r := fstest.NewRun(t)
f := r.Flocal.(*Fs)
linksMode(f)
dir := "sub" + fs.LinkSuffix
want := filepath.Join(f.root, dir)
mtime := fstest.Time("2001-02-03T04:05:06Z")
d, err := f.MkdirMetadata(ctx, dir, fs.Metadata{"mtime": mtime.Format(time.RFC3339Nano)})
require.NoError(t, err)
assert.Equal(t, want, d.(*Directory).path)
fi, err := os.Lstat(want)
require.NoError(t, err)
assert.True(t, fi.IsDir())
assert.True(t, fi.ModTime().Equal(mtime), "metadata was not written to the directory")
entries, err := f.List(ctx, "")
require.NoError(t, err)
require.Len(t, entries, 1)
got, ok := entries[0].(*Directory)
require.True(t, ok)
assert.Equal(t, dir, got.Remote())
assert.Equal(t, want, got.path)
assert.False(t, got.translatedLink)
}
func TestHashWithTypeNone(t *testing.T) {
ctx := context.Background()
r := fstest.NewRun(t)
@@ -1176,3 +1483,121 @@ func TestCopySymlink(t *testing.T) {
want = fstest.NewItem("dst2/file.txt", "hello world", when)
fstest.CompareItems(t, []fs.DirEntry{dst}, []fstest.Item{want}, nil, f.precision, "")
}
// TestCopyLinksLoop checks that -L/--copy-links follows a symlink to a
// sibling directory but skips symlinks which point to the directory
// being listed or one of its parents, as these would loop forever.
func TestCopyLinksLoop(t *testing.T) {
skipIfNoSymlinks(t)
dir := t.TempDir()
// folder-A/link-to-B and folder-B/link-to-A point at each other
// and folder-A/link-to-parent points at dir
require.NoError(t, os.Mkdir(filepath.Join(dir, "folder-A"), 0700))
require.NoError(t, os.Mkdir(filepath.Join(dir, "folder-B"), 0700))
require.NoError(t, os.WriteFile(filepath.Join(dir, "folder-A", "file-A"), []byte("A"), 0600))
require.NoError(t, os.WriteFile(filepath.Join(dir, "folder-B", "file-B"), []byte("B"), 0600))
require.NoError(t, os.Symlink(filepath.Join("..", "folder-B"), filepath.Join(dir, "folder-A", "link-to-B")))
require.NoError(t, os.Symlink(filepath.Join("..", "folder-A"), filepath.Join(dir, "folder-B", "link-to-A")))
require.NoError(t, os.Symlink("..", filepath.Join(dir, "folder-A", "link-to-parent")))
wantRoot := []string{
"folder-A",
"folder-A/file-A",
"folder-A/link-to-B",
"folder-A/link-to-B/file-B",
"folder-B",
"folder-B/file-B",
"folder-B/link-to-A",
"folder-B/link-to-A/file-A",
}
for _, test := range []struct {
name string
root string
exclude string
want []string
wantErrors int64
}{{
name: "Root",
root: dir,
want: wantRoot,
// folder-A/link-to-parent, folder-A/link-to-B/link-to-A,
// folder-B/link-to-A/link-to-B, folder-B/link-to-A/link-to-parent
wantErrors: 4,
}, {
// link-to-parent points above the root of the Fs
name: "SubDir",
root: filepath.Join(dir, "folder-A"),
want: []string{
"file-A",
"link-to-B",
"link-to-B/file-B",
},
// link-to-parent, link-to-B/link-to-A
wantErrors: 2,
}, {
// Excluded loops aren't reported
name: "Excluded",
root: dir,
exclude: "link-to-parent/**",
want: wantRoot,
// folder-A/link-to-B/link-to-A, folder-B/link-to-A/link-to-B
wantErrors: 2,
}} {
t.Run(test.name, func(t *testing.T) {
ctx := context.Background()
if test.exclude != "" {
var fi *filter.Filter
ctx, fi = filter.AddConfig(ctx)
require.NoError(t, fi.AddRule("- "+test.exclude))
}
f, err := NewFs(ctx, "local", test.root, configmap.Simple{
"copy_links": "true",
})
require.NoError(t, err)
accounting.Stats(ctx).ResetErrors()
defer accounting.Stats(ctx).ResetErrors()
var got []string
err = walk.ListR(ctx, f, "", false, -1, walk.ListAll, func(entries fs.DirEntries) error {
for _, entry := range entries {
got = append(got, entry.Remote())
}
// Stop a runaway listing rather than waiting for ELOOP
if len(got) > 100 {
return errors.New("too many entries - symlink loop not detected")
}
return nil
})
require.NoError(t, err)
sort.Strings(got)
assert.Equal(t, test.want, got)
assert.Equal(t, test.wantErrors, accounting.Stats(ctx).GetErrors(), "global errors found")
})
}
// A single List reports and skips the link to a parent, but still
// returns the link to a sibling
t.Run("List", func(t *testing.T) {
ctx := context.Background()
f, err := NewFs(ctx, "local", dir, configmap.Simple{
"copy_links": "true",
})
require.NoError(t, err)
accounting.Stats(ctx).ResetErrors()
defer accounting.Stats(ctx).ResetErrors()
entries, err := f.List(ctx, "folder-A")
require.NoError(t, err)
var got []string
for _, entry := range entries {
got = append(got, entry.Remote())
}
sort.Strings(got)
assert.Equal(t, []string{"folder-A/file-A", "folder-A/link-to-B"}, got)
assert.Equal(t, int64(1), accounting.Stats(ctx).GetErrors(), "global errors found")
})
}
+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))
}
+26 -6
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.
//
@@ -236,11 +254,11 @@ func NewFs(ctx context.Context, name, root string, m configmap.Mapper) (fs.Fs, e
if err != nil {
return nil, fmt.Errorf("couldn't parse URL %q: %w", opt.Endpoint, err)
}
u, err := rest.URLJoin(base, rest.URLPathEscape(root))
u, err := rest.URLJoinRoot(base, root)
if err != nil {
return nil, fmt.Errorf("couldn't join URL %q and %q: %w", base.String(), root, err)
return nil, err
}
client := fshttp.NewClient(ctx)
client := newClient(ctx)
f := &Fs{
name: name,
@@ -573,10 +591,12 @@ func (f *Fs) ListR(ctx context.Context, dir string, callback fs.ListRCallback) (
fs.Errorf(nil, "Unable to parse URL %q: %v", f.endpointURL, err)
return fs.ErrorDirNotFound
}
resumeURL, err := rest.URLJoin(u, rest.URLPathEscape(resumeStart))
// A bad resume start must fail the listing rather than
// return fs.ErrorDirNotFound which sync treats as an empty
// directory
resumeURL, err := rest.URLJoinRoot(u, resumeStart)
if err != nil {
fs.Errorf(nil, "Unable to join URL %q for resumeStart %s: %v", f.endpointURL, resumeStart, err)
return fs.ErrorDirNotFound
return fmt.Errorf("unable to join URL %q for resumeStart %q: %w", f.endpointURL, resumeStart, err)
}
URL = resumeURL.String()
}
@@ -0,0 +1,205 @@
package netstorage
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"
)
func TestListResumeCannotChangeHost(t *testing.T) {
// The resume start returned by a list request comes from the
// server. It must not be able to move the signed continuation
// request to another host.
var requests int
other := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests++
http.Error(w, "not found", http.StatusNotFound)
}))
defer other.Close()
otherHost := strings.TrimPrefix(other.URL, "http://")
var listRequests int
configured := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
action := r.Header.Get("X-Akamai-ACS-Action")
switch {
case strings.Contains(action, "action=stat"):
_, _ = w.Write([]byte(`<stat directory="/123456/dir"><file type="dir" name="dir" mtime="0"/></stat>`))
case strings.Contains(action, "action=list"):
listRequests++
_, _ = w.Write([]byte(`<list><file type="file" name="123456/dir/a.txt" size="1" mtime="0"/><resume start="//` + otherHost + `/123456/dir/b.txt"/></list>`))
default:
http.Error(w, "unexpected action "+action, http.StatusBadRequest)
}
}))
defer configured.Close()
m := configmap.Simple{
"type": "netstorage",
"protocol": "http",
"host": strings.TrimPrefix(configured.URL, "http://") + "/123456/",
"account": "account",
"secret": obscure.MustObscure("secret"),
}
f, err := NewFs(context.Background(), "TestNetStorage", "dir", m)
require.NoError(t, err)
// The listing must fail loudly rather than with
// fs.ErrorDirNotFound which sync treats as an empty directory
err = f.(*Fs).ListR(context.Background(), "", func(entries fs.DirEntries) error { return nil })
assert.ErrorContains(t, err, "must not change the scheme, host or user")
assert.NotErrorIs(t, err, fs.ErrorDirNotFound)
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())
}
+5 -5
View File
@@ -20,7 +20,7 @@ import (
const (
dirMimeType = "inode/directory"
timeFormatIn = time.RFC3339
timeFormatOut = "2006-01-02T15:04:05.999Z" // mS for OneDrive Personal, otherwise only S
timeFormatOut = "2006-01-02T15:04:05.999Z" // keeps mS if present, though OneDrive only stores S
)
// system metadata keys which this backend owns
@@ -32,17 +32,17 @@ var systemMetadataInfo = map[string]fs.MetadataHelp{
ReadOnly: true,
},
"mtime": {
Help: "Time of last modification with S accuracy (mS for OneDrive Personal).",
Help: "Time of last modification with S accuracy.",
Type: "RFC 3339",
Example: "2006-01-02T15:04:05Z",
},
"btime": {
Help: "Time of file birth (creation) with S accuracy (mS for OneDrive Personal).",
Help: "Time of file birth (creation) with S accuracy.",
Type: "RFC 3339",
Example: "2006-01-02T15:04:05Z",
},
"utime": {
Help: "Time of upload with S accuracy (mS for OneDrive Personal).",
Help: "Time of upload with S accuracy.",
Type: "RFC 3339",
Example: "2006-01-02T15:04:05Z",
ReadOnly: true,
@@ -113,7 +113,7 @@ var systemMetadataInfo = map[string]fs.MetadataHelp{
ReadOnly: true,
},
"shared-time": {
Help: "Time when the item was shared, with S accuracy (mS for OneDrive Personal).",
Help: "Time when the item was shared, with S accuracy.",
Type: "RFC 3339",
Example: "2006-01-02T15:04:05Z",
ReadOnly: true,
+410 -115
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 (
@@ -56,6 +57,7 @@ const (
driveTypeSharepoint = "documentLibrary"
defaultChunkSize = 10 * fs.Mebi
chunkSizeMultiple = 320 * fs.Kibi
defaultTenantAPIVersion = "v2.0"
// maxSinglePartSize is the size at which Graph stops accepting an upload in a
// single request (PUT /items/{id}/content). Microsoft documents this as
// "250 MB", see
@@ -166,7 +168,7 @@ See: https://github.com/rclone/rclone/issues/1716
Name: "tenant_url",
Help: `The tenant URL for non-admin OneDrive access.
Set this to your SharePoint tenant URL to use the SharePoint v2.0 API
Set this to your SharePoint tenant URL to use the SharePoint API
endpoint instead of the standard Microsoft Graph API. This allows
accessing business OneDrive without admin consent.
@@ -177,6 +179,16 @@ for "driveAccessToken" in the network requests. Look for the
Example: https://your-tenant.sharepoint.com/_api`,
Default: "",
Advanced: true,
}, {
Name: "tenant_api_version",
Help: `The SharePoint API version to use with tenant_url.
Set this to the SharePoint API version matching the browser-extracted
access token. For example, use v2.1 with a driveAccessTokenV21 token.
This only applies when tenant_url is set.`,
Default: defaultTenantAPIVersion,
Advanced: true,
}, {
Name: "chunk_size",
Help: `Chunk size to upload files with - must be multiple of 320k (327,680 bytes).
@@ -283,16 +295,13 @@ cases, rclone will fall back to normal copy (which will be slightly slower).`,
Default: false,
Help: `Remove all versions on modifying operations.
Onedrive for business creates versions when rclone uploads new files
Onedrive creates versions when rclone uploads new files
overwriting an existing one and when it sets the modification time.
These versions take up space out of the quota.
This flag checks for versions after file upload and setting
modification time and removes all but the last version.
**NB** Onedrive personal can't currently delete versions so don't use
this flag there.
`,
Advanced: true,
}, {
@@ -331,14 +340,18 @@ This works with OneDrive for Business, SharePoint document libraries, and OneDri
Help: "Creates a read-write link to the item.",
}, {
Value: "embed",
Help: "Creates an embeddable link to the item.",
Help: "Creates an embeddable link to the item.\nOnly available in OneDrive personal.",
}},
}, {
Name: "link_password",
Default: "",
Help: `Set the password for links created by the link command.
At the time of writing this only works with OneDrive personal paid accounts.
At the time of writing this works with OneDrive for Business and
OneDrive personal paid accounts.
OneDrive personal free accounts can't set a password or an expiry time
(with --expire) on links.
`,
Advanced: true,
Sensitive: true,
@@ -426,21 +439,12 @@ Setting this flag speeds up these things greatly:
rclone size onedrive:
rclone rc vfs/refresh recursive=true
**However** the delta listing API **only** works at the root of the
drive. If you use it not at the root then it recurses from the root
and discards all the data that is not under the directory you asked
for. So it will be correct but may not be very efficient.
This is why this flag is not set as the default.
As a rule of thumb if nearly all of your data is under rclone's root
directory (the |root/directory| in |onedrive:root/directory|) then
using this flag will be a big performance win. If your data is
mostly not under the root then using this flag will be a big
performance loss.
It is recommended if you are mounting your onedrive at the root
(or near the root when using crypt) and using rclone |rc vfs/refresh|.
Rclone asks for the delta listing of the directory being listed. If
the drive only supports delta listings at the root of the drive (as
some older OneDrive for Business and SharePoint drives do) then
rclone lists from the root and discards all the data that is not
under the directory you asked for. So it will be correct but may not
be very efficient.
`, "|", "`"),
Advanced: true,
}, {
@@ -509,12 +513,20 @@ func getRegionURL(m configmap.Mapper) (region, graphURL string) {
// Check if tenant_url is provided for non-admin mode
tenantURL, _ := m.Get("tenant_url")
if tenantURL != "" {
graphURL = tenantURL + "/v2.0"
tenantAPIVersion, _ := m.Get("tenant_api_version")
graphURL = tenantAPIEndpoint(tenantURL, tenantAPIVersion)
}
return region, graphURL
}
func tenantAPIEndpoint(tenantURL, tenantAPIVersion string) string {
if tenantAPIVersion == "" {
tenantAPIVersion = defaultTenantAPIVersion
}
return strings.TrimRight(tenantURL, "/") + "/" + strings.TrimLeft(tenantAPIVersion, "/")
}
// Config for chooseDrive
type chooseDriveOpt struct {
opts rest.Opts
@@ -808,6 +820,7 @@ type Options struct {
UploadCutoff fs.SizeSuffix `config:"upload_cutoff"`
ChunkSize fs.SizeSuffix `config:"chunk_size"`
TenantURL string `config:"tenant_url"`
TenantAPIVersion string `config:"tenant_api_version"`
DriveID string `config:"drive_id"`
DriveType string `config:"drive_type"`
RootFolderID string `config:"root_folder_id"`
@@ -832,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
@@ -1064,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 {
@@ -1115,7 +1290,7 @@ func NewFs(ctx context.Context, name, root string, m configmap.Mapper) (fs.Fs, e
rootURL := graphAPIEndpoint[opt.Region] + "/v1.0" + "/drives/" + opt.DriveID
if opt.TenantURL != "" {
rootURL = opt.TenantURL + "/v2.0" + "/drives/" + opt.DriveID
rootURL = tenantAPIEndpoint(opt.TenantURL, opt.TenantAPIVersion) + "/drives/" + opt.DriveID
}
oauthConfig, err := makeOauthConfig(ctx, opt)
@@ -1132,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,
@@ -1402,6 +1579,8 @@ func (f *Fs) itemToDirEntry(ctx context.Context, dir string, info *api.Item) (en
id := info.GetID()
f.dirCache.Put(remote, id)
d := f.newDir(id, remote)
// OneDrive reports the total size of a folder's contents
d.size = info.GetSize()
d.items = folder.ChildCount
f.setSystemMetadata(info, d.meta, remote, dirMimeType)
entry = d
@@ -1490,10 +1669,6 @@ func (f *Fs) ListR(ctx context.Context, dir string, callback fs.ListRCallback) (
return err
}
// ListR only works at the root of a onedrive, not on a folder
// So we have to filter things outside of the root which is
// inefficient.
list := list.NewHelper(callback)
// list a folder conventionally - used for shared folders
@@ -1569,17 +1744,26 @@ func (f *Fs) ListR(ctx context.Context, dir string, callback fs.ListRCallback) (
return nil
}
opts := rest.Opts{
Method: "GET",
Path: "/root/delta",
Parameters: map[string][]string{
// "token": {token},
listDelta := func(opts rest.Opts) error {
opts.Parameters = url.Values{
"$top": {fmt.Sprintf("%d", f.opt.ListChunk)},
},
}
var result api.DeltaResponse
return f._listAll(ctx, "", false, false, fn, &opts, &result, &result.Value, &result.NextLink)
}
var result api.DeltaResponse
err = f._listAll(ctx, "", false, false, fn, &opts, &result, &result.Value, &result.NextLink)
err = listDelta(f.newOptsCall(directoryID, "GET", "/delta"))
// Some drives only support delta listings at the root of the
// drive, in which case list the whole drive and filter out the
// items outside dir.
var apiErr *api.Error
if err != nil && len(seen) == 0 && errors.As(err, &apiErr) {
fs.Debugf(f, "Delta listing of directory failed, listing from the root of the drive instead: %v", err)
err = listDelta(rest.Opts{
Method: "GET",
Path: "/root/delta",
})
}
if err != nil {
return err
}
@@ -1706,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
})
@@ -1723,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":
@@ -1822,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")
}
@@ -1833,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
}
@@ -2507,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.
@@ -2555,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)
}
@@ -2587,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.
@@ -2627,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)
@@ -2656,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{
@@ -2667,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)
@@ -2706,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
@@ -2717,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
@@ -2739,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() {
@@ -2918,7 +3176,7 @@ func (o *Object) ID() string {
func (f *Fs) parseNormalizedID(ID string) (string, string, string) {
var rootURL string
if f.opt.TenantURL != "" {
rootURL = f.opt.TenantURL + "/v2.0/drives"
rootURL = tenantAPIEndpoint(f.opt.TenantURL, f.opt.TenantAPIVersion) + "/drives"
} else {
rootURL = graphAPIEndpoint[f.opt.Region] + "/v1.0/drives"
}
@@ -3086,9 +3344,15 @@ func (f *Fs) ChangeNotify(ctx context.Context, notifyFunc func(string, fs.EntryT
}
case <-tickerC:
fs.Debugf(f, "Checking for changes on remote")
nextDeltaToken, err = f.changeNotifyRunner(ctx, notifyFunc, nextDeltaToken)
var newDeltaToken string
newDeltaToken, err = f.changeNotifyRunner(ctx, notifyFunc, nextDeltaToken)
if err != nil {
// Keep the token we already have: replacing it with the
// empty token from a failed poll would reset the delta
// listing to the start and lose every change in between.
fs.Infof(f, "Change notify listener failure: %s", err)
} else {
nextDeltaToken = newDeltaToken
}
}
}
@@ -3096,7 +3360,7 @@ func (f *Fs) ChangeNotify(ctx context.Context, notifyFunc func(string, fs.EntryT
}
func (f *Fs) changeNotifyStartPageToken(ctx context.Context) (nextDeltaToken string, err error) {
delta, err := f.changeNotifyNextChange(ctx, "latest")
delta, err := f.changeNotifyNextChange(ctx, "latest", "")
if err != nil {
return
}
@@ -3108,18 +3372,37 @@ func (f *Fs) changeNotifyStartPageToken(ctx context.Context) (nextDeltaToken str
return
}
func (f *Fs) changeNotifyNextChange(ctx context.Context, token string) (delta api.DeltaResponse, err error) {
opts := f.buildDriveDeltaOpts(token)
// changeNotifyNextChange fetches one page of the drive delta listing.
//
// Pass the delta token to resume from in token, or the @odata.nextLink URL
// returned by the previous page in nextLink. Exactly one of the two should
// be set.
func (f *Fs) changeNotifyNextChange(ctx context.Context, token, nextLink string) (delta api.DeltaResponse, err error) {
opts := f.buildDriveDeltaOpts(token, nextLink)
_, err = f.srv.CallJSON(ctx, &opts, nil, &delta)
err = f.pacer.Call(func() (bool, error) {
var resp *http.Response
resp, err = f.srv.CallJSON(ctx, &opts, nil, &delta)
return shouldRetry(ctx, resp, err)
})
return
}
func (f *Fs) buildDriveDeltaOpts(token string) rest.Opts {
// buildDriveDeltaOpts builds the request for one page of the drive delta
// listing. nextLink, when set, is the complete @odata.nextLink URL from the
// previous page and is used as-is, overriding the drive root.
func (f *Fs) buildDriveDeltaOpts(token, nextLink string) rest.Opts {
if nextLink != "" {
return rest.Opts{
Method: "GET",
RootURL: nextLink,
}
}
var rootURL string
if f.opt.TenantURL != "" {
rootURL = f.opt.TenantURL + "/v2.0/drives"
rootURL = tenantAPIEndpoint(f.opt.TenantURL, f.opt.TenantAPIVersion) + "/drives"
} else {
rootURL = graphAPIEndpoint[f.opt.Region] + "/v1.0/drives"
}
@@ -3132,46 +3415,58 @@ func (f *Fs) buildDriveDeltaOpts(token string) rest.Opts {
}
}
func (f *Fs) changeNotifyRunner(ctx context.Context, notifyFunc func(string, fs.EntryType), deltaToken string) (nextDeltaToken string, err error) {
delta, err := f.changeNotifyNextChange(ctx, deltaToken)
if err != nil {
return
}
parsedURL, err := url.Parse(delta.DeltaLink)
if err != nil {
return
}
nextDeltaToken = parsedURL.Query().Get("token")
for _, item := range delta.Value {
isDriveRootFolder := item.GetParentReference().ID == ""
if isDriveRootFolder {
continue
}
fullPath, err := getItemFullPath(&item)
// changeNotifyRunner polls for changes and calls notifyFunc for each one.
//
// It walks every page of the change set and returns the token to resume from
// next time. The returned token is only valid when err is nil: the caller
// must keep the token it passed in on error, otherwise a failed poll would
// reset the delta listing to the start and lose the changes in between.
func (f *Fs) changeNotifyRunner(ctx context.Context, notifyFunc func(string, fs.EntryType), deltaToken string) (string, error) {
token, nextLink := deltaToken, ""
for {
delta, err := f.changeNotifyNextChange(ctx, token, nextLink)
if err != nil {
fs.Errorf(f, "Could not get item full path: %s", err)
continue
return "", err
}
if fullPath == f.root {
continue
for _, item := range delta.Value {
isDriveRootFolder := item.GetParentReference().ID == ""
if isDriveRootFolder {
continue
}
fullPath, err := getItemFullPath(&item)
if err != nil {
fs.Errorf(f, "Could not get item full path: %s", err)
continue
}
if fullPath == f.root {
continue
}
relName, insideRoot := getRelativePathInsideBase(f.root, fullPath)
if !insideRoot {
continue
}
if item.GetFile() != nil {
notifyFunc(relName, fs.EntryObject)
} else if item.GetFolder() != nil {
notifyFunc(relName, fs.EntryDirectory)
}
}
relName, insideRoot := getRelativePathInsideBase(f.root, fullPath)
if !insideRoot {
continue
}
if item.GetFile() != nil {
notifyFunc(relName, fs.EntryObject)
} else if item.GetFolder() != nil {
notifyFunc(relName, fs.EntryDirectory)
if delta.NextLink == "" {
// end of the change set - the deltaLink carries the resume token
parsedURL, err := url.Parse(delta.DeltaLink)
if err != nil {
return "", err
}
return parsedURL.Query().Get("token"), nil
}
nextLink = delta.NextLink
}
return
}
func getItemFullPath(item *api.Item) (fullPath string, err error) {
+295
View File
@@ -3,19 +3,27 @@ package onedrive
import (
"context"
"encoding/json"
"flag"
"fmt"
"net/http"
"net/http/httptest"
"slices"
"strings"
"sync/atomic"
"testing"
"time"
_ "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"
"github.com/rclone/rclone/lib/dircache"
"github.com/rclone/rclone/lib/pacer"
"github.com/rclone/rclone/lib/random"
"github.com/rclone/rclone/lib/rest"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -37,11 +45,49 @@ 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)
}
func TestTenantAPIEndpoint(t *testing.T) {
for _, tc := range []struct {
name string
tenantURL string
tenantAPIVersion string
want string
}{
{
name: "default",
tenantURL: "https://example-my.sharepoint.com/_api",
want: "https://example-my.sharepoint.com/_api/v2.0",
}, {
name: "explicit v2.1",
tenantURL: "https://example-my.sharepoint.com/_api",
tenantAPIVersion: "v2.1",
want: "https://example-my.sharepoint.com/_api/v2.1",
}, {
name: "future version",
tenantURL: "https://example-my.sharepoint.com/_api",
tenantAPIVersion: "v2.2",
want: "https://example-my.sharepoint.com/_api/v2.2",
}, {
name: "trailing slash",
tenantURL: "https://example-my.sharepoint.com/_api/",
tenantAPIVersion: "/v2.1",
want: "https://example-my.sharepoint.com/_api/v2.1",
},
} {
t.Run(tc.name, func(t *testing.T) {
assert.Equal(t, tc.want, tenantAPIEndpoint(tc.tenantURL, tc.tenantAPIVersion))
})
}
}
// sharingRefused caches whether the remote refuses sharing invitations.
var sharingRefused *bool
@@ -574,3 +620,252 @@ func (f *Fs) InternalTest(t *testing.T) {
}
var _ fstests.InternalTester = (*Fs)(nil)
// newChangeNotifyTestFs returns an Fs whose delta listing is served by
// handler on a local test server, plus the server itself. The tenant URL
// is pointed at the server so no request leaves the test.
func newChangeNotifyTestFs(t *testing.T, handler http.Handler) (*Fs, *httptest.Server) {
t.Helper()
server := httptest.NewServer(handler)
t.Cleanup(server.Close)
ctx := context.Background()
f := &Fs{
ci: fs.GetConfig(ctx),
root: "",
driveID: "testdrive",
srv: rest.NewClient(server.Client()).SetRoot(server.URL),
pacer: fs.NewPacer(ctx, pacer.NewDefault(pacer.MinSleep(time.Millisecond), pacer.MaxSleep(10*time.Millisecond))),
}
f.opt.TenantURL = server.URL
f.opt.TenantAPIVersion = defaultTenantAPIVersion
return f, server
}
// deltaItem builds a delta entry for a file named name inside a folder.
func deltaItem(name, folder string) api.Item {
return api.Item{
ID: name,
Name: name,
File: &api.FileFacet{},
ParentReference: &api.ItemReference{
ID: "parentid",
Path: "driveid:/" + folder,
},
}
}
// TestChangeNotifyRetriesAndKeepsToken covers a transient failure of the
// delta poll: the request must go through the pacer so it is retried, and
// a poll which fails outright must not hand back an empty token, since the
// caller stores whatever it returns. An empty token would reset the delta
// listing to the start and silently lose every change in between.
func TestChangeNotifyRetriesAndKeepsToken(t *testing.T) {
ctx := context.Background()
var attempts atomic.Int32
var tokens []string
fail := true
f, _ := newChangeNotifyTestFs(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
attempts.Add(1)
tokens = append(tokens, r.URL.Query().Get("token"))
if fail {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusServiceUnavailable)
_ = json.NewEncoder(w).Encode(map[string]any{"error": map[string]any{
"code": "serviceNotAvailable",
"message": "Service unavailable",
}})
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(api.DeltaResponse{
Value: []api.Item{deltaItem("file1.txt", "folder")},
DeltaLink: "https://example.com/delta?token=newtoken",
})
}))
var notified []string
notifyFunc := func(path string, entryType fs.EntryType) {
notified = append(notified, path)
}
// The poll fails, but is retried by the pacer first
token, err := f.changeNotifyRunner(ctx, notifyFunc, "tok1")
require.Error(t, err)
assert.Empty(t, token, "a failed poll must not return a token")
assert.Equal(t, fs.GetConfig(ctx).LowLevelRetries, int(attempts.Load()), "the failed poll should have been retried by the pacer")
assert.Equal(t, slices.Repeat([]string{"tok1"}, int(attempts.Load())), tokens, "every retry should resume from the same token")
assert.Empty(t, notified)
// Keeping the previous token (as ChangeNotify does) resumes correctly
fail = false
attempts.Store(0)
tokens = nil
token, err = f.changeNotifyRunner(ctx, notifyFunc, "tok1")
require.NoError(t, err)
assert.Equal(t, "newtoken", token)
assert.Equal(t, []string{"tok1"}, tokens, "the second poll should resume from the retained token")
assert.Equal(t, []string{"folder/file1.txt"}, notified)
}
// TestChangeNotifyFollowsNextLink covers a change set which spans more than
// one page. Graph returns @odata.nextLink and no @odata.deltaLink until the
// last page, so a runner which stops after the first page both loses the
// remaining changes and ends up with an empty resume token.
func TestChangeNotifyFollowsNextLink(t *testing.T) {
ctx := context.Background()
var urls []string
var server *httptest.Server
f, server := newChangeNotifyTestFs(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
urls = append(urls, r.URL.RequestURI())
w.Header().Set("Content-Type", "application/json")
switch len(urls) {
case 1:
_ = json.NewEncoder(w).Encode(api.DeltaResponse{
Value: []api.Item{deltaItem("page1.txt", "folder")},
NextLink: server.URL + "/nextpage?$skiptoken=abc",
})
case 2:
_ = json.NewEncoder(w).Encode(api.DeltaResponse{
Value: []api.Item{deltaItem("page2.txt", "folder")},
DeltaLink: "https://example.com/delta?token=lasttoken",
})
default:
t.Errorf("unexpected extra request to %s", r.URL)
}
}))
var notified []string
token, err := f.changeNotifyRunner(ctx, func(path string, entryType fs.EntryType) {
notified = append(notified, path)
}, "tok1")
require.NoError(t, err)
assert.Equal(t, "lasttoken", token, "the resume token should come from the last page")
assert.Equal(t, []string{"folder/page1.txt", "folder/page2.txt"}, notified, "changes from every page should be notified")
require.Len(t, urls, 2, "the nextLink page should have been fetched")
assert.Contains(t, urls[0], "/testdrive/root/delta")
assert.Contains(t, urls[0], "token=tok1")
assert.Equal(t, "/nextpage?$skiptoken=abc", urls[1], "the nextLink should be used as-is, without the delta token")
}
// TestListDirectorySize checks that a listed directory carries the size
// OneDrive reports for it, the total size of its contents, instead of -1.
func TestListDirectorySize(t *testing.T) {
ctx := context.Background()
f, _ := newChangeNotifyTestFs(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/items/rootid/children", r.URL.Path)
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(api.ListChildrenResponse{
Value: []api.Item{{
ID: "dirid",
Name: "photos",
Size: 2646204760,
Folder: &api.FolderFacet{ChildCount: 3},
}},
})
}))
f.dirCache = dircache.New("", "rootid", f)
entries, err := f.List(ctx, "")
require.NoError(t, err)
require.Len(t, entries, 1)
dir, ok := entries[0].(fs.Directory)
require.True(t, ok, "entry should be a directory")
assert.Equal(t, "photos", dir.Remote())
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)
}
Loaded 100 of 733 files, more files were not shown because too many files have changed in this diff. Show more