package dehub import ( "bytes" "dehub/accessctl" "dehub/fs" "dehub/sigcred" "dehub/yamlutil" "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" ) // ChangeCommit describes the structure of a change commit message. type ChangeCommit struct { Message string `yaml:"message"` ChangeHash yamlutil.Blob `yaml:"change_hash"` Credentials []sigcred.Credential `yaml:"credentials"` } type ccYAML struct { Val ChangeCommit `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 ChangeCommit object takes in the git commit message. func (cc ChangeCommit) MarshalText() ([]byte, error) { changeCommitEncoded, err := yaml.Marshal(ccYAML{cc}) if err != nil { return nil, fmt.Errorf("failed to encode ChangeCommit message: %w", err) } fullMsg := msgHead(cc.Message) + "\n\n" + string(changeCommitEncoded) return []byte(fullMsg), nil } // UnmarshalText implements the encoding.TextUnmarshaler interface by decoding a // ChangeCommit object which has been encoded into a git commit message. func (cc *ChangeCommit) 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 ccy ccYAML if err := yaml.Unmarshal(msg, &ccy); err != nil { return fmt.Errorf("could not unmarshal ChangeCommit message: %w", err) } *cc = ccy.Val if !strings.HasPrefix(cc.Message, string(msgHead)) { return errors.New("encoded ChangeCommit is malformed, it might not be an encoded ChangeCommit") } return 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 } // NewChangeCommit constructs a ChangeCommit. If sig is given then it is used to // create a Credential for the ChangeCommit. func (r *Repo) NewChangeCommit(msg, accountID string, sig sigcred.SignifierInterface) (ChangeCommit, error) { _, headTree, err := r.head() if errors.Is(err, plumbing.ErrReferenceNotFound) { headTree = &object.Tree{} } else if err != nil { return ChangeCommit{}, err } _, stagedTree, err := fs.FromStagedChangesTree(r.GitRepo) if err != nil { return ChangeCommit{}, err } changeHash := genChangeHash(nil, msg, headTree, stagedTree) var creds []sigcred.Credential if sig != nil { // 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 ChangeCommit{}, err } cred, err := sig.Sign(sigFS, changeHash) if err != nil { return ChangeCommit{}, fmt.Errorf("failed to sign commit hash: %w", err) } cred.AccountID = accountID creds = append(creds, cred) } return ChangeCommit{ Message: msg, ChangeHash: changeHash, Credentials: creds, }, nil } func (r *Repo) assertAccessControls( accessCtls []accessctl.BranchAccessControl, creds []sigcred.Credential, branch plumbing.ReferenceName, 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 } matchRes, err := accessctl.Match(accessCtls, accessctl.MatchInteractions{ Branch: branch.Short(), FilePathsChanged: pathsChanged, }) if err != nil { return fmt.Errorf("could not determine applicable access controls: %w", err) } for _, matchedAC := range matchRes.ChangeAccessControls { ac := matchedAC.ChangeAccessControl condInt, err := ac.Condition.Interface() if err != nil { return fmt.Errorf("could not cast Condition of file path pattern %q to interface: %w", ac.FilePathPattern, err) } else if err := condInt.Satisfied(creds); err != nil { return fmt.Errorf("access control of file path pattern %q not satisfied: %w\nFiles matched:\n%s", ac.FilePathPattern, err, strings.Join(matchedAC.FilePaths, "\n")) } } return nil } // VerifyChangeCommit verifies that the change commit at the given hash, which // is presumably on the given branch, is gucci. func (r *Repo) VerifyChangeCommit(branch plumbing.ReferenceName, 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 changeCommit ChangeCommit if err := changeCommit.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, changeCommit.Credentials, branch, parentTree, commitTree, ) if err != nil { return fmt.Errorf("failed to satisfy all access controls: %w", err) } expectedChangeHash := genChangeHash(nil, changeCommit.Message, parentTree, commitTree) if !bytes.Equal(changeCommit.ChangeHash, expectedChangeHash) { return fmt.Errorf("malformed change_hash in commit body, is %s but should be %s", base64.StdEncoding.EncodeToString(expectedChangeHash), base64.StdEncoding.EncodeToString(changeCommit.ChangeHash)) } for _, cred := range changeCommit.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 }