Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions server/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,11 @@ func (s *testStore) Open(_ context.Context, name string) (Repository, error) {
return s.repo, s.err
}

func (s *testStore) Create(context.Context, string) (Repository, error) { return nil, nil }
func (s *testStore) Delete(context.Context, string) error { return nil }
func (s *testStore) Exists(context.Context, string) (bool, error) { return false, nil }
func (s *testStore) List(context.Context) ([]string, error) { return nil, nil }

func TestServerRoutePrefixStaticFile(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "HEAD"), []byte("ref: refs/heads/main\n"), 0644); err != nil {
Expand Down
173 changes: 163 additions & 10 deletions server/storage.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,25 @@ import (
"errors"
"os"
"path/filepath"
"sort"
"strings"
)

var ErrRepositoryNotFound = errors.New("repository not found")
var (
ErrRepositoryNotFound = errors.New("repository not found")
ErrRepositoryExists = errors.New("repository already exists")
)

type Repository interface {
Path() string
}

type Store interface {
Open(context.Context, string) (Repository, error)
Create(context.Context, string) (Repository, error)
Delete(context.Context, string) error
Exists(context.Context, string) (bool, error)
List(context.Context) ([]string, error)
}

type filesystemRepository struct {
Expand All @@ -34,16 +43,10 @@ func NewFilesystemStore(root string) *FilesystemStore {
}

func (s *FilesystemStore) Open(_ context.Context, name string) (Repository, error) {
root := s.Root
if root == "" {
cwd, err := os.Getwd()
if err != nil {
return nil, err
}
root = cwd
p, err := s.repositoryPath(name)
if err != nil {
return nil, err
}

p := filepath.Join(root, filepath.Clean("/"+name))
if _, err := os.Stat(p); err != nil {
if os.IsNotExist(err) {
return nil, ErrRepositoryNotFound
Expand All @@ -53,3 +56,153 @@ func (s *FilesystemStore) Open(_ context.Context, name string) (Repository, erro

return filesystemRepository{path: p}, nil
}

func (s *FilesystemStore) Create(_ context.Context, name string) (Repository, error) {
if filepath.Ext(filepath.Clean(name)) != ".git" {
return nil, ErrRepositoryNotFound
}

p, err := s.repositoryPath(name)
if err != nil {
return nil, err
}

if _, err := os.Stat(p); err == nil {
return nil, ErrRepositoryExists
} else if !os.IsNotExist(err) {
return nil, err
}

if err := os.MkdirAll(filepath.Dir(p), 0755); err != nil {
return nil, err
}
if err := os.Mkdir(p, 0755); err != nil {
if os.IsExist(err) {
return nil, ErrRepositoryExists
}
return nil, err
}

return filesystemRepository{path: p}, nil
}

func (s *FilesystemStore) Delete(_ context.Context, name string) error {
p, err := s.repositoryPath(name)
if err != nil {
return err
}
if _, err := os.Stat(p); err != nil {
if os.IsNotExist(err) {
return ErrRepositoryNotFound
}
return err
}
return os.RemoveAll(p)
}

func (s *FilesystemStore) Exists(_ context.Context, name string) (bool, error) {
p, err := s.repositoryPath(name)
if err != nil {
return false, err
}
if _, err := os.Stat(p); err != nil {
if os.IsNotExist(err) {
return false, nil
}
return false, err
}
return true, nil
}

func (s *FilesystemStore) List(_ context.Context) ([]string, error) {
root, err := s.root()
if err != nil {
return nil, err
}

var repos []string
err = filepath.Walk(root, func(p string, info os.FileInfo, err error) error {
if err != nil {
if os.IsNotExist(err) && p == root {
return nil
}
return err
}
if !info.IsDir() || p == root {
return nil
}
if filepath.Ext(info.Name()) != ".git" {
return nil
}
Comment thread
asim marked this conversation as resolved.
name, err := filepath.Rel(root, p)
if err != nil {
return err
}
repos = append(repos, filepath.ToSlash(name))
return filepath.SkipDir
})
if err != nil {
return nil, err
}

sort.Strings(repos)
return repos, nil
}

func (s *FilesystemStore) root() (string, error) {
var root string
var err error
if s.Root != "" {
root, err = filepath.Abs(s.Root)
} else {
root, err = os.Getwd()
}
if err != nil {
return "", err
}

resolved, err := filepath.EvalSymlinks(root)
if err == nil {
return resolved, nil
}
if os.IsNotExist(err) {
return root, nil
}
return "", err
}

func (s *FilesystemStore) repositoryPath(name string) (string, error) {
root, err := s.root()
if err != nil {
return "", err
}

clean := filepath.Clean(name)
if clean == "." || filepath.IsAbs(clean) || clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) {
return "", ErrRepositoryNotFound
}

if err := rejectSymlinkComponents(root, clean); err != nil {
return "", err
}
return filepath.Join(root, clean), nil
Comment thread
asim marked this conversation as resolved.
}

func rejectSymlinkComponents(root, name string) error {
current := root
parts := strings.Split(filepath.Clean(name), string(filepath.Separator))
for _, part := range parts {
current = filepath.Join(current, part)
info, err := os.Lstat(current)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
if info.Mode()&os.ModeSymlink != 0 {
return ErrRepositoryNotFound
}
}
return nil
}
104 changes: 104 additions & 0 deletions server/storage_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import (
"errors"
"os"
"path/filepath"
"reflect"
"runtime"
"testing"
)

Expand Down Expand Up @@ -32,3 +34,105 @@ func TestFilesystemStoreNotFound(t *testing.T) {
t.Fatalf("expected ErrRepositoryNotFound, got %v", err)
}
}

func TestFilesystemStoreLifecycle(t *testing.T) {
ctx := context.Background()
store := NewFilesystemStore(t.TempDir())

repo, err := store.Create(ctx, "team/example.git")
if err != nil {
t.Fatal(err)
}
if filepath.Base(repo.Path()) != "example.git" {
t.Fatalf("unexpected repository path %q", repo.Path())
}

exists, err := store.Exists(ctx, "team/example.git")
if err != nil {
t.Fatal(err)
}
if !exists {
t.Fatal("expected repository to exist")
}

if _, err := store.Create(ctx, "team/example.git"); !errors.Is(err, ErrRepositoryExists) {
t.Fatalf("expected ErrRepositoryExists, got %v", err)
}

repos, err := store.List(ctx)
if err != nil {
t.Fatal(err)
}
if want := []string{"team/example.git"}; !reflect.DeepEqual(repos, want) {
t.Fatalf("expected %v, got %v", want, repos)
}

if err := store.Delete(ctx, "team/example.git"); err != nil {
t.Fatal(err)
}

exists, err = store.Exists(ctx, "team/example.git")
if err != nil {
t.Fatal(err)
}
if exists {
t.Fatal("expected repository to be deleted")
}

if err := store.Delete(ctx, "team/example.git"); !errors.Is(err, ErrRepositoryNotFound) {
t.Fatalf("expected ErrRepositoryNotFound, got %v", err)
}
}

func TestFilesystemStoreListSorted(t *testing.T) {
ctx := context.Background()
store := NewFilesystemStore(t.TempDir())
for _, name := range []string{"z.git", "group/b.git", "a.git"} {
if _, err := store.Create(ctx, name); err != nil {
t.Fatal(err)
}
}

repos, err := store.List(ctx)
if err != nil {
t.Fatal(err)
}
want := []string{"a.git", "group/b.git", "z.git"}
if !reflect.DeepEqual(repos, want) {
t.Fatalf("expected %v, got %v", want, repos)
}
}

func TestFilesystemStoreRejectsTraversal(t *testing.T) {
store := NewFilesystemStore(t.TempDir())
if _, err := store.Create(context.Background(), "../outside.git"); !errors.Is(err, ErrRepositoryNotFound) {
t.Fatalf("expected traversal to be rejected, got %v", err)
}
}

func TestFilesystemStoreCreateRequiresGitSuffix(t *testing.T) {
store := NewFilesystemStore(t.TempDir())
if _, err := store.Create(context.Background(), "plain"); !errors.Is(err, ErrRepositoryNotFound) {
t.Fatalf("expected suffixless repository name to be rejected, got %v", err)
}
}

func TestFilesystemStoreRejectsSymlinkComponents(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("symlink creation may require additional privileges on Windows")
}

root := t.TempDir()
outside := t.TempDir()
if err := os.Symlink(outside, filepath.Join(root, "team")); err != nil {
t.Fatal(err)
}

store := NewFilesystemStore(root)
if _, err := store.Create(context.Background(), "team/example.git"); !errors.Is(err, ErrRepositoryNotFound) {
t.Fatalf("expected symlinked path to be rejected, got %v", err)
}
if _, err := os.Stat(filepath.Join(outside, "example.git")); !os.IsNotExist(err) {
t.Fatalf("repository escaped store root: %v", err)
}
}
3 changes: 3 additions & 0 deletions server/store_api_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
package server

var _ Store = (*FilesystemStore)(nil)
Loading