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 }