force all commits to be ancestors of the root of main

---
type: change
message: |-
  force all commits to be ancestors of the root of main

  Without this commit it would be possible to just start branches willy-nilly on
  any dehub-remote, each with an independent config. Despite this obvious benefit,
  there's two significant problems which will need to be solved at a later date:

  - If an account is removed from the config, then that account will still be able
    to create and push branches based off commits older than that config change.
    This can be solved by adding a "checkpoint" flag to change commits, making
    them the new root. This would also enable hard-forks.

  - Currently there's no way to specify a custom branch name in lieu of "main". I
    don't know where this configuration would even go, so I don't know how to
    solve it atm.
change_hash: ANc9dlX1IhMQcuSAAnsAoVWBg5v02mPwcoPn9LBOBAbL
credentials:
- type: pgp_signature
  pub_key_id: 95C46FA6A41148AC
  body: iQIzBAABAgAdFiEEJ6tQKp6olvZKJ0lwlcRvpqQRSKwFAl6KI08ACgkQlcRvpqQRSKwrBRAAt8u95nX/BYjgV4yFv/GQ69miWMidFqR84xAk4ZexiUct50Adf4O8fLpf0iIN1TWNItz3d3CM28h+N5+6ewbaM+zMhmiZ13xz4U/vxKyQ2f3LRpj5x8QdEjPEPzKXEIha+6TzBwYA3ijnRgksUKuDEB0tfgA0YDHeWxaVFSgi/F3Uupu1qphiKqytRI5XGimsVoMgI2CGk3RyzchYzLPJTPeHPocSPGT+FmSNk/hh58DGZ2oIfB4bV93xcW87O1RnwAlTNINp1Opsiq2vA7qKMTbqgDl47XaAgctmbpvVfPSbUhQn3MPkV62YvyA9Ft83ls80GNf4RoYQk5szBpTkCsoEaYGZ9xxTuxH6tuAviBR8aj7UZ5SA7FrxQLTe273971nIK1Ei8P6ms4s4Air0eN34GG7yVdhv692xfGDe2E0Td/PnsHx4el8CbK+pJ6O05o7uSU+855IlNPn8gaka1govfwKdn+AsDxKkk4jIakiYB03L3hDr/pGbu1Mm9hNPMDOegKwMfEmuEaQs6tGhYmkIVcFLxOxV/20JvwGWkmPUPFpNCfS9QfVfqhRS5hjJDQvaDDBtk5e8afEUahTSaqx1pqCMU1joFZT7HSrhsldnTiKBIjobrnuAHPi2Lp8EdoSN+Lgp5jqC2wPEpU72Sh9grINrcr8JE9mXDMuFygs=
  account: mediocregopher
This commit is contained in:
mediocregopher 2020-04-05 12:28:32 -06:00
parent 54af1ee510
commit ca4887bf07
4 changed files with 142 additions and 87 deletions

View File

@ -9,7 +9,6 @@ set, only a sequence of milestones and the requirements to hit them.
Must be able to feel good about showing the project publicly, as well as be able
to accept help from people asking to help.
* Restrict new branches so that they must be ancestors of main.
* Fast-forward perms on branches (so they can be deleted)
* Ammending commits.
* Figure out commit range syntax, use that everywhere.
@ -33,6 +32,16 @@ to accept help from people asking to help.
* Add dehub version to the SPEC, make binary aware of it
* Figure out a release system?
## Milestone: Checkpoints
* Ability to set change commits as being a "checkpoint", so that they mark a new
root commit. A couple of considerations:
- Only a checkpoint on the main branch should be considered when determining
the project "root".
- Must be a flag on change commits, to allow hard-forks of projects where
the config file is completely replaced.
- Not sure if it should be subject to ACL or not.
## Milestone: Minimal plugin support
* SPEC and implement. Things which should be pluggable, initially:

157
commit.go
View File

