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


##########
io/gocloud/blob.go:
##########
@@ -188,25 +284,52 @@ func (bfs *blobFileIO) NewWriter(ctx context.Context, 
path string, overwrite boo
                nil
 }
 
-func createBlobFS(ctx context.Context, bucket *blob.Bucket, keyExtractor 
KeyExtractor) icebergio.IO {
-       return &blobFileIO{Bucket: bucket, keyExtractor: keyExtractor, ctx: ctx}
+func createBlobFS(ctx context.Context, bucket *blob.Bucket, keyExtractor 
KeyExtractor, extractObject ...objectLocationExtractor) icebergio.IO {

Review Comment:
   I'd make `extractObject` a required parameter rather than variadic.
   
   The variadic reads as composable but isn't — only `[0]` is ever used, a 
second arg is silently dropped, and `createBlobFS(ctx, bucket, ext, nil)` is 
legal and quietly installs a nil extractor that sends every call down the 
fallback path below. The only callers relying on the optionality are a few old 
3-arg test helpers; I'd rather update those four call sites than keep a 
fallback that papers over a real semantic difference.



##########
io/config.go:
##########
@@ -17,6 +17,14 @@
 
 package io
 
+// Constants for generic object store configuration options.
+const (
+       // ObjectStoreStrictAuthorityValidation rejects fully-qualified object 
paths
+       // whose URI authority differs from the bucket or container used to 
create
+       // the FileIO.
+       ObjectStoreStrictAuthorityValidation = 
"object-store.strict-authority-validation"

Review Comment:
   This sits at the top level next to `s3.*`/`gcs.*`/`adls.*`, all of which are 
mirrored in Java and PyIceberg — but `object-store.strict-authority-validation` 
is Go-only. Java's `S3FileIO`/`ADLSFileIO` and PyIceberg's fsspec create 
per-authority FileIO instances and have no cross-authority knob, so a user who 
sets this in table properties expecting portable behavior gets it honored only 
by the gocloud backend and silently ignored elsewhere.
   
   I'd either move it into the gocloud package unexported and wire it through 
the functional option you already have, or, if it stays exported, add a doc 
comment that it's honored only by the gocloud FileIO and ignored by 
Java/PyIceberg/Rust. wdyt?



##########
io/gocloud/blob.go:
##########
@@ -69,33 +68,130 @@ 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
-func defaultKeyExtractor(bucketName string) KeyExtractor {
+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) {
+               if rest[authorityEnd] != '/' {
+                       return objectLocation{}, fmt.Errorf("URI authority %q 
must be followed by an object path: %s",
+                               authority, location)
+               }
+
+               key = strings.TrimPrefix(rest[authorityEnd:], "/")
+       }
+
+       return objectLocation{
+               authority:    authority,
+               key:          key,
+               uriPrefix:    scheme + "://" + authority + "/",
+               hasAuthority: true,
+       }, nil
+}
+
+type keyExtractorConfig struct {
+       strictAuthorityValidation bool
+}
+
+type keyExtractorOption func(*keyExtractorConfig)
+
+func withStrictAuthorityValidation() keyExtractorOption {
+       return func(cfg *keyExtractorConfig) {
+               cfg.strictAuthorityValidation = true
+       }
+}
+
+type objectLocationExtractor func(location string) (objectLocation, error)
+
+func keyExtractorFromObjectLocation(extract objectLocationExtractor) 
KeyExtractor {
        return func(location string) (string, error) {
-               _, after, found := strings.Cut(location, "://")
-               if found {
-                       location = after
+               parsed, err := extract(location)
+               if err != nil {
+                       return "", err
+               }
+
+               return parsed.key, nil
+       }
+}
+
+func legacyAuthorityKey(parsed objectLocation) string {
+       if parsed.key == "" {
+               return parsed.authority + "/"
+       }
+
+       return parsed.authority + "/" + parsed.key
+}
+
+func defaultObjectLocationExtractor(bucketName string, opts 
...keyExtractorOption) objectLocationExtractor {
+       var cfg keyExtractorConfig
+       for _, opt := range opts {
+               opt(&cfg)
+       }
+
+       return func(location string) (objectLocation, error) {
+               parsed, err := splitObjectLocation(location)
+               if err != nil {
+                       return objectLocation{}, err
                }
 
-               key := strings.TrimPrefix(location, bucketName+"/")
+               if parsed.hasAuthority {
+                       if parsed.authority != bucketName {
+                               if cfg.strictAuthorityValidation {
+                                       return objectLocation{}, 
fmt.Errorf("URI authority %q does not match configured authority %q",
+                                               parsed.authority, bucketName)
+                               }
 
-               if key == "" {
-                       return "", fmt.Errorf("URI path is empty: %s", location)
+                               parsed.key = legacyAuthorityKey(parsed)

Review Comment:
   In non-strict mode a cross-authority URI silently becomes a key inside the 
configured bucket, which I think is riskier than the old behavior.
   
   `s3://data-bucket/snap-1.avro` against a FileIO bound to `meta-bucket` 
writes an object at key `data-bucket/snap-1.avro` inside `meta-bucket` and 
reports success, where the SDK previously returned a clear not-found. I'd 
either make strict the default with an explicit 
`object-store.allow-cross-authority` opt-out, or at minimum emit a structured 
warning here so a misconfigured FileIO leaves a trace instead of silently 
misplacing data. Which way would you prefer — strict-by-default, or keep the 
legacy fold but make it loud?



##########
io/gocloud/blob.go:
##########
@@ -188,25 +284,52 @@ func (bfs *blobFileIO) NewWriter(ctx context.Context, 
path string, overwrite boo
                nil
 }
 
-func createBlobFS(ctx context.Context, bucket *blob.Bucket, keyExtractor 
KeyExtractor) icebergio.IO {
-       return &blobFileIO{Bucket: bucket, keyExtractor: keyExtractor, ctx: ctx}
+func createBlobFS(ctx context.Context, bucket *blob.Bucket, keyExtractor 
KeyExtractor, extractObject ...objectLocationExtractor) icebergio.IO {
+       var extractor objectLocationExtractor
+       if len(extractObject) > 0 {
+               extractor = extractObject[0]
+       }
+
+       return &blobFileIO{Bucket: bucket, keyExtractor: keyExtractor, 
extractObject: extractor, ctx: ctx}
+}
+
+func (bfs *blobFileIO) objectLocation(root string) (objectLocation, error) {
+       if bfs.extractObject != nil {
+               return bfs.extractObject(root)
+       }
+
+       location, err := splitObjectLocation(root)
+       if err != nil {
+               return objectLocation{}, err
+       }
+
+       key, err := bfs.preprocess(root)
+       location.key = key
+
+       return location, err
 }
 
 func (bfs *blobFileIO) WalkDir(root string, fn fs.WalkDirFunc) error {
-       parsed, err := url.Parse(root)
+       location, err := bfs.objectLocation(root)
+       walkPath := location.key
        if err != nil {
-               return fmt.Errorf("invalid URL %s: %w", root, err)
-       }
+               if !errors.Is(err, errEmptyObjectKey) {
+                       return &fs.PathError{Op: "walk dir", Path: root, Err: 
err}
+               }
 
-       walkPath := strings.TrimPrefix(parsed.Path, "/")
-       if walkPath == "" {
                walkPath = "."
        }
 
-       parsed.Path = ""
-
        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 = location.uriPrefix
+                       } else {
+                               path = location.uriPrefix + path

Review Comment:
   The same doubled-authority bug surfaces here on the non-strict path even 
with `extractObject` set: `defaultObjectLocationExtractor` folds the authority 
into `parsed.key` via `legacyAuthorityKey` but leaves `uriPrefix` as the 
original `s3://other-bucket/`, so `walkPath = "other-bucket/data"`, the walk 
yields `other-bucket/data/file.parquet`, and this line prepends 
`s3://other-bucket/` to give `s3://other-bucket/other-bucket/data/file.parquet`.
   
   A table case would lock this down: write a file at 
`other-bucket/data/file.parquet`, walk `s3://other-bucket/data` non-strict 
against a FileIO bound to `my-bucket`, and assert no returned path contains a 
duplicated authority. Every current WalkDir test uses a matching authority or 
strict mode, so this path is unguarded.



##########
io/gocloud/blob.go:
##########
@@ -188,25 +284,52 @@ func (bfs *blobFileIO) NewWriter(ctx context.Context, 
path string, overwrite boo
                nil
 }
 
-func createBlobFS(ctx context.Context, bucket *blob.Bucket, keyExtractor 
KeyExtractor) icebergio.IO {
-       return &blobFileIO{Bucket: bucket, keyExtractor: keyExtractor, ctx: ctx}
+func createBlobFS(ctx context.Context, bucket *blob.Bucket, keyExtractor 
KeyExtractor, extractObject ...objectLocationExtractor) icebergio.IO {
+       var extractor objectLocationExtractor
+       if len(extractObject) > 0 {
+               extractor = extractObject[0]
+       }
+
+       return &blobFileIO{Bucket: bucket, keyExtractor: keyExtractor, 
extractObject: extractor, ctx: ctx}
+}
+
+func (bfs *blobFileIO) objectLocation(root string) (objectLocation, error) {
+       if bfs.extractObject != nil {
+               return bfs.extractObject(root)
+       }
+
+       location, err := splitObjectLocation(root)
+       if err != nil {
+               return objectLocation{}, err
+       }
+
+       key, err := bfs.preprocess(root)

Review Comment:
   This fallback computes the key two incompatible ways and they disagree on 
cross-authority input.
   
   For `s3://other-bucket/path`, `splitObjectLocation` leaves 
`location.uriPrefix = "s3://other-bucket/"` with `key = "path"`, but 
`bfs.preprocess(root)` (non-strict `defaultKeyExtractor`) then overwrites 
`location.key` with `legacyAuthorityKey` = `"other-bucket/path"` while 
`uriPrefix` still points at `other-bucket`. WalkDir reconstructs 
`s3://other-bucket/other-bucket/path` — authority doubled. If we make 
`extractObject` required (see the `createBlobFS` comment) this branch goes away 
entirely; if it stays, the key needs to come from `splitObjectLocation` too so 
prefix and key agree.



-- 
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