This is an automated email from the ASF dual-hosted git repository.
laskoviymishka pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/iceberg-go.git
The following commit(s) were added to refs/heads/main by this push:
new 6cea8d48 fix(io): parse local file URIs consistently (#1578)
6cea8d48 is described below
commit 6cea8d4843026b76ade0cba3e2c13bfe0b98bc9a
Author: Minh Vu <[email protected]>
AuthorDate: Wed Aug 5 18:57:53 2026 +0200
fix(io): parse local file URIs consistently (#1578)
* fix(io): parse local file URIs consistently
Signed-off-by: Minh Vu <[email protected]>
* fix(io): preserve ordinary local path syntax
Signed-off-by: Minh Vu <[email protected]>
* fix(io): preserve colon-containing local paths
* fix(io): support Windows file URI paths
* fix(io): normalize encoded file URIs
---------
Signed-off-by: Minh Vu <[email protected]>
---
io/local.go | 166 ++++++++++++++++++++++++++++++++++++++++++-----
io/local_test.go | 120 +++++++++++++++++++++++++++++++---
io/local_windows_test.go | 45 +++++++++++++
3 files changed, 304 insertions(+), 27 deletions(-)
diff --git a/io/local.go b/io/local.go
index d511ba8a..e44669cc 100644
--- a/io/local.go
+++ b/io/local.go
@@ -18,7 +18,9 @@
package io
import (
+ "fmt"
"io/fs"
+ "net/url"
"os"
"path/filepath"
"strings"
@@ -28,16 +30,91 @@ import (
// the local file system.
type LocalFS struct{}
+func localPath(name string) (string, error) {
+ if filepath.VolumeName(name) != "" {
+ return name, nil
+ }
+
+ schemeEnd := strings.IndexByte(name, ':')
+ firstSeparator := strings.IndexAny(name, `/\`)
+ if schemeEnd < 0 || (firstSeparator >= 0 && firstSeparator < schemeEnd)
{
+ return name, nil
+ }
+ scheme := name[:schemeEnd]
+ // A colon is valid in a native POSIX filename. Treat non-file names as
+ // URIs only when they have an authority delimiter, preserving paths
such as
+ // "partition:2026/data.parquet" while still rejecting s3:// and similar
+ // locations passed directly to LocalFS.
+ if !strings.EqualFold(scheme, "file") &&
!strings.HasPrefix(name[schemeEnd+1:], "//") {
+ return name, nil
+ }
+ if !strings.EqualFold(scheme, "file") {
+ return "", fmt.Errorf("unsupported local filesystem scheme %q",
scheme)
+ }
+
+ parsed, err := url.Parse(name)
+ if err != nil {
+ return "", fmt.Errorf("invalid local file path %q: %w", name,
err)
+ }
+ if parsed.Host != "" && !strings.EqualFold(parsed.Host, "localhost") {
+ if filepath.Separator != '\\' ||
!isWindowsDriveHost(parsed.Host) {
+ return "", fmt.Errorf("unsupported file URI authority
%q", parsed.Host)
+ }
+ }
+
+ path := parsed.Path
+ if parsed.Opaque != "" {
+ var err error
+ path, err = url.PathUnescape(parsed.Opaque)
+ if err != nil {
+ return "", fmt.Errorf("invalid local file path %q: %w",
name, err)
+ }
+ }
+
+ if filepath.Separator == '\\' && isWindowsDriveHost(parsed.Host) {
+ path = parsed.Host + path
+ } else if filepath.Separator == '\\' && isWindowsDrivePath(path) {
+ path = path[1:]
+ }
+
+ return filepath.FromSlash(path), nil
+}
+
+func isWindowsDrivePath(path string) bool {
+ return len(path) >= 3 && path[0] == '/' &&
+ ((path[1] >= 'a' && path[1] <= 'z') || (path[1] >= 'A' &&
path[1] <= 'Z')) &&
+ path[2] == ':'
+}
+
+func isWindowsDriveHost(host string) bool {
+ return len(host) == 2 &&
+ ((host[0] >= 'a' && host[0] <= 'z') || (host[0] >= 'A' &&
host[0] <= 'Z')) &&
+ host[1] == ':'
+}
+
func (LocalFS) Open(name string) (File, error) {
- return os.Open(strings.TrimPrefix(name, "file://"))
+ path, err := localPath(name)
+ if err != nil {
+ return nil, err
+ }
+
+ return os.Open(path)
}
func (LocalFS) ReadFile(name string) ([]byte, error) {
- return os.ReadFile(strings.TrimPrefix(name, "file://"))
+ path, err := localPath(name)
+ if err != nil {
+ return nil, err
+ }
+
+ return os.ReadFile(path)
}
func (LocalFS) Create(name string) (FileWriter, error) {
- filename := strings.TrimPrefix(name, "file://")
+ filename, err := localPath(name)
+ if err != nil {
+ return nil, err
+ }
if err := os.MkdirAll(filepath.Dir(filename), 0o755); err != nil {
return nil, err
}
@@ -46,7 +123,10 @@ func (LocalFS) Create(name string) (FileWriter, error) {
}
func (LocalFS) WriteFile(name string, content []byte) error {
- filename := strings.TrimPrefix(name, "file://")
+ filename, err := localPath(name)
+ if err != nil {
+ return err
+ }
if err := os.MkdirAll(filepath.Dir(filename), 0o755); err != nil {
return err
}
@@ -55,48 +135,98 @@ func (LocalFS) WriteFile(name string, content []byte)
error {
}
func (LocalFS) Remove(name string) error {
- return os.Remove(strings.TrimPrefix(name, "file://"))
+ path, err := localPath(name)
+ if err != nil {
+ return err
+ }
+
+ return os.Remove(path)
}
func (LocalFS) RemoveAll(name string) error {
- return os.RemoveAll(strings.TrimPrefix(name, "file://"))
+ path, err := localPath(name)
+ if err != nil {
+ return err
+ }
+
+ return os.RemoveAll(path)
}
func (LocalFS) WalkDir(root string, fn fs.WalkDirFunc) error {
- return filepath.WalkDir(strings.TrimPrefix(root, "file://"), fn)
+ path, err := localPath(root)
+ if err != nil {
+ return err
+ }
+
+ return filepath.WalkDir(path, fn)
}
func (LocalFS) ReadDir(name string) ([]fs.DirEntry, error) {
- return os.ReadDir(strings.TrimPrefix(name, "file://"))
+ path, err := localPath(name)
+ if err != nil {
+ return nil, err
+ }
+
+ return os.ReadDir(path)
}
-func (LocalFS) MkdirAll(path string) error {
- return os.MkdirAll(strings.TrimPrefix(path, "file://"), 0o755)
+func (LocalFS) MkdirAll(name string) error {
+ path, err := localPath(name)
+ if err != nil {
+ return err
+ }
+
+ return os.MkdirAll(path, 0o755)
}
-func (LocalFS) Mkdir(path string) error {
- return os.Mkdir(strings.TrimPrefix(path, "file://"), 0o755)
+func (LocalFS) Mkdir(name string) error {
+ path, err := localPath(name)
+ if err != nil {
+ return err
+ }
+
+ return os.Mkdir(path, 0o755)
}
func (LocalFS) Stat(name string) (fs.FileInfo, error) {
- return os.Stat(strings.TrimPrefix(name, "file://"))
+ path, err := localPath(name)
+ if err != nil {
+ return nil, err
+ }
+
+ return os.Stat(path)
}
func (LocalFS) Rename(oldpath, newpath string) error {
- return os.Rename(strings.TrimPrefix(oldpath, "file://"),
strings.TrimPrefix(newpath, "file://"))
+ src, err := localPath(oldpath)
+ if err != nil {
+ return err
+ }
+ dst, err := localPath(newpath)
+ if err != nil {
+ return err
+ }
+
+ return os.Rename(src, dst)
}
func (LocalFS) RenameNoReplace(oldpath, newpath string) error {
- oldpath = strings.TrimPrefix(oldpath, "file://")
- newpath = strings.TrimPrefix(newpath, "file://")
+ src, err := localPath(oldpath)
+ if err != nil {
+ return err
+ }
+ dst, err := localPath(newpath)
+ if err != nil {
+ return err
+ }
- if err := os.Link(oldpath, newpath); err != nil {
+ if err = os.Link(src, dst); err != nil {
return err
}
// Publishing already succeeded once the hard link exists. Removing the
// source temp file is best-effort cleanup.
- _ = os.Remove(oldpath)
+ _ = os.Remove(src)
return nil
}
diff --git a/io/local_test.go b/io/local_test.go
index 2b1d3129..addf2225 100644
--- a/io/local_test.go
+++ b/io/local_test.go
@@ -19,10 +19,10 @@ package io
import (
"io/fs"
+ "net/url"
"os"
"path/filepath"
"sort"
- "strings"
"testing"
"github.com/stretchr/testify/assert"
@@ -73,7 +73,7 @@ func TestLocalFSWalkDirWithFileScheme(t *testing.T) {
lfs := LocalFS{}
var files []string
- err := lfs.WalkDir("file://"+dir, func(path string, d fs.DirEntry, err
error) error {
+ err := lfs.WalkDir(fileURI(dir), func(path string, d fs.DirEntry, err
error) error {
if err != nil {
return err
}
@@ -93,32 +93,134 @@ func TestLocalFSWriteFileCreatesParentDirectories(t
*testing.T) {
content := []byte("content")
for _, tt := range []struct {
- name string
- path string
+ name string
+ path string
+ readPath string
}{
{
- name: "plain path",
- path: filepath.Join(dir, "plain", "nested", "file.txt"),
+ name: "plain path",
+ path: filepath.Join(dir, "plain", "nested",
"file.txt"),
+ readPath: filepath.Join(dir, "plain", "nested",
"file.txt"),
},
{
- name: "file scheme",
- path: "file://" + filepath.Join(dir, "scheme",
"nested", "file.txt"),
+ name: "file scheme",
+ path: fileURI(filepath.Join(dir, "scheme",
"nested", "file.txt")),
+ readPath: filepath.Join(dir, "scheme", "nested",
"file.txt"),
+ },
+ {
+ name: "escaped file scheme",
+ path: fileURI(filepath.Join(dir, "scheme with
space", "nested", "file.txt")),
+ readPath: filepath.Join(dir, "scheme with space",
"nested", "file.txt"),
},
} {
t.Run(tt.name, func(t *testing.T) {
require.NoError(t, LocalFS{}.WriteFile(tt.path,
content))
- got, err := os.ReadFile(strings.TrimPrefix(tt.path,
"file://"))
+ got, err := os.ReadFile(tt.readPath)
require.NoError(t, err)
assert.Equal(t, content, got)
})
}
}
+func TestLocalFSParsesFileURIs(t *testing.T) {
+ t.Parallel()
+
+ dir := t.TempDir()
+ path := filepath.Join(dir, "metadata.json")
+ require.NoError(t, os.WriteFile(path, []byte("metadata"), 0o600))
+
+ for _, name := range []string{
+ path,
+ fileURI(path),
+ singleSlashFileURI(path),
+ "file:" + filepath.ToSlash(path),
+ localhostFileURI(path),
+ } {
+ content, err := (LocalFS{}).ReadFile(name)
+ require.NoError(t, err, name)
+ assert.Equal(t, []byte("metadata"), content)
+ }
+}
+
+func TestLocalFSParsesEscapedFileURIsConsistently(t *testing.T) {
+ t.Parallel()
+
+ dir := t.TempDir()
+ path := filepath.Join(dir, "metadata with space%20.txt")
+ require.NoError(t, os.WriteFile(path, []byte("metadata"), 0o600))
+
+ for _, name := range []string{
+ fileURI(path),
+ singleSlashFileURI(path),
+ } {
+ content, err := (LocalFS{}).ReadFile(name)
+ require.NoError(t, err, name)
+ assert.Equal(t, []byte("metadata"), content)
+ }
+}
+
+func TestLocalFSRejectsUnsupportedFileURIAuthority(t *testing.T) {
+ t.Parallel()
+
+ _, err := (LocalFS{}).Open("file://remotehost/tmp/metadata.json")
+ require.ErrorContains(t, err, `unsupported file URI authority
"remotehost"`)
+}
+
+func TestLocalFSPreservesPlainPathsWithRawPercent(t *testing.T) {
+ t.Parallel()
+
+ path := filepath.Join(t.TempDir(), "discount:100%off.txt")
+ require.NoError(t, os.WriteFile(path, []byte("content"), 0o600))
+
+ content, err := (LocalFS{}).ReadFile(path)
+ require.NoError(t, err)
+ assert.Equal(t, []byte("content"), content)
+}
+
+func TestLocalFSPreservesRelativePathsWithColon(t *testing.T) {
+ t.Parallel()
+
+ path := "partition:2026/data.parquet"
+ got, err := localPath(path)
+ require.NoError(t, err)
+ assert.Equal(t, path, got)
+}
+
func TestLocalFSImplementsListableIO(t *testing.T) {
var _ ListableIO = LocalFS{}
}
+func fileURI(path string) string {
+ hasVolume := filepath.VolumeName(path) != ""
+ path = filepath.ToSlash(path)
+ if hasVolume {
+ path = "/" + path
+ }
+
+ return (&url.URL{Scheme: "file", Path: path}).String()
+}
+
+func singleSlashFileURI(path string) string {
+ hasVolume := filepath.VolumeName(path) != ""
+ pathSlash := filepath.ToSlash(path)
+ if hasVolume {
+ pathSlash = "/" + pathSlash
+ }
+
+ return "file:" + (&url.URL{Path: pathSlash}).EscapedPath()
+}
+
+func localhostFileURI(path string) string {
+ hasVolume := filepath.VolumeName(path) != ""
+ path = filepath.ToSlash(path)
+ if hasVolume {
+ path = "/" + path
+ }
+
+ return (&url.URL{Scheme: "file", Host: "localhost", Path:
path}).String()
+}
+
func TestLocalFSRenameNoReplaceDoesNotOverwrite(t *testing.T) {
dir := t.TempDir()
src := filepath.Join(dir, "src")
diff --git a/io/local_windows_test.go b/io/local_windows_test.go
new file mode 100644
index 00000000..0215d747
--- /dev/null
+++ b/io/local_windows_test.go
@@ -0,0 +1,45 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+//go:build windows
+
+package io
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestLocalFSParsesWindowsDriveFileURIs(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "metadata.json")
+ require.NoError(t, os.WriteFile(path, []byte("metadata"), 0o600))
+
+ pathSlash := filepath.ToSlash(path)
+ for _, name := range []string{
+ "file://" + pathSlash,
+ "file:///" + pathSlash,
+ "file:/" + pathSlash,
+ "file://localhost/" + pathSlash,
+ } {
+ content, err := (LocalFS{}).ReadFile(name)
+ require.NoError(t, err, name)
+ require.Equal(t, []byte("metadata"), content)
+ }
+}