375 lines
12 KiB
Go
375 lines
12 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"io/fs"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
|
|
"github.com/kandev/kandev/internal/task/gitinit"
|
|
"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 {
|
|
command, err := gitinit.CommandContext(ctx, targetPath, targetDirectory)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
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 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
|
|
}
|