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


##########
table/locations.go:
##########
@@ -50,8 +52,28 @@ type simpleLocationProvider struct {
        metadataPath *url.URL
 }
 
+// Native paths must not be parsed as URLs: percent signs, fragments, and

Review Comment:
   Worth making this a proper godoc that starts with the function name and 
spells out the contract the rest of the file leans on: an empty scheme means 
"native path carried in `URL.Path`", anything else is a real URI. 
`joinLocationPath` has no comment at all, and that empty-scheme convention is 
load-bearing for every caller, so it shouldn't be implicit.



##########
table/locations.go:
##########
@@ -50,8 +52,28 @@ type simpleLocationProvider struct {
        metadataPath *url.URL
 }
 
+// Native paths must not be parsed as URLs: percent signs, fragments, and
+// query delimiters are literal filename characters.
+func parseLocationPath(location string) (*url.URL, error) {
+       schemeEnd := strings.IndexByte(location, ':')
+       separator := strings.IndexAny(location, `/\`)
+       if filepath.VolumeName(location) != "" || schemeEnd < 0 || (separator 
>= 0 && separator < schemeEnd) {
+               return &url.URL{Path: location}, nil
+       }
+
+       return url.Parse(location)
+}
+
+func joinLocationPath(location *url.URL, elem ...string) string {
+       if location.Scheme == "" {
+               return filepath.Join(append([]string{location.Path}, 
elem...)...)

Review Comment:
   `filepath.Join` emits OS-native separators and runs `Clean`, and both land 
in the strings we persist to manifests and metadata. On Windows that writes 
backslash paths other clients can't resolve, and `Clean` quietly collapses 
`.`/`..`/trailing slashes in names we should be treating as opaque. I'd join 
with `path.Join` (forward slashes) and convert to native separators only at the 
I/O boundary.



##########
table/locations_test.go:
##########
@@ -18,16 +18,87 @@
 package table_test
 
 import (
+       "net/url"
+       "os"
+       "path/filepath"
        "strings"
        "testing"
 
        "github.com/apache/iceberg-go"
+       "github.com/apache/iceberg-go/io"
        "github.com/apache/iceberg-go/table"
        "github.com/google/uuid"
        "github.com/stretchr/testify/assert"
        "github.com/stretchr/testify/require"
 )
 
+func TestLocationProviderLocalPaths(t *testing.T) {
+       for _, objectStore := range []string{"false", "true"} {
+               for _, partitionPaths := range []string{"false", "true"} {
+                       for _, name := range []string{"my table", 
"literal%20table", "100% complete", "table#fragment", "table?query"} {
+                               t.Run(objectStore+"/"+partitionPaths+"/"+name, 
func(t *testing.T) {
+                                       if strings.Contains(name, "?") && 
os.PathSeparator == '\\' {
+                                               t.Skip("question marks are not 
valid in Windows filenames")
+                                       }
+                                       root := filepath.Join(t.TempDir(), name)
+                                       for _, custom := range []bool{false, 
true} {
+                                               props := iceberg.Properties{
+                                                       
table.ObjectStoreEnabledKey:               objectStore,
+                                                       
table.WriteObjectStorePartitionedPathsKey: partitionPaths,
+                                               }
+                                               dataDir, metadataDir := 
filepath.Join(root, "data"), filepath.Join(root, "metadata")
+                                               if custom {
+                                                       dataDir, metadataDir = 
filepath.Join(root, "custom data"), filepath.Join(root, "custom metadata")
+                                                       
props[table.WriteDataPathKey] = dataDir
+                                                       
props[table.WriteMetadataPathKey] = metadataDir
+                                               }
+                                               provider, err := 
table.LoadLocationProvider(root, props)
+                                               require.NoError(t, err)
+                                               metadataFile, err := 
provider.NewTableMetadataFileLocation(1)
+                                               require.NoError(t, err)
+                                               for _, file := range []struct{ 
location, dir string }{
+                                                       
{provider.NewDataLocation("data.parquet"), dataDir},
+                                                       
{provider.NewDataLocation("partition=value/data.parquet"), dataDir},
+                                                       
{provider.NewMetadataLocation("manifest.avro"), metadataDir},
+                                                       {metadataFile, 
metadataDir},
+                                               } {
+                                                       require.True(t, 
strings.HasPrefix(file.location, file.dir+string(os.PathSeparator)), "%q is 
outside %q", file.location, file.dir)
+                                                       writer, err := 
(io.LocalFS{}).Create(file.location)
+                                                       require.NoError(t, err)
+                                                       require.NoError(t, 
writer.Close())
+                                                       _, err = 
os.Stat(file.location)
+                                                       require.NoError(t, err)
+                                               }
+                                       }
+                               })
+                       }
+               }
+       }
+}
+
+func TestLocationProviderEscapedURIs(t *testing.T) {
+       for _, scheme := range []string{"file", "s3", "gs", "az"} {
+               t.Run(scheme, func(t *testing.T) {
+                       location := (&url.URL{Scheme: scheme, Host: "bucket", 
Path: "/my table/literal%20#?"}).String()
+                       provider, err := table.LoadLocationProvider(location, 
nil)
+                       require.NoError(t, err)
+                       assert.Equal(t, location+"/data/data.parquet", 
provider.NewDataLocation("data.parquet"))
+                       assert.Equal(t, location+"/metadata/manifest.avro", 
provider.NewMetadataLocation("manifest.avro"))
+               })
+       }
+}
+
+func TestLocationProviderRelativePaths(t *testing.T) {
+       for _, location := range []string{"my table", "100% complete", 
"literal%20table", "table#fragment", "table?query", "dir/table:part%20"} {
+               t.Run(location, func(t *testing.T) {
+                       provider, err := table.LoadLocationProvider(location, 
nil)
+                       require.NoError(t, err)
+                       assert.Equal(t, filepath.Join(location, "data", 
"data.parquet"), provider.NewDataLocation("data.parquet"))

Review Comment:
   These expectations are computed with the same `filepath.Join` the 
implementation uses, so the test can't catch the separator change: it'll pass 
on Windows even though the stored path flipped to backslashes. Hardcode the 
expected forward-slash strings, and add a case that would fail if a backslash 
crept in.



##########
table/locations_test.go:
##########
@@ -18,16 +18,87 @@
 package table_test
 
 import (
+       "net/url"
+       "os"
+       "path/filepath"
        "strings"
        "testing"
 
        "github.com/apache/iceberg-go"
+       "github.com/apache/iceberg-go/io"
        "github.com/apache/iceberg-go/table"
        "github.com/google/uuid"
        "github.com/stretchr/testify/assert"
        "github.com/stretchr/testify/require"
 )
 
+func TestLocationProviderLocalPaths(t *testing.T) {
+       for _, objectStore := range []string{"false", "true"} {
+               for _, partitionPaths := range []string{"false", "true"} {
+                       for _, name := range []string{"my table", 
"literal%20table", "100% complete", "table#fragment", "table?query"} {
+                               t.Run(objectStore+"/"+partitionPaths+"/"+name, 
func(t *testing.T) {
+                                       if strings.Contains(name, "?") && 
os.PathSeparator == '\\' {
+                                               t.Skip("question marks are not 
valid in Windows filenames")
+                                       }
+                                       root := filepath.Join(t.TempDir(), name)
+                                       for _, custom := range []bool{false, 
true} {
+                                               props := iceberg.Properties{
+                                                       
table.ObjectStoreEnabledKey:               objectStore,
+                                                       
table.WriteObjectStorePartitionedPathsKey: partitionPaths,
+                                               }
+                                               dataDir, metadataDir := 
filepath.Join(root, "data"), filepath.Join(root, "metadata")
+                                               if custom {
+                                                       dataDir, metadataDir = 
filepath.Join(root, "custom data"), filepath.Join(root, "custom metadata")
+                                                       
props[table.WriteDataPathKey] = dataDir
+                                                       
props[table.WriteMetadataPathKey] = metadataDir
+                                               }
+                                               provider, err := 
table.LoadLocationProvider(root, props)
+                                               require.NoError(t, err)
+                                               metadataFile, err := 
provider.NewTableMetadataFileLocation(1)
+                                               require.NoError(t, err)
+                                               for _, file := range []struct{ 
location, dir string }{
+                                                       
{provider.NewDataLocation("data.parquet"), dataDir},
+                                                       
{provider.NewDataLocation("partition=value/data.parquet"), dataDir},
+                                                       
{provider.NewMetadataLocation("manifest.avro"), metadataDir},
+                                                       {metadataFile, 
metadataDir},
+                                               } {
+                                                       require.True(t, 
strings.HasPrefix(file.location, file.dir+string(os.PathSeparator)), "%q is 
outside %q", file.location, file.dir)
+                                                       writer, err := 
(io.LocalFS{}).Create(file.location)
+                                                       require.NoError(t, err)
+                                                       require.NoError(t, 
writer.Close())
+                                                       _, err = 
os.Stat(file.location)
+                                                       require.NoError(t, err)
+                                               }
+                                       }
+                               })
+                       }
+               }
+       }
+}
+
+func TestLocationProviderEscapedURIs(t *testing.T) {
+       for _, scheme := range []string{"file", "s3", "gs", "az"} {
+               t.Run(scheme, func(t *testing.T) {
+                       location := (&url.URL{Scheme: scheme, Host: "bucket", 
Path: "/my table/literal%20#?"}).String()

Review Comment:
   The input here is built with `url.URL{...}.String()`, so it's already 
percent-encoded before it reaches the provider; parse-then-String just 
round-trips it and the assertion passes while the actual bug stands. Feed the 
raw, unencoded location instead (`s3://bucket/my table`) and assert the literal 
key comes back unchanged; that's the string Java and REST catalogs actually 
produce.



##########
table/locations.go:
##########
@@ -50,8 +52,28 @@ type simpleLocationProvider struct {
        metadataPath *url.URL
 }
 
+// Native paths must not be parsed as URLs: percent signs, fragments, and
+// query delimiters are literal filename characters.
+func parseLocationPath(location string) (*url.URL, error) {
+       schemeEnd := strings.IndexByte(location, ':')
+       separator := strings.IndexAny(location, `/\`)
+       if filepath.VolumeName(location) != "" || schemeEnd < 0 || (separator 
>= 0 && separator < schemeEnd) {

Review Comment:
   A scheme-less name with a colon before the first separator still gets sent 
to `url.Parse` here: `db:2024` parses as an opaque URL, and `my table:v2` 
errors out with "first path segment in URL cannot contain colon". That 
contradicts the helper's own premise that native paths never hit the parser. 
I'd match PyIceberg and gate on a real scheme (`^[A-Za-z][A-Za-z0-9+.-]*://`), 
falling back to a native path otherwise, with a test row for `db:2024`.



##########
table/locations.go:
##########
@@ -50,8 +52,28 @@ type simpleLocationProvider struct {
        metadataPath *url.URL
 }
 
+// Native paths must not be parsed as URLs: percent signs, fragments, and
+// query delimiters are literal filename characters.
+func parseLocationPath(location string) (*url.URL, error) {
+       schemeEnd := strings.IndexByte(location, ':')
+       separator := strings.IndexAny(location, `/\`)
+       if filepath.VolumeName(location) != "" || schemeEnd < 0 || (separator 
>= 0 && separator < schemeEnd) {
+               return &url.URL{Path: location}, nil
+       }
+
+       return url.Parse(location)
+}
+
+func joinLocationPath(location *url.URL, elem ...string) string {
+       if location.Scheme == "" {
+               return filepath.Join(append([]string{location.Path}, 
elem...)...)
+       }
+
+       return location.JoinPath(elem...).String()

Review Comment:
   This branch still round-trips scheme URIs through `url.Parse` + 
`JoinPath().String()`, so #2161 is only half-fixed: a raw `s3://bucket/my 
table` comes back as `s3://bucket/my%20table/data/...`, and a literal `%` 
double-encodes. Java and PyIceberg write and read the unencoded key, so we'd be 
pointing at a different object (or splitting one table across two prefixes). 
I'd drop the URL round-trip and join at the string level for every location 
(trim the trailing `/`, append `/elem`) so URIs and local paths both stay 
byte-identical to what the catalog gave us.



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