From 4b94fe8b18f3b88de38a90738a3050c205fc5a24 Mon Sep 17 00:00:00 2001 From: Asim Aslam Date: Sun, 6 Sep 2026 07:45:40 +0100 Subject: [PATCH 1/6] Add repository lifecycle to Store --- server/storage.go | 131 ++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 121 insertions(+), 10 deletions(-) diff --git a/server/storage.go b/server/storage.go index 395bc52..c43f2f8 100644 --- a/server/storage.go +++ b/server/storage.go @@ -5,9 +5,13 @@ import ( "errors" "os" "path/filepath" + "sort" ) -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 @@ -15,6 +19,10 @@ type Repository interface { 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 { @@ -34,16 +42,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 @@ -53,3 +55,112 @@ 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) { + 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 + } + 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) { + if s.Root != "" { + return filepath.Abs(s.Root) + } + return os.Getwd() +} + +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 == ".." || len(clean) >= 3 && clean[:3] == ".."+string(filepath.Separator) { + return "", ErrRepositoryNotFound + } + + return filepath.Join(root, clean), nil +} From 39da853878790aa95af2fd9fc5a6c5c61a72dbbe Mon Sep 17 00:00:00 2001 From: Asim Aslam Date: Sun, 6 Sep 2026 07:45:51 +0100 Subject: [PATCH 2/6] Test repository store lifecycle --- server/storage_test.go | 76 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) diff --git a/server/storage_test.go b/server/storage_test.go index 04acf5a..f1a6c81 100644 --- a/server/storage_test.go +++ b/server/storage_test.go @@ -5,6 +5,7 @@ import ( "errors" "os" "path/filepath" + "reflect" "testing" ) @@ -32,3 +33,78 @@ 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) + } +} From fd469712088edd1b393ba4bf374ef920f5893bac Mon Sep 17 00:00:00 2001 From: Asim Aslam Date: Sun, 6 Sep 2026 07:45:55 +0100 Subject: [PATCH 3/6] Assert Store lifecycle contract --- server/store_api_test.go | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 server/store_api_test.go diff --git a/server/store_api_test.go b/server/store_api_test.go new file mode 100644 index 0000000..1112132 --- /dev/null +++ b/server/store_api_test.go @@ -0,0 +1,3 @@ +package server + +var _ Store = (*FilesystemStore)(nil) From d64cd5efd0bbe16d1b3882c50756f5e3f6feba31 Mon Sep 17 00:00:00 2001 From: Asim Aslam Date: Sun, 6 Sep 2026 07:51:21 +0100 Subject: [PATCH 4/6] Harden repository lifecycle paths --- server/storage.go | 48 ++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 45 insertions(+), 3 deletions(-) diff --git a/server/storage.go b/server/storage.go index c43f2f8..1f27ff8 100644 --- a/server/storage.go +++ b/server/storage.go @@ -6,6 +6,7 @@ import ( "os" "path/filepath" "sort" + "strings" ) var ( @@ -57,6 +58,10 @@ func (s *FilesystemStore) Open(_ context.Context, name string) (Repository, erro } 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 @@ -145,10 +150,25 @@ func (s *FilesystemStore) List(_ context.Context) ([]string, error) { } func (s *FilesystemStore) root() (string, error) { + var root string + var err error if s.Root != "" { - return filepath.Abs(s.Root) + root, err = filepath.Abs(s.Root) + } else { + root, err = os.Getwd() + } + if err != nil { + return "", err } - return os.Getwd() + + 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) { @@ -158,9 +178,31 @@ func (s *FilesystemStore) repositoryPath(name string) (string, error) { } clean := filepath.Clean(name) - if clean == "." || filepath.IsAbs(clean) || clean == ".." || len(clean) >= 3 && clean[:3] == ".."+string(filepath.Separator) { + 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 } + +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 +} From 1e0132639d26d1a89479405c758d1bcf5d9965c1 Mon Sep 17 00:00:00 2001 From: Asim Aslam Date: Sun, 6 Sep 2026 07:51:32 +0100 Subject: [PATCH 5/6] Update test store for lifecycle interface --- server/server_test.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/server/server_test.go b/server/server_test.go index 3c9138a..5a7fc27 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -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 { From d2cfdc6f12fede18d3779a81392176a916769dd3 Mon Sep 17 00:00:00 2001 From: Asim Aslam Date: Sun, 6 Sep 2026 07:51:45 +0100 Subject: [PATCH 6/6] Test lifecycle naming and symlink safety --- server/storage_test.go | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/server/storage_test.go b/server/storage_test.go index f1a6c81..6f48a8d 100644 --- a/server/storage_test.go +++ b/server/storage_test.go @@ -6,6 +6,7 @@ import ( "os" "path/filepath" "reflect" + "runtime" "testing" ) @@ -108,3 +109,30 @@ func TestFilesystemStoreRejectsTraversal(t *testing.T) { 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) + } +}