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" ) // TrunkCommit describes the structure of the object encoded into the git // message of a commit in the repo trunk. type TrunkCommit struct { Message string `yaml:"message"` ChangeHash yamlutil.Blob `yaml:"change_hash"` Credentials []sigcred.Credential `yaml:"credentials"` } type tcYAML struct { Val TrunkCommit `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 TrunkCommit object takes in the git commit message. func (tc TrunkCommit) MarshalText() ([]byte, error) { trunkCommitEncoded, err := yaml.Marshal(tcYAML{tc}) if err != nil { return nil, fmt.Errorf("failed to encode TrunkCommit message: %w", err) } fullMsg := msgHead(tc.Message) + "\n\n" + string(trunkCommitEncoded) return []byte(fullMsg), nil } // UnmarshalText implements the encoding.TextUnmarshaler interface by decoding a // TrunkCommit object which has been encoded into a git commit message. func (tc *TrunkCommit) 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 tcy tcYAML if err := yaml.Unmarshal(msg, &tcy); err != nil { return fmt.Errorf("could not unmarshal TrunkCommit message: %w", err) } *tc = tcy.Val if !strings.HasPrefix(tc.Message, string(msgHead)) { return errors.New("encoded TrunkCommit is malformed, it might not be an encoded TrunkCommit") } 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(), }, }) } // NewTrunkCommit constructs a TrunkCommit using the given SignifierInterface to // create a Credential for it. func (r *Repo) NewTrunkCommit(msg, accountID string, sig sigcred.SignifierInterface) (TrunkCommit, error) { _, headTree, err := r.head() if errors.Is(err, plumbing.ErrReferenceNotFound) { headTree = &object.Tree{} } else if err != nil { return TrunkCommit{}, err } _, stagedTree, err := fs.FromStagedChangesTree(r.GitRepo) if err != nil { return TrunkCommit{}, 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 TrunkCommit{}, err } cfg, err := r.loadConfig(sigFS) if err != nil { return TrunkCommit{}, fmt.Errorf("could not load config: %w", err) } changeHash := genChangeHash(nil, msg, headTree, stagedTree) cred, err := sig.Sign(sigFS, changeHash) if err != nil { return TrunkCommit{}, 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 TrunkCommit{}, fmt.Errorf("commit would not satisfy access controls: %w", err) } return TrunkCommit{ Message: msg, ChangeHash: changeHash, Credentials: []sigcred.Credential{cred}, }, 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 } // VerifyTrunkCommit verifies that the commit at the given hash, which is // presumably on the repo trunk, is gucci. func (r *Repo) VerifyTrunkCommit(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 trunkCommit TrunkCommit if err := trunkCommit.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, trunkCommit.Credentials, parentTree, commitTree, ) if err != nil { return fmt.Errorf("failed to satisfy all access controls: %w", err) } expectedChangeHash := genChangeHash(nil, trunkCommit.Message, parentTree, commitTree) if !bytes.Equal(trunkCommit.ChangeHash, expectedChangeHash) { return fmt.Errorf("malformed change_hash in commit body, is %s but should be %s", base64.StdEncoding.EncodeToString(expectedChangeHash), base64.StdEncoding.EncodeToString(trunkCommit.ChangeHash)) } for _, cred := range trunkCommit.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 }