Files
seaweedfs/weed/s3api/table_bucket_verify_object_test.go
T
Javier de la TorreandJavier de la Torre de2fc03c7d s3tables: accept the external-volume verification object at a table bucket's root (#11900)
* s3tables: accept the external-volume verification object at a table bucket's root

Snowflake verifies an S3-compatible external volume before it creates or
writes Iceberg tables in it (SYSTEM$VERIFY_EXTERNAL_VOLUME, also run
implicitly): it writes, reads, lists and deletes one small object,
<base>/verify_<epoch milliseconds>_<number>, a 21-byte body. A table bucket
refuses every key outside the Iceberg layout, so the check failed and
Snowflake could not use a table bucket as an external volume.

PUT, GET, HEAD, DELETE and DeleteObjects now accept exactly that key in a
table bucket, narrowly enough that it cannot be used to store data:
- at the bucket root only, so nothing is encoded in a prefix, written into
  a hidden tree or placed on a namespace or table name;
- one shape: verify_<13 digits of epoch ms within one hour of the server's
  clock>_<1-10 digits>;
- a PUT must declare a Content-Length of at most 1 KiB (no chunked or
  undeclared body) and carry no user metadata, tagging, Content-Disposition,
  Cache-Control, Expires or website redirect.
Multipart, copy, rename, ACL, tagging and POST policy writes keep the plain
layout rule. Buckets that are not table buckets are unaffected.

Tests: table_bucket_verify_object_test.go, the key accepted (with the
Iceberg layout as the positive control and the plain rule still refusing
it), 15 look-alikes refused (prefixed, hidden tree, wrong shape, outside the
window, ordinary keys), and seven PUT refusals through PutObjectHandler.

* s3tables: keep the verification key off namespaces and narrow its PUT

A namespace may be named like a verification key (verify_<ms>_<n>), and
the key then names the namespace's directory. A PUT replaced that
directory with a prefix object, dropping the namespace's catalog
metadata; a DELETE removed an empty namespace. The key now gets the plain
layout rule when it names a directory or its entry cannot be read, and a
PUT of it never falls back to a prefix object, so a namespace created
after the check is not replaced either.

The time window applies to PUT only: GET, HEAD and DELETE take a key of
any date, so an object a check left behind for more than an hour can
still be read and removed. A verification PUT also refuses an extraction
request and the stored Content-Language, Response-Content-Disposition and
Content-Encoding headers (aws-chunked framing alone is still accepted).

The integration test runs the check's PUT, HEAD, GET, list and DELETE
against a table bucket, and the PUT and DELETE over a namespace of the
key's shape.

---------

Co-authored-by: Javier de la Torre <jatorre@lakehousebox.com>
2026-10-21 14:49:36 -07:00

164 lines
7.0 KiB
Go

