252 lines
7.3 KiB
Go
252 lines
7.3 KiB
Go
|
package dehub
|
||
|
|
||
|
import (
|
||
|
"bytes"
|
||
|
"dehub/accessctl"
|
||
|
"dehub/fs"
|
||
|
"dehub/sigcred"
|
||
|
"dehub/yamlutil"
|
||
|
"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"
|
||
|
)
|
||
|
|
||
|
// MasterCommit describes the structure of the object encoded into the git
|
||
|
// message of a commit in the master branch.
|
||
|
type MasterCommit struct {
|
||
|
Message string `yaml:"message"`
|
||
|
ChangeHash yamlutil.Blob `yaml:"change_hash"`
|
||
|
Credentials []sigcred.Credential `yaml:"credentials"`
|
||
|
}
|
||
|
|
||
|
type mcYAML struct {
|
||
|
Val MasterCommit `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 MasterCommit object takes in the git commit message.
|
||
|
func (mc MasterCommit) MarshalText() ([]byte, error) {
|
||
|
masterCommitEncoded, err := yaml.Marshal(mcYAML{mc})
|
||
|
if err != nil {
|
||
|
return nil, fmt.Errorf("failed to encode MasterCommit message: %w", err)
|
||
|
}
|
||
|
|
||
|
fullMsg := msgHead(mc.Message) + "\n\n" + string(masterCommitEncoded)
|
||
|
return []byte(fullMsg), nil
|
||
|
}
|
||
|
|
||
|
// UnmarshalText implements the encoding.TextUnmarshaler interface by decoding a
|
||
|
// MasterCommit object which has been encoded into a git commit message.
|
||
|
func (mc *MasterCommit) 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 mcy mcYAML
|
||
|
if err := yaml.Unmarshal(msg, &mcy); err != nil {
|
||
|
return fmt.Errorf("could not unmarshal MasterCommit message: %w", err)
|
||
|
}
|
||
|
|
||
|
*mc = mcy.Val
|
||
|
if !strings.HasPrefix(mc.Message, string(msgHead)) {
|
||
|
return errors.New("encoded MasterCommit is malformed, it might not be an encoded MasterCommit")
|
||
|
}
|
||
|
|
||
|
return nil
|
||
|
}
|
||
|
|
||
|
// CommitMaster constructs a MasterCommit using the given SignifierInterface to
|
||
|
// create a Credential for it. It returns the commit's hash after having set it
|
||
|
// to HEAD.
|
||
|
//
|
||
|
// TODO this method is a prototype and does not reflect the method's final form.
|
||
|
func (r *Repo) CommitMaster(msg, accountID string, sig sigcred.SignifierInterface) (MasterCommit, plumbing.Hash, error) {
|
||
|
_, headTree, err := r.head()
|
||
|
if errors.Is(err, plumbing.ErrReferenceNotFound) {
|
||
|
headTree = &object.Tree{}
|
||
|
} else if err != nil {
|
||
|
return MasterCommit{}, plumbing.ZeroHash, err
|
||
|
}
|
||
|
|
||
|
_, stagedTree, err := fs.FromStagedChangesTree(r.GitRepo)
|
||
|
if err != nil {
|
||
|
return MasterCommit{}, plumbing.ZeroHash, err
|
||
|
}
|
||
|
|
||
|
// 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 MasterCommit{}, plumbing.ZeroHash, err
|
||
|
}
|
||
|
|
||
|
cfg, err := r.loadConfig(sigFS)
|
||
|
if err != nil {
|
||
|
return MasterCommit{}, plumbing.ZeroHash, fmt.Errorf("could not load config: %w", err)
|
||
|
}
|
||
|
|
||
|
changeHash := genChangeHash(nil, msg, headTree, stagedTree)
|
||
|
cred, err := sig.Sign(sigFS, changeHash)
|
||
|
if err != nil {
|
||
|
return MasterCommit{}, plumbing.ZeroHash, fmt.Errorf("failed to sign commit hash: %w", err)
|
||
|
}
|
||
|
cred.AccountID = accountID
|
||
|
|
||
|
// This isn't strictly necessary, but we want to save people the effort of
|
||
|
// creating an invalid commit, pushing it, having it be rejected, then
|
||
|
// having to reset on the commit.
|
||
|
err = r.assertAccessControls(
|
||
|
cfg.AccessControls, []sigcred.Credential{cred},
|
||
|
headTree, stagedTree,
|
||
|
)
|
||
|
if err != nil {
|
||
|
return MasterCommit{}, plumbing.ZeroHash, fmt.Errorf("commit would not satisfy access controls: %w", err)
|
||
|
}
|
||
|
|
||
|
masterCommit := MasterCommit{
|
||
|
Message: msg,
|
||
|
ChangeHash: changeHash,
|
||
|
Credentials: []sigcred.Credential{cred},
|
||
|
}
|
||
|
|
||
|
masterCommitB, err := masterCommit.MarshalText()
|
||
|
if err != nil {
|
||
|
return masterCommit, plumbing.ZeroHash, err
|
||
|
}
|
||
|
|
||
|
w, err := r.GitRepo.Worktree()
|
||
|
if err != nil {
|
||
|
return masterCommit, plumbing.ZeroHash, fmt.Errorf("could not get git worktree: %w", err)
|
||
|
}
|
||
|
|
||
|
hash, err := w.Commit(string(masterCommitB), &git.CommitOptions{
|
||
|
Author: &object.Signature{
|
||
|
Name: accountID,
|
||
|
When: time.Now(),
|
||
|
},
|
||
|
})
|
||
|
if err != nil {
|
||
|
return masterCommit, hash, fmt.Errorf("failed to commit changed: %w", err)
|
||
|
}
|
||
|
|
||
|
return masterCommit, hash, nil
|
||
|
|
||
|
}
|
||
|
|
||
|
func (r *Repo) assertAccessControls(
|
||
|
accessCtls []accessctl.AccessControl, creds []sigcred.Credential,
|
||
|
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
|
||
|
}
|
||
|
|
||
|
accessCtls, err = accessctl.ApplicableAccessControls(accessCtls, pathsChanged)
|
||
|
if err != nil {
|
||
|
return fmt.Errorf("could not determine applicable access controls: %w", err)
|
||
|
}
|
||
|
|
||
|
for _, accessCtl := range accessCtls {
|
||
|
condInt, err := accessCtl.Condition.Interface()
|
||
|
if err != nil {
|
||
|
return fmt.Errorf("could not cast Condition to interface: %w", err)
|
||
|
} else if err := condInt.Satisfied(creds); err != nil {
|
||
|
return fmt.Errorf("access control for pattern %q not satisfied: %w",
|
||
|
accessCtl.Pattern, err)
|
||
|
}
|
||
|
}
|
||
|
|
||
|
return nil
|
||
|
}
|
||
|
|
||
|
// VerifyMasterCommit verifies that the commit at the given hash, which is
|
||
|
// presumably on the master branch, is gucci.
|
||
|
func (r *Repo) VerifyMasterCommit(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 masterCommit MasterCommit
|
||
|
if err := masterCommit.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, masterCommit.Credentials,
|
||
|
parentTree, commitTree,
|
||
|
)
|
||
|
if err != nil {
|
||
|
return fmt.Errorf("failed to satisfy all access controls: %w", err)
|
||
|
}
|
||
|
|
||
|
expectedChangeHash := genChangeHash(nil, masterCommit.Message, parentTree, commitTree)
|
||
|
if !bytes.Equal(masterCommit.ChangeHash, expectedChangeHash) {
|
||
|
return fmt.Errorf("malformed change_hash in commit body, is %s but should be %s",
|
||
|
base64.StdEncoding.EncodeToString(expectedChangeHash),
|
||
|
base64.StdEncoding.EncodeToString(masterCommit.ChangeHash))
|
||
|
}
|
||
|
|
||
|
for _, cred := range masterCommit.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)
|
||
|
}
|
||
|
}
|
||
|
|
||
|
// TODO access controls
|
||
|
|
||
|
return nil
|
||
|
}
|