package dehub import ( "bytes" "dehub/accessctl" "dehub/fs" "dehub/sigcred" "dehub/typeobj" "encoding" "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" ) // 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 } // 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) (plumbing.Hash, error) { msgB, err := m.MarshalText() if err != nil { return plumbing.ZeroHash, fmt.Errorf("error marshaling %T to string: %v", m, err) } w, err := r.GitRepo.Worktree() if err != nil { return plumbing.ZeroHash, fmt.Errorf("could not get git worktree: %w", err) } return w.Commit(string(msgB), &git.CommitOptions{ Author: &object.Signature{ Name: accountID, When: time.Now(), }, }) } // 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 isRootCommit bool } // 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 vctx.isRootCommit = vctx.commit.NumParents() == 0; vctx.isRootCommit { vctx.parentTree = new(object.Tree) } 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( accessCtls []accessctl.BranchAccessControl, 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 } matchRes, err := accessctl.Match(accessCtls, accessctl.MatchInteractions{ Branch: branch.Short(), FilePathsChanged: pathsChanged, CredentialAdded: commit.Credential != nil, }) if err != nil { return fmt.Errorf("determining applicable access controls: %w", err) } defer func() { if err != nil { err = fmt.Errorf("asserting access controls for branch_pattern %q: %w", matchRes.BranchPattern, err) } }() for _, matchedChangeAC := range matchRes.ChangeAccessControls { ac := matchedChangeAC.ChangeAccessControl if condInt, err := ac.Condition.Interface(); err != nil { return fmt.Errorf("casting ChangeAccessControl.Condition %#v to interface: %w", ac.Condition, err) } else if err := condInt.Satisfied(commit.Credentials); err != nil { return fmt.Errorf("satisfying change_access_control with file_path_pattern %q: %w\nfiles matched:\n%s", ac.FilePathPattern, err, strings.Join(matchedChangeAC.FilePaths, "\n")) } } if matchRes.CredentialAccessControl != nil { cond := matchRes.CredentialAccessControl.CredentialAccessControl.Condition if condInt, err := cond.Interface(); err != nil { return fmt.Errorf("casting CredentialAccessControl.Condition %#v to interface: %w", cond, err) } else if err := condInt.Satisfied(commit.Credentials); err != nil { return fmt.Errorf("satisfying credential_access_control: %w", err) } } return nil } // VerifyCommit verifies that the commit at the given hash, which is presumably // on the given branch, is gucci. func (r *Repo) VerifyCommit(branch plumbing.ReferenceName, h plumbing.Hash) error { vctx, err := r.verificationCtx(h) if err != nil { return err } var sigFS fs.FS if vctx.isRootCommit { sigFS = fs.FromTree(vctx.commitTree) } else { sigFS = fs.FromTree(vctx.parentTree) } var commit Commit if err := commit.UnmarshalText([]byte(vctx.commit.Message)); err != nil { return err } cfg, err := r.loadConfig(sigFS) if err != nil { return fmt.Errorf("error loading config: %w", err) } err = r.assertAccessControls(cfg.AccessControls, commit, vctx, branch) if err != nil { return fmt.Errorf("failed to satisfy all access controls: %w", err) } commitInt, err := commit.Interface() if err != nil { return fmt.Errorf("could not cast commit %+v to interface: %w", commit, err) } changeHash := commitInt.GetHash() expectedChangeHash, err := commitInt.Hash(vctx.parentTree, vctx.commitTree) if err != nil { return fmt.Errorf("error 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 commit.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) } } return nil }