package s3api
import (
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/gorilla/mux"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
"github.com/stretchr/testify/assert"
)
const verifyTestBucket = "tables"
// verifyTestNow is the server clock the tests pin; keys are dated around it.
const verifyTestNow = 1790000000000
func newVerifyTestServer(t *testing.T) *S3ApiServer {
t.Helper()
saved := volumeVerifyNow
volumeVerifyNow = func() time.Time { return time.UnixMilli(verifyTestNow) }
savedLookup := volumeVerifyLookup
volumeVerifyLookup = func(*S3ApiServer, string, string) (*filer_pb.Entry, error) { return nil, filer_pb.ErrNotFound }
t.Cleanup(func() { volumeVerifyNow, volumeVerifyLookup = saved, savedLookup })
s3a := &S3ApiServer{option: &S3ApiServerOption{BucketsPath: "/buckets"}}
s3a.bucketRegistry = NewBucketRegistry(s3a)
s3a.bucketRegistry.setMetadataCache(&BucketMetaData{Name: verifyTestBucket, IsTableBucket: true})
s3a.bucketRegistry.setMetadataCache(&BucketMetaData{Name: "plain", IsTableBucket: false})
return s3a
}
func TestTableBucketAcceptsExternalVolumeVerifyKey(t *testing.T) {
s3a := newVerifyTestServer(t)
for _, key := range []string{
"verify_1790000000000_1081846483",
"/verify_1790000000000_1",
"verify_1790003300000_42", // 55 minutes later: inside the window
"verify_1789996700000_42", // 55 minutes earlier
// reads and deletes take a key of any date (PUT limits it)
"verify_1789992000000_1",
"verify_1790008000000_1",
} {
isVerify, err := s3a.validateTableBucketObjectPathOrVerify(verifyTestBucket, key)
assert.True(t, isVerify, key)
assert.NoError(t, err, key)
// the handlers that keep the plain rule still refuse it
assert.Error(t, s3a.validateTableBucketObjectPath(verifyTestBucket, key), key)
}
// the Iceberg layout is unchanged: the positive control
isVerify, err := s3a.validateTableBucketObjectPathOrVerify(verifyTestBucket, "ns/t/data/part-00000.parquet")
assert.False(t, isVerify)
assert.NoError(t, err)
}
func TestTableBucketRefusesVerifyKeyLookalikes(t *testing.T) {
s3a := newVerifyTestServer(t)
for _, key := range []string{
// the bucket root only
"base/verify_1790000000000_1",
"ns/verify_1790000000000_1",
".uploads/verify_1790000000000_1",
".uploads/abc/verify_1790000000000_1",
// the exact shape only
"verify_1790000000000_1.txt",
"verify_1790000000000",
"verify_179000000000_1", // 12-digit timestamp
"verify_1790000000000_12345678901", // 11-digit number
"verify_abc_123",
"verify_1790000000000_1/x",
"xverify_1790000000000_1",
// ordinary non-Iceberg keys
"notes.txt",
"ns/t/data/notes.txt",
} {
isVerify, err := s3a.validateTableBucketObjectPathOrVerify(verifyTestBucket, key)
assert.False(t, isVerify, key)
assert.Error(t, err, key)
}
// a bucket that is not a table bucket is unaffected
isVerify, err := s3a.validateTableBucketObjectPathOrVerify("plain", "verify_1790000000000_1")
assert.False(t, isVerify)
assert.NoError(t, err)
}
func verifyPutRequest(key string) *http.Request {
req := httptest.NewRequest(http.MethodPut, "/"+verifyTestBucket+"/"+key, strings.NewReader("x"))
return mux.SetURLVars(req, map[string]string{"bucket": verifyTestBucket, "object": key})
}
// A PUT to the verification key cannot store data: a small declared body and
// nothing stored beside it.
func TestTableBucketVerifyKeyPutLimits(t *testing.T) {
s3a := newVerifyTestServer(t)
const key = "verify_1790000000000_1081846483"
for name, mutate := range map[string]func(*http.Request){
"too large": func(r *http.Request) { r.ContentLength = volumeVerifyMaxBytes + 1 },
"content-language": func(r *http.Request) { r.ContentLength = 0; r.Header.Set("Content-Language", "fr") },
"content-encoding": func(r *http.Request) { r.ContentLength = 0; r.Header.Set("Content-Encoding", "gzip") },
"response-content-disposition": func(r *http.Request) { r.ContentLength = 0; r.Header.Set("Response-Content-Disposition", "attachment") },
"extract query": func(r *http.Request) { r.ContentLength = 0; r.URL.RawQuery = "extract=zip" },
"extract header": func(r *http.Request) { r.ContentLength = 0; r.Header.Set(s3_constants.SeaweedFSExtract, "zip") },
"undeclared length": func(r *http.Request) { r.ContentLength = -1 },
"chunked": func(r *http.Request) { r.ContentLength = 0; r.TransferEncoding = []string{"chunked"} },
"user metadata": func(r *http.Request) { r.ContentLength = 0; r.Header.Set("X-Amz-Meta-Note", "x") },
"tagging": func(r *http.Request) { r.ContentLength = 0; r.Header.Set("X-Amz-Tagging", "a=b") },
"content-disposition": func(r *http.Request) { r.ContentLength = 0; r.Header.Set("Content-Disposition", "attachment") },
"website redirect": func(r *http.Request) { r.ContentLength = 0; r.Header.Set("X-Amz-Website-Redirect-Location", "/x") },
} {
req := verifyPutRequest(key)
mutate(req)
assert.False(t, volumeVerifyPutAllowed(req, key), name)
rec := httptest.NewRecorder()
s3a.PutObjectHandler(rec, req)
assert.Equal(t, http.StatusForbidden, rec.Code, name)
}
ok := verifyPutRequest(key)
ok.ContentLength = 21
ok.Header.Set("Content-Type", "application/octet-stream")
ok.Header.Set("Content-Encoding", "aws-chunked")
assert.True(t, volumeVerifyPutAllowed(ok, key), "the verification object itself")
// a PUT is dated within the window of the server's clock
for _, old := range []string{"verify_1789992000000_1", "verify_1790008000000_1"} {
req := verifyPutRequest(old)
req.ContentLength = 21
assert.False(t, volumeVerifyPutAllowed(req, old), old)
rec := httptest.NewRecorder()
s3a.PutObjectHandler(rec, req)
assert.Equal(t, http.StatusForbidden, rec.Code, old)
}
}
// A namespace may carry a name of the key's shape; the key then names its
// directory and gets the plain layout rule, as does a key whose entry cannot
// be read.
func TestTableBucketVerifyKeyNamingDirectoryIsRefused(t *testing.T) {
s3a := newVerifyTestServer(t)
const key = "verify_1790000000000_7"
for name, lookup := range map[string]func(*S3ApiServer, string, string) (*filer_pb.Entry, error){
"namespace directory": func(*S3ApiServer, string, string) (*filer_pb.Entry, error) {
return &filer_pb.Entry{Name: key, IsDirectory: true}, nil
},
"lookup failure": func(*S3ApiServer, string, string) (*filer_pb.Entry, error) {
return nil, errors.New("filer unavailable")
},
} {
volumeVerifyLookup = lookup
isVerify, err := s3a.validateTableBucketObjectPathOrVerify(verifyTestBucket, key)
assert.False(t, isVerify, name)
assert.Error(t, err, name)
}
// the control: an existing verification object is a file
volumeVerifyLookup = func(*S3ApiServer, string, string) (*filer_pb.Entry, error) {
return &filer_pb.Entry{Name: key}, nil
}
isVerify, err := s3a.validateTableBucketObjectPathOrVerify(verifyTestBucket, key)
assert.True(t, isVerify)
assert.NoError(t, err)
}