diff --git a/apps/backend/internal/task/handlers/repository_handlers.go b/apps/backend/internal/task/handlers/repository_handlers.go index 2ac007dcca..d93592b22a 100644 --- a/apps/backend/internal/task/handlers/repository_handlers.go +++ b/apps/backend/internal/task/handlers/repository_handlers.go @@ -11,6 +11,7 @@ import ( "github.com/kandev/kandev/internal/common/logger" "github.com/kandev/kandev/internal/task/dto" "github.com/kandev/kandev/internal/task/models" + "github.com/kandev/kandev/internal/task/repository" "github.com/kandev/kandev/internal/task/service" ws "github.com/kandev/kandev/pkg/websocket" "go.uber.org/zap" @@ -38,6 +39,7 @@ func (h *RepositoryHandlers) registerHTTP(router *gin.Engine) { api := router.Group("/api/v1") api.GET("/workspaces/:id/repositories", h.httpListRepositories) api.POST("/workspaces/:id/repositories", h.httpCreateRepository) + api.POST("/workspaces/:id/repositories/initialize-local", h.httpInitializeLocalRepository) api.GET("/workspaces/:id/repositories/discover", h.httpDiscoverRepositories) // Unified branch listing — accepts either ?repository_id= for an imported // workspace repo, or ?path= for an on-machine folder discovered but not @@ -49,6 +51,7 @@ func (h *RepositoryHandlers) registerHTTP(router *gin.Engine) { api.GET("/workspaces/:id/repositories/local-status", h.httpLocalRepositoryStatus) api.GET("/workspaces/:id/repositories/validate", h.httpValidateRepositoryPath) api.GET("/fs/list-dir", h.httpListDirectory) + api.POST("/fs/create-dir", h.httpCreateDirectory) api.GET("/repositories/:id", h.httpGetRepository) api.GET("/repositories/:id/branches", h.httpListRepositoryBranches) api.GET("/repositories/:id/active-session-count", h.httpGetRepositoryActiveSessionCount) @@ -154,14 +157,51 @@ func (h *RepositoryHandlers) httpListDirectory(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": "failed to list directory"}) return } - entries := make([]gin.H, 0, len(result.Entries)) - for _, e := range result.Entries { - entries = append(entries, gin.H{"name": e.Name, "path": e.Path}) - } c.JSON(http.StatusOK, gin.H{ "path": result.Path, "parent": result.Parent, - "entries": entries, + "entries": directoryEntriesResponse(result.Entries), + "choosable": result.Choosable, + }) +} + +func directoryEntriesResponse(entries []service.DirectoryEntry) []gin.H { + response := make([]gin.H, 0, len(entries)) + for _, entry := range entries { + response = append(response, gin.H{"name": entry.Name, "path": entry.Path}) + } + return response +} + +type httpCreateDirectoryRequest struct { + ParentPath string `json:"parent_path"` + Name string `json:"name"` +} + +func (h *RepositoryHandlers) httpCreateDirectory(c *gin.Context) { + var body httpCreateDirectoryRequest + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"}) + return + } + result, err := h.service.CreateDirectory(c.Request.Context(), body.ParentPath, body.Name) + if err != nil { + switch { + case errors.Is(err, service.ErrDirectoryAlreadyExists): + c.JSON(http.StatusConflict, gin.H{"error": "folder already exists"}) + case errors.Is(err, service.ErrInvalidDirectoryCreation), + errors.Is(err, service.ErrInvalidLocalRepositoryInitialization): + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid folder location or name"}) + default: + h.logger.Warn("failed to create directory", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create folder"}) + } + return + } + c.JSON(http.StatusCreated, gin.H{ + "path": result.Path, + "parent": result.Parent, + "entries": directoryEntriesResponse(result.Entries), "choosable": result.Choosable, }) } @@ -280,6 +320,39 @@ type httpCreateRepositoryRequest struct { CopyFiles string `json:"copy_files"` } +type httpInitializeLocalRepositoryRequest struct { + Name string `json:"name"` + ParentPath string `json:"parent_path"` +} + +func (h *RepositoryHandlers) httpInitializeLocalRepository(c *gin.Context) { + var body httpInitializeLocalRepositoryRequest + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"}) + return + } + initialized, err := h.service.InitializeLocalRepository(c.Request.Context(), &service.InitializeLocalRepositoryRequest{ + WorkspaceID: c.Param("id"), + Name: body.Name, + ParentPath: body.ParentPath, + }) + if err != nil { + switch { + case errors.Is(err, service.ErrInvalidLocalRepositoryInitialization): + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + case errors.Is(err, repository.ErrWorkspaceNotFound): + c.JSON(http.StatusNotFound, gin.H{"error": "workspace not found"}) + case errors.Is(err, service.ErrLocalRepositoryTargetExists): + c.JSON(http.StatusConflict, gin.H{"error": "repository target already exists"}) + default: + h.logger.Error("failed to initialize local repository", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to initialize local repository"}) + } + return + } + c.JSON(http.StatusCreated, dto.FromRepository(initialized)) +} + func (h *RepositoryHandlers) httpCreateRepository(c *gin.Context) { var body httpCreateRepositoryRequest if err := c.ShouldBindJSON(&body); err != nil { diff --git a/apps/backend/internal/task/handlers/repository_handlers_test.go b/apps/backend/internal/task/handlers/repository_handlers_test.go index 032802d2ab..6e88304469 100644 --- a/apps/backend/internal/task/handlers/repository_handlers_test.go +++ b/apps/backend/internal/task/handlers/repository_handlers_test.go @@ -8,7 +8,9 @@ import ( "net/http" "net/http/httptest" "net/url" + "os" "path/filepath" + "strconv" "strings" "testing" @@ -51,6 +53,115 @@ func TestHTTPCreateRepositoryRejectsInvalidLocalPathWithoutPersistence(t *testin } } +func TestHTTPInitializeLocalRepositoryCreatesRepository(t *testing.T) { + router, repo := newRepositoryHTTPTestRouter(t) + parentPath := t.TempDir() + body := []byte(`{"name":"new-project","parent_path":` + strconv.Quote(parentPath) + `}`) + request := httptest.NewRequest( + http.MethodPost, + "/api/v1/workspaces/ws-1/repositories/initialize-local", + bytes.NewReader(body), + ) + request.Header.Set("Content-Type", "application/json") + response := httptest.NewRecorder() + + router.ServeHTTP(response, request) + + if response.Code != http.StatusCreated { + t.Fatalf("status = %d, want %d; body = %s", response.Code, http.StatusCreated, response.Body.String()) + } + var created struct { + ID string `json:"id"` + WorkspaceID string `json:"workspace_id"` + Name string `json:"name"` + SourceType string `json:"source_type"` + LocalPath string `json:"local_path"` + DefaultBranch string `json:"default_branch"` + } + if err := json.Unmarshal(response.Body.Bytes(), &created); err != nil { + t.Fatalf("Unmarshal response: %v", err) + } + wantPath := filepath.Join(parentPath, "new-project") + if created.WorkspaceID != "ws-1" || created.Name != "new-project" || created.SourceType != "local" || + created.LocalPath != wantPath || created.DefaultBranch != "main" { + t.Fatalf("created repository = %+v, want workspace ws-1 local repository at %q on main", created, wantPath) + } + stored, err := repo.GetRepository(context.Background(), created.ID) + if err != nil { + t.Fatalf("GetRepository: %v", err) + } + if stored.LocalPath != wantPath || stored.DefaultBranch != "main" { + t.Fatalf("stored repository = %+v, want path %q on main", stored, wantPath) + } +} + +func TestHTTPInitializeLocalRepositoryMapsClientErrors(t *testing.T) { + t.Run("invalid input", func(t *testing.T) { + router, _ := newRepositoryHTTPTestRouter(t) + response := performInitializeLocalRepositoryRequest(t, router, "ws-1", `{"name":"nested/name","parent_path":"/tmp"}`) + if response.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want %d; body = %s", response.Code, http.StatusBadRequest, response.Body.String()) + } + }) + + t.Run("unknown workspace before mutation", func(t *testing.T) { + router, _ := newRepositoryHTTPTestRouter(t) + parent := t.TempDir() + body := `{"name":"new-project","parent_path":` + strconv.Quote(parent) + `}` + response := performInitializeLocalRepositoryRequest(t, router, "missing", body) + if response.Code != http.StatusNotFound { + t.Fatalf("status = %d, want %d; body = %s", response.Code, http.StatusNotFound, response.Body.String()) + } + if _, err := os.Stat(filepath.Join(parent, "new-project")); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("target stat error = %v, want not exist", err) + } + }) + + t.Run("existing target", func(t *testing.T) { + router, repo := newRepositoryHTTPTestRouter(t) + parent := t.TempDir() + target := filepath.Join(parent, "existing") + if err := os.Mkdir(target, 0o755); err != nil { + t.Fatalf("Mkdir target: %v", err) + } + marker := filepath.Join(target, "marker") + if err := os.WriteFile(marker, []byte("keep"), 0o644); err != nil { + t.Fatalf("WriteFile marker: %v", err) + } + body := `{"name":"existing","parent_path":` + strconv.Quote(parent) + `}` + response := performInitializeLocalRepositoryRequest(t, router, "ws-1", body) + if response.Code != http.StatusConflict { + t.Fatalf("status = %d, want %d; body = %s", response.Code, http.StatusConflict, response.Body.String()) + } + content, err := os.ReadFile(marker) + if err != nil || string(content) != "keep" { + t.Fatalf("marker = %q, error %v; existing target was modified", content, err) + } + repositories, err := repo.ListRepositories(context.Background(), "ws-1") + if err != nil || len(repositories) != 0 { + t.Fatalf("repositories = %+v, error %v; want none", repositories, err) + } + }) +} + +func performInitializeLocalRepositoryRequest( + t *testing.T, + router *gin.Engine, + workspaceID string, + body string, +) *httptest.ResponseRecorder { + t.Helper() + request := httptest.NewRequest( + http.MethodPost, + "/api/v1/workspaces/"+workspaceID+"/repositories/initialize-local", + strings.NewReader(body), + ) + request.Header.Set("Content-Type", "application/json") + response := httptest.NewRecorder() + router.ServeHTTP(response, request) + return response +} + func TestHTTPCreateRepositoryIgnoresRemoteURL(t *testing.T) { router, repo := newRepositoryHTTPTestRouter(t) request := httptest.NewRequest( @@ -176,6 +287,31 @@ func TestHTTPListDirectoryIncludesChoosableContract(t *testing.T) { } } +func TestHTTPCreateDirectoryCreatesFolder(t *testing.T) { + router, _ := newRepositoryHTTPTestRouter(t) + parent := t.TempDir() + request := httptest.NewRequest( + http.MethodPost, + "/api/v1/fs/create-dir", + strings.NewReader(`{"parent_path":`+strconv.Quote(parent)+`,"name":"projects"}`), + ) + request.Header.Set("Content-Type", "application/json") + response := httptest.NewRecorder() + + router.ServeHTTP(response, request) + + if response.Code != http.StatusCreated { + t.Fatalf("status = %d, want %d; body = %s", response.Code, http.StatusCreated, response.Body.String()) + } + wantPath := filepath.Join(parent, "projects") + if info, err := os.Stat(wantPath); err != nil || !info.IsDir() { + t.Fatalf("created directory %q: info=%v error=%v", wantPath, info, err) + } + if !strings.Contains(response.Body.String(), strconv.Quote(wantPath)) { + t.Fatalf("response missing created path %q: %s", wantPath, response.Body.String()) + } +} + func TestHTTPListBranchesRejectsRepositoryFromAnotherWorkspace(t *testing.T) { router, repo, svc := newRepositoryHTTPTestRouterWithService(t) for _, workspaceID := range []string{"ws-a", "ws-b"} { diff --git a/apps/backend/internal/task/service/directory_listing_test.go b/apps/backend/internal/task/service/directory_listing_test.go index 7f898417aa..878656ccdd 100644 --- a/apps/backend/internal/task/service/directory_listing_test.go +++ b/apps/backend/internal/task/service/directory_listing_test.go @@ -2,6 +2,7 @@ package service import ( "context" + "errors" "os" "path/filepath" "runtime" @@ -168,3 +169,28 @@ func TestListDirectory_ParentEmptyAtFilesystemRoot(t *testing.T) { t.Errorf("filesystem root %q should be choosable", target) } } + +func TestCreateDirectoryRejectsInvalidOrExistingChild(t *testing.T) { + svc := &Service{} + parent := t.TempDir() + existing := filepath.Join(parent, "existing") + if err := os.Mkdir(existing, 0o755); err != nil { + t.Fatalf("Mkdir existing: %v", err) + } + + if _, err := svc.CreateDirectory(context.Background(), parent, "nested/name"); !errors.Is( + err, + ErrInvalidDirectoryCreation, + ) { + t.Fatalf("invalid name error = %v, want ErrInvalidDirectoryCreation", err) + } + if _, err := svc.CreateDirectory(context.Background(), parent, "existing"); !errors.Is( + err, + ErrDirectoryAlreadyExists, + ) { + t.Fatalf("existing name error = %v, want ErrDirectoryAlreadyExists", err) + } + if entries, err := os.ReadDir(existing); err != nil || len(entries) != 0 { + t.Fatalf("existing directory changed: entries=%v error=%v", entries, err) + } +} diff --git a/apps/backend/internal/task/service/local_directory_creation.go b/apps/backend/internal/task/service/local_directory_creation.go new file mode 100644 index 0000000000..2c64533399 --- /dev/null +++ b/apps/backend/internal/task/service/local_directory_creation.go @@ -0,0 +1,125 @@ +package service + +import ( + "context" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "slices" + "strings" +) + +var ( + ErrInvalidDirectoryCreation = errors.New("invalid directory creation") + ErrDirectoryAlreadyExists = errors.New("directory already exists") +) + +// CreateDirectory creates one child directory for the local folder navigator. +func (s *Service) CreateDirectory( + ctx context.Context, + parentPath string, + name string, +) (DirectoryListing, error) { + name = strings.TrimSpace(name) + if err := validateLocalRepositoryName(name); err != nil { + return DirectoryListing{}, fmt.Errorf("%w: name must be a single path segment", ErrInvalidDirectoryCreation) + } + parentPath, err := canonicalLocalRepositoryParent(parentPath) + if err != nil { + return DirectoryListing{}, fmt.Errorf("%w: parent directory cannot be accessed", ErrInvalidDirectoryCreation) + } + targetPath := filepath.Join(parentPath, name) + if _, err := lstatLocalRepositoryPath(targetPath); err == nil { + return DirectoryListing{}, ErrDirectoryAlreadyExists + } else if !errors.Is(err, fs.ErrNotExist) { + return DirectoryListing{}, fmt.Errorf("%w: target path cannot be inspected", ErrInvalidDirectoryCreation) + } + createdPath, err := createMissingLocalDirectories(parentPath, []string{name}) + if err != nil { + return DirectoryListing{}, fmt.Errorf("create directory: %w", err) + } + return s.ListDirectory(ctx, createdPath) +} + +func ensureLocalRepositoryDirectory(path string) (string, error) { + existingPath, missingSegments, err := nearestExistingLocalDirectory(path) + if err != nil { + return "", err + } + canonicalPath, err := filepath.EvalSymlinks(existingPath) + if err != nil { + return "", err + } + if err := validateLocalRepositoryParentDirectory(canonicalPath); err != nil { + return "", err + } + if len(missingSegments) == 0 { + return canonicalPath, nil + } + createdPath, err := createMissingLocalDirectories(canonicalPath, missingSegments) + if err != nil { + return "", err + } + return filepath.EvalSymlinks(createdPath) +} + +func nearestExistingLocalDirectory(path string) (string, []string, error) { + currentPath := filepath.Clean(path) + missingSegments := make([]string, 0) + for { + resolvedPath, err := filepath.EvalSymlinks(currentPath) + if err == nil { + info, statErr := statLocalRepositoryPath(resolvedPath) + if statErr != nil { + return "", nil, statErr + } + if !info.IsDir() { + return "", nil, fmt.Errorf("not a directory: %s", currentPath) + } + slices.Reverse(missingSegments) + return resolvedPath, missingSegments, nil + } + if !errors.Is(err, fs.ErrNotExist) { + return "", nil, err + } + parentPath := filepath.Dir(currentPath) + if parentPath == currentPath { + return "", nil, err + } + missingSegments = append(missingSegments, filepath.Base(currentPath)) + currentPath = parentPath + } +} + +func createMissingLocalDirectories(parentPath string, segments []string) (string, error) { + currentRoot, err := os.OpenRoot(parentPath) + if err != nil { + return "", err + } + defer func() { _ = currentRoot.Close() }() + + currentPath := parentPath + for _, segment := range segments { + if err := currentRoot.Mkdir(segment, 0o755); err != nil && !errors.Is(err, fs.ErrExist) { + return "", err + } + info, err := currentRoot.Lstat(segment) + if err != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 || + !localRepositoryDirectoryOwnedByProcess(info) || localRepositoryParentSharedWritable(info) { + return "", fmt.Errorf("created directory is not trusted") + } + nextRoot, err := currentRoot.OpenRoot(segment) + if err != nil { + return "", err + } + if err := currentRoot.Close(); err != nil { + _ = nextRoot.Close() + return "", err + } + currentRoot = nextRoot + currentPath = filepath.Join(currentPath, segment) + } + return currentPath, nil +} diff --git a/apps/backend/internal/task/service/local_repository_initialization.go b/apps/backend/internal/task/service/local_repository_initialization.go new file mode 100644 index 0000000000..4e9112bc34 --- /dev/null +++ b/apps/backend/internal/task/service/local_repository_initialization.go @@ -0,0 +1,390 @@ +package service + +import ( + "context" + "crypto/rand" + "encoding/hex" + "errors" + "fmt" + "io" + "io/fs" + "os" + "os/exec" + "path/filepath" + "runtime" + "strconv" + "strings" + + "github.com/kandev/kandev/internal/task/models" + "go.uber.org/zap" +) + +var ( + ErrInvalidLocalRepositoryInitialization = errors.New("invalid local repository initialization") + ErrLocalRepositoryTargetExists = errors.New("local repository target exists") + errLocalRepositoryTargetChanged = errors.New("local repository target changed during initialization") +) + +type initializeGitRepositoryFunc func(context.Context, string, *os.File) error + +const windowsGOOS = "windows" + +type localRepositoryStaging struct { + path string + directory *os.File + identity fs.FileInfo +} + +// InitializeLocalRepository creates an empty Git repository and registers it with a workspace. +func (s *Service) InitializeLocalRepository( + ctx context.Context, + req *InitializeLocalRepositoryRequest, +) (*models.Repository, error) { + return s.initializeLocalRepository(ctx, req, initializeGitRepository) +} + +func (s *Service) initializeLocalRepository( + ctx context.Context, + req *InitializeLocalRepositoryRequest, + initializeGit initializeGitRepositoryFunc, +) (*models.Repository, error) { + if _, err := s.workspaces.GetWorkspace(ctx, req.WorkspaceID); err != nil { + return nil, err + } + + name := strings.TrimSpace(req.Name) + if err := validateLocalRepositoryName(name); err != nil { + return nil, err + } + parentPath, err := canonicalLocalRepositoryParent(req.ParentPath) + if err != nil { + return nil, err + } + targetPath := filepath.Join(parentPath, name) + if _, statErr := lstatLocalRepositoryPath(targetPath); statErr == nil { + return nil, fmt.Errorf("%w: %s", ErrLocalRepositoryTargetExists, targetPath) + } else if !errors.Is(statErr, fs.ErrNotExist) { + return nil, fmt.Errorf("%w: target path cannot be inspected", ErrInvalidLocalRepositoryInitialization) + } + + staging, err := createLocalRepositoryStaging(parentPath) + if err != nil { + return nil, err + } + defer s.closeLocalRepositoryStaging(staging) + published := false + cleanup := func() { + cleanupPath := staging.path + if published { + cleanupPath = targetPath + } + s.cleanupInitializedLocalRepository(cleanupPath, staging.identity) + } + + if initErr := initializeGit(ctx, staging.path, staging.directory); initErr != nil { + cleanup() + return nil, fmt.Errorf("initialize git repository: %w", initErr) + } + if !localRepositoryTargetMatches(staging.path, staging.identity) { + cleanup() + return nil, errLocalRepositoryTargetChanged + } + if publishErr := publishLocalRepository(staging.path, targetPath); publishErr != nil { + cleanup() + if errors.Is(publishErr, fs.ErrExist) { + return nil, fmt.Errorf("%w: %s", ErrLocalRepositoryTargetExists, targetPath) + } + return nil, fmt.Errorf("publish initialized local repository: %w", publishErr) + } + published = true + if chmodErr := setPublishedLocalRepositoryPermissions(staging.directory); chmodErr != nil { + cleanup() + return nil, fmt.Errorf("set initialized local repository permissions: %w", chmodErr) + } + if !localRepositoryTargetMatches(targetPath, staging.identity) { + cleanup() + return nil, errLocalRepositoryTargetChanged + } + + repository, err := s.createRepositoryWithCanonicalPath(ctx, &CreateRepositoryRequest{ + WorkspaceID: req.WorkspaceID, + Name: name, + SourceType: sourceTypeLocal, + LocalPath: targetPath, + DefaultBranch: "main", + }) + if err != nil { + cleanup() + return nil, fmt.Errorf("persist initialized local repository: %w", err) + } + return repository, nil +} + +func createLocalRepositoryStaging(parentPath string) (*localRepositoryStaging, error) { + filesystemRoot, parentRelativePath, err := openLocalRepositoryFilesystemRoot(parentPath) + if err != nil { + return nil, fmt.Errorf("%w: parent directory is not writable", ErrInvalidLocalRepositoryInitialization) + } + defer func() { _ = filesystemRoot.Close() }() + parentRoot, err := filesystemRoot.OpenRoot(parentRelativePath) + if err != nil { + return nil, fmt.Errorf("%w: parent directory is not writable", ErrInvalidLocalRepositoryInitialization) + } + defer func() { _ = parentRoot.Close() }() + + stagingName, stagingDirectory, err := createLocalRepositoryStagingDirectory(parentRoot) + if err != nil { + return nil, err + } + stagingPath := filepath.Join(parentPath, stagingName) + stagingPathInfo, err := parentRoot.Lstat(stagingName) + if err != nil || !stagingPathInfo.IsDir() || stagingPathInfo.Mode()&os.ModeSymlink != 0 { + _ = stagingDirectory.Close() + _ = parentRoot.Remove(stagingName) + return nil, errLocalRepositoryTargetChanged + } + stagingIdentity, err := stagingDirectory.Stat() + if err != nil || !os.SameFile(stagingPathInfo, stagingIdentity) || + !localRepositoryDirectoryOwnedByProcess(stagingIdentity) || + !localRepositoryStagingPermissionsPrivate(stagingIdentity) { + _ = stagingDirectory.Close() + _ = parentRoot.Remove(stagingName) + return nil, errLocalRepositoryTargetChanged + } + if entries, readErr := stagingDirectory.ReadDir(1); len(entries) != 0 || !errors.Is(readErr, io.EOF) { + _ = stagingDirectory.Close() + _ = parentRoot.Remove(stagingName) + return nil, errLocalRepositoryTargetChanged + } + return &localRepositoryStaging{ + path: stagingPath, + directory: stagingDirectory, + identity: stagingIdentity, + }, nil +} + +func createLocalRepositoryStagingDirectory(root *os.Root) (string, *os.File, error) { + for range 8 { + name, err := localRepositoryStagingName() + if err != nil { + return "", nil, fmt.Errorf("create staging directory name: %w", err) + } + if err := root.Mkdir(name, 0o700); err != nil { + if errors.Is(err, fs.ErrExist) { + continue + } + return "", nil, fmt.Errorf("%w: parent directory is not writable", ErrInvalidLocalRepositoryInitialization) + } + directory, err := root.Open(name) + if err != nil { + _ = root.Remove(name) + return "", nil, fmt.Errorf("open local repository staging directory: %w", err) + } + return name, directory, nil + } + return "", nil, fmt.Errorf("%w: could not reserve staging directory", ErrInvalidLocalRepositoryInitialization) +} + +func localRepositoryStagingName() (string, error) { + var suffix [12]byte + if _, err := rand.Read(suffix[:]); err != nil { + return "", err + } + return ".kandev-repository-init-" + hex.EncodeToString(suffix[:]), nil +} + +func (s *Service) closeLocalRepositoryStaging(staging *localRepositoryStaging) { + if err := staging.directory.Close(); err != nil { + s.logger.Warn("failed to close local repository staging directory", + zap.String("path", staging.path), zap.Error(err)) + } +} + +func initializeGitRepository(ctx context.Context, targetPath string, targetDirectory *os.File) error { + gitTarget := targetPath + var extraFiles []*os.File + if inheritedPath := inheritedDirectoryPath(3); inheritedPath != "" { + gitTarget = inheritedPath + extraFiles = []*os.File{targetDirectory} + } + command := exec.CommandContext(ctx, "git", "init", "--initial-branch=main", gitTarget) + command.ExtraFiles = extraFiles + output, err := command.CombinedOutput() + if err != nil { + return fmt.Errorf("%w: %s", err, strings.TrimSpace(string(output))) + } + return nil +} + +func setPublishedLocalRepositoryPermissions(directory *os.File) error { + if runtime.GOOS == windowsGOOS { + return nil + } + return directory.Chmod(0o755) +} + +func (s *Service) cleanupInitializedLocalRepository( + targetPath string, + targetIdentity fs.FileInfo, +) { + filesystemRoot, relativePath, err := openLocalRepositoryFilesystemRoot(targetPath) + if err != nil { + s.logger.Warn("failed to open local repository root for cleanup", + zap.String("path", targetPath), zap.Error(err)) + return + } + defer func() { _ = filesystemRoot.Close() }() + targetDirectory, err := filesystemRoot.OpenRoot(relativePath) + if err != nil { + s.logger.Warn("refusing to remove replaced local repository directory", + zap.String("path", targetPath), zap.Error(err)) + return + } + defer func() { _ = targetDirectory.Close() }() + targetInfo, err := targetDirectory.Stat(".") + if err != nil || !os.SameFile(targetIdentity, targetInfo) { + s.logger.Warn("refusing to remove replaced local repository directory", + zap.String("path", targetPath)) + return + } + if err := removeLocalRepositoryRootTree(targetDirectory, ".git"); err != nil && !errors.Is(err, fs.ErrNotExist) { + s.logger.Warn("failed to clean up initialized Git metadata", + zap.String("path", targetPath), zap.Error(err)) + } + if err := targetDirectory.Close(); err != nil { + s.logger.Warn("failed to close initialized local repository directory", + zap.String("path", targetPath), zap.Error(err)) + return + } + if err := filesystemRoot.Remove(relativePath); err != nil { + s.logger.Warn("failed to remove initialized local repository directory", + zap.String("path", targetPath), zap.Error(err)) + } +} + +func removeLocalRepositoryRootTree(root *os.Root, name string) error { + directory, err := root.Open(name) + if err != nil { + return err + } + entries, readErr := directory.ReadDir(-1) + closeErr := directory.Close() + if readErr != nil { + return readErr + } + if closeErr != nil { + return closeErr + } + for _, entry := range entries { + entryName := filepath.Join(name, entry.Name()) + if entry.IsDir() { + if err := removeLocalRepositoryRootTree(root, entryName); err != nil { + return err + } + continue + } + if err := root.Remove(entryName); err != nil { + return err + } + } + return root.Remove(name) +} + +func inheritedDirectoryPath(fd int) string { + switch runtime.GOOS { + case "linux": + return "/proc/self/fd/" + strconv.Itoa(fd) + case "darwin", "freebsd", "openbsd", "netbsd": + return "/dev/fd/" + strconv.Itoa(fd) + default: + return "" + } +} + +func localRepositoryTargetMatches(targetPath string, createdTarget fs.FileInfo) bool { + currentTarget, err := lstatLocalRepositoryPath(targetPath) + return err == nil && currentTarget.IsDir() && os.SameFile(createdTarget, currentTarget) +} + +func validateLocalRepositoryName(name string) error { + if name == "" || name == "." || name == ".." || strings.ContainsAny(name, `/\`) || strings.ContainsRune(name, 0) { + return fmt.Errorf("%w: name must be a single path segment", ErrInvalidLocalRepositoryInitialization) + } + return nil +} + +func canonicalLocalRepositoryParent(parentPath string) (string, error) { + if parentPath == "" || !filepath.IsAbs(parentPath) { + return "", fmt.Errorf("%w: parent_path must be absolute", ErrInvalidLocalRepositoryInitialization) + } + canonicalPath, err := ensureLocalRepositoryDirectory(filepath.Clean(parentPath)) + if err != nil { + return "", fmt.Errorf("%w: parent directory cannot be accessed", ErrInvalidLocalRepositoryInitialization) + } + if err := validateLocalRepositoryParentDirectory(canonicalPath); err != nil { + return "", err + } + return canonicalPath, nil +} + +func validateLocalRepositoryParentDirectory(canonicalPath string) error { + info, err := statLocalRepositoryPath(canonicalPath) + if err != nil || !info.IsDir() { + return fmt.Errorf("%w: parent_path must be an accessible directory", ErrInvalidLocalRepositoryInitialization) + } + if !localRepositoryParentWritable(info) { + return fmt.Errorf("%w: parent directory is not writable", ErrInvalidLocalRepositoryInitialization) + } + if err := validateLocalRepositoryParentChain(canonicalPath); err != nil { + return err + } + return nil +} + +func validateLocalRepositoryParentChain(parentPath string) error { + filesystemRoot, relativePath, err := openLocalRepositoryFilesystemRoot(parentPath) + if err != nil { + return fmt.Errorf("%w: parent directory ownership is not trusted", ErrInvalidLocalRepositoryInitialization) + } + defer func() { _ = filesystemRoot.Close() }() + for path := relativePath; ; path = filepath.Dir(path) { + info, err := filesystemRoot.Lstat(path) + if err != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 || + !localRepositoryDirectoryOwnerTrusted(info) { + return fmt.Errorf("%w: parent directory ownership is not trusted", ErrInvalidLocalRepositoryInitialization) + } + if localRepositoryParentSharedWritable(info) { + return fmt.Errorf("%w: parent directory must not be shared writable", ErrInvalidLocalRepositoryInitialization) + } + if path == "." { + return nil + } + } +} + +func lstatLocalRepositoryPath(path string) (fs.FileInfo, error) { + filesystemRoot, relativePath, err := openLocalRepositoryFilesystemRoot(path) + if err != nil { + return nil, err + } + defer func() { _ = filesystemRoot.Close() }() + return filesystemRoot.Lstat(relativePath) +} + +func statLocalRepositoryPath(path string) (fs.FileInfo, error) { + filesystemRoot, relativePath, err := openLocalRepositoryFilesystemRoot(path) + if err != nil { + return nil, err + } + defer func() { _ = filesystemRoot.Close() }() + return filesystemRoot.Stat(relativePath) +} + +func openLocalRepositoryFilesystemRoot(path string) (*os.Root, string, error) { + rootPath, relativePath := splitAbsForRoot(path) + filesystemRoot, err := os.OpenRoot(rootPath) + if err != nil { + return nil, "", err + } + return filesystemRoot, relativePath, nil +} diff --git a/apps/backend/internal/task/service/local_repository_initialization_test.go b/apps/backend/internal/task/service/local_repository_initialization_test.go new file mode 100644 index 0000000000..bf24e0f0f8 --- /dev/null +++ b/apps/backend/internal/task/service/local_repository_initialization_test.go @@ -0,0 +1,374 @@ +package service + +import ( + "context" + "errors" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/kandev/kandev/internal/events" + "github.com/kandev/kandev/internal/task/models" + "github.com/kandev/kandev/internal/task/repository" +) + +func TestServiceInitializeLocalRepositoryCreatesCommitlessMainRepository(t *testing.T) { + svc, eventBus, repo := createTestService(t) + ctx := context.Background() + if err := repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace"}); err != nil { + t.Fatalf("CreateWorkspace: %v", err) + } + realParent := t.TempDir() + parentLink := filepath.Join(t.TempDir(), "projects") + if err := os.Symlink(realParent, parentLink); err != nil { + t.Fatalf("Symlink parent: %v", err) + } + + created, err := svc.InitializeLocalRepository(ctx, &InitializeLocalRepositoryRequest{ + WorkspaceID: "ws-1", + Name: " new-project ", + ParentPath: parentLink, + }) + if err != nil { + t.Fatalf("InitializeLocalRepository: %v", err) + } + wantPath := filepath.Join(realParent, "new-project") + if created.WorkspaceID != "ws-1" || created.Name != "new-project" || created.SourceType != sourceTypeLocal || + created.LocalPath != wantPath || created.DefaultBranch != "main" { + t.Fatalf("created repository = %+v, want workspace ws-1 local repository at %q on main", created, wantPath) + } + entries, err := os.ReadDir(wantPath) + if err != nil { + t.Fatalf("ReadDir target: %v", err) + } + if len(entries) != 1 || entries[0].Name() != ".git" || !entries[0].IsDir() { + t.Fatalf("target entries = %+v, want only .git directory", entries) + } + if branch := runGitOutput(t, wantPath, "symbolic-ref", "--short", "HEAD"); branch != "main" { + t.Fatalf("unborn branch = %q, want main", branch) + } + if command := exec.Command("git", "-C", wantPath, "rev-parse", "--verify", "HEAD"); command.Run() == nil { + t.Fatal("rev-parse HEAD succeeded, want repository with no commits") + } + stored, err := repo.GetRepository(ctx, created.ID) + if err != nil { + t.Fatalf("GetRepository: %v", err) + } + if stored.LocalPath != wantPath || stored.DefaultBranch != "main" { + t.Fatalf("stored repository = %+v, want path %q on main", stored, wantPath) + } + eventsPublished := eventBus.GetPublishedEvents() + if len(eventsPublished) != 1 || eventsPublished[0].Type != events.RepositoryCreated { + t.Fatalf("published events = %+v, want one %q event", eventsPublished, events.RepositoryCreated) + } +} + +func TestServiceInitializeLocalRepositoryCreatesMissingParentDirectories(t *testing.T) { + svc, _, repo := createTestService(t) + ctx := context.Background() + if err := repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace"}); err != nil { + t.Fatalf("CreateWorkspace: %v", err) + } + parent := filepath.Join(t.TempDir(), "projects", "team") + + created, err := svc.InitializeLocalRepository(ctx, &InitializeLocalRepositoryRequest{ + WorkspaceID: "ws-1", + Name: "new-project", + ParentPath: parent, + }) + if err != nil { + t.Fatalf("InitializeLocalRepository: %v", err) + } + wantPath := filepath.Join(parent, "new-project") + if created.LocalPath != wantPath { + t.Fatalf("created path = %q, want %q", created.LocalPath, wantPath) + } + for _, path := range []string{parent, wantPath, filepath.Join(wantPath, ".git")} { + if info, statErr := os.Stat(path); statErr != nil || !info.IsDir() { + t.Fatalf("directory %q: info=%v error=%v", path, info, statErr) + } + } +} + +func TestServiceInitializeLocalRepositoryRejectsInvalidInputWithoutMutation(t *testing.T) { + tests := []struct { + name string + repository string + parent func(t *testing.T) string + target func(parent string) string + }{ + {name: "empty name", repository: " ", parent: func(t *testing.T) string { return t.TempDir() }}, + {name: "dot name", repository: ".", parent: func(t *testing.T) string { return t.TempDir() }}, + {name: "parent name", repository: "..", parent: func(t *testing.T) string { return t.TempDir() }}, + {name: "nested name", repository: "nested/name", parent: func(t *testing.T) string { return t.TempDir() }}, + {name: "windows nested name", repository: `nested\name`, parent: func(t *testing.T) string { return t.TempDir() }}, + {name: "relative parent", repository: "new-project", parent: func(t *testing.T) string { return "relative" }}, + {name: "file parent", repository: "new-project", parent: func(t *testing.T) string { + path := filepath.Join(t.TempDir(), "file") + if err := os.WriteFile(path, []byte("file"), 0o644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + return path + }}, + {name: "unwritable parent", repository: "new-project", parent: func(t *testing.T) string { + path := t.TempDir() + if err := os.Chmod(path, 0o555); err != nil { + t.Fatalf("Chmod: %v", err) + } + return path + }, target: func(parent string) string { return filepath.Join(parent, "new-project") }}, + {name: "shared writable parent", repository: "new-project", parent: func(t *testing.T) string { + path := t.TempDir() + if err := os.Chmod(path, 0o777); err != nil { + t.Fatalf("Chmod: %v", err) + } + return path + }, target: func(parent string) string { return filepath.Join(parent, "new-project") }}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + svc, _, repo := createTestService(t) + ctx := context.Background() + if err := repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace"}); err != nil { + t.Fatalf("CreateWorkspace: %v", err) + } + parent := test.parent(t) + + _, err := svc.InitializeLocalRepository(ctx, &InitializeLocalRepositoryRequest{ + WorkspaceID: "ws-1", + Name: test.repository, + ParentPath: parent, + }) + if !errors.Is(err, ErrInvalidLocalRepositoryInitialization) { + t.Fatalf("error = %v, want ErrInvalidLocalRepositoryInitialization", err) + } + if test.target != nil { + if _, statErr := os.Stat(test.target(parent)); !errors.Is(statErr, os.ErrNotExist) { + t.Fatalf("target stat error = %v, want not exist", statErr) + } + } + assertNoInitializedRepositories(t, repo, "ws-1") + }) + } +} + +func TestOpenLocalRepositoryDirectoryDoesNotFollowSymlink(t *testing.T) { + target := t.TempDir() + link := filepath.Join(t.TempDir(), "staging-link") + if err := os.Symlink(target, link); err != nil { + t.Skipf("Symlink is unavailable: %v", err) + } + + file, err := openLocalRepositoryDirectory(link) + if file != nil { + _ = file.Close() + } + if err == nil { + t.Fatal("openLocalRepositoryDirectory followed symlink") + } +} + +func TestServiceInitializeLocalRepositoryRejectsExistingTargetWithoutMutation(t *testing.T) { + for _, existingContent := range []string{"", "keep me"} { + name := "empty" + if existingContent != "" { + name = "non-empty" + } + t.Run(name, func(t *testing.T) { + svc, _, repo := createTestService(t) + ctx := context.Background() + if err := repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace"}); err != nil { + t.Fatalf("CreateWorkspace: %v", err) + } + parent := t.TempDir() + target := filepath.Join(parent, "existing") + if err := os.Mkdir(target, 0o755); err != nil { + t.Fatalf("Mkdir target: %v", err) + } + if existingContent != "" { + if err := os.WriteFile(filepath.Join(target, "marker"), []byte(existingContent), 0o644); err != nil { + t.Fatalf("WriteFile marker: %v", err) + } + } + + _, err := svc.InitializeLocalRepository(ctx, &InitializeLocalRepositoryRequest{ + WorkspaceID: "ws-1", Name: "existing", ParentPath: parent, + }) + if !errors.Is(err, ErrLocalRepositoryTargetExists) { + t.Fatalf("error = %v, want ErrLocalRepositoryTargetExists", err) + } + if existingContent != "" { + content, readErr := os.ReadFile(filepath.Join(target, "marker")) + if readErr != nil || string(content) != existingContent { + t.Fatalf("marker = %q, error %v; existing target was modified", content, readErr) + } + } + assertNoInitializedRepositories(t, repo, "ws-1") + }) + } +} + +func TestServiceInitializeLocalRepositoryValidatesWorkspaceBeforeMutation(t *testing.T) { + svc, _, repo := createTestService(t) + parent := t.TempDir() + target := filepath.Join(parent, "new-project") + + _, err := svc.InitializeLocalRepository(context.Background(), &InitializeLocalRepositoryRequest{ + WorkspaceID: "missing", Name: "new-project", ParentPath: parent, + }) + if !errors.Is(err, repository.ErrWorkspaceNotFound) { + t.Fatalf("error = %v, want ErrWorkspaceNotFound", err) + } + if _, statErr := os.Stat(target); !errors.Is(statErr, os.ErrNotExist) { + t.Fatalf("target stat error = %v, want not exist", statErr) + } + assertNoInitializedRepositories(t, repo, "missing") +} + +func TestServiceInitializeLocalRepositoryCleansUpGitFailure(t *testing.T) { + svc, _, repo := createTestService(t) + ctx := context.Background() + if err := repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace"}); err != nil { + t.Fatalf("CreateWorkspace: %v", err) + } + parent := t.TempDir() + t.Setenv("PATH", t.TempDir()) + + _, err := svc.InitializeLocalRepository(ctx, &InitializeLocalRepositoryRequest{ + WorkspaceID: "ws-1", Name: "new-project", ParentPath: parent, + }) + if err == nil || !strings.Contains(err.Error(), "initialize git repository") { + t.Fatalf("error = %v, want Git initialization error", err) + } + if _, statErr := os.Stat(filepath.Join(parent, "new-project")); !errors.Is(statErr, os.ErrNotExist) { + t.Fatalf("target stat error = %v, want cleanup", statErr) + } + assertNoInitializedRepositories(t, repo, "ws-1") +} + +func TestServiceInitializeLocalRepositoryDoesNotRemoveReplacedTarget(t *testing.T) { + svc, _, repo := createTestService(t) + ctx := context.Background() + if err := repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace"}); err != nil { + t.Fatalf("CreateWorkspace: %v", err) + } + parent := t.TempDir() + target := filepath.Join(parent, "new-project") + movedTarget := filepath.Join(parent, "request-owned") + replacementPath := "" + wantErr := errors.New("git unavailable") + + _, err := svc.initializeLocalRepository(ctx, &InitializeLocalRepositoryRequest{ + WorkspaceID: "ws-1", Name: "new-project", ParentPath: parent, + }, func(_ context.Context, stagingPath string, _ *os.File) error { + replacementPath = stagingPath + if renameErr := os.Rename(stagingPath, movedTarget); renameErr != nil { + t.Fatalf("Rename target: %v", renameErr) + } + if mkdirErr := os.Mkdir(stagingPath, 0o755); mkdirErr != nil { + t.Fatalf("Mkdir replacement: %v", mkdirErr) + } + if writeErr := os.WriteFile(filepath.Join(stagingPath, "keep"), []byte("replacement"), 0o644); writeErr != nil { + t.Fatalf("Write replacement marker: %v", writeErr) + } + return wantErr + }) + if !errors.Is(err, wantErr) { + t.Fatalf("error = %v, want %v", err, wantErr) + } + if content, readErr := os.ReadFile(filepath.Join(replacementPath, "keep")); readErr != nil || string(content) != "replacement" { + t.Fatalf("replacement marker = %q, error %v; cleanup removed or changed replacement", content, readErr) + } + if info, statErr := os.Stat(movedTarget); statErr != nil || !info.IsDir() { + t.Fatalf("request-owned directory stat = %+v, error %v; want preserved directory", info, statErr) + } + if _, statErr := os.Stat(target); !errors.Is(statErr, os.ErrNotExist) { + t.Fatalf("published target stat error = %v, want not exist", statErr) + } + assertNoInitializedRepositories(t, repo, "ws-1") +} + +func TestServiceInitializeLocalRepositoryDoesNotPublishOverRacingTarget(t *testing.T) { + svc, _, repo := createTestService(t) + ctx := context.Background() + if err := repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace"}); err != nil { + t.Fatalf("CreateWorkspace: %v", err) + } + parent := t.TempDir() + target := filepath.Join(parent, "new-project") + marker := filepath.Join(target, "keep") + + _, err := svc.initializeLocalRepository(ctx, &InitializeLocalRepositoryRequest{ + WorkspaceID: "ws-1", Name: "new-project", ParentPath: parent, + }, func(context.Context, string, *os.File) error { + if mkdirErr := os.Mkdir(target, 0o755); mkdirErr != nil { + t.Fatalf("Mkdir racing target: %v", mkdirErr) + } + if writeErr := os.WriteFile(marker, []byte("racing target"), 0o644); writeErr != nil { + t.Fatalf("Write racing target marker: %v", writeErr) + } + return nil + }) + if !errors.Is(err, ErrLocalRepositoryTargetExists) { + t.Fatalf("error = %v, want ErrLocalRepositoryTargetExists", err) + } + if content, readErr := os.ReadFile(marker); readErr != nil || string(content) != "racing target" { + t.Fatalf("racing target marker = %q, error %v; publish modified existing target", content, readErr) + } + assertNoInitializedRepositories(t, repo, "ws-1") +} + +type failingCreateRepository struct { + repository.RepositoryEntityRepository + err error +} + +func (r failingCreateRepository) CreateRepository(context.Context, *models.Repository) error { + return r.err +} + +func TestServiceInitializeLocalRepositoryCleansUpPersistenceFailure(t *testing.T) { + svc, _, repo := createTestService(t) + ctx := context.Background() + if err := repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace"}); err != nil { + t.Fatalf("CreateWorkspace: %v", err) + } + wantErr := errors.New("database unavailable") + svc.repoEntities = failingCreateRepository{RepositoryEntityRepository: repo, err: wantErr} + parent := t.TempDir() + + _, err := svc.InitializeLocalRepository(ctx, &InitializeLocalRepositoryRequest{ + WorkspaceID: "ws-1", Name: "new-project", ParentPath: parent, + }) + if !errors.Is(err, wantErr) { + t.Fatalf("error = %v, want %v", err, wantErr) + } + if _, statErr := os.Stat(filepath.Join(parent, "new-project")); !errors.Is(statErr, os.ErrNotExist) { + t.Fatalf("target stat error = %v, want cleanup", statErr) + } + assertNoInitializedRepositories(t, repo, "ws-1") +} + +func runGitOutput(t *testing.T, repositoryPath string, args ...string) string { + t.Helper() + commandArgs := append([]string{"-C", repositoryPath}, args...) + output, err := exec.Command("git", commandArgs...).CombinedOutput() + if err != nil { + t.Fatalf("git %v: %v: %s", args, err, output) + } + return strings.TrimSpace(string(output)) +} + +func assertNoInitializedRepositories(t *testing.T, repo repository.RepositoryEntityRepository, workspaceID string) { + t.Helper() + repositories, err := repo.ListRepositories(context.Background(), workspaceID) + if err != nil { + t.Fatalf("ListRepositories: %v", err) + } + if len(repositories) != 0 { + t.Fatalf("persisted repositories = %+v, want none", repositories) + } +} diff --git a/apps/backend/internal/task/service/local_repository_initialization_unix_test.go b/apps/backend/internal/task/service/local_repository_initialization_unix_test.go new file mode 100644 index 0000000000..fd40d0a1ff --- /dev/null +++ b/apps/backend/internal/task/service/local_repository_initialization_unix_test.go @@ -0,0 +1,62 @@ +//go:build !windows + +package service + +import ( + "context" + "errors" + "os" + "path/filepath" + "testing" + + "github.com/kandev/kandev/internal/task/models" +) + +func TestServiceInitializeLocalRepositoryRejectsForeignOwnedStickyParent(t *testing.T) { + if os.Geteuid() != 0 { + t.Skip("changing directory ownership requires root") + } + parent := t.TempDir() + if err := os.Chmod(parent, os.ModeSticky|0o777); err != nil { + t.Fatalf("Chmod parent: %v", err) + } + if err := os.Chown(parent, 12345, 12345); err != nil { + t.Fatalf("Chown parent: %v", err) + } + assertLocalRepositoryParentRejected(t, parent) +} + +func TestServiceInitializeLocalRepositoryRejectsForeignOwnedAncestor(t *testing.T) { + if os.Geteuid() != 0 { + t.Skip("changing directory ownership requires root") + } + ancestor := filepath.Join(t.TempDir(), "foreign") + parent := filepath.Join(ancestor, "projects") + if err := os.MkdirAll(parent, 0o755); err != nil { + t.Fatalf("MkdirAll parent: %v", err) + } + if err := os.Chown(ancestor, 12345, 12345); err != nil { + t.Fatalf("Chown ancestor: %v", err) + } + assertLocalRepositoryParentRejected(t, parent) +} + +func assertLocalRepositoryParentRejected(t *testing.T, parent string) { + t.Helper() + svc, _, repo := createTestService(t) + ctx := context.Background() + if err := repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace"}); err != nil { + t.Fatalf("CreateWorkspace: %v", err) + } + + _, err := svc.InitializeLocalRepository(ctx, &InitializeLocalRepositoryRequest{ + WorkspaceID: "ws-1", Name: "new-project", ParentPath: parent, + }) + if !errors.Is(err, ErrInvalidLocalRepositoryInitialization) { + t.Fatalf("error = %v, want ErrInvalidLocalRepositoryInitialization", err) + } + if _, statErr := os.Stat(filepath.Join(parent, "new-project")); !errors.Is(statErr, os.ErrNotExist) { + t.Fatalf("target stat error = %v, want not exist", statErr) + } + assertNoInitializedRepositories(t, repo, "ws-1") +} diff --git a/apps/backend/internal/task/service/local_repository_publish_darwin.go b/apps/backend/internal/task/service/local_repository_publish_darwin.go new file mode 100644 index 0000000000..4b8fa20733 --- /dev/null +++ b/apps/backend/internal/task/service/local_repository_publish_darwin.go @@ -0,0 +1,51 @@ +//go:build darwin + +package service + +import ( + "fmt" + "io/fs" + "os" + "syscall" + + "golang.org/x/sys/unix" +) + +func localRepositoryDirectoryOwnedByProcess(info fs.FileInfo) bool { + stat, ok := info.Sys().(*syscall.Stat_t) + return ok && stat.Uid == uint32(os.Geteuid()) +} + +func localRepositoryDirectoryOwnerTrusted(info fs.FileInfo) bool { + stat, ok := info.Sys().(*syscall.Stat_t) + return ok && (stat.Uid == 0 || stat.Uid == uint32(os.Geteuid())) +} + +func localRepositoryParentWritable(info fs.FileInfo) bool { + return info.Mode().Perm()&0o222 != 0 +} + +func localRepositoryParentSharedWritable(info fs.FileInfo) bool { + return info.Mode().Perm()&0o022 != 0 && info.Mode()&os.ModeSticky == 0 +} + +func localRepositoryStagingPermissionsPrivate(info fs.FileInfo) bool { + return info.Mode().Perm() == 0o700 +} + +func openLocalRepositoryDirectory(path string) (*os.File, error) { + fd, err := unix.Open(path, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0) + if err != nil { + return nil, err + } + file := os.NewFile(uintptr(fd), path) + if file == nil { + _ = unix.Close(fd) + return nil, fmt.Errorf("wrap directory descriptor") + } + return file, nil +} + +func publishLocalRepository(stagingPath, targetPath string) error { + return unix.RenameatxNp(unix.AT_FDCWD, stagingPath, unix.AT_FDCWD, targetPath, unix.RENAME_EXCL) +} diff --git a/apps/backend/internal/task/service/local_repository_publish_linux.go b/apps/backend/internal/task/service/local_repository_publish_linux.go new file mode 100644 index 0000000000..b5d5a8dc62 --- /dev/null +++ b/apps/backend/internal/task/service/local_repository_publish_linux.go @@ -0,0 +1,51 @@ +//go:build linux + +package service + +import ( + "fmt" + "io/fs" + "os" + "syscall" + + "golang.org/x/sys/unix" +) + +func localRepositoryDirectoryOwnedByProcess(info fs.FileInfo) bool { + stat, ok := info.Sys().(*syscall.Stat_t) + return ok && stat.Uid == uint32(os.Geteuid()) +} + +func localRepositoryDirectoryOwnerTrusted(info fs.FileInfo) bool { + stat, ok := info.Sys().(*syscall.Stat_t) + return ok && (stat.Uid == 0 || stat.Uid == uint32(os.Geteuid())) +} + +func localRepositoryParentWritable(info fs.FileInfo) bool { + return info.Mode().Perm()&0o222 != 0 +} + +func localRepositoryParentSharedWritable(info fs.FileInfo) bool { + return info.Mode().Perm()&0o022 != 0 && info.Mode()&os.ModeSticky == 0 +} + +func localRepositoryStagingPermissionsPrivate(info fs.FileInfo) bool { + return info.Mode().Perm() == 0o700 +} + +func openLocalRepositoryDirectory(path string) (*os.File, error) { + fd, err := unix.Open(path, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0) + if err != nil { + return nil, err + } + file := os.NewFile(uintptr(fd), path) + if file == nil { + _ = unix.Close(fd) + return nil, fmt.Errorf("wrap directory descriptor") + } + return file, nil +} + +func publishLocalRepository(stagingPath, targetPath string) error { + return unix.Renameat2(unix.AT_FDCWD, stagingPath, unix.AT_FDCWD, targetPath, unix.RENAME_NOREPLACE) +} diff --git a/apps/backend/internal/task/service/local_repository_publish_other.go b/apps/backend/internal/task/service/local_repository_publish_other.go new file mode 100644 index 0000000000..a12c651a65 --- /dev/null +++ b/apps/backend/internal/task/service/local_repository_publish_other.go @@ -0,0 +1,50 @@ +//go:build !linux && !darwin && !windows + +package service + +import ( + "fmt" + "io/fs" + "os" +) + +func localRepositoryDirectoryOwnedByProcess(fs.FileInfo) bool { + return false +} + +func localRepositoryDirectoryOwnerTrusted(fs.FileInfo) bool { + return false +} + +func localRepositoryParentWritable(info fs.FileInfo) bool { + return info.Mode().Perm()&0o222 != 0 +} + +func localRepositoryParentSharedWritable(info fs.FileInfo) bool { + return info.Mode().Perm()&0o022 != 0 && info.Mode()&os.ModeSticky == 0 +} + +func localRepositoryStagingPermissionsPrivate(info fs.FileInfo) bool { + return info.Mode().Perm() == 0o700 +} + +func openLocalRepositoryDirectory(path string) (*os.File, error) { + pathInfo, err := os.Lstat(path) + if err != nil || !pathInfo.IsDir() || pathInfo.Mode()&os.ModeSymlink != 0 { + return nil, fmt.Errorf("path is not a directory") + } + file, err := os.Open(path) + if err != nil { + return nil, err + } + fileInfo, err := file.Stat() + if err != nil || !os.SameFile(pathInfo, fileInfo) { + _ = file.Close() + return nil, fmt.Errorf("directory changed while opening") + } + return file, nil +} + +func publishLocalRepository(_, _ string) error { + return fmt.Errorf("exclusive repository publication is unsupported on this platform") +} diff --git a/apps/backend/internal/task/service/local_repository_publish_windows.go b/apps/backend/internal/task/service/local_repository_publish_windows.go new file mode 100644 index 0000000000..02d625ca99 --- /dev/null +++ b/apps/backend/internal/task/service/local_repository_publish_windows.go @@ -0,0 +1,75 @@ +//go:build windows + +package service + +import ( + "errors" + "fmt" + "io/fs" + "os" + + "golang.org/x/sys/windows" +) + +func localRepositoryDirectoryOwnedByProcess(fs.FileInfo) bool { + return true +} + +func localRepositoryDirectoryOwnerTrusted(fs.FileInfo) bool { + return true +} + +func localRepositoryParentWritable(info fs.FileInfo) bool { + // Windows reports directory permissions as 0777; MkdirTemp performs the + // authoritative write check when the staging directory is created. + return info.Mode().Perm()&0o222 != 0 +} + +func localRepositoryParentSharedWritable(fs.FileInfo) bool { + return false +} + +func localRepositoryStagingPermissionsPrivate(fs.FileInfo) bool { + return true +} + +func openLocalRepositoryDirectory(path string) (*os.File, error) { + pathInfo, err := os.Lstat(path) + if err != nil || !pathInfo.IsDir() || pathInfo.Mode()&os.ModeSymlink != 0 { + return nil, fmt.Errorf("path is not a directory") + } + file, err := os.Open(path) + if err != nil { + return nil, err + } + fileInfo, err := file.Stat() + if err != nil || !os.SameFile(pathInfo, fileInfo) { + _ = file.Close() + return nil, fmt.Errorf("directory changed while opening") + } + return file, nil +} + +func publishLocalRepository(stagingPath, targetPath string) error { + from, err := windows.UTF16PtrFromString(stagingPath) + if err != nil { + return err + } + to, err := windows.UTF16PtrFromString(targetPath) + if err != nil { + return err + } + if err := windows.MoveFileEx(from, to, 0); err != nil { + if errors.Is(err, windows.ERROR_ALREADY_EXISTS) || + errors.Is(err, windows.ERROR_FILE_EXISTS) || localRepositoryTargetExists(targetPath) { + return fs.ErrExist + } + return err + } + return nil +} + +func localRepositoryTargetExists(targetPath string) bool { + _, err := os.Lstat(targetPath) + return err == nil +} diff --git a/apps/backend/internal/task/service/local_repository_publish_windows_test.go b/apps/backend/internal/task/service/local_repository_publish_windows_test.go new file mode 100644 index 0000000000..a426bf585b --- /dev/null +++ b/apps/backend/internal/task/service/local_repository_publish_windows_test.go @@ -0,0 +1,36 @@ +//go:build windows + +package service + +import ( + "errors" + "io/fs" + "os" + "path/filepath" + "testing" +) + +func TestPublishLocalRepositoryDoesNotReplaceExistingWindowsTarget(t *testing.T) { + parent := t.TempDir() + stagingPath := filepath.Join(parent, "staging") + targetPath := filepath.Join(parent, "target") + if err := os.Mkdir(stagingPath, 0o700); err != nil { + t.Fatalf("Mkdir staging: %v", err) + } + if err := os.Mkdir(targetPath, 0o755); err != nil { + t.Fatalf("Mkdir target: %v", err) + } + markerPath := filepath.Join(targetPath, "keep.txt") + if err := os.WriteFile(markerPath, []byte("keep"), 0o644); err != nil { + t.Fatalf("WriteFile marker: %v", err) + } + + err := publishLocalRepository(stagingPath, targetPath) + if !errors.Is(err, fs.ErrExist) { + t.Fatalf("publishLocalRepository error = %v, want fs.ErrExist", err) + } + content, readErr := os.ReadFile(markerPath) + if readErr != nil || string(content) != "keep" { + t.Fatalf("target marker = %q, error %v; existing target was modified", content, readErr) + } +} diff --git a/apps/backend/internal/task/service/service_requests.go b/apps/backend/internal/task/service/service_requests.go index 532e7e9a5d..7040699b2f 100644 --- a/apps/backend/internal/task/service/service_requests.go +++ b/apps/backend/internal/task/service/service_requests.go @@ -144,6 +144,13 @@ type CreateRepositoryRequest struct { CopyFiles string `json:"copy_files"` } +// InitializeLocalRepositoryRequest contains the data for creating and registering a new local repository. +type InitializeLocalRepositoryRequest struct { + WorkspaceID string `json:"workspace_id"` + Name string `json:"name"` + ParentPath string `json:"parent_path"` +} + // UpdateRepositoryRequest contains the data for updating a repository type UpdateRepositoryRequest struct { Name *string `json:"name,omitempty"` diff --git a/apps/backend/internal/task/service/service_resources.go b/apps/backend/internal/task/service/service_resources.go index 3b904c8a97..03a67cf8b9 100644 --- a/apps/backend/internal/task/service/service_resources.go +++ b/apps/backend/internal/task/service/service_resources.go @@ -608,6 +608,22 @@ func (s *Service) CreateRepository(ctx context.Context, req *CreateRepositoryReq if err != nil { return nil, err } + return s.createRepository(ctx, req, localPath, true) +} + +func (s *Service) createRepositoryWithCanonicalPath( + ctx context.Context, + req *CreateRepositoryRequest, +) (*models.Repository, error) { + return s.createRepository(ctx, req, req.LocalPath, false) +} + +func (s *Service) createRepository( + ctx context.Context, + req *CreateRepositoryRequest, + localPath string, + resolveProvider bool, +) (*models.Repository, error) { sourceType := req.SourceType if sourceType == "" { sourceType = sourceTypeLocal @@ -652,17 +668,8 @@ func (s *Service) CreateRepository(ctx context.Context, req *CreateRepositoryReq CopyFiles: req.CopyFiles, } - // Auto-detect provider identity from the origin when it is available. - if repository.LocalPath != "" && (repository.Provider == "" || repository.ProviderHost == "") { - p, h, o, n := ResolveGitRemoteProviderIdentity(repository.LocalPath) - if repository.Provider == "" { - repository.Provider = p - } - if repository.Provider != "" && (strings.HasPrefix(h, "http://") || strings.HasPrefix(h, "https://")) { - repository.ProviderHost = h - repository.ProviderOwner = o - repository.ProviderName = n - } + if resolveProvider { + resolveRepositoryProviderIdentity(repository) } if err := s.repoEntities.CreateRepository(ctx, repository); err != nil { @@ -675,6 +682,22 @@ func (s *Service) CreateRepository(ctx context.Context, req *CreateRepositoryReq return repository, nil } +// resolveRepositoryProviderIdentity fills missing provider metadata from a local repository origin. +func resolveRepositoryProviderIdentity(repository *models.Repository) { + if repository.LocalPath == "" || (repository.Provider != "" && repository.ProviderHost != "") { + return + } + p, h, o, n := ResolveGitRemoteProviderIdentity(repository.LocalPath) + if repository.Provider == "" { + repository.Provider = p + } + if repository.Provider != "" && (strings.HasPrefix(h, "http://") || strings.HasPrefix(h, "https://")) { + repository.ProviderHost = h + repository.ProviderOwner = o + repository.ProviderName = n + } +} + func (s *Service) GetRepository(ctx context.Context, id string) (*models.Repository, error) { return s.repoEntities.GetRepository(ctx, id) } diff --git a/apps/web/components/branch-refresh-button.test.tsx b/apps/web/components/branch-refresh-button.test.tsx new file mode 100644 index 0000000000..12a1177463 --- /dev/null +++ b/apps/web/components/branch-refresh-button.test.tsx @@ -0,0 +1,40 @@ +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { TooltipProvider } from "@kandev/ui/tooltip"; +import { BranchRefreshButton } from "./branch-refresh-button"; + +function renderRefreshButton() { + const onRefresh = vi.fn(); + render( + + + , + ); + return onRefresh; +} + +afterEach(cleanup); + +describe("BranchRefreshButton", () => { + it("renders a repository refresh target with the supplied test id and touch target", () => { + renderRefreshButton(); + + const button = screen.getByTestId("repo-refresh-button"); + expect(button.getAttribute("aria-label")).toBe("Refresh repositories"); + expect(button.className).toContain("h-12"); + expect(button.className).toContain("w-12"); + }); + + it("refreshes on click", () => { + const onRefresh = renderRefreshButton(); + const button = screen.getByTestId("repo-refresh-button"); + + fireEvent.click(button); + expect(onRefresh).toHaveBeenCalledOnce(); + }); +}); diff --git a/apps/web/components/branch-refresh-button.tsx b/apps/web/components/branch-refresh-button.tsx index 22e8dee547..c7250725ff 100644 --- a/apps/web/components/branch-refresh-button.tsx +++ b/apps/web/components/branch-refresh-button.tsx @@ -9,6 +9,9 @@ type BranchRefreshButtonProps = { refreshing?: boolean; fetchedAt?: string; fetchError?: string; + label?: string; + testId?: string; + touchTarget?: boolean; }; export function BranchRefreshButton({ @@ -16,20 +19,23 @@ export function BranchRefreshButton({ refreshing, fetchedAt, fetchError, + label = "branches", + testId = "branch-refresh-button", + touchTarget = false, }: BranchRefreshButtonProps) { // Controlled open so the tooltip only reacts to hover, not focus. // Radix Popover auto-focuses the first focusable child when it opens, which // would otherwise trigger this tooltip the moment the dropdown is opened. const [open, setOpen] = useState(false); const hasError = Boolean(fetchError); - const tooltip = formatRefreshTooltip(fetchedAt, refreshing, fetchError); + const tooltip = formatRefreshTooltip(label, fetchedAt, refreshing, fetchError); return ( + + +
+ + Destination + + {targetPath || parentPath || "Loading folder…"} + +
+ + ); +} + +type RepositoryFormDetailsProps = RepositoryLocationFieldsProps & { + executorSelection: DirectLocalExecutorSelection | null; + submitError: string | null; +}; + +function RepositoryFormDetails({ + executorSelection, + submitError, + ...locationFields +}: RepositoryFormDetailsProps) { + return ( +
+
+ +
+
+ +

{executorNotice(executorSelection)}

+
+ {submitError ? ( +
+ +

{submitError}

+
+ ) : null} +
+ ); +} + +function CreateRepositoryFooter({ + canSubmit, + submitting, +}: { + canSubmit: boolean; + submitting: boolean; +}) { + return ( +
+ +
+ ); +} + +function CreateRepositoryForm({ + open, + workspaceId, + executorSelection, + onCreated, + onDismiss, +}: CreateLocalRepositorySurfaceProps & { onDismiss: () => void }) { + const [name, setName] = useState(""); + const [parentPath, setParentPath] = useState(""); + const [editingParentPath, setEditingParentPath] = useState(false); + const [submitting, setSubmitting] = useState(false); + const [submitError, setSubmitError] = useState(null); + const { listing, loading, error: listingError, load } = useDirectoryListing(open, ""); + useEffect(() => { + if (!open) { + setParentPath(""); + setEditingParentPath(false); + setSubmitError(null); + return; + } + if (listing && !editingParentPath) setParentPath(listing.path); + }, [editingParentPath, listing, open]); + + const nameError = validateLocalRepositoryName(name); + const targetPath = + parentPath && !nameError ? buildLocalRepositoryTargetPath(parentPath, name) : ""; + const canSubmit = Boolean( + workspaceId && executorSelection && parentPath && !nameError && !submitting, + ); + + const handleSubmit = async (event: FormEvent) => { + event.preventDefault(); + event.stopPropagation(); + if (!canSubmit || !workspaceId || !executorSelection) return; + setSubmitting(true); + setSubmitError(null); + try { + const repository = await initializeLocalRepository(workspaceId, { + name: name.trim(), + parentPath, + }); + onCreated(repository); + setName(""); + onDismiss(); + } catch (err) { + setSubmitError(err instanceof Error ? err.message : "Failed to create repository"); + } finally { + setSubmitting(false); + } + }; + + const handleDirectoryNavigation = (path: string) => { + setSubmitError(null); + setParentPath(path); + setEditingParentPath(false); + void load(path); + }; + + const loadTypedDirectory = () => { + if (parentPath) void load(parentPath); + }; + + const handleParentPathChange = (path: string) => { + setSubmitError(null); + setParentPath(path); + setEditingParentPath(true); + }; + + const handleNameChange = (nextName: string) => { + setSubmitError(null); + setName(nextName); + }; + + const handleCreateDirectory = async (folderName: string) => { + if (!listing) return; + setSubmitError(null); + const created = await createDirectory(listing.path, folderName); + setParentPath(created.path); + setEditingParentPath(false); + await load(created.path); + }; + + return ( +
void handleSubmit(event)} className="flex min-h-0 flex-1 flex-col"> + + + + + ); +} + +export function CreateLocalRepositorySurface(props: CreateLocalRepositorySurfaceProps) { + const { isMobile } = useResponsiveBreakpoint(); + const handleOpenChange = (open: boolean) => props.onOpenChange(open); + const form = handleOpenChange(false)} />; + + if (isMobile) { + return ( + + + + Create new repository + + Choose a folder or enter a new path on this machine. + + + {form} + + + ); + } + + return ( + + + + Create new repository + + Choose a folder or enter a new path on this machine. + + + {form} + + + ); +} diff --git a/apps/web/components/folder-picker.tsx b/apps/web/components/folder-picker.tsx index 2e563f9a1b..78a50d2470 100644 --- a/apps/web/components/folder-picker.tsx +++ b/apps/web/components/folder-picker.tsx @@ -1,7 +1,16 @@ "use client"; import { Fragment, useCallback, useEffect, useRef, useState } from "react"; -import { IconFolder, IconBox, IconChevronRight } from "@tabler/icons-react"; +import { + IconBox, + IconCheck, + IconChevronRight, + IconFolder, + IconFolderPlus, + IconX, +} from "@tabler/icons-react"; +import { Button } from "@kandev/ui/button"; +import { Input } from "@kandev/ui/input"; import { Popover, PopoverContent, PopoverTrigger } from "@kandev/ui/popover"; import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; import { cn } from "@/lib/utils"; @@ -64,12 +73,11 @@ export function FolderPicker({ value, onChange, placeholder }: FolderPickerProps sideOffset={4} data-testid="folder-picker-popover" > - void load(p)} /> - void load(p)} + onNavigate={(p) => void load(p)} />