a8c7f92328
--- type: change message: include commit hashes as part of credential commit when accrediting change commits change_hash: AF5g6f2/qrEn0z3JA2PJ9wec71TwWGuMYfrsPPlRTxla credentials: - type: pgp_signature pub_key_id: 95C46FA6A41148AC body: iQIzBAABAgAdFiEEJ6tQKp6olvZKJ0lwlcRvpqQRSKwFAl578NwACgkQlcRvpqQRSKzoVRAAmWfVIA//2yVHJ8VgJyhnfVY/zqFk4dmX+ynxuyBrH2Bp7HVgZBscxLSdrBLBPB68NHinAbK6kPXw2U/NIGcUdHoHfo4Oh6crCtfsMhHb6k4afJKshVKCWDsIws/4gWo4/usvJZEKAjLXys+wmXz6HEsXj1DOP1Z+WjrApuDoOKDxKXVvSmwqKKpfJPhLJJOxKLlfJtwa5a4EuLNvXDL99eOPlvIIaxtl5hj499torpin6Wra6WJikFxEk6jECxz9K7xiVNmKCJ/eT0YQc42misi3kEV5HkIlHpl3IJ6g40dAr92kKVTy2xbgAa2KJYfbU5eF2YoAlByuX6g1upKFSPQBzAuCRLqxIwSDHApSkLG+S56Z8vuxOjrUo868VzXyu2z6VJRvcLJeTpMzm35u1B8WffEqyNnnI+v4m4z7ge7g01HakT1kJuyxBtR3INl3vYT3uXD8yXs0oxexyT12ZRA0l9rKlqdkp7DcxNAkm8SJUUkuG2nArWfqYYtagWKk26espGh1tiBTsTqkbwfdu1mxl1qBwU6n80eUT5oAo0LccjH3sKSPObNKwMLiKn6O9kvSX9yO9a1XiuqdfcTxcvBkqR9hHKdQtmbN2R5HvUv1jNQz/jsqh48u9h01GN6zCDt3AaCEAn6cB/DxCvYbDZwdAVJX3ulONnVs4c6k1UI= account: mediocregopher
484 lines
15 KiB
Go
484 lines
15 KiB
Go
package dehub
|
|
|
|
import (
|
|
"bytes"
|
|
"dehub/accessctl"
|
|
"dehub/fs"
|
|
"dehub/sigcred"
|
|
"dehub/typeobj"
|
|
"encoding/base64"
|
|
"errors"
|
|
"fmt"
|
|
"reflect"
|
|
"sort"
|
|
"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"
|
|
)
|
|
|
|
// CommitInterface describes the methods which must be implemented by the
|
|
// different commit types.
|
|
type CommitInterface interface {
|
|
// MessageHead returns the head of the commit message (i.e. the first line).
|
|
// The CommitCommon of the outer Commit is passed in for added context, if
|
|
// necessary.
|
|
MessageHead(CommitCommon) (string, error)
|
|
|
|
// Hash returns the raw hash which Signifiers can sign to accredit this
|
|
// commit. The tree objects given describe the filesystem state of the
|
|
// parent commit, and the filesystem state of this commit.
|
|
//
|
|
// This method should _not_ change any fields on the commit.
|
|
Hash(parent, this *object.Tree) ([]byte, error)
|
|
|
|
// GetHash returns the signable Hash embedded in the commit, which should
|
|
// hopefully correspond to the Commit's Credentials.
|
|
GetHash() []byte
|
|
}
|
|
|
|
// CommitCommon describes the fields common to all Commit objects.
|
|
type CommitCommon struct {
|
|
// Credentials represent all created Credentials for this commit, and can be
|
|
// set on all Commit objects regardless of other fields being set.
|
|
Credentials []sigcred.Credential `yaml:"credentials"`
|
|
}
|
|
|
|
func (cc CommitCommon) credAccountIDs() []string {
|
|
m := map[string]struct{}{}
|
|
for _, cred := range cc.Credentials {
|
|
m[cred.AccountID] = struct{}{}
|
|
}
|
|
s := make([]string, 0, len(m))
|
|
for accountID := range m {
|
|
s = append(s, accountID)
|
|
}
|
|
sort.Strings(s)
|
|
return s
|
|
}
|
|
|
|
func abbrevCommitMessage(msg string) string {
|
|
i := strings.Index(msg, "\n")
|
|
if i > 0 {
|
|
msg = msg[:i]
|
|
}
|
|
if len(msg) > 80 {
|
|
msg = msg[:80] + "..."
|
|
}
|
|
return msg
|
|
}
|
|
|
|
// Commit represents a single Commit which is being added to a branch. Only one
|
|
// field should be set on a Commit, unless otherwise noted.
|
|
type Commit struct {
|
|
Change *CommitChange `type:"change,default"`
|
|
Credential *CommitCredential `type:"credential"`
|
|
Comment *CommitComment `type:"comment"`
|
|
|
|
Common CommitCommon `yaml:",inline"`
|
|
}
|
|
|
|
// MarshalYAML implements the yaml.Marshaler interface.
|
|
func (c Commit) MarshalYAML() (interface{}, error) {
|
|
return typeobj.MarshalYAML(c)
|
|
}
|
|
|
|
// UnmarshalYAML implements the yaml.Unmarshaler interface.
|
|
func (c *Commit) UnmarshalYAML(unmarshal func(interface{}) error) error {
|
|
return typeobj.UnmarshalYAML(c, unmarshal)
|
|
}
|
|
|
|
// Interface returns the CommitInterface instance encapsulated by this Commit
|
|
// object.
|
|
func (c Commit) Interface() (CommitInterface, error) {
|
|
el, _, err := typeobj.Element(c)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return el.(CommitInterface), nil
|
|
}
|
|
|
|
// Type returns the Commit's type (as would be used in its YAML "type" field).
|
|
func (c Commit) Type() (string, error) {
|
|
_, typeStr, err := typeobj.Element(c)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return typeStr, nil
|
|
}
|
|
|
|
// MarshalText implements the encoding.TextMarshaler interface by returning the
|
|
// form the Commit object takes in the git commit message.
|
|
func (c Commit) MarshalText() ([]byte, error) {
|
|
commitInt, err := c.Interface()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("could not cast Commit %+v to interface : %w", c, err)
|
|
}
|
|
|
|
msgHead, err := commitInt.MessageHead(c.Common)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("error constructing message head: %w", err)
|
|
}
|
|
|
|
msgBodyB, err := yaml.Marshal(c)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("error marshaling commit %+v as yaml: %w", c, err)
|
|
}
|
|
|
|
w := new(bytes.Buffer)
|
|
w.WriteString(msgHead)
|
|
w.WriteString("\n\n---\n")
|
|
w.Write(msgBodyB)
|
|
return w.Bytes(), nil
|
|
}
|
|
|
|
// UnmarshalText implements the encoding.TextUnmarshaler interface by decoding a
|
|
// Commit object which has been encoded into a git commit message.
|
|
func (c *Commit) UnmarshalText(msg []byte) error {
|
|
i := bytes.Index(msg, []byte("\n"))
|
|
if i < 0 {
|
|
return fmt.Errorf("commit message %q is malformed, it has no body", msg)
|
|
}
|
|
msgBody := msg[i:]
|
|
|
|
if err := yaml.Unmarshal(msgBody, c); err != nil {
|
|
return fmt.Errorf("could not unmarshal Commit message from yaml: %w", err)
|
|
|
|
} else if reflect.DeepEqual(*c, Commit{}) {
|
|
// a basic check, but worthwhile
|
|
return errors.New("commit message is malformed, could not unmarshal yaml object")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// AccreditCommit returns the given Commit with an appended Credential provided
|
|
// by the given SignifierInterface.
|
|
func (r *Repo) AccreditCommit(commit Commit, sigInt sigcred.SignifierInterface) (Commit, error) {
|
|
commitInt, err := commit.Interface()
|
|
if err != nil {
|
|
return commit, fmt.Errorf("could not cast commit %+v to interface: %w", commit, err)
|
|
}
|
|
|
|
headFS, err := r.headFS()
|
|
if err != nil {
|
|
return commit, fmt.Errorf("could not grab snapshot of HEAD fs: %w", err)
|
|
}
|
|
|
|
cred, err := sigInt.Sign(headFS, commitInt.GetHash())
|
|
if err != nil {
|
|
return commit, fmt.Errorf("could not accredit change commit: %w", err)
|
|
}
|
|
commit.Common.Credentials = append(commit.Common.Credentials, cred)
|
|
return commit, nil
|
|
}
|
|
|
|
// CommitBareParams are the parameters to the CommitBare method. All are
|
|
// required, unless otherwise noted.
|
|
type CommitBareParams struct {
|
|
Commit Commit
|
|
Author string
|
|
ParentHash plumbing.Hash // can be zero if the commit has no parents (Q_Q)
|
|
GitTree *object.Tree
|
|
}
|
|
|
|
// CommitBare constructs a git commit object and and stores it, returning the
|
|
// resulting GitCommit. This method does not interact with HEAD at all.
|
|
func (r *Repo) CommitBare(params CommitBareParams) (GitCommit, error) {
|
|
msgB, err := params.Commit.MarshalText()
|
|
if err != nil {
|
|
return GitCommit{}, fmt.Errorf("encoding %T to message string: %w",
|
|
params.Commit, err)
|
|
}
|
|
|
|
author := object.Signature{
|
|
Name: params.Author,
|
|
When: time.Now(),
|
|
}
|
|
commit := &object.Commit{
|
|
Author: author,
|
|
Committer: author,
|
|
Message: string(msgB),
|
|
TreeHash: params.GitTree.Hash,
|
|
}
|
|
if params.ParentHash != plumbing.ZeroHash {
|
|
commit.ParentHashes = []plumbing.Hash{params.ParentHash}
|
|
}
|
|
|
|
commitObj := r.GitRepo.Storer.NewEncodedObject()
|
|
if err := commit.Encode(commitObj); err != nil {
|
|
return GitCommit{}, fmt.Errorf("encoding commit object: %w", err)
|
|
}
|
|
|
|
commitHash, err := r.GitRepo.Storer.SetEncodedObject(commitObj)
|
|
if err != nil {
|
|
return GitCommit{}, fmt.Errorf("setting encoded object: %w", err)
|
|
}
|
|
|
|
return r.GetGitCommit(commitHash)
|
|
}
|
|
|
|
// Commit uses the given Commit to create a git commit object (with the
|
|
// specified accountID as the author) and commits it to the current HEAD,
|
|
// returning the full GitCommit.
|
|
func (r *Repo) Commit(commit Commit, accountID string) (GitCommit, error) {
|
|
headRef, err := r.TraverseReferenceChain(plumbing.HEAD, func(ref *plumbing.Reference) bool {
|
|
return ref.Type() == plumbing.HashReference
|
|
})
|
|
if err != nil {
|
|
return GitCommit{}, fmt.Errorf("resolving HEAD to a hash reference: %w", err)
|
|
}
|
|
headRefName := headRef.Name()
|
|
|
|
headHash, err := r.ReferenceToHash(headRefName)
|
|
if err != nil {
|
|
return GitCommit{}, fmt.Errorf("resolving ref %q (HEAD): %w", headRefName, err)
|
|
}
|
|
|
|
// TODO this is also used in the same way in NewCommitChange. It might make
|
|
// sense to refactor this logic out, it might not be needed in fs at all.
|
|
_, stagedTree, err := fs.FromStagedChangesTree(r.GitRepo)
|
|
if err != nil {
|
|
return GitCommit{}, fmt.Errorf("getting staged changes: %w", err)
|
|
}
|
|
|
|
gitCommit, err := r.CommitBare(CommitBareParams{
|
|
Commit: commit,
|
|
Author: accountID,
|
|
ParentHash: headHash,
|
|
GitTree: stagedTree,
|
|
})
|
|
if err != nil {
|
|
return GitCommit{}, err
|
|
}
|
|
|
|
// now set the branch to this new commit
|
|
newHeadRef := plumbing.NewHashReference(headRefName, gitCommit.GitCommit.Hash)
|
|
if err := r.GitRepo.Storer.SetReference(newHeadRef); err != nil {
|
|
return GitCommit{}, fmt.Errorf("setting reference %q to new commit hash %q: %w",
|
|
headRefName, gitCommit.GitCommit.Hash, err)
|
|
}
|
|
|
|
return gitCommit, nil
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
type verificationCtx struct {
|
|
commit *object.Commit
|
|
commitTree, parentTree *object.Tree
|
|
}
|
|
|
|
// non-gophers gonna hate on this method, but I say it's fine
|
|
func (r *Repo) verificationCtx(h plumbing.Hash) (vctx verificationCtx, err error) {
|
|
if vctx.commit, err = r.GitRepo.CommitObject(h); err != nil {
|
|
return vctx, fmt.Errorf("retrieving commit object: %w", err)
|
|
|
|
} else if vctx.commitTree, err = r.GitRepo.TreeObject(vctx.commit.TreeHash); err != nil {
|
|
return vctx, fmt.Errorf("retrieving commit tree object %q: %w",
|
|
vctx.commit.TreeHash, err)
|
|
|
|
} else if parent, err := vctx.commit.Parent(0); err != nil {
|
|
return vctx, fmt.Errorf("retrieving commit parent: %w", err)
|
|
|
|
} else if vctx.parentTree, err = r.GitRepo.TreeObject(parent.TreeHash); err != nil {
|
|
return vctx, fmt.Errorf("retrieving commit parent tree object %q: %w",
|
|
parent.Hash, err)
|
|
}
|
|
|
|
return vctx, nil
|
|
}
|
|
|
|
func (r *Repo) assertAccessControls(
|
|
acl []accessctl.AccessControl,
|
|
commit Commit, vctx verificationCtx, branch plumbing.ReferenceName,
|
|
) (err error) {
|
|
filesChanged, err := calcDiff(vctx.parentTree, vctx.commitTree)
|
|
if err != nil {
|
|
return fmt.Errorf("calculating diff from tree %q to tree %q: %w",
|
|
vctx.parentTree.Hash, vctx.commitTree.Hash, err)
|
|
|
|
} else if len(filesChanged) > 0 && commit.Change == nil {
|
|
return errors.New("files changes but commit is not a change commit")
|
|
}
|
|
|
|
pathsChanged := make([]string, len(filesChanged))
|
|
for i := range filesChanged {
|
|
pathsChanged[i] = filesChanged[i].path
|
|
}
|
|
|
|
commitType, err := commit.Type()
|
|
if err != nil {
|
|
return fmt.Errorf("determining type of commit %+v: %w", commit, err)
|
|
}
|
|
|
|
return accessctl.AssertCanCommit(acl, accessctl.CommitRequest{
|
|
Type: commitType,
|
|
Branch: branch.Short(),
|
|
Credentials: commit.Common.Credentials,
|
|
FilesChanged: pathsChanged,
|
|
})
|
|
}
|
|
|
|
// VerifyCommits verifies that the given commits, which are presumably on the
|
|
// given branch, are gucci.
|
|
func (r *Repo) VerifyCommits(branch plumbing.ReferenceName, gitCommits []GitCommit) error {
|
|
|
|
for i, gitCommit := range gitCommits {
|
|
// It's not a requirement that the given GitCommits are in ancestral
|
|
// order, but usually they are, so we can help verifyCommit not have to
|
|
// calculate the parentTree if the previous commit is the parent of this
|
|
// one.
|
|
var parentTree *object.Tree
|
|
if i > 0 && gitCommits[i-1].GitCommit.Hash == gitCommit.GitCommit.ParentHashes[0] {
|
|
parentTree = gitCommits[i-1].GitTree
|
|
}
|
|
|
|
if err := r.verifyCommit(branch, gitCommit, parentTree); err != nil {
|
|
return fmt.Errorf("verifying commit %q: %w",
|
|
gitCommit.GitCommit.Hash, err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// parentTree returns the tree of the parent commit of the given commit. If the
|
|
// given commit has no parents then a bare tree is returned.
|
|
func (r *Repo) parentTree(commitObj *object.Commit) (*object.Tree, error) {
|
|
switch commitObj.NumParents() {
|
|
case 0:
|
|
return new(object.Tree), nil
|
|
case 1:
|
|
if parentCommitObj, err := commitObj.Parent(0); err != nil {
|
|
return nil, fmt.Errorf("getting parent commit %q: %w",
|
|
commitObj.ParentHashes[0], err)
|
|
} else if parentTree, err := r.GitRepo.TreeObject(parentCommitObj.TreeHash); err != nil {
|
|
return nil, fmt.Errorf("getting parent tree object %q: %w",
|
|
parentCommitObj.TreeHash, err)
|
|
} else {
|
|
return parentTree, nil
|
|
}
|
|
default:
|
|
return nil, errors.New("commit has multiple parents")
|
|
}
|
|
}
|
|
|
|
// if parentTree is nil then it will be inferred.
|
|
func (r *Repo) verifyCommit(branch plumbing.ReferenceName, gitCommit GitCommit, parentTree *object.Tree) error {
|
|
parentTree, err := r.parentTree(gitCommit.GitCommit)
|
|
if err != nil {
|
|
return fmt.Errorf("retrieving parent tree of commit: %w", err)
|
|
}
|
|
|
|
vctx := verificationCtx{
|
|
commit: gitCommit.GitCommit,
|
|
commitTree: gitCommit.GitTree,
|
|
parentTree: parentTree,
|
|
}
|
|
|
|
var sigFS fs.FS
|
|
if gitCommit.Root() {
|
|
sigFS = fs.FromTree(vctx.commitTree)
|
|
} else {
|
|
sigFS = fs.FromTree(vctx.parentTree)
|
|
}
|
|
|
|
cfg, err := r.loadConfig(sigFS)
|
|
if err != nil {
|
|
return fmt.Errorf("loading config of parent %q: %w",
|
|
gitCommit.GitCommit.ParentHashes[0], err)
|
|
}
|
|
|
|
err = r.assertAccessControls(cfg.AccessControls, gitCommit.Commit, vctx, branch)
|
|
if err != nil {
|
|
return fmt.Errorf("enforcing access controls: %w", err)
|
|
}
|
|
|
|
changeHash := gitCommit.Interface.GetHash()
|
|
expectedChangeHash, err := gitCommit.Interface.Hash(vctx.parentTree, vctx.commitTree)
|
|
if err != nil {
|
|
return fmt.Errorf("calculating expected change hash: %w", err)
|
|
} else if !bytes.Equal(changeHash, expectedChangeHash) {
|
|
return fmt.Errorf("malformed change_hash in commit body, is %s but should be %s",
|
|
base64.StdEncoding.EncodeToString(expectedChangeHash),
|
|
base64.StdEncoding.EncodeToString(changeHash))
|
|
}
|
|
|
|
for _, cred := range gitCommit.Commit.Common.Credentials {
|
|
sig, err := r.signifierForCredential(sigFS, cred)
|
|
if err != nil {
|
|
return fmt.Errorf("finding signifier for credential %+v: %w", cred, err)
|
|
} else if err := sig.Verify(sigFS, expectedChangeHash, cred); err != nil {
|
|
return fmt.Errorf("verifying credential %+v: %w", cred, err)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
type changeRangeInfo struct {
|
|
changeCommits []GitCommit
|
|
authors map[string]struct{}
|
|
msg string
|
|
startTree, endTree *object.Tree
|
|
changeHash []byte
|
|
}
|
|
|
|
// changeRangeInfo returns various pieces of information about a range of
|
|
// commits' changes.
|
|
func (r *Repo) changeRangeInfo(commits []GitCommit) (changeRangeInfo, error) {
|
|
info := changeRangeInfo{
|
|
authors: map[string]struct{}{},
|
|
}
|
|
|
|
for _, commit := range commits {
|
|
if _, ok := commit.Interface.(*CommitChange); ok {
|
|
info.changeCommits = append(info.changeCommits, commit)
|
|
for _, cred := range commit.Commit.Common.Credentials {
|
|
info.authors[cred.AccountID] = struct{}{}
|
|
}
|
|
}
|
|
}
|
|
if len(info.changeCommits) == 0 {
|
|
return changeRangeInfo{}, errors.New("no change commits found")
|
|
}
|
|
|
|
// startTree has to be the tree of the parent of the first commit, which
|
|
// isn't included in commits. Determine it the hard way.
|
|
var err error
|
|
if info.startTree, err = r.parentTree(commits[0].GitCommit); err != nil {
|
|
return changeRangeInfo{}, fmt.Errorf("getting tree of parent of %q: %w",
|
|
commits[0].GitCommit.Hash, err)
|
|
}
|
|
|
|
lastChangeCommit := info.changeCommits[len(info.changeCommits)-1]
|
|
info.msg = lastChangeCommit.Commit.Change.Message
|
|
info.endTree = lastChangeCommit.GitTree
|
|
info.changeHash = genChangeHash(nil, info.msg, info.startTree, info.endTree)
|
|
return info, nil
|
|
}
|