This is an automated email from the ASF dual-hosted git repository.

zeroshade 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 5dce5dee3 refactor(rest): Centralize identifier path encoding (#2104)
5dce5dee3 is described below

commit 5dce5dee3426a89df2bb9af4dcf9fc312afd6ae0
Author: Alessandro Nori <[email protected]>
AuthorDate: Mon Oct 19 17:16:07 2026 +0200

    refactor(rest): Centralize identifier path encoding (#2104)
    
    * Refactor REST identifier path handling
    
    * fix(rest): Encode plus in scan plan IDs
    
    * fix(rest): Preserve dot path segments
    
    * test(rest): Cover path encoding review cases
    
    * refactor(rest): Encapsulate identifier request path construction
    
    * chore(rest): Clarify identifier path contracts
---
 catalog/rest/metrics_reporter_test.go |  12 +-
 catalog/rest/rest.go                  | 163 ++++++++++++++++----------
 catalog/rest/rest_internal_test.go    | 208 +++++++++++++++++++++++++++++++---
 catalog/rest/rest_test.go             |   4 +
 catalog/rest/scan_planning.go         |  34 +-----
 catalog/rest/scan_planning_test.go    |  47 +++++---
 6 files changed, 339 insertions(+), 129 deletions(-)

diff --git a/catalog/rest/metrics_reporter_test.go 
b/catalog/rest/metrics_reporter_test.go
index 3814fb639..9d9122976 100644
--- a/catalog/rest/metrics_reporter_test.go
+++ b/catalog/rest/metrics_reporter_test.go
@@ -554,15 +554,15 @@ func TestMetricsDispatcherCloseIsIdempotent(t *testing.T) 
{
 }
 
 // TestRESTMetricsReporterBuildsPathThroughProduction runs the real path
-// composition — splitIdentForPath (encodeNamespace) and endpointReportMetrics
-// .reqPath — for a multi-level namespace with segments needing escaping, so a
-// regression in that composition or in the separator handling is caught rather
-// than bypassed by a hand-built path.
+// composition — tableIdentifierPath and identifierPath.objectReqPath — for a
+// multi-level namespace with segments needing escaping, so a regression in 
that
+// composition or in the separator handling is caught rather than bypassed by a
+// hand-built path.
 func TestRESTMetricsReporterBuildsPathThroughProduction(t *testing.T) {
        c := &Catalog{namespaceSeparator: defaultNamespaceSeparator}
-       ns, tbl, err := c.splitIdentForPath(table.Identifier{"a b", "d e", "t 
x"})
+       identPath, err := c.tableIdentifierPath(table.Identifier{"a b", "d e", 
"t x"})
        require.NoError(t, err)
-       path, err := endpointReportMetrics.reqPath(ns, tbl)
+       path, err := identPath.objectReqPath(endpointReportMetrics)
        require.NoError(t, err)
 
        received := make(chan capturedRequest, 1)
diff --git a/catalog/rest/rest.go b/catalog/rest/rest.go
index 62024c49d..d0f727b92 100644
--- a/catalog/rest/rest.go
+++ b/catalog/rest/rest.go
@@ -1322,13 +1322,13 @@ func (r *Catalog) tableFromResponse(
        // catalog-owned dispatcher. It is non-nil only when the client enabled
        // reporting and the server advertises the endpoint (see init).
        if r.metricsDispatcher != nil {
-               if ns, tbl, idErr := r.splitIdentForPath(identifier); idErr != 
nil {
+               if identPath, idErr := r.tableIdentifierPath(identifier); idErr 
!= nil {
                        // Unreachable for a valid identifier that already 
loaded above, but if it
                        // ever regresses, log why the REST reporter was 
dropped rather than let
                        // metrics silently vanish with no signal.
-                       slog.Debug("iceberg: skipping REST metrics reporter, 
cannot split identifier",
+                       slog.Debug("iceberg: skipping REST metrics reporter, 
cannot build identifier path",
                                "error", idErr)
-               } else if path, pErr := endpointReportMetrics.reqPath(ns, tbl); 
pErr != nil {
+               } else if path, pErr := 
identPath.objectReqPath(endpointReportMetrics); pErr != nil {
                        slog.Debug("iceberg: skipping REST metrics reporter, 
cannot build metrics path",
                                "error", pErr)
                } else {
@@ -1362,12 +1362,12 @@ func (r *Catalog) fetchTableCreds(ctx context.Context, 
ident []string, location
                return nil, err
        }
 
-       ns, tbl, err := r.splitIdentForPath(ident)
+       identPath, err := r.tableIdentifierPath(ident)
        if err != nil {
                return nil, err
        }
 
-       path, err := endpointTableCredentials.reqPath(ns, tbl)
+       path, err := identPath.objectReqPath(endpointTableCredentials)
        if err != nil {
                return nil, err
        }
@@ -1511,10 +1511,18 @@ func (r *Catalog) nsSeparator() string {
 }
 
 // encodePathSegment escapes a REST path segment per RFC 3986. PathEscape
-// leaves plus signs literal, so encode them explicitly to avoid form decoders
-// interpreting them as spaces.
+// leaves plus signs and pure-dot segments literal, so encode them explicitly 
to
+// avoid form decoders interpreting plus signs as spaces and JoinPath resolving
+// dot segments while cleaning the path.
 func encodePathSegment(value string) string {
-       return strings.ReplaceAll(url.PathEscape(value), "+", "%2B")
+       switch value {
+       case ".":
+               return "%2E"
+       case "..":
+               return "%2E%2E"
+       default:
+               return strings.ReplaceAll(url.PathEscape(value), "+", "%2B")
+       }
 }
 
 // encodeNamespace URL-encodes each namespace level and joins them with the
@@ -1545,28 +1553,67 @@ func (r *Catalog) namespaceToQueryParam(namespace 
table.Identifier) string {
        return strings.Join(namespace, r.decodedNamespaceSeparator())
 }
 
-func (r *Catalog) splitIdentForPath(ident table.Identifier) (string, string, 
error) {
+// identifierPath keeps rawName for JSON request bodies separate from
+// encodedNamespace and encodedName for URL paths. The validated table, view, 
and
+// function path builders return a zero value only alongside a non-nil error.
+type identifierPath struct {
+       encodedNamespace string
+       rawName          string
+       encodedName      string
+}
+
+// namespaceReqPath builds a namespace-scoped path, such as a create or 
register
+// endpoint, without including the object name.
+func (p identifierPath) namespaceReqPath(ep endpoint) ([]string, error) {
+       return ep.reqPath(p.encodedNamespace)
+}
+
+// objectReqPath builds an object-scoped path using the encoded identifier 
components.
+// rawExtra contains raw opaque path segments, such as scan plan IDs. Encode 
each
+// segment here so slashes, plus signs, and pure-dot segments remain literal.
+func (p identifierPath) objectReqPath(ep endpoint, rawExtra ...string) 
([]string, error) {
+       params := make([]string, 0, 2+len(rawExtra))
+       params = append(params, p.encodedNamespace, p.encodedName)
+       for _, seg := range rawExtra {
+               params = append(params, encodePathSegment(seg))
+       }
+
+       return ep.reqPath(params...)
+}
+
+// newIdentifierPath builds path components from an already validated 
identifier.
+func (r *Catalog) newIdentifierPath(ident table.Identifier) identifierPath {
+       rawName := catalog.ObjectNameFromIdent(ident)
+
+       return identifierPath{
+               encodedNamespace: 
r.encodeNamespace(catalog.NamespaceFromIdent(ident)),
+               rawName:          rawName,
+               encodedName:      encodePathSegment(rawName),
+       }
+}
+
+func (r *Catalog) tableIdentifierPath(ident table.Identifier) (identifierPath, 
error) {
        if err := catalog.ValidateTableIdentifier(ident); err != nil {
-               return "", "", err
+               return identifierPath{}, err
        }
 
-       return r.encodeNamespace(catalog.NamespaceFromIdent(ident)), 
encodePathSegment(catalog.ObjectNameFromIdent(ident)), nil
+       return r.newIdentifierPath(ident), nil
 }
 
-func (r *Catalog) splitViewIdentForPath(ident table.Identifier) (string, 
string, error) {
+func (r *Catalog) viewIdentifierPath(ident table.Identifier) (identifierPath, 
error) {
        if err := catalog.ValidateViewIdentifier(ident); err != nil {
-               return "", "", err
+               return identifierPath{}, err
        }
 
-       return r.encodeNamespace(catalog.NamespaceFromIdent(ident)), 
encodePathSegment(catalog.ObjectNameFromIdent(ident)), nil
+       return r.newIdentifierPath(ident), nil
 }
 
-func (r *Catalog) splitFunctionIdentForPath(ident table.Identifier) (string, 
string, error) {
+func (r *Catalog) functionIdentifierPath(ident table.Identifier) 
(identifierPath, error) {
        if err := catalog.ValidateFunctionIdentifier(ident); err != nil {
-               return "", "", err
+               return identifierPath{}, err
        }
 
-       return r.encodeNamespace(catalog.NamespaceFromIdent(ident)), 
encodePathSegment(catalog.ObjectNameFromIdent(ident)), nil
+       return r.newIdentifierPath(ident), nil
 }
 
 func (r *Catalog) CreateTable(ctx context.Context, identifier 
table.Identifier, schema *iceberg.Schema, opts ...catalog.CreateTableOpt) 
(*table.Table, error) {
@@ -1574,7 +1621,7 @@ func (r *Catalog) CreateTable(ctx context.Context, 
identifier table.Identifier,
                return nil, err
        }
 
-       ns, _, err := r.splitIdentForPath(identifier)
+       identPath, err := r.tableIdentifierPath(identifier)
        if err != nil {
                return nil, err
        }
@@ -1601,7 +1648,7 @@ func (r *Catalog) CreateTable(ctx context.Context, 
identifier table.Identifier,
        stagedCreate := len(cfg.StagedUpdates) > 0
 
        payload := createTableRequest{
-               Name:          catalog.ObjectNameFromIdent(identifier),
+               Name:          identPath.rawName,
                Schema:        schema,
                Location:      cfg.Location,
                PartitionSpec: cfg.PartitionSpec,
@@ -1610,7 +1657,7 @@ func (r *Catalog) CreateTable(ctx context.Context, 
identifier table.Identifier,
                Props:         cfg.Properties,
        }
 
-       path, err := endpointCreateTable.reqPath(ns)
+       path, err := identPath.namespaceReqPath(endpointCreateTable)
        if err != nil {
                return nil, err
        }
@@ -1700,14 +1747,14 @@ func (r *Catalog) CommitTable(ctx context.Context, 
ident table.Identifier, requi
                return nil, "", err
        }
 
-       ns, encodedTbl, err := r.splitIdentForPath(ident)
+       identPath, err := r.tableIdentifierPath(ident)
        if err != nil {
                return nil, "", err
        }
 
        restIdentifier := identifier{
                Namespace: catalog.NamespaceFromIdent(ident),
-               Name:      catalog.ObjectNameFromIdent(ident),
+               Name:      identPath.rawName,
        }
 
        type payload struct {
@@ -1716,7 +1763,7 @@ func (r *Catalog) CommitTable(ctx context.Context, ident 
table.Identifier, requi
                Updates      []table.Update      `json:"updates"`
        }
 
-       path, err := endpointUpdateTable.reqPath(ns, encodedTbl)
+       path, err := identPath.objectReqPath(endpointUpdateTable)
        if err != nil {
                return nil, "", err
        }
@@ -1830,7 +1877,7 @@ func (r *Catalog) RegisterTable(ctx context.Context, 
identifier table.Identifier
                return nil, err
        }
 
-       ns, _, err := r.splitIdentForPath(identifier)
+       identPath, err := r.tableIdentifierPath(identifier)
        if err != nil {
                return nil, err
        }
@@ -1849,13 +1896,13 @@ func (r *Catalog) RegisterTable(ctx context.Context, 
identifier table.Identifier
                return nil, fmt.Errorf("failed to initialize metrics reporter: 
%w", err)
        }
 
-       path, err := endpointRegisterTable.reqPath(ns)
+       path, err := identPath.namespaceReqPath(endpointRegisterTable)
        if err != nil {
                return nil, err
        }
 
        ret, err := doPost[payload, loadTableResponse](ctx, r.baseURI, path,
-               payload{Name: catalog.ObjectNameFromIdent(identifier), 
MetadataLoc: metadataLoc}, r.cl, map[int]error{
+               payload{Name: identPath.rawName, MetadataLoc: metadataLoc}, 
r.cl, map[int]error{
                        http.StatusNotFound: catalog.ErrNoSuchNamespace, 
http.StatusConflict: catalog.ErrTableAlreadyExists,
                })
        if err != nil {
@@ -1889,12 +1936,12 @@ func (r *Catalog) loadTableWithMode(ctx 
context.Context, identifier table.Identi
                return nil, err
        }
 
-       ns, tbl, err := r.splitIdentForPath(identifier)
+       identPath, err := r.tableIdentifierPath(identifier)
        if err != nil {
                return nil, err
        }
 
-       path, err := endpointLoadTable.reqPath(ns, tbl)
+       path, err := identPath.objectReqPath(endpointLoadTable)
        if err != nil {
                return nil, err
        }
@@ -1929,21 +1976,21 @@ func (r *Catalog) UpdateTable(ctx context.Context, 
ident table.Identifier, requi
                return nil, err
        }
 
-       ns, encodedTbl, err := r.splitIdentForPath(ident)
+       identPath, err := r.tableIdentifierPath(ident)
        if err != nil {
                return nil, err
        }
 
        restIdentifier := identifier{
                Namespace: catalog.NamespaceFromIdent(ident),
-               Name:      catalog.ObjectNameFromIdent(ident),
+               Name:      identPath.rawName,
        }
        type payload struct {
                Identifier   identifier          `json:"identifier"`
                Requirements []table.Requirement `json:"requirements"`
                Updates      []table.Update      `json:"updates"`
        }
-       path, err := endpointUpdateTable.reqPath(ns, encodedTbl)
+       path, err := identPath.objectReqPath(endpointUpdateTable)
        if err != nil {
                return nil, err
        }
@@ -1978,12 +2025,12 @@ func (r *Catalog) DropTable(ctx context.Context, 
identifier table.Identifier) er
                return err
        }
 
-       ns, tbl, err := r.splitIdentForPath(identifier)
+       identPath, err := r.tableIdentifierPath(identifier)
        if err != nil {
                return err
        }
 
-       path, err := endpointDeleteTable.reqPath(ns, tbl)
+       path, err := identPath.objectReqPath(endpointDeleteTable)
        if err != nil {
                return err
        }
@@ -2004,12 +2051,12 @@ func (r *Catalog) PurgeTable(ctx context.Context, 
identifier table.Identifier) e
                return err
        }
 
-       ns, tbl, err := r.splitIdentForPath(identifier)
+       identPath, err := r.tableIdentifierPath(identifier)
        if err != nil {
                return err
        }
 
-       path, err := endpointDeleteTable.reqPath(ns, tbl)
+       path, err := identPath.objectReqPath(endpointDeleteTable)
        if err != nil {
                return err
        }
@@ -2272,7 +2319,7 @@ func (r *Catalog) CheckNamespaceExists(ctx 
context.Context, namespace table.Iden
 }
 
 func (r *Catalog) CheckTableExists(ctx context.Context, identifier 
table.Identifier) (bool, error) {
-       ns, tbl, err := r.splitIdentForPath(identifier)
+       identPath, err := r.tableIdentifierPath(identifier)
        if err != nil {
                return false, err
        }
@@ -2292,7 +2339,7 @@ func (r *Catalog) CheckTableExists(ctx context.Context, 
identifier table.Identif
                return true, nil
        }
 
-       path, err := endpointTableExists.reqPath(ns, tbl)
+       path, err := identPath.objectReqPath(endpointTableExists)
        if err != nil {
                return false, err
        }
@@ -2385,12 +2432,12 @@ func (r *Catalog) DropView(ctx context.Context, 
identifier table.Identifier) err
                return err
        }
 
-       ns, view, err := r.splitViewIdentForPath(identifier)
+       identPath, err := r.viewIdentifierPath(identifier)
        if err != nil {
                return err
        }
 
-       path, err := endpointDeleteView.reqPath(ns, view)
+       path, err := identPath.objectReqPath(endpointDeleteView)
        if err != nil {
                return err
        }
@@ -2402,7 +2449,7 @@ func (r *Catalog) DropView(ctx context.Context, 
identifier table.Identifier) err
 }
 
 func (r *Catalog) CheckViewExists(ctx context.Context, identifier 
table.Identifier) (bool, error) {
-       ns, view, err := r.splitViewIdentForPath(identifier)
+       identPath, err := r.viewIdentifierPath(identifier)
        if err != nil {
                return false, err
        }
@@ -2422,7 +2469,7 @@ func (r *Catalog) CheckViewExists(ctx context.Context, 
identifier table.Identifi
                return true, nil
        }
 
-       path, err := endpointViewExists.reqPath(ns, view)
+       path, err := identPath.objectReqPath(endpointViewExists)
        if err != nil {
                return false, err
        }
@@ -2476,7 +2523,7 @@ func (r *Catalog) CreateView(ctx context.Context, 
identifier table.Identifier, v
                return nil, fmt.Errorf("%w: view version cannot be nil", 
iceberg.ErrInvalidArgument)
        }
 
-       ns, _, err := r.splitViewIdentForPath(identifier)
+       identPath, err := r.viewIdentifierPath(identifier)
        if err != nil {
                return nil, err
        }
@@ -2507,14 +2554,14 @@ func (r *Catalog) CreateView(ctx context.Context, 
identifier table.Identifier, v
        }
 
        payload := createViewRequest{
-               Name:        catalog.ObjectNameFromIdent(identifier),
+               Name:        identPath.rawName,
                Location:    cfg.Location,
                Schema:      freshSchema,
                Props:       cfg.Properties,
                ViewVersion: version,
        }
 
-       path, err := endpointCreateView.reqPath(ns)
+       path, err := identPath.namespaceReqPath(endpointCreateView)
        if err != nil {
                return nil, err
        }
@@ -2537,21 +2584,21 @@ func (r *Catalog) UpdateView(ctx context.Context, ident 
table.Identifier, requir
                return nil, err
        }
 
-       ns, encodedView, err := r.splitViewIdentForPath(ident)
+       identPath, err := r.viewIdentifierPath(ident)
        if err != nil {
                return nil, err
        }
 
        restIdentifier := identifier{
                Namespace: catalog.NamespaceFromIdent(ident),
-               Name:      catalog.ObjectNameFromIdent(ident),
+               Name:      identPath.rawName,
        }
        type payload struct {
                Identifier   identifier         `json:"identifier"`
                Requirements []view.Requirement `json:"requirements"`
                Updates      []view.Update      `json:"updates"`
        }
-       path, err := endpointUpdateView.reqPath(ns, encodedView)
+       path, err := identPath.objectReqPath(endpointUpdateView)
        if err != nil {
                return nil, err
        }
@@ -2583,7 +2630,7 @@ func (r *Catalog) RegisterView(ctx context.Context, 
identifier table.Identifier,
                return nil, err
        }
 
-       ns, _, err := r.splitViewIdentForPath(identifier)
+       identPath, err := r.viewIdentifierPath(identifier)
        if err != nil {
                return nil, err
        }
@@ -2593,13 +2640,13 @@ func (r *Catalog) RegisterView(ctx context.Context, 
identifier table.Identifier,
                MetadataLoc string `json:"metadata-location"`
        }
 
-       path, err := endpointRegisterView.reqPath(ns)
+       path, err := identPath.namespaceReqPath(endpointRegisterView)
        if err != nil {
                return nil, err
        }
 
        rsp, err := doPost[payload, loadViewResponse](ctx, r.baseURI, path,
-               payload{Name: catalog.ObjectNameFromIdent(identifier), 
MetadataLoc: metadataLoc}, r.cl, map[int]error{
+               payload{Name: identPath.rawName, MetadataLoc: metadataLoc}, 
r.cl, map[int]error{
                        http.StatusNotFound: catalog.ErrNoSuchNamespace, 
http.StatusConflict: catalog.ErrViewAlreadyExists,
                })
        if err != nil {
@@ -2620,12 +2667,12 @@ func (r *Catalog) LoadView(ctx context.Context, 
identifier table.Identifier) (*v
                return nil, err
        }
 
-       ns, v, err := r.splitViewIdentForPath(identifier)
+       identPath, err := r.viewIdentifierPath(identifier)
        if err != nil {
                return nil, err
        }
 
-       path, err := endpointLoadView.reqPath(ns, v)
+       path, err := identPath.objectReqPath(endpointLoadView)
        if err != nil {
                return nil, err
        }
@@ -2793,18 +2840,18 @@ func (r *Catalog) LoadFunction(ctx context.Context, 
identifier table.Identifier)
                return nil, err
        }
 
-       ns, fn, err := r.splitFunctionIdentForPath(identifier)
+       identPath, err := r.functionIdentifierPath(identifier)
        if err != nil {
                return nil, err
        }
 
-       return r.loadFunction(ctx, identifier, ns, fn)
+       return r.loadFunction(ctx, identifier, identPath)
 }
 
 // loadFunction fetches and parses a function after endpoint negotiation and
 // identifier validation, so its ErrNoSuchFunction is always server-reported.
-func (r *Catalog) loadFunction(ctx context.Context, identifier 
table.Identifier, ns, fn string) (*udf.UDF, error) {
-       path, err := endpointLoadFunction.reqPath(ns, fn)
+func (r *Catalog) loadFunction(ctx context.Context, identifier 
table.Identifier, identPath identifierPath) (*udf.UDF, error) {
+       path, err := identPath.objectReqPath(endpointLoadFunction)
        if err != nil {
                return nil, err
        }
@@ -2852,12 +2899,12 @@ func (r *Catalog) CheckFunctionExists(ctx 
context.Context, identifier table.Iden
                return false, err
        }
 
-       ns, fn, err := r.splitFunctionIdentForPath(identifier)
+       identPath, err := r.functionIdentifierPath(identifier)
        if err != nil {
                return false, err
        }
 
-       if _, err := r.loadFunction(ctx, identifier, ns, fn); err != nil {
+       if _, err := r.loadFunction(ctx, identifier, identPath); err != nil {
                if errors.Is(err, catalog.ErrNoSuchFunction) {
                        return false, nil
                }
diff --git a/catalog/rest/rest_internal_test.go 
b/catalog/rest/rest_internal_test.go
index 057f3e565..a95ebff7f 100644
--- a/catalog/rest/rest_internal_test.go
+++ b/catalog/rest/rest_internal_test.go
@@ -464,7 +464,7 @@ func TestHeaderDefaultsReachSeparateTokenEndpoint(t 
*testing.T) {
        assert.Equal(t, "SECRET-API-KEY", tokenAPIKey, "the configured token 
endpoint must still receive header.* defaults")
 }
 
-func TestSplitIdentForPathRequiresNamespaceAndName(t *testing.T) {
+func TestIdentifierPathsRequireNamespaceAndName(t *testing.T) {
        cat := &Catalog{}
 
        for _, ident := range []table.Identifier{
@@ -476,38 +476,166 @@ func TestSplitIdentForPathRequiresNamespaceAndName(t 
*testing.T) {
                {"namespace", "table/name"},
                {"namespace", "table\nname"},
        } {
-               _, _, err := cat.splitIdentForPath(ident)
+               _, err := cat.tableIdentifierPath(ident)
                require.ErrorIs(t, err, catalog.ErrNoSuchTable)
        }
 
-       _, _, err := cat.splitViewIdentForPath(table.Identifier{"view"})
+       _, err := cat.viewIdentifierPath(table.Identifier{"view"})
        require.ErrorIs(t, err, catalog.ErrNoSuchView)
        require.NotErrorIs(t, err, catalog.ErrNoSuchTable)
 
-       ns, tbl, err := cat.splitIdentForPath(table.Identifier{"namespace", 
"table"})
+       identPath, err := cat.tableIdentifierPath(table.Identifier{"namespace", 
"table"})
        require.NoError(t, err)
-       assert.Equal(t, "namespace", ns)
-       assert.Equal(t, "table", tbl)
+       assert.Equal(t, "namespace", identPath.encodedNamespace)
+       assert.Equal(t, "table", identPath.rawName)
+       assert.Equal(t, "table", identPath.encodedName)
 
-       ns, tbl, err = cat.splitIdentForPath(table.Identifier{"parent", 
"namespace", "table"})
+       identPath, err = cat.tableIdentifierPath(table.Identifier{"parent", 
"namespace", "table"})
        require.NoError(t, err)
-       assert.Equal(t, "parent%1Fnamespace", ns)
-       assert.Equal(t, "table", tbl)
+       assert.Equal(t, "parent%1Fnamespace", identPath.encodedNamespace)
+       assert.Equal(t, "table", identPath.rawName)
+       assert.Equal(t, "table", identPath.encodedName)
 
-       ns, tbl, err = cat.splitIdentForPath(table.Identifier{"namespace+name", 
"table+name"})
+       identPath, err = 
cat.tableIdentifierPath(table.Identifier{"namespace+name", "table+name"})
        require.NoError(t, err)
-       assert.Equal(t, "namespace%2Bname", ns)
-       assert.Equal(t, "table%2Bname", tbl)
+       assert.Equal(t, "namespace%2Bname", identPath.encodedNamespace)
+       assert.Equal(t, "table+name", identPath.rawName)
+       assert.Equal(t, "table%2Bname", identPath.encodedName)
 
-       for name, split := range map[string]func(table.Identifier) (string, 
string, error){
-               "view":     cat.splitViewIdentForPath,
-               "function": cat.splitFunctionIdentForPath,
+       for name, buildPath := range map[string]func(table.Identifier) 
(identifierPath, error){
+               "view":     cat.viewIdentifierPath,
+               "function": cat.functionIdentifierPath,
        } {
                t.Run(name, func(t *testing.T) {
-                       ns, object, err := 
split(table.Identifier{"namespace+name", name + "+name"})
+                       identPath, err := 
buildPath(table.Identifier{"namespace+name", name + "+name"})
                        require.NoError(t, err)
-                       assert.Equal(t, "namespace%2Bname", ns)
-                       assert.Equal(t, name+"%2Bname", object)
+                       assert.Equal(t, "namespace%2Bname", 
identPath.encodedNamespace)
+                       assert.Equal(t, name+"+name", identPath.rawName)
+                       assert.Equal(t, name+"%2Bname", identPath.encodedName)
+               })
+       }
+}
+
+func TestIdentifierPathsRejectDotComponents(t *testing.T) {
+       cat := &Catalog{}
+
+       for _, builder := range []struct {
+               name      string
+               buildPath func(table.Identifier) (identifierPath, error)
+               wantErr   error
+       }{
+               {name: "table", buildPath: cat.tableIdentifierPath, wantErr: 
catalog.ErrNoSuchTable},
+               {name: "view", buildPath: cat.viewIdentifierPath, wantErr: 
catalog.ErrNoSuchView},
+               {name: "function", buildPath: cat.functionIdentifierPath, 
wantErr: catalog.ErrNoSuchFunction},
+       } {
+               t.Run(builder.name, func(t *testing.T) {
+                       for _, tt := range []struct {
+                               name  string
+                               ident table.Identifier
+                       }{
+                               {name: "dot namespace", ident: 
table.Identifier{".", "object"}},
+                               {name: "double dot namespace", ident: 
table.Identifier{"..", "object"}},
+                               {name: "dot name", ident: 
table.Identifier{"namespace", "."}},
+                               {name: "double dot name", ident: 
table.Identifier{"namespace", ".."}},
+                       } {
+                               t.Run(tt.name, func(t *testing.T) {
+                                       path, err := builder.buildPath(tt.ident)
+                                       require.ErrorIs(t, err, builder.wantErr)
+                                       assert.Equal(t, identifierPath{}, path)
+                               })
+                       }
+               })
+       }
+}
+
+func TestIdentifierPathObjectReqPath(t *testing.T) {
+       cat := &Catalog{}
+       identPath, err := cat.tableIdentifierPath(table.Identifier{"a b+c", "d 
e+f", "t x+y%2F"})
+       require.NoError(t, err)
+       baseURI := mustParseURL(t, "https://catalog.example/base/v1/prefix";)
+
+       for _, tt := range []struct {
+               name       string
+               ep         endpoint
+               extra      []string
+               wantSuffix string
+       }{
+               {name: "load table", ep: endpointLoadTable, wantSuffix: 
"/tables/t%20x%2By%252F"},
+               {name: "update table", ep: endpointUpdateTable, wantSuffix: 
"/tables/t%20x%2By%252F"},
+               {name: "delete table", ep: endpointDeleteTable, wantSuffix: 
"/tables/t%20x%2By%252F"},
+               {name: "table exists", ep: endpointTableExists, wantSuffix: 
"/tables/t%20x%2By%252F"},
+               {name: "credentials", ep: endpointTableCredentials, wantSuffix: 
"/tables/t%20x%2By%252F/credentials"},
+               {name: "metrics", ep: endpointReportMetrics, wantSuffix: 
"/tables/t%20x%2By%252F/metrics"},
+               {name: "plan scan", ep: endpointPlanTableScan, wantSuffix: 
"/tables/t%20x%2By%252F/plan"},
+               {name: "fetch tasks", ep: endpointFetchScanTasks, wantSuffix: 
"/tables/t%20x%2By%252F/tasks"},
+               {name: "fetch plan", ep: endpointFetchPlanResult, extra: 
[]string{"a+b/c%2F"}, wantSuffix: "/tables/t%20x%2By%252F/plan/a%2Bb%2Fc%252F"},
+               {name: "cancel plan", ep: endpointCancelPlanning, extra: 
[]string{".."}, wantSuffix: "/tables/t%20x%2By%252F/plan/%2E%2E"},
+               {name: "load view", ep: endpointLoadView, wantSuffix: 
"/views/t%20x%2By%252F"},
+               {name: "update view", ep: endpointUpdateView, wantSuffix: 
"/views/t%20x%2By%252F"},
+               {name: "delete view", ep: endpointDeleteView, wantSuffix: 
"/views/t%20x%2By%252F"},
+               {name: "view exists", ep: endpointViewExists, wantSuffix: 
"/views/t%20x%2By%252F"},
+               {name: "load function", ep: endpointLoadFunction, wantSuffix: 
"/functions/t%20x%2By%252F"},
+       } {
+               t.Run(tt.name, func(t *testing.T) {
+                       path, err := identPath.objectReqPath(tt.ep, tt.extra...)
+                       require.NoError(t, err)
+                       assert.Equal(t, 
"/base/v1/prefix/namespaces/a%20b%2Bc%1Fd%20e%2Bf"+tt.wantSuffix,
+                               baseURI.JoinPath(path...).EscapedPath())
+               })
+       }
+}
+
+func TestIdentifierPathNamespaceReqPath(t *testing.T) {
+       cat := &Catalog{}
+       identPath, err := cat.tableIdentifierPath(table.Identifier{"a b+c", "d 
e+f", "t x+y"})
+       require.NoError(t, err)
+       baseURI := mustParseURL(t, "https://catalog.example/base/v1/prefix";)
+
+       for _, tt := range []struct {
+               name       string
+               ep         endpoint
+               wantSuffix string
+       }{
+               {name: "create table", ep: endpointCreateTable, wantSuffix: 
"/tables"},
+               {name: "register table", ep: endpointRegisterTable, wantSuffix: 
"/register"},
+               {name: "create view", ep: endpointCreateView, wantSuffix: 
"/views"},
+               {name: "register view", ep: endpointRegisterView, wantSuffix: 
"/register-view"},
+       } {
+               t.Run(tt.name, func(t *testing.T) {
+                       path, err := identPath.namespaceReqPath(tt.ep)
+                       require.NoError(t, err)
+                       assert.Equal(t, 
"/base/v1/prefix/namespaces/a%20b%2Bc%1Fd%20e%2Bf"+tt.wantSuffix,
+                               baseURI.JoinPath(path...).EscapedPath())
+               })
+       }
+}
+
+func TestIdentifierPathMethodsRejectWrongParameterCount(t *testing.T) {
+       cat := &Catalog{}
+       identPath, err := cat.tableIdentifierPath(table.Identifier{"namespace", 
"table"})
+       require.NoError(t, err)
+
+       for _, tt := range []struct {
+               name      string
+               buildPath func() ([]string, error)
+       }{
+               {name: "object method on namespace endpoint", buildPath: func() 
([]string, error) {
+                       return identPath.objectReqPath(endpointCreateTable)
+               }},
+               {name: "namespace method on object endpoint", buildPath: func() 
([]string, error) {
+                       return identPath.namespaceReqPath(endpointLoadTable)
+               }},
+               {name: "missing plan id", buildPath: func() ([]string, error) {
+                       return identPath.objectReqPath(endpointFetchPlanResult)
+               }},
+               {name: "unexpected extra segment", buildPath: func() ([]string, 
error) {
+                       return identPath.objectReqPath(endpointLoadTable, 
"extra")
+               }},
+       } {
+               t.Run(tt.name, func(t *testing.T) {
+                       path, err := tt.buildPath()
+                       require.ErrorIs(t, err, ErrRESTError)
+                       assert.Nil(t, path)
                })
        }
 }
@@ -522,6 +650,9 @@ func TestEncodePathSegment(t *testing.T) {
                {name: "plus", value: "+", want: "%2B"},
                {name: "percent", value: "%", want: "%25"},
                {name: "slash", value: "/", want: "%2F"},
+               {name: "dot", value: ".", want: "%2E"},
+               {name: "double dot", value: "..", want: "%2E%2E"},
+               {name: "dots in name", value: "a..b", want: "a..b"},
                {name: "unicode", value: "£€", want: "%C2%A3%E2%82%AC"},
        }
 
@@ -2172,6 +2303,47 @@ func TestCheckValidNamespaceRejectsDecodedSeparator(t 
*testing.T) {
        }
 }
 
+func TestLoadNamespacePropertiesPreservesDotSegments(t *testing.T) {
+       for _, tt := range []struct {
+               name      string
+               namespace table.Identifier
+               wantPath  string
+       }{
+               {name: "dot", namespace: table.Identifier{"."}, wantPath: 
"/v1/namespaces/%2E"},
+               {name: "double dot", namespace: table.Identifier{".."}, 
wantPath: "/v1/namespaces/%2E%2E"},
+               {name: "nested dots", namespace: table.Identifier{"..", "."}, 
wantPath: "/v1/namespaces/%2E%2E%1F%2E"},
+       } {
+               t.Run(tt.name, func(t *testing.T) {
+                       mux := http.NewServeMux()
+                       mux.HandleFunc("/v1/config", func(w 
http.ResponseWriter, r *http.Request) {
+                               assert.NoError(t, 
json.NewEncoder(w).Encode(map[string]any{
+                                       "defaults": map[string]any{}, 
"overrides": map[string]any{},
+                               }))
+                       })
+                       mux.HandleFunc("/v1/namespaces/", func(w 
http.ResponseWriter, r *http.Request) {
+                               if !assert.Equal(t, http.MethodGet, r.Method) 
|| !assert.Equal(t, tt.wantPath, r.URL.EscapedPath()) {
+                                       return
+                               }
+                               assert.NoError(t, 
json.NewEncoder(w).Encode(map[string]any{
+                                       "namespace":  tt.namespace,
+                                       "properties": 
iceberg.Properties{"owner": "data-team"},
+                               }))
+                       })
+
+                       srv := httptest.NewServer(mux)
+                       t.Cleanup(srv.Close)
+
+                       cat, err := NewCatalog(context.Background(), "rest", 
srv.URL)
+                       require.NoError(t, err)
+                       t.Cleanup(func() { assert.NoError(t, cat.Close()) })
+
+                       props, err := 
cat.LoadNamespaceProperties(context.Background(), tt.namespace)
+                       require.NoError(t, err)
+                       assert.Equal(t, iceberg.Properties{"owner": 
"data-team"}, props)
+               })
+       }
+}
+
 func TestNamespaceSeparatorFromConfig(t *testing.T) {
        mux := http.NewServeMux()
        srv := httptest.NewServer(mux)
diff --git a/catalog/rest/rest_test.go b/catalog/rest/rest_test.go
index 4af1ad224..d04c958de 100644
--- a/catalog/rest/rest_test.go
+++ b/catalog/rest/rest_test.go
@@ -3231,6 +3231,8 @@ func (r *RestCatalogSuite) 
TestRequestPathsEncodeNamesAndBodiesRemainRaw() {
        }
 
        r.mux.HandleFunc("/v1/namespaces/create-ns/tables", func(w 
http.ResponseWriter, req *http.Request) {
+               r.Equal("/v1/namespaces/create-ns/tables", 
req.URL.EscapedPath())
+
                var payload namedPayload
                if !decodePayload(req, &payload) {
                        return
@@ -3240,6 +3242,8 @@ func (r *RestCatalogSuite) 
TestRequestPathsEncodeNamesAndBodiesRemainRaw() {
        })
 
        r.mux.HandleFunc("/v1/namespaces/register-ns/register", func(w 
http.ResponseWriter, req *http.Request) {
+               r.Equal("/v1/namespaces/register-ns/register", 
req.URL.EscapedPath())
+
                var payload namedPayload
                if !decodePayload(req, &payload) {
                        return
diff --git a/catalog/rest/scan_planning.go b/catalog/rest/scan_planning.go
index 3ae6b6d70..920d9ce01 100644
--- a/catalog/rest/scan_planning.go
+++ b/catalog/rest/scan_planning.go
@@ -29,7 +29,6 @@ import (
        "maps"
        "math/rand/v2"
        "net/http"
-       "net/url"
        "runtime"
        "slices"
        "strconv"
@@ -1033,40 +1032,13 @@ func (r *Catalog) abandonPlan(ctx context.Context, 
ident table.Identifier, planI
        _ = r.CancelPlanning(cctx, ident, planID)
 }
 
-func (r *Catalog) scanPlanningPath(ep endpoint, ident table.Identifier, extra 
...string) ([]string, error) {
-       ns, tbl, err := r.splitIdentForPath(ident)
+func (r *Catalog) scanPlanningPath(ep endpoint, ident table.Identifier, 
rawExtra ...string) ([]string, error) {
+       identPath, err := r.tableIdentifierPath(ident)
        if err != nil {
                return nil, err
        }
 
-       // extra carries opaque, server-issued path segments (the plan-id). The 
spec
-       // puts no character restriction on them, and baseURI.JoinPath 
path.Cleans its
-       // arguments — so an unescaped id containing '/' would split into 
segments and
-       // a '..'/'.' segment would resolve to a different endpoint (e.g. ".." 
on the
-       // plan path lands on .../tasks). Escape each as a single literal path 
segment.
-       params := make([]string, 0, 2+len(extra))
-       params = append(params, ns, tbl)
-       for _, seg := range extra {
-               params = append(params, escapeOpaquePathSegment(seg))
-       }
-
-       return ep.reqPath(params...)
-}
-
-// escapeOpaquePathSegment percent-encodes an opaque string so it survives
-// url.URL.JoinPath as a single literal path segment. url.PathEscape handles 
'/',
-// '%', spaces, etc., but leaves a pure-dot segment ("." or "..") unescaped, 
which
-// JoinPath's path.Clean would then resolve as a dot-segment; encode those 
dots so
-// the segment is preserved verbatim.
-func escapeOpaquePathSegment(s string) string {
-       switch escaped := url.PathEscape(s); escaped {
-       case ".":
-               return "%2E"
-       case "..":
-               return "%2E%2E"
-       default:
-               return escaped
-       }
+       return identPath.objectReqPath(ep, rawExtra...)
 }
 
 func scanPlanningHeaders(idempotencyKey, accessDelegation *string, 
includeIdempotency bool) (map[string]string, error) {
diff --git a/catalog/rest/scan_planning_test.go 
b/catalog/rest/scan_planning_test.go
index 30eb2bc49..498050a4d 100644
--- a/catalog/rest/scan_planning_test.go
+++ b/catalog/rest/scan_planning_test.go
@@ -96,22 +96,37 @@ func TestScanPlanningEscapesOpaquePlanID(t *testing.T) {
        // literal segment; otherwise "a/b" splits and ".." / "../tasks" 
resolve to a
        // different endpoint. Each must survive JoinPath as the final 
.../plan/ segment
        // and round-trip.
-       for _, planID := range []string{"a/b", "../tasks", "a b", "a%2Fb", ".", 
"..", "plan-123"} {
-               path, err := cat.scanPlanningPath(endpointFetchPlanResult, 
table.Identifier{"db", "tbl"}, planID)
-               require.NoErrorf(t, err, "plan-id %q", planID)
-
-               seg := path[len(path)-1]
-               assert.NotContainsf(t, seg, "/", "plan-id %q split into 
multiple segments: %q", planID, seg)
-
-               decoded, err := url.PathUnescape(seg)
-               require.NoErrorf(t, err, "plan-id %q", planID)
-               assert.Equalf(t, planID, decoded, "plan-id %q did not 
round-trip", planID)
-
-               // The escaped segment must survive JoinPath's path.Clean as 
the last
-               // .../plan/ segment (no split, no traversal to a different 
endpoint).
-               escaped := cat.baseURI.JoinPath(path...).EscapedPath()
-               assert.Truef(t, strings.HasSuffix(escaped, "/plan/"+seg),
-                       "plan-id %q was mangled by JoinPath: %s", planID, 
escaped)
+       for _, tt := range []struct {
+               name    string
+               id      string
+               wantSeg string
+       }{
+               {name: "slash", id: "a/b", wantSeg: "a%2Fb"},
+               {name: "traversal", id: "../tasks", wantSeg: "..%2Ftasks"},
+               {name: "space", id: "a b", wantSeg: "a%20b"},
+               {name: "plus", id: "a+b", wantSeg: "a%2Bb"},
+               {name: "percent", id: "a%2Fb", wantSeg: "a%252Fb"},
+               {name: "dot", id: ".", wantSeg: "%2E"},
+               {name: "double dot", id: "..", wantSeg: "%2E%2E"},
+               {name: "plain", id: "plan-123", wantSeg: "plan-123"},
+       } {
+               t.Run(tt.name, func(t *testing.T) {
+                       path, err := 
cat.scanPlanningPath(endpointFetchPlanResult, table.Identifier{"db", "tbl"}, 
tt.id)
+                       require.NoError(t, err)
+
+                       seg := path[len(path)-1]
+                       assert.Equal(t, tt.wantSeg, seg)
+
+                       decoded, err := url.PathUnescape(seg)
+                       require.NoError(t, err)
+                       assert.Equal(t, tt.id, decoded)
+
+                       // The escaped segment must survive JoinPath's 
path.Clean as the last
+                       // .../plan/ segment (no split, no traversal to a 
different endpoint).
+                       escaped := cat.baseURI.JoinPath(path...).EscapedPath()
+                       assert.Truef(t, strings.HasSuffix(escaped, 
"/plan/"+tt.wantSeg),
+                               "plan-id %q was mangled by JoinPath: %s", 
tt.id, escaped)
+               })
        }
 }
 

Reply via email to