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>
This commit is contained in:
Javier de la TorreandJavier de la Torre authored and GitHub committed 2026-10-21 14:49:36 -07:00
1 parent 21add33b6e
commit de2fc03c7d
6 files changed
+427 -6

No files matched your search

@@ -0,0 +1,139 @@
package s3tables
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"testing"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/aws/smithy-go"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func newObjectClient(t *testing.T) *s3.Client {
t.Helper()
cfg, err := config.LoadDefaultConfig(context.Background(),
config.WithRegion(testRegion),
config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(testAccessKey, testSecretKey, "")),
)
require.NoError(t, err)
return s3.NewFromConfig(cfg, func(o *s3.Options) {
o.BaseEndpoint = aws.String(sharedCluster.s3Endpoint)
o.UsePathStyle = true
})
}
func apiErrorCode(err error) string {
var apiErr smithy.APIError
if errors.As(err, &apiErr) {
return apiErr.ErrorCode()
}
return ""
}
// The external-volume verification object round-trips at a table bucket's
// root the way the check uses it: PUT, HEAD, GET, list, DELETE. Any other key
// at the root is still refused.
func TestTableBucketExternalVolumeVerificationRoundTrip(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}
ctx := context.Background()
tables := NewS3TablesClient(sharedCluster.s3Endpoint, testRegion, testAccessKey, testSecretKey)
bucket := "verify-" + randomString(8)
created, err := tables.CreateTableBucket(bucket, nil)
require.NoError(t, err)
defer tables.DeleteTableBucket(created.ARN)
client := newObjectClient(t)
body := []byte("verification payload!") // 21 bytes
key := fmt.Sprintf("verify_%d_%d", time.Now().UnixMilli(), 1081846483)
// the control: an ordinary key at the root is refused
_, err = client.PutObject(ctx, &s3.PutObjectInput{Bucket: aws.String(bucket), Key: aws.String("notes.parquet"), Body: bytes.NewReader(body)})
require.Error(t, err)
assert.Equal(t, "AccessDenied", apiErrorCode(err))
_, err = client.PutObject(ctx, &s3.PutObjectInput{Bucket: aws.String(bucket), Key: aws.String(key), Body: bytes.NewReader(body), ContentLength: aws.Int64(int64(len(body)))})
require.NoError(t, err)
head, err := client.HeadObject(ctx, &s3.HeadObjectInput{Bucket: aws.String(bucket), Key: aws.String(key)})
require.NoError(t, err)
assert.Equal(t, int64(len(body)), aws.ToInt64(head.ContentLength))
got, err := client.GetObject(ctx, &s3.GetObjectInput{Bucket: aws.String(bucket), Key: aws.String(key)})
require.NoError(t, err)
data, err := io.ReadAll(got.Body)
got.Body.Close()
require.NoError(t, err)
assert.Equal(t, body, data)
list, err := client.ListObjectsV2(ctx, &s3.ListObjectsV2Input{Bucket: aws.String(bucket), Prefix: aws.String("verify_")})
require.NoError(t, err)
var keys []string
for _, o := range list.Contents {
keys = append(keys, aws.ToString(o.Key))
}
assert.Equal(t, []string{key}, keys)
_, err = client.DeleteObject(ctx, &s3.DeleteObjectInput{Bucket: aws.String(bucket), Key: aws.String(key)})
require.NoError(t, err)
_, err = client.HeadObject(ctx, &s3.HeadObjectInput{Bucket: aws.String(bucket), Key: aws.String(key)})
require.Error(t, err, "the verification object must be gone after DELETE")
}
// A namespace can carry a name of the verification key's shape. The key
// then names the namespace's directory: a PUT or DELETE through it must not
// replace or remove the namespace.
func TestTableBucketVerificationKeyLeavesNamespaceAlone(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}
ctx := context.Background()
tables := NewS3TablesClient(sharedCluster.s3Endpoint, testRegion, testAccessKey, testSecretKey)
bucket := "verify-ns-" + randomString(8)
created, err := tables.CreateTableBucket(bucket, nil)
require.NoError(t, err)
defer tables.DeleteTableBucket(created.ARN)
client := newObjectClient(t)
body := []byte("verification payload!")
listed := func(name string) bool {
resp, err := tables.ListNamespaces(created.ARN, "", "", 0)
require.NoError(t, err)
for _, ns := range resp.Namespaces {
if len(ns.Namespace) == 1 && ns.Namespace[0] == name {
return true
}
}
return false
}
for i, op := range []string{"PUT", "DELETE"} {
t.Run(op, func(t *testing.T) {
name := fmt.Sprintf("verify_%d_%d", time.Now().UnixMilli(), 7+i)
_, err := tables.CreateNamespace(created.ARN, []string{name})
require.NoError(t, err)
defer tables.DeleteNamespace(created.ARN, []string{name})
require.True(t, listed(name), "setup: the namespace is listed")
if op == "PUT" {
_, err = client.PutObject(ctx, &s3.PutObjectInput{Bucket: aws.String(bucket), Key: aws.String(name), Body: bytes.NewReader(body), ContentLength: aws.Int64(int64(len(body)))})
assert.Error(t, err, "a PUT over a namespace must be refused")
} else {
_, err = client.DeleteObject(ctx, &s3.DeleteObjectInput{Bucket: aws.String(bucket), Key: aws.String(name)})
assert.Error(t, err, "a DELETE of a namespace must be refused")
}
_, err = tables.GetNamespace(created.ARN, []string{name})
assert.NoError(t, err, "the namespace survives the %s", op)
assert.True(t, listed(name), "the namespace is still listed after the %s", op)
})
}
}
+115
View File
@@ -2,8 +2,12 @@ package s3api
import (
"errors"
"net/http"
"path"
"regexp"
"strconv"
"strings"
"time"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
@@ -83,6 +87,117 @@ func (s3a *S3ApiServer) validateTableBucketObjectPath(bucket, object string) err
return nil
}
// volumeVerifyKey matches the object Snowflake writes, reads, lists and
// deletes at the base of an S3-compatible external volume before it creates
// or writes Iceberg tables there (SYSTEM$VERIFY_EXTERNAL_VOLUME, also run
// implicitly): <base>/verify_<epoch milliseconds>_<number>, a 21-byte body.
// A table bucket refuses every key outside the Iceberg layout, so without
// this exception Snowflake cannot use a table bucket as an external volume.
//
// The exception is narrow so that it cannot be used to store data:
// - only at the bucket root: nothing can be encoded in a prefix, written
// into a hidden tree or take a namespace or table name;
// - one shape: 13 digits of epoch milliseconds, then 1-10 digits;
// - never a directory: a namespace may carry a name of that shape, and the
// key then names its directory (volumeVerifyKeyIsDirectory);
// - a PUT must be dated within volumeVerifyWindow of the server's clock,
// declare a Content-Length of at most volumeVerifyMaxBytes (no chunked or
// undeclared body), and carry no user metadata, tagging, stored content
// headers or extraction request (volumeVerifyPutAllowed). Reads and
// deletes take a key of any date, so an object a check left behind can
// still be removed.
//
// Only the calls the check makes (PUT, GET, HEAD, DELETE, DeleteObjects)
// accept the key; multipart, copy, rename, ACL, tagging and POST policy keep
// the plain layout rule.
var volumeVerifyKey = regexp.MustCompile(`^verify_([0-9]{13})_[0-9]{1,10}$`)
const (
volumeVerifyMaxBytes = 1024
volumeVerifyWindow = time.Hour
)
// volumeVerifyNow is the clock a PUT's key is checked against.
var volumeVerifyNow = time.Now
// volumeVerifyKeyTime is the time a verification key is dated, and whether
// the object has the key's shape.
func volumeVerifyKeyTime(object string) (time.Time, bool) {
m := volumeVerifyKey.FindStringSubmatch(strings.TrimPrefix(object, "/"))
if m == nil {
return time.Time{}, false
}
ms, err := strconv.ParseInt(m[1], 10, 64)
if err != nil {
return time.Time{}, false
}
return time.UnixMilli(ms), true
}
func isVolumeVerifyKey(object string) bool {
_, ok := volumeVerifyKeyTime(object)
return ok
}
// volumeVerifyLookup reads the entry a verification key names.
var volumeVerifyLookup = func(s3a *S3ApiServer, dir, name string) (*filer_pb.Entry, error) {
return s3a.getEntry(dir, name)
}
// volumeVerifyKeyIsDirectory reports whether the key names a directory (a
// namespace), or whether that cannot be known.
func (s3a *S3ApiServer) volumeVerifyKeyIsDirectory(bucket, object string) bool {
entry, err := volumeVerifyLookup(s3a, s3a.bucketDir(bucket), strings.TrimPrefix(object, "/"))
if err != nil {
return !errors.Is(err, filer_pb.ErrNotFound)
}
return entry != nil && entry.IsDirectory
}
// validateTableBucketObjectPathOrVerify is validateTableBucketObjectPath that
// also accepts the external-volume verification key, and reports it so the
// PUT handler can apply its limits.
func (s3a *S3ApiServer) validateTableBucketObjectPathOrVerify(bucket, object string) (isVerify bool, err error) {
if !s3a.isTableBucket(bucket) {
return false, nil
}
if isVolumeVerifyKey(object) && !s3a.volumeVerifyKeyIsDirectory(bucket, object) {
return true, nil
}
return false, s3a.validateTableBucketObjectPath(bucket, object)
}
// volumeVerifyPutAllowed reports whether a PUT to the verification key is
// dated now, declares a small body and stores nothing beside it.
func volumeVerifyPutAllowed(r *http.Request, object string) bool {
at, ok := volumeVerifyKeyTime(object)
if !ok {
return false
}
if d := volumeVerifyNow().Sub(at); d > volumeVerifyWindow || d < -volumeVerifyWindow {
return false
}
if r.ContentLength < 0 || r.ContentLength > volumeVerifyMaxBytes || len(r.TransferEncoding) > 0 {
return false
}
if isExtractZip(r) {
return false
}
for name, values := range r.Header {
n := strings.ToLower(name)
if strings.HasPrefix(n, "x-amz-meta-") || n == "x-amz-tagging" || n == "content-disposition" ||
n == "response-content-disposition" || n == "content-language" ||
n == "cache-control" || n == "expires" || n == "x-amz-website-redirect-location" {
return false
}
// aws-chunked only frames the body; any other coding is stored.
if n == "content-encoding" && storedContentEncoding(values) != "" {
return false
}
}
return true
}
func (s3a *S3ApiServer) bucketPrefix(bucket string) string {
return s3a.bucketDir(bucket) + "/"
}
+2 -2
View File
@@ -804,7 +804,7 @@ func (s3a *S3ApiServer) GetObjectHandler(w http.ResponseWriter, r *http.Request)
bucket, object := s3_constants.GetBucketAndObject(r)
glog.V(3).Infof("GetObjectHandler %s %s", bucket, object)
if err := s3a.validateTableBucketObjectPath(bucket, object); err != nil {
if _, err := s3a.validateTableBucketObjectPathOrVerify(bucket, object); err != nil {
s3err.WriteErrorResponse(w, r, s3err.ErrAccessDenied)
return
}
@@ -2499,7 +2499,7 @@ func (s3a *S3ApiServer) HeadObjectHandler(w http.ResponseWriter, r *http.Request
bucket, object := s3_constants.GetBucketAndObject(r)
glog.V(3).Infof("HeadObjectHandler %s %s", bucket, object)
if err := s3a.validateTableBucketObjectPath(bucket, object); err != nil {
if _, err := s3a.validateTableBucketObjectPathOrVerify(bucket, object); err != nil {
s3err.WriteErrorResponse(w, r, s3err.ErrAccessDenied)
return
}
+2 -2
View File
@@ -210,7 +210,7 @@ func (s3a *S3ApiServer) DeleteObjectHandler(w http.ResponseWriter, r *http.Reque
// The filer ops below each retry, and a failover walk runs the whole set
// once per filer, so the backoff comes out of one allowance held here.
r = r.WithContext(withFilerRetryBudget(r.Context(), filerRetryRequestBudget))
if err := s3a.validateTableBucketObjectPath(bucket, object); err != nil {
if _, err := s3a.validateTableBucketObjectPathOrVerify(bucket, object); err != nil {
s3err.WriteErrorResponse(w, r, s3err.ErrAccessDenied)
return
}
@@ -479,7 +479,7 @@ func (s3a *S3ApiServer) DeleteMultipleObjectsHandler(w http.ResponseWriter, r *h
deleteErrors = append(deleteErrors, deleteErrorFromCode(validationCode, object.Key, object.VersionId))
continue
}
if err := s3a.validateTableBucketObjectPath(bucket, object.Key); err != nil {
if _, err := s3a.validateTableBucketObjectPathOrVerify(bucket, object.Key); err != nil {
deleteErrors = append(deleteErrors, deleteErrorFromCode(s3err.ErrAccessDenied, object.Key, object.VersionId))
continue
}
+6 -2
View File
@@ -112,7 +112,7 @@ func (s3a *S3ApiServer) PutObjectHandler(w http.ResponseWriter, r *http.Request)
s3err.WriteErrorResponse(w, r, s3err.ErrKeyTooLongError)
return
}
if err := s3a.validateTableBucketObjectPath(bucket, object); err != nil {
if isVerify, err := s3a.validateTableBucketObjectPathOrVerify(bucket, object); err != nil || (isVerify && !volumeVerifyPutAllowed(r, object)) {
s3err.WriteErrorResponse(w, r, s3err.ErrAccessDenied)
return
}
@@ -991,7 +991,11 @@ func (s3a *S3ApiServer) putToFiler(r *http.Request, filePath string, dataReader
}
glog.V(3).Infof("putToFiler: Calling CreateEntry for %s", filePath)
err := filer_pb.CreateEntry(context.Background(), client, req)
if errors.Is(err, filer_pb.ErrExistingIsDirectory) && !isReservedDirectoryName(entry.Name) {
// A verification key in a table bucket never becomes a prefix
// object: the directory it meets is a namespace, created after the
// handler looked.
if errors.Is(err, filer_pb.ErrExistingIsDirectory) && !isReservedDirectoryName(entry.Name) &&
!(isVolumeVerifyKey(object) && s3a.isTableBucket(bucket)) {
// Other keys are nested under this one. S3 keys are flat, so the key is
// stored on the directory they live under rather than refused.
entry.MarkPrefixObject()
@@ -0,0 +1,163 @@
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)
}