Compare commits

...
Author SHA1 Message Date
Chris Lu e222158de9 s3api: carry presign params onto synthesized policy requests
objectKeyPolicyRequest rebuilt the query with only versionId and the STS
token, so a presigned request's s3:authType condition value silently
changed from REST-QUERY-STRING to unset during phase-2 rechecks (version
read grant, copy source, delete keys, post-policy writes). A policy
conditioned on s3:authType would then deny a request it already allowed.
Carry the SigV2/SigV4 presign keys through; none of them steer
ResolveS3Action.
2026-10-21 15:09:36 -07:00
DevinandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> bdd9b1a8a9 s3api: reject a named versionId on buckets without versioning
Tagging, ACL, and attributes handlers answered versionId requests on
never-versioned buckets as the current object while evaluating the
version-scoped action, so a grant limited to the version action could
authorize changes to the live entry. Reject the request up front through
the existing checkVersionIdConfigured gate.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-21 14:54:49 -07:00
DevinandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 8e92be099a s3api: check the version read grant on a synthesized versioned GET
The versioned attributes check passed the literal s3:GetObjectVersion
action, but both policy paths re-resolve the action from the request, so
the attributes query parameter mapped it back to
s3:GetObjectVersionAttributes and a lone version-read Deny never fired.
The literal action also reached the legacy CanDo path, where a plain
Read grant no longer matched. Evaluate the grant on a request shaped
like a plain versioned GET instead.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-21 14:51:55 -07:00
DevinandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> d7c608ec51 s3api: require the version read grant for versioned attributes
AWS asks for s3:GetObjectVersion alongside s3:GetObjectVersionAttributes
when a GetObjectAttributes request names a versionId; the resolved
version-scoped action on its own left a lone s3:GetObjectVersion Deny
unable to cover the attributes read. Check the version read grant too:
the identity side through VerifyActionPermission, the bucket-policy side
through the same recheck that evaluates the scoped action.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-21 14:29:00 -07:00
DevinandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> c0034c9848 s3api: resolve HEAD ?acl as the head-object action, not an ACL action
No HEAD acl route exists, so HEAD /bucket/key?acl lands on
HeadObjectHandler — which serves object headers, not the ACL. Resolving
it to an ACL action denied readers who hold only GetObject(Version) and
let ACL grants reach a head-object read. Dropping HEAD from the acl
branch lets the request fall through to GetObject/GetObjectVersion,
matching how the tagging branch already treats HEAD.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-21 14:11:54 -07:00
DevinandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 0a08101acb s3api: recheck the version action on versioned tagging requests
The same phase-2 gap exists in the tagging handlers left by the earlier
version-tagging resolution change: versioned GetObjectTagging /
PutObjectTagging / DeleteObjectTagging rechecks evaluated the
current-object tagging action, so a Deny on s3:*VersionTagging could not
fire.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-21 14:07:18 -07:00
DevinandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 080e094b7e s3api: recheck the version action on versioned ACL/attributes requests
The phase-2 policy recheck inside GetObjectAclHandler,
PutObjectAclHandler, and GetObjectAttributesHandler passed the
current-object action even when the request carried versionId and phase 1
had resolved the version action. A Deny scoped to s3:*VersionAcl or
s3:GetObjectVersionAttributes could never fire in the recheck, and an
Allow limited to the version action went unevaluated. Select the action
the same way the resolver does — versioned whenever versionId is present.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-21 14:07:18 -07:00
Chris Lu f71620925d s3: resolve versionId attributes requests to the version attributes action
GET /bucket/key?attributes&versionId=<id> fetched the historical version's attributes but authorized against s3:GetObjectAttributes, so a grant limited to the current-object attributes action could reach noncurrent versions. The request now resolves to s3:GetObjectVersionAttributes, and both attributes actions map onto the Read identity action so policies can name them (s3:GetObjectAttributes was previously rejected as an unmapped action).
2026-10-21 14:02:07 -07:00
Chris Lu 95c31f3840 s3: resolve versionId ACL requests to the version ACL actions
A request for an object's ACL carrying versionId acted on the historical version but authorized against the current-object action (s3:GetObjectAcl/s3:PutObjectAcl), because the acl block in resolveFromQueryParameters returned before versionId was considered - the same gap closed for tagging. An identity granted only current-object ACL access - or explicitly denied s3:*ObjectVersionAcl - could still read and modify ACLs on noncurrent versions.

?acl&versionId=<id> on an object now resolves to s3:GetObjectVersionAcl/s3:PutObjectVersionAcl; ?acl alone keeps the current-object actions and bucket ACL is unaffected. s3:PutObjectVersionAcl is mapped onto the WriteAcp identity action so policies can name it.
2026-10-21 14:01:28 -07:00
Chris LuGitHubDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com>Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
780760fccc s3: report the copied bytes' MD5 in UploadPartCopy ETags (#11884)
* s3api: regression test for UploadPartCopy ETag on ranged copies