@ -2,10 +2,6 @@ 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"
@ -14,6 +10,11 @@ import (
"strings"
"time"
"dehub.dev/src/dehub.git/accessctl"
"dehub.dev/src/dehub.git/fs"
"dehub.dev/src/dehub.git/sigcred"
"dehub.dev/src/dehub.git/typeobj"
"gopkg.in/src-d/go-git.v4"
"gopkg.in/src-d/go-git.v4/plumbing"
"gopkg.in/src-d/go-git.v4/plumbing/object"
@ -288,74 +289,49 @@ func (r *Repo) HasStagedChanges() (bool, error) {
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 {
// First determine the root of the main branch. All commits need to be an
// ancestor of it.
var root plumbing.Hash
mainGitCommit, err := r.GetGitRevision(plumbing.Revision(MainRefName))
if err != nil {
return fmt.Errorf("retrieving commit at HEAD of main: %w", err)
}
rootCommit := mainGitCommit.GitCommit
for {
if rootCommit.NumParents() == 0 {
break
} else if rootCommit.NumParents() > 1 {
return fmt.Errorf("commit %q in main branch has more than one parent", root)
} else if rootCommit, err = rootCommit.Parent(0); err != nil {
return fmt.Errorf("retrieving parent commit of %q: %w", root, err)
}
}
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.
// one, and not have to determine that each commit is an ancestor of
// main manually.
var parentTree *object.Tree
if i > 0 && gitCommits[i-1].GitCommit.Hash == gitCommit.GitCommit.ParentHashes[0] {
parentTree = gitCommits[i-1].GitTree
} else if gitCommit.GitCommit.Hash == rootCommit.Hash {
// looking at the root commit itself, assume it's ok
} else if isAncestor, err := rootCommit.IsAncestor(gitCommit.GitCommit); err != nil {
return fmt.Errorf("determining if %q is an ancestor of %q (root of main): %w",
gitCommit.GitCommit.Hash, rootCommit.Hash, err)
} else if !isAncestor {
return fmt.Errorf("%q is not an ancestor of %q (root of main)",
gitCommit.GitCommit.Hash, rootCommit.Hash)
}
if err := r.verifyCommit(branch, gitCommit, parentTree); err != nil {
@ -394,17 +370,11 @@ func (r *Repo) verifyCommit(branch plumbing.ReferenceName, gitCommit GitCommit,
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)
sigFS = fs.FromTree(gitCommit.GitTree)
} else {
sigFS = fs.FromTree(vctx.parentTree)
sigFS = fs.FromTree(parentTree)
}
cfg, err := r.loadConfig(sigFS)
@ -413,26 +383,53 @@ func (r *Repo) verifyCommit(branch plumbing.ReferenceName, gitCommit GitCommit,
gitCommit.GitCommit.ParentHashes[0], err)
}
err = r.assertAccessControls(cfg.AccessControls, gitCommit.Commit, vctx, branch)
// assert access controls
filesChanged, err := calcDiff(parentTree, gitCommit.GitTree)
if err != nil {
return fmt.Errorf("enforcing access controls: %w", err)
return fmt.Errorf("calculating diff from tree %q to tree %q: %w",
parentTree.Hash, gitCommit.GitTree.Hash, err)
} else if len(filesChanged) > 0 && gitCommit.Commit.Change == nil {
return errors.New("files changes but commit is not a change commit")
}
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))
pathsChanged := make([]string, len(filesChanged))
for i := range filesChanged {
pathsChanged[i] = filesChanged[i].path
}
commitType, err := gitCommit.Commit.Type()
if err != nil {
return fmt.Errorf("determining type of commit %+v: %w", gitCommit.Commit, err)
}
err = accessctl.AssertCanCommit(cfg.AccessControls, accessctl.CommitRequest{
Type: commitType,
Branch: branch.Short(),
Credentials: gitCommit.Commit.Common.Credentials,
FilesChanged: pathsChanged,
})
if err != nil {
return fmt.Errorf("asserting access controls: %w", err)
}
// ensure the hash is what it's expected to be
commitHash := gitCommit.Interface.GetHash()
expectedCommitHash, err := gitCommit.Interface.Hash(parentTree, gitCommit.GitTree)
if err != nil {
return fmt.Errorf("calculating expected commit hash: %w", err)
} else if !bytes.Equal(commitHash, expectedCommitHash) {
return fmt.Errorf("unexpected hash in commit body, is %s but should be %s",
base64.StdEncoding.EncodeToString(expectedCommitHash),
base64.StdEncoding.EncodeToString(commitHash))
}
// verify all credentials
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 {
} else if err := sig.Verify(sigFS, expectedCommitHash, cred); err != nil {
return fmt.Errorf("verifying credential %+v: %w", cred, err)
}
}

