From 48d1eb22b05149f1a7604b440d3a93d69acadb1c Mon Sep 17 00:00:00 2001 From: Virgil Date: Thu, 2 Apr 2026 14:20:14 +0000 Subject: [PATCH] refactor(ax): dedupe sync repo parsing Co-Authored-By: Virgil --- cmd/forge/cmd_sync.go | 28 ++----------------- cmd/gitea/cmd_sync.go | 28 ++----------------- cmd/internal/syncutil/repo_name.go | 37 +++++++++++++++++++++++++ cmd/internal/syncutil/repo_name_test.go | 34 +++++++++++++++++++++++ 4 files changed, 75 insertions(+), 52 deletions(-) create mode 100644 cmd/internal/syncutil/repo_name.go create mode 100644 cmd/internal/syncutil/repo_name_test.go diff --git a/cmd/forge/cmd_sync.go b/cmd/forge/cmd_sync.go index d06e540..725b180 100644 --- a/cmd/forge/cmd_sync.go +++ b/cmd/forge/cmd_sync.go @@ -8,10 +8,10 @@ import ( os "dappco.re/go/core/scm/internal/ax/osx" strings "dappco.re/go/core/scm/internal/ax/stringsx" exec "golang.org/x/sys/execabs" - "net/url" coreerr "dappco.re/go/core/log" "dappco.re/go/core/scm/agentci" + "dappco.re/go/core/scm/cmd/internal/syncutil" fg "dappco.re/go/core/scm/forge" forgejo "codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2" @@ -99,7 +99,7 @@ func buildSyncRepoList(client *fg.Client, args []string, basePath string) ([]syn if len(args) > 0 { for _, arg := range args { - name, err := syncRepoNameFromArg(arg) + name, err := syncutil.ParseRepoName(arg) if err != nil { return nil, coreerr.E("forge.buildSyncRepoList", "invalid repo argument", err) } @@ -347,27 +347,3 @@ func syncCreateMainFromUpstream(client *fg.Client, org, repo string) error { return nil } - -func syncRepoNameFromArg(arg string) (string, error) { - decoded, err := url.PathUnescape(arg) - if err != nil { - return "", coreerr.E("forge.syncRepoNameFromArg", "decode repo argument", err) - } - - parts := strings.Split(decoded, "/") - switch len(parts) { - case 1: - return agentci.ValidatePathElement(parts[0]) - case 2: - if _, err := agentci.ValidatePathElement(parts[0]); err != nil { - return "", coreerr.E("forge.syncRepoNameFromArg", "invalid repo owner", err) - } - name, err := agentci.ValidatePathElement(parts[1]) - if err != nil { - return "", coreerr.E("forge.syncRepoNameFromArg", "invalid repo name", err) - } - return name, nil - default: - return "", coreerr.E("forge.syncRepoNameFromArg", "repo argument must be repo or owner/repo", nil) - } -} diff --git a/cmd/gitea/cmd_sync.go b/cmd/gitea/cmd_sync.go index ccf74b3..3815863 100644 --- a/cmd/gitea/cmd_sync.go +++ b/cmd/gitea/cmd_sync.go @@ -8,10 +8,10 @@ import ( os "dappco.re/go/core/scm/internal/ax/osx" strings "dappco.re/go/core/scm/internal/ax/stringsx" exec "golang.org/x/sys/execabs" - "net/url" coreerr "dappco.re/go/core/log" "dappco.re/go/core/scm/agentci" + "dappco.re/go/core/scm/cmd/internal/syncutil" gt "dappco.re/go/core/scm/gitea" "code.gitea.io/sdk/gitea" @@ -100,7 +100,7 @@ func buildRepoList(client *gt.Client, args []string, basePath string) ([]repoEnt if len(args) > 0 { // Specific repos from args for _, arg := range args { - name, err := repoNameFromArg(arg) + name, err := syncutil.ParseRepoName(arg) if err != nil { return nil, coreerr.E("gitea.buildRepoList", "invalid repo argument", err) } @@ -365,27 +365,3 @@ func createMainFromUpstream(client *gt.Client, org, repo string) error { } func strPtr(s string) *string { return &s } - -func repoNameFromArg(arg string) (string, error) { - decoded, err := url.PathUnescape(arg) - if err != nil { - return "", coreerr.E("gitea.repoNameFromArg", "decode repo argument", err) - } - - parts := strings.Split(decoded, "/") - switch len(parts) { - case 1: - return agentci.ValidatePathElement(parts[0]) - case 2: - if _, err := agentci.ValidatePathElement(parts[0]); err != nil { - return "", coreerr.E("gitea.repoNameFromArg", "invalid repo owner", err) - } - name, err := agentci.ValidatePathElement(parts[1]) - if err != nil { - return "", coreerr.E("gitea.repoNameFromArg", "invalid repo name", err) - } - return name, nil - default: - return "", coreerr.E("gitea.repoNameFromArg", "repo argument must be repo or owner/repo", nil) - } -} diff --git a/cmd/internal/syncutil/repo_name.go b/cmd/internal/syncutil/repo_name.go new file mode 100644 index 0000000..dbf61d6 --- /dev/null +++ b/cmd/internal/syncutil/repo_name.go @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package syncutil + +import ( + "net/url" + + coreerr "dappco.re/go/core/log" + "dappco.re/go/core/scm/agentci" + strings "dappco.re/go/core/scm/internal/ax/stringsx" +) + +// ParseRepoName normalises a sync argument into a validated repo name. +// Usage: ParseRepoName(...) +func ParseRepoName(arg string) (string, error) { + decoded, err := url.PathUnescape(arg) + if err != nil { + return "", coreerr.E("syncutil.ParseRepoName", "decode repo argument", err) + } + + parts := strings.Split(decoded, "/") + switch len(parts) { + case 1: + return agentci.ValidatePathElement(parts[0]) + case 2: + if _, err := agentci.ValidatePathElement(parts[0]); err != nil { + return "", coreerr.E("syncutil.ParseRepoName", "invalid repo owner", err) + } + name, err := agentci.ValidatePathElement(parts[1]) + if err != nil { + return "", coreerr.E("syncutil.ParseRepoName", "invalid repo name", err) + } + return name, nil + default: + return "", coreerr.E("syncutil.ParseRepoName", "repo argument must be repo or owner/repo", nil) + } +} diff --git a/cmd/internal/syncutil/repo_name_test.go b/cmd/internal/syncutil/repo_name_test.go new file mode 100644 index 0000000..8dca244 --- /dev/null +++ b/cmd/internal/syncutil/repo_name_test.go @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package syncutil + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseRepoName_Good(t *testing.T) { + name, err := ParseRepoName("core") + require.NoError(t, err) + assert.Equal(t, "core", name) +} + +func TestParseRepoName_Good_OwnerRepo(t *testing.T) { + name, err := ParseRepoName("host-uk/core") + require.NoError(t, err) + assert.Equal(t, "core", name) +} + +func TestParseRepoName_Bad_PathTraversal(t *testing.T) { + _, err := ParseRepoName("../escape") + require.Error(t, err) + assert.Contains(t, err.Error(), "syncutil.ParseRepoName") +} + +func TestParseRepoName_Bad_PathTraversalEncoded(t *testing.T) { + _, err := ParseRepoName("host-uk%2F..%2Fescape") + require.Error(t, err) + assert.Contains(t, err.Error(), "syncutil.ParseRepoName") +}