From ca4887bf07505a91ecf339cc8aecc450dd15a3ad Mon Sep 17 00:00:00 2001 From: mediocregopher <> Date: Sun, 5 Apr 2020 12:28:32 -0600 Subject: [PATCH] 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 --- ROADMAP.md | 11 +++- commit.go | 151 ++++++++++++++++++++++++------------------------- commit_test.go | 40 ++++++++++++- repo_test.go | 21 +++++-- 4 files changed, 139 insertions(+), 84 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 2859ea4..0d4877a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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: diff --git a/commit.go b/commit.go index 80cbda6..238d20f 100644 --- a/commit.go +++ b/commit.go @@ -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 -} +// 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 { -func (r *Repo) assertAccessControls( - acl []accessctl.AccessControl, - commit Commit, vctx verificationCtx, branch plumbing.ReferenceName, -) (err error) { - filesChanged, err := calcDiff(vctx.parentTree, vctx.commitTree) + // 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("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 + return fmt.Errorf("retrieving commit at HEAD of main: %w", err) } - commitType, err := commit.Type() - if err != nil { - return fmt.Errorf("determining type of commit %+v: %w", commit, 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) + } } - 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 { - 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("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") + } + + 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("enforcing access controls: %w", err) + return fmt.Errorf("asserting access controls: %w", err) } - changeHash := gitCommit.Interface.GetHash() - expectedChangeHash, err := gitCommit.Interface.Hash(vctx.parentTree, vctx.commitTree) + // 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 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)) + 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) } } diff --git a/commit_test.go b/commit_test.go index 46b6a1a..9e3b5b5 100644 --- a/commit_test.go +++ b/commit_test.go @@ -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) + }) +} diff --git a/repo_test.go b/repo_test.go index 19775ee..55b49d1 100644 --- a/repo_test.go +++ b/repo_test.go @@ -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 }