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

331 lines
10 KiB

package dehub
import (
"bytes"
"dehub/accessctl"
"dehub/fs"
"dehub/sigcred"
"dehub/typeobj"
"encoding"
"encoding/base64"
"errors"
"fmt"
"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).
MessageHead() (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
}
// 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"`
// 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"`
}
// 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()
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)
}
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.Credentials = append(commit.Credentials, cred)
return commit, 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) (GitCommit, error) {
msgB, err := m.MarshalText()
if err != nil {
return GitCommit{}, fmt.Errorf("encoding %T to message string: %v", m, err)
}
w, err := r.GitRepo.Worktree()
if err != nil {
return GitCommit{}, fmt.Errorf("getting git worktree: %w", err)
}
h, err := w.Commit(string(msgB), &git.CommitOptions{
Author: &object.Signature{
Name: accountID,
When: time.Now(),
},
})
if err != nil {
return GitCommit{}, fmt.Errorf("committing to git worktree: %w", err)
}
gc, err := r.GetGitCommit(h)
if err != nil {
return GitCommit{}, fmt.Errorf("retrieving fresh commit %q back from git: %w", h, err)
}
return gc, 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.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
}
// if parentTree is nil then it will be inferred.
func (r *Repo) verifyCommit(branch plumbing.ReferenceName, gitCommit GitCommit, parentTree *object.Tree) error {
isRoot := gitCommit.Root()
if parentTree == nil {
if isRoot {
parentTree = new(object.Tree)
} else if parentCommit, err := gitCommit.GitCommit.Parent(0); err != nil {
return fmt.Errorf("getting parent commit %q: %w",
gitCommit.GitCommit.ParentHashes[0], err)
} else if parentTree, err = r.GitRepo.TreeObject(parentCommit.TreeHash); err != nil {
return fmt.Errorf("getting parent tree object %q: %w",
parentCommit.TreeHash, err)
}
}
vctx := verificationCtx{
commit: gitCommit.GitCommit,
commitTree: gitCommit.GitTree,
parentTree: parentTree,
}
var sigFS fs.FS
if isRoot {
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.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
}