package dehub import ( "bytes" "dehub.dev/src/dehub.git/accessctl" "dehub.dev/src/dehub.git/fs" "dehub.dev/src/dehub.git/sigcred" "dehub.dev/src/dehub.git/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 }