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 }