View File

@ -1,8 +1,11 @@
package dehub
import (
"dehub.dev/src/dehub.git/sigcred"
"testing"
"dehub.dev/src/dehub.git/sigcred"
"gopkg.in/src-d/go-git.v4/plumbing"
)
func TestConfigChange(t *testing.T) {
@ -46,3 +49,38 @@ func TestConfigChange(t *testing.T) {
t.Fatal(err)
}
}
func TestMainAncestryRequirement(t *testing.T) {
otherBranch := plumbing.NewBranchReferenceName("other")
t.Run("empty repo", func(t *testing.T) {
h := newHarness(t)
h.checkout(otherBranch)
// stage and try to add to the "other" branch, it shouldn't work though
h.stageCfg()
badCommit, err := h.repo.NewCommitChange("starting new branch at other")
if err != nil {
t.Fatalf("creating CommitChange: %v", err)
}
h.tryCommit(false, badCommit, h.cfg.Accounts[0].ID, h.sig)
})
t.Run("new branch, single commit", func(t *testing.T) {
h := newHarness(t)
h.stageCfg()
h.changeCommit("add cfg", h.cfg.Accounts[0].ID, h.sig)
// set HEAD to this other branch which doesn't really exist
ref := plumbing.NewSymbolicReference(plumbing.HEAD, otherBranch)
if err := h.repo.GitRepo.Storer.SetReference(ref); err != nil {
h.t.Fatal(err)
}
h.stageCfg()
badCommit, err := h.repo.NewCommitChange("starting new branch at other")
if err != nil {
t.Fatalf("creating CommitChange: %v", err)
}
h.tryCommit(false, badCommit, h.cfg.Accounts[0].ID, h.sig)
})
}

View File

@ -2,7 +2,6 @@ package dehub
import (
"bytes"
"dehub.dev/src/dehub.git/sigcred"
"errors"
"io"
"math/rand"
@ -10,6 +9,8 @@ import (
"runtime/debug"
"testing"
"dehub.dev/src/dehub.git/sigcred"
"gopkg.in/src-d/go-git.v4"
"gopkg.in/src-d/go-git.v4/plumbing"
yaml "gopkg.in/yaml.v2"
@ -100,13 +101,22 @@ func (h *harness) stageCfg() {
}
func (h *harness) checkout(branch plumbing.ReferenceName) {
w, err := h.repo.GitRepo.Worktree()
if err != nil {
h.t.Fatal(err)
}
head, err := h.repo.GetGitHead()
if err != nil {
if errors.Is(err, ErrHeadIsZero) {
// if HEAD is resolvable to any hash than the Checkout method doesn't
// work, just set HEAD manually.
ref := plumbing.NewSymbolicReference(plumbing.HEAD, branch)
if err := h.repo.GitRepo.Storer.SetReference(ref); err != nil {
h.t.Fatal(err)
}
return
} else if err != nil {
h.t.Fatal(err)
}
@ -176,11 +186,12 @@ func (h *harness) tryCommit(
h.t.Fatalf("verifying commit %q should have failed", gitCommit.GitCommit.Hash)
}
if gitCommit.GitCommit.NumParents() == 0 {
h.t.Fatalf("unverifiable commit %q has no parents, but it should", gitCommit.GitCommit.Hash)
var parentHash plumbing.Hash
if gitCommit.GitCommit.NumParents() > 0 {
parentHash = gitCommit.GitCommit.ParentHashes[0]
}
h.reset(gitCommit.GitCommit.ParentHashes[0], git.HardReset)
h.reset(parentHash, git.HardReset)
return gitCommit
}