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 { diff --git a/server/storage.go b/server/storage.go index 395bc52..1f27ff8 100644 --- a/server/storage.go +++ b/server/storage.go @@ -5,9 +5,14 @@ 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 @@ -15,6 +20,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 +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 @@ -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 + } + 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 +} + +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 +} diff --git a/server/storage_test.go b/server/storage_test.go index 04acf5a..6f48a8d 100644 --- a/server/storage_test.go +++ b/server/storage_test.go @@ -5,6 +5,8 @@ import ( "errors" "os" "path/filepath" + "reflect" + "runtime" "testing" ) @@ -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) + } +} 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)