A read-only clone of the dehub project, for until dehub.dev can be brought back online.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
dehub/commit.go

261 lines
7.5 KiB

package dehub
import (
"bytes"
"dehub/accessctl"
"dehub/fs"
"dehub/sigcred"
"dehub/yamlutil"
"encoding"
"encoding/base64"
"errors"
"fmt"
"strings"
"time"
"gopkg.in/src-d/go-git.v4"
"gopkg.in/src-d/go-git.v4/plumbing"
"gopkg.in/src-d/go-git.v4/plumbing/object"
yaml "gopkg.in/yaml.v2"
)
// ChangeCommit describes the structure of a change commit message.
type ChangeCommit struct {
Message string `yaml:"message"`
ChangeHash yamlutil.Blob `yaml:"change_hash"`
Credentials []sigcred.Credential `yaml:"credentials"`
}
type ccYAML struct {
Val ChangeCommit `yaml:",inline"`
}
func msgHead(msg string) string {
i := strings.Index(msg, "\n")
if i > 0 {
return msg[:i]
}
return msg
}
// MarshalText implements the encoding.TextMarshaler interface by returning the
// form the ChangeCommit object takes in the git commit message.
func (cc ChangeCommit) MarshalText() ([]byte, error) {
changeCommitEncoded, err := yaml.Marshal(ccYAML{cc})
if err != nil {
return nil, fmt.Errorf("failed to encode ChangeCommit message: %w", err)
}
fullMsg := msgHead(cc.Message) + "\n\n" + string(changeCommitEncoded)
return []byte(fullMsg), nil
}
// UnmarshalText implements the encoding.TextUnmarshaler interface by decoding a
// ChangeCommit object which has been encoded into a git commit message.
func (cc *ChangeCommit) UnmarshalText(msg []byte) error {
i := bytes.Index(msg, []byte("\n"))
if i < 0 {
return fmt.Errorf("commit message %q is malformed", msg)
}
msgHead, msg := msg[:i], msg[i:]
var ccy ccYAML
if err := yaml.Unmarshal(msg, &ccy); err != nil {
return fmt.Errorf("could not unmarshal ChangeCommit message: %w", err)
}
*cc = ccy.Val
if !strings.HasPrefix(cc.Message, string(msgHead)) {
return errors.New("encoded ChangeCommit is malformed, it might not be an encoded ChangeCommit")
}
return nil
}
// Commit uses the given TextMarshaler to create a git commit object (with the
// specified accountID as the author) and commits it to the current HEAD,
// returning the hash of the commit.
func (r *Repo) Commit(m encoding.TextMarshaler, accountID string) (plumbing.Hash, error) {
msgB, err := m.MarshalText()
if err != nil {
return plumbing.ZeroHash, fmt.Errorf("error marshaling %T to string: %v", m, err)
}
w, err := r.GitRepo.Worktree()
if err != nil {
return plumbing.ZeroHash, fmt.Errorf("could not get git worktree: %w", err)
}
return w.Commit(string(msgB), &git.CommitOptions{
Author: &object.Signature{
Name: accountID,
When: time.Now(),
},
})
}
// HasStagedChanges returns true if there are file changes which have been
// staged (e.g. via "git add").
func (r *Repo) HasStagedChanges() (bool, error) {
w, err := r.GitRepo.Worktree()
if err != nil {
return false, fmt.Errorf("error retrieving worktree: %w", err)
}
status, err := w.Status()
if err != nil {
return false, fmt.Errorf("error retrieving worktree status: %w", err)
}
var any bool
for _, fileStatus := range status {
if fileStatus.Staging != git.Unmodified {
any = true
break
}
}
return any, nil
}
// NewChangeCommit constructs a ChangeCommit. If sig is given then it is used to
// create a Credential for the ChangeCommit.
func (r *Repo) NewChangeCommit(msg, accountID string, sig sigcred.SignifierInterface) (ChangeCommit, error) {
_, headTree, err := r.head()
if errors.Is(err, plumbing.ErrReferenceNotFound) {
headTree = &object.Tree{}
} else if err != nil {
return ChangeCommit{}, err
}
_, stagedTree, err := fs.FromStagedChangesTree(r.GitRepo)
if err != nil {
return ChangeCommit{}, err
}
changeHash := genChangeHash(nil, msg, headTree, stagedTree)
var creds []sigcred.Credential
if sig != nil {
// this is necessarily different than headTree for the case of there
// being no HEAD (ie it's the first commit). In that case we want
// headTree to be empty (because it's being used to generate the change
// hash), but we want the signifier to use the raw fs (because that's
// where the signifier's data might be).
sigFS, err := r.headOrRawFS()
if err != nil {
return ChangeCommit{}, err
}
cred, err := sig.Sign(sigFS, changeHash)
if err != nil {
return ChangeCommit{}, fmt.Errorf("failed to sign commit hash: %w", err)
}
cred.AccountID = accountID
creds = append(creds, cred)
}
return ChangeCommit{
Message: msg,
ChangeHash: changeHash,
Credentials: creds,
}, nil
}
func (r *Repo) assertAccessControls(
accessCtls []accessctl.BranchAccessControl, creds []sigcred.Credential,
branch plumbing.ReferenceName, from, to *object.Tree,
) error {
filesChanged, err := calcDiff(from, to)
if err != nil {
return err
}
pathsChanged := make([]string, len(filesChanged))
for i := range filesChanged {
pathsChanged[i] = filesChanged[i].path
}
matchRes, err := accessctl.Match(accessCtls, accessctl.MatchInteractions{
Branch: branch.Short(),
FilePathsChanged: pathsChanged,
})
if err != nil {
return fmt.Errorf("could not determine applicable access controls: %w", err)
}
for _, matchedAC := range matchRes.ChangeAccessControls {
ac := matchedAC.ChangeAccessControl
condInt, err := ac.Condition.Interface()
if err != nil {
return fmt.Errorf("could not cast Condition of file path pattern %q to interface: %w",
ac.FilePathPattern, err)
} else if err := condInt.Satisfied(creds); err != nil {
return fmt.Errorf("access control of file path pattern %q not satisfied: %w\nFiles matched:\n%s",
ac.FilePathPattern, err, strings.Join(matchedAC.FilePaths, "\n"))
}
}
return nil
}
// VerifyChangeCommit verifies that the change commit at the given hash, which
// is presumably on the given branch, is gucci.
func (r *Repo) VerifyChangeCommit(branch plumbing.ReferenceName, h plumbing.Hash) error {
commit, err := r.GitRepo.CommitObject(h)
if err != nil {
return fmt.Errorf("could not retrieve commit object: %w", err)
}
commitTree, err := r.GitRepo.TreeObject(commit.TreeHash)
if err != nil {
return fmt.Errorf("could not retrieve tree object: %w", err)
}
var changeCommit ChangeCommit
if err := changeCommit.UnmarshalText([]byte(commit.Message)); err != nil {
return err
}
sigTree := commitTree // only for root commit
parentTree := &object.Tree{}
if commit.NumParents() > 0 {
parent, err := commit.Parent(0)
if err != nil {
return fmt.Errorf("could not retrieve parent of commit: %w", err)
} else if parentTree, err = r.GitRepo.TreeObject(parent.TreeHash); err != nil {
return fmt.Errorf("could not retrieve tree object of parent %q: %w", parent.Hash, err)
}
sigTree = parentTree
}
sigFS := fs.FromTree(sigTree)
cfg, err := r.loadConfig(sigFS)
if err != nil {
return fmt.Errorf("error loading config: %w", err)
}
err = r.assertAccessControls(
cfg.AccessControls, changeCommit.Credentials,
branch, parentTree, commitTree,
)
if err != nil {
return fmt.Errorf("failed to satisfy all access controls: %w", err)
}
expectedChangeHash := genChangeHash(nil, changeCommit.Message, parentTree, commitTree)
if !bytes.Equal(changeCommit.ChangeHash, expectedChangeHash) {
return fmt.Errorf("malformed change_hash in commit body, is %s but should be %s",
base64.StdEncoding.EncodeToString(expectedChangeHash),
base64.StdEncoding.EncodeToString(changeCommit.ChangeHash))
}
for _, cred := range changeCommit.Credentials {
sig, err := r.signifierForCredential(sigFS, cred)
if err != nil {
return fmt.Errorf("error finding signifier for credential %+v: %w", cred, err)
} else if err := sig.Verify(sigFS, expectedChangeHash, cred); err != nil {
return fmt.Errorf("error verifying credential %+v: %w", cred, err)
}
}
return nil
}