laskoviymishka commented on code in PR #1259:
URL: https://github.com/apache/iceberg-go/pull/1259#discussion_r3466850027


##########
io/gocloud/blob.go:
##########
@@ -69,22 +68,70 @@ func (f *blobOpenFile) Sys() any                   { return 
f.b }
 func (f *blobOpenFile) IsDir() bool                { return false }
 func (f *blobOpenFile) Stat() (fs.FileInfo, error) { return f, nil }
 
-// KeyExtractor extracts the object key from an input path
+// KeyExtractor extracts the object key from an input path.
 type KeyExtractor func(path string) (string, error)
 
-// defaultKeyExtractor extracts the object key by removing the scheme and 
bucket name from the URI
-// e.g., s3://bucket/path/file -> path/file
+// ErrEmptyObjectKey is returned when a location names a bucket or authority
+// without an object key.
+var ErrEmptyObjectKey = errors.New("object key is empty")
+
+type objectLocation struct {
+       authority    string
+       key          string
+       uriPrefix    string
+       hasAuthority bool
+}
+
+func splitObjectLocation(location string) (objectLocation, error) {
+       scheme, rest, ok := strings.Cut(location, "://")
+       if !ok {
+               return objectLocation{key: location}, nil
+       }
+
+       authorityEnd := strings.IndexAny(rest, "/?#")
+       if authorityEnd == -1 {
+               authorityEnd = len(rest)
+       }
+
+       authority := rest[:authorityEnd]
+       if authority == "" {
+               return objectLocation{}, fmt.Errorf("URI authority is empty: 
%s", location)
+       }
+
+       key := ""
+       if authorityEnd < len(rest) && rest[authorityEnd] == '/' {
+               key = strings.TrimPrefix(rest[authorityEnd:], "/")
+       }
+
+       return objectLocation{
+               authority:    authority,
+               key:          key,
+               uriPrefix:    scheme + "://" + authority + "/",
+               hasAuthority: true,
+       }, nil
+}
+
+// defaultKeyExtractor extracts the object key by removing the scheme and 
bucket name from the URI.
+// e.g., s3://bucket/path/file -> path/file.
 func defaultKeyExtractor(bucketName string) KeyExtractor {
        return func(location string) (string, error) {
-               _, after, found := strings.Cut(location, "://")
-               if found {
-                       location = after
+               parsed, err := splitObjectLocation(location)
+               if err != nil {
+                       return "", err
                }
 
-               key := strings.TrimPrefix(location, bucketName+"/")
+               key := parsed.key
+               if parsed.hasAuthority {
+                       if parsed.authority != bucketName {

Review Comment:
   My one real hesitation about merging this: the hard authority check runs on 
the default read path, and I think it breaks a supported Iceberg layout.
   
   A table can set `write.data.path = s3://data-bucket/` while its location and 
metadata live in `s3://metadata-bucket/...`. Manifests then carry full URIs 
like `s3://data-bucket/data/00000.parquet`. As I read it, the scan opens those 
through the single table IO keyed to the metadata bucket — so 
`parsed.authority` is `data-bucket`, `bucketName` is `metadata-bucket`, and we 
now reject a file that Java `S3FileIO` and PyIceberg read without complaint 
(neither restricts which bucket a path resolves to).
   
   I'd keep the validation but make it not the default on reads — either 
construct a per-authority IO when a data-file URI's authority differs from the 
table's, or gate strict equality behind an opt-in property. Before I lean too 
hard on this though: can you confirm the scan path really does reuse one IO 
across differing data-path authorities? If it already loads an FS per data 
path, this concern mostly evaporates and I'd drop it to a nit. wdyt?



##########
io/gocloud/blob.go:
##########
@@ -193,20 +240,30 @@ func createBlobFS(ctx context.Context, bucket 
*blob.Bucket, keyExtractor KeyExtr
 }
 
 func (bfs *blobFileIO) WalkDir(root string, fn fs.WalkDirFunc) error {
-       parsed, err := url.Parse(root)
+       walkPath, err := bfs.preprocess(root)
        if err != nil {
-               return fmt.Errorf("invalid URL %s: %w", root, err)
-       }
+               if !errors.Is(err, ErrEmptyObjectKey) {
+                       return &fs.PathError{Op: "walkdir", Path: root, Err: 
err}
+               }
 
-       walkPath := strings.TrimPrefix(parsed.Path, "/")
-       if walkPath == "" {
                walkPath = "."
        }
 
-       parsed.Path = ""
+       location, err := splitObjectLocation(root)

Review Comment:
   We parse `root` twice here — once via `preprocess` (which runs 
`keyExtractor`) and again directly through `splitObjectLocation` — then use the 
second parse's `uriPrefix` to reconstruct the callback paths.
   
   That's fine for `defaultKeyExtractor`, but for any extractor whose authority 
boundary differs from the naive up-to-`/?#` cut — `adlsKeyExtractor` today, or 
any custom one — the prefix used for reconstruction can diverge from what the 
extractor actually accepted. The two parsers disagreeing is exactly the kind of 
bug that stays invisible until someone plugs in a non-default extractor.
   
   I'd parse once and share the `objectLocation` for both the key extraction 
and the prefix reconstruction, so there's a single source of truth for the 
authority boundary.



##########
io/gocloud/blob_test.go:
##########
@@ -229,13 +333,67 @@ func TestBlobFileIOWalkDir(t *testing.T) {
        require.NoError(t, err)
 
        expected := []string{
+               "s3://test-bucket/data/100%off/file.parquet",
+               "s3://test-bucket/data/city=New York/file.parquet",
                "s3://test-bucket/data/file1.parquet",
                "s3://test-bucket/data/file2.parquet",
                "s3://test-bucket/metadata/snap-123.avro",
        }
        assert.ElementsMatch(t, expected, walked)
 }
 
+func TestBlobFileIOWalkDirRejectsWrongBucket(t *testing.T) {
+       ctx := context.Background()
+
+       bucket := memblob.OpenBucket(nil)
+       defer bucket.Close()
+
+       require.NoError(t, bucket.WriteAll(ctx, "data/file1.parquet", 
[]byte("content"), nil))
+
+       bfs := &blobFileIO{
+               Bucket:       bucket,
+               keyExtractor: defaultKeyExtractor("test-bucket"),
+               ctx:          ctx,
+       }
+
+       for _, tt := range []struct {
+               name string
+               root string
+       }{
+               {name: "s3", root: "s3://other-bucket/"},
+               {name: "gcs", root: "gs://other-bucket/data"},
+       } {
+               t.Run(tt.name, func(t *testing.T) {
+                       err := bfs.WalkDir(tt.root, func(string, fs.DirEntry, 
error) error {
+                               t.Fatal("WalkDir callback should not be called")
+
+                               return nil
+                       })
+                       require.ErrorContains(t, err, "does not match 
configured authority")
+               })
+       }
+}
+
+func TestBlobFileIOWalkDirRejectsWrongAzureAuthority(t *testing.T) {
+       ctx := context.Background()
+
+       bucket := memblob.OpenBucket(nil)
+       defer bucket.Close()
+
+       bfs := &blobFileIO{
+               Bucket:       bucket,
+               keyExtractor: 
defaultKeyExtractor("[email protected]"),

Review Comment:
   This test doesn't actually exercise the Azure path. Production Azure/ADLS 
schemes are registered with `adlsKeyExtractor` (register.go), which extracts 
the path but never compares the authority — so 
`WriteFile("abfs://other-container@account.../data/file.parquet")` still 
silently lands in the opened container with the old bug intact.
   
   The test only rejects because it wires up `defaultKeyExtractor` here, so 
it's proving the S3 extractor's behavior under an Azure-looking string rather 
than the real Azure code path. As written it implies a cross-container 
protection that doesn't exist.
   
   I'd either add a configured authority + mismatch check to `adlsKeyExtractor` 
and point this test at it, or drop the test and note in the PR that ADLS 
authority validation is out of scope for now.



##########
io/gocloud/blob.go:
##########
@@ -69,22 +68,70 @@ func (f *blobOpenFile) Sys() any                   { return 
f.b }
 func (f *blobOpenFile) IsDir() bool                { return false }
 func (f *blobOpenFile) Stat() (fs.FileInfo, error) { return f, nil }
 
-// KeyExtractor extracts the object key from an input path
+// KeyExtractor extracts the object key from an input path.
 type KeyExtractor func(path string) (string, error)
 
-// defaultKeyExtractor extracts the object key by removing the scheme and 
bucket name from the URI
-// e.g., s3://bucket/path/file -> path/file
+// ErrEmptyObjectKey is returned when a location names a bucket or authority
+// without an object key.
+var ErrEmptyObjectKey = errors.New("object key is empty")
+
+type objectLocation struct {
+       authority    string
+       key          string
+       uriPrefix    string
+       hasAuthority bool
+}
+
+func splitObjectLocation(location string) (objectLocation, error) {
+       scheme, rest, ok := strings.Cut(location, "://")
+       if !ok {
+               return objectLocation{key: location}, nil
+       }
+
+       authorityEnd := strings.IndexAny(rest, "/?#")
+       if authorityEnd == -1 {
+               authorityEnd = len(rest)
+       }
+
+       authority := rest[:authorityEnd]
+       if authority == "" {
+               return objectLocation{}, fmt.Errorf("URI authority is empty: 
%s", location)
+       }
+
+       key := ""

Review Comment:
   A malformed authority-only URI quietly degrades into a full bucket walk, 
which surprised me. For `s3://my-bucket?foo/bar`, `IndexAny` stops at `?`, the 
authority is `my-bucket`, and since `rest[authorityEnd]` is `?` not `/`, `key` 
stays empty. That bubbles up as `ErrEmptyObjectKey`, which `WalkDir` then 
treats as a bucket root and promotes to `walkPath = "."` — walking the entire 
bucket from a malformed input.
   
   Iceberg locations shouldn't carry a query or fragment, so this is an edge, 
but silently turning a bad URI into a full walk feels risky. I'd either reject 
a non-`/` continuation after the authority explicitly, or at least keep that 
case from reaching the walk-everything fallback.



##########
io/gocloud/blob.go:
##########
@@ -193,20 +240,30 @@ func createBlobFS(ctx context.Context, bucket 
*blob.Bucket, keyExtractor KeyExtr
 }
 
 func (bfs *blobFileIO) WalkDir(root string, fn fs.WalkDirFunc) error {
-       parsed, err := url.Parse(root)
+       walkPath, err := bfs.preprocess(root)
        if err != nil {
-               return fmt.Errorf("invalid URL %s: %w", root, err)
-       }
+               if !errors.Is(err, ErrEmptyObjectKey) {
+                       return &fs.PathError{Op: "walkdir", Path: root, Err: 
err}
+               }
 
-       walkPath := strings.TrimPrefix(parsed.Path, "/")
-       if walkPath == "" {
                walkPath = "."
        }
 
-       parsed.Path = ""
+       location, err := splitObjectLocation(root)
+       if err != nil {
+               return &fs.PathError{Op: "walkdir", Path: root, Err: err}
+       }
 
        return fs.WalkDir(bfs.Bucket, walkPath, func(path string, d 
fs.DirEntry, err error) error {
-               return fn(parsed.JoinPath(path).String(), d, err)
+               if location.hasAuthority {
+                       if path == "." {
+                               path = strings.TrimSuffix(location.uriPrefix, 
"/")

Review Comment:
   The root-entry path changed shape here. The old code returned 
`parsed.JoinPath(".")` → `s3://bucket/` for the root entry; this returns 
`strings.TrimSuffix(uriPrefix, "/")` → `s3://bucket` (no trailing slash), while 
every child is `uriPrefix + path` → `s3://bucket/...`. So the root entry no 
longer matches the form callers passed in as the walk root.
   
   Current callers all filter with `!d.IsDir()`, so the root `"."` entry is 
skipped and nothing breaks today — but it's a silent behavior change waiting 
for the first caller that compares a walked entry against the stored root. I'd 
use `uriPrefix` consistently for the `"."` case so the root keeps its trailing 
slash.



##########
io/gocloud/blob_test.go:
##########
@@ -57,25 +85,60 @@ func TestDefaultKeyExtractor(t *testing.T) {
                        input:       "gs://my-bucket/path/to/file.parquet",
                        expectedKey: "path/to/file.parquet",
                },
+               {
+                       name:        "azure URI with path",
+                       bucketName:  "[email protected]",
+                       input:       
"abfs://[email protected]/path/to/file.parquet",
+                       expectedKey: "path/to/file.parquet",
+               },
+               {
+                       name:            "s3 URI with different bucket",
+                       input:           
"s3://other-bucket/path/to/file.parquet",
+                       wantErrContains: "does not match configured authority",
+               },
+               {
+                       name:            "gs URI with different bucket",
+                       input:           
"gs://other-bucket/path/to/file.parquet",
+                       wantErrContains: "does not match configured authority",
+               },
+               {
+                       name:            "azure URI with different container",
+                       bucketName:      
"[email protected]",
+                       input:           
"abfs://[email protected]/path/to/file.parquet",
+                       wantErrContains: "does not match configured authority",
+               },
                {
                        name:        "URI with query and fragment",
                        input:       
"s3://my-bucket/path/to/file.parquet?param=value#fragment",
                        expectedKey: 
"path/to/file.parquet?param=value#fragment",
                },
                {
-                       name:        "URI with empty path",
-                       input:       "s3://my-bucket/",
-                       shouldError: true,
+                       name:            "URI with empty path",
+                       input:           "s3://my-bucket/",
+                       wantErrContains: "object key is empty",
+                       wantErrIs:       ErrEmptyObjectKey,
+               },
+               {
+                       name:            "URI with empty authority",
+                       input:           "s3:///path/to/file.parquet",
+                       wantErrContains: "URI authority is empty",
                },
        }
 
-       extractor := defaultKeyExtractor("my-bucket")
        for _, test := range tests {
                t.Run(test.name, func(t *testing.T) {
+                       bucketName := test.bucketName
+                       if bucketName == "" {
+                               bucketName = "my-bucket"
+                       }
+                       extractor := defaultKeyExtractor(bucketName)
                        key, err := extractor(test.input)
 
-                       if test.shouldError {
-                               assert.Error(t, err, "Expected error for input: 
%s", test.input)
+                       if test.wantErrContains != "" {
+                               assert.ErrorContains(t, err, 
test.wantErrContains, "Expected error for input: %s", test.input)
+                               if test.wantErrIs != nil {
+                                       assert.True(t, errors.Is(err, 
test.wantErrIs), "expected errors.Is(%v)", test.wantErrIs)

Review Comment:
   `assert.ErrorIs(t, err, test.wantErrIs)` reads better here and prints the 
actual error chain on failure, versus `assert.True` over a bare bool.
   
   Separately, the gating assertions in this table are `assert`, not `require` 
— on a nil-when-error-expected case the `ErrorContains` fails but execution 
continues into this `errors.Is` check (and on the success path `NoError` 
continues into `Equal`), so one root cause produces two failures. I'd make the 
gate a `require`.



##########
io/gocloud/blob.go:
##########
@@ -69,22 +68,70 @@ func (f *blobOpenFile) Sys() any                   { return 
f.b }
 func (f *blobOpenFile) IsDir() bool                { return false }
 func (f *blobOpenFile) Stat() (fs.FileInfo, error) { return f, nil }
 
-// KeyExtractor extracts the object key from an input path
+// KeyExtractor extracts the object key from an input path.
 type KeyExtractor func(path string) (string, error)
 
-// defaultKeyExtractor extracts the object key by removing the scheme and 
bucket name from the URI
-// e.g., s3://bucket/path/file -> path/file
+// ErrEmptyObjectKey is returned when a location names a bucket or authority
+// without an object key.
+var ErrEmptyObjectKey = errors.New("object key is empty")

Review Comment:
   `ErrEmptyObjectKey` is exported, but its only consumer is the internal 
`errors.Is` in `WalkDir` — bucket-root handling is otherwise transparent to 
callers. Exporting it is a permanent API commitment, so I'd either unexport it 
to `errEmptyObjectKey`, or document it explicitly as part of the `KeyExtractor` 
author contract ("custom extractors must wrap this for bucket-root inputs") — 
which also ties into the `adlsKeyExtractor` gap.



##########
io/gocloud/blob.go:
##########
@@ -193,20 +240,30 @@ func createBlobFS(ctx context.Context, bucket 
*blob.Bucket, keyExtractor KeyExtr
 }
 
 func (bfs *blobFileIO) WalkDir(root string, fn fs.WalkDirFunc) error {
-       parsed, err := url.Parse(root)
+       walkPath, err := bfs.preprocess(root)
        if err != nil {
-               return fmt.Errorf("invalid URL %s: %w", root, err)
-       }
+               if !errors.Is(err, ErrEmptyObjectKey) {
+                       return &fs.PathError{Op: "walkdir", Path: root, Err: 
err}

Review Comment:
   Small one: the rest of this file uses space-separated op names (`"write 
file"`, `"new writer"`), so I'd make this `"walk dir"` to match.
   
   While we're here — the authority-mismatch error is a plain `fmt.Errorf` with 
no sentinel, so callers can't `errors.Is` it the way they can 
`ErrEmptyObjectKey`. If we expect anyone to branch on it, an exported 
`ErrWrongAuthority` wrapped with `%w` would be more consistent. Not blocking.



##########
io/gocloud/blob_test.go:
##########
@@ -84,6 +147,45 @@ func TestDefaultKeyExtractor(t *testing.T) {
        }
 }
 
+func TestBlobFileIORejectsWrongBucketObjectPaths(t *testing.T) {
+       ctx := context.Background()
+
+       bucket := memblob.OpenBucket(nil)
+       defer bucket.Close()
+
+       bfs := &blobFileIO{
+               Bucket:       bucket,
+               keyExtractor: defaultKeyExtractor("test-bucket"),
+               ctx:          ctx,
+       }
+
+       for _, tt := range []struct {
+               name   string
+               path   string
+               oldKey string
+       }{
+               {
+                       name:   "s3",
+                       path:   "s3://other-bucket/data/file.parquet",
+                       oldKey: "other-bucket/data/file.parquet",
+               },
+               {
+                       name:   "gcs",
+                       path:   "gs://other-bucket/data/file.parquet",
+                       oldKey: "other-bucket/data/file.parquet",
+               },
+       } {
+               t.Run(tt.name, func(t *testing.T) {
+                       err := bfs.WriteFile(tt.path, []byte("content"))
+                       require.ErrorContains(t, err, "does not match 
configured authority")
+
+                       exists, err := bucket.Exists(ctx, tt.oldKey)

Review Comment:
   This `oldKey` existence check is vacuously true — nothing ever seeds 
`oldKey`, so `Exists` returns false even if the write had been misrouted 
somewhere else, and the `require.ErrorContains` above already guarantees no 
write happened.
   
   If we want this to actually prove "not misrouted to the old key", seed 
`oldKey` with a known sentinel first and assert it's unchanged after the failed 
write. Otherwise I'd just drop the `Exists` block — the `ErrorContains` carries 
the assertion on its own.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to