Detailed changes
@@ -1,7 +1,5 @@
package backend
-import "golang.org/x/crypto/ssh"
-
// AccessLevel is the level of access allowed to a repo.
type AccessLevel int
@@ -34,9 +32,3 @@ func (a AccessLevel) String() string {
return "unknown"
}
}
-
-// AccessMethod is an interface that handles repository authorization.
-type AccessMethod interface {
- // AccessLevel returns the access level for the given repository and key.
- AccessLevel(repo string, pk ssh.PublicKey) AccessLevel
-}
@@ -31,6 +31,7 @@ import (
"github.com/charmbracelet/log"
"github.com/charmbracelet/soft-serve/git"
"github.com/charmbracelet/soft-serve/server/backend"
+ "github.com/charmbracelet/soft-serve/server/utils"
"github.com/charmbracelet/ssh"
gossh "golang.org/x/crypto/ssh"
)
@@ -59,8 +60,6 @@ var (
var _ backend.Backend = &FileBackend{}
-var _ backend.AccessMethod = &FileBackend{}
-
// FileBackend is a backend that uses the filesystem.
type FileBackend struct { // nolint:revive
// path is the path to the directory containing the repositories and config
@@ -78,11 +77,6 @@ func (fb *FileBackend) reposPath() string {
return filepath.Join(fb.path, repos)
}
-// RepositoryStorePath returns the path to the repository store.
-func (fb *FileBackend) RepositoryStorePath() string {
- return fb.reposPath()
-}
-
func (fb *FileBackend) settingsPath() string {
return filepath.Join(fb.path, settings)
}
@@ -95,10 +89,6 @@ func (fb *FileBackend) collabsPath(repo string) string {
return filepath.Join(fb.path, collabs, repo, collabs)
}
-func sanatizeRepo(repo string) string {
- return strings.TrimSuffix(repo, ".git")
-}
-
func readOneLine(path string) (string, error) {
f, err := os.Open(path)
if err != nil {
@@ -221,7 +211,7 @@ func (fb *FileBackend) AddAdmin(pk gossh.PublicKey, memo string) error {
//
// It implements backend.Backend.
func (fb *FileBackend) AddCollaborator(pk gossh.PublicKey, memo string, repo string) error {
- name := sanatizeRepo(repo)
+ name := utils.SanitizeRepo(repo)
repo = name + ".git"
// Check if repo exists
if !exists(filepath.Join(fb.reposPath(), repo)) {
@@ -278,7 +268,7 @@ func (fb *FileBackend) Admins() ([]string, error) {
//
// It implements backend.Backend.
func (fb *FileBackend) Collaborators(repo string) ([]string, error) {
- name := sanatizeRepo(repo)
+ name := utils.SanitizeRepo(repo)
repo = name + ".git"
// Check if repo exists
if !exists(filepath.Join(fb.reposPath(), repo)) {
@@ -359,7 +349,7 @@ func (fb *FileBackend) RemoveAdmin(pk gossh.PublicKey) error {
//
// It implements backend.Backend.
func (fb *FileBackend) RemoveCollaborator(pk gossh.PublicKey, repo string) error {
- name := sanatizeRepo(repo)
+ name := utils.SanitizeRepo(repo)
repo = name + ".git"
// Check if repo exists
if !exists(filepath.Join(fb.reposPath(), repo)) {
@@ -458,7 +448,7 @@ func (fb *FileBackend) AnonAccess() backend.AccessLevel {
//
// It implements backend.Backend.
func (fb *FileBackend) Description(repo string) string {
- repo = sanatizeRepo(repo) + ".git"
+ repo = utils.SanitizeRepo(repo) + ".git"
r := &Repo{path: filepath.Join(fb.reposPath(), repo), root: fb.reposPath()}
return r.Description()
}
@@ -501,7 +491,7 @@ func (fb *FileBackend) IsAdmin(pk gossh.PublicKey) bool {
//
// It implements backend.Backend.
func (fb *FileBackend) IsCollaborator(pk gossh.PublicKey, repo string) bool {
- repo = sanatizeRepo(repo) + ".git"
+ repo = utils.SanitizeRepo(repo) + ".git"
_, err := os.Stat(fb.collabsPath(repo))
if err != nil {
return false
@@ -532,7 +522,7 @@ func (fb *FileBackend) IsCollaborator(pk gossh.PublicKey, repo string) bool {
//
// It implements backend.Backend.
func (fb *FileBackend) IsPrivate(repo string) bool {
- repo = sanatizeRepo(repo) + ".git"
+ repo = utils.SanitizeRepo(repo) + ".git"
r := &Repo{path: filepath.Join(fb.reposPath(), repo), root: fb.reposPath()}
return r.IsPrivate()
}
@@ -569,7 +559,7 @@ func (fb *FileBackend) SetAnonAccess(level backend.AccessLevel) error {
//
// It implements backend.Backend.
func (fb *FileBackend) SetDescription(repo string, desc string) error {
- repo = sanatizeRepo(repo) + ".git"
+ repo = utils.SanitizeRepo(repo) + ".git"
f, err := os.OpenFile(filepath.Join(fb.reposPath(), repo, description), os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
if err != nil {
return fmt.Errorf("failed to open description file: %w", err)
@@ -584,7 +574,7 @@ func (fb *FileBackend) SetDescription(repo string, desc string) error {
//
// It implements backend.Backend.
func (fb *FileBackend) SetPrivate(repo string, priv bool) error {
- repo = sanatizeRepo(repo) + ".git"
+ repo = utils.SanitizeRepo(repo) + ".git"
daemonExport := filepath.Join(fb.reposPath(), repo, exportOk)
if priv {
_ = os.Remove(daemonExport)
@@ -612,7 +602,7 @@ func (fb *FileBackend) SetPrivate(repo string, priv bool) error {
//
// It implements backend.Backend.
func (fb *FileBackend) CreateRepository(repo string, private bool) (backend.Repository, error) {
- name := sanatizeRepo(repo)
+ name := utils.SanitizeRepo(repo)
repo = name + ".git"
rp := filepath.Join(fb.reposPath(), repo)
if _, err := os.Stat(rp); err == nil {
@@ -637,7 +627,7 @@ func (fb *FileBackend) CreateRepository(repo string, private bool) (backend.Repo
//
// It implements backend.Backend.
func (fb *FileBackend) DeleteRepository(repo string) error {
- name := sanatizeRepo(repo)
+ name := utils.SanitizeRepo(repo)
delete(fb.repos, name)
repo = name + ".git"
return os.RemoveAll(filepath.Join(fb.reposPath(), repo))
@@ -647,8 +637,8 @@ func (fb *FileBackend) DeleteRepository(repo string) error {
//
// It implements backend.Backend.
func (fb *FileBackend) RenameRepository(oldName string, newName string) error {
- oldName = filepath.Join(fb.reposPath(), sanatizeRepo(oldName)+".git")
- newName = filepath.Join(fb.reposPath(), sanatizeRepo(newName)+".git")
+ oldName = filepath.Join(fb.reposPath(), utils.SanitizeRepo(oldName)+".git")
+ newName = filepath.Join(fb.reposPath(), utils.SanitizeRepo(newName)+".git")
if _, err := os.Stat(oldName); errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("repository %q does not exist", strings.TrimSuffix(filepath.Base(oldName), ".git"))
}
@@ -663,7 +653,7 @@ func (fb *FileBackend) RenameRepository(oldName string, newName string) error {
//
// It implements backend.Backend.
func (fb *FileBackend) Repository(repo string) (backend.Repository, error) {
- name := sanatizeRepo(repo)
+ name := utils.SanitizeRepo(repo)
if r, ok := fb.repos[name]; ok {
return r, nil
}
@@ -14,18 +14,11 @@ var ErrNotImpl = fmt.Errorf("not implemented")
var _ backend.Backend = (*Noop)(nil)
-var _ backend.AccessMethod = (*Noop)(nil)
-
// Noop is a backend that does nothing. It's used for testing.
type Noop struct {
Port string
}
-// RepositoryStorePath implements backend.Backend
-func (*Noop) RepositoryStorePath() string {
- return ""
-}
-
// Admins implements backend.Backend
func (*Noop) Admins() ([]string, error) {
return nil, nil
@@ -11,8 +11,6 @@ type RepositoryStore interface {
Repository(repo string) (Repository, error)
// Repositories returns a list of all repositories.
Repositories() ([]Repository, error)
- // RepositoryStorePath returns the path to the repository store.
- RepositoryStorePath() string
// CreateRepository creates a new repository.
CreateRepository(name string, private bool) (Repository, error)
// DeleteRepository deletes a repository.
@@ -35,6 +33,8 @@ type RepositoryMetadata interface {
// RepositoryAccess is an interface for managing repository access.
type RepositoryAccess interface {
+ // AccessLevel returns the access level for the given repository and key.
+ AccessLevel(repo string, pk ssh.PublicKey) AccessLevel
// IsCollaborator returns true if the authorized key is a collaborator on the repository.
IsCollaborator(pk ssh.PublicKey, repo string) bool
// AddCollaborator adds the authorized key as a collaborator on the repository.
@@ -3,10 +3,10 @@ package cmd
import (
"context"
"fmt"
- "strings"
"github.com/charmbracelet/soft-serve/server/backend"
"github.com/charmbracelet/soft-serve/server/config"
+ "github.com/charmbracelet/soft-serve/server/utils"
"github.com/charmbracelet/ssh"
"github.com/charmbracelet/wish"
"github.com/spf13/cobra"
@@ -75,8 +75,8 @@ func checkIfReadable(cmd *cobra.Command, args []string) error {
repo = args[0]
}
cfg, s := fromContext(cmd)
- rn := strings.TrimSuffix(repo, ".git")
- auth := cfg.Access.AccessLevel(rn, s.PublicKey())
+ rn := utils.SanitizeRepo(repo)
+ auth := cfg.Backend.AccessLevel(rn, s.PublicKey())
if auth < backend.ReadOnlyAccess {
return ErrUnauthorized
}
@@ -97,8 +97,8 @@ func checkIfCollab(cmd *cobra.Command, args []string) error {
repo = args[0]
}
cfg, s := fromContext(cmd)
- rn := strings.TrimSuffix(repo, ".git")
- auth := cfg.Access.AccessLevel(rn, s.PublicKey())
+ rn := utils.SanitizeRepo(repo)
+ auth := cfg.Backend.AccessLevel(rn, s.PublicKey())
if auth < backend.ReadWriteAccess {
return ErrUnauthorized
}
@@ -13,21 +13,21 @@ import (
// listCommand returns a command that list file or directory at path.
func listCommand() *cobra.Command {
listCmd := &cobra.Command{
- Use: "list PATH",
- Aliases: []string{"ls"},
- Short: "List files at repository.",
- Args: cobra.RangeArgs(0, 1),
- PersistentPreRunE: checkIfReadable,
+ Use: "list PATH",
+ Aliases: []string{"ls"},
+ Short: "List files at repository.",
+ Args: cobra.RangeArgs(0, 1),
RunE: func(cmd *cobra.Command, args []string) error {
cfg, s := fromContext(cmd)
rn := ""
path := ""
ps := []string{}
if len(args) > 0 {
+ // FIXME: nested repos are not supported.
path = filepath.Clean(args[0])
ps = strings.Split(path, "/")
rn = strings.TrimSuffix(ps[0], ".git")
- auth := cfg.Access.AccessLevel(rn, s.PublicKey())
+ auth := cfg.Backend.AccessLevel(rn, s.PublicKey())
if auth < backend.ReadOnlyAccess {
return ErrUnauthorized
}
@@ -38,7 +38,7 @@ func listCommand() *cobra.Command {
return err
}
for _, r := range repos {
- if cfg.Access.AccessLevel(r.Name(), s.PublicKey()) >= backend.ReadOnlyAccess {
+ if cfg.Backend.AccessLevel(r.Name(), s.PublicKey()) >= backend.ReadOnlyAccess {
cmd.Println(r.Name())
}
}
@@ -33,14 +33,11 @@ func showCommand() *cobra.Command {
Args: cobra.ExactArgs(1),
PersistentPreRunE: checkIfReadable,
RunE: func(cmd *cobra.Command, args []string) error {
- cfg, s := fromContext(cmd)
+ cfg, _ := fromContext(cmd)
+ // FIXME: nested repos are not supported.
ps := strings.Split(args[0], "/")
rn := strings.TrimSuffix(ps[0], ".git")
fp := strings.Join(ps[1:], "/")
- auth := cfg.Access.AccessLevel(rn, s.PublicKey())
- if auth < backend.ReadOnlyAccess {
- return ErrUnauthorized
- }
var repo backend.Repository
repoExists := false
repos, err := cfg.Backend.Repositories()
@@ -6,7 +6,6 @@ import (
"github.com/caarlos0/env/v6"
"github.com/charmbracelet/log"
"github.com/charmbracelet/soft-serve/server/backend"
- "github.com/charmbracelet/soft-serve/server/backend/file"
)
// SSHConfig is the configuration for the SSH server.
@@ -20,6 +19,9 @@ type SSHConfig struct {
// KeyPath is the path to the SSH server's private key.
KeyPath string `env:"KEY_PATH"`
+ // InternalKeyPath is the path to the SSH server's internal private key.
+ InternalKeyPath string `env:"INTERNAL_KEY_PATH"`
+
// MaxTimeout is the maximum number of seconds a connection can take.
MaxTimeout int `env:"MAX_TIMEOUT" envDefault:"0"`
@@ -73,9 +75,6 @@ type Config struct {
// Backend is the Git backend to use.
Backend backend.Backend
-
- // Access is the access control backend to use.
- Access backend.AccessMethod
}
// DefaultConfig returns a Config with the values populated with the defaults
@@ -90,13 +89,10 @@ func DefaultConfig() *Config {
if cfg.SSH.KeyPath == "" {
cfg.SSH.KeyPath = filepath.Join(cfg.DataPath, "ssh", "soft_serve")
}
- fb, err := file.NewFileBackend(cfg.DataPath)
- if err != nil {
- log.Fatal(err)
+ if cfg.SSH.InternalKeyPath == "" {
+ cfg.SSH.InternalKeyPath = filepath.Join(cfg.DataPath, "ssh", "soft_serve_internal")
}
- // Add the initial admin keys to the list of admins.
- fb.AdditionalAdmins = cfg.InitialAdminKeys
- return cfg.WithBackend(fb).WithAccessMethod(fb)
+ return cfg
}
// WithBackend sets the backend for the configuration.
@@ -104,9 +100,3 @@ func (c *Config) WithBackend(backend backend.Backend) *Config {
c.Backend = backend
return c
}
-
-// WithAccessMethod sets the access control method for the configuration.
-func (c *Config) WithAccessMethod(access backend.AccessMethod) *Config {
- c.Access = access
- return c
-}
@@ -12,6 +12,7 @@ import (
"github.com/charmbracelet/soft-serve/server/backend"
"github.com/charmbracelet/soft-serve/server/config"
+ "github.com/charmbracelet/soft-serve/server/utils"
"github.com/go-git/go-git/v5/plumbing/format/pktline"
)
@@ -201,19 +202,19 @@ func (d *GitDaemon) handleClient(conn net.Conn) {
return
}
- name := sanitizeRepoName(string(opts[0]))
+ name := utils.SanitizeRepo(string(opts[0]))
logger.Debugf("git: connect %s %s %s", c.RemoteAddr(), cmd, name)
defer logger.Debugf("git: disconnect %s %s %s", c.RemoteAddr(), cmd, name)
// git bare repositories should end in ".git"
// https://git-scm.com/docs/gitrepository-layout
repo := name + ".git"
- reposDir := d.cfg.Backend.RepositoryStorePath()
+ reposDir := filepath.Join(d.cfg.DataPath, "repos")
if err := ensureWithin(reposDir, repo); err != nil {
fatal(c, err)
return
}
- auth := d.cfg.Access.AccessLevel(name, nil)
+ auth := d.cfg.Backend.AccessLevel(name, nil)
if auth < backend.ReadOnlyAccess {
fatal(c, ErrNotAuthed)
return
@@ -8,10 +8,12 @@ import (
"log"
"net"
"os"
+ "path/filepath"
"strings"
"testing"
"time"
+ "github.com/charmbracelet/soft-serve/server/backend/file"
"github.com/charmbracelet/soft-serve/server/config"
"github.com/go-git/go-git/v5/plumbing/format/pktline"
)
@@ -29,7 +31,11 @@ func TestMain(m *testing.M) {
os.Setenv("SOFT_SERVE_GIT_MAX_TIMEOUT", "100")
os.Setenv("SOFT_SERVE_GIT_IDLE_TIMEOUT", "1")
os.Setenv("SOFT_SERVE_GIT_LISTEN_ADDR", fmt.Sprintf(":%d", randomPort()))
- cfg := config.DefaultConfig()
+ fb, err := file.NewFileBackend(filepath.Join(tmp, "repos"))
+ if err != nil {
+ log.Fatal(err)
+ }
+ cfg := config.DefaultConfig().WithBackend(fb).WithAccessMethod(fb)
d, err := NewGitDaemon(cfg)
if err != nil {
log.Fatal(err)
@@ -13,6 +13,7 @@ import (
"github.com/charmbracelet/soft-serve/server/backend"
"github.com/charmbracelet/soft-serve/server/config"
+ "github.com/charmbracelet/soft-serve/server/utils"
"github.com/dustin/go-humanize"
"goji.io"
"goji.io/pat"
@@ -68,7 +69,7 @@ func NewHTTPServer(cfg *config.Config) (*HTTPServer, error) {
mux := goji.NewMux()
s := &HTTPServer{
cfg: cfg,
- dirHandler: http.FileServer(http.Dir(cfg.Backend.RepositoryStorePath())),
+ dirHandler: http.FileServer(http.Dir(filepath.Join(cfg.DataPath, "repos"))),
server: &http.Server{
Addr: cfg.HTTP.ListenAddr,
Handler: mux,
@@ -114,7 +115,7 @@ Redirecting to docs at <a href="https://godoc.org/{{.ImportRoot}}/{{.Repo}}">god
func (s *HTTPServer) repoIndexHandler(w http.ResponseWriter, r *http.Request) {
repo := pat.Param(r, "repo")
- repo = sanitizeRepoName(repo)
+ repo = utils.SanitizeRepo(repo)
// Only respond to go-get requests
if r.URL.Query().Get("go-get") != "1" {
@@ -122,7 +123,7 @@ func (s *HTTPServer) repoIndexHandler(w http.ResponseWriter, r *http.Request) {
return
}
- access := s.cfg.Access.AccessLevel(repo, nil)
+ access := s.cfg.Backend.AccessLevel(repo, nil)
if access < backend.ReadOnlyAccess {
http.NotFound(w, r)
return
@@ -149,16 +150,16 @@ func (s *HTTPServer) repoIndexHandler(w http.ResponseWriter, r *http.Request) {
func (s *HTTPServer) dumbGitHandler(w http.ResponseWriter, r *http.Request) {
repo := pat.Param(r, "repo")
- repo = sanitizeRepoName(repo) + ".git"
+ repo = utils.SanitizeRepo(repo) + ".git"
- access := s.cfg.Access.AccessLevel(repo, nil)
+ access := s.cfg.Backend.AccessLevel(repo, nil)
if access < backend.ReadOnlyAccess || !s.cfg.Backend.AllowKeyless() {
httpStatusError(w, http.StatusUnauthorized)
return
}
path := pattern.Path(r.Context())
- stat, err := os.Stat(filepath.Join(s.cfg.Backend.RepositoryStorePath(), repo, path))
+ stat, err := os.Stat(filepath.Join(s.cfg.DataPath, "repos", repo, path))
// Restrict access to files
if err != nil || stat.IsDir() {
http.NotFound(w, r)
@@ -7,6 +7,7 @@ import (
"github.com/charmbracelet/log"
"github.com/charmbracelet/soft-serve/server/backend"
+ "github.com/charmbracelet/soft-serve/server/backend/file"
"github.com/charmbracelet/soft-serve/server/config"
"github.com/charmbracelet/ssh"
"golang.org/x/sync/errgroup"
@@ -23,7 +24,6 @@ type Server struct {
HTTPServer *HTTPServer
Config *config.Config
Backend backend.Backend
- Access backend.AccessMethod
}
// NewServer returns a new *ssh.Server configured to serve Soft Serve. The SSH
@@ -33,10 +33,19 @@ type Server struct {
// publicly writable until configured otherwise by cloning the `config` repo.
func NewServer(cfg *config.Config) (*Server, error) {
var err error
+ if cfg.Backend == nil {
+ fb, err := file.NewFileBackend(cfg.DataPath)
+ if err != nil {
+ logger.Fatal(err)
+ }
+ // Add the initial admin keys to the list of admins.
+ fb.AdditionalAdmins = cfg.InitialAdminKeys
+ cfg = cfg.WithBackend(fb)
+ }
+
srv := &Server{
Config: cfg,
Backend: cfg.Backend,
- Access: cfg.Access,
}
srv.SSHServer, err = NewSSHServer(cfg)
if err != nil {
@@ -26,7 +26,7 @@ func SessionHandler(cfg *config.Config) bm.ProgramHandler {
initialRepo := ""
if len(cmd) == 1 {
initialRepo = cmd[0]
- auth := cfg.Access.AccessLevel(initialRepo, s.PublicKey())
+ auth := cfg.Backend.AccessLevel(initialRepo, s.PublicKey())
if auth < backend.ReadOnlyAccess {
wish.Fatalln(s, cm.ErrUnauthorized)
return nil
@@ -3,10 +3,13 @@ package server
import (
"errors"
"fmt"
+ "log"
"os"
+ "path/filepath"
"testing"
"time"
+ "github.com/charmbracelet/soft-serve/server/backend/file"
"github.com/charmbracelet/soft-serve/server/config"
"github.com/charmbracelet/ssh"
bm "github.com/charmbracelet/wish/bubbletea"
@@ -49,7 +52,11 @@ func setup(tb testing.TB) *gossh.Session {
is.NoErr(os.Unsetenv("SOFT_SERVE_SSH_LISTEN_ADDR"))
is.NoErr(os.RemoveAll(dp))
})
- cfg := config.DefaultConfig()
+ fb, err := file.NewFileBackend(filepath.Join(dp, "repos"))
+ if err != nil {
+ log.Fatal(err)
+ }
+ cfg := config.DefaultConfig().WithBackend(fb).WithAccessMethod(fb)
return testsession.New(tb, &ssh.Server{
Handler: bm.MiddlewareWithProgramHandler(SessionHandler(cfg), termenv.ANSI256)(func(s ssh.Session) {
_, _, active := s.Pty()
@@ -12,6 +12,7 @@ import (
"github.com/charmbracelet/soft-serve/server/backend"
cm "github.com/charmbracelet/soft-serve/server/cmd"
"github.com/charmbracelet/soft-serve/server/config"
+ "github.com/charmbracelet/soft-serve/server/utils"
"github.com/charmbracelet/ssh"
"github.com/charmbracelet/wish"
bm "github.com/charmbracelet/wish/bubbletea"
@@ -88,7 +89,7 @@ func (s *SSHServer) Shutdown(ctx context.Context) error {
// PublicKeyAuthHandler handles public key authentication.
func (s *SSHServer) PublicKeyHandler(ctx ssh.Context, pk ssh.PublicKey) bool {
- return s.cfg.Access.AccessLevel("", pk) > backend.NoAccess
+ return s.cfg.Backend.AccessLevel("", pk) > backend.NoAccess
}
// KeyboardInteractiveHandler handles keyboard interactive authentication.
@@ -109,14 +110,14 @@ func (s *SSHServer) Middleware(cfg *config.Config) wish.Middleware {
if len(cmd) >= 2 && strings.HasPrefix(cmd[0], "git") {
gc := cmd[0]
// repo should be in the form of "repo.git"
- name := sanitizeRepoName(cmd[1])
+ name := utils.SanitizeRepo(cmd[1])
pk := s.PublicKey()
- access := cfg.Access.AccessLevel(name, pk)
+ access := cfg.Backend.AccessLevel(name, pk)
// git bare repositories should end in ".git"
// https://git-scm.com/docs/gitrepository-layout
repo := name + ".git"
- reposDir := cfg.Backend.RepositoryStorePath()
+ reposDir := filepath.Join(cfg.DataPath, "repos")
if err := ensureWithin(reposDir, repo); err != nil {
sshFatal(s, err)
return
@@ -168,10 +169,3 @@ func sshFatal(s ssh.Session, v ...interface{}) {
WritePktline(s, v...)
s.Exit(1) // nolint: errcheck
}
-
-func sanitizeRepoName(repo string) string {
- repo = strings.TrimPrefix(repo, "/")
- repo = filepath.Clean(repo)
- repo = strings.TrimSuffix(repo, ".git")
- return repo
-}
@@ -0,0 +1,14 @@
+package utils
+
+import (
+ "path/filepath"
+ "strings"
+)
+
+// SanitizeRepo returns a sanitized version of the given repository name.
+func SanitizeRepo(repo string) string {
+ repo = strings.TrimPrefix(repo, "/")
+ repo = filepath.Clean(repo)
+ repo = strings.TrimSuffix(repo, ".git")
+ return repo
+}