Generated with [Devin](https://devin.ai)\n\nCo-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* s3api: hash the copied bytes for a partial-chunk UploadPartCopy ETag

A ranged UploadPartCopy that lands inside a source chunk stored the source chunk's ETag on the destination chunk, so the part reported the MD5 of the wrong bytes. streamCopyChunkRange now tees the forwarded plaintext into an optional digest writer; copySingleChunkForRange feeds it an md5 only when the range is not the whole source chunk, since a whole-chunk copy's stored bytes (and ETag) are unchanged.\n\nGenerated with [Devin](https://devin.ai)\n\nCo-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* s3api: stamp the copied range's MD5 on UploadPartCopy part entries

The part entry got no Attributes.Md5, so copyEntryETag fell back to filer.ETagChunks: a single-chunk part inherited the source chunk's MD5 and a multi-chunk part reported a <md5>-N composite of source chunks. copyChunksForRange now returns the MD5 of the copied bytes — a single chunk's own digest, or for multi-chunk parts a second ordered stream of the source range hashed beside the concurrent chunk copies (md5 cannot combine per-chunk digests). The handler stores it on Attributes.Md5 and mirrors putToFiler's ExtETagKey, so CopyPartResult, ListParts and the completed object's composite ETag use each part's own MD5 (#11855).\n\nGenerated with [Devin](https://devin.ai)\n\nCo-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* s3api: drop the source chunk's gzip flag on partial-range copies

A ranged UploadPartCopy fetches raw (decompressed) bytes from the source volume and stores them uncompressed, but the destination chunk kept the source's IsCompressed flag, so reads of a partial copy of a gzip-stored chunk would try to decompress raw bytes.\n\nGenerated with [Devin](https://devin.ai)\n\nCo-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* s3api: hash the chunk range, not the entry, for the part MD5

getEncryptedStreamFromVolumes prefers inline Content over chunks, but copyChunksForRange copies the chunks — on an entry carrying both, the hash would describe different bytes than the copy.\n\nGenerated with [Devin](https://devin.ai)\n\nCo-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* s3api: cover sparse ranges in the part MD5 and fail on hash errors

A single chunk's digest is the part MD5 only when it supplies the whole range; a sparse source zero-fills the rest, so gaps are hashed via the ordered range stream, and a fully chunkless range hashes zeros locally. A failed hash pass now fails the part copy instead of storing a chunk-composite ETag that describes the wrong bytes.\n\nGenerated with [Devin](https://devin.ai)\n\nCo-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* s3api: rank copied-range destination chunks by source timestamps

The concurrent chunk copies stamped each destination chunk with an arbitrary finish time, but reads resolve overlapping chunks by ModifiedTsNs — a copy of overlapping source chunks could expose different bytes than the source (and than the part MD5 just hashed). Destination chunks now get timestamps ranked by the source's, preserving its overlap precedence.\n\nGenerated with [Devin](https://devin.ai)\n\nCo-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* s3api: cancel the part-MD5 stream when a chunk copy fails

The hashing goroutine ran on context.Background(), so an early chunk-copy error return left it streaming and hashing the whole source range for a discarded result. A cancellable context now aborts it on every exit path.\n\nGenerated with [Devin](https://devin.ai)\n\nCo-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* s3api: hash the copied range when the covering chunk has no usable ETag

TUS and mount writes leave chunks without an ETag, so a whole-range
UploadPartCopy would otherwise save the part with an empty Md5 and no
ExtETagKey, and multipart completion rejects the part.

* s3api: build the md5 source entry without copying the proto message

* s3api: drop the issue reference from the part-copy etag test

* s3api: route inline-content sources through the plaintext part copy

The raw-byte path walks entry.GetChunks() only, so a ranged
UploadPartCopy of an inline-Content source (filer HTTP or mount writes)
produced a zero-filled part. Send those through
copyObjectPartViaReencryption, which streams the real bytes and stamps
their MD5 on the part.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* s3api: store the empty-part MD5 on writeEmptyCopyPart entries

The slow path's empty part returned the right CopyPartResult ETag but
saved no Attributes.Md5 or ExtETagKey, so ListParts and the completed
object's composite ETag still fell back to a chunk-derived value.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-21 13:46:11 -07:00
a3e97ec709 s3: apply Range to HeadObject (#11856)
* s3: apply Range to HeadObject

* s3: apply Range to a HEAD of a stored directory key

Directory-key entries answer through serveDirectoryContent before
HeadObjectHandler's range branch, so a ranged HEAD returned 200 and the
full length.

* s3: keep the missing-directory check for plain directories only

A stored directory-key marker is an object, so a range on its empty body
answers 416 like on any other zero-byte object.

* s3: let partNumber keep its priority over Range on a directory key HEAD

* s3: serve ranged GETs of a stored directory key

The same branch now applies the validated range to the inline content on
GET, so a directory-key object answers 206 with the selected bytes like
any other object.

---------

Co-authored-by: Chris Lu <chrislusf@users.noreply.github.com>
2026-10-21 13:37:00 -07:00
Lisandro PinGitHubClaude Opus 5.5Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>Chris Lu
23d8c62844 volume: route lookups to the leader master only (#11888)
* volume: route lookups to the leader master only (#11887)

After a leader change, the volume server kept sending volume and EC
shard lookups to masters that were not the leader:

* currentMaster was set as soon as a heartbeat stream opened, before
  the peer said whether it was leader, and kept a follower through the
  redirect and its 3s sleep. A follower answers LookupVolume from its
  own vid cache, which right after an election can be empty, so reads
  went 404 rather than failing over.
* EC lookups used Store.MasterAddress, a second, unlocked copy that the
  heartbeat loop set before each dial and cleared on errors, so EC and
  plain reads could disagree on the master or have none.
* the receive goroutine called stream.Send itself when the volume
  options changed, racing the send loop, and exited silently on
  failure, after which leader redirects were no longer read.

Publish currentMaster only once a master is known to be leader: the
peer answered a new stream without naming a leader, which the leader
always does, or a follower named the leader. Compare redirects against
the connected peer. Replace Store.MasterAddress with MasterFn, backed
by getCurrentMaster. Have the receiver ask the send loop for the extra
heartbeat instead of sending it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* volume: ask the masters for the leader after losing the heartbeat

When the heartbeat stream to the leader failed, currentMaster kept the
lost master until a new stream answered, so lookups on a cache miss
kept dialing it through the retry delay even with a new leader already
elected.

On a regular heartbeat error, ask the configured masters for the raft
leader and publish it, within the retry delay rather than on top of it.
Without an answer the last known leader stays: clearing it would only
trade a dead address for an empty one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test: stop the heartbeat on a failed assertion

The leader test closed stopChan only once every assertion had passed,
so a failure left the stream open and the fake master's GracefulStop
waited for the whole test run's timeout.

Start the heartbeat through a helper whose cleanup stops it, safe to
call twice, and waits for it to exit. It runs before the cleanups
registered earlier, so the stream is closed before the store and the
fake master go away. Both heartbeat tests use it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* volume: clear the leader when no master names one

When the heartbeat lost its master and no configured master named a
leader, refreshLeaderFromPeers kept the lost one. That master is dead
or no longer leader, and a follower answers LookupVolume from its own
cache instead of refusing, so lookups could return locations no leader
vouched for.

Clear currentMaster instead. Lookups then fail fast ("missing address")
until a leader is known again, rather than reading a follower's cache
or waiting on a dial to a dead master.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* volume: keep volumeSizeLimit 64-bit aligned on 32-bit builds

MasterFn (a func, 4 bytes on 386) replaced MasterAddress (a string, 8
bytes) ahead of volumeSizeLimit and moved it to a 4-byte-aligned
offset, where atomic.LoadUint64 panics with an unaligned 64-bit
operation. The atomic.Uint64 type carries the alignment itself.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* volume: publish the answering leader before a duplicate-uuid retry

Read the leader field first on each heartbeat reply. A follower reply
carries nothing else, so it no longer runs through the option updates;
and a leader that rejects the registration over duplicate uuids still
answers lookups, so it stays the lookup target through the retry wait
instead of leaving the previous master in place.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* volume: publish the master only once it is known leader (rust)

* volume: ask the masters for the leader after losing the heartbeat (rust)

* volume: read the leader field first on each heartbeat reply (rust)

* volume: drop the seed fallback from ec shard lookups (rust)

cached_lookup_ec_shard_locations fell back to the static first seed when
no leader was published, so a mid-election EC lookup could land on a
follower and read its stale shard map. Error out like Go's
masterAddress() returning "" instead, via a current_master() getter
matching getCurrentMaster.

* volume: send volume lookups to the confirmed leader, not the seed (rust)

Every HTTP lookup path — proxy/redirect targets, replicated writes and
deletes, batch chunk deletes, remote chunk reads — dialed the static
first seed, so after a failover they kept asking a follower whose vid
map can be stale. Go routes all of these through GetMaster; switch the
lookup calls and their leader-known gates to current_master().

* volume: mark volumes readonly on the leader, retrying the named one (rust)

notify_master_volume_readonly fell back to the seed when no leader was
published, which the peer answers with "Not current leader". It now
uses only the confirmed leader, skips the call in masterless mode, and
on NotLeader asks the seed masters who leads and retries there — the
same path Go's notifyMasterVolumeReadonly takes. The peer leader query
is shared with refresh_leader_from_peers as lookup_leader_from_peers.

* volume: ask the confirmed leader for copy prealloc options (rust)

VolumeCopy's preallocate query went to the static seed; Go issues it
through GetMaster. Route it through current_master() too — when no
leader is known the lookup fails fast and the copy proceeds without
preallocating, as before.

* volume: keep the announced master port forms, not just http (rust)

publish_current_master stripped to http form, so a leader configured
with an explicit gRPC port (host:9333.19999) was redialed on the
default offset. Store the announced host:port[.grpcPort] form like Go's
currentMaster; callers pick to_http_address / to_grpc_address /
parse_grpc_address per use, and Ping admission compares on the http
form.

* volume: fail replicated writes and deletes when no leader is known (rust)

Gating replication on a known leader meant a write could be
acknowledged on fewer copies than required, and a delete could leave
the data readable on the other replicas. Go fails the request when the
replica-location lookup cannot run; drop the gate so the empty-leader
lookup errors the same way.

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Chris Lu <chrislusf@users.noreply.github.com>
2026-10-21 13:36:05 -07:00
Chris LuGitHubDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com>Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
4bdbfbaaa8 s3tables: judge an absent tag target by its canonical ARN (#11912)
* s3tables: judge an absent tag target by its canonical ARN

authorizeAbsentTarget matched the bucket policy's Deny statements
against the request's raw resource ARN. The ARN parsers accept any
account and region but resolve the target by name, so a caller with an
identity grant could reformat the ARN, miss the Deny match, and learn
whether a denied name exists — the oracle the absent-target check was
meant to close. Rebuild the ARN from the resolved names and the real
bucket owner and evaluate the Deny against that, matching what the
existing-target path checks.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* s3tables: cover a foreign-account ARN on an absent denied table

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* s3tables: keep the absent-target deny check behind a supplied ARN

Callers that pass no resource ARN (ListTables, ListViews,
CreateNamespace) must reach CheckTableOrBucketPermission's admin
shortcut unchanged; running the bucket-policy Deny match first would
deny the admin lists those callers take.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-21 13:31:04 -07:00
e73c23a4ee fix(s3): set Access-Control-Allow-Credentials on CORS responses (#11886)
* fix(s3): set Access-Control-Allow-Credentials on CORS responses (fixes #11885)

* fix(s3): expand wildcard exposed headers for credentialed CORS responses

* fix(s3): keep wildcard in exposed headers for non-credentialed CORS requests

* fix(s3): expose x-amz-tagging-count on credentialed CORS responses

---------

Co-authored-by: Chris Lu <chrislusf@users.noreply.github.com>
2026-10-21 13:10:49 -07:00
Chris LuGitHubDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com>Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
8c3d8fcea0 s3tables: keep absent-target answers identical to denied ones (#11905)
* s3tables: authorize before revealing a list or tag target exists

ListTables, ListViews and the tag operations read the target's catalog
metadata before any permission check, so a denied caller could tell an
existing name (403 or the resource's own error) from an absent one (an
empty list, or 404). Evaluate the operation's permission with whatever
bucket context could be read and answer a denied caller the same way for
absent, damaged and denied targets.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* s3tables: answer bucket reads uniformly when the entry is not a table bucket

A plain S3 bucket directory answers GetTableBucket and ListNamespaces with
500 attribute-not-found while a missing name gets 404, and a denied
CreateNamespace is 403 while a missing bucket is 404. Run the permission
check before the absence answer and read not-a-table-bucket as
NoSuchTableBucket.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* s3tables: cover absent-target answers matching denied ones

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-21 13:07:47 -07:00
Chris LuandGitHub bb1ba8b247 iam: take the implicit user name from the verified identity (#11903)
* iam: take the implicit user name from the verified identity

For self-service actions sent without UserName, the implicit user was
resolved by re-parsing the raw Authorization header for a SigV4
credential and looking that access key up. The header is not what
authentication verified: a request can be authenticated by one
mechanism while carrying a header that names another user's access
key, and the action then ran against that other user.

The auth middleware already records the verified identity in the
request context, so use it, on both the standalone iam server and the
embedded IAM surface. When no verified identity is present the name is
left unset rather than guessed.

* iam: test implicit user name resolution

Cover a request authenticated by presigned credentials that also
carries a junk SigV4 header naming another user: the self-service
actions must run as the verified signer. Update the unit tests for the
context-based lookup.

* iam: resolve the implicit user for standalone GetUser too

The embedded surface fills UserName for GetUser; the standalone server
skipped it, so an implicit GetUser on `weed iam` answered NoSuchEntity.
Same verified-identity source as the other self-service actions.

* iam: cover all implicit-username actions in the regression test
2026-10-21 12:21:04 -07:00
593cfee85b s3tables: accept Puffin statistics files in a table's metadata/ (#11892)
Table and partition statistics are Puffin files that the table metadata lists
in its statistics entries; orphan cleanup keeps them (#11651), and its test
places one at metadata/stats-1.puffin. The table bucket layout rule accepted
only *.stats among them, so an engine that names a statistics file *.puffin
had the upload refused with 403 and could not write statistics at all.

The metadata patterns now admit a *.puffin file directly under metadata/,
with the same safe-character rule as the other catch-all patterns. Nothing
else widens: a Puffin file in a subdirectory of metadata/, with a trailing
extension, or outside <namespace>/<table>/ is still refused.

Test: the new metadata/*.puffin cases in
TestIcebergLayoutValidator_AcceptsRealWorldManifestNames and
TestTableBucketFileValidator_PuffinStatisticsOnlyInTableMetadata fail on
master ("invalid metadata file format") and pass with the change; the
rejected cases pass on both.

Co-authored-by: Javier de la Torre <jatorre@lakehousebox.com>
2026-10-21 12:19:53 -07:00
Daniel GarcíaandGitHub 03065155db filer.remote.sync: do not upload an entry whose only content is the remote object (#11890)
* filer.remote.sync: do not upload an entry whose local copy was dropped

remote.uncache and the filer's eviction under storage pressure drop a
synced entry's chunks and clear LastLocalSyncTsNs; remote.meta.sync does
the same when it pulls a newer remote version. When the remote mtime
falls in the entry's mtime second, which is the common case for a small
file synced promptly, shouldSendToRemote falls back to the cleared stamp
and reads the entry as dirty. The dropped chunks make the update a
content change, so the chunkless entry was uploaded and a zero-byte
object replaced the remote one, the entry's only content.

Skip an update that leaves the entry at the same key with no local data
and a sized RemoteEntry: the object at that key already is the content.
The stamp stays cleared, so remote.cache and eviction still see the
entry as uncached. Renames keep going through completeRemoteOnlyRename,
metadata-only edits of a remote-only entry still reach the remote, and a
rewrite in the remote mtime's second holds local data and is uploaded.

* filer.remote.sync: upload a truncate that leaves the entry remote-only

A FUSE truncate of a cached, remote-backed file to zero (truncateEntry,
or SetAttr with size 0) drops the chunks and keeps the sized
RemoteEntry, so the entry reads as remote-only and the dropped-copy skip
left the remote object with its old content.

Skip only while the entry's size is still the remote object's. Uncache
and eviction keep the size and remote.meta.sync sets it from the
object, so those stay skipped; a truncate changes the size and is
uploaded, in the remote mtime's second too.
2026-10-21 11:32:20 -07:00
49 changed files with 2102 additions and 318 deletions
+47 -16
View File
@@ -53,6 +53,12 @@ fn needle_not_found(needle_id: NeedleId) -> Status {
Status::not_found(format!("needle not found {}", needle_id.0))
}
/// Mirrors Go's `isNotLeaderErr`: the master returns raft's NotLeaderError,
/// which arrives as an Unknown-status message.
fn is_not_leader_status(e: &Status) -> bool {
e.message().contains("Not current leader")
}
/// Deadline from the caller's `grpc-timeout` header (TimeoutValue +
/// H|M|S|m|u|n unit, per the gRPC-over-HTTP2 spec). Tonic's GrpcTimeout layer
/// drops an over-deadline handler future anyway; parsing it here lets a queued
@@ -344,22 +350,46 @@ impl VolumeGrpcService {
is_readonly: bool,
) -> Result<(), Status> {
// VolumeMarkReadonly mutates raft-replicated master topology, so it must
// reach the leader. Prefer the live leader the heartbeat is talking to
// (current_master_url), falling back to the static seed before the first
// heartbeat — mirrors store_ec.rs and Go's vs.GetMaster(). Sending it to
// the static seed fails "not current leader" after any master failover.
let master_url = {
let live = self.state.current_master_url.read().await.clone();
if !live.is_empty() {
live
} else {
self.state.master_url.clone()
}
};
// reach the leader the heartbeat confirmed. On "Not current leader",
// ask the seed masters who leads and retry there — mirrors Go's
// notifyMasterVolumeReadonly.
let master_url = self.state.current_master().await;
if master_url.is_empty() {
return Ok(());
if !self.state.has_master {
return Ok(());
}
return Err(Status::unavailable("no master leader known"));
}
let grpc_addr = parse_grpc_address(&master_url).map_err(|e| {
match self
.volume_mark_readonly_on_master(&master_url, info, is_readonly)
.await
{
Err(e) if is_not_leader_status(&e) => {
let leader = super::heartbeat::lookup_leader_from_peers(
&self.state.master_urls,
self.state.outgoing_grpc_tls.as_ref(),
)
.await
.ok_or_else(|| {
Status::unavailable(format!(
"master {} rejected mark-readonly and no peer named a leader",
master_url
))
})?;
self.volume_mark_readonly_on_master(&leader, info, is_readonly)
.await
}
result => result,
}
}
async fn volume_mark_readonly_on_master(
&self,
master_url: &str,
info: &MasterVolumeInfo,
is_readonly: bool,
) -> Result<(), Status> {
let grpc_addr = parse_grpc_address(master_url).map_err(|e| {
Status::internal(format!("invalid master address {}: {}", master_url, e))
})?;
let channel = connect_channel(
@@ -5776,7 +5806,8 @@ impl VolumeCopyJob {
(&self.dest.data_base_name, self.dest.has_remote_dat);
let mut preallocate_size: i64 = 0;
if !has_remote_dat {
let grpc_addr = super::heartbeat::to_grpc_address(&state.master_url);
let master = state.current_master().await;
let grpc_addr = super::heartbeat::to_grpc_address(&master);
// Race the master-configuration RPC against the caller's
// response channel: a stalled master (or a slow leader
// election) would otherwise hold the task and its .note
@@ -5801,7 +5832,7 @@ impl VolumeCopyJob {
}
}
Err(e) => {
tracing::warn!("get master {} configuration: {}", state.master_url, e);
tracing::warn!("get master {} configuration: {}", master, e);
}
}
+12 -9
View File
@@ -603,7 +603,7 @@ async fn batch_delete_file_ids(
let locations = lookup_volume(
&state.http_client,
&state.outgoing_http_scheme,
&state.master_url,
&state.current_master().await,
volume_id,
)
.await
@@ -675,7 +675,7 @@ async fn do_replicated_request(
let locations = lookup_volume(
&state.http_client,
&state.outgoing_http_scheme,
&state.master_url,
&state.current_master().await,
vid,
)
.await
@@ -832,7 +832,7 @@ async fn proxy_or_redirect_to_target(
let locations = match lookup_volume(
&state.http_client,
&state.outgoing_http_scheme,
&state.master_url,
&state.current_master().await,
vid.0,
)
.await
@@ -1238,7 +1238,7 @@ async fn proxy_missing_volume(
let query_string = uri.query().unwrap_or("").to_string();
if is_proxied_query(&query_string)
|| state.read_mode == ReadMode::Local
|| state.master_url.is_empty()
|| state.current_master().await.is_empty()
{
return StatusCode::NOT_FOUND.into_response();
}
@@ -1282,7 +1282,7 @@ async fn check_download_limit(
// Go tries proxy to replica once before the wait loop
// (checkDownloadLimit); it does not retry on each wakeup.
let should_try_replica =
!is_proxied_query(&query_string) && !state.master_url.is_empty() && {
!is_proxied_query(&query_string) && !state.current_master().await.is_empty() && {
let store = state.store.read().unwrap();
store
.find_volume(vid)
@@ -2960,7 +2960,9 @@ pub async fn post_handler(
// Replicate to remote volume servers if this volume has replicas.
// Matches Go's GetWritableRemoteReplications: skip if copy_count == 1.
if !is_replicate && write_result.is_ok() && !state.master_url.is_empty() {
// No leader-known gate: when the lookup cannot run, the write must fail
// rather than be acknowledged on fewer copies than required.
if !is_replicate && write_result.is_ok() {
let needs_replication = {
let store = state.store.read().unwrap();
store
@@ -3358,7 +3360,7 @@ pub async fn delete_handler(
};
let is_replicate = del_query.split('&').any(|p| p == "type=replicate");
if !is_replicate && delete_result.is_ok() && !state.master_url.is_empty() {
if !is_replicate && delete_result.is_ok() {
let needs_replication = {
let store = state.store.read().unwrap();
store
@@ -4232,7 +4234,7 @@ async fn read_remote_chunk_needle(
let locations = lookup_volume(
&state.http_client,
&state.outgoing_http_scheme,
&state.master_url,
&state.current_master().await,
vid.0,
)
.await?;
@@ -6671,7 +6673,8 @@ mod tests {
let s = Arc::get_mut(&mut state).unwrap();
// Empty self_url makes `url.contains(self_url)` true for every peer.
s.self_url = "198.51.100.8:8080".to_string();
s.master_url = master_url;
s.master_url = master_url.clone();
s.current_master_url = tokio::sync::RwLock::new(master_url);
state
}
+71 -16
View File
@@ -155,7 +155,11 @@ pub async fn run_heartbeat_with_state(
}
PostAction::SleepPulse => {
new_leader = None;
tokio::time::sleep(pulse).await;
// The lookup spends part of the retry delay rather than
// adding to it.
let retry_at = tokio::time::Instant::now() + pulse;
refresh_leader_from_peers(&config, &state, pulse).await;
tokio::time::sleep_until(retry_at).await;
}
}
@@ -254,10 +258,56 @@ pub async fn try_get_master_configuration(
Ok(resp.into_inner())
}
/// Publish the leader the configured masters report, so lookups stop going
/// to the master the heartbeat just lost while the loop retries. Without an
/// answer inside `timeout` the published master is cleared: the lost master
/// is dead or no longer leader, and a follower answers lookups from its own
/// cache rather than refusing them. Mirrors Go's refreshLeaderFromPeers.
async fn refresh_leader_from_peers(
config: &HeartbeatConfig,
state: &Arc<VolumeServerState>,
timeout: Duration,
) {
let lookup = lookup_leader_from_peers(
&config.master_addresses,
state.outgoing_grpc_tls.as_ref(),
);
match tokio::time::timeout(timeout, lookup).await {
Ok(Some(leader)) => publish_current_master(state, &leader).await,
_ => state.current_master_url.write().await.clear(),
}
}
/// Ask each seed master for the raft leader and return the first answer, or
/// None when none replies with one. Mirrors Go's
/// `operation.LookupRaftLeaderMaster`.
pub async fn lookup_leader_from_peers(
master_addrs: &[String],
tls: Option<&super::grpc_client::OutgoingGrpcTlsConfig>,
) -> Option<String> {
for master_addr in master_addrs {
let grpc_addr = to_grpc_address(master_addr);
if let Ok(resp) = try_get_master_configuration(&grpc_addr, tls).await
&& !resp.leader.is_empty()
{
return Some(resp.leader);
}
}
None
}
fn is_stopping(state: &VolumeServerState) -> bool {
*state.is_stopping.read().unwrap()
}
/// Store the master lookups should go to, in the address form it was
/// announced in — `host:port` or `host:port.grpcPort` — so an explicit gRPC
/// port survives to the gRPC callers, mirroring Go's setCurrentMaster.
/// The heartbeat calls this only for a master known to lead.
async fn publish_current_master(state: &Arc<VolumeServerState>, master: &str) {
*state.current_master_url.write().await = master.to_string();
}
fn duplicate_uuid_retry_delay(retry_count: u32) -> Duration {
Duration::from_secs((1u64 << retry_count) * 2)
}
@@ -430,14 +480,6 @@ async fn do_heartbeat(
let mut response_stream = client.send_heartbeat(stream).await?.into_inner();
info!("Heartbeat stream established with {}", grpc_addr);
// Publish the master we're now talking to in canonical http host:port
// form so Ping admission can recognise it once a leader change moves us
// off the seed list. Mirrors Go's vs.setCurrentMaster(masterAddress).
{
let normalised = super::volume_server::to_http_address(current_master).into_owned();
let mut guard = state.current_master_url.write().await;
*guard = normalised;
}
if is_stopping(state) {
state.is_heartbeating.store(false, Ordering::Relaxed);
send_deregister_heartbeat(config, state, &tx).await;
@@ -451,13 +493,31 @@ async fn do_heartbeat(
volume_tick.tick().await;
ec_tick.tick().await;
let mut confirmed_leader = false;
loop {
tokio::select! {
resp = response_stream.message() => {
match resp {
Ok(Some(hb_resp)) => {
// Match Go ordering: DuplicatedUuids first, then volume
// options, then leader redirect.
// A reply names the leader or comes from it, so it
// settles where lookups go before anything else on the
// reply is handled: a follower reply carries no other
// fields, and a leader stays the lookup target even
// when it rejects this registration. Mirrors Go.
if !hb_resp.leader.is_empty() {
if super::volume_server::to_http_address(current_master)
!= super::volume_server::to_http_address(&hb_resp.leader)
{
publish_current_master(state, &hb_resp.leader).await;
return Ok(Some(hb_resp.leader));
}
} else if !confirmed_leader {
// The leader answers every new stream without
// naming a leader, so this peer is the one lookups
// go to.
confirmed_leader = true;
publish_current_master(state, current_master).await;
}
if !hb_resp.duplicated_uuids.is_empty() {
let uuids = hb_resp.duplicated_uuids.clone();
let duplicate_dirs = off_runtime(config, state, move |_, state| {
@@ -502,11 +562,6 @@ async fn do_heartbeat(
if metrics_changed {
state.metrics_notify.notify_waiters();
}
// Match Go: only redirect if leader is non-empty AND
// different from the current master we're connected to.
if !hb_resp.leader.is_empty() && current_master != hb_resp.leader {
return Ok(Some(hb_resp.leader));
}
}
Ok(None) => return Ok(None),
Err(e) => return Err(Box::new(e)),
+2 -9
View File
@@ -1139,16 +1139,9 @@ async fn cached_lookup_ec_shard_locations(
state: &Arc<VolumeServerState>,
vid: VolumeId,
) -> io::Result<HashMap<ShardId, Vec<String>>> {
let master = {
let live = state.current_master_url.read().await.clone();
if !live.is_empty() {
live
} else {
state.master_url.clone()
}
};
let master = state.current_master().await;
if master.is_empty() {
return Err(io::Error::other("no master configured for ec shard lookup"));
return Err(io::Error::other("no master leader known for ec shard lookup"));
}
let grpc_addr =
+16 -6
View File
@@ -86,7 +86,8 @@ pub struct VolumeServerState {
/// If true, FetchAndWriteNeedle skips remote S3 endpoint validation,
/// allowing arbitrary (incl. loopback / link-local / metadata) hosts.
pub allow_untrusted_remote_endpoints: bool,
/// First master address for volume lookups (e.g., "localhost:9333").
/// First configured master address (e.g., "localhost:9333"). A seed, not a
/// lookup target: lookups go to `current_master_url`.
pub master_url: String,
/// Seed master addresses for UI rendering.
pub master_urls: Vec<String>,
@@ -94,10 +95,12 @@ pub struct VolumeServerState {
/// Built once at construction so Ping admission stays O(1). Mirrors
/// Go's `seedMasterSet` on `VolumeServer`.
pub seed_master_set: std::collections::HashSet<String>,
/// Current master this server is heartbeating with, in canonical http
/// `host:port` form. Empty when no heartbeat connection is active. The
/// heartbeat goroutine writes; admission reads — the lock keeps them
/// from racing on a leader change. Mirrors Go's `currentMaster` plus
/// Master this server takes as leader, in its announced
/// `host:port[.grpcPort]` form — callers pick http or gRPC form per use.
/// Empty until the heartbeat confirms one — a peer that answered a
/// stream without naming a leader, or the leader a follower named — and
/// cleared again when no master reports one. The heartbeat writes;
/// admission and lookups read. Mirrors Go's `currentMaster` plus
/// `currentMasterLock`.
pub current_master_url: tokio::sync::RwLock<String>,
/// This server's own address (ip:port) for filtering self from lookup results.
@@ -140,6 +143,13 @@ pub struct VolumeServerState {
}
impl VolumeServerState {
/// The master lookups should go to: the leader the heartbeat confirmed,
/// or empty while none is known, in its announced `host:port[.grpcPort]`
/// form. Mirrors Go's `vs.getCurrentMaster()`.
pub async fn current_master(&self) -> String {
self.current_master_url.read().await.clone()
}
/// Check if the server is in maintenance mode; return gRPC error if so.
pub fn check_maintenance(&self) -> Result<(), tonic::Status> {
if self.maintenance.load(Ordering::Relaxed) {
@@ -209,7 +219,7 @@ impl VolumeServerState {
return false;
}
let current = self.current_master_url.read().await.clone();
if !current.is_empty() && current == key {
if !current.is_empty() && to_http_address(&current) == key {
return true;
}
self.seed_master_set.contains(&key)
+19
View File
@@ -384,6 +384,16 @@ func processUpdateEvent(
}
glog.V(0).Infof("never replicated, uploading %s", remote_storage.FormatLocation(dest))
}
if proto.Equal(oldDest, dest) && isDroppedLocalCopy(message.NewEntry) {
// The update dropped the local copy: remote.uncache, eviction under
// storage pressure, or remote.meta.sync pulling a newer remote
// version. Each clears the sync stamp, so shouldSendToRemote reads
// the entry as dirty when the remote mtime shares its second. The
// content is the object at this very key, and uploading the chunkless
// entry would replace it with zero bytes.
glog.V(2).Infof("skipping update of remote-only entry: %+v", resp)
return nil
}
glog.V(2).Infof("update: %+v", resp)
if !proto.Equal(oldDest, dest) {
// A renamed entry that holds no local data now (the snapshot was
@@ -488,6 +498,15 @@ func isRemoteOnly(entry *filer_pb.Entry) bool {
return entry != nil && !filer.HasData(entry) && entry.IsInRemoteOnly()
}
// isDroppedLocalCopy reports a remote-only entry whose size is still the
// remote object's: its local copy was dropped and the object describes it.
// Uncache and eviction keep the size, and remote.meta.sync sets it from the
// object. A truncate drops the chunks too but changes the size, and the
// truncated content has to reach the remote.
func isDroppedLocalCopy(entry *filer_pb.Entry) bool {
return isRemoteOnly(entry) && entry.GetAttributes().GetFileSize() == uint64(entry.RemoteEntry.RemoteSize)
}
// completeRemoteOnlyRename finishes a rename whose content exists only on the
// remote. When the destination object already holds what the entry's stamp
// describes (the rename ran before, its offset was not persisted, and
+200
View File
@@ -1722,6 +1722,206 @@ func TestMetadataOnlyUpdateRestampsSyncTs(t *testing.T) {
}
}
// The droppedCopy fixtures are a small synced file under a mount: a Delta log
// entry whose remote object was written in the second of its mtime, as a quick
// sync of a small file is.
const droppedCopyMtime = 1786096669
var (
droppedCopyDir = "/buckets/gold/sales/_delta_log"
droppedCopyName = "00000000000000000007.json"
droppedCopyMount = &remote_pb.RemoteStorageLocation{Name: "s3", Bucket: "gold", Path: "/"}
droppedCopyDest = &remote_pb.RemoteStorageLocation{Name: "s3", Bucket: "gold", Path: "/sales/_delta_log/" + droppedCopyName}
droppedCopySynced = &filer_pb.RemoteEntry{StorageName: "s3", RemoteETag: `"abc"`, RemoteSize: 1024, RemoteMtime: droppedCopyMtime, LastLocalSyncTsNs: droppedCopyMtime*1e9 + 500}
)
// droppedCopyStamp is the synced stamp with LastLocalSyncTsNs cleared, as
// remote.uncache and the eviction leave it.
func droppedCopyStamp() *filer_pb.RemoteEntry {
stamp := proto.Clone(droppedCopySynced).(*filer_pb.RemoteEntry)
stamp.LastLocalSyncTsNs = 0
return stamp
}
// droppedCopyEntry is the fixture file with the given stamp and chunks, sized
// as its remote object.
func droppedCopyEntry(stamp *filer_pb.RemoteEntry, chunks ...*filer_pb.FileChunk) *filer_pb.Entry {
entry := entryWith(droppedCopyName, stamp, chunks...)
entry.Attributes.FileSize = uint64(droppedCopySynced.RemoteSize)
return entry
}
func droppedCopyUpdate(oldEntry, newEntry *filer_pb.Entry) *filer_pb.SubscribeMetadataResponse {
return &filer_pb.SubscribeMetadataResponse{
Directory: droppedCopyDir,
EventNotification: &filer_pb.EventNotification{OldEntry: oldEntry, NewParentPath: droppedCopyDir, NewEntry: newEntry},
}
}
// TestDroppedLocalCopyIsNotUploaded: remote.uncache and the filer's eviction
// under storage pressure drop a synced entry's chunks and clear its sync
// stamp; remote.meta.sync does the same when it pulls a newer remote version.
// With the remote mtime in the entry's second, shouldSendToRemote falls back
// to the cleared stamp and reads the entry as dirty. The object at the same
// key is the entry's only content, so uploading the chunkless entry would
// replace it with a zero-byte object. The update is skipped, and the stamp
// stays cleared so remote.cache and eviction still see the entry as uncached.
func TestDroppedLocalCopyIsNotUploaded(t *testing.T) {
// the eviction's entry, built the way uncacheRemoteEntry builds it, and
// the event the way the filer's CreateEntry emits it
cached := &filer.Entry{
FullPath: util.NewFullPath(droppedCopyDir, droppedCopyName),
Attr: filer.Attr{Mtime: time.Unix(droppedCopyMtime, 0), Mode: 0660, FileSize: 1024},
Chunks: []*filer_pb.FileChunk{chunk("3,01", "e1")},
Remote: droppedCopySynced,
}
evicted := cached.ShallowClone()
evicted.Chunks = nil
evicted.Remote = proto.Clone(cached.Remote).(*filer_pb.RemoteEntry)
evicted.Remote.LastLocalSyncTsNs = 0
// remote.meta.sync found a newer object: its mtime becomes the entry's
newer := &filer_pb.RemoteEntry{StorageName: "s3", RemoteETag: `"def"`, RemoteSize: 4096, RemoteMtime: droppedCopyMtime + 60}
pulled := &filer_pb.Entry{Name: droppedCopyName, Attributes: &filer_pb.FuseAttributes{Mtime: droppedCopyMtime + 60, FileSize: 4096}, RemoteEntry: newer}
tests := []struct {
name string
oldEntry *filer_pb.Entry
newEntry *filer_pb.Entry
}{
{name: "remote.uncache", oldEntry: droppedCopyEntry(droppedCopySynced, chunk("3,01", "e1")), newEntry: droppedCopyEntry(droppedCopyStamp())},
{name: "eviction under storage pressure", oldEntry: cached.ToProtoEntry(), newEntry: evicted.ToProtoEntry()},
{name: "remote.meta.sync pulls a newer version", oldEntry: entryWith(droppedCopyName, droppedCopySynced, chunk("3,01", "e1")), newEntry: pulled},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
resp := droppedCopyUpdate(tt.oldEntry, tt.newEntry)
if !shouldSendToRemote(tt.newEntry) {
t.Fatal("precondition: the cleared stamp in the remote mtime's second reads as dirty, the bug's trigger")
}
if isMetadataOnlyUpdate(resp.Directory, resp.EventNotification) {
t.Fatal("precondition: the dropped chunks make the update a content change")
}
remote := &recordingRemote{}
filerClient := &stubFilerClient{entry: tt.newEntry}
if err := processUpdateEvent(filerClient, filerClient, "", remote, "/buckets/gold", droppedCopyMount, resp); err != nil {
t.Fatal(err)
}
if len(remote.writes) != 0 {
t.Errorf("writes = %+v, want none: the remote object is the entry's content and an upload would zero it", remote.writes)
}
if len(remote.deletes) != 0 || len(remote.metadataUpdates) != 0 {
t.Errorf("deletes = %+v, metadata updates = %+v, want the remote object left alone", remote.deletes, remote.metadataUpdates)
}
if filerClient.updates != 0 {
t.Errorf("stamped the entry %d times, want 0: a stamp would mark the uncached entry as cached", filerClient.updates)
}
})
}
t.Run("a metadata edit of a remote-only entry still reaches the remote", func(t *testing.T) {
tagged := droppedCopyEntry(droppedCopyStamp())
tagged.Extended = map[string][]byte{"X-Amz-Tagging-tier": []byte("gold")}
resp := droppedCopyUpdate(droppedCopyEntry(droppedCopyStamp()), tagged)
remote := &recordingRemote{}
filerClient := &stubFilerClient{entry: tagged}
if err := processUpdateEvent(filerClient, filerClient, "", remote, "/buckets/gold", droppedCopyMount, resp); err != nil {
t.Fatal(err)
}
if len(remote.metadataUpdates) != 1 || len(remote.writes) != 0 {
t.Errorf("metadata updates = %+v, writes = %+v, want one metadata update and no upload", remote.metadataUpdates, remote.writes)
}
})
}
// TestSameSecondRewriteIsStillUploaded: a rewrite that lands in the remote
// mtime's second holds local data, so the skip for dropped local copies does
// not apply and it is uploaded and restamped (#11715).
func TestSameSecondRewriteIsStillUploaded(t *testing.T) {
stale := proto.Clone(droppedCopySynced).(*filer_pb.RemoteEntry)
stale.LastLocalSyncTsNs = droppedCopyMtime*1e9 + 100
rewrite := func(stamp *filer_pb.RemoteEntry) *filer_pb.Entry {
entry := entryWith(droppedCopyName, stamp, chunk("4,02", "e2"))
entry.Attributes.MtimeNs = 337
return entry
}
tests := []struct {
name string
oldEntry *filer_pb.Entry
newEntry *filer_pb.Entry
}{
// Filer.UpdateEntry clears the stamp of a content change
{name: "synced entry, stamp cleared by the filer", oldEntry: entryWith(droppedCopyName, droppedCopySynced, chunk("3,01", "e1")), newEntry: rewrite(droppedCopyStamp())},
{name: "synced entry, stamp older than the rewrite", oldEntry: entryWith(droppedCopyName, stale, chunk("3,01", "e1")), newEntry: rewrite(stale)},
{name: "uncached entry", oldEntry: entryWith(droppedCopyName, droppedCopyStamp()), newEntry: rewrite(droppedCopyStamp())},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.newEntry.Attributes.Mtime != tt.newEntry.RemoteEntry.RemoteMtime {
t.Fatal("precondition: the rewrite lands in the remote mtime's second")
}
remote := &recordingRemote{}
filerClient := &stubFilerClient{entry: tt.newEntry}
if err := processUpdateEvent(filerClient, filerClient, "", remote, "/buckets/gold", droppedCopyMount, droppedCopyUpdate(tt.oldEntry, tt.newEntry)); err != nil {
t.Fatal(err)
}
if len(remote.writes) != 1 || !proto.Equal(remote.writes[0], droppedCopyDest) {
t.Fatalf("writes = %+v, want the rewrite uploaded to %s", remote.writes, remote_storage.FormatLocation(droppedCopyDest))
}
if !slices.Equal(remote.writtenChunks[0], []string{"4,02"}) {
t.Errorf("uploaded chunks %v, want the rewrite's [4,02]", remote.writtenChunks[0])
}
if filerClient.updates != 1 {
t.Errorf("stamped the entry %d times, want 1 after the upload", filerClient.updates)
}
})
}
}
// TestTruncateToZeroIsUploaded: a FUSE truncate of a cached file to zero
// (truncateEntry, or SetAttr with size 0) drops the chunks and keeps the
// sized RemoteEntry, so the entry looks remote-only. Its size is no longer
// the remote object's, and the empty file has to replace the object.
func TestTruncateToZeroIsUploaded(t *testing.T) {
truncated := func(mtime int64, mtimeNs int32) *filer_pb.Entry {
// the filer clears the stamp of a content change
entry := entryWith(droppedCopyName, droppedCopyStamp())
entry.Attributes.Mtime, entry.Attributes.MtimeNs = mtime, mtimeNs
return entry
}
tests := []struct {
name string
newEntry *filer_pb.Entry
}{
{name: "in a later second", newEntry: truncated(droppedCopyMtime+5, 0)},
{name: "in the remote mtime's second", newEntry: truncated(droppedCopyMtime, 337)},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if !isRemoteOnly(tt.newEntry) {
t.Fatal("precondition: the truncated entry looks remote-only")
}
remote := &recordingRemote{}
filerClient := &stubFilerClient{entry: tt.newEntry}
resp := droppedCopyUpdate(droppedCopyEntry(droppedCopySynced, chunk("3,01", "e1")), tt.newEntry)
if err := processUpdateEvent(filerClient, filerClient, "", remote, "/buckets/gold", droppedCopyMount, resp); err != nil {
t.Fatal(err)
}
if len(remote.writes) != 1 || !proto.Equal(remote.writes[0], droppedCopyDest) {
t.Fatalf("writes = %+v, want the empty file uploaded to %s", remote.writes, remote_storage.FormatLocation(droppedCopyDest))
}
if len(remote.writtenChunks[0]) != 0 {
t.Errorf("uploaded chunks %v, want none: a zero-byte object", remote.writtenChunks[0])
}
if filerClient.updates != 1 {
t.Errorf("stamped the entry %d times, want 1 after the upload", filerClient.updates)
}
})
}
}
func TestCompleteRemoteOnlyRenameKeepsAReusedOldKey(t *testing.T) {
// A was renamed to B, then D to A before the sync got to the first
// rename: the object at A is now D's, and the only copy of it
+8 -5
View File
@@ -98,11 +98,13 @@ var fineGrainedActionMap = map[string]string{
// prefixed (s3:Action) and unprefixed (Action) variants in init().
var baseS3ActionMap = map[string]string{
// Object operations
"DeleteObject": s3_constants.ACTION_WRITE,
"PutObject": s3_constants.ACTION_WRITE,
"GetObject": s3_constants.ACTION_READ,
"DeleteObjectVersion": s3_constants.ACTION_WRITE,
"GetObjectVersion": s3_constants.ACTION_READ,
"DeleteObject": s3_constants.ACTION_WRITE,
"PutObject": s3_constants.ACTION_WRITE,
"GetObject": s3_constants.ACTION_READ,
"DeleteObjectVersion": s3_constants.ACTION_WRITE,
"GetObjectVersion": s3_constants.ACTION_READ,
"GetObjectAttributes": s3_constants.ACTION_READ,
"GetObjectVersionAttributes": s3_constants.ACTION_READ,
// Tagging operations
"GetObjectTagging": s3_constants.ACTION_TAGGING,
"GetObjectVersionTagging": s3_constants.ACTION_TAGGING,
@@ -115,6 +117,7 @@ var baseS3ActionMap = map[string]string{
"DeleteBucketTagging": s3_constants.ACTION_TAGGING,
// ACL operations
"PutObjectAcl": s3_constants.ACTION_WRITE_ACP,
"PutObjectVersionAcl": s3_constants.ACTION_WRITE_ACP,
"GetObjectAcl": s3_constants.ACTION_READ_ACP,
"GetObjectVersionAcl": s3_constants.ACTION_READ_ACP,
"PutBucketAcl": s3_constants.ACTION_WRITE_ACP,
+72
View File
@@ -2,10 +2,16 @@ package iamapi
import (
"context"
"crypto/hmac"
"crypto/sha1"
"crypto/sha256"
"encoding/base64"
"fmt"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strconv"
"strings"
"testing"
"time"
@@ -141,3 +147,69 @@ func TestIamManagementAllowedForAdmin(t *testing.T) {
"", "Action=CreateUser&UserName=new-user&Version=2010-05-08")
assert.Equal(t, http.StatusOK, resp.StatusCode)
}
// The implicit UserName must come from the identity authentication verified,
// never from re-parsing the Authorization header: a request can carry presigned
// credentials for one user alongside a junk SigV4 header naming another.
func TestImplicitUserNameUsesVerifiedIdentity(t *testing.T) {
server := newAuthzTestServer(t)
expires := strconv.FormatInt(time.Now().Add(10*time.Minute).Unix(), 10)
sts := "POST\n\napplication/x-www-form-urlencoded\n" + expires + "\n/"
mac := hmac.New(sha1.New, []byte("wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"))
mac.Write([]byte(sts))
sig := base64.StdEncoding.EncodeToString(mac.Sum(nil))
q := url.Values{"AWSAccessKeyId": {"AKIAIOSFODNN7EXAMPLE"}, "Expires": {expires}, "Signature": {sig}}
post := func(t *testing.T, body string) (int, string) {
t.Helper()
req, err := http.NewRequest(http.MethodPost, server.URL+"/?"+q.Encode(), strings.NewReader(body))
require.NoError(t, err)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Authorization", "AWS4-HMAC-SHA256 Credential=AKIATESTFAKEADMIN0001/20261021/us-east-1/iam/aws4_request, SignedHeaders=host, Signature=00")
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
b, err := io.ReadAll(resp.Body)
require.NoError(t, err)
t.Logf("%d %s", resp.StatusCode, b)
return resp.StatusCode, string(b)
}
for _, action := range []string{"ListAccessKeys", "CreateAccessKey", "GetUser"} {
t.Run(action, func(t *testing.T) {
code, body := post(t, "Action="+action+"&Version=2010-05-08")
assert.Equal(t, http.StatusOK, code)
assert.NotContains(t, body, "iam_admin")
assert.Contains(t, body, "power_user")
})
}
// ListUserPolicies is not self-service: a caller without IAM rights is
// denied whichever name the implicit resolution lands on.
t.Run("ListUserPolicies", func(t *testing.T) {
code, body := post(t, "Action=ListUserPolicies&Version=2010-05-08")
assert.Equal(t, http.StatusForbidden, code)
assert.NotContains(t, body, "iam_admin")
})
// A write aimed at the header-named admin's key resolves to the verified
// caller instead: the key is not power_user's, so it is not found.
t.Run("UpdateAccessKey", func(t *testing.T) {
code, body := post(t, "Action=UpdateAccessKey&AccessKeyId=AKIATESTFAKEADMIN0001&Status=Inactive&Version=2010-05-08")
assert.Equal(t, http.StatusNotFound, code)
assert.Contains(t, body, "NoSuchEntity")
})
t.Run("DeleteAccessKey", func(t *testing.T) {
code, _ := post(t, "Action=DeleteAccessKey&AccessKeyId=AKIATESTFAKEADMIN0001&Version=2010-05-08")
assert.Equal(t, http.StatusOK, code)
})
// The admin key the junk header named is untouched: still present and active.
resp := postSignedIamAction(t, server.URL, "AKIATESTFAKEADMIN0001", "testAdminSecretFake0000000000000000000000",
"", "Action=ListAccessKeys&Version=2010-05-08")
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
assert.Contains(t, string(body), "AKIATESTFAKEADMIN0001")
assert.Contains(t, string(body), "<Status>Active</Status>")
}
+11 -43
View File
@@ -22,6 +22,7 @@ import (
"github.com/seaweedfs/seaweedfs/weed/pb/iam_pb"
"github.com/seaweedfs/seaweedfs/weed/s3api"
"github.com/seaweedfs/seaweedfs/weed/s3api/policy_engine"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
"github.com/seaweedfs/seaweedfs/weed/util/request_id"
)
@@ -1265,53 +1266,19 @@ func (iama *IamApiServer) DeleteAccessKey(s3cfg *iam_pb.S3ApiConfiguration, valu
return resp
}
// handleImplicitUsername adds username who signs the request to values if 'username' is not specified.
// According to AWS documentation: "If you do not specify a user name, IAM determines the user name
// implicitly based on the Amazon Web Services access key ID signing the request."
// This function extracts the AccessKeyId from the SigV4 credential and looks up the corresponding
// identity name in the credential store.
// handleImplicitUsername adds the name of the authenticated caller to values
// when 'UserName' is not specified, per AWS: "If you do not specify a user name,
// IAM determines the user name implicitly based on the Amazon Web Services
// access key ID signing the request." The name comes from the identity the
// authentication middleware verified, which the request context carries; the
// Authorization header itself is attacker-controlled and is never re-parsed.
func (iama *IamApiServer) handleImplicitUsername(r *http.Request, values url.Values) {
if len(r.Header["Authorization"]) == 0 || values.Get("UserName") != "" {
if values.Get("UserName") != "" {
return
}
// Log presence of auth header without exposing sensitive signature material
glog.V(4).Infof("Authorization header present, extracting access key")
// Parse AWS SigV4 Authorization header format:
// "AWS4-HMAC-SHA256 Credential=AKIAIOSFODNN7EXAMPLE/20130524/us-east-1/iam/aws4_request, ..."
s := strings.Split(r.Header["Authorization"][0], "Credential=")
if len(s) < 2 {
return
if name := s3_constants.GetIdentityNameFromContext(r); name != "" {
values.Set("UserName", name)
}
s = strings.Split(s[1], ",")
if len(s) < 1 {
return
}
s = strings.Split(s[0], "/")
if len(s) < 1 {
return
}
// s[0] is the AccessKeyId
accessKeyId := s[0]
if accessKeyId == "" {
return
}
// Nil-guard: ensure iam is initialized before lookup
if iama.iam == nil {
glog.V(4).Infof("IAM not initialized, cannot look up access key")
return
}
// Look up the identity by access key to get the username
identity, _, found := iama.iam.LookupByAccessKey(accessKeyId)
if !found {
// Mask access key in logs - show only first 4 chars
maskedKey := accessKeyId
if len(accessKeyId) > 4 {
maskedKey = accessKeyId[:4] + "***"
}
glog.V(4).Infof("Access key %s not found in credential store", maskedKey)
return
}
values.Set("UserName", identity.Name)
}
// targetedCredentialManager returns the credential manager through which a change to the named
@@ -1470,6 +1437,7 @@ func (iama *IamApiServer) DoActions(w http.ResponseWriter, r *http.Request) {
}
}
case "GetUser":
iama.handleImplicitUsername(r, values)
userName := values.Get("UserName")
var err *IamError
response, err = iama.GetUser(s3cfg, userName)
+14 -32
View File
@@ -21,6 +21,7 @@ import (
"github.com/seaweedfs/seaweedfs/weed/pb/iam_pb"
"github.com/seaweedfs/seaweedfs/weed/s3api"
"github.com/seaweedfs/seaweedfs/weed/s3api/policy_engine"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
"github.com/seaweedfs/seaweedfs/weed/util/request_id"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -524,32 +525,13 @@ func executeRequest(req *http.Request, v interface{}) (*httptest.ResponseRecorde
}
func TestHandleImplicitUsername(t *testing.T) {
// Create a mock IamApiServer with credential store
// The handleImplicitUsername function now looks up the username from the
// credential store based on AccessKeyId, not from the region field in the auth header.
// Note: Using obviously fake access keys to avoid CI secret scanner false positives
// The implicit name is the authenticated identity recorded in the request
// context by the auth middleware; request headers are never re-parsed.
iama := &IamApiServer{}
// Create IAM directly as struct literal (same pattern as other tests)
iam := &s3api.IdentityAccessManagement{}
// Load test credentials - map access key to identity name
testConfig := &iam_pb.S3ApiConfiguration{
Identities: []*iam_pb.Identity{
{
Name: "testuser1",
Credentials: []*iam_pb.Credential{
{AccessKey: "AKIATESTFAKEKEY000001", SecretKey: "testsecretfake"},
},
},
},
}
err := iam.LoadS3ApiConfigurationFromBytes(mustMarshalJSON(t, testConfig))
if err != nil {
t.Fatalf("Failed to load test config: %v", err)
}
iama := &IamApiServer{
iam: iam,
withIdentity := func(name string) *http.Request {
r := &http.Request{Header: http.Header{}}
return r.WithContext(s3_constants.SetIdentityNameInContext(r.Context(), name))
}
var tests = []struct {
@@ -557,14 +539,14 @@ func TestHandleImplicitUsername(t *testing.T) {
values url.Values
userName string
}{
// No authorization header - should not set username
// No authenticated identity - should not set username
{&http.Request{}, url.Values{}, ""},
// Valid auth header with known access key - should look up and find "testuser1"
{&http.Request{Header: http.Header{"Authorization": []string{"AWS4-HMAC-SHA256 Credential=AKIATESTFAKEKEY000001/20220420/us-east-1/iam/aws4_request, SignedHeaders=content-type;host;x-amz-date, Signature=fakesignature0123456789abcdef"}}}, url.Values{}, "testuser1"},
// Malformed auth header (no Credential=) - should not set username
{&http.Request{Header: http.Header{"Authorization": []string{"AWS4-HMAC-SHA256 =AKIATESTFAKEKEY000001/20220420/test1/iam/aws4_request, SignedHeaders=content-type;host;x-amz-date, Signature=fakesignature0123456789abcdef"}}}, url.Values{}, ""},
// Unknown access key - should not set username
{&http.Request{Header: http.Header{"Authorization": []string{"AWS4-HMAC-SHA256 Credential=AKIATESTUNKNOWN000000/20220420/us-east-1/iam/aws4_request, SignedHeaders=content-type;host;x-amz-date, Signature=fakesignature0123456789abcdef"}}}, url.Values{}, ""},
// Authenticated identity in context - sets its name
{withIdentity("testuser1"), url.Values{}, "testuser1"},
// A client-controlled Authorization header naming another user is ignored
{&http.Request{Header: http.Header{"Authorization": []string{"AWS4-HMAC-SHA256 Credential=AKIATESTFAKEKEY000001/20220420/us-east-1/iam/aws4_request, SignedHeaders=content-type;host;x-amz-date, Signature=fakesignature0123456789abcdef"}}}, url.Values{}, ""},
// An explicit UserName is left alone
{withIdentity("testuser1"), url.Values{"UserName": {"other"}}, "other"},
}
for i, test := range tests {
+11 -5
View File
@@ -3,6 +3,7 @@ package s3api
import (
"context"
"net/http"
"net/url"
"testing"
"github.com/stretchr/testify/assert"
@@ -251,9 +252,10 @@ func TestAuthorizeCopySource_PresignedURLSessionTokenPreserved(t *testing.T) {
}
// Presigned URL: session token is in the query string, no
// X-Amz-Security-Token header. The destination URL also carries unrelated
// SigV4 query params (X-Amz-Algorithm, etc.) that must not leak into the
// synthetic source action resolution.
// X-Amz-Security-Token header. The destination URL also carries the rest of
// the SigV4 presign params, which ride along so auth-derived policy
// conditions (s3:authType) match the real request; none of them steer
// ResolveS3Action.
req, err := http.NewRequest(http.MethodPut,
"http://s3.local/bucket-a/dst.bin?X-Amz-Algorithm=AWS4-HMAC-SHA256"+
"&X-Amz-Credential=stsKey%2F20260518%2Fus-east-1%2Fs3%2Faws4_request"+
@@ -267,8 +269,12 @@ func TestAuthorizeCopySource_PresignedURLSessionTokenPreserved(t *testing.T) {
assert.Equal(t, s3err.ErrNone, errCode)
assert.Equal(t, "presigned-session-token", capturedSessionToken,
"session token from presigned-URL query must reach the IAM check")
// versionId omitted, only the token should be present in the synthetic query.
assert.Equal(t, "X-Amz-Security-Token=presigned-session-token", capturedRawQuery)
capturedQuery, err := url.ParseQuery(capturedRawQuery)
require.NoError(t, err)
assert.Equal(t, "presigned-session-token", capturedQuery.Get("X-Amz-Security-Token"))
assert.Equal(t, "AWS4-HMAC-SHA256", capturedQuery.Get("X-Amz-Algorithm"))
assert.False(t, capturedQuery.Has("versionId"))
assert.False(t, capturedQuery.Has("acl"))
}
// TestAuthorizeCopySource_PreservesCopySourceHeader verifies that the synthetic
+14 -4
View File
@@ -3025,11 +3025,21 @@ func (iam *IdentityAccessManagement) bucketsNamedByAttachedPolicies(r *http.Requ
// its query describe. versionId, when set, and the STS session token ride along
// for policy conditions. Returns ErrNone when auth is disabled (checked by the
// callers) or the identity is an admin.
// authQueryParams are the query keys carrying a request's authentication
// material (SigV2 and SigV4 presign parameters). None steer ResolveS3Action,
// so they can ride along on a synthesized request.
var authQueryParams = []string{
"AWSAccessKeyId", "Signature", "Expires",
"X-Amz-Algorithm", "X-Amz-Credential", "X-Amz-Date", "X-Amz-Expires",
"X-Amz-Signature", "X-Amz-SignedHeaders", "X-Amz-Security-Token",
}
// objectKeyPolicyRequest synthesizes a <method> /<bucket>/<objectKey> request
// for policy evaluation against a key the real URL does not name. The query is
// built from scratch so a param on the real request (delete, uploadId,
// partNumber, ...) cannot steer ResolveS3Action off the intended action; the
// STS token and versionId ride along for policy conditions.
// versionId and the auth-material params ride along so auth-derived policy
// conditions (s3:authType) evaluate as they did on the real request.
func objectKeyPolicyRequest(r *http.Request, method, bucket, objectKey, versionId string) *http.Request {
keyReq := new(http.Request)
*keyReq = *r
@@ -3042,9 +3052,9 @@ func objectKeyPolicyRequest(r *http.Request, method, bucket, objectKey, versionI
if versionId != "" {
keyQuery.Set("versionId", versionId)
}
if strings.Contains(r.URL.RawQuery, "X-Amz-Security-Token") {
if token := r.URL.Query().Get("X-Amz-Security-Token"); token != "" {
keyQuery.Set("X-Amz-Security-Token", token)
for _, key := range authQueryParams {
if vals, ok := r.URL.Query()[key]; ok {
keyQuery[key] = vals
}
}
if len(keyQuery) > 0 {
+47 -3
View File
@@ -235,13 +235,15 @@ func buildPreflightResponse(rule *CORSRule, corsReq *CORSRequest) *CORSResponse
// Set exposed headers
if len(rule.ExposeHeaders) > 0 {
response.ExposeHeaders = strings.Join(rule.ExposeHeaders, ", ")
response.ExposeHeaders = formatExposeHeaders(rule.ExposeHeaders)
}
// Set max age
if rule.MaxAgeSeconds != nil {
response.MaxAge = strconv.Itoa(*rule.MaxAgeSeconds)
}
response.AllowCredentials = true
}
return response
@@ -250,7 +252,8 @@ func buildPreflightResponse(rule *CORSRule, corsReq *CORSRequest) *CORSResponse
// buildResponse builds a CORS response from a matching rule
func buildResponse(rule *CORSRule, corsReq *CORSRequest) *CORSResponse {
response := &CORSResponse{
AllowOrigin: corsReq.Origin,
AllowOrigin: corsReq.Origin,
AllowCredentials: true,
}
// Set allowed methods
@@ -263,7 +266,7 @@ func buildResponse(rule *CORSRule, corsReq *CORSRequest) *CORSResponse {
// Set expose headers
if len(rule.ExposeHeaders) > 0 {
response.ExposeHeaders = strings.Join(rule.ExposeHeaders, ", ")
response.ExposeHeaders = formatExposeHeaders(rule.ExposeHeaders)
}
// Set max age
@@ -274,6 +277,47 @@ func buildResponse(rule *CORSRule, corsReq *CORSRequest) *CORSResponse {
return response
}
// defaultExposeHeaders are emitted alongside "*" so credentialed CORS requests
// can read standard response headers ("*" is treated literally when credentials
// are included, per the Fetch spec).
var defaultExposeHeaders = []string{
"ETag",
"Content-Length",
"Content-Type",
"Last-Modified",
"Content-Range",
"x-amz-request-id",
"x-amz-version-id",
"x-amz-tagging-count",
}
// formatExposeHeaders formats the exposed headers slice into a comma-separated string.
func formatExposeHeaders(exposeHeaders []string) string {
var headers []string
hasWildcard := false
seen := map[string]bool{}
for _, h := range exposeHeaders {
if h == "*" {
hasWildcard = true
continue
}
if lower := strings.ToLower(h); !seen[lower] {
seen[lower] = true
headers = append(headers, h)
}
}
if hasWildcard {
for _, h := range defaultExposeHeaders {
if lower := strings.ToLower(h); !seen[lower] {
seen[lower] = true
headers = append(headers, h)
}
}
headers = append(headers, "*")
}
return strings.Join(headers, ", ")
}
// Helper functions
// matchesOrigin checks if the request origin matches any allowed origin
+51 -23
View File
@@ -405,11 +405,12 @@ func TestEvaluateRequest(t *testing.T) {
Method: "GET",
},
want: &CORSResponse{
AllowOrigin: "http://example.com",
AllowMethods: "GET, POST",
AllowHeaders: "Content-Type",
ExposeHeaders: "ETag",
MaxAge: "3600",
AllowOrigin: "http://example.com",
AllowMethods: "GET, POST",
AllowHeaders: "Content-Type",
ExposeHeaders: "ETag",
MaxAge: "3600",
AllowCredentials: true,
},
wantErr: false,
},
@@ -421,8 +422,9 @@ func TestEvaluateRequest(t *testing.T) {
Method: "PUT",
},
want: &CORSResponse{
AllowOrigin: "http://other.com",
AllowMethods: "PUT",
AllowOrigin: "http://other.com",
AllowMethods: "PUT",
AllowCredentials: true,
},
wantErr: false,
},
@@ -447,11 +449,12 @@ func TestEvaluateRequest(t *testing.T) {
AccessControlRequestHeaders: []string{"Content-Type"},
},
want: &CORSResponse{
AllowOrigin: "http://example.com",
AllowMethods: "GET, POST",
AllowHeaders: "Content-Type",
ExposeHeaders: "ETag",
MaxAge: "3600",
AllowOrigin: "http://example.com",
AllowMethods: "GET, POST",
AllowHeaders: "Content-Type",
ExposeHeaders: "ETag",
MaxAge: "3600",
AllowCredentials: true,
},
wantErr: false,
},
@@ -467,7 +470,30 @@ func TestEvaluateRequest(t *testing.T) {
},
want: &CORSResponse{
AllowOrigin: "http://example.com",
// No AllowMethods or AllowHeaders because the requested header is forbidden
// No AllowMethods, AllowHeaders, or AllowCredentials because the requested header is forbidden
},
wantErr: false,
},
{
name: "wildcard expose headers expands for credentialed responses",
config: &CORSConfiguration{
CORSRules: []CORSRule{
{
AllowedOrigins: []string{"http://example.com"},
AllowedMethods: []string{"GET"},
ExposeHeaders: []string{"*"},
},
},
},
corsReq: &CORSRequest{
Origin: "http://example.com",
Method: "GET",
},
want: &CORSResponse{
AllowOrigin: "http://example.com",
AllowMethods: "GET",
ExposeHeaders: "ETag, Content-Length, Content-Type, Last-Modified, Content-Range, x-amz-request-id, x-amz-version-id, x-amz-tagging-count, *",
AllowCredentials: true,
},
wantErr: false,
},
@@ -511,18 +537,20 @@ func TestApplyHeaders(t *testing.T) {
{
name: "complete response",
corsResp: &CORSResponse{
AllowOrigin: "http://example.com",
AllowMethods: "GET, POST",
AllowHeaders: "Content-Type",
ExposeHeaders: "ETag",
MaxAge: "3600",
AllowOrigin: "http://example.com",
AllowMethods: "GET, POST",
AllowHeaders: "Content-Type",
ExposeHeaders: "ETag",
MaxAge: "3600",
AllowCredentials: true,
},
want: map[string]string{
"Access-Control-Allow-Origin": "http://example.com",
"Access-Control-Allow-Methods": "GET, POST",
"Access-Control-Allow-Headers": "Content-Type",
"Access-Control-Expose-Headers": "ETag",
"Access-Control-Max-Age": "3600",
"Access-Control-Allow-Origin": "http://example.com",
"Access-Control-Allow-Methods": "GET, POST",
"Access-Control-Allow-Headers": "Content-Type",
"Access-Control-Expose-Headers": "ETag",
"Access-Control-Max-Age": "3600",
"Access-Control-Allow-Credentials": "true",
},
},
{
+15 -2
View File
@@ -166,15 +166,25 @@ func resolveFromQueryParameters(query url.Values, method string, hasObject bool)
}
}
// ACL operations
// ACL operations. A versionId retargets the request at a specific
// version, so the version ACL action applies, not the current-object one.
if query.Has("acl") {
versioned := hasObject && query.Get("versionId") != ""
switch method {
case http.MethodGet, http.MethodHead:
// HEAD has no acl route; it lands on HeadObjectHandler and should
// resolve like a read — s3:GetObject, or s3:GetObjectVersion below.
case http.MethodGet:
if versioned {
return s3_constants.S3_ACTION_GET_OBJECT_VERSION_ACL
}
if hasObject {
return s3_constants.S3_ACTION_GET_OBJECT_ACL
}
return s3_constants.S3_ACTION_GET_BUCKET_ACL
case http.MethodPut:
if versioned {
return s3_constants.S3_ACTION_PUT_OBJECT_VERSION_ACL
}
if hasObject {
return s3_constants.S3_ACTION_PUT_OBJECT_ACL
}
@@ -220,6 +230,9 @@ func resolveFromQueryParameters(query url.Values, method string, hasObject bool)
// Must be checked before versionId, because GET /bucket/key?attributes&versionId=xyz
// is a GetObjectAttributes request, not a GetObjectVersion request
if hasObject && query.Has("attributes") && method == http.MethodGet {
if query.Get("versionId") != "" {
return s3_constants.S3_ACTION_GET_OBJECT_VERSION_ATTRIBUTES
}
return s3_constants.S3_ACTION_GET_OBJECT_ATTRIBUTES
}
+54 -1
View File
@@ -83,7 +83,7 @@ func TestResolveS3Action_AttributesBeforeVersionId(t *testing.T) {
method: http.MethodGet,
baseAction: s3_constants.ACTION_READ,
object: "key",
want: s3_constants.S3_ACTION_GET_OBJECT_ATTRIBUTES,
want: s3_constants.S3_ACTION_GET_OBJECT_VERSION_ATTRIBUTES,
},
{
name: "versionId only GET",
@@ -146,6 +146,59 @@ func TestResolveS3Action_VersionTagging(t *testing.T) {
}
}
// An ACL request carrying versionId targets a specific version and must
// resolve to the version ACL action so a grant limited to the current-object
// action cannot reach historical versions.
func TestResolveS3Action_VersionAcl(t *testing.T) {
tests := []struct {
name string
method string
object string
query string
want string
}{
{"get current acl", http.MethodGet, "key", "acl", s3_constants.S3_ACTION_GET_OBJECT_ACL},
{"put current acl", http.MethodPut, "key", "acl", s3_constants.S3_ACTION_PUT_OBJECT_ACL},
{"get version acl", http.MethodGet, "key", "acl&versionId=v1", s3_constants.S3_ACTION_GET_OBJECT_VERSION_ACL},
{"head acl resolves as head object", http.MethodHead, "key", "acl", s3_constants.S3_ACTION_GET_OBJECT},
{"head version acl resolves as head version", http.MethodHead, "key", "acl&versionId=v1", s3_constants.S3_ACTION_GET_OBJECT_VERSION},
{"put version acl", http.MethodPut, "key", "acl&versionId=v1", s3_constants.S3_ACTION_PUT_OBJECT_VERSION_ACL},
{"empty versionId stays current", http.MethodGet, "key", "acl&versionId=", s3_constants.S3_ACTION_GET_OBJECT_ACL},
{"bucket acl ignores versionId", http.MethodGet, "", "acl&versionId=v1", s3_constants.S3_ACTION_GET_BUCKET_ACL},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r, _ := http.NewRequest(tt.method, "http://localhost/bucket/"+tt.object+"?"+tt.query, nil)
if got := ResolveS3Action(r, s3_constants.ACTION_READ, "bucket", tt.object); got != tt.want {
t.Errorf("ResolveS3Action() = %q, want %q", got, tt.want)
}
})
}
}
// An attributes request carrying versionId targets a specific version and
// must resolve to the version attributes action.
func TestResolveS3Action_VersionAttributes(t *testing.T) {
tests := []struct {
name string
method string
object string
query string
want string
}{
{"get current attributes", http.MethodGet, "key", "attributes", s3_constants.S3_ACTION_GET_OBJECT_ATTRIBUTES},
{"get version attributes", http.MethodGet, "key", "attributes&versionId=v1", s3_constants.S3_ACTION_GET_OBJECT_VERSION_ATTRIBUTES},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r, _ := http.NewRequest(tt.method, "http://localhost/bucket/"+tt.object+"?"+tt.query, nil)
if got := ResolveS3Action(r, s3_constants.ACTION_READ, "bucket", tt.object); got != tt.want {
t.Errorf("ResolveS3Action() = %q, want %q", got, tt.want)
}
})
}
}
// Bucket subresources registered with ACTION_ADMIN must resolve to their own
// S3 actions so a policy granting one of them does not need s3:*, and so no
// broader grant sweeps them in.
+11 -6
View File
@@ -4,17 +4,22 @@ package s3_constants
// These match the official AWS S3 action format used in IAM and bucket policies
const (
// Object operations
S3_ACTION_GET_OBJECT = "s3:GetObject"
S3_ACTION_PUT_OBJECT = "s3:PutObject"
S3_ACTION_DELETE_OBJECT = "s3:DeleteObject"
S3_ACTION_DELETE_OBJECT_VERSION = "s3:DeleteObjectVersion"
S3_ACTION_GET_OBJECT_VERSION = "s3:GetObjectVersion"
S3_ACTION_GET_OBJECT_ATTRIBUTES = "s3:GetObjectAttributes"
S3_ACTION_GET_OBJECT = "s3:GetObject"
S3_ACTION_PUT_OBJECT = "s3:PutObject"
S3_ACTION_DELETE_OBJECT = "s3:DeleteObject"
S3_ACTION_DELETE_OBJECT_VERSION = "s3:DeleteObjectVersion"
S3_ACTION_GET_OBJECT_VERSION = "s3:GetObjectVersion"
S3_ACTION_GET_OBJECT_ATTRIBUTES = "s3:GetObjectAttributes"
S3_ACTION_GET_OBJECT_VERSION_ATTRIBUTES = "s3:GetObjectVersionAttributes"
// Object ACL operations
S3_ACTION_GET_OBJECT_ACL = "s3:GetObjectAcl"
S3_ACTION_PUT_OBJECT_ACL = "s3:PutObjectAcl"
// Version-specific ACL operations (?acl&versionId)
S3_ACTION_GET_OBJECT_VERSION_ACL = "s3:GetObjectVersionAcl"
S3_ACTION_PUT_OBJECT_VERSION_ACL = "s3:PutObjectVersionAcl"
// Object tagging operations
S3_ACTION_GET_OBJECT_TAGGING = "s3:GetObjectTagging"
S3_ACTION_PUT_OBJECT_TAGGING = "s3:PutObjectTagging"
+94
View File
@@ -576,3 +576,97 @@ func TestRecheckObjectAclTagConditionedPolicy(t *testing.T) {
assert.Equal(t, s3err.ErrNone,
s3a.recheckPolicyWithObjectEntry(putReq, "b", "secret.txt", s3_constants.S3_ACTION_PUT_OBJECT_ACL, publicEntry, "test"))
}
// A versioned GetObjectAttributes request is authorized against the version
// read grant, not the attributes action the original request resolves to: an
// explicit bucket-policy Deny on s3:GetObjectVersion blocks it, while a legacy
// identity holding only the coarse Read grant is still accepted.
func TestCheckVersionReadGrant(t *testing.T) {
s3a := &S3ApiServer{
policyEngine: NewBucketPolicyEngine(),
iam: &IdentityAccessManagement{},
}
denyPolicyJSON := `{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": "*",
"Action": "s3:*",
"Resource": ["arn:aws:s3:::deny-bucket", "arn:aws:s3:::deny-bucket/*"]
},
{
"Effect": "Deny",
"Principal": "*",
"Action": "s3:GetObjectVersion",
"Resource": "arn:aws:s3:::deny-bucket/key"
}
]
}`
require.NoError(t, s3a.policyEngine.engine.SetBucketPolicy("deny-bucket", denyPolicyJSON))
allowPolicyJSON := `{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": "*",
"Action": "s3:*",
"Resource": ["arn:aws:s3:::allow-bucket", "arn:aws:s3:::allow-bucket/*"]
}
]
}`
require.NoError(t, s3a.policyEngine.engine.SetBucketPolicy("allow-bucket", allowPolicyJSON))
identity := &Identity{
Name: "reader",
Actions: []Action{s3_constants.ACTION_READ},
PrincipalArn: "arn:aws:iam::000000000000:user/reader",
}
attrsReq := func(bucket string) *http.Request {
req := httptest.NewRequest(http.MethodGet, "/"+bucket+"/key?attributes&versionId=v1", nil)
return req.WithContext(s3_constants.SetIdentityInContext(req.Context(), identity))
}
assert.Equal(t, s3err.ErrAccessDenied,
s3a.checkVersionReadGrant(attrsReq("deny-bucket"), "deny-bucket", "key", "v1", nil))
assert.Equal(t, s3err.ErrNone,
s3a.checkVersionReadGrant(attrsReq("allow-bucket"), "allow-bucket", "key", "v1", nil))
assert.Equal(t, s3err.ErrNone,
s3a.checkVersionReadGrant(attrsReq("no-policy-bucket"), "no-policy-bucket", "key", "v1", nil))
}
// Presigned-URL credentials ride along on the synthesized version-read
// request, so an s3:authType=REST-QUERY-STRING policy condition decides the
// recheck the same way it decided the real request.
func TestCheckVersionReadGrantPreservesQueryAuth(t *testing.T) {
s3a := &S3ApiServer{
policyEngine: NewBucketPolicyEngine(),
iam: &IdentityAccessManagement{},
}
policyJSON := `{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": "*",
"Action": "s3:*",
"Resource": ["arn:aws:s3:::qs-bucket", "arn:aws:s3:::qs-bucket/*"],
"Condition": {"StringEquals": {"s3:authType": "REST-QUERY-STRING"}}
}]
}`
require.NoError(t, s3a.policyEngine.engine.SetBucketPolicy("qs-bucket", policyJSON))
presigned := httptest.NewRequest(http.MethodGet,
"/qs-bucket/key?attributes&versionId=v1&AWSAccessKeyId=AK&Signature=sig&Expires=9999999999", nil)
assert.Equal(t, s3err.ErrNone,
s3a.checkVersionReadGrant(presigned, "qs-bucket", "key", "v1", nil))
keyReq := objectKeyPolicyRequest(presigned, http.MethodGet, "qs-bucket", "key", "v1")
q := keyReq.URL.Query()
assert.Equal(t, "AK", q.Get("AWSAccessKeyId"))
assert.Equal(t, "v1", q.Get("versionId"))
assert.False(t, q.Has("attributes"))
}
+9 -43
View File
@@ -2594,53 +2594,19 @@ func (e *EmbeddedIamApi) ListGroupPolicies(s3cfg *iam_pb.S3ApiConfiguration, val
return resp, nil
}
// handleImplicitUsername adds username who signs the request to values if 'username' is not specified.
// According to AWS documentation: "If you do not specify a user name, IAM determines the user name
// implicitly based on the Amazon Web Services access key ID signing the request."
// This function extracts the AccessKeyId from the SigV4 credential and looks up the corresponding
// identity name in the credential store.
// handleImplicitUsername adds the name of the authenticated caller to values
// when 'UserName' is not specified, per AWS: "If you do not specify a user name,
// IAM determines the user name implicitly based on the Amazon Web Services
// access key ID signing the request." The name comes from the identity the
// authentication middleware verified, which the request context carries; the
// Authorization header itself is attacker-controlled and is never re-parsed.
func (e *EmbeddedIamApi) handleImplicitUsername(r *http.Request, values url.Values) {
if len(r.Header["Authorization"]) == 0 || values.Get("UserName") != "" {
if values.Get("UserName") != "" {
return
}
// Log presence of auth header without exposing sensitive signature material
glog.V(4).Infof("Authorization header present, extracting access key")
// Parse AWS SigV4 Authorization header format:
// "AWS4-HMAC-SHA256 Credential=AKIAIOSFODNN7EXAMPLE/20130524/us-east-1/iam/aws4_request, ..."
s := strings.Split(r.Header["Authorization"][0], "Credential=")
if len(s) < 2 {
return
if name := s3_constants.GetIdentityNameFromContext(r); name != "" {
values.Set("UserName", name)
}
s = strings.Split(s[1], ",")
if len(s) < 1 {
return
}
s = strings.Split(s[0], "/")
if len(s) < 1 {
return
}
// s[0] is the AccessKeyId
accessKeyId := s[0]
if accessKeyId == "" {
return
}
// Nil-guard: ensure iam is initialized before lookup
if e.iam == nil {
glog.V(4).Infof("IAM not initialized, cannot look up access key")
return
}
// Look up the identity by access key to get the username
identity, _, found := e.iam.LookupByAccessKey(accessKeyId)
if !found {
// Mask access key in logs - show only first 4 chars
maskedKey := accessKeyId
if len(accessKeyId) > 4 {
maskedKey = accessKeyId[:4] + "***"
}
glog.V(4).Infof("Access key %s not found in credential store", maskedKey)
return
}
values.Set("UserName", identity.Name)
}
// iamSelfServiceActions are actions that users can perform on their own resources without admin rights.
+17 -31
View File
@@ -372,13 +372,13 @@ func TestEmbeddedIamGetUserImplicitUsername(t *testing.T) {
t.Fatalf("failed to load iam config: %v", err)
}
// GetUser request with NO UserName, but signed by accessKey.
// GetUser request with NO UserName, carrying the identity authentication
// verified for accessKey, the way the middleware records it.
form := url.Values{}
form.Set("Action", "GetUser")
req, _ := http.NewRequest(http.MethodPost, "/", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Authorization", "AWS4-HMAC-SHA256 Credential="+accessKey+
"/20220420/us-east-1/iam/aws4_request, SignedHeaders=content-type;host;x-amz-date, Signature=fakesig")
req = req.WithContext(SetIdentityNameInContext(req.Context(), "TestUser"))
out := iamGetUserResponse{}
rr, err := executeEmbeddedIamRequest(api, req, &out)
@@ -1578,29 +1578,15 @@ func TestEmbeddedIamDeleteAccessKey(t *testing.T) {
assert.Len(t, api.mockConfig.Identities[0].Credentials, 0)
}
// TestEmbeddedIamHandleImplicitUsername tests implicit username extraction from authorization header
// TestEmbeddedIamHandleImplicitUsername checks that the implicit user name is
// taken from the authenticated identity in the request context, never from
// re-parsing the Authorization header.
func TestEmbeddedIamHandleImplicitUsername(t *testing.T) {
// Create IAM with test credentials - the handleImplicitUsername function now looks
// up the username from the credential store based on AccessKeyId
// Note: Using obviously fake access keys to avoid secret scanner false positives
iam := &IdentityAccessManagement{}
testConfig := &iam_pb.S3ApiConfiguration{
Identities: []*iam_pb.Identity{
{
Name: "testuser1",
Credentials: []*iam_pb.Credential{
{AccessKey: UserAccessKeyPrefix + "TESTFAKEKEY000001", SecretKey: "testsecretfake"},
},
},
},
}
err := iam.LoadS3ApiConfigurationFromBytes(mustMarshalJSON(testConfig))
if err != nil {
t.Fatalf("Failed to load test config: %v", err)
}
embeddedApi := &EmbeddedIamApi{}
embeddedApi := &EmbeddedIamApi{
iam: iam,
withIdentity := func(name string) *http.Request {
r := &http.Request{Header: http.Header{}}
return r.WithContext(SetIdentityNameInContext(r.Context(), name))
}
var tests = []struct {
@@ -1608,14 +1594,14 @@ func TestEmbeddedIamHandleImplicitUsername(t *testing.T) {
values url.Values
userName string
}{
// No authorization header - should not set username
// No authenticated identity - should not set username
{&http.Request{}, url.Values{}, ""},
// Valid auth header with known access key - should look up and find "testuser1"
{&http.Request{Header: http.Header{"Authorization": []string{"AWS4-HMAC-SHA256 Credential=" + UserAccessKeyPrefix + "TESTFAKEKEY000001/20220420/us-east-1/iam/aws4_request, SignedHeaders=content-type;host;x-amz-date, Signature=fakesignature0123456789abcdef"}}}, url.Values{}, "testuser1"},
// Malformed auth header (no Credential=) - should not set username
{&http.Request{Header: http.Header{"Authorization": []string{"AWS4-HMAC-SHA256 =" + UserAccessKeyPrefix + "TESTFAKEKEY000001/20220420/test1/iam/aws4_request, SignedHeaders=content-type;host;x-amz-date, Signature=fakesignature0123456789abcdef"}}}, url.Values{}, ""},
// Unknown access key - should not set username
{&http.Request{Header: http.Header{"Authorization": []string{"AWS4-HMAC-SHA256 Credential=" + UserAccessKeyPrefix + "TESTUNKNOWN000000/20220420/us-east-1/iam/aws4_request, SignedHeaders=content-type;host;x-amz-date, Signature=fakesignature0123456789abcdef"}}}, url.Values{}, ""},
// Authenticated identity in context - sets its name
{withIdentity("testuser1"), url.Values{}, "testuser1"},
// A client-controlled Authorization header naming another user is ignored
{&http.Request{Header: http.Header{"Authorization": []string{"AWS4-HMAC-SHA256 Credential=" + UserAccessKeyPrefix + "TESTFAKEKEY000001/20220420/us-east-1/iam/aws4_request, SignedHeaders=content-type;host;x-amz-date, Signature=fakesignature0123456789abcdef"}}}, url.Values{}, ""},
// An explicit UserName is left alone
{withIdentity("testuser1"), url.Values{"UserName": {"other"}}, "other"},
}
for i, test := range tests {
+35 -4
View File
@@ -181,8 +181,10 @@ func (s3a *S3ApiServer) parseAndValidateRange(w http.ResponseWriter, r *http.Req
return 0, totalSize, false, nil
}
// Empty directory: 404. A file promoted to a directory keeps its data and stays retrievable.
if entry.IsDirectory && totalSize == 0 {
// Empty directory: 404. A file promoted to a directory keeps its data and
// stays retrievable, and a stored directory-key marker is an object, not a
// missing key — an unsatisfiable range answers 416.
if entry.IsDirectory && totalSize == 0 && !entry.IsDirectoryKeyObject() {
s3err.WriteErrorResponse(w, r, s3err.ErrNoSuchKey)
return 0, 0, false, newStreamErrorWithResponse(fmt.Errorf("directory object %s/%s cannot be retrieved", bucket, object))
}
@@ -533,6 +535,24 @@ func (s3a *S3ApiServer) serveDirectoryContent(w http.ResponseWriter, r *http.Req
if r.Method == http.MethodHead && len(entry.Chunks) > 0 {
contentLength = int64(entry.Attributes.FileSize)
}
statusCode := http.StatusOK
if r.Header.Get("Range") != "" && requestedPartNumber(r) == 0 {
// A ranged request answers like the object path does: 206 with its
// Content-Range and the range's length, served on GET, reported on HEAD.
totalSize := contentLength
offset, size, isRangeRequest, rangeErr := s3a.parseAndValidateRange(w, r, entry, totalSize, bucket, object)
if rangeErr != nil {
return
}
if isRangeRequest {
contentLength = size
w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", offset, offset+size-1, totalSize))
statusCode = http.StatusPartialContent
if r.Method != http.MethodHead {
content = content[offset : offset+size]
}
}
}
w.Header().Set("Content-Length", strconv.FormatInt(contentLength, 10))
// Set last modified
@@ -545,12 +565,12 @@ func (s3a *S3ApiServer) serveDirectoryContent(w http.ResponseWriter, r *http.Req
// For HEAD requests, don't write body
if r.Method == http.MethodHead {
w.WriteHeader(http.StatusOK)
w.WriteHeader(statusCode)
return
}
// Write content
w.WriteHeader(http.StatusOK)
w.WriteHeader(statusCode)
if len(content) > 0 {
if _, err := w.Write(content); err != nil {
glog.Errorf("serveDirectoryContent: failed to write response: %v", err)
@@ -2745,6 +2765,17 @@ func (s3a *S3ApiServer) HeadObjectHandler(w http.ResponseWriter, r *http.Request
w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", startOffset, endOffset, totalSize))
statusCode = http.StatusPartialContent
glog.V(3).Infof("HeadObject: part %d is bytes %d-%d/%d", partNumber, startOffset, endOffset, totalSize)
} else if r.Header.Get("Range") != "" {
// A ranged HEAD reports the range like GET does: 206 with its Content-Range and length
offset, size, isRangeRequest, rangeErr := s3a.parseAndValidateRange(w, r, objectEntryForSSE, totalSize, bucket, object)
if rangeErr != nil {
return
}
if isRangeRequest {
responseSize = size
w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", offset, offset+size-1, totalSize))
statusCode = http.StatusPartialContent
}
}
s3a.setResponseHeaders(w, r, objectEntryForSSE, responseSize)
+18 -2
View File
@@ -30,6 +30,10 @@ func (s3a *S3ApiServer) GetObjectAclHandler(w http.ResponseWriter, r *http.Reque
// Check for specific version ID in query parameters
versionId := r.URL.Query().Get("versionId")
if errCode := s3a.checkVersionIdConfigured(bucket, versionId); errCode != s3err.ErrNone {
s3err.WriteErrorResponse(w, r, errCode)
return
}
// Check if versioning is configured for the bucket (Enabled or Suspended)
versioningConfigured, err := s3a.isVersioningConfigured(bucket)
@@ -89,7 +93,11 @@ func (s3a *S3ApiServer) GetObjectAclHandler(w http.ResponseWriter, r *http.Reque
return
}
if errCode := s3a.recheckPolicyWithObjectEntry(r, bucket, object, s3_constants.S3_ACTION_GET_OBJECT_ACL, entry.Extended, "GetObjectAclHandler"); errCode != s3err.ErrNone {
action := s3_constants.S3_ACTION_GET_OBJECT_ACL
if versionId != "" {
action = s3_constants.S3_ACTION_GET_OBJECT_VERSION_ACL
}
if errCode := s3a.recheckPolicyWithObjectEntry(r, bucket, object, action, entry.Extended, "GetObjectAclHandler"); errCode != s3err.ErrNone {
s3err.WriteErrorResponse(w, r, errCode)
return
}
@@ -142,6 +150,10 @@ func (s3a *S3ApiServer) PutObjectAclHandler(w http.ResponseWriter, r *http.Reque
// Check for specific version ID in query parameters
versionId := r.URL.Query().Get("versionId")
if errCode := s3a.checkVersionIdConfigured(bucket, versionId); errCode != s3err.ErrNone {
s3err.WriteErrorResponse(w, r, errCode)
return
}
// Check if versioning is configured for the bucket (Enabled or Suspended)
versioningConfigured, err := s3a.isVersioningConfigured(bucket)
@@ -201,7 +213,11 @@ func (s3a *S3ApiServer) PutObjectAclHandler(w http.ResponseWriter, r *http.Reque
return
}
if errCode := s3a.recheckPolicyWithObjectEntry(r, bucket, object, s3_constants.S3_ACTION_PUT_OBJECT_ACL, entry.Extended, "PutObjectAclHandler"); errCode != s3err.ErrNone {
putAction := s3_constants.S3_ACTION_PUT_OBJECT_ACL
if versionId != "" {
putAction = s3_constants.S3_ACTION_PUT_OBJECT_VERSION_ACL
}
if errCode := s3a.recheckPolicyWithObjectEntry(r, bucket, object, putAction, entry.Extended, "PutObjectAclHandler"); errCode != s3err.ErrNone {
s3err.WriteErrorResponse(w, r, errCode)
return
}
+18 -1
View File
@@ -129,6 +129,10 @@ func (s3a *S3ApiServer) GetObjectAttributesHandler(w http.ResponseWriter, r *htt
// Check for specific version ID
versionId := r.URL.Query().Get("versionId")
if errCode := s3a.checkVersionIdConfigured(bucket, versionId); errCode != s3err.ErrNone {
s3err.WriteErrorResponse(w, r, errCode)
return
}
var (
entry *filer_pb.Entry
@@ -219,11 +223,24 @@ func (s3a *S3ApiServer) GetObjectAttributesHandler(w http.ResponseWriter, r *htt
}
}
if errCode := s3a.recheckPolicyWithObjectEntry(r, bucket, object, s3_constants.S3_ACTION_GET_OBJECT_ATTRIBUTES, entry.Extended, "GetObjectAttributesHandler"); errCode != s3err.ErrNone {
action := s3_constants.S3_ACTION_GET_OBJECT_ATTRIBUTES
if versionId != "" {
action = s3_constants.S3_ACTION_GET_OBJECT_VERSION_ATTRIBUTES
}
if errCode := s3a.recheckPolicyWithObjectEntry(r, bucket, object, action, entry.Extended, "GetObjectAttributesHandler"); errCode != s3err.ErrNone {
s3err.WriteErrorResponse(w, r, errCode)
return
}
// AWS also asks for the version read grant on a versioned attributes
// request: s3:GetObjectVersionAttributes alone is not sufficient.
if versionId != "" {
if errCode := s3a.checkVersionReadGrant(r, bucket, object, versionId, entry.Extended); errCode != s3err.ErrNone {
s3err.WriteErrorResponse(w, r, errCode)
return
}
}
// Evaluate conditional headers against the resolved entry (after version resolution)
// This ensures conditions are checked against the correct version, not always the latest
if s3a.hasConditionalHeaders(r) {
+142 -8
View File
@@ -2,17 +2,21 @@ package s3api
import (
"bytes"
"cmp"
"context"
"crypto/md5"
"crypto/rand"
"encoding/base64"
"encoding/xml"
"errors"
"fmt"
"hash"
"io"
"net/http"
"net/url"
"os"
"reflect"
"slices"
"strconv"
"strings"
"time"
@@ -1146,7 +1150,9 @@ func (s3a *S3ApiServer) CopyObjectPartHandler(w http.ResponseWriter, r *http.Req
return
}
if uploadEntryHasSSE(uploadEntry) || sourceEntryIsEncrypted(entry) || uploadEntryHasChecksum(uploadEntry) {
// Inline-Content sources have no chunks for the raw-byte path to copy;
// the plaintext stream serves their range instead.
if uploadEntryHasSSE(uploadEntry) || sourceEntryIsEncrypted(entry) || uploadEntryHasChecksum(uploadEntry) || len(entry.Content) > 0 {
etag, sseMetadata, errCode := s3a.copyObjectPartViaReencryption(r, entry, startOffset, endOffset, dstBucket, dstObject, uploadID, partID, uploadEntry)
if errCode != s3err.ErrNone {
s3err.WriteErrorResponse(w, r, errCode)
@@ -1204,15 +1210,24 @@ func (s3a *S3ApiServer) CopyObjectPartHandler(w http.ResponseWriter, r *http.Req
// For zero-size files or invalid ranges, create an empty part with size 0
dstEntry.Attributes.FileSize = 0
dstEntry.Chunks = nil
dstEntry.Attributes.Md5 = md5.New().Sum(nil)
} else {
// Copy chunks that overlap with the range
dstChunks, err := s3a.copyChunksForRange(entry, startOffset, endOffset, dstAssignPath)
dstChunks, partMd5, err := s3a.copyChunksForRange(entry, startOffset, endOffset, dstAssignPath)
if err != nil {
glog.Errorf("CopyObjectPartHandler copy chunks error: %v", err)
s3err.WriteErrorResponse(w, r, s3a.mapCopyErrorToS3Error(err))
return
}
dstEntry.Chunks = dstChunks
dstEntry.Attributes.Md5 = partMd5
}
// Mirror putToFiler: the part entry carries the MD5 of its bytes so
// CopyPartResult, ListParts and complete's composite ETag all describe
// the copied range rather than the source chunks.
if len(dstEntry.Attributes.Md5) > 0 {
dstEntry.Extended[s3_constants.ExtETagKey] = []byte(fmt.Sprintf("%x", dstEntry.Attributes.Md5))
}
// Save the part entry to the multipart uploads folder
@@ -1479,7 +1494,7 @@ func (s3a *S3ApiServer) copySingleChunk(chunk *filer_pb.FileChunk, dstPath strin
// the source volume for compressed bytes, so a gzipped chunk is
// forwarded to the destination without anyone having to decompress.
if canStreamCopyChunk(chunk) {
if err := s3a.streamCopyChunkRange(context.Background(), srcUrl, fileId, 0, int64(chunk.Size), true /*isFullChunk*/, assignResult); err != nil {
if err := s3a.streamCopyChunkRange(context.Background(), srcUrl, fileId, 0, int64(chunk.Size), true /*isFullChunk*/, assignResult, nil); err != nil {
return nil, fmt.Errorf("stream chunk: %w", err)
}
return dstChunk, nil
@@ -1534,9 +1549,22 @@ func (s3a *S3ApiServer) copySingleChunkForRange(originalChunk, rangeChunk *filer
// part-size = chunk-size assemble pattern hits this branch.
if canStreamCopyChunk(originalChunk) {
isFullChunk := offsetInChunk == 0 && rangeChunk.Size == originalChunk.Size
if err := s3a.streamCopyChunkRange(context.Background(), srcUrl, fileId, offsetInChunk, int64(rangeChunk.Size), isFullChunk, assignResult); err != nil {
// A partial range stores different bytes than the source chunk, so
// its ETag has to be recomputed; a whole-chunk copy reuses it.
var digest hash.Hash
if !isFullChunk {
digest = md5.New()
}
if err := s3a.streamCopyChunkRange(context.Background(), srcUrl, fileId, offsetInChunk, int64(rangeChunk.Size), isFullChunk, assignResult, digest); err != nil {
return nil, fmt.Errorf("stream chunk range: %w", err)
}
if digest != nil {
dstChunk.ETag = base64.StdEncoding.EncodeToString(digest.Sum(nil))
// The range fetch returns raw bytes and the destination stores
// them uncompressed, so the source chunk's gzip flag must not
// carry over onto a partial copy.
dstChunk.IsCompressed = false
}
return dstChunk, nil
}
@@ -1609,8 +1637,16 @@ func parseRangeHeader(rangeHeader string, fileSize int64) (startOffset, endOffse
return startOffset, endOffset, nil
}
// copyChunksForRange copies chunks that overlap with the specified range
func (s3a *S3ApiServer) copyChunksForRange(entry *filer_pb.Entry, startOffset, endOffset int64, dstPath string) ([]*filer_pb.FileChunk, error) {
// partMd5Result carries the whole-part MD5 the copiedRangeMd5 goroutine
// computes beside the per-chunk copies.
type partMd5Result struct {
sum []byte
err error
}
// copyChunksForRange copies chunks that overlap with the specified range and
// returns the MD5 of the copied bytes for the part entry's Attributes.Md5.
func (s3a *S3ApiServer) copyChunksForRange(entry *filer_pb.Entry, startOffset, endOffset int64, dstPath string) ([]*filer_pb.FileChunk, []byte, error) {
var relevantChunks []*filer_pb.FileChunk
var originalChunks []*filer_pb.FileChunk
@@ -1641,6 +1677,36 @@ func (s3a *S3ApiServer) copyChunksForRange(entry *filer_pb.Entry, startOffset, e
}
}
// The part's Attributes.Md5 must cover the copied range in byte order,
// and md5 cannot combine per-chunk digests. A single chunk's own digest
// is the answer only when it supplies the whole range; anything else —
// several chunks, or gaps a sparse source zero-fills — needs an ordered
// pass the concurrent copies cannot feed, so a second stream of the
// source range hashes beside them.
partSize := endOffset - startOffset + 1
singleChunkCovers := len(relevantChunks) == 1 &&
relevantChunks[0].Offset == 0 && int64(relevantChunks[0].Size) == partSize
md5Ctx, cancelMd5 := context.WithCancel(context.Background())
defer cancelMd5()
var partMd5 []byte
var partMd5Ch chan partMd5Result
switch {
case singleChunkCovers:
// resolved below from the copied chunk's own digest
case len(entry.GetChunks()) == 0:
// no chunks to read at all: the part is partSize zero bytes (the
// stream helper returns empty for a chunkless entry, not zero-fill)
partMd5 = md5Zeros(partSize)
default:
partMd5Ch = make(chan partMd5Result, 1)
go func() {
sum, err := s3a.copiedRangeMd5(md5Ctx, entry, startOffset, endOffset)
partMd5Ch <- partMd5Result{sum, err}
}()
}
// Copy the relevant chunks using a specialized method for range copies
dstChunks := make([]*filer_pb.FileChunk, len(relevantChunks))
const defaultChunkCopyConcurrency = 4
@@ -1664,11 +1730,79 @@ func (s3a *S3ApiServer) copyChunksForRange(entry *filer_pb.Entry, startOffset, e
// Wait for all operations to complete and check for errors
for i := 0; i < len(relevantChunks); i++ {
if err := <-errChan; err != nil {
return nil, err
return nil, nil, err
}
}
return dstChunks, nil
// Reads resolve overlapping chunks by ModifiedTsNs, and the concurrent
// copies stamped arbitrary finish times: rank the destination chunks by
// the source's timestamps so the part resolves overlaps the way the
// source did (and the way copiedRangeMd5 hashed it).
if len(dstChunks) > 1 {
order := make([]int, len(dstChunks))
for i := range order {
order[i] = i
}
slices.SortStableFunc(order, func(a, b int) int {
return cmp.Compare(originalChunks[a].ModifiedTsNs, originalChunks[b].ModifiedTsNs)
})
now := time.Now().UnixNano()
for rank, i := range order {
dstChunks[i].ModifiedTsNs = now + int64(rank)
}
}
if partMd5Ch != nil {
if res := <-partMd5Ch; res.err != nil {
return nil, nil, fmt.Errorf("md5 of copied range: %w", res.err)
} else {
partMd5 = res.sum
}
} else if singleChunkCovers {
if partMd5 = util.Base64Md5ToBytes(dstChunks[0].ETag); len(partMd5) != md5.Size {
// TUS and mount writes leave chunks without an ETag; hash the
// copied range when the inherited digest is missing or malformed.
sum, err := s3a.copiedRangeMd5(md5Ctx, entry, startOffset, endOffset)
if err != nil {
return nil, nil, fmt.Errorf("md5 of copied range: %w", err)
}
partMd5 = sum
}
}
return dstChunks, partMd5, nil
}
// md5Zeros hashes size zero bytes without a volume read — a copied range
// that overlaps no source chunk assembles to all zeros.
func md5Zeros(size int64) []byte {
h := md5.New()
var zeros [1 << 16]byte
for size > 0 {
n := min(size, int64(len(zeros)))
h.Write(zeros[:n])
size -= n
}
return h.Sum(nil)
}
// copiedRangeMd5 streams the source range once and hashes its plaintext —
// exactly the bytes the non-SSE part copy assembles on the destination.
func (s3a *S3ApiServer) copiedRangeMd5(ctx context.Context, entry *filer_pb.Entry, startOffset, endOffset int64) ([]byte, error) {
size := endOffset - startOffset + 1
// The copy walks the chunks, so the hash must too: on an entry that also
// carries inline Content the stream helper would prefer Content instead.
chunked := &filer_pb.Entry{Chunks: entry.GetChunks()}
reader, err := s3a.getEncryptedStreamFromVolumes(ctx, chunked, startOffset, size)
if err != nil {
return nil, err
}
defer reader.Close()
h := md5.New()
if _, err := io.Copy(h, reader); err != nil {
return nil, err
}
return h.Sum(nil), nil
}
// Helper methods for copy operations to avoid code duplication
@@ -160,7 +160,7 @@ func BenchmarkCopyChunk_Streamed(b *testing.B) {
for i := 0; i < b.N; i++ {
if err := s3a.streamCopyChunkRange(context.Background(),
env.srcSrv.URL, env.assign.FileId, 0, int64(size),
true /*isFullChunk*/, env.assign); err != nil {
true /*isFullChunk*/, env.assign, nil); err != nil {
b.Fatalf("stream: %v", err)
}
}
@@ -1,6 +1,11 @@
package s3api
import (
"bytes"
"crypto/md5"
"encoding/base64"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
@@ -9,6 +14,7 @@ import (
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
util_http "github.com/seaweedfs/seaweedfs/weed/util/http"
)
func TestCopyEntryETagPrefersStoredExtendedETag(t *testing.T) {
@@ -145,3 +151,292 @@ func TestParseRangeHeaderRejectsUnsatisfiableRange(t *testing.T) {
})
}
}
// A part copied with x-amz-copy-source-range must report the MD5 of the copied
// bytes: per destination chunk for a partial-chunk copy, and the whole-part MD5
// the part entry's Attributes.Md5 carries for CopyPartResult / ListParts /
// complete's composite ETag.
func TestCopyChunksForRangeCopiedETags(t *testing.T) {
volume := startFakeVolumeServer(t)
filerImpl := &ambiguousPutFiler{volume: volume, entries: map[string]*filer_pb.Entry{}}
s3a := newPutTestServer(t, startFakeFiler(t, filerImpl))
util_http.InitGlobalHttpClient()
const chunkSize = 4 << 20
payload := make([]byte, 2*chunkSize)
for i := range payload {
payload[i] = byte(i*31 + 7)
}
b64md5 := func(data []byte) string {
sum := md5.Sum(data)
return base64.StdEncoding.EncodeToString(sum[:])
}
fid0 := fmt.Sprintf("3,%016x%08x", 101, 202)
fid1 := fmt.Sprintf("3,%016x%08x", 103, 204)
volume.stored[fid0] = payload[:chunkSize]
volume.stored[fid1] = payload[chunkSize:]
entry := &filer_pb.Entry{
Name: "src",
Attributes: &filer_pb.FuseAttributes{
FileSize: uint64(len(payload)),
},
Chunks: []*filer_pb.FileChunk{
{FileId: fid0, Offset: 0, Size: chunkSize, ETag: b64md5(payload[:chunkSize])},
{FileId: fid1, Offset: chunkSize, Size: chunkSize, ETag: b64md5(payload[chunkSize:])},
},
}
// A range spanning the first chunk fully plus 1 MiB of the second.
multiEnd := int64(chunkSize + 1<<20 - 1)
dstChunks, partMd5, err := s3a.copyChunksForRange(entry, 0, multiEnd, "/dst")
if err != nil {
t.Fatalf("copyChunksForRange: %v", err)
}
if len(dstChunks) != 2 {
t.Fatalf("copyChunksForRange returned %d chunks, want 2", len(dstChunks))
}
// A whole-chunk copy forwards the stored bytes, so the source ETag stays valid.
if got, want := dstChunks[0].ETag, entry.Chunks[0].ETag; got != want {
t.Errorf("whole-chunk copy ETag = %q, want source chunk ETag %q", got, want)
}
// A partial copy stores only the range bytes; its ETag must be their MD5.
if got, want := dstChunks[1].ETag, b64md5(payload[chunkSize:multiEnd+1]); got != want {
t.Errorf("partial-chunk copy ETag = %q, want copied-bytes MD5 %q", got, want)
}
// The copied bytes landed on the destination volume (inside the multipart framing).
if stored := volume.stored[dstChunks[1].GetFileIdString()]; !bytes.Contains(stored, payload[chunkSize:multiEnd+1]) {
t.Error("destination volume does not hold the copied range bytes")
}
// The whole-part MD5 hashes every copied byte once, in range order.
wantPartMd5 := md5.Sum(payload[:multiEnd+1])
if !bytes.Equal(partMd5, wantPartMd5[:]) {
t.Errorf("part Md5 = %x, want %x", partMd5, wantPartMd5)
}
partEntry := &filer_pb.Entry{
Attributes: &filer_pb.FuseAttributes{Md5: partMd5},
Chunks: dstChunks,
}
if got, want := copyEntryETag(partEntry), fmt.Sprintf("%x", wantPartMd5); got != want {
t.Errorf("copyEntryETag = %q, want %q", got, want)
}
// A range inside one chunk: the part's ETag is the MD5 of those bytes.
innerChunks, innerMd5, err := s3a.copyChunksForRange(entry, 100, 199, "/dst")
if err != nil {
t.Fatalf("copyChunksForRange inner range: %v", err)
}
if len(innerChunks) != 1 {
t.Fatalf("inner range returned %d chunks, want 1", len(innerChunks))
}
if got, want := innerChunks[0].ETag, b64md5(payload[100:200]); got != want {
t.Errorf("inner-range chunk ETag = %q, want copied-bytes MD5 %q", got, want)
}
wantInnerMd5 := md5.Sum(payload[100:200])
if !bytes.Equal(innerMd5, wantInnerMd5[:]) {
t.Errorf("inner-range part Md5 = %x, want %x", innerMd5, wantInnerMd5)
}
}
// A range over a sparse source zero-fills the gaps, so the part MD5 has to
// hash the logical range — not just the one chunk that overlaps it.
func TestCopyChunksForRangeSparseETags(t *testing.T) {
volume := startFakeVolumeServer(t)
filerImpl := &ambiguousPutFiler{volume: volume, entries: map[string]*filer_pb.Entry{}}
s3a := newPutTestServer(t, startFakeFiler(t, filerImpl))
util_http.InitGlobalHttpClient()
fid := fmt.Sprintf("3,%016x%08x", 105, 206)
volume.stored[fid] = []byte("abc")
sum := md5.Sum([]byte("abc"))
entry := &filer_pb.Entry{
Name: "sparse",
Attributes: &filer_pb.FuseAttributes{
FileSize: 6,
},
Chunks: []*filer_pb.FileChunk{
{FileId: fid, Offset: 3, Size: 3, ETag: base64.StdEncoding.EncodeToString(sum[:])},
},
}
// bytes 0-5 read as "\x00\x00\x00abc"; the part MD5 must cover the zeros too.
dstChunks, partMd5, err := s3a.copyChunksForRange(entry, 0, 5, "/dst")
if err != nil {
t.Fatalf("copyChunksForRange sparse: %v", err)
}
if len(dstChunks) != 1 || dstChunks[0].Offset != 3 {
t.Fatalf("sparse copy chunks = %+v, want one chunk at offset 3", dstChunks)
}
wantMd5 := md5.Sum([]byte("\x00\x00\x00abc"))
if !bytes.Equal(partMd5, wantMd5[:]) {
t.Errorf("sparse part Md5 = %x, want %x (zero-filled range)", partMd5, wantMd5)
}
// A range over a pure hole copies nothing but still reads as zeros.
holeChunks, holeMd5, err := s3a.copyChunksForRange(entry, 0, 2, "/dst")
if err != nil {
t.Fatalf("copyChunksForRange hole: %v", err)
}
if len(holeChunks) != 0 {
t.Fatalf("hole copy returned %d chunks, want 0", len(holeChunks))
}
wantHoleMd5 := md5.Sum([]byte{0, 0, 0})
if !bytes.Equal(holeMd5, wantHoleMd5[:]) {
t.Errorf("hole part Md5 = %x, want %x (all zeros)", holeMd5, wantHoleMd5)
}
}
// Destination chunks must resolve overlaps the way the source did: reads
// pick winners by ModifiedTsNs, so the copied chunks keep the source's
// precedence even though they were uploaded concurrently.
func TestCopyChunksForRangePreservesSourcePrecedence(t *testing.T) {
volume := startFakeVolumeServer(t)
filerImpl := &ambiguousPutFiler{volume: volume, entries: map[string]*filer_pb.Entry{}}
s3a := newPutTestServer(t, startFakeFiler(t, filerImpl))
util_http.InitGlobalHttpClient()
etagOf := func(b []byte) string {
sum := md5.Sum(b)
return base64.StdEncoding.EncodeToString(sum[:])
}
fidA := fmt.Sprintf("3,%016x%08x", 107, 208)
fidB := fmt.Sprintf("3,%016x%08x", 109, 210)
volume.stored[fidA] = []byte("aaaa")
volume.stored[fidB] = []byte("bbbb")
// A covers [0,4), B covers [2,6); B's range is copied into a chunk that
// overlaps A's copy on [2,4), and the source says A wins there.
entry := &filer_pb.Entry{
Name: "overlap",
Attributes: &filer_pb.FuseAttributes{FileSize: 6},
Chunks: []*filer_pb.FileChunk{
{FileId: fidA, Offset: 0, Size: 4, ModifiedTsNs: 20, ETag: etagOf([]byte("aaaa"))},
{FileId: fidB, Offset: 2, Size: 4, ModifiedTsNs: 10, ETag: etagOf([]byte("bbbb"))},
},
}
dstChunks, partMd5, err := s3a.copyChunksForRange(entry, 0, 5, "/dst")
if err != nil {
t.Fatalf("copyChunksForRange overlap: %v", err)
}
if len(dstChunks) != 2 {
t.Fatalf("overlap copy returned %d chunks, want 2", len(dstChunks))
}
if dstChunks[0].ModifiedTsNs <= dstChunks[1].ModifiedTsNs {
t.Errorf("destination precedence inverted: chunk from ts=20 got %d, chunk from ts=10 got %d",
dstChunks[0].ModifiedTsNs, dstChunks[1].ModifiedTsNs)
}
// The source resolves to "aaaa"+"bb"; the part MD5 must match that view.
wantMd5 := md5.Sum([]byte("aaaabb"))
if !bytes.Equal(partMd5, wantMd5[:]) {
t.Errorf("overlap part Md5 = %x, want %x", partMd5, wantMd5)
}
}
// An inline-Content source has no chunks for the raw-byte copy; the plaintext
// stream path must write the range's real bytes and tag the part with their
// MD5, not a zero-fill digest.
func TestCopyObjectPartInlineContent(t *testing.T) {
volume := startFakeVolumeServer(t)
filerImpl := &ambiguousPutFiler{volume: volume, apply: true, entries: map[string]*filer_pb.Entry{}}
s3a := newPutTestServer(t, startFakeFiler(t, filerImpl))
util_http.InitGlobalHttpClient()
content := []byte("inline-content")
entry := &filer_pb.Entry{
Name: "src",
Attributes: &filer_pb.FuseAttributes{FileSize: uint64(len(content))},
Content: content,
}
r := httptest.NewRequest(http.MethodPut, "/b/dst?partNumber=1&uploadId=u1", nil)
etag, _, code := s3a.copyObjectPartViaReencryption(r, entry, 2, 5, "b", "dst", "u1", 1, &filer_pb.Entry{})
if code != s3err.ErrNone {
t.Fatalf("copyObjectPartViaReencryption: %v", code)
}
wantMd5 := md5.Sum(content[2:6])
if got := strings.Trim(etag, `"`); got != fmt.Sprintf("%x", wantMd5) {
t.Errorf("etag = %q, want %x", got, wantMd5)
}
var partEntry *filer_pb.Entry
for key, e := range filerImpl.entries {
if strings.Contains(key, "u1") {
partEntry = e
}
}
if partEntry == nil || len(partEntry.Chunks) == 0 {
t.Fatalf("no part entry with chunks stored: %+v", filerImpl.entries)
}
found := false
for _, stored := range volume.stored {
if bytes.Contains(stored, content[2:6]) {
found = true
}
}
if !found {
t.Errorf("destination volume does not hold the copied content bytes")
}
}
// An empty part must carry the empty-body MD5 on the stored entry too, so
// ListParts and complete's composite ETag see the same digest CopyPartResult
// reported.
func TestWriteEmptyCopyPartStoresMd5(t *testing.T) {
volume := startFakeVolumeServer(t)
filerImpl := &ambiguousPutFiler{volume: volume, apply: true, entries: map[string]*filer_pb.Entry{}}
s3a := newPutTestServer(t, startFakeFiler(t, filerImpl))
util_http.InitGlobalHttpClient()
etag, code := s3a.writeEmptyCopyPart("b", "u1", 3)
if code != s3err.ErrNone {
t.Fatalf("writeEmptyCopyPart: %v", code)
}
const emptyMD5Hex = "d41d8cd98f00b204e9800998ecf8427e"
if etag != emptyMD5Hex {
t.Fatalf("writeEmptyCopyPart etag = %q, want %q", etag, emptyMD5Hex)
}
var partEntry *filer_pb.Entry
for key, e := range filerImpl.entries {
if strings.Contains(key, "u1") {
partEntry = e
}
}
if partEntry == nil {
t.Fatalf("no part entry stored: %+v", filerImpl.entries)
}
if got := string(partEntry.Extended[s3_constants.ExtETagKey]); got != emptyMD5Hex {
t.Errorf("stored ExtETagKey = %q, want %q", got, emptyMD5Hex)
}
if got := fmt.Sprintf("%x", partEntry.Attributes.Md5); got != emptyMD5Hex {
t.Errorf("stored Attributes.Md5 = %q, want %q", got, emptyMD5Hex)
}
}
// A whole-range chunk copy normally reuses the source chunk's ETag as the
// part MD5; TUS and mount writes leave chunks without one, so the part has
// to hash the copied bytes instead of saving an empty digest.
func TestCopyChunksForRangeMissingChunkETag(t *testing.T) {
volume := startFakeVolumeServer(t)
filerImpl := &ambiguousPutFiler{volume: volume, entries: map[string]*filer_pb.Entry{}}
s3a := newPutTestServer(t, startFakeFiler(t, filerImpl))
util_http.InitGlobalHttpClient()
fid := fmt.Sprintf("3,%016x%08x", 111, 212)
volume.stored[fid] = []byte("etagless")
entry := &filer_pb.Entry{
Name: "src",
Attributes: &filer_pb.FuseAttributes{FileSize: 8},
Chunks: []*filer_pb.FileChunk{
{FileId: fid, Offset: 0, Size: 8},
},
}
dstChunks, partMd5, err := s3a.copyChunksForRange(entry, 0, 7, "/dst")
if err != nil {
t.Fatalf("copyChunksForRange: %v", err)
}
if len(dstChunks) != 1 {
t.Fatalf("copyChunksForRange returned %d chunks, want 1", len(dstChunks))
}
wantMd5 := md5.Sum([]byte("etagless"))
if !bytes.Equal(partMd5, wantMd5[:]) {
t.Errorf("part Md5 = %x, want %x (hashed copied range)", partMd5, wantMd5)
}
}
@@ -2,6 +2,7 @@ package s3api
import (
"context"
"crypto/md5"
"encoding/base64"
"encoding/json"
"errors"
@@ -419,14 +420,19 @@ func (s3a *S3ApiServer) writeEmptyCopyPart(dstBucket, uploadID string, partID in
return "", s3err.ErrInternalError
}
}
const emptyMD5Hex = "d41d8cd98f00b204e9800998ecf8427e"
if err := s3a.mkFile(uploadDir, partName, nil, func(e *filer_pb.Entry) {
if e.Attributes == nil {
e.Attributes = &filer_pb.FuseAttributes{}
}
e.Attributes.FileSize = 0
e.Attributes.Md5 = md5.New().Sum(nil)
if e.Extended == nil {
e.Extended = map[string][]byte{}
}
e.Extended[s3_constants.ExtETagKey] = []byte(emptyMD5Hex)
}); err != nil {
return "", s3err.ErrInternalError
}
const emptyMD5Hex = "d41d8cd98f00b204e9800998ecf8427e"
return emptyMD5Hex, s3err.ErrNone
}
@@ -73,12 +73,19 @@ func canStreamCopyChunk(chunk *filer_pb.FileChunk) bool {
// Trusting a hint that disagrees with the wire bytes (e.g. caller says
// "compressed" but the volume returned raw bytes for a partial range)
// would label raw bytes as gzip and corrupt reads on the destination.
//
// digestWriter, when non-nil, receives the plaintext bytes as they are
// forwarded so the caller can derive the destination chunk's ETag. It is
// not fed when the wire bytes are gzip — only possible on the full-chunk
// pass-through, whose logical content still matches the source chunk's
// own ETag and needs no recomputation.
func (s3a *S3ApiServer) streamCopyChunkRange(
ctx context.Context,
srcUrl, srcFileId string,
offset, size int64,
isFullChunk bool,
assignResult *filer_pb.AssignVolumeResponse,
digestWriter io.Writer,
) error {
// Range header takes int64, but mirror the int32 bound from
// downloadChunkData so the two paths reject the same inputs.
@@ -166,7 +173,11 @@ func (s3a *S3ApiServer) streamCopyChunkRange(
return
}
if _, err := io.Copy(fw, srcResp.Body); err != nil {
src := io.Reader(srcResp.Body)
if digestWriter != nil && !bodyIsGzipped {
src = io.TeeReader(srcResp.Body, digestWriter)
}
if _, err := io.Copy(fw, src); err != nil {
if shouldLogStreamError(err) {
glog.V(2).Infof("stream copy %s offset=%d size=%d: %v",
srcUrl, offset, size, err)
@@ -87,7 +87,7 @@ func TestCopyChunksReadOnlyIsAccessDenied(t *testing.T) {
t.Fatalf("copyChunks err %v maps to %v, want %v", err, code, s3err.ErrAccessDenied)
}
_, err = s3a.copyChunksForRange(entry, 0, 7, "/buckets/b/dst")
_, _, err = s3a.copyChunksForRange(entry, 0, 7, "/buckets/b/dst")
if code := s3a.mapCopyErrorToS3Error(err); code != s3err.ErrAccessDenied {
t.Fatalf("copyChunksForRange err %v maps to %v, want %v", err, code, s3err.ErrAccessDenied)
}
+37 -5
View File
@@ -29,6 +29,10 @@ func (s3a *S3ApiServer) GetObjectTaggingHandler(w http.ResponseWriter, r *http.R
// Check for specific version ID in query parameters
versionId := r.URL.Query().Get("versionId")
if errCode := s3a.checkVersionIdConfigured(bucket, versionId); errCode != s3err.ErrNone {
s3err.WriteErrorResponse(w, r, errCode)
return
}
// Check if versioning is configured for the bucket (Enabled or Suspended)
versioningConfigured, err := s3a.isVersioningConfigured(bucket)
@@ -87,7 +91,11 @@ func (s3a *S3ApiServer) GetObjectTaggingHandler(w http.ResponseWriter, r *http.R
}
}
if errCode := s3a.recheckPolicyWithObjectEntry(r, bucket, object, s3_constants.S3_ACTION_GET_OBJECT_TAGGING, entry.Extended, "GetObjectTaggingHandler"); errCode != s3err.ErrNone {
action := s3_constants.S3_ACTION_GET_OBJECT_TAGGING
if versionId != "" {
action = s3_constants.S3_ACTION_GET_OBJECT_VERSION_TAGGING
}
if errCode := s3a.recheckPolicyWithObjectEntry(r, bucket, object, action, entry.Extended, "GetObjectTaggingHandler"); errCode != s3err.ErrNone {
s3err.WriteErrorResponse(w, r, errCode)
return
}
@@ -140,6 +148,10 @@ func (s3a *S3ApiServer) PutObjectTaggingHandler(w http.ResponseWriter, r *http.R
// Check for specific version ID in query parameters
versionId := r.URL.Query().Get("versionId")
if errCode := s3a.checkVersionIdConfigured(bucket, versionId); errCode != s3err.ErrNone {
s3err.WriteErrorResponse(w, r, errCode)
return
}
// Check if versioning is configured for the bucket (Enabled or Suspended)
versioningConfigured, err := s3a.isVersioningConfigured(bucket)
@@ -185,7 +197,11 @@ func (s3a *S3ApiServer) PutObjectTaggingHandler(w http.ResponseWriter, r *http.R
target := util.FullPath(fmt.Sprintf("%s/%s", s3a.bucketDir(bucket), object))
dir, name := target.DirAndName()
if errCode := s3a.recheckTagConditionedPolicyWithLookup(r, bucket, object, versionId, s3_constants.S3_ACTION_PUT_OBJECT_TAGGING, "PutObjectTaggingHandler"); errCode != s3err.ErrNone {
putAction := s3_constants.S3_ACTION_PUT_OBJECT_TAGGING
if versionId != "" {
putAction = s3_constants.S3_ACTION_PUT_OBJECT_VERSION_TAGGING
}
if errCode := s3a.recheckTagConditionedPolicyWithLookup(r, bucket, object, versionId, putAction, "PutObjectTaggingHandler"); errCode != s3err.ErrNone {
s3err.WriteErrorResponse(w, r, errCode)
return
}
@@ -206,7 +222,11 @@ func (s3a *S3ApiServer) PutObjectTaggingHandler(w http.ResponseWriter, r *http.R
return
}
if errCode := s3a.recheckPolicyWithObjectEntry(r, bucket, object, s3_constants.S3_ACTION_PUT_OBJECT_TAGGING, entry.Extended, "PutObjectTaggingHandler"); errCode != s3err.ErrNone {
putAction := s3_constants.S3_ACTION_PUT_OBJECT_TAGGING
if versionId != "" {
putAction = s3_constants.S3_ACTION_PUT_OBJECT_VERSION_TAGGING
}
if errCode := s3a.recheckPolicyWithObjectEntry(r, bucket, object, putAction, entry.Extended, "PutObjectTaggingHandler"); errCode != s3err.ErrNone {
s3err.WriteErrorResponse(w, r, errCode)
return
}
@@ -268,6 +288,10 @@ func (s3a *S3ApiServer) DeleteObjectTaggingHandler(w http.ResponseWriter, r *htt
// Check for specific version ID in query parameters
versionId := r.URL.Query().Get("versionId")
if errCode := s3a.checkVersionIdConfigured(bucket, versionId); errCode != s3err.ErrNone {
s3err.WriteErrorResponse(w, r, errCode)
return
}
// Check if versioning is configured for the bucket (Enabled or Suspended)
versioningConfigured, err := s3a.isVersioningConfigured(bucket)
@@ -313,7 +337,11 @@ func (s3a *S3ApiServer) DeleteObjectTaggingHandler(w http.ResponseWriter, r *htt
target := util.FullPath(fmt.Sprintf("%s/%s", s3a.bucketDir(bucket), object))
dir, name := target.DirAndName()
if errCode := s3a.recheckTagConditionedPolicyWithLookup(r, bucket, object, versionId, s3_constants.S3_ACTION_DELETE_OBJECT_TAGGING, "DeleteObjectTaggingHandler"); errCode != s3err.ErrNone {
deleteAction := s3_constants.S3_ACTION_DELETE_OBJECT_TAGGING
if versionId != "" {
deleteAction = s3_constants.S3_ACTION_DELETE_OBJECT_VERSION_TAGGING
}
if errCode := s3a.recheckTagConditionedPolicyWithLookup(r, bucket, object, versionId, deleteAction, "DeleteObjectTaggingHandler"); errCode != s3err.ErrNone {
s3err.WriteErrorResponse(w, r, errCode)
return
}
@@ -335,7 +363,11 @@ func (s3a *S3ApiServer) DeleteObjectTaggingHandler(w http.ResponseWriter, r *htt
return
}
if errCode := s3a.recheckPolicyWithObjectEntry(r, bucket, object, s3_constants.S3_ACTION_DELETE_OBJECT_TAGGING, entry.Extended, "DeleteObjectTaggingHandler"); errCode != s3err.ErrNone {
deleteAction := s3_constants.S3_ACTION_DELETE_OBJECT_TAGGING
if versionId != "" {
deleteAction = s3_constants.S3_ACTION_DELETE_OBJECT_VERSION_TAGGING
}
if errCode := s3a.recheckPolicyWithObjectEntry(r, bucket, object, deleteAction, entry.Extended, "DeleteObjectTaggingHandler"); errCode != s3err.ErrNone {
s3err.WriteErrorResponse(w, r, errCode)
return
}
+30
View File
@@ -40,3 +40,33 @@ func TestParseAndValidateRangeRejectsMalformedNumericOffsets(t *testing.T) {
})
}
}
func TestHeadObjectRange(t *testing.T) {
s3a, f := newAnonymousReadTestServer(t)
f.entries["/buckets/b/o"] = anonymousReadEntry(anonymousReadGrant("READ"))
for _, tc := range []struct {
rangeHeader, contentRange, contentLength string
code int
}{
{"", "", "11", http.StatusOK},
{"bytes=1-4", "bytes 1-4/11", "4", http.StatusPartialContent},
{"bytes=-3", "bytes 8-10/11", "3", http.StatusPartialContent},
{"bytes=5-", "bytes 5-10/11", "6", http.StatusPartialContent},
{"bytes=20-", "bytes */11", "", http.StatusRequestedRangeNotSatisfiable},
} {
headers := map[string]string{}
if tc.rangeHeader != "" {
headers["Range"] = tc.rangeHeader
}
rr := serveAnonymousRead(s3a, http.MethodHead, "o", "", headers)
if rr.Code != tc.code {
t.Errorf("Range %q: status %d, want %d", tc.rangeHeader, rr.Code, tc.code)
}
if got := rr.Header().Get("Content-Range"); got != tc.contentRange {
t.Errorf("Range %q: Content-Range %q, want %q", tc.rangeHeader, got, tc.contentRange)
}
if got := rr.Header().Get("Content-Length"); tc.contentLength != "" && got != tc.contentLength {
t.Errorf("Range %q: Content-Length %q, want %q", tc.rangeHeader, got, tc.contentLength)
}
}
}
+15
View File
@@ -678,6 +678,21 @@ func (s3a *S3ApiServer) recheckPolicyWithObjectEntry(r *http.Request, bucket, ob
return errCode
}
// checkVersionReadGrant enforces the version read grant AWS requires alongside
// a version-scoped action such as s3:GetObjectVersionAttributes. The check runs
// on a request shaped like a plain versioned GET so the s3:GetObjectVersion
// action resolves from the request instead of the caller's subresource, and a
// legacy Read grant still applies.
func (s3a *S3ApiServer) checkVersionReadGrant(r *http.Request, bucket, object, versionId string, objectEntry map[string][]byte) s3err.ErrorCode {
versionReadReq := objectKeyPolicyRequest(r, http.MethodGet, bucket, object, versionId)
if identity, ok := GetIdentityFromContext(r).(*Identity); ok && identity != nil {
if errCode := s3a.iam.VerifyActionPermission(versionReadReq, identity, ACTION_READ, bucket, object); errCode != s3err.ErrNone {
return errCode
}
}
return s3a.recheckPolicyWithObjectEntry(versionReadReq, bucket, object, ACTION_READ, objectEntry, "checkVersionReadGrant")
}
// recheckCopySourcePolicy repeats the source object's policy check once its
// entry is fetched, so conditions on the object's existing tags apply to a
// CopyObject / UploadPartCopy / RenameObject source the same way they do to a
+64
View File
@@ -459,6 +459,70 @@ func isAuthError(err error) bool {
return errors.As(err, &authErr) || errors.Is(err, ErrAccessDenied)
}
// authorizeAbsentTarget reports whether the caller may learn the target
// resolved to nothing, so a caller without a grant gets the same response a
// denied existing target would give. The bucket named by the target still
// applies whatever metadata and policy load; with nothing readable to
// evaluate, only an identity-level grant on the operation, the admin, or a
// default-allow deployment passes.
func (h *S3TablesHandler) authorizeAbsentTarget(r *http.Request, filerClient FilerClient, operation, bucketName, namespace, name, resourceARN string) bool {
principal := h.getAccountID(r)
if principal == "" {
return false
}
var bucketOwner, bucketPolicy string
var bucketTags map[string]string
if bucketName != "" {
bucketPath := GetTableBucketPath(bucketName)
_ = filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
if data, err := h.getExtendedAttribute(r.Context(), client, bucketPath, ExtendedKeyMetadata); err == nil {
var meta tableBucketMetadata
if json.Unmarshal(data, &meta) == nil {
bucketOwner = meta.OwnerAccountID
}
}
if policy, err := h.readPolicy(r, client, bucketPath); err == nil {
bucketPolicy = policy
}
bucketTags, _ = h.readTags(r.Context(), client, bucketPath)
return nil
})
}
bucketARN := h.generateTableBucketARN(bucketOwner, bucketName)
// Judge the target by the canonical ARN the surviving bucket resolves to:
// the request ARN's account and region are the caller's to choose and must
// not decide whether a policy Deny applies.
canonicalARN := resourceARN
if bucketOwner != "" {
if name != "" {
canonicalARN = h.generateTableARN(bucketOwner, bucketName, namespace+"/"+name)
} else {
canonicalARN = bucketARN
}
}
ctx := &PolicyContext{
TrustedIssuers: h.trustedIssuers(r),
IsLocalAccountID: h.localAccountIDs,
TableBucketName: bucketName,
Namespace: namespace,
TableName: name,
TableBucketTags: bucketTags,
IdentityActions: getIdentityActions(r),
DefaultAllow: h.defaultAllowFor(r),
}
if resourceARN != "" &&
matchesDenyStatement(operation, principal, bucketPolicy, canonicalARN, ctx) {
return false
}
if CheckTableOrBucketPermission(operation, principal, "", "", canonicalARN,
bucketOwner, bucketPolicy, bucketARN, ctx) {
return true
}
return bucketOwner == "" && checkPermission(operation, principal, "", "", resourceARN, ctx)
}
func (h *S3TablesHandler) readTags(ctx context.Context, client filer_pb.SeaweedFilerClient, path string) (map[string]string, error) {
data, err := h.getExtendedAttribute(ctx, client, path, ExtendedKeyTags)
if err != nil {
@@ -58,7 +58,7 @@ func (h *S3TablesHandler) handleGetTableBucket(w http.ResponseWriter, r *http.Re
})
if err != nil {
if errors.Is(err, filer_pb.ErrNotFound) {
if errors.Is(err, filer_pb.ErrNotFound) || errors.Is(err, ErrAttributeNotFound) {
h.writeError(w, http.StatusNotFound, ErrCodeNoSuchBucket, fmt.Sprintf("table bucket %s not found", bucketName))
} else {
h.writeError(w, http.StatusInternalServerError, ErrCodeInternalError, fmt.Sprintf("failed to get table bucket: %v", err))
@@ -3,6 +3,7 @@ package s3tables
import (
"context"
"encoding/json"
"fmt"
"net/http/httptest"
"testing"
@@ -211,3 +212,165 @@ func TestDeniedTableWritesDoNotRevealViews(t *testing.T) {
})
}
}
// A caller without a grant must get the same answer whether a list target
// exists or not, and whether the bucket behind it exists at all.
func TestAbsentListTargetsMatchDeniedAnswers(t *testing.T) {
existing, manager := startRenameManager(t)
missing := s3tablestest.Start(t)
manager.SetTrusted(false)
manager.SetDefaultAllow(false)
for _, tc := range []struct {
name string
fs *s3tablestest.MemFiler
operation string
denied interface{}
request interface{}
}{
{"ListTables missing namespace", existing, "ListTables",
&ListTablesRequest{TableBucketARN: mustBucketARN(t), Namespace: []string{"ns"}},
&ListTablesRequest{TableBucketARN: mustBucketARN(t), Namespace: []string{"ghost"}}},
{"ListTables missing bucket", missing, "ListTables",
&ListTablesRequest{TableBucketARN: mustBucketARN(t), Namespace: []string{"ns"}},
&ListTablesRequest{TableBucketARN: mustBucketARN(t), Namespace: []string{"ns"}}},
{"ListTables missing bucket, all namespaces", missing, "ListTables",
&ListTablesRequest{TableBucketARN: mustBucketARN(t), Namespace: []string{"ns"}},
&ListTablesRequest{TableBucketARN: mustBucketARN(t)}},
{"ListViews missing namespace", existing, "ListViews",
&ListViewsRequest{TableBucketARN: mustBucketARN(t), Namespace: []string{"ns"}},
&ListViewsRequest{TableBucketARN: mustBucketARN(t), Namespace: []string{"ghost"}}},
{"CreateNamespace missing bucket", missing, "CreateNamespace",
&CreateNamespaceRequest{TableBucketARN: mustBucketARN(t), Namespace: []string{"newns"}},
&CreateNamespaceRequest{TableBucketARN: mustBucketARN(t), Namespace: []string{"newns"}}},
} {
t.Run(tc.name, func(t *testing.T) {
denied := runUnauthorizedRequest(t, manager, existing, tc.operation, tc.denied)
assert.Equal(t, 403, denied.status)
got := runUnauthorizedRequest(t, manager, tc.fs, tc.operation, tc.request)
assert.Equal(t, denied, got)
})
}
}
// A denied tag operation and the same operation on a missing table must answer
// alike; a granted caller still gets the truthful not-found.
func TestAbsentTagTargetsMatchDeniedAnswers(t *testing.T) {
existing, manager := startRenameManager(t)
manager.SetTrusted(false)
manager.SetDefaultAllow(false)
existingARN, err := BuildTableARN(DefaultRegion, DefaultAccountID, renameTestBucket, "ns", "t")
require.NoError(t, err)
missingARN, err := BuildTableARN(DefaultRegion, DefaultAccountID, renameTestBucket, "ns", "ghost")
require.NoError(t, err)
requestFor := func(operation, arn string) interface{} {
switch operation {
case "TagResource":
return &TagResourceRequest{ResourceARN: arn, Tags: map[string]string{"k": "v"}}
case "UntagResource":
return &UntagResourceRequest{ResourceARN: arn, TagKeys: []string{"k"}}
}
return &ListTagsForResourceRequest{ResourceARN: arn}
}
for _, operation := range []string{"TagResource", "ListTagsForResource", "UntagResource"} {
t.Run(operation, func(t *testing.T) {
denied := runUnauthorizedRequest(t, manager, existing, operation, requestFor(operation, existingARN))
assert.Equal(t, 403, denied.status)
got := runUnauthorizedRequest(t, manager, existing, operation, requestFor(operation, missingARN))
assert.Equal(t, denied, got)
})
}
}
// A directory that holds a plain S3 bucket name is not a table bucket: every
// caller gets the same 404 an absent name gets, not a 500 that reveals the
// name resolves in the filer.
func TestPlainBucketReadsAsAbsent(t *testing.T) {
existing, manager := startRenameManager(t)
manager.SetTrusted(false)
manager.SetDefaultAllow(false)
existing.Put(TablesPath, "plainbkt", nil)
plainARN, err := BuildBucketARN(DefaultRegion, DefaultAccountID, "plainbkt")
require.NoError(t, err)
for _, tc := range []struct {
operation string
request interface{}
}{
{"GetTableBucket", &GetTableBucketRequest{TableBucketARN: plainARN}},
{"ListNamespaces", &ListNamespacesRequest{TableBucketARN: plainARN}},
} {
t.Run(tc.operation, func(t *testing.T) {
got := runUnauthorizedRequest(t, manager, existing, tc.operation, tc.request)
assert.Equal(t, 404, got.status)
assert.Equal(t, ErrCodeNoSuchBucket, got.body.Type)
})
}
}
// A caller with an identity-level grant still learns that a name is absent.
func TestGrantedCallerLearnsAbsence(t *testing.T) {
missing := s3tablestest.Start(t)
manager := NewManager()
manager.SetTrusted(false)
manager.SetDefaultAllow(false)
got := runRequestAs(t, manager, missing, "ListTables", &ListTablesRequest{
TableBucketARN: mustBucketARN(t), Namespace: []string{"ns"}},
[]string{"s3tables:ListTables"})
assert.Equal(t, 200, got.status)
missingTableARN, err := BuildTableARN(DefaultRegion, DefaultAccountID, renameTestBucket, "ns", "ghost")
require.NoError(t, err)
got = runRequestAs(t, manager, missing, "ListTagsForResource",
&ListTagsForResourceRequest{ResourceARN: missingTableARN},
[]string{"s3tables:ListTagsForResource"})
assert.Equal(t, 404, got.status)
assert.Equal(t, ErrCodeNoSuchTable, got.body.Type)
}
// A request ARN that names a foreign account still resolves to the local
// names, so the absent-target answer must match the denial the canonical ARN
// gets; otherwise a reformatted ARN restores the existence oracle.
func TestAbsentTagTargetDenySurvivesForeignAccountARN(t *testing.T) {
fs, manager := startRenameManager(t)
manager.SetTrusted(false)
manager.SetDefaultAllow(false)
bucketMeta, _ := json.Marshal(tableBucketMetadata{Name: renameTestBucket, OwnerAccountID: DefaultAccountID})
fs.Put(TablesPath, renameTestBucket, map[string][]byte{
ExtendedKeyTableBucket: []byte("{}"),
ExtendedKeyMetadata: bucketMeta,
ExtendedKeyPolicy: []byte(fmt.Sprintf(
`{"Statement":[{"Effect":"Deny","Principal":"*","Action":"s3tables:*","Resource":%q}]}`,
mustBucketARN(t)+"/table/*")),
})
foreignARN := func(table string) string {
return fmt.Sprintf("arn:aws:s3tables:%s:999999999999:bucket/%s/table/ns/%s",
DefaultRegion, renameTestBucket, table)
}
requestFor := func(operation, arn string) interface{} {
switch operation {
case "TagResource":
return &TagResourceRequest{ResourceARN: arn, Tags: map[string]string{"k": "v"}}
case "UntagResource":
return &UntagResourceRequest{ResourceARN: arn, TagKeys: []string{"k"}}
}
return &ListTagsForResourceRequest{ResourceARN: arn}
}
for _, operation := range []string{"TagResource", "ListTagsForResource", "UntagResource"} {
t.Run(operation, func(t *testing.T) {
actions := []string{"s3tables:" + operation}
denied := runRequestAs(t, manager, fs, operation, requestFor(operation, foreignARN("t")), actions)
assert.Equal(t, 403, denied.status)
got := runRequestAs(t, manager, fs, operation, requestFor(operation, foreignARN("ghost")), actions)
assert.Equal(t, denied, got)
})
}
}
+5 -1
View File
@@ -140,6 +140,10 @@ func (h *S3TablesHandler) handleCreateNamespace(w http.ResponseWriter, r *http.R
return ErrAccessDenied
}
if errors.Is(err, filer_pb.ErrNotFound) {
if !h.authorizeAbsentTarget(r, filerClient, "CreateNamespace", bucketName, namespaceName, "", "") {
h.writeError(w, http.StatusForbidden, ErrCodeAccessDenied, "not authorized to create namespace in this bucket")
return ErrAccessDenied
}
h.writeError(w, http.StatusNotFound, ErrCodeNoSuchBucket, fmt.Sprintf("table bucket %s not found", bucketName))
} else {
h.writeError(w, http.StatusInternalServerError, ErrCodeInternalError, fmt.Sprintf("failed to check table bucket: %v", err))
@@ -481,7 +485,7 @@ func (h *S3TablesHandler) handleListNamespaces(w http.ResponseWriter, r *http.Re
})
if err != nil {
if errors.Is(err, filer_pb.ErrNotFound) {
if errors.Is(err, filer_pb.ErrNotFound) || errors.Is(err, ErrAttributeNotFound) {
h.writeError(w, http.StatusNotFound, ErrCodeNoSuchBucket, fmt.Sprintf("table bucket %s not found", bucketName))
} else {
h.writeError(w, http.StatusInternalServerError, ErrCodeInternalError, fmt.Sprintf("failed to list namespaces: %v", err))
+25
View File
@@ -659,6 +659,9 @@ func (h *S3TablesHandler) handleTagResource(w http.ResponseWriter, r *http.Reque
// Read metadata for ownership check
data, err := h.getExtendedAttribute(r.Context(), client, resourcePath, ExtendedKeyMetadata)
if err != nil {
if errors.Is(err, filer_pb.ErrNotFound) || errors.Is(err, ErrAttributeNotFound) {
return h.absentTagTargetError(r, client, "TagResource", "not authorized to tag resource", req.ResourceARN, rType)
}
return err
}
@@ -793,6 +796,9 @@ func (h *S3TablesHandler) handleListTagsForResource(w http.ResponseWriter, r *ht
// Read metadata for ownership check
data, err := h.getExtendedAttribute(r.Context(), client, resourcePath, ExtendedKeyMetadata)
if err != nil {
if errors.Is(err, filer_pb.ErrNotFound) || errors.Is(err, ErrAttributeNotFound) {
return h.absentTagTargetError(r, client, "ListTagsForResource", "not authorized to list tags for resource", req.ResourceARN, rType)
}
return err
}
@@ -913,6 +919,9 @@ func (h *S3TablesHandler) handleUntagResource(w http.ResponseWriter, r *http.Req
// Read metadata for ownership check
data, err := h.getExtendedAttribute(r.Context(), client, resourcePath, ExtendedKeyMetadata)
if err != nil {
if errors.Is(err, filer_pb.ErrNotFound) || errors.Is(err, ErrAttributeNotFound) {
return h.absentTagTargetError(r, client, "UntagResource", "not authorized to untag resource", req.ResourceARN, rType)
}
return err
}
@@ -1017,6 +1026,22 @@ func (h *S3TablesHandler) handleUntagResource(w http.ResponseWriter, r *http.Req
return nil
}
// absentTagTargetError resolves a tag operation when the resource's catalog
// metadata did not load: a caller the operation would refuse gets its denial,
// everyone else the same not-found an absent name gets.
func (h *S3TablesHandler) absentTagTargetError(r *http.Request, client filer_pb.SeaweedFilerClient, operation, deniedMessage, resourceARN string, rType ResourceType) error {
var bucketName, namespace, name string
if rType == ResourceTypeTable {
bucketName, namespace, name, _ = parseTableFromARN(resourceARN)
} else {
bucketName, _ = parseBucketNameFromARN(resourceARN)
}
if !h.authorizeAbsentTarget(r, &singleClient{client}, operation, bucketName, namespace, name, resourceARN) {
return NewAuthError(operation, h.getAccountID(r), deniedMessage)
}
return filer_pb.ErrNotFound
}
// resolveResourcePath determines the resource path and extended attribute key from a resource ARN
func (h *S3TablesHandler) resolveResourcePath(resourceARN string) (path string, key string, rType ResourceType, err error) {
// Try parsing as table ARN first
+27 -7
View File
@@ -515,11 +515,14 @@ func (h *S3TablesHandler) handleListTables(w http.ResponseWriter, r *http.Reques
bucketTags := map[string]string{}
// Fetch namespace metadata and policy
targetAbsent := false
data, err := h.getExtendedAttribute(r.Context(), client, namespacePath, ExtendedKeyMetadata)
if err != nil {
return err // Not Found handled by caller
}
if err := json.Unmarshal(data, &nsMeta); err != nil {
if !errors.Is(err, filer_pb.ErrNotFound) && !errors.Is(err, ErrAttributeNotFound) {
return err
}
targetAbsent = true
} else if err := json.Unmarshal(data, &nsMeta); err != nil {
return err
}
@@ -527,7 +530,7 @@ func (h *S3TablesHandler) handleListTables(w http.ResponseWriter, r *http.Reques
policyData, err := h.getExtendedAttribute(r.Context(), client, namespacePath, ExtendedKeyPolicy)
if err == nil {
namespacePolicy = string(policyData)
} else if !errors.Is(err, ErrAttributeNotFound) {
} else if !errors.Is(err, ErrAttributeNotFound) && !errors.Is(err, filer_pb.ErrNotFound) {
return fmt.Errorf("failed to fetch namespace policy: %w", err)
}
@@ -537,6 +540,8 @@ func (h *S3TablesHandler) handleListTables(w http.ResponseWriter, r *http.Reques
if err := json.Unmarshal(data, &bucketMeta); err != nil {
return fmt.Errorf("failed to unmarshal bucket metadata: %w", err)
}
} else if errors.Is(err, filer_pb.ErrNotFound) {
targetAbsent = true
} else if !errors.Is(err, ErrAttributeNotFound) {
return fmt.Errorf("failed to fetch bucket metadata: %w", err)
}
@@ -544,15 +549,24 @@ func (h *S3TablesHandler) handleListTables(w http.ResponseWriter, r *http.Reques
policyData, err = h.getExtendedAttribute(r.Context(), client, bucketPath, ExtendedKeyPolicy)
if err == nil {
bucketPolicy = string(policyData)
} else if !errors.Is(err, ErrAttributeNotFound) {
} else if !errors.Is(err, ErrAttributeNotFound) && !errors.Is(err, filer_pb.ErrNotFound) {
return fmt.Errorf("failed to fetch bucket policy: %w", err)
}
if tags, err := h.readTags(r.Context(), client, bucketPath); err != nil {
return fmt.Errorf("failed to read bucket tags: %w", err)
if !errors.Is(err, filer_pb.ErrNotFound) {
return fmt.Errorf("failed to read bucket tags: %w", err)
}
} else if tags != nil {
bucketTags = tags
}
if targetAbsent {
if !h.authorizeAbsentTarget(r, &singleClient{client}, "ListTables", bucketName, namespaceName, "", "") {
return ErrAccessDenied
}
return filer_pb.ErrNotFound
}
bucketARN := h.generateTableBucketARN(bucketMeta.OwnerAccountID, bucketName)
identityActions := getIdentityActions(r)
if !CheckTableOrBucketPermission("ListTables", accountID,
@@ -583,7 +597,13 @@ func (h *S3TablesHandler) handleListTables(w http.ResponseWriter, r *http.Reques
// Fetch bucket metadata and policy
data, err := h.getExtendedAttribute(r.Context(), client, bucketPath, ExtendedKeyMetadata)
if err != nil {
return fmt.Errorf("failed to fetch bucket metadata: %w", err)
if !errors.Is(err, filer_pb.ErrNotFound) && !errors.Is(err, ErrAttributeNotFound) {
return fmt.Errorf("failed to fetch bucket metadata: %w", err)
}
if !h.authorizeAbsentTarget(r, &singleClient{client}, "ListTables", bucketName, "", "", "") {
return ErrAccessDenied
}
return filer_pb.ErrNotFound
}
if err := json.Unmarshal(data, &bucketMeta); err != nil {
return fmt.Errorf("failed to unmarshal bucket metadata: %w", err)
+7 -1
View File
@@ -279,7 +279,13 @@ func (h *S3TablesHandler) handleListViews(w http.ResponseWriter, r *http.Request
var nsMeta namespaceMetadata
data, err := h.getExtendedAttribute(r.Context(), client, namespacePath, ExtendedKeyMetadata)
if err != nil {
return err
if !errors.Is(err, filer_pb.ErrNotFound) && !errors.Is(err, ErrAttributeNotFound) {
return err
}
if !h.authorizeAbsentTarget(r, &singleClient{client}, "ListViews", bucketName, namespaceName, "", "") {
return ErrAccessDenied
}
return filer_pb.ErrNotFound
}
if err := json.Unmarshal(data, &nsMeta); err != nil {
return err
+4
View File
@@ -15,6 +15,7 @@ import (
// - metadata/v*.metadata.json (table metadata)
// - metadata/snap-*.avro (snapshot manifest lists)
// - metadata/*.avro (manifest files)
// - metadata/*.stats, metadata/*.puffin (statistics files)
// - data/*.parquet, *.orc, *.avro, *.puffin (data files)
const uuidPattern = `[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}`
@@ -68,6 +69,9 @@ var (
// whose naming we can't anticipate across engines and versions.
regexp.MustCompile(`^[A-Za-z0-9._-]+\.avro$`),
regexp.MustCompile(`^[A-Za-z0-9._-]+\.metadata\.json$`),
// Table and partition statistics files listed in the table metadata
// are Puffin files; engines name them *.stats or *.puffin.
regexp.MustCompile(`^[A-Za-z0-9._-]+\.puffin$`),
}
// Patterns for valid data files
@@ -45,6 +45,10 @@ func TestIcebergLayoutValidator_AcceptsRealWorldManifestNames(t *testing.T) {
"trino/iceberg stats file",
"metadata/table.stats",
},
{
"puffin statistics file",
"metadata/20260928-1-0199a3c2-7d1e-7c55-9a0e-3b1f2c4d5e6f.puffin",
},
}
v := NewIcebergLayoutValidator()
@@ -70,6 +74,8 @@ func TestIcebergLayoutValidator_RejectsClearlyBadMetadataNames(t *testing.T) {
{"subdirectory under metadata is not allowed", "metadata/sub/file.avro"},
{"top-level dir other than metadata or data", "garbage/file.avro"},
{"catch-all anchors must reject trailing extension", "metadata/file.avro.txt"},
{"puffin with a trailing extension", "metadata/stats.puffin.txt"},
{"puffin in a subdirectory under metadata", "metadata/sub/stats.puffin"},
}
v := NewIcebergLayoutValidator()
@@ -137,3 +143,31 @@ func TestValidateFilePathAcceptsLanceLayout(t *testing.T) {
}
}
}
// Statistics files are referenced from the table metadata and kept by orphan
// cleanup; the upload door has to admit them in the table's metadata/, and
// only there.
func TestTableBucketFileValidator_PuffinStatisticsOnlyInTableMetadata(t *testing.T) {
v := NewTableBucketFileValidator()
accepted := []string{
TablesPath + "/cat/ns/t/metadata/stats-1.puffin",
TablesPath + "/cat/ns/t/metadata/table.stats",
TablesPath + "/cat/ns/t/data/0199a3c2-deletes.puffin",
}
for _, p := range accepted {
if err := v.ValidateTableBucketUpload(p); err != nil {
t.Errorf("ValidateTableBucketUpload(%q) = %v, want nil", p, err)
}
}
rejected := []string{
TablesPath + "/cat/ns/t/stats-1.puffin",
TablesPath + "/cat/ns/t/other/stats-1.puffin",
TablesPath + "/cat/ns/t/metadata/sub/stats-1.puffin",
TablesPath + "/cat/ns/t/metadata/notes.txt",
}
for _, p := range rejected {
if err := v.ValidateTableBucketUpload(p); err == nil {
t.Errorf("ValidateTableBucketUpload(%q) = nil, want an error", p)
}
}
}
+59 -17
View File
@@ -54,6 +54,22 @@ func (vs *VolumeServer) lookupRaftLeaderMaster(ctx context.Context) (pb.ServerAd
return leader, nil
}
// refreshLeaderFromPeers publishes the leader the configured masters report,
// so lookups stop going to a master the heartbeat just lost while the loop
// retries. Without an answer within timeout no leader is published: the lost
// master is dead or no longer leader, and a follower answers lookups from its
// own cache rather than refusing them, so failing fast is the safer answer.
func (vs *VolumeServer) refreshLeaderFromPeers(timeout time.Duration) {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
leader, err := vs.lookupRaftLeaderMaster(ctx)
if err != nil {
vs.setCurrentMaster("")
return
}
vs.setCurrentMaster(leader)
}
func isContextDoneErr(err error) bool {
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return true
@@ -74,7 +90,10 @@ func (vs *VolumeServer) getCurrentMaster() pb.ServerAddress {
}
// setCurrentMaster updates vs.currentMaster under a write lock. The
// heartbeat goroutine calls this whenever it (re)connects to a master.
// heartbeat goroutine calls this only with a master known to be the leader:
// one that answered a heartbeat as leader, or the one a follower named, on
// the heartbeat stream or, after the stream failed, in its configuration.
// After a failed stream with no leader named, it is cleared to "".
func (vs *VolumeServer) setCurrentMaster(master pb.ServerAddress) {
vs.currentMasterLock.Lock()
vs.currentMaster = master
@@ -122,7 +141,6 @@ func (vs *VolumeServer) heartbeat() {
time.Sleep(3 * time.Second)
master = newLeader
}
vs.store.MasterAddress = master
newLeader, err = vs.doHeartbeatWithRetry(master, grpcDialOption, vs.pulsePeriod, duplicateRetryCount)
if err != nil {
glog.V(0).Infof("heartbeat to %s error: %v", master, err)
@@ -136,12 +154,15 @@ func (vs *VolumeServer) heartbeat() {
} else {
// Regular error, reset duplicate retry count
duplicateRetryCount = 0
time.Sleep(vs.pulsePeriod)
// The lookup spends part of the retry delay rather than
// adding to it.
retryAt := time.Now().Add(vs.pulsePeriod)
vs.refreshLeaderFromPeers(vs.pulsePeriod)
time.Sleep(time.Until(retryAt))
}
stats.VolumeServerMasterDisconnections.WithLabelValues(master.String()).Inc()
newLeader = ""
vs.store.MasterAddress = ""
} else {
// Successful connection, reset retry count
duplicateRetryCount = 0
@@ -180,17 +201,42 @@ func (vs *VolumeServer) doHeartbeatWithRetry(masterAddress pb.ServerAddress, grp
return "", err
}
glog.V(0).Infof("Heartbeat to: %v", masterAddress)
vs.setCurrentMaster(masterAddress)
doneChan := make(chan error, 1)
// The receiver asks for a fresh heartbeat here rather than sending it,
// since a gRPC stream allows only one sender at a time.
resendChan := make(chan struct{}, 1)
go func() {
confirmedLeader := false
for {
in, err := stream.Recv()
if err != nil {
doneChan <- err
return
}
// A reply names the leader or comes from it, so it settles where
// lookups go before anything else on the reply is handled: a
// follower reply carries no other fields, and a leader stays the
// lookup target even when it rejects this registration.
if in.GetLeader() != "" {
// Only a follower names the leader. Publish the named leader right
// away, so lookups stop landing on this follower while the loop
// reconnects.
leader := pb.ServerAddress(in.GetLeader())
if !masterAddress.Equals(leader) {
glog.V(0).Infof("Volume Server found a new master newLeader: %v instead of %v", leader, masterAddress)
vs.setCurrentMaster(leader)
newLeader = leader
doneChan <- nil
return
}
} else if !confirmedLeader {
// The leader answers every new heartbeat stream without naming a
// leader, so this peer is the one lookups should go to.
confirmedLeader = true
vs.setCurrentMaster(masterAddress)
}
if len(in.DuplicatedUuids) > 0 {
var duplicateDir []string
for _, loc := range vs.store.Locations {
@@ -231,9 +277,9 @@ func (vs *VolumeServer) doHeartbeatWithRetry(masterAddress pb.ServerAddress, grp
}
if volumeOptsChanged {
if vs.store.MaybeAdjustVolumeMax() {
if err = stream.Send(vs.store.CollectHeartbeat()); err != nil {
glog.V(0).Infof("Volume Server Failed to talk with master %s: %v", vs.getCurrentMaster(), err)
return
select {
case resendChan <- struct{}{}:
default:
}
}
}
@@ -244,15 +290,6 @@ func (vs *VolumeServer) doHeartbeatWithRetry(masterAddress pb.ServerAddress, grp
glog.V(0).Infof("master %s asked for the full volume list", masterAddress)
vs.store.RequestFullVolumeList()
}
if in.GetLeader() != "" {
current := vs.getCurrentMaster()
if !current.Equals(pb.ServerAddress(in.GetLeader())) {
glog.V(0).Infof("Volume Server found a new master newLeader: %v instead of %v", in.GetLeader(), current)
newLeader = pb.ServerAddress(in.GetLeader())
doneChan <- nil
return
}
}
}
}()
@@ -359,6 +396,11 @@ func (vs *VolumeServer) doHeartbeatWithRetry(masterAddress pb.ServerAddress, grp
glog.V(0).Infof("Volume Server Failed to update to master %s: %v", masterAddress, err)
return "", err
}
case <-resendChan:
if err = stream.Send(vs.store.CollectHeartbeat()); err != nil {
glog.V(0).Infof("Volume Server Failed to talk with master %s: %v", masterAddress, err)
return "", err
}
case <-volumeTickChan.C:
glog.V(4).Infof("volume server %s:%d heartbeat", vs.store.Ip, vs.store.Port)
vs.store.MaybeAdjustVolumeMax()
@@ -4,11 +4,17 @@ import (
"context"
"fmt"
"net"
"strings"
"sync"
"testing"
"time"
"github.com/seaweedfs/seaweedfs/weed/pb"
"github.com/seaweedfs/seaweedfs/weed/pb/master_pb"
"github.com/seaweedfs/seaweedfs/weed/stats"
"github.com/seaweedfs/seaweedfs/weed/storage"
"github.com/seaweedfs/seaweedfs/weed/storage/types"
"github.com/seaweedfs/seaweedfs/weed/util"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
)
@@ -60,3 +66,279 @@ func startFakeMasterServerForLeaderLookup(t *testing.T, srv master_pb.SeaweedSer
_, port, _ := net.SplitHostPort(lis.Addr().String())
return pb.ServerAddress(fmt.Sprintf("127.0.0.1:0.%s", port))
}
// fakeHeartbeatMaster answers SendHeartbeat the way a master does: a follower
// names the leader on every heartbeat, the leader answers the first one with
// its volume options and never names itself.
type fakeHeartbeatMaster struct {
master_pb.UnimplementedSeaweedServer
leader string // set on a follower
release chan struct{} // a follower waits on it before answering
heartbeats chan *master_pb.Heartbeat
}
func (m *fakeHeartbeatMaster) SendHeartbeat(stream master_pb.Seaweed_SendHeartbeatServer) error {
answered := false
for {
hb, err := stream.Recv()
if err != nil {
return err
}
if m.heartbeats != nil {
m.heartbeats <- hb
}
if m.leader != "" {
if m.release != nil {
<-m.release
}
if err := stream.Send(&master_pb.HeartbeatResponse{Leader: m.leader}); err != nil {
return err
}
continue
}
if !answered {
answered = true
if err := stream.Send(&master_pb.HeartbeatResponse{VolumeSizeLimit: 1 << 30}); err != nil {
return err
}
}
}
}
func newHeartbeatTestVolumeServer(t *testing.T) *VolumeServer {
t.Helper()
store := storage.NewStore(nil, "127.0.0.1", 8080, 18080, "http://127.0.0.1:8080", "store-id",
[]string{t.TempDir()},
[]int32{0},
[]util.MinFreeSpace{{}},
"",
storage.NeedleMapInMemory,
[]types.DiskType{types.HardDriveType},
nil,
3,
stats.DefaultDiskIOProbeConfig(),
)
t.Cleanup(store.Close)
vs := &VolumeServer{
grpcDialOption: grpc.WithTransportCredentials(insecure.NewCredentials()),
store: store,
stopChan: make(chan bool),
isHeartbeating: true,
}
store.MasterFn = vs.getCurrentMaster
return vs
}
func waitForCurrentMaster(t *testing.T, vs *VolumeServer, want pb.ServerAddress) {
t.Helper()
deadline := time.Now().Add(5 * time.Second)
for !vs.getCurrentMaster().Equals(want) {
if time.Now().After(deadline) {
t.Fatalf("currentMaster = %q, want %q", vs.getCurrentMaster(), want)
}
time.Sleep(10 * time.Millisecond)
}
}
// heartbeatRun is one doHeartbeatWithRetry call running in the background.
type heartbeatRun struct {
done chan heartbeatResult // receives the call's result once
exited chan struct{}
stop func()
}
type heartbeatResult struct {
newLeader pb.ServerAddress
err error
}
// startHeartbeat runs doHeartbeatWithRetry against master until it returns or
// is stopped. Its cleanup stops the heartbeat and waits for it, and runs before
// the cleanups registered earlier, so a failed assertion cannot leave the stream
// open for the fake master's GracefulStop to wait on.
func startHeartbeat(t *testing.T, vs *VolumeServer, master pb.ServerAddress) *heartbeatRun {
t.Helper()
var stopOnce sync.Once
run := &heartbeatRun{
done: make(chan heartbeatResult, 1),
exited: make(chan struct{}),
stop: func() { stopOnce.Do(func() { close(vs.stopChan) }) },
}
go func() {
defer close(run.exited)
newLeader, err := vs.doHeartbeatWithRetry(master, vs.grpcDialOption, time.Hour, 0)
run.done <- heartbeatResult{newLeader, err}
}()
t.Cleanup(func() {
run.stop()
select {
case <-run.exited:
case <-time.After(5 * time.Second):
t.Error("heartbeat did not stop")
}
})
return run
}
// A follower must never become the master lookups go to: until it names the
// leader the previous one stays, and then the named leader takes over before
// the heartbeat loop has reconnected.
func TestHeartbeatToFollowerPublishesNamedLeaderOnly(t *testing.T) {
const leader = pb.ServerAddress("leader.example.com:9333.19333")
follower := &fakeHeartbeatMaster{
leader: string(leader),
release: make(chan struct{}),
heartbeats: make(chan *master_pb.Heartbeat, 8),
}
followerAddr := startFakeMasterServerForLeaderLookup(t, follower)
// cleanups run last-in first-out, so a failed assertion still lets the
// follower answer before its server stops gracefully
var releaseOnce sync.Once
release := func() { releaseOnce.Do(func() { close(follower.release) }) }
t.Cleanup(release)
vs := newHeartbeatTestVolumeServer(t)
previous := pb.ServerAddress("previous.example.com:9333.19333")
vs.setCurrentMaster(previous)
run := startHeartbeat(t, vs, followerAddr)
select {
case <-follower.heartbeats:
case <-time.After(5 * time.Second):
t.Fatal("follower got no heartbeat")
}
if got := vs.getCurrentMaster(); got != previous {
t.Fatalf("currentMaster = %q while connected to follower %q, want unchanged %q", string(got), string(followerAddr), string(previous))
}
if got := vs.store.MasterFn(); got != previous {
t.Fatalf("store master = %q, want %q", got, previous)
}
release()
select {
case r := <-run.done:
if r.err != nil {
t.Fatalf("heartbeat: %v", r.err)
}
if r.newLeader != leader {
t.Fatalf("newLeader = %q, want %q", r.newLeader, leader)
}
case <-time.After(5 * time.Second):
t.Fatal("heartbeat did not return after the follower named the leader")
}
if got := vs.getCurrentMaster(); got != leader {
t.Fatalf("currentMaster = %q, want named leader %q", got, leader)
}
}
// The leader's first answer confirms it, and the size limit that answer carries
// makes the receiver ask for a fresh heartbeat, which the send loop delivers.
func TestHeartbeatToLeaderPublishesItAndResendsFromSendLoop(t *testing.T) {
leader := &fakeHeartbeatMaster{heartbeats: make(chan *master_pb.Heartbeat, 8)}
leaderAddr := startFakeMasterServerForLeaderLookup(t, leader)
vs := newHeartbeatTestVolumeServer(t)
run := startHeartbeat(t, vs, leaderAddr)
waitForCurrentMaster(t, vs, leaderAddr)
if got := vs.store.MasterFn(); got != leaderAddr {
t.Fatalf("store master = %q, want %q", got, leaderAddr)
}
// the initial volume and ec heartbeats, then the resend
for i := 0; i < 3; i++ {
select {
case <-leader.heartbeats:
case <-time.After(5 * time.Second):
t.Fatalf("leader got %d heartbeats, want 3", i)
}
}
run.stop()
select {
case r := <-run.done:
if r.err != nil {
t.Fatalf("heartbeat: %v", r.err)
}
case <-time.After(5 * time.Second):
t.Fatal("heartbeat did not stop")
}
}
// duplicateUUIDMaster answers every heartbeat the way a leader holding a stale
// registration does: a duplicate-uuid response and nothing else.
type duplicateUUIDMaster struct {
master_pb.UnimplementedSeaweedServer
}
func (m *duplicateUUIDMaster) SendHeartbeat(stream master_pb.Seaweed_SendHeartbeatServer) error {
for {
if _, err := stream.Recv(); err != nil {
return err
}
if err := stream.Send(&master_pb.HeartbeatResponse{DuplicatedUuids: []string{"uuid-1"}}); err != nil {
return err
}
}
}
// A leader that rejects the registration over duplicate uuids still answers
// lookups, so it is published before the retry error goes out.
func TestHeartbeatPublishesLeaderBeforeDuplicateUuidError(t *testing.T) {
masterAddr := startFakeMasterServerForLeaderLookup(t, &duplicateUUIDMaster{})
vs := newHeartbeatTestVolumeServer(t)
vs.setCurrentMaster("previous.example.com:9333.19333")
_, err := vs.doHeartbeatWithRetry(masterAddr, vs.grpcDialOption, time.Hour, 0)
if err == nil || !strings.Contains(err.Error(), "duplicate UUIDs") {
t.Fatalf("err = %v, want duplicate UUIDs", err)
}
if got := vs.getCurrentMaster(); got != masterAddr {
t.Fatalf("currentMaster = %q, want the answering leader %q", got, masterAddr)
}
}
// After the heartbeat loses its master, lookups move to the leader the other
// masters report instead of dialing the lost one until a new stream answers.
func TestRefreshLeaderFromPeersPublishesReportedLeader(t *testing.T) {
const leader = pb.ServerAddress("leader.example.com:9333.19333")
peer := startFakeMasterServerForLeaderLookup(t, &fakeLeaderConfigServer{leader: string(leader)})
vs := &VolumeServer{
SeedMasterNodes: []pb.ServerAddress{peer},
grpcDialOption: grpc.WithTransportCredentials(insecure.NewCredentials()),
store: &storage.Store{Ip: "127.0.0.1", Port: 8080},
}
vs.store.MasterFn = vs.getCurrentMaster
vs.setCurrentMaster("lost.example.com:9333.19333")
vs.refreshLeaderFromPeers(5 * time.Second)
if got := vs.getCurrentMaster(); got != leader {
t.Fatalf("currentMaster = %q, want reported leader %q", string(got), string(leader))
}
if got := vs.store.MasterFn(); got != leader {
t.Fatalf("store master = %q, want %q", string(got), string(leader))
}
}
// Mid-election no master names a leader. The lost master is dead or now a
// follower that would answer lookups from its own cache, so none is published.
func TestRefreshLeaderFromPeersClearsLeaderWithoutAnswer(t *testing.T) {
peer := startFakeMasterServerForLeaderLookup(t, &fakeLeaderConfigServer{})
vs := &VolumeServer{
SeedMasterNodes: []pb.ServerAddress{peer},
grpcDialOption: grpc.WithTransportCredentials(insecure.NewCredentials()),
store: &storage.Store{Ip: "127.0.0.1", Port: 8080},
}
vs.store.MasterFn = vs.getCurrentMaster
vs.setCurrentMaster("lost.example.com:9333.19333")
vs.refreshLeaderFromPeers(5 * time.Second)
if got := vs.getCurrentMaster(); got != "" {
t.Fatalf("currentMaster = %q, want none", string(got))
}
if got := vs.store.MasterFn(); got != "" {
t.Fatalf("store master = %q, want none", string(got))
}
}
+1
View File
@@ -148,6 +148,7 @@ func NewVolumeServer(adminMux, publicMux *http.ServeMux, ip string,
vs.checkWithMaster()
vs.store = storage.NewStore(vs.grpcDialOption, ip, port, grpcPort, publicUrl, id, folders, maxCounts, minFreeSpaces, idxFolder, vs.needleMapKind, diskTypes, diskTags, ldbTimeout, diskProbeConfig)
vs.store.MasterFn = vs.getCurrentMaster
vs.guard = security.NewGuard(whiteList, signingKey, expiresAfterSec, readSigningKey, readExpiresAfterSec)
handleStaticResources(adminMux)
+16 -5
View File
@@ -61,14 +61,25 @@ type ReadOption struct {
ReadBufferSize int
}
// masterAddress returns the master to ask for volume and shard locations, or
// "" while no leader is known.
func (s *Store) masterAddress() pb.ServerAddress {
if s.MasterFn == nil {
return ""
}
return s.MasterFn()
}
/*
* A VolumeServer contains one Store
*/
type Store struct {
MasterAddress pb.ServerAddress
// MasterFn reports the master the volume server currently takes as leader.
// The heartbeat loop owns that answer; the store only reads it.
MasterFn func() pb.ServerAddress
grpcDialOption grpc.DialOption
volumeSizeLimit uint64 // read from the master
preallocate atomic.Bool // read from the master
volumeSizeLimit atomic.Uint64 // read from the master
preallocate atomic.Bool // read from the master
Ip string
Port int
GrpcPort int
@@ -1302,11 +1313,11 @@ func (s *Store) ConfigureVolume(i needle.VolumeId, replication string) error {
}
func (s *Store) SetVolumeSizeLimit(x uint64) {
atomic.StoreUint64(&s.volumeSizeLimit, x)
s.volumeSizeLimit.Store(x)
}
func (s *Store) GetVolumeSizeLimit() uint64 {
return atomic.LoadUint64(&s.volumeSizeLimit)
return s.volumeSizeLimit.Load()
}
func (s *Store) SetPreallocate(x bool) {
+2 -2
View File
@@ -568,7 +568,7 @@ const ecIntervalReadConcurrency = 8
func (s *Store) readEcShardIntervals(needleId types.NeedleId, ecVolume *erasure_coding.EcVolume, intervals []erasure_coding.Interval) (data []byte, is_deleted bool, err error) {
if err = s.cachedLookupEcShardLocations(ecVolume); err != nil {
return nil, false, fmt.Errorf("failed to locate shard via master grpc %s: %v", s.MasterAddress, err)
return nil, false, fmt.Errorf("failed to locate shard via master grpc %s: %v", s.masterAddress(), err)
}
var totalSize int
@@ -721,7 +721,7 @@ func (s *Store) cachedLookupEcShardLocations(ecVolume *erasure_coding.EcVolume)
glog.V(3).Infof("lookup and cache ec volume %d locations", ecVolume.VolumeId)
err = operation.WithMasterServerClient(context.Background(), false, s.MasterAddress, s.grpcDialOption, func(masterClient master_pb.SeaweedClient) error {
err = operation.WithMasterServerClient(context.Background(), false, s.masterAddress(), s.grpcDialOption, func(masterClient master_pb.SeaweedClient) error {
req := &master_pb.LookupEcVolumeRequest{
VolumeId: uint32(ecVolume.VolumeId),
}
+4 -4
View File
@@ -78,7 +78,7 @@ func rewindShardLocationsRefresh(ecVolume *erasure_coding.EcVolume, d time.Durat
func TestCachedLookupEcShardLocationsRefreshesAfterAFailedRead(t *testing.T) {
master, masterAddr := startCountingMaster(t)
store := &Store{
MasterAddress: masterAddr,
MasterFn: func() pb.ServerAddress { return masterAddr },
grpcDialOption: grpc.WithTransportCredentials(insecure.NewCredentials()),
}
ecVolume := &erasure_coding.EcVolume{
@@ -127,7 +127,7 @@ func TestCachedLookupEcShardLocationsRefreshesAfterAFailedRead(t *testing.T) {
func TestCachedLookupEcShardLocationsSettlesAfterRefresh(t *testing.T) {
master, masterAddr := startCountingMaster(t)
store := &Store{
MasterAddress: masterAddr,
MasterFn: func() pb.ServerAddress { return masterAddr },
grpcDialOption: grpc.WithTransportCredentials(insecure.NewCredentials()),
}
ecVolume := &erasure_coding.EcVolume{
@@ -257,7 +257,7 @@ func TestReadOneEcShardIntervalMarksTheMapAfterADirectReadFails(t *testing.T) {
func TestCachedLookupEcShardLocationsKeepsAMarkRaisedDuringTheLookup(t *testing.T) {
master, masterAddr := startCountingMaster(t)
store := &Store{
MasterAddress: masterAddr,
MasterFn: func() pb.ServerAddress { return masterAddr },
grpcDialOption: grpc.WithTransportCredentials(insecure.NewCredentials()),
}
ecVolume := &erasure_coding.EcVolume{
@@ -295,7 +295,7 @@ func TestCachedLookupEcShardLocationsKeepsAMarkRaisedDuringTheLookup(t *testing.
func TestCachedLookupEcShardLocationsKeepsTheMarkWhenTheLookupFails(t *testing.T) {
master, masterAddr := startCountingMaster(t)
store := &Store{
MasterAddress: masterAddr,
MasterFn: func() pb.ServerAddress { return masterAddr },
grpcDialOption: grpc.WithTransportCredentials(insecure.NewCredentials()),
}
ecVolume := &erasure_coding.EcVolume{
+2 -2
View File
@@ -23,7 +23,7 @@ func (s *Store) ScrubEcVolume(vid needle.VolumeId, mode volume_server_pb.VolumeS
return 0, nil, []error{fmt.Errorf("EC volume id %d not found", vid)}
}
if err := s.cachedLookupEcShardLocations(ecv); err != nil {
return 0, nil, []error{fmt.Errorf("failed to locate shard via master grpc %s: %v", s.MasterAddress, err)}
return 0, nil, []error{fmt.Errorf("failed to locate shard via master grpc %s: %v", s.masterAddress(), err)}
}
return s.scrubEcVolumeWalk(ecv, mode, forceDeletedNeedlesCheck)
}
@@ -56,7 +56,7 @@ func (s *Store) ScrubEcVolumeMerged(merged *erasure_coding.MergedEcRuntimes, mod
}
}
if err := s.cachedLookupEcShardLocations(ecv); err != nil {
return 0, nil, []error{fmt.Errorf("failed to locate shard via master grpc %s: %v", s.MasterAddress, err)}
return 0, nil, []error{fmt.Errorf("failed to locate shard via master grpc %s: %v", s.masterAddress(), err)}
}
files, shardInfos, errs := s.scrubEcVolumeWalk(ecv, mode, forceDeletedNeedlesCheck)