746 lines
29 KiB
Go
746 lines
29 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"unicode"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/kandev/kandev/internal/common/securityutil"
|
|
"github.com/kandev/kandev/internal/common/subproc"
|
|
"github.com/kandev/kandev/internal/task/models"
|
|
"go.uber.org/zap"
|
|
)
|
|
|
|
// MaterializeRepositoryRequest describes one repository checkout below the
|
|
// agentctl workspace. RepositoryURL must be a credential-free Git locator;
|
|
// destination is always a direct child of the current workspace root.
|
|
type MaterializeRepositoryRequest struct {
|
|
RepositoryURL string `json:"repository_url"`
|
|
Destination string `json:"destination"`
|
|
BaseBranch string `json:"base_branch"`
|
|
CheckoutBranch string `json:"checkout_branch,omitempty"`
|
|
RemoteContribution *models.RemoteContribution `json:"remote_contribution,omitempty"`
|
|
ContributionDestination *models.ContributionDestination `json:"contribution_destination,omitempty"`
|
|
}
|
|
|
|
// MaterializeRepositoryResponse deliberately contains no remote locator so a
|
|
// credential accidentally supplied by an untrusted caller cannot be echoed.
|
|
type MaterializeRepositoryResponse struct {
|
|
Destination string `json:"destination"`
|
|
Reused bool `json:"reused,omitempty"`
|
|
Error string `json:"error,omitempty"`
|
|
}
|
|
|
|
// RemoveMaterializedRepositoryRequest identifies a previously materialized,
|
|
// credential-free checkout that may be removed during a failed batch rollback.
|
|
type RemoveMaterializedRepositoryRequest struct {
|
|
RepositoryURL string `json:"repository_url"`
|
|
Destination string `json:"destination"`
|
|
}
|
|
|
|
type removeMaterializedRepositoryResponse struct {
|
|
Removed bool `json:"removed"`
|
|
Error string `json:"error,omitempty"`
|
|
}
|
|
|
|
func (s *Server) handleWorkspaceMaterializeRepository(c *gin.Context) {
|
|
var req MaterializeRepositoryRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, MaterializeRepositoryResponse{Error: "invalid request"})
|
|
return
|
|
}
|
|
destination, err := materializeDestination(s.procMgr.WorkDir(), req.Destination)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, MaterializeRepositoryResponse{Error: "invalid destination"})
|
|
return
|
|
}
|
|
if err := validateRepositoryLocator(req.RepositoryURL); err != nil {
|
|
c.JSON(http.StatusBadRequest, MaterializeRepositoryResponse{Error: "invalid repository locator"})
|
|
return
|
|
}
|
|
if !securityutil.IsValidBranchName(req.BaseBranch) || (req.CheckoutBranch != "" && !securityutil.IsValidBranchName(req.CheckoutBranch)) {
|
|
c.JSON(http.StatusBadRequest, MaterializeRepositoryResponse{Error: "invalid repository branch"})
|
|
return
|
|
}
|
|
if req.RemoteContribution != nil {
|
|
if err := req.RemoteContribution.Validate(); err != nil {
|
|
c.JSON(http.StatusBadRequest, MaterializeRepositoryResponse{Error: "invalid remote contribution"})
|
|
return
|
|
}
|
|
if req.BaseBranch != req.RemoteContribution.BaseBranch || req.CheckoutBranch != req.RemoteContribution.HeadBranch {
|
|
c.JSON(http.StatusBadRequest, MaterializeRepositoryResponse{Error: "remote contribution branch mismatch"})
|
|
return
|
|
}
|
|
}
|
|
if req.ContributionDestination != nil {
|
|
if err := req.ContributionDestination.Validate(); err != nil {
|
|
c.JSON(http.StatusBadRequest, MaterializeRepositoryResponse{Error: "invalid contribution destination"})
|
|
return
|
|
}
|
|
}
|
|
|
|
reused, err := materializeRepositoryWithDestination(c.Request.Context(), req.RepositoryURL, destination, req.BaseBranch, req.CheckoutBranch, req.RemoteContribution, req.ContributionDestination)
|
|
if err != nil {
|
|
if errors.Is(err, errMaterializeCollision) {
|
|
c.JSON(http.StatusConflict, MaterializeRepositoryResponse{Error: "destination already exists"})
|
|
return
|
|
}
|
|
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
|
c.JSON(http.StatusRequestTimeout, MaterializeRepositoryResponse{Error: "repository materialization cancelled"})
|
|
return
|
|
}
|
|
s.logger.Warn("workspace repository materialization failed", zap.String("destination", req.Destination), zap.Error(err))
|
|
c.JSON(http.StatusUnprocessableEntity, MaterializeRepositoryResponse{Error: "repository materialization failed"})
|
|
return
|
|
}
|
|
status := http.StatusCreated
|
|
if reused {
|
|
status = http.StatusOK
|
|
}
|
|
c.JSON(status, MaterializeRepositoryResponse{Destination: req.Destination, Reused: reused})
|
|
}
|
|
|
|
func (s *Server) handleWorkspaceRemoveMaterializedRepository(c *gin.Context) {
|
|
var req RemoveMaterializedRepositoryRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, removeMaterializedRepositoryResponse{Error: "invalid request"})
|
|
return
|
|
}
|
|
destination, err := materializeDestination(s.procMgr.WorkDir(), req.Destination)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, removeMaterializedRepositoryResponse{Error: "invalid destination"})
|
|
return
|
|
}
|
|
if err := validateRemovalRepositoryLocator(req.RepositoryURL); err != nil {
|
|
c.JSON(http.StatusBadRequest, removeMaterializedRepositoryResponse{Error: "invalid repository locator"})
|
|
return
|
|
}
|
|
removed, err := removeMaterializedRepository(c.Request.Context(), s.procMgr.WorkDir(), destination, req.RepositoryURL)
|
|
if err != nil {
|
|
if errors.Is(err, errMaterializeCollision) {
|
|
c.JSON(http.StatusConflict, removeMaterializedRepositoryResponse{Error: "destination is not the requested checkout"})
|
|
return
|
|
}
|
|
s.logger.Warn("workspace repository cleanup failed", zap.String("destination", req.Destination), zap.Error(err))
|
|
c.JSON(http.StatusUnprocessableEntity, removeMaterializedRepositoryResponse{Error: "repository cleanup failed"})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, removeMaterializedRepositoryResponse{Removed: removed})
|
|
}
|
|
|
|
var errMaterializeCollision = errors.New("materialize destination collision")
|
|
|
|
var beforeMaterializeQuarantineRename = func() {}
|
|
|
|
var afterMaterializeQuarantineOpen = func(string) {}
|
|
|
|
func materializeDestination(workDir, destination string) (string, error) {
|
|
if destination == "" || filepath.IsAbs(destination) || filepath.Base(destination) != destination || destination == "." || destination == ".." {
|
|
return "", errors.New("unsafe destination")
|
|
}
|
|
root, err := filepath.Abs(workDir)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
root, err = filepath.EvalSymlinks(root)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
destination = filepath.Join(root, destination)
|
|
if filepath.Dir(destination) != root {
|
|
return "", errors.New("destination escapes workspace")
|
|
}
|
|
return destination, nil
|
|
}
|
|
|
|
func validateRepositoryLocator(locator string) error {
|
|
if malformedRepositoryLocator(locator) {
|
|
return errors.New("empty or malformed locator")
|
|
}
|
|
if filepath.IsAbs(locator) {
|
|
return errors.New("local locator is not allowed")
|
|
}
|
|
parsed, err := url.Parse(locator)
|
|
if err != nil || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" {
|
|
return errors.New("credentialed or malformed locator")
|
|
}
|
|
if isNetworkRepositoryScheme(parsed.Scheme) {
|
|
return validateNetworkRepositoryLocator(parsed)
|
|
}
|
|
// Git's SCP-like syntax permits only the conventional git user; accepting
|
|
// arbitrary users would make it impossible to distinguish credentials.
|
|
if strings.HasPrefix(locator, "git@") && strings.Count(locator, ":") == 1 && !strings.ContainsAny(locator, " \\t") {
|
|
return nil
|
|
}
|
|
return errors.New("unsupported locator")
|
|
}
|
|
|
|
func malformedRepositoryLocator(locator string) bool {
|
|
return locator == "" || strings.TrimSpace(locator) != locator || strings.IndexFunc(locator, unicode.IsControl) >= 0
|
|
}
|
|
|
|
func isNetworkRepositoryScheme(scheme string) bool {
|
|
switch scheme {
|
|
case "https", "http", "ssh", "git":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func validateNetworkRepositoryLocator(locator *url.URL) error {
|
|
if locator.Host == "" {
|
|
return errors.New("locator host required")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// validateRemovalRepositoryLocator keeps rollback capable of recognizing
|
|
// legacy local-test checkouts. Removal never opens the locator: it only
|
|
// compares it with an existing checkout's origin before deleting that owned
|
|
// destination, unlike the materialize endpoint which must not fetch local or
|
|
// file URLs from an HTTP caller.
|
|
func validateRemovalRepositoryLocator(locator string) error {
|
|
if filepath.IsAbs(locator) || strings.HasPrefix(locator, "file:") {
|
|
return nil
|
|
}
|
|
return validateRepositoryLocator(locator)
|
|
}
|
|
|
|
func materializeRepository(ctx context.Context, locator, destination, baseBranch, checkoutBranch string, bindings ...*models.RemoteContribution) (bool, error) {
|
|
var binding *models.RemoteContribution
|
|
if len(bindings) > 0 {
|
|
binding = bindings[0]
|
|
}
|
|
return materializeRepositoryInternal(ctx, locator, destination, baseBranch, checkoutBranch, binding, nil)
|
|
}
|
|
|
|
func materializeRepositoryWithDestination(ctx context.Context, locator, destination, baseBranch, checkoutBranch string, binding *models.RemoteContribution, contributionDestination *models.ContributionDestination) (bool, error) {
|
|
return materializeRepositoryInternal(ctx, locator, destination, baseBranch, checkoutBranch, binding, contributionDestination)
|
|
}
|
|
|
|
func materializeRepositoryInternal(ctx context.Context, locator, destination, baseBranch, checkoutBranch string, binding *models.RemoteContribution, contributionDestination *models.ContributionDestination) (bool, error) {
|
|
if reused, err := matchingCheckoutWithDestination(ctx, destination, locator, baseBranch, checkoutBranch, binding, contributionDestination); err != nil || reused {
|
|
return reused, err
|
|
}
|
|
// codeql[go/path-injection] destination is a direct child of the canonical workspace root; Lstat rejects links before use.
|
|
if _, err := os.Lstat(destination); err == nil {
|
|
return false, errMaterializeCollision
|
|
} else if !os.IsNotExist(err) {
|
|
return false, fmt.Errorf("inspect destination: %w", err)
|
|
}
|
|
parent := filepath.Dir(destination)
|
|
// codeql[go/path-injection] parent is the canonical workspace root containing the direct-child destination.
|
|
tmp, err := os.MkdirTemp(parent, ".kandev-clone-")
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
defer func() { _ = os.RemoveAll(tmp) }()
|
|
checkout := filepath.Join(tmp, "checkout")
|
|
if _, err := materializeGitOutput(ctx, "clone", "--no-checkout", "--", locator, checkout); err != nil {
|
|
return false, err
|
|
}
|
|
if binding != nil {
|
|
if err := materializeRemoteContribution(ctx, checkout, binding); err != nil {
|
|
return false, err
|
|
}
|
|
} else if err := checkoutMaterializedBranch(ctx, checkout, baseBranch, checkoutBranch); err != nil {
|
|
return false, err
|
|
}
|
|
if contributionDestination != nil {
|
|
if err := configureContributionDestination(ctx, checkout, contributionDestination); err != nil {
|
|
return false, err
|
|
}
|
|
}
|
|
// codeql[go/path-injection] checkout is newly created beneath the trusted workspace root; destination is its direct child.
|
|
if err := os.Rename(checkout, destination); err != nil {
|
|
if os.IsExist(err) {
|
|
return false, errMaterializeCollision
|
|
}
|
|
return false, err
|
|
}
|
|
return false, nil
|
|
}
|
|
|
|
func matchingCheckoutWithDestination(ctx context.Context, destination, locator, baseBranch, checkoutBranch string, binding *models.RemoteContribution, contributionDestination *models.ContributionDestination) (bool, error) {
|
|
reused, err := matchingCheckout(ctx, destination, locator, baseBranch, checkoutBranch, binding)
|
|
if err != nil || !reused || contributionDestination == nil {
|
|
return reused, err
|
|
}
|
|
return true, configureContributionDestination(ctx, destination, contributionDestination)
|
|
}
|
|
|
|
func configureContributionDestination(ctx context.Context, checkout string, destination *models.ContributionDestination) error {
|
|
if destination == nil {
|
|
return nil
|
|
}
|
|
if err := destination.Validate(); err != nil {
|
|
return err
|
|
}
|
|
remoteName := destination.ContributionRemoteName()
|
|
configured, err := materializeGitOutput(ctx, "-C", checkout, "config", "--get", "remote."+remoteName+".url")
|
|
if err == nil {
|
|
if strings.TrimSpace(configured) != destination.TargetRepository.RemoteURL {
|
|
return errors.New("contribution destination identity conflict")
|
|
}
|
|
} else if _, err := materializeGitOutput(ctx, "-C", checkout, "remote", "add", remoteName, destination.TargetRepository.RemoteURL); err != nil {
|
|
return errors.New("contribution destination could not be configured")
|
|
}
|
|
pushURLs, pushErr := materializeGitOutput(ctx, "-C", checkout, "config", "--get-all", "remote."+remoteName+".pushurl")
|
|
if pushErr == nil && !contributionDestinationPushURLsMatch(pushURLs, destination.TargetRepository.RemoteURL) {
|
|
return errors.New("contribution destination push identity conflict")
|
|
}
|
|
if pushErr != nil {
|
|
if _, err := materializeGitOutput(ctx, "-C", checkout, "config", "--add", "remote."+remoteName+".pushurl", destination.TargetRepository.RemoteURL); err != nil {
|
|
return errors.New("contribution destination push URL could not be configured")
|
|
}
|
|
}
|
|
branch, err := materializeGitOutput(ctx, "-C", checkout, "branch", "--show-current")
|
|
if err != nil || strings.TrimSpace(branch) == "" {
|
|
return errors.New("contribution destination branch could not be identified")
|
|
}
|
|
if _, err := materializeGitOutput(ctx, "-C", checkout, "config", "branch."+strings.TrimSpace(branch)+".pushRemote", remoteName); err != nil {
|
|
return errors.New("contribution destination push remote could not be configured")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func contributionDestinationPushURLsMatch(configured, target string) bool {
|
|
urls := strings.Split(strings.TrimSpace(configured), "\n")
|
|
if len(urls) == 0 || (len(urls) == 1 && urls[0] == "") {
|
|
return false
|
|
}
|
|
for _, configuredURL := range urls {
|
|
if strings.TrimSpace(configuredURL) != target {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func checkoutMaterializedBranch(ctx context.Context, checkout, baseBranch, checkoutBranch string) error {
|
|
branch := checkoutBranch
|
|
if branch == "" {
|
|
branch = baseBranch
|
|
}
|
|
if hasGitRef(ctx, checkout, "refs/heads/"+branch) {
|
|
_, err := materializeGitOutput(ctx, "-C", checkout, "checkout", branch)
|
|
return err
|
|
}
|
|
if hasGitRef(ctx, checkout, "refs/remotes/origin/"+branch) {
|
|
_, err := materializeGitOutput(ctx, "-C", checkout, "checkout", "--track", "-b", branch, "origin/"+branch)
|
|
return err
|
|
}
|
|
if checkoutBranch == "" {
|
|
return errors.New("base branch is unavailable from origin")
|
|
}
|
|
_, err := materializeGitOutput(ctx, "-C", checkout, "checkout", "-b", checkoutBranch, "origin/"+baseBranch)
|
|
return err
|
|
}
|
|
|
|
func hasGitRef(ctx context.Context, directory, ref string) bool {
|
|
_, err := materializeGitOutput(ctx, "-C", directory, "show-ref", "--verify", "--quiet", ref)
|
|
return err == nil
|
|
}
|
|
|
|
func materializeRemoteContribution(ctx context.Context, checkout string, binding *models.RemoteContribution) error {
|
|
if binding == nil {
|
|
return errors.New("remote contribution binding is required")
|
|
}
|
|
if err := binding.Validate(); err != nil {
|
|
return err
|
|
}
|
|
remoteName := binding.ContributionRemoteName()
|
|
configured, err := materializeGitOutput(ctx, "-C", checkout, "config", "--get", "remote."+remoteName+".url")
|
|
if err == nil {
|
|
if strings.TrimSpace(configured) != binding.SourceRepository.RemoteURL {
|
|
return errors.New("contribution remote identity conflict")
|
|
}
|
|
} else if _, err := materializeGitOutput(ctx, "-C", checkout, "remote", "add", remoteName, binding.SourceRepository.RemoteURL); err != nil {
|
|
return errors.New("contribution remote could not be configured")
|
|
}
|
|
|
|
remoteRef := "refs/remotes/" + remoteName + "/" + binding.HeadBranch
|
|
refspec := "+refs/heads/" + binding.HeadBranch + ":" + remoteRef
|
|
if _, err := materializeGitOutput(ctx, "-C", checkout, "fetch", "--no-tags", remoteName, refspec); err != nil {
|
|
return errors.New("contribution source branch is unavailable")
|
|
}
|
|
actual, err := materializeGitOutput(ctx, "-C", checkout, "rev-parse", "--verify", remoteRef+"^{commit}")
|
|
if err != nil || !strings.EqualFold(strings.TrimSpace(actual), binding.HeadSHA) {
|
|
return errors.New("contribution source head changed")
|
|
}
|
|
|
|
branch := binding.HeadBranch
|
|
if hasGitRef(ctx, checkout, "refs/heads/"+branch) {
|
|
suffix := strings.TrimPrefix(remoteName, "contrib-")
|
|
branch = binding.HeadBranch + "-kandev-" + suffix
|
|
for index := 1; hasGitRef(ctx, checkout, "refs/heads/"+branch); index++ {
|
|
branch = fmt.Sprintf("%s-kandev-%s-%d", binding.HeadBranch, suffix, index)
|
|
}
|
|
}
|
|
if _, err := materializeGitOutput(ctx, "-C", checkout, "checkout", "-b", branch, remoteRef); err != nil {
|
|
return errors.New("contribution branch could not be checked out")
|
|
}
|
|
if _, err := materializeGitOutput(ctx, "-C", checkout, "branch", "--set-upstream-to="+remoteName+"/"+binding.HeadBranch, branch); err != nil {
|
|
return errors.New("contribution branch upstream could not be configured")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func matchingCheckout(ctx context.Context, destination, locator, baseBranch, checkoutBranch string, bindings ...*models.RemoteContribution) (bool, error) {
|
|
var binding *models.RemoteContribution
|
|
if len(bindings) > 0 {
|
|
binding = bindings[0]
|
|
}
|
|
exists, err := materializedCheckoutExists(destination)
|
|
if err != nil || !exists {
|
|
return false, err
|
|
}
|
|
branch := checkoutBranch
|
|
if branch == "" {
|
|
branch = baseBranch
|
|
}
|
|
if binding != nil {
|
|
if err := matchingCheckoutOrigin(ctx, destination, locator); err != nil {
|
|
return false, err
|
|
}
|
|
return matchingRemoteContributionCheckout(ctx, destination, binding)
|
|
}
|
|
if err := matchingCheckoutIdentity(ctx, destination, locator, branch); err != nil {
|
|
return false, err
|
|
}
|
|
return matchingCheckoutCommit(ctx, destination, baseBranch, branch)
|
|
}
|
|
|
|
func materializedCheckoutExists(destination string) (bool, error) {
|
|
// codeql[go/path-injection] destination is a direct child of the canonical workspace root and must be a real directory before Git probes.
|
|
destinationInfo, err := os.Lstat(destination)
|
|
if os.IsNotExist(err) {
|
|
return false, nil
|
|
}
|
|
if err != nil || destinationInfo.Mode()&os.ModeSymlink != 0 || !destinationInfo.IsDir() {
|
|
return false, errMaterializeCollision
|
|
}
|
|
gitPath := filepath.Join(destination, ".git")
|
|
// codeql[go/path-injection] .git is the exact child of a real, non-symlink destination and is Lstat-checked before Git probes.
|
|
gitInfo, err := os.Lstat(gitPath)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return false, nil
|
|
}
|
|
return false, errMaterializeCollision
|
|
}
|
|
if gitInfo.Mode()&os.ModeSymlink != 0 || !gitInfo.IsDir() {
|
|
return false, errMaterializeCollision
|
|
}
|
|
return true, nil
|
|
}
|
|
|
|
func matchingCheckoutIdentity(ctx context.Context, destination, locator, branch string) error {
|
|
// Read the URL stored in the checkout instead of the expanded URL returned by
|
|
// `git remote get-url`. Executor-specific insteadOf rules can rewrite the
|
|
// latter to a local transport, even though the checkout still belongs to the
|
|
// requested repository.
|
|
origin, err := materializeGitOutput(ctx, "-C", destination, "config", "--local", "--get", "remote.origin.url")
|
|
if err != nil || strings.TrimSpace(origin) != locator {
|
|
return errMaterializeCollision
|
|
}
|
|
currentBranch, err := materializeGitOutput(ctx, "-C", destination, "branch", "--show-current")
|
|
if err != nil || strings.TrimSpace(currentBranch) != branch {
|
|
return errMaterializeCollision
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func matchingCheckoutOrigin(ctx context.Context, destination, locator string) error {
|
|
origin, err := materializeGitOutput(ctx, "-C", destination, "remote", "get-url", "origin")
|
|
if err != nil || strings.TrimSpace(origin) != locator {
|
|
return errMaterializeCollision
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func matchingRemoteContributionCheckout(ctx context.Context, destination string, binding *models.RemoteContribution) (bool, error) {
|
|
if err := materializeRemoteContributionRef(ctx, destination, binding); err != nil {
|
|
return false, err
|
|
}
|
|
remoteName := binding.ContributionRemoteName()
|
|
currentBranch, err := materializeGitOutput(ctx, "-C", destination, "branch", "--show-current")
|
|
if err != nil || strings.TrimSpace(currentBranch) == "" {
|
|
return false, errMaterializeCollision
|
|
}
|
|
upstream, err := materializeGitOutput(ctx, "-C", destination, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}")
|
|
if err != nil || strings.TrimSpace(upstream) != remoteName+"/"+binding.HeadBranch {
|
|
return false, errMaterializeCollision
|
|
}
|
|
head, err := materializeGitOutput(ctx, "-C", destination, "rev-parse", "--verify", "HEAD^{commit}")
|
|
if err != nil {
|
|
return false, errMaterializeCollision
|
|
}
|
|
if strings.EqualFold(strings.TrimSpace(head), binding.HeadSHA) {
|
|
return true, nil
|
|
}
|
|
if _, err := materializeGitOutput(ctx, "-C", destination, "merge-base", "--is-ancestor", binding.HeadSHA, "HEAD"); err != nil {
|
|
return false, errMaterializeCollision
|
|
}
|
|
return true, nil
|
|
}
|
|
|
|
func materializeRemoteContributionRef(ctx context.Context, checkout string, binding *models.RemoteContribution) error {
|
|
if binding == nil {
|
|
return errors.New("remote contribution binding is required")
|
|
}
|
|
if err := binding.Validate(); err != nil {
|
|
return err
|
|
}
|
|
remoteName := binding.ContributionRemoteName()
|
|
configured, err := materializeGitOutput(ctx, "-C", checkout, "config", "--get", "remote."+remoteName+".url")
|
|
if err != nil || strings.TrimSpace(configured) != binding.SourceRepository.RemoteURL {
|
|
return errMaterializeCollision
|
|
}
|
|
remoteRef := "refs/remotes/" + remoteName + "/" + binding.HeadBranch
|
|
refspec := "+refs/heads/" + binding.HeadBranch + ":" + remoteRef
|
|
if _, err := materializeGitOutput(ctx, "-C", checkout, "fetch", "--no-tags", remoteName, refspec); err != nil {
|
|
return err
|
|
}
|
|
actual, err := materializeGitOutput(ctx, "-C", checkout, "rev-parse", "--verify", remoteRef+"^{commit}")
|
|
if err != nil || !strings.EqualFold(strings.TrimSpace(actual), binding.HeadSHA) {
|
|
return errors.New("contribution source head changed")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func matchingCheckoutCommit(ctx context.Context, destination, baseBranch, branch string) (bool, error) {
|
|
requestedRef := "origin/" + branch
|
|
if !hasGitRef(ctx, destination, "refs/remotes/"+requestedRef) {
|
|
requestedRef = "origin/" + baseBranch
|
|
}
|
|
requestedCommit, err := materializeGitOutput(ctx, "-C", destination, "rev-parse", "--verify", requestedRef+"^{commit}")
|
|
if err != nil {
|
|
return false, errMaterializeCollision
|
|
}
|
|
headCommit, err := materializeGitOutput(ctx, "-C", destination, "rev-parse", "--verify", "HEAD^{commit}")
|
|
if err != nil || strings.TrimSpace(headCommit) != strings.TrimSpace(requestedCommit) {
|
|
return false, errMaterializeCollision
|
|
}
|
|
return true, nil
|
|
}
|
|
|
|
func removeMaterializedRepository(ctx context.Context, workDir, destination, locator string) (bool, error) {
|
|
root, destinationName, err := openMaterializeRemovalRoot(workDir, destination)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
defer func() { _ = root.Close() }()
|
|
quarantine, err := moveToMaterializeQuarantine(root, destinationName)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
if quarantine == "" {
|
|
return false, nil
|
|
}
|
|
quarantineRoot, capturedInfo, err := openMaterializedQuarantine(root, quarantine, destinationName)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
defer func() { _ = quarantineRoot.Close() }()
|
|
afterMaterializeQuarantineOpen(quarantine)
|
|
if err := validateMaterializedQuarantine(quarantineRoot, locator); err != nil {
|
|
return false, restoreMaterializedQuarantine(root, capturedInfo, quarantine, destinationName)
|
|
}
|
|
if err := removeCapturedMaterializedQuarantine(root, quarantineRoot, capturedInfo, quarantine); err != nil {
|
|
return false, err
|
|
}
|
|
return true, nil
|
|
}
|
|
|
|
func openMaterializeRemovalRoot(workDir, destination string) (*os.Root, string, error) {
|
|
canonicalWorkDir, err := filepath.EvalSymlinks(workDir)
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
if filepath.Dir(destination) != canonicalWorkDir {
|
|
return nil, "", errMaterializeCollision
|
|
}
|
|
root, err := os.OpenRoot(canonicalWorkDir)
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
return root, filepath.Base(destination), nil
|
|
}
|
|
|
|
func moveToMaterializeQuarantine(root *os.Root, destination string) (string, error) {
|
|
quarantine, err := materializeQuarantine(root)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
beforeMaterializeQuarantineRename()
|
|
// codeql[go/path-injection] Root binds both direct-child names to the canonical workspace; quarantine captures the exact object before validation.
|
|
if err := root.Rename(destination, quarantine); err != nil {
|
|
if os.IsNotExist(err) {
|
|
return "", nil
|
|
}
|
|
return "", err
|
|
}
|
|
return quarantine, nil
|
|
}
|
|
|
|
func openMaterializedQuarantine(root *os.Root, quarantine, destination string) (*os.Root, os.FileInfo, error) {
|
|
// codeql[go/path-injection] Root Lstat rejects a link or non-directory before opening the quarantined name.
|
|
quarantineInfo, err := root.Lstat(quarantine)
|
|
if err != nil || quarantineInfo.Mode()&os.ModeSymlink != 0 || !quarantineInfo.IsDir() {
|
|
return nil, nil, restoreMaterializedQuarantine(root, quarantineInfo, quarantine, destination)
|
|
}
|
|
// codeql[go/path-injection] Root opens the exact object atomically moved into its private canonical-workdir quarantine.
|
|
quarantineRoot, err := root.OpenRoot(quarantine)
|
|
if err != nil {
|
|
return nil, nil, restoreMaterializedQuarantine(root, nil, quarantine, destination)
|
|
}
|
|
capturedInfo, err := quarantineRoot.Lstat(".")
|
|
if err != nil || capturedInfo.Mode()&os.ModeSymlink != 0 || !capturedInfo.IsDir() {
|
|
_ = quarantineRoot.Close()
|
|
return nil, nil, restoreMaterializedQuarantine(root, capturedInfo, quarantine, destination)
|
|
}
|
|
return quarantineRoot, capturedInfo, nil
|
|
}
|
|
|
|
func validateMaterializedQuarantine(quarantineRoot *os.Root, locator string) error {
|
|
// codeql[go/path-injection] The opened quarantine root keeps .git bound to the captured directory, independent of later pathname replacement.
|
|
gitDir, err := quarantineRoot.Lstat(".git")
|
|
if err != nil || gitDir.Mode()&os.ModeSymlink != 0 || !gitDir.IsDir() {
|
|
return errMaterializeCollision
|
|
}
|
|
origin, err := materializeQuarantineOrigin(quarantineRoot)
|
|
if err != nil || strings.TrimSpace(origin) != locator {
|
|
return errMaterializeCollision
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func removeCapturedMaterializedQuarantine(root, quarantineRoot *os.Root, capturedInfo os.FileInfo, quarantine string) error {
|
|
// codeql[go/path-injection] The captured quarantine root recursively removes only its own entries after origin verification.
|
|
if err := clearMaterializedQuarantine(quarantineRoot); err != nil {
|
|
return err
|
|
}
|
|
if matches, err := quarantineMatchesCaptured(root, quarantine, capturedInfo); err != nil || !matches {
|
|
return errMaterializeCollision
|
|
}
|
|
// codeql[go/path-injection] Root non-recursively removes the quarantined parent entry only after it still matches the captured object.
|
|
if err := root.Remove(quarantine); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func materializeQuarantine(root *os.Root) (string, error) {
|
|
for range 10 {
|
|
entropy := make([]byte, 16)
|
|
if _, err := rand.Read(entropy); err != nil {
|
|
return "", err
|
|
}
|
|
name := fmt.Sprintf(".kandev-remove-%x", entropy)
|
|
if err := root.Mkdir(name, 0o700); err == nil {
|
|
if err := root.Remove(name); err != nil {
|
|
return "", err
|
|
}
|
|
return name, nil
|
|
} else if !errors.Is(err, os.ErrExist) {
|
|
return "", err
|
|
}
|
|
}
|
|
return "", errors.New("allocate removal quarantine")
|
|
}
|
|
|
|
func restoreMaterializedQuarantine(root *os.Root, captured os.FileInfo, quarantine, destination string) error {
|
|
if captured == nil {
|
|
return errMaterializeCollision
|
|
}
|
|
if matches, err := quarantineMatchesCaptured(root, quarantine, captured); err != nil || !matches {
|
|
return errMaterializeCollision
|
|
}
|
|
if _, err := root.Lstat(destination); err == nil {
|
|
return errMaterializeCollision
|
|
} else if !os.IsNotExist(err) {
|
|
return err
|
|
}
|
|
// codeql[go/path-injection] Root restores the exact quarantined object only when its direct-child destination remains absent.
|
|
if err := root.Rename(quarantine, destination); err != nil {
|
|
return err
|
|
}
|
|
return errMaterializeCollision
|
|
}
|
|
|
|
func quarantineMatchesCaptured(root *os.Root, quarantine string, captured os.FileInfo) (bool, error) {
|
|
info, err := root.Lstat(quarantine)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
return os.SameFile(info, captured), nil
|
|
}
|
|
|
|
func materializeQuarantineOrigin(root *os.Root) (string, error) {
|
|
config, err := root.ReadFile(filepath.Join(".git", "config"))
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return parseMaterializeOriginURL(string(config)), nil
|
|
}
|
|
|
|
func parseMaterializeOriginURL(config string) string {
|
|
inOrigin := false
|
|
for line := range strings.SplitSeq(config, "\n") {
|
|
line = strings.TrimSpace(line)
|
|
if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") {
|
|
fields := strings.Fields(strings.TrimSpace(line[1 : len(line)-1]))
|
|
inOrigin = len(fields) == 2 && strings.EqualFold(fields[0], "remote") && fields[1] == `"origin"`
|
|
continue
|
|
}
|
|
if !inOrigin || strings.HasPrefix(line, "#") || strings.HasPrefix(line, ";") {
|
|
continue
|
|
}
|
|
key, value, ok := strings.Cut(line, "=")
|
|
if ok && strings.EqualFold(strings.TrimSpace(key), "url") {
|
|
return strings.TrimSpace(value)
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func clearMaterializedQuarantine(root *os.Root) error {
|
|
directory, err := root.Open(".")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer func() { _ = directory.Close() }()
|
|
entries, err := directory.ReadDir(-1)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, entry := range entries {
|
|
if err := root.RemoveAll(entry.Name()); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func materializeGitOutput(ctx context.Context, args ...string) (string, error) {
|
|
cmd := subproc.NewGitCommand(ctx, args...)
|
|
output, err := subproc.RunGitCombinedOutputClass(ctx, subproc.GitLifecycle, cmd)
|
|
if err != nil {
|
|
if ctx.Err() != nil {
|
|
return "", ctx.Err()
|
|
}
|
|
return "", errors.New("git command failed")
|
|
}
|
|
return string(output), nil
|
|
}
|