Completely refactor naming of everything, in light of new SPEC

---
type: change
description: |-
  Completely refactor naming of everything, in light of new SPEC

  Writing the SPEC shed some light on just how weakly a lot of concepts, like
  "commit", had been defined, and prompted the delineation of a lot of things
  along specific lines (commit vs payload, repo vs project). This commit makes the
  code reflect the SPEC much better in quite a few ways:

  * Repo is now Project
  * Commit is now Payload
  * GitCommit is now just Commit
  * Hash is now Fingerprint
  * A lot of minor fields got renamed
  * All the XXXInterface types are now just XXX, and their old XXX type is now
    XXXUnion.

  More than likely there's still some comments and variable names that have
  slipped passed, but overall I feel like I got most of the changes.
fingerprint: AKkDC5BKhKbfXzZQ/F4KquHeMgVvcNxgLmkZFz/nP/tY
credentials:
- type: pgp_signature
  pub_key_id: 95C46FA6A41148AC
  body: iQIzBAABAgAdFiEEJ6tQKp6olvZKJ0lwlcRvpqQRSKwFAl6l7aYACgkQlcRvpqQRSKxFrA//VQ+f8B6pwGS3ORB4VVBnHvvJTGZvAYTvB0fHuHJx2EreR4FwjhaNakk5ClkwbO7WFMq++2OV4xIkvzwswLdbXZF0IHx3wScQM59v4vIkR4V9Lj5p1aGGhQna52uIKugF2gTqKdU4tqYzmBjDND/c2XDwCN5CwTwwnAHXUSSsHxviiPUYPWV5wzFP7uyRW0ZeK8Isv7QECKRXlsDjcSJa+g+jc091FG/jG9Dkai8fbDbW8YXj7W3ALaXgXWEBJMrgQxZcJJRjgCvLY72FIIrUBquu3FepiyzMtZ0yaIvi4NmGCsYqIv00NcMvMtD7iwhOCZn10Sku4wvaKJ8YBMRduhqC99fnr/ZDW0/HvTNcL7GKx11GjwtmzkJgwsHFPy3zX+kMdF4m3WgtoeI0GwEsBXXZE2C49yAk3Mb/3puegl3a1PPMvOabTzo7Xm6xpWkI6gISChI7My71H3EuKZWhkb+IubPmMvJJXIdVxHnsHPz2dl/BZXLgpfVdEgQa2qWeXtYI4NNm37pLl3gv92V4kka+Kr4gfdoq8mJ7aqvc9was35baJbHg4+fEVJG2Wj+2AQU+ncx3nAFzgYyMxwo9K8VuC4QdfRF4ImyxTnWkuokEn9H6JRrbkBDKIELj6vzdPmsjOUEQ4nsYX66/zSibFD7UvhQmdXFs8Gp8/Qq6g4M=
  account: mediocregopher
main
mediocregopher 4 years ago committed by Brian Picciano
parent 351048e9aa
commit b01fe1524a
  1. 31
      ROADMAP.md
  2. 23
      accessctl/access_control.go
  3. 47
      accessctl/access_control_test.go
  4. 57
      accessctl/filter.go
  5. 11
      accessctl/filter_logical.go
  6. 8
      accessctl/filter_logical_test.go
  7. 4
      accessctl/filter_pattern.go
  8. 2
      accessctl/filter_sig.go
  9. 4
      accessctl/filter_sig_test.go
  10. 18
      accessctl/filter_test.go
  11. 93
      cmd/dehub/cmd_commit.go
  12. 8
      cmd/dehub/cmd_hook.go
  13. 12
      cmd/dehub/cmd_misc.go
  14. 32
      cmd/dehub/cmd_util.go
  15. 17
      cmd/dehub/cmd_verify.go
  16. 2
      cmd/dehub/main.go
  17. 660
      commit.go
  18. 156
      commit_change.go
  19. 49
      commit_comment.go
  20. 81
      commit_credential.go
  21. 27
      config.go
  22. 4
      fingerprint.go
  23. 8
      fingerprint_test.go
  24. 604
      payload.go
  25. 171
      payload_change.go
  26. 73
      payload_change_test.go
  27. 43
      payload_comment.go
  28. 73
      payload_credential.go
  29. 12
      payload_credential_test.go
  30. 114
      payload_test.go
  31. 326
      project.go
  32. 105
      project_test.go
  33. 544
      repo.go
  34. 39
      sigcred/credential.go
  35. 6
      sigcred/credential_test.go
  36. 48
      sigcred/pgp.go
  37. 6
      sigcred/pgp_test.go
  38. 91
      sigcred/signifier.go
  39. 4
      yamlutil/yamlutil.go

@ -20,18 +20,18 @@ to accept help from people asking to help.
## Milestone: Versions
* Tag commits
* Add dehub version to the SPEC, make binary aware of it
* Add dehub version to payloads, make binary aware of it
* Figure out a release system?
## Milestone: Checkpoints
## Milestone: Prime commits
* 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.
(Cloning/remote management is probably a pre-requisite of this, so it's a good
thing it comes after IPFS support)
* Ability to specify which commit is prime.
* The prime commit is essentially the identifier of the entire project; even
if two project instances share a commit tree, if they are using a
different prime commit then they are not the same project.
## Milestone: Minimal plugin support
@ -39,7 +39,7 @@ to accept help from people asking to help.
* Conditions
* Signifiers
* Filters
* Commits???
* Payloads???
## Milestone: Minimal notifications support
@ -63,14 +63,6 @@ are things that could use doing anyway.
* Maybe coalesce the `accessctl`, `fs`, and `sigcred` packages back into the
root "dehub" package.
* Polish all error messages. A good error message has the following qualities:
* If wrapping an error which was returned from a sub-call:
* Uses `fmt.Errorf` with the `%w` format directive at the end.
* Phrased as if the sentence starts with the word "while", e.g. "opening
file: %w".
* Only includes information the caller of that function/method couldn't
already know.
* Polish commands
* New flag system, some kind of interactivity support (e.g. user doesn't
specify required argument, give them a prompt on the CLI to input it
@ -86,6 +78,3 @@ are things that could use doing anyway.
* Possibly save state locally in order to speed things along, such as
"account id" which probably isn't going to change often for a user.
* More/better tests
* Commits need much better test coverage.

@ -37,8 +37,8 @@ var DefaultAccessControlsStr = `
filters:
- type: branch
pattern: main
- type: commit_type
commit_type: change
- type: payload_type
payload_type: change
- type: signature
any_account: true
count: 1
@ -66,8 +66,8 @@ type CommitRequest struct {
// It is required.
Branch string
// Credentials are the Credential objects attached to the commit.
Credentials []sigcred.Credential
// Credentials are the credentials attached to the commit.
Credentials []sigcred.CredentialUnion
// FilesChanged is the set of file paths (relative to the repo root) which
// have been modified in some way.
@ -98,26 +98,19 @@ const (
// taken on a CommitRequest if those Filters all match on the CommitRequest.
type AccessControl struct {
Action Action `yaml:"action"`
Filters []Filter `yaml:"filters"`
Filters []FilterUnion `yaml:"filters"`
}
// ActionForCommit returns what Action this AccessControl says to take for a
// given CommitRequest. It may return ActionNext if the request is not matched
// by the AccessControl's Filters.
func (ac AccessControl) ActionForCommit(req CommitRequest) (Action, error) {
for _, filter := range ac.Filters {
filterI, err := filter.Interface()
if err != nil {
return "", fmt.Errorf("casting %+v to a FilterInterface: %w", filter, err)
} else if err := filterI.MatchCommit(req); errors.As(err, new(ErrFilterNoMatch)) {
for _, filterUn := range ac.Filters {
if err := filterUn.Filter().MatchCommit(req); errors.As(err, new(ErrFilterNoMatch)) {
return ActionNext, nil
} else if err != nil {
// ignore the error here, if we could get the FilterInterface then
// we should be able to get the type.
filterTypeStr, _ := filter.Type()
return "", fmt.Errorf("matching commit using filter of type %q: %w", filterTypeStr, err)
return "", fmt.Errorf("matching commit using filter of type %q: %w", filterUn.Type(), err)
}
}
return ac.Action, nil

@ -1,9 +1,10 @@
package accessctl
import (
"dehub.dev/src/dehub.git/sigcred"
"errors"
"testing"
"dehub.dev/src/dehub.git/sigcred"
)
func TestAssertCanCommit(t *testing.T) {
@ -18,14 +19,14 @@ func TestAssertCanCommit(t *testing.T) {
acl: []AccessControl{
{
Action: ActionAllow,
Filters: []Filter{{
CommitType: &FilterCommitType{Type: "foo"},
Filters: []FilterUnion{{
PayloadType: &FilterPayloadType{Type: "foo"},
}},
},
{
Action: ActionDeny,
Filters: []Filter{{
CommitType: &FilterCommitType{Type: "foo"},
Filters: []FilterUnion{{
PayloadType: &FilterPayloadType{Type: "foo"},
}},
},
},
@ -37,14 +38,14 @@ func TestAssertCanCommit(t *testing.T) {
acl: []AccessControl{
{
Action: ActionDeny,
Filters: []Filter{{
CommitType: &FilterCommitType{Type: "foo"},
Filters: []FilterUnion{{
PayloadType: &FilterPayloadType{Type: "foo"},
}},
},
{
Action: ActionAllow,
Filters: []Filter{{
CommitType: &FilterCommitType{Type: "foo"},
Filters: []FilterUnion{{
PayloadType: &FilterPayloadType{Type: "foo"},
}},
},
},
@ -56,14 +57,14 @@ func TestAssertCanCommit(t *testing.T) {
acl: []AccessControl{
{
Action: ActionDeny,
Filters: []Filter{{
CommitType: &FilterCommitType{Type: "bar"},
Filters: []FilterUnion{{
PayloadType: &FilterPayloadType{Type: "bar"},
}},
},
{
Action: ActionAllow,
Filters: []Filter{{
CommitType: &FilterCommitType{Type: "foo"},
Filters: []FilterUnion{{
PayloadType: &FilterPayloadType{Type: "foo"},
}},
},
},
@ -75,14 +76,14 @@ func TestAssertCanCommit(t *testing.T) {
acl: []AccessControl{
{
Action: ActionDeny,
Filters: []Filter{{
CommitType: &FilterCommitType{Type: "bar"},
Filters: []FilterUnion{{
PayloadType: &FilterPayloadType{Type: "bar"},
}},
},
{
Action: ActionDeny,
Filters: []Filter{{
CommitType: &FilterCommitType{Type: "foo"},
Filters: []FilterUnion{{
PayloadType: &FilterPayloadType{Type: "foo"},
}},
},
},
@ -94,15 +95,15 @@ func TestAssertCanCommit(t *testing.T) {
acl: []AccessControl{
{
Action: ActionDeny,
Filters: []Filter{{
CommitType: &FilterCommitType{Type: "bar"},
Filters: []FilterUnion{{
PayloadType: &FilterPayloadType{Type: "bar"},
}},
},
},
req: CommitRequest{
Branch: "not_main",
Type: "foo",
Credentials: []sigcred.Credential{{
Credentials: []sigcred.CredentialUnion{{
PGPSignature: new(sigcred.CredentialPGPSignature),
AccountID: "a",
}},
@ -114,15 +115,15 @@ func TestAssertCanCommit(t *testing.T) {
acl: []AccessControl{
{
Action: ActionDeny,
Filters: []Filter{{
CommitType: &FilterCommitType{Type: "bar"},
Filters: []FilterUnion{{
PayloadType: &FilterPayloadType{Type: "bar"},
}},
},
},
req: CommitRequest{
Branch: "main",
Type: "foo",
Credentials: []sigcred.Credential{{
Credentials: []sigcred.CredentialUnion{{
PGPSignature: new(sigcred.CredentialPGPSignature),
AccountID: "a",
}},

@ -18,70 +18,73 @@ func (err ErrFilterNoMatch) Error() string {
return fmt.Sprintf("matching with filter: %s", err.Err.Error())
}
// FilterInterface describes the methods that all Filters must implement.
type FilterInterface interface {
// Filter describes the methods that all Filters must implement.
type Filter interface {
// MatchCommit returns nil if the CommitRequest is matched by the filter,
// otherwise it returns an error (ErrFilterNoMatch if the error is due to
// the CommitRequest).
MatchCommit(CommitRequest) error
}
// Filter represents an access control filter being defined in the Config. Only
// one of its fields may be filled at a time.
type Filter struct {
// FilterUnion represents an access control filter being defined in the Config.
// Only one of its fields may be filled at a time.
type FilterUnion struct {
Signature *FilterSignature `type:"signature"`
Branch *FilterBranch `type:"branch"`
FilesChanged *FilterFilesChanged `type:"files_changed"`
CommitType *FilterCommitType `type:"commit_type"`
PayloadType *FilterPayloadType `type:"payload_type"`
CommitAttributes *FilterCommitAttributes `type:"commit_attributes"`
Not *FilterNot `type:"not"`
}
// MarshalYAML implements the yaml.Marshaler interface.
func (f Filter) MarshalYAML() (interface{}, error) {
func (f FilterUnion) MarshalYAML() (interface{}, error) {
return typeobj.MarshalYAML(f)
}
// UnmarshalYAML implements the yaml.Unmarshaler interface.
func (f *Filter) UnmarshalYAML(unmarshal func(interface{}) error) error {
func (f *FilterUnion) UnmarshalYAML(unmarshal func(interface{}) error) error {
return typeobj.UnmarshalYAML(f, unmarshal)
}
// Interface returns the FilterInterface encapsulated by this Filter.
func (f Filter) Interface() (FilterInterface, error) {
// Filter returns the Filter encapsulated by this FilterUnion.
//
// This method will panic if a Filter field is not populated.
func (f FilterUnion) Filter() Filter {
el, _, err := typeobj.Element(f)
if err != nil {
return nil, err
panic(err)
}
return el.(FilterInterface), nil
return el.(Filter)
}
// Type returns a string describing what type of Filter this object
// encapsulates, based on which of its fields are filled in.
func (f Filter) Type() (string, error) {
// Type returns the Filter's type (as would be used in its YAML "type" field).
//
// This will panic if a Filter field is not populated.
func (f FilterUnion) Type() string {
_, typeStr, err := typeobj.Element(f)
if err != nil {
return "", err
panic(err)
}
return typeStr, nil
return typeStr
}
// FilterCommitType filters by what type of commit is being requested. Exactly
// FilterPayloadType filters by what type of payload is being requested. Exactly
// one of its fields should be filled.
type FilterCommitType struct {
Type string `yaml:"commit_type"`
Types []string `yaml:"commit_types"`
type FilterPayloadType struct {
Type string `yaml:"payload_type"`
Types []string `yaml:"payload_types"`
}
var _ FilterInterface = FilterCommitType{}
var _ Filter = FilterPayloadType{}
// MatchCommit implements the method for FilterInterface.
func (f FilterCommitType) MatchCommit(req CommitRequest) error {
func (f FilterPayloadType) MatchCommit(req CommitRequest) error {
switch {
case f.Type != "":
if f.Type != req.Type {
return ErrFilterNoMatch{
Err: fmt.Errorf("commit type %q does not match filter's type %q",
Err: fmt.Errorf("payload type %q does not match filter's type %q",
req.Type, f.Type),
}
}
@ -94,12 +97,12 @@ func (f FilterCommitType) MatchCommit(req CommitRequest) error {
}
}
return ErrFilterNoMatch{
Err: fmt.Errorf("commit type %q does not match any of filter's types %+v",
Err: fmt.Errorf("payload type %q does not match any of filter's types %+v",
req.Type, f.Types),
}
default:
return errors.New(`one of the following fields must be set: "commit_type", "commit_types"`)
return errors.New(`one of the following fields must be set: "payload_type", "payload_types"`)
}
}
@ -110,7 +113,7 @@ type FilterCommitAttributes struct {
NonFastForward bool `yaml:"non_fast_forward"`
}
var _ FilterInterface = FilterCommitAttributes{}
var _ Filter = FilterCommitAttributes{}
// MatchCommit implements the method for FilterInterface.
func (f FilterCommitAttributes) MatchCommit(req CommitRequest) error {

@ -2,24 +2,19 @@ package accessctl
import (
"errors"
"fmt"
)
// FilterNot wraps another Filter. If that filter matches, FilterNot does not
// match, and vice-versa.
type FilterNot struct {
Filter Filter `yaml:"filter"`
Filter FilterUnion `yaml:"filter"`
}
var _ FilterInterface = FilterNot{}
var _ Filter = FilterNot{}
// MatchCommit implements the method for FilterInterface.
func (f FilterNot) MatchCommit(req CommitRequest) error {
fI, err := f.Filter.Interface()
if err != nil {
return fmt.Errorf("casting %+v to a FilterInterface: %w", f.Filter, err)
} else if err := fI.MatchCommit(req); errors.As(err, new(ErrFilterNoMatch)) {
if err := f.Filter.Filter().MatchCommit(req); errors.As(err, new(ErrFilterNoMatch)) {
return nil
} else if err != nil {
return err

@ -7,8 +7,8 @@ func TestFilterNot(t *testing.T) {
{
descr: "sub-filter does match",
filter: FilterNot{
Filter: Filter{
CommitType: &FilterCommitType{Type: "foo"},
Filter: FilterUnion{
PayloadType: &FilterPayloadType{Type: "foo"},
},
},
req: CommitRequest{
@ -19,8 +19,8 @@ func TestFilterNot(t *testing.T) {
{
descr: "sub-filter does not match",
filter: FilterNot{
Filter: Filter{
CommitType: &FilterCommitType{Type: "foo"},
Filter: FilterUnion{
PayloadType: &FilterPayloadType{Type: "foo"},
},
},
req: CommitRequest{

@ -65,7 +65,7 @@ type FilterBranch struct {
StringMatcher StringMatcher `yaml:",inline"`
}
var _ FilterInterface = FilterBranch{}
var _ Filter = FilterBranch{}
// MatchCommit implements the method for FilterInterface.
func (f FilterBranch) MatchCommit(req CommitRequest) error {
@ -79,7 +79,7 @@ type FilterFilesChanged struct {
StringMatcher StringMatcher `yaml:",inline"`
}
var _ FilterInterface = FilterFilesChanged{}
var _ Filter = FilterFilesChanged{}
// MatchCommit implements the method for FilterInterface.
func (f FilterFilesChanged) MatchCommit(req CommitRequest) error {

@ -20,7 +20,7 @@ type FilterSignature struct {
Count string `yaml:"count,omitempty"`
}
var _ FilterInterface = FilterSignature{}
var _ Filter = FilterSignature{}
func (f FilterSignature) targetNum() (int, error) {
if f.Count == "" {

@ -8,7 +8,7 @@ import (
func TestFilterSignature(t *testing.T) {
mkReq := func(accountIDs ...string) CommitRequest {
creds := make([]sigcred.Credential, len(accountIDs))
creds := make([]sigcred.CredentialUnion, len(accountIDs))
for i := range accountIDs {
creds[i].PGPSignature = new(sigcred.CredentialPGPSignature)
creds[i].AccountID = accountIDs[i]
@ -106,7 +106,7 @@ func TestFilterSignature(t *testing.T) {
Any: true,
},
req: CommitRequest{
Credentials: []sigcred.Credential{
Credentials: []sigcred.CredentialUnion{
{PGPSignature: new(sigcred.CredentialPGPSignature)},
},
},

@ -8,7 +8,7 @@ import (
type filterCommitMatchTest struct {
descr string
filter FilterInterface
filter Filter
req CommitRequest
match bool
@ -38,7 +38,7 @@ func runCommitMatchTests(t *testing.T, tests []filterCommitMatchTest) {
}
}
func TestFilterCommitType(t *testing.T) {
func TestFilterPayloadType(t *testing.T) {
mkReq := func(commitType string) CommitRequest {
return CommitRequest{Type: commitType}
}
@ -46,7 +46,7 @@ func TestFilterCommitType(t *testing.T) {
runCommitMatchTests(t, []filterCommitMatchTest{
{
descr: "single match",
filter: FilterCommitType{
filter: FilterPayloadType{
Type: "foo",
},
req: mkReq("foo"),
@ -54,7 +54,7 @@ func TestFilterCommitType(t *testing.T) {
},
{
descr: "single no match",
filter: FilterCommitType{
filter: FilterPayloadType{
Type: "foo",
},
req: mkReq("bar"),
@ -62,7 +62,7 @@ func TestFilterCommitType(t *testing.T) {
},
{
descr: "multi match first",
filter: FilterCommitType{
filter: FilterPayloadType{
Types: []string{"foo", "bar"},
},
req: mkReq("foo"),
@ -70,7 +70,7 @@ func TestFilterCommitType(t *testing.T) {
},
{
descr: "multi match second",
filter: FilterCommitType{
filter: FilterPayloadType{
Types: []string{"foo", "bar"},
},
req: mkReq("bar"),
@ -78,7 +78,7 @@ func TestFilterCommitType(t *testing.T) {
},
{
descr: "multi no match",
filter: FilterCommitType{
filter: FilterPayloadType{
Types: []string{"foo", "bar"},
},
req: mkReq("baz"),
@ -119,7 +119,7 @@ func TestFilterCommitAttributes(t *testing.T) {
},
{
descr: "ff with inverted non-ff filter",
filter: FilterNot{Filter: Filter{
filter: FilterNot{Filter: FilterUnion{
CommitAttributes: &FilterCommitAttributes{NonFastForward: true},
}},
req: mkReq(false),
@ -127,7 +127,7 @@ func TestFilterCommitAttributes(t *testing.T) {
},
{
descr: "non-ff with inverted non-ff filter",
filter: FilterNot{Filter: Filter{
filter: FilterNot{Filter: FilterUnion{
CommitAttributes: &FilterCommitAttributes{NonFastForward: true},
}},
req: mkReq(true),

@ -17,14 +17,14 @@ func cmdCommit(ctx context.Context, cmd *dcmd.Cmd) {
accountID := flag.String("as", "", "Account to accredit commit with")
pgpKeyID := flag.String("anon-pgp-key", "", "ID of pgp key to sign with instead of using an account")
var repo repo
repo.initFlags(flag)
var proj proj
proj.initFlags(flag)
accreditAndCommit := func(commit dehub.Commit) error {
accreditAndCommit := func(payUn dehub.PayloadUnion) error {
var sigInt sigcred.SignifierInterface
var sig sigcred.Signifier
if *accountID != "" {
cfg, err := repo.LoadConfig()
cfg, err := proj.LoadConfig()
if err != nil {
return err
}
@ -43,30 +43,25 @@ func cmdCommit(ctx context.Context, cmd *dcmd.Cmd) {
return fmt.Errorf("account %q has %d signifiers, only one is supported right now", *accountID, l)
}
sig := account.Signifiers[0]
sigInt, err = sig.Interface(*accountID)
if err != nil {
return fmt.Errorf("casting %#v to SignifierInterface: %w", sig, err)
}
sig = account.Signifiers[0].Signifier(*accountID)
} else {
var err error
if sigInt, err = sigcred.LoadSignifierPGP(*pgpKeyID, true); err != nil {
if sig, err = sigcred.LoadSignifierPGP(*pgpKeyID, true); err != nil {
return fmt.Errorf("loading pgp key %q: %w", *pgpKeyID, err)
}
}
commit, err := repo.AccreditCommit(commit, sigInt)
payUn, err := proj.AccreditPayload(payUn, sig)
if err != nil {
return fmt.Errorf("accrediting commit: %w", err)
return fmt.Errorf("accrediting payload: %w", err)
}
gitCommit, err := repo.Commit(commit)
commit, err := proj.Commit(payUn)
if err != nil {
return fmt.Errorf("committing to git: %w", err)
}
fmt.Printf("committed to HEAD as %s\n", gitCommit.GitCommit.Hash)
fmt.Printf("committed to HEAD as %s\n", commit.Hash)
return nil
}
@ -76,12 +71,12 @@ func cmdCommit(ctx context.Context, cmd *dcmd.Cmd) {
return nil, errors.New("-as or -anon-pgp-key is required")
}
if err := repo.openRepo(); err != nil {
if err := proj.openProj(); err != nil {
return nil, err
}
var err error
if hasStaged, err = repo.HasStagedChanges(); err != nil {
if hasStaged, err = proj.HasStagedChanges(); err != nil {
return nil, fmt.Errorf("determining if any changes have been staged: %w", err)
}
return ctx, nil
@ -90,7 +85,7 @@ func cmdCommit(ctx context.Context, cmd *dcmd.Cmd) {
cmd.SubCmd("change", "Commit file changes",
func(ctx context.Context, cmd *dcmd.Cmd) {
flag := cmd.FlagSet()
msg := flag.String("msg", "", "Commit message")
description := flag.String("descr", "", "Description of changes")
amend := flag.Bool("amend", false, "Add changes to HEAD commit, amend its message, and re-accredit it")
cmd.Run(func() (context.Context, error) {
if !hasStaged && !*amend {
@ -99,28 +94,28 @@ func cmdCommit(ctx context.Context, cmd *dcmd.Cmd) {
var prevMsg string
if *amend {
oldHead, err := repo.softReset("change")
oldHead, err := proj.softReset("change")
if err != nil {
return nil, err
}
prevMsg = oldHead.Commit.Change.Message
prevMsg = oldHead.Payload.Change.Description
}
if *msg == "" {
if *description == "" {
var err error
if *msg, err = tmpFileMsg(defaultCommitFileMsgTpl, prevMsg); err != nil {
if *description, err = tmpFileMsg(defaultCommitFileMsgTpl, prevMsg); err != nil {
return nil, fmt.Errorf("error collecting commit message from user: %w", err)
} else if *msg == "" {
} else if *description == "" {
return nil, errors.New("empty commit message, not doing anything")
}
}
commit, err := repo.NewCommitChange(*msg)
payUn, err := proj.NewPayloadChange(*description)
if err != nil {
return nil, fmt.Errorf("could not construct change commit: %w", err)
return nil, fmt.Errorf("could not construct change payload: %w", err)
} else if err := accreditAndCommit(commit); err != nil {
} else if err := accreditAndCommit(payUn); err != nil {
return nil, err
}
return nil, nil
@ -141,31 +136,30 @@ func cmdCommit(ctx context.Context, cmd *dcmd.Cmd) {
return nil, errors.New("credential commit cannot have staged changes")
}
var credCommit dehub.Commit
var credPayUn dehub.PayloadUnion
if *rev != "" {
gitCommit, err := repo.GetGitRevision(plumbing.Revision(*rev))
commit, err := proj.GetCommitByRevision(plumbing.Revision(*rev))
if err != nil {
return nil, fmt.Errorf("resolving revision %q: %w", *rev, err)
}
gitCommits := []dehub.GitCommit{gitCommit}
if credCommit, err = repo.NewCommitCredentialFromChanges(gitCommits); err != nil {
if credPayUn, err = proj.NewPayloadCredentialFromChanges([]dehub.Commit{commit}); err != nil {
return nil, fmt.Errorf("constructing credential commit: %w", err)
}
} else {
gitCommits, err := repo.GetGitRevisionRange(
commits, err := proj.GetCommitRangeByRevision(
plumbing.Revision(*startRev),
plumbing.Revision(*endRev),
)
if err != nil {
return nil, fmt.Errorf("resolving revisions %q to %q: %w",
*startRev, *endRev, err)
} else if credCommit, err = repo.NewCommitCredentialFromChanges(gitCommits); err != nil {
} else if credPayUn, err = proj.NewPayloadCredentialFromChanges(commits); err != nil {
return nil, fmt.Errorf("constructing credential commit: %w", err)
}
}
if err := accreditAndCommit(credCommit); err != nil {
if err := accreditAndCommit(credPayUn); err != nil {
return nil, err
}
return nil, nil
@ -176,37 +170,37 @@ func cmdCommit(ctx context.Context, cmd *dcmd.Cmd) {
cmd.SubCmd("comment", "Commit a comment to a branch",
func(ctx context.Context, cmd *dcmd.Cmd) {
flag := cmd.FlagSet()
msg := flag.String("msg", "", "Comment message")
comment := flag.String("comment", "", "Comment message")
amend := flag.Bool("amend", false, "Amend the comment message currently in HEAD")
cmd.Run(func() (context.Context, error) {
if hasStaged {
return nil, errors.New("comment commit cannot have staged changes")
}
var prevMsg string
var prevComment string
if *amend {
oldHead, err := repo.softReset("comment")
oldHead, err := proj.softReset("comment")
if err != nil {
return nil, err
}
prevMsg = oldHead.Commit.Comment.Message
prevComment = oldHead.Payload.Comment.Comment
}
if *msg == "" {
if *comment == "" {
var err error
if *msg, err = tmpFileMsg(defaultCommitFileMsgTpl, prevMsg); err != nil {
if *comment, err = tmpFileMsg(defaultCommitFileMsgTpl, prevComment); err != nil {
return nil, fmt.Errorf("collecting comment message from user: %w", err)
} else if *msg == "" {
} else if *comment == "" {
return nil, errors.New("empty comment message, not doing anything")
}
}
commit, err := repo.NewCommitComment(*msg)
payUn, err := proj.NewPayloadComment(*comment)
if err != nil {
return nil, fmt.Errorf("constructing comment commit: %w", err)
}
return nil, accreditAndCommit(commit)
return nil, accreditAndCommit(payUn)
})
},
)
@ -220,8 +214,8 @@ func cmdCombine(ctx context.Context, cmd *dcmd.Cmd) {
startRev := flag.String("start", "", "Revision of the starting commit to combine")
endRev := flag.String("end", "", "Revision of the ending commit to combine")
var repo repo
repo.initFlags(flag)
var proj proj
proj.initFlags(flag)
cmd.Run(func() (context.Context, error) {
if *onto == "" ||
@ -230,11 +224,11 @@ func cmdCombine(ctx context.Context, cmd *dcmd.Cmd) {
return nil, errors.New("-onto, -start, and -end are required")
}
if err := repo.openRepo(); err != nil {
if err := proj.openProj(); err != nil {
return nil, err
}
commits, err := repo.GetGitRevisionRange(
commits, err := proj.GetCommitRangeByRevision(
plumbing.Revision(*startRev),
plumbing.Revision(*endRev),
)
@ -244,13 +238,12 @@ func cmdCombine(ctx context.Context, cmd *dcmd.Cmd) {
}
ontoBranch := plumbing.NewBranchReferenceName(*onto)
gitCommit, err := repo.CombineCommitChanges(commits, ontoBranch)
commit, err := proj.CombinePayloadChanges(commits, ontoBranch)
if err != nil {
return nil, err
}
fmt.Printf("new commit %q added to branch %q\n",
gitCommit.GitCommit.Hash, ontoBranch.Short())
fmt.Printf("new commit %q added to branch %q\n", commit.Hash, ontoBranch.Short())
return nil, nil
})
}

@ -18,15 +18,15 @@ func cmdHook(ctx context.Context, cmd *dcmd.Cmd) {
flag := cmd.FlagSet()
preRcv := flag.Bool("pre-receive", false, "Use dehub as a server-side pre-receive hook")
var repo repo
repo.initFlags(flag)
var proj proj
proj.initFlags(flag)
cmd.Run(func() (context.Context, error) {
if !*preRcv {
return nil, errors.New("must set the hook type")
}
if err := repo.openRepo(); err != nil {
if err := proj.openProj(); err != nil {
return nil, err
}
@ -54,7 +54,7 @@ func cmdHook(ctx context.Context, cmd *dcmd.Cmd) {
return nil, errors.New("deleting remote branches is not currently supported")
}
return nil, repo.VerifyCanSetBranchHEADTo(branchName, endHash)
return nil, proj.VerifyCanSetBranchHEADTo(branchName, endHash)
}
fmt.Println("All pushed commits have been verified, well done.")

@ -10,14 +10,14 @@ import (
func cmdInit(ctx context.Context, cmd *dcmd.Cmd) {
flag := cmd.FlagSet()
path := flag.String("path", ".", "Path to initialize the repo at")
bare := flag.Bool("bare", false, "Initialize the repo as a bare repository")
remote := flag.Bool("remote", false, "Configure the directory to allow it to be used as a remote endpoint")
path := flag.String("path", ".", "Path to initialize the project at")
bare := flag.Bool("bare", false, "Initialize the git repo as a bare repository")
remote := flag.Bool("remote", false, "Configure the git repo to allow it to be used as a remote endpoint")
cmd.Run(func() (context.Context, error) {
_, err := dehub.InitRepo(*path,
dehub.InitBare(*bare),
dehub.InitRemote(*remote),
_, err := dehub.InitProject(*path,
dehub.InitBareRepo(*bare),
dehub.InitRemoteRepo(*remote),
)
if err != nil {
return nil, fmt.Errorf("initializing repo at %q: %w", *path, err)

@ -10,19 +10,19 @@ import (
"gopkg.in/src-d/go-git.v4/plumbing"
)
type repo struct {
type proj struct {
bare bool
*dehub.Repo
*dehub.Project
}
func (r *repo) initFlags(flag *flag.FlagSet) {
flag.BoolVar(&r.bare, "bare", false, "If set then the repo being opened will be expected to be bare")
func (proj *proj) initFlags(flag *flag.FlagSet) {
flag.BoolVar(&proj.bare, "bare", false, "If set then the project being opened will be expected to have a bare git repo")
}
func (r *repo) openRepo() error {
func (proj *proj) openProj() error {
var err error
if r.Repo, err = dehub.OpenRepo(".", dehub.OpenBare(r.bare)); err != nil {
if proj.Project, err = dehub.OpenProject(".", dehub.OpenBareRepo(proj.bare)); err != nil {
wd, _ := os.Getwd()
return fmt.Errorf("opening repo at %q: %w", wd, err)
}
@ -31,19 +31,17 @@ func (r *repo) openRepo() error {
// softReset resets to HEAD^ (or to an orphaned index, if HEAD has no parents),
// returning the old HEAD.
func (r *repo) softReset(expType string) (dehub.GitCommit, error) {
head, err := r.GetGitHead()
func (proj *proj) softReset(expType string) (dehub.Commit, error) {
head, err := proj.GetHeadCommit()
if err != nil {
return head, fmt.Errorf("getting HEAD commit: %w", err)
} else if typ, err := head.Commit.Type(); err != nil {
return head, fmt.Errorf("determining commit type of HEAD:% w", err)
} else if expType != "" && typ != expType {
return head, fmt.Errorf("expected HEAD to be a %q commit, but found %q",
} else if typ := head.Payload.Type(); expType != "" && typ != expType {
return head, fmt.Errorf("expected HEAD to be have a %q payload, but found a %q payload",
expType, typ)
}
branchName, branchErr := r.ReferenceToBranchName(plumbing.HEAD)
numParents := head.GitCommit.NumParents()
branchName, branchErr := proj.ReferenceToBranchName(plumbing.HEAD)
numParents := head.Object.NumParents()
if numParents > 1 {
return head, errors.New("cannot reset to parent of a commit with multiple parents")
@ -55,7 +53,7 @@ func (r *repo) softReset(expType string) (dehub.GitCommit, error) {
// it and all of HEAD's changes will be in the index.
if branchErr != nil {
return head, branchErr
} else if err := r.GitRepo.Storer.RemoveReference(branchName); err != nil {
} else if err := proj.GitRepo.Storer.RemoveReference(branchName); err != nil {
return head, fmt.Errorf("removing reference %q: %w", branchName, err)
}
return head, nil
@ -68,9 +66,9 @@ func (r *repo) softReset(expType string) (dehub.GitCommit, error) {
return head, fmt.Errorf("resolving HEAD: %w", err)
}
parentHash := head.GitCommit.ParentHashes[0]
parentHash := head.Object.ParentHashes[0]
newHeadRef := plumbing.NewHashReference(refName, parentHash)
if err := r.GitRepo.Storer.SetReference(newHeadRef); err != nil {
if err := proj.GitRepo.Storer.SetReference(newHeadRef); err != nil {
return head, fmt.Errorf("storing reference %q: %w", newHeadRef, err)
}
return head, nil

@ -15,35 +15,34 @@ func cmdVerify(ctx context.Context, cmd *dcmd.Cmd) {
rev := flag.String("rev", "HEAD", "Revision of commit to verify")
branch := flag.String("branch", "", "Branch that the revision is on. If not given then the currently checked out branch is assumed")
var repo repo
repo.initFlags(flag)
var proj proj
proj.initFlags(flag)
cmd.Run(func() (context.Context, error) {
if err := repo.openRepo(); err != nil {
if err := proj.openProj(); err != nil {
return nil, err
}
gitCommit, err := repo.GetGitRevision(plumbing.Revision(*rev))
commit, err := proj.GetCommitByRevision(plumbing.Revision(*rev))
if err != nil {
return nil, fmt.Errorf("resolving revision %q: %w", *rev, err)
}
gitCommitHash := gitCommit.GitCommit.Hash
var branchName plumbing.ReferenceName
if *branch == "" {
if branchName, err = repo.ReferenceToBranchName(plumbing.HEAD); err != nil {
if branchName, err = proj.ReferenceToBranchName(plumbing.HEAD); err != nil {
return nil, fmt.Errorf("determining branch at HEAD: %w", err)
}
} else {
branchName = plumbing.NewBranchReferenceName(*branch)
}
if err := repo.VerifyCommits(branchName, []dehub.GitCommit{gitCommit}); err != nil {
if err := proj.VerifyCommits(branchName, []dehub.Commit{commit}); err != nil {
return nil, fmt.Errorf("could not verify commit at %q (%s): %w",
*rev, gitCommitHash, err)
*rev, commit.Hash, err)
}
fmt.Printf("commit at %q (%s) is good to go!\n", *rev, gitCommitHash)
fmt.Printf("commit at %q (%s) is good to go!\n", *rev, commit.Hash)
return nil, nil
})
}

@ -8,7 +8,7 @@ import (
func main() {
cmd := dcmd.New()
cmd.SubCmd("init", "Initialize a new repository in a directory", cmdInit)
cmd.SubCmd("init", "Initialize a new project in a directory", cmdInit)
cmd.SubCmd("commit", "Commits staged changes to the head of the current branch", cmdCommit)
cmd.SubCmd("verify", "Verifies one or more commits as having the proper credentials", cmdVerify)
cmd.SubCmd("hook", "Use dehub as a git hook", cmdHook)

@ -1,610 +1,222 @@
package dehub
import (
"bytes"
"encoding/base64"
"encoding/hex"
"errors"
"fmt"
"reflect"
"sort"
"path/filepath"
"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"
yaml "gopkg.in/yaml.v2"
)
// CommitInterface describes the methods which must be implemented by the
// different commit types. None of the methods should modify the underlying
// object.
type CommitInterface interface {
// MessageHead returns the head of the commit message (i.e. the first line).
// The CommitCommon of the outer Commit is passed in for added context, if
// necessary.
MessageHead(CommitCommon) (string, error)
// ExpectedHash returns the raw hash which Signifiers can sign to accredit
// this commit. The ChangedFile objects given describe the file changes
// between the parent commit and this commit.
ExpectedHash([]ChangedFile) ([]byte, error)
// StoredHash returns the signable Hash embedded in the commit, which should
// hopefully correspond to the ExpectedHash.
StoredHash() []byte
}
// CommitCommon describes the fields common to all Commit objects.
type CommitCommon struct {
// Credentials represent all created Credentials for this commit, and can be
// set on all Commit objects regardless of other fields being set.
Credentials []sigcred.Credential `yaml:"credentials"`
}
func (cc CommitCommon) credIDs() []string {
m := map[string]struct{}{}
for _, cred := range cc.Credentials {
if cred.AccountID != "" {
m[cred.AccountID] = struct{}{}
} else if cred.AnonID != "" {
m[cred.AnonID] = struct{}{}
}
}
s := make([]string, 0, len(m))
for id := range m {
s = append(s, id)
}
sort.Strings(s)
return s
}
func abbrevCommitMessage(msg string) string {
i := strings.Index(msg, "\n")
if i > 0 {
msg = msg[:i]
}
if len(msg) > 80 {
msg = msg[:80] + "..."
}
return msg
}
// Commit represents a single Commit which is being added to a branch. Only one
// field should be set on a Commit, unless otherwise noted.
// Commit wraps a single git commit object, and also contains various fields
// which are parsed out of it, including the payload. It is used as a
// convenience type, in place of having to manually retrieve and parse specific
// information out of commit objects.
type Commit struct {
Change *CommitChange `type:"change,default"`
Credential *CommitCredential `type:"credential"`
Comment *CommitComment `type:"comment"`
Common CommitCommon `yaml:",inline"`
}
// MarshalYAML implements the yaml.Marshaler interface.
func (c Commit) MarshalYAML() (interface{}, error) {
return typeobj.MarshalYAML(c)
}
// UnmarshalYAML implements the yaml.Unmarshaler interface.
func (c *Commit) UnmarshalYAML(unmarshal func(interface{}) error) error {
return typeobj.UnmarshalYAML(c, unmarshal)
}
// Interface returns the CommitInterface instance encapsulated by this Commit
// object.
func (c Commit) Interface() (CommitInterface, error) {
el, _, err := typeobj.Element(c)
if err != nil {
return nil, err
}
return el.(CommitInterface), nil
}
// Type returns the Commit's type (as would be used in its YAML "type" field).
func (c Commit) Type() (string, error) {
_, typeStr, err := typeobj.Element(c)
if err != nil {
return "", err
}
return typeStr, nil
}
// MarshalText implements the encoding.TextMarshaler interface by returning the
// form the Commit object takes in the git commit message.
func (c Commit) MarshalText() ([]byte, error) {
commitInt, err := c.Interface()
if err != nil {
return nil, fmt.Errorf("could not cast Commit %+v to interface : %w", c, err)
}
msgHead, err := commitInt.MessageHead(c.Common)
if err != nil {
return nil, fmt.Errorf("error constructing message head: %w", err)
}
msgBodyB, err := yaml.Marshal(c)
if err != nil {
return nil, fmt.Errorf("error marshaling commit %+v as yaml: %w", c, err)
}
Payload PayloadUnion
w := new(bytes.Buffer)
w.WriteString(msgHead)
w.WriteString("\n\n---\n")
w.Write(msgBodyB)
return w.Bytes(), nil
Hash plumbing.Hash
Object *object.Commit
TreeObject *object.Tree
}
// UnmarshalText implements the encoding.TextUnmarshaler interface by decoding a
// Commit object which has been encoded into a git commit message.
func (c *Commit) UnmarshalText(msg []byte) error {
i := bytes.Index(msg, []byte("\n"))
if i < 0 {
return fmt.Errorf("commit message %q is malformed, it has no body", msg)
}
msgBody := msg[i:]
if err := yaml.Unmarshal(msgBody, c); err != nil {
return fmt.Errorf("could not unmarshal Commit message from yaml: %w", err)
} else if reflect.DeepEqual(*c, Commit{}) {
// a basic check, but worthwhile
return errors.New("commit message is malformed, could not unmarshal yaml object")
// GetCommit retrieves the Commit at the given hash, and all of its sub-data
// which can be pulled out of it.
func (proj *Project) GetCommit(h plumbing.Hash) (c Commit, err error) {
if c.Object, err = proj.GitRepo.CommitObject(h); err != nil {
return c, fmt.Errorf("getting git commit object: %w", err)
} else if c.TreeObject, err = proj.GitRepo.TreeObject(c.Object.TreeHash); err != nil {
return c, fmt.Errorf("getting git tree object %q: %w",
c.Object.TreeHash, err)
} else if c.Payload.UnmarshalText([]byte(c.Object.Message)); err != nil {
return c, fmt.Errorf("decoding commit message: %w", err)
}
return nil
c.Hash = c.Object.Hash
return
}
// AccreditCommit returns the given Commit with an appended Credential provided
// by the given SignifierInterface.
func (r *Repo) AccreditCommit(commit Commit, sigInt sigcred.SignifierInterface) (Commit, error) {
commitInt, err := commit.Interface()
if err != nil {
return commit, fmt.Errorf("could not cast commit %+v to interface: %w", commit, err)
}
// ErrHeadIsZero is used to indicate that HEAD resolves to the zero hash. An
// example of when this can happen is if the project was just initialized and
// has no commits, or if an orphan branch is checked out.
var ErrHeadIsZero = errors.New("HEAD resolves to the zero hash")
headFS, err := r.headFS()
// GetHeadCommit returns the Commit which is currently referenced by HEAD.
// This method may return ErrHeadIsZero if HEAD resolves to the zero hash.
func (proj *Project) GetHeadCommit() (Commit, error) {
headHash, err := proj.ReferenceToHash(plumbing.HEAD)
if err != nil {
return commit, fmt.Errorf("could not grab snapshot of HEAD fs: %w", err)
}
cred, err := sigInt.Sign(headFS, commitInt.StoredHash())
if err != nil {
return commit, fmt.Errorf("could not accredit change commit: %w", err)
}
commit.Common.Credentials = append(commit.Common.Credentials, cred)
return commit, nil
}
// CommitBareParams are the parameters to the CommitBare method. All are
// required, unless otherwise noted.
type CommitBareParams struct {
Commit Commit
Author string
ParentHash plumbing.Hash // can be zero if the commit has no parents (Q_Q)
GitTree *object.Tree
}
// CommitBare constructs a git commit object and and stores it, returning the
// resulting GitCommit. This method does not interact with HEAD at all.
func (r *Repo) CommitBare(params CommitBareParams) (GitCommit, error) {
msgB, err := params.Commit.MarshalText()
if err != nil {
return GitCommit{}, fmt.Errorf("encoding %T to message string: %w",
params.Commit, err)
}
author := object.Signature{
Name: params.Author,
When: time.Now(),
}
commit := &object.Commit{
Author: author,
Committer: author,
Message: string(msgB),
TreeHash: params.GitTree.Hash,
}
if params.ParentHash != plumbing.ZeroHash {
commit.ParentHashes = []plumbing.Hash{params.ParentHash}
return Commit{}, fmt.Errorf("resolving HEAD: %w", err)
} else if headHash == plumbing.ZeroHash {
return Commit{}, ErrHeadIsZero
}
commitObj := r.GitRepo.Storer.NewEncodedObject()
if err := commit.Encode(commitObj); err != nil {
return GitCommit{}, fmt.Errorf("encoding commit object: %w", err)
}
commitHash, err := r.GitRepo.Storer.SetEncodedObject(commitObj)
c, err := proj.GetCommit(headHash)
if err != nil {
return GitCommit{}, fmt.Errorf("setting encoded object: %w", err)
return Commit{}, fmt.Errorf("getting commit %q: %w", headHash, err)
}
return r.GetGitCommit(commitHash)
return c, nil
}
// Commit uses the given Commit to create a git commit object and commits it to
// the current HEAD, returning the full GitCommit.
func (r *Repo) Commit(commit Commit) (GitCommit, error) {
headRef, err := r.TraverseReferenceChain(plumbing.HEAD, func(ref *plumbing.Reference) bool {
return ref.Type() == plumbing.HashReference
})
if err != nil {
return GitCommit{}, fmt.Errorf("resolving HEAD to a hash reference: %w", err)
}
headRefName := headRef.Name()
headHash, err := r.ReferenceToHash(headRefName)
if err != nil {
return GitCommit{}, fmt.Errorf("resolving ref %q (HEAD): %w", headRefName, err)
}
// TODO this is also used in the same way in NewCommitChange. It might make
// sense to refactor this logic out, it might not be needed in fs at all.
_, stagedTree, err := fs.FromStagedChangesTree(r.GitRepo)
if err != nil {
return GitCommit{}, fmt.Errorf("getting staged changes: %w", err)
}
gitCommit, err := r.CommitBare(CommitBareParams{
Commit: commit,
Author: strings.Join(commit.Common.credIDs(), ", "),
ParentHash: headHash,
GitTree: stagedTree,
})
if err != nil {
return GitCommit{}, err
}
// now set the branch to this new commit
newHeadRef := plumbing.NewHashReference(headRefName, gitCommit.GitCommit.Hash)
if err := r.GitRepo.Storer.SetReference(newHeadRef); err != nil {
return GitCommit{}, fmt.Errorf("setting reference %q to new commit hash %q: %w",
headRefName, gitCommit.GitCommit.Hash, err)
}
return gitCommit, nil
}
// 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()
// GetCommitRange returns an ancestry of Commits, with the first being the
// commit immediately following the given starting hash, and the last being the
// given ending hash.
//
// If start is plumbing.ZeroHash then the root commit will be the starting hash.
func (proj *Project) GetCommitRange(start, end plumbing.Hash) ([]Commit, error) {
curr, err := proj.GetCommit(end)
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 &&
fileStatus.Staging != git.Untracked {
any = true
break
}
}
return any, nil
}
// VerifyCommits verifies that the given commits, which are presumably on the
// given branch, are gucci.
func (r *Repo) VerifyCommits(branchName plumbing.ReferenceName, gitCommits []GitCommit) error {
// this isn't strictly necessary for this method, but it helps discover bugs
// in other parts of the code.
if len(gitCommits) == 0 {
return errors.New("cannot call VerifyCommits with empty commit slice")
}
// First determine the root of the main branch. All commits need to be an
// ancestor of it. If the main branch has not been created yet then there
// might not be a root commit yet.
var rootCommit *object.Commit
mainGitCommit, err := r.GetGitRevision(plumbing.Revision(MainRefName))
if errors.Is(err, plumbing.ErrReferenceNotFound) {
// main branch hasn't been created yet. The commits can only be verified
// if they are for the main branch and they include the root commit.
if branchName != MainRefName {
return fmt.Errorf("cannot verify commits in branch %q when no main branch exists", branchName)
}
for _, gitCommit := range gitCommits {
if gitCommit.GitCommit.NumParents() == 0 {
rootCommit = gitCommit.GitCommit
break
}
}
if rootCommit == nil {
return errors.New("root commit of main branch cannot be determined")
return nil, fmt.Errorf("retrieving commit %q: %w", end, err)
}
} else if err != nil {
return fmt.Errorf("retrieving commit at HEAD of %q: %w", MainRefName.Short(), err)
} else {
rootCommit = mainGitCommit.GitCommit
var commits []Commit
var found bool
for {
if rootCommit.NumParents() == 0 {
if found = start != plumbing.ZeroHash && curr.Hash == start; found {
break
} else if rootCommit.NumParents() > 1 {
return fmt.Errorf("commit %q in main branch has more than one parent", rootCommit.Hash)
} else if rootCommit, err = rootCommit.Parent(0); err != nil {
return fmt.Errorf("retrieving parent commit of %q: %w", rootCommit.Hash, err)
}
}
}
// We also need the HEAD of the given branch, if it exists.
branchGitCommit, err := r.GetGitRevision(plumbing.Revision(branchName))
if err != nil && !errors.Is(err, plumbing.ErrReferenceNotFound) {
return fmt.Errorf("retrieving commit at HEAD of %q: %w", branchName.Short(), err)
commits = append(commits, curr)
numParents := curr.Object.NumParents()
if numParents == 0 {
break
} else if numParents > 1 {
return nil, fmt.Errorf("commit %q has more than one parent: %+v",
curr.Hash, curr.Object.ParentHashes)
}
for i, gitCommit := range gitCommits {
// It's not a requirement that the given GitCommits are in ancestral
// order, but usually they are; if the previous commit is the parent of
// this one we can skip a bunch of work.
var parentTree *object.Tree
var isNonFF bool
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, assume it's ok
} else {
var err error
isAncestor := func(older, younger *object.Commit) bool {
var isAncestor bool
parentHash := curr.Object.ParentHashes[0]
parent, err := proj.GetCommit(parentHash)
if err != nil {
return false
} else if isAncestor, err = older.IsAncestor(younger); err != nil {
err = fmt.Errorf("determining if %q is an ancestor of %q: %w",
younger.Hash, older.Hash, err)
return false
return nil, fmt.Errorf("retrieving commit %q: %w", parentHash, err)
}
return isAncestor
curr = parent
}
ancestorOfRoot := isAncestor(rootCommit, gitCommit.GitCommit)
if branchGitCommit.GitCommit != nil {
// if the branch doesn't actually exist then this couldn't
// possibly be a nonFF
isNonFF = !isAncestor(branchGitCommit.GitCommit, gitCommit.GitCommit)
if !found && start != plumbing.ZeroHash {
return nil, fmt.Errorf("unable to find commit %q as an ancestor of %q",
start, end)
}
if err != nil {
return err
} else if !ancestorOfRoot {
return fmt.Errorf("commit %q must be direct descendant of root commit of %q (%q)",
gitCommit.GitCommit.Hash, MainRefName.Short(), rootCommit.Hash,
)
}
// reverse the commits to be in the expected order
for l, r := 0, len(commits)-1; l < r; l, r = l+1, r-1 {
commits[l], commits[r] = commits[r], commits[l]
}
if err := r.verifyCommit(branchName, gitCommit, parentTree, isNonFF); err != nil {
return fmt.Errorf("verifying commit %q: %w",
gitCommit.GitCommit.Hash, err)
}
}
return nil
return commits, nil
}
// parentTree returns the tree of the parent commit of the given commit. If the
// given commit has no parents then a bare tree is returned.
func (r *Repo) parentTree(commitObj *object.Commit) (*object.Tree, error) {
switch commitObj.NumParents() {
case 0:
return new(object.Tree), nil
case 1:
if parentCommitObj, err := commitObj.Parent(0); err != nil {
return nil, fmt.Errorf("getting parent commit %q: %w",
commitObj.ParentHashes[0], err)
} else if parentTree, err := r.GitRepo.TreeObject(parentCommitObj.TreeHash); err != nil {
return nil, fmt.Errorf("getting parent tree object %q: %w",
parentCommitObj.TreeHash, err)
} else {
return parentTree, nil
}
default:
return nil, errors.New("commit has multiple parents")
}
}
// if parentTree is nil then it will be inferred.
func (r *Repo) verifyCommit(
branchName plumbing.ReferenceName,
gitCommit GitCommit,
parentTree *object.Tree,
isNonFF bool,
) error {
parentTree, err := r.parentTree(gitCommit.GitCommit)
if err != nil {
return fmt.Errorf("retrieving parent tree of commit: %w", err)
}
var (
hashStrLen = len(plumbing.ZeroHash.String())
errNotHex = errors.New("not a valid hex string")
)
var sigFS fs.FS
if gitCommit.Root() {
sigFS = fs.FromTree(gitCommit.GitTree)
} else {
sigFS = fs.FromTree(parentTree)
func (proj *Project) findCommitByShortHash(hashStr string) (plumbing.Hash, error) {
paddedHashStr := hashStr
if len(hashStr)%2 > 0 {
paddedHashStr += "0"
}
cfg, err := r.loadConfig(sigFS)
if err != nil {
return fmt.Errorf("loading config of parent %q: %w",
gitCommit.GitCommit.ParentHashes[0], err)
if hashB, err := hex.DecodeString(paddedHashStr); err != nil {
return plumbing.ZeroHash, errNotHex
} else if len(hashStr) == hashStrLen {
var hash plumbing.Hash
copy(hash[:], hashB)
return hash, nil
} else if len(hashStr) < 2 {
return plumbing.ZeroHash, errors.New("hash string must be 2 characters long or more")
}
// assert access controls
changedFiles, err := ChangedFilesBetweenTrees(parentTree, gitCommit.GitTree)
for i := 2; i < hashStrLen; i++ {
hashPrefix, hashTail := hashStr[:i], hashStr[i:]
path := filepath.Join("objects", hashPrefix)
fileInfos, err := proj.GitDirFS.ReadDir(path)
if err != nil {
return fmt.Errorf("calculating diff from tree %q to tree %q: %w",
parentTree.Hash, gitCommit.GitTree.Hash, err)
} else if len(changedFiles) > 0 && gitCommit.Commit.Change == nil {
return errors.New("files changes but commit is not a change commit")
return plumbing.ZeroHash, fmt.Errorf("listing files in %q: %w", path, err)
}
pathsChanged := make([]string, len(changedFiles))
for i := range changedFiles {
pathsChanged[i] = changedFiles[i].Path
var matchedHash plumbing.Hash
for _, fileInfo := range fileInfos {
objFileName := fileInfo.Name()
if !strings.HasPrefix(objFileName, hashTail) {
continue
}
commitType, err := gitCommit.Commit.Type()
objHash := plumbing.NewHash(hashPrefix + objFileName)
obj, err := proj.GitRepo.Storer.EncodedObject(plumbing.AnyObject, objHash)
if err != nil {
return fmt.Errorf("determining type of commit %+v: %w", gitCommit.Commit, err)
}
return plumbing.ZeroHash, fmt.Errorf("reading object %q off disk: %w", objHash, err)
} else if obj.Type() != plumbing.CommitObject {
continue
err = accessctl.AssertCanCommit(cfg.AccessControls, accessctl.CommitRequest{
Type: commitType,
Branch: branchName.Short(),
Credentials: gitCommit.Commit.Common.Credentials,
FilesChanged: pathsChanged,
NonFastForward: isNonFF,
})
if err != nil {
return fmt.Errorf("asserting access controls: %w", err)
} else if matchedHash == plumbing.ZeroHash {
matchedHash = objHash
continue
}
// ensure the hash is what it's expected to be
storedCommitHash := gitCommit.Interface.StoredHash()
expectedCommitHash, err := gitCommit.Interface.ExpectedHash(changedFiles)
if err != nil {
return fmt.Errorf("calculating expected commit hash: %w", err)
} else if !bytes.Equal(storedCommitHash, expectedCommitHash) {
return fmt.Errorf("unexpected hash in commit body, is %s but should be %s",
base64.StdEncoding.EncodeToString(storedCommitHash),
base64.StdEncoding.EncodeToString(expectedCommitHash))
return plumbing.ZeroHash, fmt.Errorf("both %q and %q match", matchedHash, objHash)
}
// verify all credentials
for _, cred := range gitCommit.Commit.Common.Credentials {
if cred.AccountID == "" {
if err := cred.SelfVerify(expectedCommitHash); err != nil {
return fmt.Errorf("verifying credential %+v: %w", cred, err)
if matchedHash != plumbing.ZeroHash {
return matchedHash, nil
}
} else {
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, expectedCommitHash, cred); err != nil {
return fmt.Errorf("verifying credential %+v: %w", cred, err)
}
}
}
return nil
}
type changeRangeInfo struct {
changeCommits []GitCommit
authors map[string]struct{}
msg string
startTree, endTree *object.Tree
changeHash []byte
return plumbing.ZeroHash, errors.New("failed to find a commit object with a matching prefix")
}
// changeRangeInfo returns various pieces of information about a range of
// commits' changes.
func (r *Repo) changeRangeInfo(commits []GitCommit) (changeRangeInfo, error) {
info := changeRangeInfo{
authors: map[string]struct{}{},
func (proj *Project) resolveRev(rev plumbing.Revision) (plumbing.Hash, error) {
if rev == plumbing.Revision(plumbing.ZeroHash.String()) {
return plumbing.ZeroHash, nil
}
for _, commit := range commits {
if _, ok := commit.Interface.(*CommitChange); ok {
info.changeCommits = append(info.changeCommits, commit)
for _, cred := range commit.Commit.Common.Credentials {
info.authors[cred.AccountID] = struct{}{}
}
}
{
// pretend the revision is a short hash until proven otherwise
shortHash := string(rev)
hash, err := proj.findCommitByShortHash(shortHash)
if errors.Is(err, errNotHex) {
// ok, continue
} else if err != nil {
return plumbing.ZeroHash, fmt.Errorf("resolving as short hash: %w", err)
} else {
// guess it _is_ a short hash, knew it!
return hash, nil
}
if len(info.changeCommits) == 0 {
return changeRangeInfo{}, errors.New("no change commits found")
}
// startTree has to be the tree of the parent of the first commit, which
// isn't included in commits. Determine it the hard way.
var err error
if info.startTree, err = r.parentTree(commits[0].GitCommit); err != nil {
return changeRangeInfo{}, fmt.Errorf("getting tree of parent of %q: %w",
commits[0].GitCommit.Hash, err)
}
lastChangeCommit := info.changeCommits[len(info.changeCommits)-1]
info.msg = lastChangeCommit.Commit.Change.Message
info.endTree = lastChangeCommit.GitTree
changedFiles, err := ChangedFilesBetweenTrees(info.startTree, info.endTree)
h, err := proj.GitRepo.ResolveRevision(rev)
if err != nil {
return changeRangeInfo{}, fmt.Errorf("calculating diff of commit trees %q and %q: %w",
info.startTree.Hash, info.endTree.Hash, err)
return plumbing.ZeroHash, fmt.Errorf("resolving revision %q: %w", rev, err)
}
info.changeHash = genChangeHash(nil, info.msg, changedFiles)
return info, nil
return *h, nil
}
// VerifyCanSetBranchHEADTo is used to verify that a branch's HEAD can be set to
// the given hash. It verifies any new commits which are being added, and
// handles verifying non-fast-forward commits as well.
//
// If the given hash matches the current HEAD of the branch then this performs
// no further checks and returns nil.
func (r *Repo) VerifyCanSetBranchHEADTo(branchName plumbing.ReferenceName, hash plumbing.Hash) error {
oldCommitRef, err := r.GitRepo.Reference(branchName, true)
if errors.Is(err, plumbing.ErrReferenceNotFound) {
// if the branch is being created then just pull all of its commits and
// verify them.
// TODO optimize this so that it tries to use the merge-base with main,
// so we're not re-verifying a ton of commits unecessarily
commits, err := r.GetGitCommitRange(plumbing.ZeroHash, hash)
// GetCommitByRevision resolves the revision and returns the Commit it references.
func (proj *Project) GetCommitByRevision(rev plumbing.Revision) (Commit, error) {
hash, err := proj.resolveRev(rev)
if err != nil {
return fmt.Errorf("retrieving %q and all its ancestors: %w", hash, err)
return Commit{}, err
}
return r.VerifyCommits(branchName, commits)
} else if err != nil {
return fmt.Errorf("resolving branch reference to a hash: %w", err)
} else if oldCommitRef.Hash() == hash {
// if the HEAD is already at the given hash then it must be fine.
return nil
}
oldCommitObj, err := r.GitRepo.CommitObject(oldCommitRef.Hash())
c, err := proj.GetCommit(hash)
if err != nil {
return fmt.Errorf("retrieving commit object %q: %w", oldCommitRef.Hash(), err)
return Commit{}, fmt.Errorf("getting commit %q: %w", hash, err)
}
return c, nil
}
newCommitObj, err := r.GitRepo.CommitObject(hash)
// GetCommitRangeByRevision is like GetCommitRange, first resolving the given
// revisions into hashes before continuing with GetCommitRange's behavior.
func (proj *Project) GetCommitRangeByRevision(startRev, endRev plumbing.Revision) ([]Commit, error) {
start, err := proj.resolveRev(startRev)
if err != nil {
return fmt.Errorf("retrieving commit object %q: %w", hash, err)
return nil, err
}
mbCommits, err := oldCommitObj.MergeBase(newCommitObj)
end, err := proj.resolveRev(endRev)
if err != nil {
return fmt.Errorf("determining merge-base between %q and %q: %w",
oldCommitObj.Hash, newCommitObj.Hash, err)
} else if len(mbCommits) == 0 {
return fmt.Errorf("%q and %q have no ancestors in common",
oldCommitObj.Hash, newCommitObj.Hash)
} else if len(mbCommits) == 2 {
return fmt.Errorf("%q and %q have more than one ancestor in common",
oldCommitObj.Hash, newCommitObj.Hash)
return nil, err
}
commits, err := r.GetGitCommitRange(mbCommits[0].Hash, hash)
if err != nil {
return fmt.Errorf("retrieving commits %q to %q: %w", mbCommits[0].Hash, hash, err)
}
return r.VerifyCommits(branchName, commits)
return proj.GetCommitRange(start, end)
}

@ -1,156 +0,0 @@
package dehub
import (
"bytes"
"errors"
"fmt"
"sort"
"strings"
"dehub.dev/src/dehub.git/fs"
"dehub.dev/src/dehub.git/sigcred"
"dehub.dev/src/dehub.git/yamlutil"
"gopkg.in/src-d/go-git.v4/plumbing"
"gopkg.in/src-d/go-git.v4/plumbing/object"
)
// CommitChange describes the structure of a change commit message.
type CommitChange struct {
Message string `yaml:"message"`
ChangeHash yamlutil.Blob `yaml:"change_hash"`
}
var _ CommitInterface = CommitChange{}
// NewCommitChange constructs a Commit populated with a CommitChange
// encompassing the currently staged file changes. The Credentials of the
// returned Commit will _not_ be filled in.
func (r *Repo) NewCommitChange(msg string) (Commit, error) {
headTree := new(object.Tree)
if head, err := r.GetGitHead(); err != nil && !errors.Is(err, ErrHeadIsZero) {
return Commit{}, fmt.Errorf("getting HEAD commit: %w", err)
} else if err == nil {
headTree = head.GitTree
}
_, stagedTree, err := fs.FromStagedChangesTree(r.GitRepo)
if err != nil {
return Commit{}, err
}
changedFiles, err := ChangedFilesBetweenTrees(headTree, stagedTree)
if err != nil {
return Commit{}, fmt.Errorf("calculating diff between HEAD and staged changes: %w", err)
}
cc := CommitChange{Message: msg}
if cc.ChangeHash, err = cc.ExpectedHash(changedFiles); err != nil {
return Commit{}, err
}
return Commit{
Change: &cc,
}, nil
}
// MessageHead implements the method for the CommitInterface interface.
func (cc CommitChange) MessageHead(CommitCommon) (string, error) {
return abbrevCommitMessage(cc.Message), nil
}
// ExpectedHash implements the method for the CommitInterface interface.
func (cc CommitChange) ExpectedHash(changedFiles []ChangedFile) ([]byte, error) {
return genChangeHash(nil, cc.Message, changedFiles), nil
}
// StoredHash implements the method for the CommitInterface interface.
func (cc CommitChange) StoredHash() []byte {
return cc.ChangeHash
}
// CombineCommitChanges takes all changes in the given range and combines them
// into a single change Commit. The resulting Commit will have the same message
// as the latest change commit in the range, and will contain all Credentials
// for the resulting change hash that it finds in the range as well.
//
// The combined commit is then committed to the repo with the given revision as
// its parent. If the diff between start/end and onto/end is different then this
// will return an error, as the change hash which has been accredited in
// start/end will be different than the one which needs to be accredited in
// onto/end.
func (r *Repo) CombineCommitChanges(commits []GitCommit, onto plumbing.ReferenceName) (GitCommit, error) {
info, err := r.changeRangeInfo(commits)
if err != nil {
return GitCommit{}, err
}
authors := make([]string, 0, len(info.authors))
for author := range info.authors {
authors = append(authors, author)
}
sort.Strings(authors)
ontoBranchName, err := r.ReferenceToBranchName(onto)
if err != nil {
return GitCommit{}, fmt.Errorf("resolving %q into a branch name: %w", onto, err)
}
// now determine the change hash from onto->end, to ensure that it remains
// the same as from start->end
ontoCommit, err := r.GetGitRevision(plumbing.Revision(onto))
if err != nil {
return GitCommit{}, fmt.Errorf("resolving revision %q: %w", onto, err)
}
ontoEndChangedFiles, err := ChangedFilesBetweenTrees(ontoCommit.GitTree, info.endTree)
if err != nil {
return GitCommit{}, fmt.Errorf("calculating file changes between %q and %q: %w",
ontoCommit.GitCommit.Hash, commits[len(commits)-1].GitCommit.Hash, err)
}
ontoEndChangeHash := genChangeHash(nil, info.msg, ontoEndChangedFiles)
if !bytes.Equal(ontoEndChangeHash, info.changeHash) {
// TODO figure out what files to show as being the "problem files" in
// the error message
return GitCommit{}, fmt.Errorf("combining onto %q would cause the change hash to change, aborting combine", onto.Short())
}
var creds []sigcred.Credential
for _, commit := range commits {
if bytes.Equal(commit.Interface.StoredHash(), info.changeHash) {
creds = append(creds, commit.Commit.Common.Credentials...)
}
}
// this is mostly to make tests easier
sort.Slice(creds, func(i, j int) bool {
return creds[i].AccountID < creds[j].AccountID
})
commit := Commit{
Change: &CommitChange{
Message: info.msg,
ChangeHash: info.changeHash,
},
Common: CommitCommon{Credentials: creds},
}
gitCommit, err := r.CommitBare(CommitBareParams{
Commit: commit,
Author: strings.Join(authors, ","),
ParentHash: ontoCommit.GitCommit.Hash,
GitTree: info.endTree,
})
if err != nil {
return GitCommit{}, fmt.Errorf("storing commit: %w", err)
}
// set the onto branch to this new commit
newHeadRef := plumbing.NewHashReference(ontoBranchName, gitCommit.GitCommit.Hash)
if err := r.GitRepo.Storer.SetReference(newHeadRef); err != nil {
return GitCommit{}, fmt.Errorf("setting reference %q to new commit hash %q: %w",
ontoBranchName, gitCommit.GitCommit.Hash, err)
}
return gitCommit, nil
}

@ -1,49 +0,0 @@
package dehub
import (
"errors"
"fmt"
"strings"
"dehub.dev/src/dehub.git/yamlutil"
)
// CommitComment describes the structure of a comment commit message.
type CommitComment struct {
Message string `yaml:"message"`
MessageHash yamlutil.Blob `yaml:"message_hash"`
}
var _ CommitInterface = CommitComment{}
// NewCommitComment constructs and returns a Commit populated with a
// CommitComment encompassing the given message. The Credentials of the returned
// Commit will _not_ be filled in.
func (r *Repo) NewCommitComment(msg string) (Commit, error) {
cc := CommitComment{Message: msg}
var err error
if cc.MessageHash, err = cc.ExpectedHash(nil); err != nil {
return Commit{}, fmt.Errorf("calculating comment hash: %w", err)
}
return Commit{Comment: &cc}, nil
}
// MessageHead implements the method for the CommitInterface interface.
func (cc CommitComment) MessageHead(common CommitCommon) (string, error) {
msgAbbrev := abbrevCommitMessage(cc.Message)
credIDs := strings.Join(common.credIDs(), ", ")
return fmt.Sprintf("Comment by %s: %s", credIDs, msgAbbrev), nil
}
// ExpectedHash implements the method for the CommitInterface.
func (cc CommitComment) ExpectedHash(changes []ChangedFile) ([]byte, error) {
if len(changes) > 0 {
return nil, errors.New("CommitComment cannot have any changed files")
}
return genCommentHash(nil, cc.Message), nil
}
// StoredHash implements the method for the CommitInterface.
func (cc CommitComment) StoredHash() []byte {
return cc.MessageHash
}

@ -1,81 +0,0 @@
package dehub
import (
"encoding/base64"
"errors"
"fmt"
"strings"
"dehub.dev/src/dehub.git/yamlutil"
)
// CommitCredential describes the structure of a credential commit message.
type CommitCredential struct {
CredentialedHash yamlutil.Blob `yaml:"credentialed_hash"`
// CommitHashes represents the commits which this credential is accrediting.
// It is only present for informational purposes, as commits don't not have
// any bearing on the CredentialedHash itself.
CommitHashes []string `yaml:"commits,omitempty"`
}
var _ CommitInterface = CommitCredential{}
// NewCommitCredential constructs and returns a Commit populated with a
// CommitCredential encompassing the given hash. The Credentials of the returned
// Commit will _not_ be filled in.
func (r *Repo) NewCommitCredential(hash []byte) (Commit, error) {
return Commit{
Credential: &CommitCredential{
CredentialedHash: hash,
},
}, nil
}
// NewCommitCredentialFromChanges constructs and returns a Commit populated with
// a CommitCredential for all changes in the given range of GitCommits. The
// message of the last change commit in the range is used when generating the
// hash.
func (r *Repo) NewCommitCredentialFromChanges(commits []GitCommit) (Commit, error) {
info, err := r.changeRangeInfo(commits)
if err != nil {
return Commit{}, err
}
commitCred, err := r.NewCommitCredential(info.changeHash)
if err != nil {
return Commit{}, err
}
for _, commit := range info.changeCommits {
commitCred.Credential.CommitHashes = append(
commitCred.Credential.CommitHashes,
commit.GitCommit.Hash.String(),
)
}
return commitCred, nil
}
// MessageHead implements the method for the CommitInterface interface.
func (cc CommitCredential) MessageHead(common CommitCommon) (string, error) {
hash64 := base64.StdEncoding.EncodeToString(cc.CredentialedHash)
if len(hash64) > 6 {
hash64 = hash64[:6] + "..."
}
credIDs := strings.Join(common.credIDs(), ", ")
return fmt.Sprintf("Credential of hash %s by %s", hash64, credIDs), nil
}
// ExpectedHash implements the method for the CommitInterface.
func (cc CommitCredential) ExpectedHash(changes []ChangedFile) ([]byte, error) {
if len(changes) > 0 {
return nil, errors.New("CommitCredential cannot have any changed files")
}
return cc.CredentialedHash, nil
}
// StoredHash implements the method for the CommitInterface.
func (cc CommitCredential) StoredHash() []byte {
return cc.CredentialedHash
}

@ -14,7 +14,7 @@ import (
// Account represents a single account defined in the Config.
type Account struct {
ID string `yaml:"id"`
Signifiers []sigcred.Signifier `yaml:"signifiers"`
Signifiers []sigcred.SignifierUnion `yaml:"signifiers"`
Meta map[string]string `yaml:"meta,omitempty"`
}
@ -25,7 +25,7 @@ type Config struct {
AccessControls []accessctl.AccessControl `yaml:"access_controls"`
}
func (r *Repo) loadConfig(fs fs.FS) (Config, error) {
func (proj *Project) loadConfig(fs fs.FS) (Config, error) {
rc, err := fs.Open(ConfigPath)
if err != nil {
return Config{}, fmt.Errorf("could not open config.yml: %w", err)
@ -53,18 +53,18 @@ func (r *Repo) loadConfig(fs fs.FS) (Config, error) {
return cfg, nil
}
// LoadConfig loads the Config object from the HEAD of the repo, or directly
// from the filesystem if there is no HEAD yet.
func (r *Repo) LoadConfig() (Config, error) {
headFS, err := r.headFS()
// LoadConfig loads the Config object from the HEAD of the project's git repo,
// or directly from the filesystem if there is no HEAD yet.
func (proj *Project) LoadConfig() (Config, error) {
headFS, err := proj.headFS()
if err != nil {
return Config{}, fmt.Errorf("error retrieving repo HEAD: %w", err)
}
return r.loadConfig(headFS)
return proj.loadConfig(headFS)
}
func (r *Repo) signifierForCredential(fs fs.FS, cred sigcred.Credential) (sigcred.SignifierInterface, error) {
cfg, err := r.loadConfig(fs)
func (proj *Project) signifierForCredential(fs fs.FS, cred sigcred.CredentialUnion) (sigcred.Signifier, error) {
cfg, err := proj.loadConfig(fs)
if err != nil {
return nil, fmt.Errorf("error loading config: %w", err)
}
@ -81,13 +81,12 @@ func (r *Repo) signifierForCredential(fs fs.FS, cred sigcred.Credential) (sigcre
return nil, fmt.Errorf("no account object for account id %q present in config", cred.AccountID)
}
for i, sig := range account.Signifiers {
if sigInt, err := sig.Interface(cred.AccountID); err != nil {
return nil, fmt.Errorf("error converting signifier index:%d to inteface: %w", i, err)
} else if ok, err := sigInt.Signed(fs, cred); err != nil {
for i, sigUn := range account.Signifiers {
sig := sigUn.Signifier(cred.AccountID)
if ok, err := sig.Signed(fs, cred); err != nil {
return nil, fmt.Errorf("error checking if signfier index:%d signed credential: %w", i, err)
} else if ok {
return sigInt, nil
return sig, nil
}
}

@ -68,7 +68,7 @@ var (
)
// if h is nil it then defaultHashHelperAlgo will be used
func genChangeHash(h hash.Hash, msg string, changedFiles []ChangedFile) []byte {
func genChangeFingerprint(h hash.Hash, msg string, changedFiles []ChangedFile) []byte {
s := newHashHelper(h)
s.writeStr(msg)
s.writeChangedFiles(changedFiles)
@ -76,7 +76,7 @@ func genChangeHash(h hash.Hash, msg string, changedFiles []ChangedFile) []byte {
}
// if h is nil it then defaultHashHelperAlgo will be used
func genCommentHash(h hash.Hash, comment string) []byte {
func genCommentFingerprint(h hash.Hash, comment string) []byte {
s := newHashHelper(h)
s.writeStr(comment)
return s.sum(commentHashVersion)

@ -47,7 +47,7 @@ func uvarint(i uint64) []byte {
return buf[:n]
}
func TestGenCommentHash(t *testing.T) {
func TestGenCommentFingerprint(t *testing.T) {
type test struct {
descr string
comment string
@ -75,13 +75,13 @@ func TestGenCommentHash(t *testing.T) {
for _, test := range tests {
t.Run(test.descr, func(t *testing.T) {
th := new(testHash)
genCommentHash(th, test.comment)
genCommentFingerprint(th, test.comment)
th.assertContents(t, test.exp)
})
}
}
func TestGenChangeHash(t *testing.T) {
func TestGenChangeFingerprint(t *testing.T) {
type test struct {
descr string
msg string
@ -230,7 +230,7 @@ func TestGenChangeHash(t *testing.T) {
for _, test := range tests {
t.Run(test.descr, func(t *testing.T) {
th := new(testHash)
genChangeHash(th, test.msg, test.changedFiles)
genChangeFingerprint(th, test.msg, test.changedFiles)
th.assertContents(t, test.exp)
})
}

@ -0,0 +1,604 @@
package dehub
import (
"bytes"
"errors"
"fmt"
"reflect"
"sort"
"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"
"dehub.dev/src/dehub.git/yamlutil"
"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"
)
// Payload describes the methods which must be implemented by the different
// payload types. None of the methods should modify the underlying object.
type Payload interface {
// MessageHead returns the head of the commit message (i.e. the first line).
// The PayloadCommon of the outer PayloadUnion is passed in for added
// context, if necessary.
MessageHead(PayloadCommon) (string, error)
// Fingerprint returns the raw fingerprint which can be signed when
// accrediting this payload. The ChangedFile objects given describe the file
// changes between the parent commit and this commit.
//
// If this method returns nil it means that the payload has no fingerprint
// in-and-of-itself.
Fingerprint([]ChangedFile) ([]byte, error)
}
// PayloadCommon describes the fields common to all Payloads.
type PayloadCommon struct {
Fingerprint yamlutil.Blob `yaml:"fingerprint"`
Credentials []sigcred.CredentialUnion `yaml:"credentials"`
// LegacyChangeHash is no longer used, use Fingerprint instead.
LegacyChangeHash yamlutil.Blob `yaml:"change_hash,omitempty"`
}
func (cc PayloadCommon) credIDs() []string {
m := map[string]struct{}{}
for _, cred := range cc.Credentials {
if cred.AccountID != "" {
m[cred.AccountID] = struct{}{}
} else if cred.AnonID != "" {
m[cred.AnonID] = struct{}{}
}
}
s := make([]string, 0, len(m))
for id := range m {
s = append(s, id)
}
sort.Strings(s)
return s
}
func abbrevCommitMessage(msg string) string {
i := strings.Index(msg, "\n")
if i > 0 {
msg = msg[:i]
}
if len(msg) > 80 {
msg = msg[:77] + "..."
}
return msg
}
// PayloadUnion represents a single Payload of variable type. Only one field
// should be set on a PayloadUnion, unless otherwise noted.
type PayloadUnion struct {
Change *PayloadChange `type:"change,default"`
Credential *PayloadCredential `type:"credential"`
Comment *PayloadComment `type:"comment"`
// Common may be set in addition to one of the other fields.
Common PayloadCommon `yaml:",inline"`
}
// MarshalYAML implements the yaml.Marshaler interface.
func (p PayloadUnion) MarshalYAML() (interface{}, error) {
return typeobj.MarshalYAML(p)
}
// UnmarshalYAML implements the yaml.Unmarshaler interface.
func (p *PayloadUnion) UnmarshalYAML(unmarshal func(interface{}) error) error {
if err := typeobj.UnmarshalYAML(p, unmarshal); err != nil {
return err
} else if len(p.Common.LegacyChangeHash) > 0 {
p.Common.Fingerprint = p.Common.LegacyChangeHash
p.Common.LegacyChangeHash = nil
}
return nil
}
// Payload returns the Payload instance encapsulated by this PayloadUnion.
//
// This will panic if a Payload field is not populated.
func (p PayloadUnion) Payload() Payload {
el, _, err := typeobj.Element(p)
if err != nil {
panic(err)
}
return el.(Payload)
}
// Type returns the Payload's type (as would be used in its YAML "type" field).
//
// This will panic if a Payload field is not populated.
func (p PayloadUnion) Type() string {
_, typeStr, err := typeobj.Element(p)
if err != nil {
panic(err)
}
return typeStr
}
// MarshalText implements the encoding.TextMarshaler interface by returning the
// form the payload in the git commit message.
func (p PayloadUnion) MarshalText() ([]byte, error) {
msgHead, err := p.Payload().MessageHead(p.Common)
if err != nil {
return nil, fmt.Errorf("constructing message head: %w", err)
}
msgBodyB, err := yaml.Marshal(p)
if err != nil {
return nil, fmt.Errorf("marshaling payload %+v as yaml: %w", p, err)
}
w := new(bytes.Buffer)
w.WriteString(msgHead)
w.WriteString("\n\n---\n")
w.Write(msgBodyB)
return w.Bytes(), nil
}
// UnmarshalText implements the encoding.TextUnmarshaler interface by decoding a
// payload object which has been encoded into a git commit message.
func (p *PayloadUnion) UnmarshalText(msg []byte) error {
i := bytes.Index(msg, []byte("\n"))
if i < 0 {
return fmt.Errorf("commit message %q is malformed, it has no body", msg)
}
msgBody := msg[i:]
if err := yaml.Unmarshal(msgBody, p); err != nil {
return fmt.Errorf("unmarshaling commit payload from yaml: %w", err)
} else if reflect.DeepEqual(*p, PayloadUnion{}) {
// a basic check, but worthwhile
return errors.New("commit message is malformed, could not unmarshal yaml object")
}
return nil
}
// AccreditPayload returns the given PayloadUnion with an appended Credential
// provided by the given SignifierInterface.
func (proj *Project) AccreditPayload(payUn PayloadUnion, sig sigcred.Signifier) (PayloadUnion, error) {
headFS, err := proj.headFS()
if err != nil {
return payUn, fmt.Errorf("retrieving HEAD fs: %w", err)
}
cred, err := sig.Sign(headFS, payUn.Common.Fingerprint)
if err != nil {
return payUn, fmt.Errorf("signing fingerprint %q: %w", payUn.Common.Fingerprint, err)
}
payUn.Common.Credentials = append(payUn.Common.Credentials, cred)
return payUn, nil
}
// CommitDirectParams are the parameters to the CommitDirect method. All are
// required, unless otherwise noted.
type CommitDirectParams struct {
PayloadUnion PayloadUnion
Author string
ParentHash plumbing.Hash // can be zero if the commit has no parents (Q_Q)
GitTree *object.Tree
}
// CommitDirect constructs a git commit object and and stores it, returning the
// resulting Commit. This method does not interact with HEAD at all.
func (proj *Project) CommitDirect(params CommitDirectParams) (Commit, error) {
msgB, err := params.PayloadUnion.MarshalText()
if err != nil {
return Commit{}, fmt.Errorf("encoding payload to message string: %w", err)
}
author := object.Signature{
Name: params.Author,
When: time.Now(),
}
commit := &object.Commit{
Author: author,
Committer: author,
Message: string(msgB),
TreeHash: params.GitTree.Hash,
}
if params.ParentHash != plumbing.ZeroHash {
commit.ParentHashes = []plumbing.Hash{params.ParentHash}
}
commitObj := proj.GitRepo.Storer.NewEncodedObject()
if err := commit.Encode(commitObj); err != nil {
return Commit{}, fmt.Errorf("encoding commit object: %w", err)
}
commitHash, err := proj.GitRepo.Storer.SetEncodedObject(commitObj)
if err != nil {
return Commit{}, fmt.Errorf("setting encoded object: %w", err)
}
return proj.GetCommit(commitHash)
}
// Commit uses the given PayloadUnion to create a git commit object and commits
// it to the current HEAD, returning the full Commit.
func (proj *Project) Commit(payUn PayloadUnion) (Commit, error) {
headRef, err := proj.TraverseReferenceChain(plumbing.HEAD, func(ref *plumbing.Reference) bool {
return ref.Type() == plumbing.HashReference
})
if err != nil {
return Commit{}, fmt.Errorf("resolving HEAD to a hash reference: %w", err)
}
headRefName := headRef.Name()
headHash, err := proj.ReferenceToHash(headRefName)
if err != nil {
return Commit{}, fmt.Errorf("resolving ref %q (HEAD): %w", headRefName, err)
}
// TODO this is also used in the same way in NewCommitChange. It might make
// sense to refactor this logic out, it might not be needed in fs at all.
_, stagedTree, err := fs.FromStagedChangesTree(proj.GitRepo)
if err != nil {
return Commit{}, fmt.Errorf("getting staged changes: %w", err)
}
commit, err := proj.CommitDirect(CommitDirectParams{
PayloadUnion: payUn,
Author: strings.Join(payUn.Common.credIDs(), ", "),
ParentHash: headHash,
GitTree: stagedTree,
})
if err != nil {
return Commit{}, err
}
// now set the branch to this new commit
newHeadRef := plumbing.NewHashReference(headRefName, commit.Hash)
if err := proj.GitRepo.Storer.SetReference(newHeadRef); err != nil {
return Commit{}, fmt.Errorf("setting reference %q to new commit hash %q: %w",
headRefName, commit.Hash, err)
}
return commit, nil
}
// HasStagedChanges returns true if there are file changes which have been
// staged (e.g. via "git add").
func (proj *Project) HasStagedChanges() (bool, error) {
w, err := proj.GitRepo.Worktree()
if err != nil {
return false, fmt.Errorf("retrieving worktree: %w", err)
}
status, err := w.Status()
if err != nil {
return false, fmt.Errorf("retrieving worktree status: %w", err)
}
var any bool
for _, fileStatus := range status {
if fileStatus.Staging != git.Unmodified &&
fileStatus.Staging != git.Untracked {
any = true
break
}
}
return any, nil
}
// VerifyCommits verifies that the given commits, which are presumably on the
// given branch, are gucci.
func (proj *Project) VerifyCommits(branchName plumbing.ReferenceName, commits []Commit) error {
// this isn't strictly necessary for this method, but it helps discover bugs
// in other parts of the code.
if len(commits) == 0 {
return errors.New("cannot call VerifyCommits with empty commit slice")
}
// First determine the root of the main branch. All commits need to be an
// ancestor of it. If the main branch has not been created yet then there
// might not be a root commit yet.
var rootCommitObj *object.Commit
mainCommit, err := proj.GetCommitByRevision(plumbing.Revision(MainRefName))
if errors.Is(err, plumbing.ErrReferenceNotFound) {
// main branch hasn't been created yet. The commits can only be verified
// if they are for the main branch and they include the root commit.
if branchName != MainRefName {
return fmt.Errorf("cannot verify commits in branch %q when no main branch exists", branchName)
}
for _, commit := range commits {
if commit.Object.NumParents() == 0 {
rootCommitObj = commit.Object
break
}
}
if rootCommitObj == nil {
return errors.New("root commit of main branch cannot be determined")
}
} else if err != nil {
return fmt.Errorf("retrieving commit at HEAD of %q: %w", MainRefName.Short(), err)
} else {
rootCommitObj = mainCommit.Object
for {
if rootCommitObj.NumParents() == 0 {
break
} else if rootCommitObj.NumParents() > 1 {
return fmt.Errorf("commit %q in main branch has more than one parent", rootCommitObj.Hash)
} else if rootCommitObj, err = rootCommitObj.Parent(0); err != nil {
return fmt.Errorf("retrieving parent commit of %q: %w", rootCommitObj.Hash, err)
}
}
}
// We also need the HEAD of the given branch, if it exists.
branchCommit, err := proj.GetCommitByRevision(plumbing.Revision(branchName))
if err != nil && !errors.Is(err, plumbing.ErrReferenceNotFound) {
return fmt.Errorf("retrieving commit at HEAD of %q: %w", branchName.Short(), err)
}
for i, commit := range commits {
// It's not a requirement that the given Commits are in ancestral order,
// but usually they are; if the previous commit is the parent of this
// one we can skip a bunch of work.
var parentTree *object.Tree
var isNonFF bool
if i > 0 && commits[i-1].Hash == commit.Object.ParentHashes[0] {
parentTree = commits[i-1].TreeObject
} else if commit.Hash == rootCommitObj.Hash {
// looking at the root commit, assume it's ok
} else {
var err error
isAncestor := func(older, younger *object.Commit) bool {
var isAncestor bool
if err != nil {
return false
} else if isAncestor, err = older.IsAncestor(younger); err != nil {
err = fmt.Errorf("determining if %q is an ancestor of %q: %w",
younger.Hash, older.Hash, err)
return false
}
return isAncestor
}
ancestorOfRoot := isAncestor(rootCommitObj, commit.Object)
if branchCommit.Hash != plumbing.ZeroHash { // checking if the var was set
// this could only be a nonFF if the branch actually exists.
isNonFF = !isAncestor(branchCommit.Object, commit.Object)
}
if err != nil {
return err
} else if !ancestorOfRoot {
return fmt.Errorf("commit %q must be direct descendant of root commit of %q (%q)",
commit.Hash, MainRefName.Short(), rootCommitObj.Hash,
)
}
}
if err := proj.verifyCommit(branchName, commit, parentTree, isNonFF); err != nil {
return fmt.Errorf("verifying commit %q: %w", commit.Hash, err)
}
}
return nil
}
// parentTree returns the tree of the parent commit of the given commit. If the
// given commit has no parents then a bare tree is returned.
func (proj *Project) parentTree(commitObj *object.Commit) (*object.Tree, error) {
switch commitObj.NumParents() {
case 0:
return new(object.Tree), nil
case 1:
if parentCommitObj, err := commitObj.Parent(0); err != nil {
return nil, fmt.Errorf("getting parent commit %q: %w",
commitObj.ParentHashes[0], err)
} else if parentTree, err := proj.GitRepo.TreeObject(parentCommitObj.TreeHash); err != nil {
return nil, fmt.Errorf("getting parent tree object %q: %w",
parentCommitObj.TreeHash, err)
} else {
return parentTree, nil
}
default:
return nil, errors.New("commit has multiple parents")
}
}
// if parentTree is nil then it will be inferred.
func (proj *Project) verifyCommit(
branchName plumbing.ReferenceName,
commit Commit,
parentTree *object.Tree,
isNonFF bool,
) error {
parentTree, err := proj.parentTree(commit.Object)
if err != nil {
return fmt.Errorf("retrieving parent tree of commit: %w", err)
}
var sigFS fs.FS
if commit.Object.NumParents() == 0 {
sigFS = fs.FromTree(commit.TreeObject)
} else {
sigFS = fs.FromTree(parentTree)
}
cfg, err := proj.loadConfig(sigFS)
if err != nil {
return fmt.Errorf("loading config of parent %q: %w", commit.Object.ParentHashes[0], err)
}
// assert access controls
changedFiles, err := ChangedFilesBetweenTrees(parentTree, commit.TreeObject)
if err != nil {
return fmt.Errorf("calculating diff from tree %q to tree %q: %w",
parentTree.Hash, commit.TreeObject.Hash, err)
} else if len(changedFiles) > 0 && commit.Payload.Change == nil {
return errors.New("files changes but commit is not a change commit")
}
pathsChanged := make([]string, len(changedFiles))
for i := range changedFiles {
pathsChanged[i] = changedFiles[i].Path
}
commitType := commit.Payload.Type()
err = accessctl.AssertCanCommit(cfg.AccessControls, accessctl.CommitRequest{
Type: commitType,
Branch: branchName.Short(),
Credentials: commit.Payload.Common.Credentials,
FilesChanged: pathsChanged,
NonFastForward: isNonFF,
})
if err != nil {
return fmt.Errorf("asserting access controls: %w", err)
}
// ensure the fingerprint is what it's expected to be
storedFingerprint := commit.Payload.Common.Fingerprint
expectedFingerprint, err := commit.Payload.Payload().Fingerprint(changedFiles)
if err != nil {
return fmt.Errorf("calculating expected payload fingerprint: %w", err)
} else if expectedFingerprint == nil {
// the payload doesn't have a fingerprint of its own, it's just carrying
// one, so no point in checking if it's "correct".
} else if !bytes.Equal(storedFingerprint, expectedFingerprint) {
return fmt.Errorf("unexpected fingerprint in payload, is %q but should be %q",
storedFingerprint, yamlutil.Blob(expectedFingerprint))
}
// verify all credentials
for _, cred := range commit.Payload.Common.Credentials {
if cred.AccountID == "" {
if err := cred.SelfVerify(storedFingerprint); err != nil {
return fmt.Errorf("verifying credential %+v: %w", cred, err)
}
} else {
sig, err := proj.signifierForCredential(sigFS, cred)
if err != nil {
return fmt.Errorf("finding signifier for credential %+v: %w", cred, err)
} else if err := sig.Verify(sigFS, storedFingerprint, cred); err != nil {
return fmt.Errorf("verifying credential %+v: %w", cred, err)
}
}
}
return nil
}
type changeRangeInfo struct {
changeCommits []Commit
authors map[string]struct{}
msg string
startTree, endTree *object.Tree
changeFingerprint []byte
}
// changeRangeInfo returns various pieces of information about a range of
// commits' changes.
func (proj *Project) changeRangeInfo(commits []Commit) (changeRangeInfo, error) {
info := changeRangeInfo{
authors: map[string]struct{}{},
}
for _, commit := range commits {
if commit.Payload.Change != nil {
info.changeCommits = append(info.changeCommits, commit)
for _, cred := range commit.Payload.Common.Credentials {
info.authors[cred.AccountID] = struct{}{}
}
}
}
if len(info.changeCommits) == 0 {
return changeRangeInfo{}, errors.New("no change commits found in range")
}
// startTree has to be the tree of the parent of the first commit, which
// isn't included in commits. Determine it the hard way.
var err error
if info.startTree, err = proj.parentTree(commits[0].Object); err != nil {
return changeRangeInfo{}, fmt.Errorf("getting tree of parent of %q: %w",
commits[0].Hash, err)
}
lastChangeCommit := info.changeCommits[len(info.changeCommits)-1]
info.msg = lastChangeCommit.Payload.Change.Description
info.endTree = lastChangeCommit.TreeObject
changedFiles, err := ChangedFilesBetweenTrees(info.startTree, info.endTree)
if err != nil {
return changeRangeInfo{}, fmt.Errorf("calculating diff of commit trees %q and %q: %w",
info.startTree.Hash, info.endTree.Hash, err)
}
info.changeFingerprint = genChangeFingerprint(nil, info.msg, changedFiles)
return info, nil
}
// VerifyCanSetBranchHEADTo is used to verify that a branch's HEAD can be set to
// the given hash. It verifies any new commits which are being added, and
// handles verifying non-fast-forward commits as well.
//
// If the given hash matches the current HEAD of the branch then this performs
// no further checks and returns nil.
func (proj *Project) VerifyCanSetBranchHEADTo(branchName plumbing.ReferenceName, hash plumbing.Hash) error {
oldCommitRef, err := proj.GitRepo.Reference(branchName, true)
if errors.Is(err, plumbing.ErrReferenceNotFound) {
// if the branch is being created then just pull all of its commits and
// verify them.
// TODO optimize this so that it tries to use the merge-base with main,
// so we're not re-verifying a ton of commits unecessarily
commits, err := proj.GetCommitRange(plumbing.ZeroHash, hash)
if err != nil {
return fmt.Errorf("retrieving %q and all its ancestors: %w", hash, err)
}
return proj.VerifyCommits(branchName, commits)
} else if err != nil {
return fmt.Errorf("resolving branch reference to a hash: %w", err)
} else if oldCommitRef.Hash() == hash {
// if the HEAD is already at the given hash then it must be fine.
return nil
}
oldCommitObj, err := proj.GitRepo.CommitObject(oldCommitRef.Hash())
if err != nil {
return fmt.Errorf("retrieving commit object %q: %w", oldCommitRef.Hash(), err)
}
newCommitObj, err := proj.GitRepo.CommitObject(hash)
if err != nil {
return fmt.Errorf("retrieving commit object %q: %w", hash, err)
}
mbCommits, err := oldCommitObj.MergeBase(newCommitObj)
if err != nil {
return fmt.Errorf("determining merge-base between %q and %q: %w",
oldCommitObj.Hash, newCommitObj.Hash, err)
} else if len(mbCommits) == 0 {
return fmt.Errorf("%q and %q have no ancestors in common",
oldCommitObj.Hash, newCommitObj.Hash)
} else if len(mbCommits) == 2 {
return fmt.Errorf("%q and %q have more than one ancestor in common",
oldCommitObj.Hash, newCommitObj.Hash)
}
commits, err := proj.GetCommitRange(mbCommits[0].Hash, hash)
if err != nil {
return fmt.Errorf("retrieving commits %q to %q: %w", mbCommits[0].Hash, hash, err)
}
return proj.VerifyCommits(branchName, commits)
}

@ -0,0 +1,171 @@
package dehub
import (
"bytes"
"errors"
"fmt"
"sort"
"strings"
"dehub.dev/src/dehub.git/fs"
"dehub.dev/src/dehub.git/sigcred"
"gopkg.in/src-d/go-git.v4/plumbing"
"gopkg.in/src-d/go-git.v4/plumbing/object"
)
// PayloadChange describes the structure of a change payload.
type PayloadChange struct {
Description string `yaml:"description"`
// LegacyMessage is no longer used, use Description instead
LegacyMessage string `yaml:"message,omitempty"`
}
var _ Payload = PayloadChange{}
// NewPayloadChange constructs a PayloadUnion populated with a PayloadChange
// encompassing the currently staged file changes. The Credentials of the
// returned PayloadUnion will _not_ be filled in.
func (proj *Project) NewPayloadChange(description string) (PayloadUnion, error) {
headTree := new(object.Tree)
if head, err := proj.GetHeadCommit(); err != nil && !errors.Is(err, ErrHeadIsZero) {
return PayloadUnion{}, fmt.Errorf("getting HEAD commit: %w", err)
} else if err == nil {
headTree = head.TreeObject
}
_, stagedTree, err := fs.FromStagedChangesTree(proj.GitRepo)
if err != nil {
return PayloadUnion{}, err
}
changedFiles, err := ChangedFilesBetweenTrees(headTree, stagedTree)
if err != nil {
return PayloadUnion{}, fmt.Errorf("calculating diff between HEAD and staged changes: %w", err)
}
payCh := PayloadChange{Description: description}
fingerprint, err := payCh.Fingerprint(changedFiles)
if err != nil {
return PayloadUnion{}, err
}
return PayloadUnion{
Change: &payCh,
Common: PayloadCommon{Fingerprint: fingerprint},
}, nil
}
// MessageHead implements the method for the Payload interface.
func (payCh PayloadChange) MessageHead(PayloadCommon) (string, error) {
return abbrevCommitMessage(payCh.Description), nil
}
// Fingerprint implements the method for the Payload interface.
func (payCh PayloadChange) Fingerprint(changedFiles []ChangedFile) ([]byte, error) {
return genChangeFingerprint(nil, payCh.Description, changedFiles), nil
}
// UnmarshalYAML implements the yaml.Unmarshaler interface.
func (payCh *PayloadChange) UnmarshalYAML(unmarshal func(interface{}) error) error {
var wrap struct {
Inner PayloadChange `yaml:",inline"`
}
if err := unmarshal(&wrap); err != nil {
return err
}
*payCh = wrap.Inner
if payCh.LegacyMessage != "" {
payCh.Description = payCh.LegacyMessage
payCh.LegacyMessage = ""
}
return nil
}
// CombinePayloadChanges takes all changes in the given range, combines them
// into a single PayloadChange, and commits it. The resulting payload will have
// the same message as the latest change payload in the range. If the
// fingerprint of the PayloadChange produced by this method has any matching
// Credentials in the range, those will be included in the payload as well.
//
// The combined commit is committed to the project with the given revision as
// its parent. If the diff across the given range and the diff from onto to the
// end of the range are different then this will return an error.
func (proj *Project) CombinePayloadChanges(commits []Commit, onto plumbing.ReferenceName) (Commit, error) {
info, err := proj.changeRangeInfo(commits)
if err != nil {
return Commit{}, err
}
authors := make([]string, 0, len(info.authors))
for author := range info.authors {
authors = append(authors, author)
}
sort.Strings(authors)
ontoBranchName, err := proj.ReferenceToBranchName(onto)
if err != nil {
return Commit{}, fmt.Errorf("resolving %q into a branch name: %w", onto, err)
}
// now determine the change hash from onto->end, to ensure that it remains
// the same as from start->end
ontoCommit, err := proj.GetCommitByRevision(plumbing.Revision(onto))
if err != nil {
return Commit{}, fmt.Errorf("resolving revision %q: %w", onto, err)
}
ontoEndChangedFiles, err := ChangedFilesBetweenTrees(ontoCommit.TreeObject, info.endTree)
if err != nil {
return Commit{}, fmt.Errorf("calculating file changes between %q and %q: %w",
ontoCommit.Hash, commits[len(commits)-1].Hash, err)
}
ontoEndChangeFingerprint := genChangeFingerprint(nil, info.msg, ontoEndChangedFiles)
if !bytes.Equal(ontoEndChangeFingerprint, info.changeFingerprint) {
// TODO figure out what files to show as being the "problem files" in
// the error message
return Commit{}, fmt.Errorf("combining onto %q would produce a different change fingerprint, aborting combine", onto.Short())
}
var creds []sigcred.CredentialUnion
for _, commit := range commits {
if bytes.Equal(commit.Payload.Common.Fingerprint, info.changeFingerprint) {
creds = append(creds, commit.Payload.Common.Credentials...)
}
}
// this is mostly to make tests easier
sort.Slice(creds, func(i, j int) bool {
return creds[i].AccountID < creds[j].AccountID
})
payUn := PayloadUnion{
Change: &PayloadChange{
Description: info.msg,
},
Common: PayloadCommon{
Fingerprint: info.changeFingerprint,
Credentials: creds,
},
}
commit, err := proj.CommitDirect(CommitDirectParams{
PayloadUnion: payUn,
Author: strings.Join(authors, ","),
ParentHash: ontoCommit.Hash,
GitTree: info.endTree,
})
if err != nil {
return Commit{}, fmt.Errorf("storing commit: %w", err)
}
// set the onto branch to this new commit
newHeadRef := plumbing.NewHashReference(ontoBranchName, commit.Hash)
if err := proj.GitRepo.Storer.SetReference(newHeadRef); err != nil {
return Commit{}, fmt.Errorf("setting reference %q to new commit hash %q: %w",
ontoBranchName, commit.Hash, err)
}
return commit, nil
}

@ -9,9 +9,9 @@ import (
"gopkg.in/src-d/go-git.v4/plumbing"
)
func TestChangeCommitVerify(t *testing.T) {
func TestPayloadChangeVerify(t *testing.T) {
type step struct {
msg string
descr string
msgHead string // defaults to msg
tree map[string]string
}
@ -23,7 +23,7 @@ func TestChangeCommitVerify(t *testing.T) {
descr: "single commit",
steps: []step{
{
msg: "first commit",
descr: "first commit",
tree: map[string]string{"a": "0", "b": "1"},
},
},
@ -32,18 +32,18 @@ func TestChangeCommitVerify(t *testing.T) {
descr: "multiple commits",
steps: []step{
{
msg: "first commit",
descr: "first commit",
tree: map[string]string{"a": "0", "b": "1"},
},
{
msg: "second commit, changing a",
descr: "second commit, changing a",
tree: map[string]string{"a": "1"},
},
{
msg: "third commit, empty",
descr: "third commit, empty",
},
{
msg: "fourth commit, adding c, removing b",
descr: "fourth commit, adding c, removing b",
tree: map[string]string{"b": "", "c": "2"},
},
},
@ -52,18 +52,18 @@ func TestChangeCommitVerify(t *testing.T) {
descr: "big body commits",
steps: []step{
{
msg: "first commit, single line but with newline\n",
descr: "first commit, single line but with newline\n",
},
{
msg: "second commit, single line but with two newlines\n\n",
descr: "second commit, single line but with two newlines\n\n",
msgHead: "second commit, single line but with two newlines\n\n",
},
{
msg: "third commit, multi-line with one newline\nanother line!",
descr: "third commit, multi-line with one newline\nanother line!",
msgHead: "third commit, multi-line with one newline\n\n",
},
{
msg: "fourth commit, multi-line with two newlines\n\nanother line!",
descr: "fourth commit, multi-line with two newlines\n\nanother line!",
msgHead: "fourth commit, multi-line with two newlines\n\n",
},
},
@ -78,29 +78,29 @@ func TestChangeCommitVerify(t *testing.T) {
for _, step := range test.steps {
h.stage(step.tree)
gitCommit := h.assertCommitChange(verifyShouldSucceed, step.msg, rootSig)
commit := h.assertCommitChange(verifyShouldSucceed, step.descr, rootSig)
if step.msgHead == "" {
step.msgHead = strings.TrimSpace(step.msg) + "\n\n"
step.msgHead = strings.TrimSpace(step.descr) + "\n\n"
}
if !strings.HasPrefix(gitCommit.GitCommit.Message, step.msgHead) {
if !strings.HasPrefix(commit.Object.Message, step.msgHead) {
t.Fatalf("commit message %q does not start with expected head %q",
gitCommit.GitCommit.Message, step.msgHead)
commit.Object.Message, step.msgHead)
}
var actualCommit Commit
if err := actualCommit.UnmarshalText([]byte(gitCommit.GitCommit.Message)); err != nil {
t.Fatalf("error unmarshaling commit body: %v", err)
} else if !reflect.DeepEqual(actualCommit, gitCommit.Commit) {
t.Fatalf("returned change commit:\n%s\ndoes not match actual one:\n%s",
spew.Sdump(gitCommit.Commit), spew.Sdump(actualCommit))
var payUn PayloadUnion
if err := payUn.UnmarshalText([]byte(commit.Object.Message)); err != nil {
t.Fatalf("error unmarshaling commit message: %v", err)
} else if !reflect.DeepEqual(payUn, commit.Payload) {
t.Fatalf("returned change payload:\n%s\ndoes not match actual one:\n%s",
spew.Sdump(commit.Payload), spew.Sdump(payUn))
}
}
})
}
}
func TestCombineCommitChanges(t *testing.T) {
func TestCombinePayloadChanges(t *testing.T) {
h := newHarness(t)
// commit initial config, so the root user can modify it in the next commit
@ -115,8 +115,8 @@ func TestCombineCommitChanges(t *testing.T) {
filters:
- type: branch
pattern: main
- type: commit_type
commit_type: change
- type: payload_type
payload_type: change
- type: signature
any_account: true
count: 2
@ -141,27 +141,24 @@ func TestCombineCommitChanges(t *testing.T) {
fooCommit := h.assertCommitChange(verifyShouldSucceed, "add foo file", rootSig)
// now adding a credential commit from toot should work
credCommitObj, err := h.repo.NewCommitCredential(fooCommit.Interface.StoredHash())
credCommitPayUn, err := h.proj.NewPayloadCredential(fooCommit.Payload.Common.Fingerprint)
if err != nil {
t.Fatal(err)
}
credCommit := h.tryCommit(verifyShouldSucceed, credCommitObj, tootSig)
credCommit := h.tryCommit(verifyShouldSucceed, credCommitPayUn, tootSig)
allCommits, err := h.repo.GetGitCommitRange(
tootCommit.GitCommit.Hash,
credCommit.GitCommit.Hash,
)
allCommits, err := h.proj.GetCommitRange(tootCommit.Hash, credCommit.Hash)
if err != nil {
t.Fatalf("error getting commits: %v", err)
t.Fatalf("getting commits: %v", err)
}
combinedCommit, err := h.repo.CombineCommitChanges(allCommits, MainRefName)
combinedCommit, err := h.proj.CombinePayloadChanges(allCommits, MainRefName)
if err != nil {
t.Fatal(err)
}
// that new commit should have both credentials
creds := combinedCommit.Commit.Common.Credentials
creds := combinedCommit.Payload.Common.Credentials
if len(creds) != 2 {
t.Fatalf("combined commit has %d credentials, not 2", len(creds))
} else if creds[0].AccountID != "root" {
@ -172,15 +169,15 @@ func TestCombineCommitChanges(t *testing.T) {
// double check that the HEAD commit of main got properly set
h.checkout(MainRefName)
mainHead, err := h.repo.GetGitHead()
mainHead, err := h.proj.GetHeadCommit()
if err != nil {
t.Fatal(err)
} else if mainHead.GitCommit.Hash != combinedCommit.GitCommit.Hash {
} else if mainHead.Hash != combinedCommit.Hash {
t.Fatalf("mainHead's should be pointed at %s but is pointed at %s",
combinedCommit.GitCommit.Hash, mainHead.GitCommit.Hash)
} else if err = h.repo.VerifyCommits(MainRefName, []GitCommit{combinedCommit}); err != nil {
combinedCommit.Hash, mainHead.Hash)
} else if err = h.proj.VerifyCommits(MainRefName, []Commit{combinedCommit}); err != nil {
t.Fatalf("unable to verify combined commit: %v", err)
} else if author := combinedCommit.GitCommit.Author.Name; author != "root" {
} else if author := combinedCommit.Object.Author.Name; author != "root" {
t.Fatalf("unexpected author value %q", author)
}
}

@ -0,0 +1,43 @@
package dehub
import (
"errors"
"fmt"
"strings"
)
// PayloadComment describes the structure of a comment payload.
type PayloadComment struct {
Comment string `yaml:"comment"`
}
var _ Payload = PayloadComment{}
// NewPayloadComment constructs a PayloadUnion populated with a PayloadComment.
// The Credentials of the returned PayloadUnion will _not_ be filled in.
func (proj *Project) NewPayloadComment(comment string) (PayloadUnion, error) {
payCom := PayloadComment{Comment: comment}
fingerprint, err := payCom.Fingerprint(nil)
if err != nil {
return PayloadUnion{}, err
}
return PayloadUnion{
Comment: &payCom,
Common: PayloadCommon{Fingerprint: fingerprint},
}, nil
}
// MessageHead implements the method for the Payload interface.
func (payCom PayloadComment) MessageHead(common PayloadCommon) (string, error) {
credIDs := strings.Join(common.credIDs(), ", ")
fullMsgHead := fmt.Sprintf("Comment by %s: %s", credIDs, payCom.Comment)
return abbrevCommitMessage(fullMsgHead), nil
}
// Fingerprint implements the method for the Payload interface.
func (payCom PayloadComment) Fingerprint(changes []ChangedFile) ([]byte, error) {
if len(changes) > 0 {
return nil, errors.New("PayloadComment cannot have any changed files")
}
return genCommentFingerprint(nil, payCom.Comment), nil
}

@ -0,0 +1,73 @@
package dehub
import (
"errors"
"fmt"
"strings"
)
// PayloadCredential describes the structure of a credential payload.
type PayloadCredential struct {
// CommitHashes represents the commits which this credential is accrediting.
// It is only present for informational purposes, as commits don't not have
// any bearing on the CredentialedHash itself.
CommitHashes []string `yaml:"commits,omitempty"`
}
var _ Payload = PayloadCredential{}
// NewPayloadCredential constructs and returns a PayloadUnion populated with a
// PayloadCredential for the given fingerprint. The Credentials of the returned
// PayloadUnion will _not_ be filled in.
func (proj *Project) NewPayloadCredential(fingerprint []byte) (PayloadUnion, error) {
return PayloadUnion{
Credential: &PayloadCredential{},
Common: PayloadCommon{Fingerprint: fingerprint},
}, nil
}
// NewPayloadCredentialFromChanges constructs and returns a PayloadUnion
// populated with a PayloadCredential. The fingerprint of the payload will be a
// change fingerprint encompassing all changes in the given range of Commits.
// The description of the last change payload in the range is used when
// generating the fingerprint.
func (proj *Project) NewPayloadCredentialFromChanges(commits []Commit) (PayloadUnion, error) {
info, err := proj.changeRangeInfo(commits)
if err != nil {
return PayloadUnion{}, err
}
payCred, err := proj.NewPayloadCredential(info.changeFingerprint)
if err != nil {
return PayloadUnion{}, err
}
for _, commit := range info.changeCommits {
payCred.Credential.CommitHashes = append(
payCred.Credential.CommitHashes,
commit.Hash.String(),
)
}
return payCred, nil
}
// MessageHead implements the method for the Payload interface.
func (payCred PayloadCredential) MessageHead(common PayloadCommon) (string, error) {
fingerprintStr := common.Fingerprint.String()
if len(fingerprintStr) > 9 {
fingerprintStr = fingerprintStr[:6] + "..."
}
credIDs := strings.Join(common.credIDs(), ", ")
fullMsgHead := fmt.Sprintf("Credential of hash %s by %s", fingerprintStr, credIDs)
return abbrevCommitMessage(fullMsgHead), nil
}
// Fingerprint implements the method for the Payload interface.
func (payCred PayloadCredential) Fingerprint(changes []ChangedFile) ([]byte, error) {
if len(changes) > 0 {
return nil, errors.New("PayloadCredential cannot have any changed files")
}
// a PayloadCredential can't compute its own fingerprint, it's stored in the
// common.
return nil, nil
}

@ -6,7 +6,7 @@ import (
"gopkg.in/src-d/go-git.v4/plumbing"
)
func TestCredentialCommitVerify(t *testing.T) {
func TestPayloadCredentialVerify(t *testing.T) {
h := newHarness(t)
rootSig := h.stageNewAccount("root", false)
@ -36,15 +36,15 @@ func TestCredentialCommitVerify(t *testing.T) {
// toot user wants to create a credential commit for the root commit, for
// whatever reason.
rootChangeHash := rootGitCommit.Commit.Change.ChangeHash
credCommit, err := h.repo.NewCommitCredential(rootChangeHash)
rootChangeFingerprint := rootGitCommit.Payload.Common.Fingerprint
credCommitPayUn, err := h.proj.NewPayloadCredential(rootChangeFingerprint)
if err != nil {
t.Fatalf("creating credential commit for hash %x: %v", rootChangeHash, err)
t.Fatalf("creating credential commit for fingerprint %x: %v", rootChangeFingerprint, err)
}
h.tryCommit(verifyShouldFail, credCommit, tootSig)
h.tryCommit(verifyShouldFail, credCommitPayUn, tootSig)
// toot tries again in their own branch, and should be allowed.
h.checkout(tootBranch)
h.tryCommit(verifyShouldSucceed, credCommit, tootSig)
h.tryCommit(verifyShouldSucceed, credCommitPayUn, tootSig)
}

@ -15,12 +15,12 @@ func TestConfigChange(t *testing.T) {
h := newHarness(t)
rootSig := h.stageNewAccount("root", false)
var gitCommits []GitCommit
var commits []Commit
// commit the initial staged changes, which merely include the config and
// public key
gitCommit := h.assertCommitChange(verifyShouldSucceed, "commit configuration", rootSig)
gitCommits = append(gitCommits, gitCommit)
commit := h.assertCommitChange(verifyShouldSucceed, "commit configuration", rootSig)
commits = append(commits, commit)
// create a new account and add it to the configuration. That commit should
// not be verifiable, though
@ -30,15 +30,15 @@ func TestConfigChange(t *testing.T) {
// now add with the root user, this should work.
h.stageCfg()
gitCommit = h.assertCommitChange(verifyShouldSucceed, "add toot user", rootSig)
gitCommits = append(gitCommits, gitCommit)
commit = h.assertCommitChange(verifyShouldSucceed, "add toot user", rootSig)
commits = append(commits, commit)
// _now_ the toot user should be able to do things.
h.stage(map[string]string{"foo/bar": "what a cool file"})
gitCommit = h.assertCommitChange(verifyShouldSucceed, "add a cool file", tootSig)
gitCommits = append(gitCommits, gitCommit)
commit = h.assertCommitChange(verifyShouldSucceed, "add a cool file", tootSig)
commits = append(commits, commit)
if err := h.repo.VerifyCommits(MainRefName, gitCommits); err != nil {
if err := h.proj.VerifyCommits(MainRefName, commits); err != nil {
t.Fatal(err)
}
}
@ -62,7 +62,7 @@ func TestMainAncestryRequirement(t *testing.T) {
// 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 {
if err := h.proj.GitRepo.Storer.SetReference(ref); err != nil {
h.t.Fatal(err)
}
@ -93,15 +93,15 @@ func TestNonFastForwardCommits(t *testing.T) {
h.stage(map[string]string{"foo": "foo"})
fooCommit := h.assertCommitChange(verifyShouldSucceed, "foo", rootSig)
commitOn := func(hash plumbing.Hash, msg string) GitCommit {
commitOn := func(hash plumbing.Hash, msg string) Commit {
ref := plumbing.NewHashReference(plumbing.HEAD, hash)
if err := h.repo.GitRepo.Storer.SetReference(ref); err != nil {
if err := h.proj.GitRepo.Storer.SetReference(ref); err != nil {
h.t.Fatal(err)
} else if commitChange, err := h.repo.NewCommitChange("bar"); err != nil {
} else if commitChange, err := h.proj.NewPayloadChange("bar"); err != nil {
h.t.Fatal(err)
} else if commitChange, err = h.repo.AccreditCommit(commitChange, rootSig); err != nil {
} else if commitChange, err = h.proj.AccreditPayload(commitChange, rootSig); err != nil {
h.t.Fatal(err)
} else if gitCommit, err := h.repo.Commit(commitChange); err != nil {
} else if gitCommit, err := h.proj.Commit(commitChange); err != nil {
h.t.Fatal(err)
} else {
return gitCommit
@ -112,8 +112,8 @@ func TestNonFastForwardCommits(t *testing.T) {
// checkout initCommit directly, make a new commit on top of it, and try to
// verify that (this is too fancy for the harness, must be done manually).
h.stage(map[string]string{"bar": "bar"})
barCommit := commitOn(initCommit.GitCommit.Hash, "bar")
err := h.repo.VerifyCommits(MainRefName, []GitCommit{barCommit})
barCommit := commitOn(initCommit.Hash, "bar")
err := h.proj.VerifyCommits(MainRefName, []Commit{barCommit})
if !errors.As(err, new(accessctl.ErrCommitRequestDenied)) {
h.t.Fatalf("expected ErrCommitRequestDenied, got: %v", err)
}
@ -135,14 +135,14 @@ func TestNonFastForwardCommits(t *testing.T) {
// checking out allowNonFFCommit directly and performing a nonFF commit
// should work now.
h.stage(map[string]string{"baz": "baz"})
bazCommit := commitOn(allowNonFFCommit.GitCommit.Hash, "baz")
if err = h.repo.VerifyCommits(MainRefName, []GitCommit{bazCommit}); err != nil {
bazCommit := commitOn(allowNonFFCommit.Hash, "baz")
if err = h.proj.VerifyCommits(MainRefName, []Commit{bazCommit}); err != nil {
h.t.Fatal(err)
}
// verifying the full history should also work
gitCommits := []GitCommit{initCommit, fooCommit, allowNonFFCommit, bazCommit}
if err = h.repo.VerifyCommits(MainRefName, gitCommits); err != nil {
gitCommits := []Commit{initCommit, fooCommit, allowNonFFCommit, bazCommit}
if err = h.proj.VerifyCommits(MainRefName, gitCommits); err != nil {
h.t.Fatal(err)
}
}
@ -161,7 +161,7 @@ func TestCanSetBranchHEADTo(t *testing.T) {
type test struct {
descr string
init func(h *harness, rootSig sigcred.SignifierInterface) toTest
init func(h *harness, rootSig sigcred.Signifier) toTest
// If true then the verify call is expected to fail. The string is a
// regex which should match the unwrapped error returned.
@ -171,7 +171,7 @@ func TestCanSetBranchHEADTo(t *testing.T) {
tests := []test{
{
descr: "creation of main",
init: func(h *harness, rootSig sigcred.SignifierInterface) toTest {
init: func(h *harness, rootSig sigcred.Signifier) toTest {
// checkout other and build on top of that, so that when
// VerifyCanSetBranchHEADTo is called main won't exist.
other := plumbing.NewBranchReferenceName("other")
@ -180,26 +180,26 @@ func TestCanSetBranchHEADTo(t *testing.T) {
initCommit := h.assertCommitChange(verifySkip, "init", rootSig)
return toTest{
branchName: MainRefName,
hash: initCommit.GitCommit.Hash,
hash: initCommit.Hash,
}
},
},
{
descr: "main ff",
init: func(h *harness, rootSig sigcred.SignifierInterface) toTest {
init: func(h *harness, rootSig sigcred.Signifier) toTest {
initCommit := h.assertCommitChange(verifySkip, "init", rootSig)
h.stage(map[string]string{"foo": "foo"})
nextCommit := h.assertCommitChange(verifySkip, "next", rootSig)
return toTest{
branchName: MainRefName,
hash: nextCommit.GitCommit.Hash,
resetTo: initCommit.GitCommit.Hash,
hash: nextCommit.Hash,
resetTo: initCommit.Hash,
}
},
},
{
descr: "new branch, no main",
init: func(h *harness, rootSig sigcred.SignifierInterface) toTest {
init: func(h *harness, rootSig sigcred.Signifier) toTest {
// checkout other and build on top of that, so that when
// VerifyCanSetBranchHEADTo is called main won't exist.
other := plumbing.NewBranchReferenceName("other")
@ -208,7 +208,7 @@ func TestCanSetBranchHEADTo(t *testing.T) {
initCommit := h.assertCommitChange(verifySkip, "init", rootSig)
return toTest{
branchName: plumbing.NewBranchReferenceName("other2"),
hash: initCommit.GitCommit.Hash,
hash: initCommit.Hash,
}
},
expErr: `^cannot verify commits in branch "refs/heads/other2" when no main branch exists$`,
@ -217,7 +217,7 @@ func TestCanSetBranchHEADTo(t *testing.T) {
// this case isn't generally possible, unless someone manually
// creates a branch in an empty repo on the remote
descr: "existing branch, no main",
init: func(h *harness, rootSig sigcred.SignifierInterface) toTest {
init: func(h *harness, rootSig sigcred.Signifier) toTest {
// checkout other and build on top of that, so that when
// VerifyCanSetBranchHEADTo is called main won't exist.
other := plumbing.NewBranchReferenceName("other")
@ -229,21 +229,21 @@ func TestCanSetBranchHEADTo(t *testing.T) {
return toTest{
branchName: other,
hash: fooCommit.GitCommit.Hash,
resetTo: initCommit.GitCommit.Hash,
hash: fooCommit.Hash,
resetTo: initCommit.Hash,
}
},
expErr: `^cannot verify commits in branch "refs/heads/other" when no main branch exists$`,
},
{
descr: "new branch, not ancestor of main",
init: func(h *harness, rootSig sigcred.SignifierInterface) toTest {
init: func(h *harness, rootSig sigcred.Signifier) toTest {
h.assertCommitChange(verifySkip, "init", rootSig)
// create new branch with no HEAD, and commit on that.
other := plumbing.NewBranchReferenceName("other")
ref := plumbing.NewSymbolicReference(plumbing.HEAD, other)
if err := h.repo.GitRepo.Storer.SetReference(ref); err != nil {
if err := h.proj.GitRepo.Storer.SetReference(ref); err != nil {
t.Fatal(err)
}
@ -252,7 +252,7 @@ func TestCanSetBranchHEADTo(t *testing.T) {
badInitCommit := h.assertCommitChange(verifySkip, "a different init", rootSig)
return toTest{
branchName: plumbing.NewBranchReferenceName("other2"),
hash: badInitCommit.GitCommit.Hash,
hash: badInitCommit.Hash,
}
},
expErr: `^commit "[0-9a-f]+" must be direct descendant of root commit of "main" \("[0-9a-f]+"\)$`,
@ -261,13 +261,13 @@ func TestCanSetBranchHEADTo(t *testing.T) {
// this case isn't generally possible, unless someone manually
// creates a branch in an empty repo on the remote
descr: "existing branch, not ancestor of main",
init: func(h *harness, rootSig sigcred.SignifierInterface) toTest {
init: func(h *harness, rootSig sigcred.Signifier) toTest {
h.assertCommitChange(verifySkip, "init", rootSig)
// create new branch with no HEAD, and commit on that.
other := plumbing.NewBranchReferenceName("other")
ref := plumbing.NewSymbolicReference(plumbing.HEAD, other)
if err := h.repo.GitRepo.Storer.SetReference(ref); err != nil {
if err := h.proj.GitRepo.Storer.SetReference(ref); err != nil {
t.Fatal(err)
}
@ -280,15 +280,15 @@ func TestCanSetBranchHEADTo(t *testing.T) {
return toTest{
branchName: other,
hash: barCommit.GitCommit.Hash,
resetTo: badInitCommit.GitCommit.Hash,
hash: barCommit.Hash,
resetTo: badInitCommit.Hash,
}
},
expErr: `^commit "[0-9a-f]+" must be direct descendant of root commit of "main" \("[0-9a-f]+"\)$`,
},
{
descr: "new branch off of main",
init: func(h *harness, rootSig sigcred.SignifierInterface) toTest {
init: func(h *harness, rootSig sigcred.Signifier) toTest {
initCommit := h.assertCommitChange(verifySkip, "init", rootSig)
other := plumbing.NewBranchReferenceName("other")
@ -298,14 +298,14 @@ func TestCanSetBranchHEADTo(t *testing.T) {
return toTest{
branchName: other,
hash: fooCommit.GitCommit.Hash,
resetTo: initCommit.GitCommit.Hash,
hash: fooCommit.Hash,
resetTo: initCommit.Hash,
}
},
},
{
descr: "new branch off of older main commit",
init: func(h *harness, rootSig sigcred.SignifierInterface) toTest {
init: func(h *harness, rootSig sigcred.Signifier) toTest {
initCommit := h.assertCommitChange(verifySkip, "init", rootSig)
h.stage(map[string]string{"foo": "foo"})
@ -313,26 +313,26 @@ func TestCanSetBranchHEADTo(t *testing.T) {
other := plumbing.NewBranchReferenceName("other")
h.checkout(other)
h.reset(initCommit.GitCommit.Hash, git.HardReset)
h.reset(initCommit.Hash, git.HardReset)
h.stage(map[string]string{"bar": "bar"})
barCommit := h.assertCommitChange(verifySkip, "bar", rootSig)
return toTest{
branchName: other,
hash: barCommit.GitCommit.Hash,
resetTo: initCommit.GitCommit.Hash,
hash: barCommit.Hash,
resetTo: initCommit.Hash,
}
},
},
{
descr: "branch ff",
init: func(h *harness, rootSig sigcred.SignifierInterface) toTest {
init: func(h *harness, rootSig sigcred.Signifier) toTest {
h.assertCommitChange(verifySkip, "init", rootSig)
other := plumbing.NewBranchReferenceName("other")
h.checkout(other)
var commits []GitCommit
var commits []Commit
for _, str := range []string{"foo", "bar", "baz", "biz", "buz"} {
h.stage(map[string]string{str: str})
commit := h.assertCommitChange(verifySkip, str, rootSig)
@ -341,14 +341,14 @@ func TestCanSetBranchHEADTo(t *testing.T) {
return toTest{
branchName: other,
hash: commits[len(commits)-1].GitCommit.Hash,
resetTo: commits[0].GitCommit.Hash,
hash: commits[len(commits)-1].Hash,
resetTo: commits[0].Hash,
}
},
},
{
descr: "main nonff",
init: func(h *harness, rootSig sigcred.SignifierInterface) toTest {
init: func(h *harness, rootSig sigcred.Signifier) toTest {
initCommit := h.assertCommitChange(verifySkip, "init", rootSig)
h.stage(map[string]string{"foo": "foo"})
h.assertCommitChange(verifySkip, "foo", rootSig)
@ -356,20 +356,20 @@ func TestCanSetBranchHEADTo(t *testing.T) {
// start another branch back at init and make a new commit on it
other := plumbing.NewBranchReferenceName("other")
h.checkout(other)
h.reset(initCommit.GitCommit.Hash, git.HardReset)
h.reset(initCommit.Hash, git.HardReset)
h.stage(map[string]string{"bar": "bar"})
barCommit := h.assertCommitChange(verifySkip, "bar", rootSig)
return toTest{
branchName: MainRefName,
hash: barCommit.GitCommit.Hash,
hash: barCommit.Hash,
}
},
expErr: `^commit matched and denied by this access control:`,
},
{
descr: "branch nonff",
init: func(h *harness, rootSig sigcred.SignifierInterface) toTest {
init: func(h *harness, rootSig sigcred.Signifier) toTest {
h.assertCommitChange(verifySkip, "init", rootSig)
other := plumbing.NewBranchReferenceName("other")
@ -381,13 +381,13 @@ func TestCanSetBranchHEADTo(t *testing.T) {
other2 := plumbing.NewBranchReferenceName("other2")
h.checkout(other2)
h.reset(fooCommit.GitCommit.Hash, git.HardReset)
h.reset(fooCommit.Hash, git.HardReset)
h.stage(map[string]string{"baz": "baz"})
bazCommit := h.assertCommitChange(verifySkip, "baz", rootSig)
return toTest{
branchName: other,
hash: bazCommit.GitCommit.Hash,
hash: bazCommit.Hash,
}
},
},
@ -401,12 +401,12 @@ func TestCanSetBranchHEADTo(t *testing.T) {
if toTest.resetTo != plumbing.ZeroHash {
ref := plumbing.NewHashReference(toTest.branchName, toTest.resetTo)
if err := h.repo.GitRepo.Storer.SetReference(ref); err != nil {
if err := h.proj.GitRepo.Storer.SetReference(ref); err != nil {
t.Fatal(err)
}
}
err := h.repo.VerifyCanSetBranchHEADTo(toTest.branchName, toTest.hash)
err := h.proj.VerifyCanSetBranchHEADTo(toTest.branchName, toTest.hash)
if test.expErr == "" {
if err != nil {
t.Fatalf("unexpected error: %v", err)

@ -0,0 +1,326 @@
// Package dehub TODO needs package docs
package dehub
import (
"bytes"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"dehub.dev/src/dehub.git/fs"
"gopkg.in/src-d/go-billy.v4"
"gopkg.in/src-d/go-billy.v4/memfs"
"gopkg.in/src-d/go-git.v4"
"gopkg.in/src-d/go-git.v4/plumbing"
"gopkg.in/src-d/go-git.v4/plumbing/cache"
"gopkg.in/src-d/go-git.v4/plumbing/format/config"
"gopkg.in/src-d/go-git.v4/storage"
"gopkg.in/src-d/go-git.v4/storage/filesystem"
)
const (
// DehubDir defines the name of the directory where all dehub-related files
// are expected to be found within the git repo.
DehubDir = ".dehub"
)
var (
// ConfigPath defines the expected path to the Project's configuration file.
ConfigPath = filepath.Join(DehubDir, "config.yml")
// Main defines the name of the main branch.
Main = "main"
// MainRefName defines the reference name of the main branch.
MainRefName = plumbing.NewBranchReferenceName(Main)
)
type openOpts struct {
bare bool
}
// OpenOption is an option which can be passed to the OpenProject function to
// affect the Project's behavior.
type OpenOption func(*openOpts)
// OpenBareRepo returns an OpenOption which, if true is given, causes the
// OpenProject function to expect to open a bare git repo.
func OpenBareRepo(bare bool) OpenOption {
return func(o *openOpts) {
o.bare = bare
}
}
// Project implements accessing and modifying a local dehub project, as well as
// extending the functionality of the underlying git repo in ways which are
// specifically useful for dehub projects.
type Project struct {
// GitRepo is the git repository which houses the project.
GitRepo *git.Repository
// GitDirFS corresponds to the .git directory (or the entire repo directory
// if it's a bare repo)
GitDirFS billy.Filesystem
}
func extractGitDirFS(storer storage.Storer) (billy.Filesystem, error) {
dotGitFSer, ok := storer.(interface{ Filesystem() billy.Filesystem })
if !ok {
return nil, fmt.Errorf("git storage object of type %T does not expose its underlying filesystem",
storer)
}
return dotGitFSer.Filesystem(), nil
}
// OpenProject opens the dehub project in the given directory and returns a
// Project instance for it.
//
// The given path is expected to have a git repo already initialized.
func OpenProject(path string, options ...OpenOption) (*Project, error) {
var opts openOpts
for _, opt := range options {
opt(&opts)
}
proj := Project{}
var err error
openOpts := &git.PlainOpenOptions{
DetectDotGit: !opts.bare,
}
if proj.GitRepo, err = git.PlainOpenWithOptions(path, openOpts); err != nil {
return nil, fmt.Errorf("opening git repo: %w", err)
} else if proj.GitDirFS, err = extractGitDirFS(proj.GitRepo.Storer); err != nil {
return nil, err
}
return &proj, nil
}
type initOpts struct {
bare bool
remote bool
}
// InitOption is an option which can be passed into the Init functions to affect
// their behavior.
type InitOption func(*initOpts)
// InitBareRepo returns an InitOption which, if true is given, causes the Init
// function to initialize the project's git repo without a worktree.
func InitBareRepo(bare bool) InitOption {
return func(o *initOpts) {
o.bare = bare
}
}
// InitRemoteRepo returns an InitOption which, if true is given, causes the Init
// function to initialize the project's git repo with certain git configuration
// options set which make the repo able to be used as a remote repo.
func InitRemoteRepo(remote bool) InitOption {
return func(o *initOpts) {
o.remote = remote
}
}
// InitProject will initialize a new project at the given path. If bare is true
// then the project's git repo will not have a worktree.
func InitProject(path string, options ...InitOption) (*Project, error) {
var opts initOpts
for _, opt := range options {
opt(&opts)
}
var proj Project
var err error
if proj.GitRepo, err = git.PlainInit(path, opts.bare); err != nil {
return nil, fmt.Errorf("initializing git repo: %w", err)
} else if proj.GitDirFS, err = extractGitDirFS(proj.GitRepo.Storer); err != nil {
return nil, err
} else if err = proj.init(opts); err != nil {
return nil, fmt.Errorf("initializing repo with dehub defaults: %w", err)
}
return &proj, nil
}
// InitMemProject initializes an empty project which only exists in memory.
func InitMemProject(options ...InitOption) *Project {
var opts initOpts
for _, opt := range options {
opt(&opts)
}
fs := memfs.New()
dotGitFS, err := fs.Chroot(git.GitDirName)
if err != nil {
panic(err)
}
storage := filesystem.NewStorage(dotGitFS, cache.NewObjectLRUDefault())
var worktree billy.Filesystem
if !opts.bare {
worktree = fs
}
r, err := git.Init(storage, worktree)
if err != nil {
panic(err)
}
proj := &Project{GitRepo: r, GitDirFS: dotGitFS}
if err := proj.init(opts); err != nil {
panic(err)
}
return proj
}
func (proj *Project) initRemotePreReceive(bare bool) error {
if err := proj.GitDirFS.MkdirAll("hooks", 0755); err != nil {
return fmt.Errorf("creating hooks directory: %w", err)
}
preRcvFlags := os.O_WRONLY | os.O_CREATE | os.O_TRUNC
preRcv, err := proj.GitDirFS.OpenFile("hooks/pre-receive", preRcvFlags, 0755)
if err != nil {
return fmt.Errorf("opening hooks/pre-receive file: %w", err)
}
defer preRcv.Close()
var preRcvBody string
if bare {
preRcvBody = "#!/bin/sh\nexec dehub hook -bare -pre-receive\n"
} else {
preRcvBody = "#!/bin/sh\nexec dehub hook -pre-receive\n"
}
if _, err := io.Copy(preRcv, bytes.NewBufferString(preRcvBody)); err != nil {
return fmt.Errorf("writing to hooks/pre-receive: %w", err)
}
return nil
}
func (proj *Project) init(opts initOpts) error {
headRef := plumbing.NewSymbolicReference(plumbing.HEAD, MainRefName)
if err := proj.GitRepo.Storer.SetReference(headRef); err != nil {
return fmt.Errorf("setting HEAD reference to %q: %w", MainRefName, err)
}
if opts.remote {
cfg, err := proj.GitRepo.Config()
if err != nil {
return fmt.Errorf("opening git cfg: %w", err)
}
cfg.Raw = cfg.Raw.AddOption("http", config.NoSubsection, "receivepack", "true")
if err := proj.GitRepo.Storer.SetConfig(cfg); err != nil {
return fmt.Errorf("storing modified git config: %w", err)
}
if err := proj.initRemotePreReceive(opts.bare); err != nil {
return fmt.Errorf("initializing pre-receive hook for remote-enabled repo: %w", err)
}
}
return nil
}
func (proj *Project) billyFilesystem() (billy.Filesystem, error) {
w, err := proj.GitRepo.Worktree()
if err != nil {
return nil, fmt.Errorf("opening git worktree: %w", err)
}
return w.Filesystem, nil
}
var errTraverseRefNoMatch = errors.New("failed to find reference matching given predicate")
// TraverseReferenceChain resolves a chain of references, calling the given
// predicate on each one, and returning the first one for which the predicate
// returns true. This method will return an error if it reaches the end of the
// chain and the predicate still has not returned true.
//
// If a reference name is encountered which does not actually exist, then it is
// assumed to be a hash reference to the zero hash.
func (proj *Project) TraverseReferenceChain(refName plumbing.ReferenceName, pred func(*plumbing.Reference) bool) (*plumbing.Reference, error) {
// TODO infinite loop checking
// TODO check that this (and the methods which use it) are actually useful
for {
ref, err := proj.GitRepo.Storer.Reference(refName)
if errors.Is(err, plumbing.ErrReferenceNotFound) {
ref = plumbing.NewHashReference(refName, plumbing.ZeroHash)
} else if err != nil {
return nil, fmt.Errorf("resolving reference %q: %w", refName, err)
}
if pred(ref) {
return ref, nil
} else if ref.Type() != plumbing.SymbolicReference {
return nil, errTraverseRefNoMatch
}
refName = ref.Target()
}
}
// ErrNoBranchReference is returned from ReferenceToBranchName if no reference
// in the reference chain is for a branch.
var ErrNoBranchReference = errors.New("no branch reference found")
// ReferenceToBranchName traverses a chain of references looking for the first
// branch reference, and returns that name, or returns ErrNoBranchReference if
// no branch reference is part of the chain.
func (proj *Project) ReferenceToBranchName(refName plumbing.ReferenceName) (plumbing.ReferenceName, error) {
// first check if the given refName is a branch, if so just return that.
if refName.IsBranch() {
return refName, nil
}
ref, err := proj.TraverseReferenceChain(refName, func(ref *plumbing.Reference) bool {
return ref.Target().IsBranch()
})
if errors.Is(err, errTraverseRefNoMatch) {
return "", ErrNoBranchReference
} else if err != nil {
return "", fmt.Errorf("traversing reference chain: %w", err)
}
return ref.Target(), nil
}
// ReferenceToHash fully resolves a reference to a hash. If a reference cannot
// be resolved then plumbing.ZeroHash is returned.
func (proj *Project) ReferenceToHash(refName plumbing.ReferenceName) (plumbing.Hash, error) {
ref, err := proj.TraverseReferenceChain(refName, func(ref *plumbing.Reference) bool {
return ref.Type() == plumbing.HashReference
})
if errors.Is(err, errTraverseRefNoMatch) {
return plumbing.ZeroHash, errors.New("no hash in reference chain (is this even possible???)")
} else if errors.Is(err, plumbing.ErrReferenceNotFound) {
return plumbing.ZeroHash, nil
} else if err != nil {
return plumbing.ZeroHash, fmt.Errorf("traversing reference chain: %w", err)
}
return ref.Hash(), nil
}
// headFS returns an FS based on the HEAD commit, or if there is no HEAD commit
// (it's an empty repo) an FS based on the raw filesystem.
func (proj *Project) headFS() (fs.FS, error) {
head, err := proj.GetHeadCommit()
if errors.Is(err, ErrHeadIsZero) {
bfs, err := proj.billyFilesystem()
if err != nil {
return nil, fmt.Errorf("getting underlying filesystem: %w", err)
}
return fs.FromBillyFilesystem(bfs), nil
} else if err != nil {
return nil, fmt.Errorf("could not get HEAD tree: %w", err)
}
return fs.FromTree(head.TreeObject), nil
}

@ -18,7 +18,7 @@ import (
type harness struct {
t *testing.T
rand *rand.Rand
repo *Repo
proj *Project
cfg *Config
}
@ -27,13 +27,13 @@ func newHarness(t *testing.T) *harness {
return &harness{
t: t,
rand: rand,
repo: InitMemRepo(),
proj: InitMemProject(),
cfg: new(Config),
}
}
func (h *harness) stage(tree map[string]string) {
w, err := h.repo.GitRepo.Worktree()
w, err := h.proj.GitRepo.Worktree()
if err != nil {
h.t.Fatal(err)
}
@ -41,28 +41,28 @@ func (h *harness) stage(tree map[string]string) {
for path, content := range tree {
if content == "" {
if _, err := w.Remove(path); err != nil {
h.t.Fatalf("error removing %q: %v", path, err)
h.t.Fatalf("removing %q: %v", path, err)
}
continue
}
dir := filepath.Dir(path)
if err := fs.MkdirAll(dir, 0666); err != nil {
h.t.Fatalf("error making directory %q: %v", dir, err)
h.t.Fatalf("making directory %q: %v", dir, err)
}
f, err := fs.Create(path)
if err != nil {
h.t.Fatalf("error creating file %q: %v", path, err)
h.t.Fatalf("creating file %q: %v", path, err)
} else if _, err := io.Copy(f, bytes.NewBufferString(content)); err != nil {
h.t.Fatalf("error writing to file %q: %v", path, err)
h.t.Fatalf("writing to file %q: %v", path, err)
} else if err := f.Close(); err != nil {
h.t.Fatalf("error closing file %q: %v", path, err)
h.t.Fatalf("closing file %q: %v", path, err)
} else if _, err := w.Add(path); err != nil {
h.t.Fatalf("error adding file %q to index: %v", path, err)
h.t.Fatalf("adding file %q to index: %v", path, err)
}
}
}
@ -75,12 +75,12 @@ func (h *harness) stageCfg() {
h.stage(map[string]string{ConfigPath: string(cfgBody)})
}
func (h *harness) stageNewAccount(accountID string, anon bool) sigcred.SignifierInterface {
func (h *harness) stageNewAccount(accountID string, anon bool) sigcred.Signifier {
sig, pubKeyBody := sigcred.TestSignifierPGP(accountID, anon, h.rand)
if !anon {
h.cfg.Accounts = append(h.cfg.Accounts, Account{
ID: accountID,
Signifiers: []sigcred.Signifier{{PGPPublicKey: &sigcred.SignifierPGP{
Signifiers: []sigcred.SignifierUnion{{PGPPublicKey: &sigcred.SignifierPGP{
Body: string(pubKeyBody),
}}},
})
@ -97,17 +97,17 @@ func (h *harness) stageAccessControls(aclYAML string) {
}
func (h *harness) checkout(branch plumbing.ReferenceName) {
w, err := h.repo.GitRepo.Worktree()
w, err := h.proj.GitRepo.Worktree()
if err != nil {
h.t.Fatal(err)
}
head, err := h.repo.GetGitHead()
head, err := h.proj.GetHeadCommit()
if errors.Is(err, ErrHeadIsZero) {
// if HEAD is not 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 {
if err := h.proj.GitRepo.Storer.SetReference(ref); err != nil {
h.t.Fatal(err)
}
return
@ -115,10 +115,10 @@ func (h *harness) checkout(branch plumbing.ReferenceName) {
h.t.Fatal(err)
}
_, err = h.repo.GitRepo.Storer.Reference(branch)
_, err = h.proj.GitRepo.Storer.Reference(branch)
if errors.Is(err, plumbing.ErrReferenceNotFound) {
err = w.Checkout(&git.CheckoutOptions{
Hash: head.GitCommit.Hash,
Hash: head.Hash,
Branch: branch,
Create: true,
})
@ -136,7 +136,7 @@ func (h *harness) checkout(branch plumbing.ReferenceName) {
}
func (h *harness) reset(to plumbing.Hash, mode git.ResetMode) {
w, err := h.repo.GitRepo.Worktree()
w, err := h.proj.GitRepo.Worktree()
if err != nil {
h.t.Fatal(err)
}
@ -160,65 +160,65 @@ const (
func (h *harness) tryCommit(
verifyExp verifyExpectation,
commit Commit,
accountSig sigcred.SignifierInterface,
) GitCommit {
payUn PayloadUnion,
accountSig sigcred.Signifier,
) Commit {
if accountSig != nil {
var err error
if commit, err = h.repo.AccreditCommit(commit, accountSig); err != nil {
h.t.Fatalf("accrediting commit: %v", err)
if payUn, err = h.proj.AccreditPayload(payUn, accountSig); err != nil {
h.t.Fatalf("accrediting payload: %v", err)
}
}
gitCommit, err := h.repo.Commit(commit)
commit, err := h.proj.Commit(payUn)
if err != nil {
h.t.Fatalf("failed to commit ChangeCommit: %v", err)
h.t.Fatalf("committing PayloadChange: %v", err)
} else if verifyExp == verifySkip {
return gitCommit
return commit
}
branch, err := h.repo.ReferenceToBranchName(plumbing.HEAD)
branch, err := h.proj.ReferenceToBranchName(plumbing.HEAD)
if err != nil {
h.t.Fatalf("determining checked out branch: %v", err)
}
shouldSucceed := verifyExp > 0
err = h.repo.VerifyCommits(branch, []GitCommit{gitCommit})
err = h.proj.VerifyCommits(branch, []Commit{commit})
if shouldSucceed && err != nil {
h.t.Fatalf("verifying commit %q: %v", gitCommit.GitCommit.Hash, err)
h.t.Fatalf("verifying commit %q: %v", commit.Hash, err)
} else if shouldSucceed {
return gitCommit
return commit
} else if !shouldSucceed && err == nil {
h.t.Fatalf("verifying commit %q should have failed", gitCommit.GitCommit.Hash)
h.t.Fatalf("verifying commit %q should have failed", commit.Hash)
}
var parentHash plumbing.Hash
if gitCommit.GitCommit.NumParents() > 0 {
parentHash = gitCommit.GitCommit.ParentHashes[0]
if commit.Object.NumParents() > 0 {
parentHash = commit.Object.ParentHashes[0]
}
h.reset(parentHash, git.HardReset)
return gitCommit
return commit
}
func (h *harness) assertCommitChange(
verifyExp verifyExpectation,
msg string,
sig sigcred.SignifierInterface,
) GitCommit {
commit, err := h.repo.NewCommitChange(msg)
sig sigcred.Signifier,
) Commit {
payUn, err := h.proj.NewPayloadChange(msg)
if err != nil {
h.t.Fatalf("creating ChangeCommit: %v", err)
h.t.Fatalf("creating PayloadChange: %v", err)
}
return h.tryCommit(verifyExp, commit, sig)
return h.tryCommit(verifyExp, payUn, sig)
}
func TestHasStagedChanges(t *testing.T) {
h := newHarness(t)
rootSig := h.stageNewAccount("root", false)
assertHasStaged := func(expHasStaged bool) {
hasStaged, err := h.repo.HasStagedChanges()
hasStaged, err := h.proj.HasStagedChanges()
if err != nil {
t.Fatalf("error calling HasStagedChanges: %v", err)
} else if hasStaged != expHasStaged {
@ -240,31 +240,30 @@ func TestHasStagedChanges(t *testing.T) {
assertHasStaged(false)
}
// TestThisRepoStillVerifies opens this actual repository and ensures that all
// commits in it still verify, given this codebase.
func TestThisRepoStillVerifies(t *testing.T) {
repo, err := OpenRepo(".")
// TestThisProjectStillVerifies opens this actual project and ensures that all
// commits in it still verify.
func TestThisProjectStillVerifies(t *testing.T) {
proj, err := OpenProject(".")
if err != nil {
t.Fatalf("error opening repo: %v", err)
}
headGitCommit, err := repo.GetGitHead()
headCommit, err := proj.GetHeadCommit()
if err != nil {
t.Fatalf("getting repo head: %v", err)
}
allCommits, err := repo.GetGitCommitRange(plumbing.ZeroHash, headGitCommit.GitCommit.Hash)
allCommits, err := proj.GetCommitRange(plumbing.ZeroHash, headCommit.Hash)
if err != nil {
t.Fatalf("getting all commits (up to %q): %v",
headGitCommit.GitCommit.Hash, err)
t.Fatalf("getting all commits (up to %q): %v", headCommit.Hash, err)
}
checkedOutBranch, err := repo.ReferenceToBranchName(plumbing.HEAD)
checkedOutBranch, err := proj.ReferenceToBranchName(plumbing.HEAD)
if err != nil {
t.Fatalf("error determining checked out branch: %v", err)
}
if err := repo.VerifyCommits(checkedOutBranch, allCommits); err != nil {
if err := proj.VerifyCommits(checkedOutBranch, allCommits); err != nil {
t.Fatal(err)
}
}
@ -274,17 +273,17 @@ func TestShortHashResolving(t *testing.T) {
// but that's hard...
h := newHarness(t)
rootSig := h.stageNewAccount("root", false)
hash := h.assertCommitChange(verifyShouldSucceed, "first commit", rootSig).GitCommit.Hash
hash := h.assertCommitChange(verifyShouldSucceed, "first commit", rootSig).Hash
hashStr := hash.String()
t.Log(hashStr)
for i := 2; i < len(hashStr); i++ {
gotCommit, err := h.repo.GetGitRevision(plumbing.Revision(hashStr[:i]))
gotCommit, err := h.proj.GetCommitByRevision(plumbing.Revision(hashStr[:i]))
if err != nil {
t.Fatalf("resolving %q: %v", hashStr[:i], err)
} else if gotCommit.GitCommit.Hash != hash {
} else if gotCommit.Hash != hash {
t.Fatalf("expected hash %q but got %q",
gotCommit.GitCommit.Hash, hash)
gotCommit.Hash, hash)
}
}
}

@ -1,544 +0,0 @@
// Package dehub TODO needs package docs
package dehub
import (
"bytes"
"encoding/hex"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"dehub.dev/src/dehub.git/fs"
"gopkg.in/src-d/go-billy.v4"
"gopkg.in/src-d/go-billy.v4/memfs"
"gopkg.in/src-d/go-git.v4"
"gopkg.in/src-d/go-git.v4/plumbing"
"gopkg.in/src-d/go-git.v4/plumbing/cache"
"gopkg.in/src-d/go-git.v4/plumbing/format/config"
"gopkg.in/src-d/go-git.v4/plumbing/object"
"gopkg.in/src-d/go-git.v4/storage"
"gopkg.in/src-d/go-git.v4/storage/filesystem"
)
const (
// DehubDir defines the name of the directory where all dehub-related files are
// expected to be found.
DehubDir = ".dehub"
)
var (
// ConfigPath defines the expected path to the Repo's configuration file.
ConfigPath = filepath.Join(DehubDir, "config.yml")
// Main defines the name of the main branch.
Main = "main"
// MainRefName defines the reference name of the main branch.
MainRefName = plumbing.NewBranchReferenceName(Main)
)
type openOpts struct {
bare bool
}
// OpenOption is an option which can be passed to the OpenRepo function to
// affect the Repo's behavior.
type OpenOption func(*openOpts)
// OpenBare returns an OpenOption which, if true is given, causes the OpenRepo
// function to expect to open a bare repo.
func OpenBare(bare bool) OpenOption {
return func(o *openOpts) {
o.bare = bare
}
}
// Repo is an object which allows accessing and modifying the dehub repo.
type Repo struct {
GitRepo *git.Repository
// GitDirFS corresponds to the .git directory (or the entire repo directory
// if it's a bare repo)
GitDirFS billy.Filesystem
}
func extractGitDirFS(storer storage.Storer) (billy.Filesystem, error) {
dotGitFSer, ok := storer.(interface{ Filesystem() billy.Filesystem })
if !ok {
return nil, fmt.Errorf("git storage object of type %T does not expose its underlying filesystem",
storer)
}
return dotGitFSer.Filesystem(), nil
}
// OpenRepo opens the dehub repo in the given directory and returns the object
// for it.
//
// The given path is expected to have a git repo and .dehub folder already
// initialized.
func OpenRepo(path string, options ...OpenOption) (*Repo, error) {
var opts openOpts
for _, opt := range options {
opt(&opts)
}
r := Repo{}
var err error
openOpts := &git.PlainOpenOptions{
DetectDotGit: !opts.bare,
}
if r.GitRepo, err = git.PlainOpenWithOptions(path, openOpts); err != nil {
return nil, fmt.Errorf("could not open git repo: %w", err)
} else if r.GitDirFS, err = extractGitDirFS(r.GitRepo.Storer); err != nil {
return nil, err
}
return &r, nil
}
type initOpts struct {
bare bool
remote bool
}
// InitOption is an option which can be passed into the Init functions to affect
// their behavior.
type InitOption func(*initOpts)
// InitBare returns an InitOption which, if true is given, causes the Init
// function to initialize the repo without a worktree.
func InitBare(bare bool) InitOption {
return func(o *initOpts) {
o.bare = bare
}
}
// InitRemote returns an InitOption which, if true is given, causes the Init
// function to initialize the repo with certain git configuration options set
// which make the repo able to be used as a remote repo.
func InitRemote(remote bool) InitOption {
return func(o *initOpts) {
o.remote = remote
}
}
// InitRepo will initialize a new repository at the given path. If bare is true
// then the repository will not have a worktree.
func InitRepo(path string, options ...InitOption) (*Repo, error) {
var opts initOpts
for _, opt := range options {
opt(&opts)
}
var repo Repo
var err error
if repo.GitRepo, err = git.PlainInit(path, opts.bare); err != nil {
return nil, fmt.Errorf("initializing git repo: %w", err)
} else if repo.GitDirFS, err = extractGitDirFS(repo.GitRepo.Storer); err != nil {
return nil, err
} else if err = repo.init(opts); err != nil {
return nil, fmt.Errorf("initializing repo with dehub defaults: %w", err)
}
return &repo, nil
}
// InitMemRepo initializes an empty repository which only exists in memory.
func InitMemRepo(options ...InitOption) *Repo {
var opts initOpts
for _, opt := range options {
opt(&opts)
}
fs := memfs.New()
dotGitFS, err := fs.Chroot(git.GitDirName)
if err != nil {
panic(err)
}
storage := filesystem.NewStorage(dotGitFS, cache.NewObjectLRUDefault())
var worktree billy.Filesystem
if !opts.bare {
worktree = fs
}
r, err := git.Init(storage, worktree)
if err != nil {
panic(err)
}
repo := &Repo{GitRepo: r, GitDirFS: dotGitFS}
if err := repo.init(opts); err != nil {
panic(err)
}
return repo
}
func (r *Repo) initRemotePreReceive(bare bool) error {
if err := r.GitDirFS.MkdirAll("hooks", 0755); err != nil {
return fmt.Errorf("creating hooks directory: %w", err)
}
preRcvFlags := os.O_WRONLY | os.O_CREATE | os.O_TRUNC
preRcv, err := r.GitDirFS.OpenFile("hooks/pre-receive", preRcvFlags, 0755)
if err != nil {
return fmt.Errorf("opening hooks/pre-receive file: %w", err)
}
defer preRcv.Close()
var preRcvBody string
if bare {
preRcvBody = "#!/bin/sh\nexec dehub hook -bare -pre-receive\n"
} else {
preRcvBody = "#!/bin/sh\nexec dehub hook -pre-receive\n"
}
if _, err := io.Copy(preRcv, bytes.NewBufferString(preRcvBody)); err != nil {
return fmt.Errorf("writing to hooks/pre-receive: %w", err)
}
return nil
}
func (r *Repo) init(opts initOpts) error {
headRef := plumbing.NewSymbolicReference(plumbing.HEAD, MainRefName)
if err := r.GitRepo.Storer.SetReference(headRef); err != nil {
return fmt.Errorf("setting HEAD reference to %q: %w", MainRefName, err)
}
if opts.remote {
cfg, err := r.GitRepo.Config()
if err != nil {
return fmt.Errorf("opening git cfg: %w", err)
}
cfg.Raw = cfg.Raw.AddOption("http", config.NoSubsection, "receivepack", "true")
if err := r.GitRepo.Storer.SetConfig(cfg); err != nil {
return fmt.Errorf("storing modified git config: %w", err)
}
if err := r.initRemotePreReceive(opts.bare); err != nil {
return fmt.Errorf("initializing pre-receive hook for remote-enabled repo: %w", err)
}
}
return nil
}
func (r *Repo) billyFilesystem() (billy.Filesystem, error) {
w, err := r.GitRepo.Worktree()
if err != nil {
return nil, fmt.Errorf("opening git worktree: %w", err)
}
return w.Filesystem, nil
}
var errTraverseRefNoMatch = errors.New("failed to find reference matching given predicate")
// TraverseReferenceChain resolves a chain of references, calling the given
// predicate on each one, and returning the first one for which the predicate
// returns true. This method will return an error if it reaches the end of the
// chain and the predicate still has not returned true.
//
// If a reference name is encountered which does not actually exist, then it is
// assumed to be a hash reference to the zero hash.
func (r *Repo) TraverseReferenceChain(refName plumbing.ReferenceName, pred func(*plumbing.Reference) bool) (*plumbing.Reference, error) {
// TODO infinite loop checking
for {
ref, err := r.GitRepo.Storer.Reference(refName)
if errors.Is(err, plumbing.ErrReferenceNotFound) {
ref = plumbing.NewHashReference(refName, plumbing.ZeroHash)
} else if err != nil {
return nil, fmt.Errorf("resolving reference %q: %w", refName, err)
}
if pred(ref) {
return ref, nil
} else if ref.Type() != plumbing.SymbolicReference {
return nil, errTraverseRefNoMatch
}
refName = ref.Target()
}
}
// ErrNoBranchReference is returned from ReferenceToBranchName if no reference
// in the reference chain is for a branch.
var ErrNoBranchReference = errors.New("no branch reference found")
// ReferenceToBranchName traverses a chain of references looking for the first
// branch reference, and returns that name, or returns ErrNoBranchReference if
// no branch reference is part of the chain.
func (r *Repo) ReferenceToBranchName(refName plumbing.ReferenceName) (plumbing.ReferenceName, error) {
// first check if the given refName is a branch, if so just return that.
if refName.IsBranch() {
return refName, nil
}
ref, err := r.TraverseReferenceChain(refName, func(ref *plumbing.Reference) bool {
return ref.Target().IsBranch()
})
if errors.Is(err, errTraverseRefNoMatch) {
return "", ErrNoBranchReference
} else if err != nil {
return "", fmt.Errorf("traversing reference chain: %w", err)
}
return ref.Target(), nil
}
// ReferenceToHash fully resolves a reference to a hash. If a reference cannot
// be resolved then plumbing.ZeroHash is returned.
func (r *Repo) ReferenceToHash(refName plumbing.ReferenceName) (plumbing.Hash, error) {
ref, err := r.TraverseReferenceChain(refName, func(ref *plumbing.Reference) bool {
return ref.Type() == plumbing.HashReference
})
if errors.Is(err, errTraverseRefNoMatch) {
return plumbing.ZeroHash, errors.New("no hash in reference chain (is this even possible???)")
} else if errors.Is(err, plumbing.ErrReferenceNotFound) {
return plumbing.ZeroHash, nil
} else if err != nil {
return plumbing.ZeroHash, fmt.Errorf("traversing reference chain: %w", err)
}
return ref.Hash(), nil
}
// headFS returns an FS based on the HEAD commit, or if there is no HEAD commit
// (it's an empty repo) an FS based on the raw filesystem.
func (r *Repo) headFS() (fs.FS, error) {
head, err := r.GetGitHead()
if errors.Is(err, ErrHeadIsZero) {
bfs, err := r.billyFilesystem()
if err != nil {
return nil, fmt.Errorf("getting underlying filesystem: %w", err)
}
return fs.FromBillyFilesystem(bfs), nil
} else if err != nil {
return nil, fmt.Errorf("could not get HEAD tree: %w", err)
}
return fs.FromTree(head.GitTree), nil
}
// GitCommit wraps a single git commit object, and also contains various fields
// which are parsed out of it. It is used as a convenience type, in place of
// having to manually retrieve and parse specific information out of commit
// objects.
type GitCommit struct {
GitCommit *object.Commit
// Fields based on that Commit, which can't be directly gleaned from it.
GitTree *object.Tree
Commit Commit
Interface CommitInterface
}
// Root returns true if this commit is the root commit in its branch (i.e. it
// has no parents)
func (gc GitCommit) Root() bool {
return gc.GitCommit.NumParents() == 0
}
// GetGitCommit retrieves the commit at the given hash, and all of its sub-data
// which can be pulled out of it.
func (r *Repo) GetGitCommit(h plumbing.Hash) (gc GitCommit, err error) {
if gc.GitCommit, err = r.GitRepo.CommitObject(h); err != nil {
return gc, fmt.Errorf("getting git commit object: %w", err)
} else if gc.GitTree, err = r.GitRepo.TreeObject(gc.GitCommit.TreeHash); err != nil {
return gc, fmt.Errorf("getting git tree object %q: %w",
gc.GitCommit.TreeHash, err)
} else if gc.Commit.UnmarshalText([]byte(gc.GitCommit.Message)); err != nil {
return gc, fmt.Errorf("decoding commit message: %w", err)
} else if gc.Interface, err = gc.Commit.Interface(); err != nil {
return gc, fmt.Errorf("casting %+v to a CommitInterface: %w", gc.Commit, err)
}
return
}
// ErrHeadIsZero is used to indicate that HEAD resolves to the zero hash. An
// example of when this can happen is if the repo was just initialized and has
// no commits, or if an orphan branch is checked out.
var ErrHeadIsZero = errors.New("HEAD resolves to the zero hash")
// GetGitHead returns the GitCommit which is currently referenced by HEAD.
// This method may return ErrHeadIsZero if HEAD resolves to the zero hash.
func (r *Repo) GetGitHead() (GitCommit, error) {
headHash, err := r.ReferenceToHash(plumbing.HEAD)
if err != nil {
return GitCommit{}, fmt.Errorf("resolving HEAD: %w", err)
} else if headHash == plumbing.ZeroHash {
return GitCommit{}, ErrHeadIsZero
}
gc, err := r.GetGitCommit(headHash)
if err != nil {
return GitCommit{}, fmt.Errorf("getting commit %q: %w", headHash, err)
}
return gc, nil
}
// GetGitCommitRange returns an ancestry of GitCommits, with the first being the
// commit immediately following the given starting hash, and the last being the
// given ending hash.
//
// If start is plumbing.ZeroHash then the root commit will be the starting one.
func (r *Repo) GetGitCommitRange(start, end plumbing.Hash) ([]GitCommit, error) {
curr, err := r.GetGitCommit(end)
if err != nil {
return nil, fmt.Errorf("retrieving commit %q: %w", end, err)
}
var commits []GitCommit
var found bool
for {
if found = start != plumbing.ZeroHash && curr.GitCommit.Hash == start; found {
break
}
commits = append(commits, curr)
numParents := curr.GitCommit.NumParents()
if numParents == 0 {
break
} else if numParents > 1 {
return nil, fmt.Errorf("commit %q has more than one parent: %+v",
curr.GitCommit.Hash, curr.GitCommit.ParentHashes)
}
parentHash := curr.GitCommit.ParentHashes[0]
parent, err := r.GetGitCommit(parentHash)
if err != nil {
return nil, fmt.Errorf("retrieving commit %q: %w", parentHash, err)
}
curr = parent
}
if !found && start != plumbing.ZeroHash {
return nil, fmt.Errorf("unable to find commit %q as an ancestor of %q",
start, end)
}
// reverse the commits to be in the expected order
for l, r := 0, len(commits)-1; l < r; l, r = l+1, r-1 {
commits[l], commits[r] = commits[r], commits[l]
}
return commits, nil
}
var (
hashStrLen = len(plumbing.ZeroHash.String())
errNotHex = errors.New("not a valid hex string")
)
func (r *Repo) findCommitByShortHash(hashStr string) (plumbing.Hash, error) {
paddedHashStr := hashStr
if len(hashStr)%2 > 0 {
paddedHashStr += "0"
}
if hashB, err := hex.DecodeString(paddedHashStr); err != nil {
return plumbing.ZeroHash, errNotHex
} else if len(hashStr) == hashStrLen {
var hash plumbing.Hash
copy(hash[:], hashB)
return hash, nil
} else if len(hashStr) < 2 {
return plumbing.ZeroHash, errors.New("hash string must be 2 characters long or more")
}
for i := 2; i < hashStrLen; i++ {
hashPrefix, hashTail := hashStr[:i], hashStr[i:]
path := filepath.Join("objects", hashPrefix)
fileInfos, err := r.GitDirFS.ReadDir(path)
if err != nil {
return plumbing.ZeroHash, fmt.Errorf("listing files in %q: %w", path, err)
}
var matchedHash plumbing.Hash
for _, fileInfo := range fileInfos {
objFileName := fileInfo.Name()
if !strings.HasPrefix(objFileName, hashTail) {
continue
}
objHash := plumbing.NewHash(hashPrefix + objFileName)
obj, err := r.GitRepo.Storer.EncodedObject(plumbing.AnyObject, objHash)
if err != nil {
return plumbing.ZeroHash, fmt.Errorf("reading object %q off disk: %w", objHash, err)
} else if obj.Type() != plumbing.CommitObject {
continue
} else if matchedHash == plumbing.ZeroHash {
matchedHash = objHash
continue
}
return plumbing.ZeroHash, fmt.Errorf("both %q and %q match", matchedHash, objHash)
}
if matchedHash != plumbing.ZeroHash {
return matchedHash, nil
}
}
return plumbing.ZeroHash, errors.New("failed to find a commit object with a matching prefix")
}
func (r *Repo) resolveRev(rev plumbing.Revision) (plumbing.Hash, error) {
if rev == plumbing.Revision(plumbing.ZeroHash.String()) {
return plumbing.ZeroHash, nil
}
{
// pretend the revision is a short hash until proven otherwise
shortHash := string(rev)
hash, err := r.findCommitByShortHash(shortHash)
if errors.Is(err, errNotHex) {
// ok, continue
} else if err != nil {
return plumbing.ZeroHash, fmt.Errorf("resolving as short hash: %w", err)
} else {
// guess it _is_ a short hash, knew it!
return hash, nil
}
}
h, err := r.GitRepo.ResolveRevision(rev)
if err != nil {
return plumbing.ZeroHash, fmt.Errorf("resolving revision %q: %w", rev, err)
}
return *h, nil
}
// GetGitRevision resolves the revision and returns the GitCommit it references.
func (r *Repo) GetGitRevision(rev plumbing.Revision) (GitCommit, error) {
hash, err := r.resolveRev(rev)
if err != nil {
return GitCommit{}, err
}
gc, err := r.GetGitCommit(hash)
if err != nil {
return GitCommit{}, fmt.Errorf("getting commit %q: %w", hash, err)
}
return gc, nil
}
// GetGitRevisionRange is like GetGitCommitRange, first resolving the given
// revisions into hashes before continuing with GetGitCommitRange's behavior.
func (r *Repo) GetGitRevisionRange(startRev, endRev plumbing.Revision) ([]GitCommit, error) {
start, err := r.resolveRev(startRev)
if err != nil {
return nil, err
}
end, err := r.resolveRev(endRev)
if err != nil {
return nil, err
}
return r.GetGitCommitRange(start, end)
}

@ -6,40 +6,39 @@ import (
"dehub.dev/src/dehub.git/typeobj"
)
// Credential represents a credential which has been attached to a commit which
// hopefully will allow it to be included in the main. Exactly one field tagged
// with "type" should be set.
type Credential struct {
// CredentialUnion represents a credential, signifying a user's approval of a
// payload. Exactly one field tagged with "type" should be set.
type CredentialUnion struct {
PGPSignature *CredentialPGPSignature `type:"pgp_signature"`
// AccountID specifies the account which generated this Credential.
// AccountID specifies the account which generated this CredentialUnion.
//
// NOTE that Credentials produced by the direct implementations of
// SignifierInterface won't fill in this field, unless specifically
// documented. The SignifierInterface produced by the Interface() method of
// Signifier _will_ fill this field in, however.
// NOTE that credentials produced by the direct implementations of Signifier
// won't fill in this field, unless specifically documented. The Signifier
// produced by the Signifier() method of SignifierUnion _will_ fill this
// field in, however.
AccountID string `yaml:"account,omitempty"`
// AnonID specifies an identifier for the anonymous user which produced this
// credential. This field is mutually exclusive with AccountID, and won't be
// set by any SignifierInterface unless specifically documented.
// set by any Signifier implementation unless specifically documented.
AnonID string `yaml:"-"`
}
// MarshalYAML implements the yaml.Marshaler interface.
func (c Credential) MarshalYAML() (interface{}, error) {
func (c CredentialUnion) MarshalYAML() (interface{}, error) {
return typeobj.MarshalYAML(c)
}
// UnmarshalYAML implements the yaml.Unmarshaler interface.
func (c *Credential) UnmarshalYAML(unmarshal func(interface{}) error) error {
func (c *CredentialUnion) UnmarshalYAML(unmarshal func(interface{}) error) error {
return typeobj.UnmarshalYAML(c, unmarshal)
}
// ErrNotSelfVerifying is returned from the SelfVerify method of Credential when
// the Credential does not implement the SelfVerifyingCredential interface. It
// may also be returned from the SelfVerify method of the
// SelfVerifyingCredential itself, if the Credential can only self-verify under
// ErrNotSelfVerifying is returned from the SelfVerify method of CredentialUnion
// when the credential does not implement the SelfVerifyingCredential interface.
// It may also be returned from the SelfVerify method of the
// SelfVerifyingCredential itself, if the credential can only self-verify under
// certain circumstances.
type ErrNotSelfVerifying struct {
// Subject is a descriptor of the value which could not be verified. It may
@ -51,16 +50,16 @@ func (e ErrNotSelfVerifying) Error() string {
return fmt.Sprintf("%s cannot verify itself", e.Subject)
}
// SelfVerify will attempt to cast the Credential as a SelfVerifyingCredential,
// SelfVerify will attempt to cast the credential as a SelfVerifyingCredential,
// and returns the result of the SelfVerify method being called on it.
func (c Credential) SelfVerify(data []byte) error {
func (c CredentialUnion) SelfVerify(data []byte) error {
el, _, err := typeobj.Element(c)
if err != nil {
return err
} else if selfVerifyingCred, ok := el.(SelfVerifyingCredential); !ok {
return ErrNotSelfVerifying{Subject: fmt.Sprintf("Credential of type %T", el)}
return ErrNotSelfVerifying{Subject: fmt.Sprintf("credential of type %T", el)}
} else if err := selfVerifyingCred.SelfVerify(data); err != nil {
return fmt.Errorf("self-verifying Credential of type %T: %w", el, err)
return fmt.Errorf("self-verifying credential of type %T: %w", el, err)
}
return nil
}

@ -14,12 +14,12 @@ func TestSelfVerifyingCredentials(t *testing.T) {
tests := []struct {
descr string
mkCred func(toSign []byte) (Credential, error)
mkCred func(toSign []byte) (CredentialUnion, error)
expErr bool
}{
{
descr: "pgp sig no body",
mkCred: func(toSign []byte) (Credential, error) {
mkCred: func(toSign []byte) (CredentialUnion, error) {
privKey, _ := TestSignifierPGP("", false, rand)
return privKey.Sign(nil, toSign)
},
@ -27,7 +27,7 @@ func TestSelfVerifyingCredentials(t *testing.T) {
},
{
descr: "pgp sig with body",
mkCred: func(toSign []byte) (Credential, error) {
mkCred: func(toSign []byte) (CredentialUnion, error) {
privKey, _ := TestSignifierPGP("", true, rand)
return privKey.Sign(nil, toSign)
},

@ -38,7 +38,7 @@ func (c *CredentialPGPSignature) SelfVerify(data []byte) error {
}
sig := SignifierPGP{Body: c.PubKeyBody}
return sig.Verify(nil, data, Credential{PGPSignature: c})
return sig.Verify(nil, data, CredentialUnion{PGPSignature: c})
}
type pgpKey struct {
@ -59,9 +59,9 @@ func newPGPPubKey(r io.Reader) (pgpKey, error) {
return pgpKey{entity: entity}, nil
}
func (s pgpKey) Sign(_ fs.FS, data []byte) (Credential, error) {
func (s pgpKey) Sign(_ fs.FS, data []byte) (CredentialUnion, error) {
if s.entity.PrivateKey == nil {
return Credential{}, errors.New("private key not loaded")
return CredentialUnion{}, errors.New("private key not loaded")
}
h := sha256.New()
@ -70,15 +70,15 @@ func (s pgpKey) Sign(_ fs.FS, data []byte) (Credential, error) {
sig.Hash = crypto.SHA256
sig.PubKeyAlgo = s.entity.PrimaryKey.PubKeyAlgo
if err := sig.Sign(h, s.entity.PrivateKey, nil); err != nil {
return Credential{}, fmt.Errorf("signing data: %w", err)
return CredentialUnion{}, fmt.Errorf("signing data: %w", err)
}
body := new(bytes.Buffer)
if err := sig.Serialize(body); err != nil {
return Credential{}, fmt.Errorf("serializing signature: %w", err)
return CredentialUnion{}, fmt.Errorf("serializing signature: %w", err)
}
return Credential{
return CredentialUnion{
PGPSignature: &CredentialPGPSignature{
PubKeyID: s.entity.PrimaryKey.KeyIdString(),
Body: body.Bytes(),
@ -86,7 +86,7 @@ func (s pgpKey) Sign(_ fs.FS, data []byte) (Credential, error) {
}, nil
}
func (s pgpKey) Signed(_ fs.FS, cred Credential) (bool, error) {
func (s pgpKey) Signed(_ fs.FS, cred CredentialUnion) (bool, error) {
if cred.PGPSignature == nil {
return false, nil
}
@ -94,7 +94,7 @@ func (s pgpKey) Signed(_ fs.FS, cred Credential) (bool, error) {
return cred.PGPSignature.PubKeyID == s.entity.PrimaryKey.KeyIdString(), nil
}
func (s pgpKey) Verify(_ fs.FS, data []byte, cred Credential) error {
func (s pgpKey) Verify(_ fs.FS, data []byte, cred CredentialUnion) error {
credSig := cred.PGPSignature
if credSig == nil {
return fmt.Errorf("SignifierPGPFile cannot verify %+v", cred)
@ -145,7 +145,7 @@ func (s pgpKey) userID() (*packet.UserId, error) {
return identity.UserId, nil
}
func anonPGPSignifier(pgpKey pgpKey, sigInt SignifierInterface) (SignifierInterface, error) {
func anonPGPSignifier(pgpKey pgpKey, sig Signifier) (Signifier, error) {
keyID := pgpKey.entity.PrimaryKey.KeyIdString()
userID, err := pgpKey.userID()
if err != nil {
@ -158,20 +158,20 @@ func anonPGPSignifier(pgpKey pgpKey, sigInt SignifierInterface) (SignifierInterf
}
return signifierMiddleware{
SignifierInterface: sigInt,
signCallback: func(cred *Credential) {
Signifier: sig,
signCallback: func(cred *CredentialUnion) {
cred.PGPSignature.PubKeyBody = string(pubKeyBody)
cred.AnonID = fmt.Sprintf("%s %q", keyID, userID.Email)
},
}, nil
}
// TestSignifierPGP returns a direct implementation of the SignifierInterface
// which uses a random private key generated in memory, as well as an armored
// version of its public key.
// TestSignifierPGP returns a direct implementation of Signifier which uses a
// random private key generated in memory, as well as an armored version of its
// public key.
//
// NOTE that the key returned is very weak, and should only be used for tests.
func TestSignifierPGP(name string, anon bool, randReader io.Reader) (SignifierInterface, []byte) {
func TestSignifierPGP(name string, anon bool, randReader io.Reader) (Signifier, []byte) {
entity, err := openpgp.NewEntity(name, "", name+"@example.com", &packet.Config{
Rand: randReader,
RSABits: 512,
@ -209,7 +209,7 @@ type SignifierPGP struct {
Path string `yaml:"path,omitempty"`
}
var _ SignifierInterface = SignifierPGP{}
var _ Signifier = SignifierPGP{}
func cmdGPG(stdin []byte, args ...string) ([]byte, error) {
args = append([]string{"--openpgp"}, args...)
@ -229,8 +229,8 @@ func cmdGPG(stdin []byte, args ...string) ([]byte, error) {
//
// If this is being called for an anonymous user to use, then anon can be set to
// true. This will have the effect of setting the PubKeyBody and AnonID of all
// produced Credentials.
func LoadSignifierPGP(keyID string, anon bool) (SignifierInterface, error) {
// produced credentials.
func LoadSignifierPGP(keyID string, anon bool) (Signifier, error) {
pubKey, err := cmdGPG(nil, "-a", "--export", keyID)
if err != nil {
return nil, fmt.Errorf("loading public key: %w", err)
@ -270,19 +270,19 @@ func (s SignifierPGP) load(fs fs.FS) (pgpKey, error) {
// Sign will sign the given arbitrary bytes using the private key corresponding
// to the pgp public key embedded in this Signifier.
func (s SignifierPGP) Sign(fs fs.FS, data []byte) (Credential, error) {
func (s SignifierPGP) Sign(fs fs.FS, data []byte) (CredentialUnion, error) {
sigPGP, err := s.load(fs)
if err != nil {
return Credential{}, err
return CredentialUnion{}, err
}
keyID := sigPGP.entity.PrimaryKey.KeyIdString()
sig, err := cmdGPG(data, "--detach-sign", "--local-user", keyID)
if err != nil {
return Credential{}, fmt.Errorf("signing with pgp key: %w", err)
return CredentialUnion{}, fmt.Errorf("signing with pgp key: %w", err)
}
return Credential{
return CredentialUnion{
PGPSignature: &CredentialPGPSignature{
PubKeyID: keyID,
Body: sig,
@ -292,7 +292,7 @@ func (s SignifierPGP) Sign(fs fs.FS, data []byte) (Credential, error) {
// Signed returns true if the private key corresponding to the pgp public key
// embedded in this Signifier was used to produce the given Credential.
func (s SignifierPGP) Signed(fs fs.FS, cred Credential) (bool, error) {
func (s SignifierPGP) Signed(fs fs.FS, cred CredentialUnion) (bool, error) {
sigPGP, err := s.load(fs)
if err != nil {
return false, err
@ -303,7 +303,7 @@ func (s SignifierPGP) Signed(fs fs.FS, cred Credential) (bool, error) {
// Verify asserts that the given signature was produced by this key signing the
// given piece of data.
func (s SignifierPGP) Verify(fs fs.FS, data []byte, cred Credential) error {
func (s SignifierPGP) Verify(fs fs.FS, data []byte, cred CredentialUnion) error {
sigPGP, err := s.load(fs)
if err != nil {
return err

@ -15,17 +15,17 @@ import (
func TestPGPVerification(t *testing.T) {
tests := []struct {
descr string
init func(pubKeyBody []byte) (SignifierInterface, fs.FS)
init func(pubKeyBody []byte) (Signifier, fs.FS)
}{
{
descr: "SignifierPGP Body",
init: func(pubKeyBody []byte) (SignifierInterface, fs.FS) {
init: func(pubKeyBody []byte) (Signifier, fs.FS) {
return SignifierPGP{Body: string(pubKeyBody)}, nil
},
},
{
descr: "SignifierPGP Path",
init: func(pubKeyBody []byte) (SignifierInterface, fs.FS) {
init: func(pubKeyBody []byte) (Signifier, fs.FS) {
pubKeyPath := "some/dir/pubkey.asc"
fs := fs.Stub{pubKeyPath: pubKeyBody}
return SignifierPGP{Path: pubKeyPath}, fs

@ -5,72 +5,73 @@ import (
"dehub.dev/src/dehub.git/typeobj"
)
// Signifier reprsents a single signing method being defined in the Config. Only
// one field should be set on each Signifier.
type Signifier struct {
// Signifier describes the methods that all signifiers must implement.
type Signifier interface {
// Sign returns a credential containing a signature of the given data.
//
// tree can be used to find the Signifier at a particular snapshot.
Sign(fs.FS, []byte) (CredentialUnion, error)
// Signed returns true if the Signifier was used to sign the credential.
Signed(fs.FS, CredentialUnion) (bool, error)
// Verify asserts that the Signifier produced the given credential for the
// given data set, or returns an error.
//
// tree can be used to find the Signifier at a particular snapshot.
Verify(fs.FS, []byte, CredentialUnion) error
}
// SignifierUnion represents a single signifier for an account. Only one field
// should be set on each SignifierUnion.
type SignifierUnion struct {
PGPPublicKey *SignifierPGP `type:"pgp_public_key"`
// PGPPublicKeyFile is deprecated, only PGPPublicKey should be used
PGPPublicKeyFile *SignifierPGPFile `type:"pgp_public_key_file"`
// LegacyPGPPublicKeyFile is deprecated, only PGPPublicKey should be used
LegacyPGPPublicKeyFile *SignifierPGPFile `type:"pgp_public_key_file"`
}
// MarshalYAML implements the yaml.Marshaler interface.
func (s Signifier) MarshalYAML() (interface{}, error) {
func (s SignifierUnion) MarshalYAML() (interface{}, error) {
return typeobj.MarshalYAML(s)
}
// UnmarshalYAML implements the yaml.Unmarshaler interface.
func (s *Signifier) UnmarshalYAML(unmarshal func(interface{}) error) error {
func (s *SignifierUnion) UnmarshalYAML(unmarshal func(interface{}) error) error {
if err := typeobj.UnmarshalYAML(s, unmarshal); err != nil {
return err
}
// TODO deprecate PGPPublicKeyFile
if s.PGPPublicKeyFile != nil {
s.PGPPublicKey = &SignifierPGP{Path: s.PGPPublicKeyFile.Path}
s.PGPPublicKeyFile = nil
if s.LegacyPGPPublicKeyFile != nil {
s.PGPPublicKey = &SignifierPGP{Path: s.LegacyPGPPublicKeyFile.Path}
s.LegacyPGPPublicKeyFile = nil
}
return nil
}
// Interface returns the SignifierInterface instance encapsulated by this
// Signifier object.
// Signifier returns the Signifier instance encapsulated by this SignifierUnion.
//
// This will panic if no Signifier field is populated.
//
// accountID is given so as to automatically fill the AccountID field of
// Credentials returned from Sign, since the underlying implementation doesn't
// credentials returned from Sign, since the underlying implementation doesn't
// know what account it's signing for.
func (s Signifier) Interface(accountID string) (SignifierInterface, error) {
func (s SignifierUnion) Signifier(accountID string) Signifier {
el, _, err := typeobj.Element(s)
if err != nil {
return nil, err
panic(err)
}
return accountSignifier(accountID, el.(SignifierInterface)), nil
}
// SignifierInterface describes the methods that all Signifiers must implement.
type SignifierInterface interface {
// Sign returns a Credential containing a signature of the given data.
//
// tree can be used to find the Signifier at a particular snapshot.
Sign(fs fs.FS, data []byte) (Credential, error)
// Signed returns true if the Signifier was used to sign the Credential.
Signed(fs fs.FS, cred Credential) (bool, error)
// Verify asserts that the Signifier produced the given Credential for the
// given data set, or returns an error.
//
// tree can be used to find the Signifier at a particular snapshot.
Verify(fs fs.FS, data []byte, cred Credential) error
return accountSignifier(accountID, el.(Signifier))
}
type signifierMiddleware struct {
SignifierInterface
signCallback func(*Credential)
Signifier
signCallback func(*CredentialUnion)
}
func (sm signifierMiddleware) Sign(fs fs.FS, data []byte) (Credential, error) {
cred, err := sm.SignifierInterface.Sign(fs, data)
func (sm signifierMiddleware) Sign(fs fs.FS, data []byte) (CredentialUnion, error) {
cred, err := sm.Signifier.Sign(fs, data)
if err != nil || sm.signCallback == nil {
return cred, err
}
@ -78,16 +79,16 @@ func (sm signifierMiddleware) Sign(fs fs.FS, data []byte) (Credential, error) {
return cred, nil
}
// accountSignifier wraps a SignifierInterface to always set the accountID field
// on Credentials it produces via the Sign method.
// accountSignifier wraps a Signifier to always set the accountID field on
// credentials it produces via the Sign method.
//
// TODO accountSignifier shouldn't be necessary, it's very ugly. Which indicates
// that Credential probably shouldn't have AccountID on it, which makes sense.
// Some refactoring is required here.
func accountSignifier(accountID string, sigInt SignifierInterface) SignifierInterface {
// TODO accountSignifier shouldn't be necessary, it's very ugly. It indicates
// that CredentialUnion probably shouldn't have AccountID on it, which makes
// sense. Some refactoring is required here.
func accountSignifier(accountID string, sig Signifier) Signifier {
return signifierMiddleware{
SignifierInterface: sigInt,
signCallback: func(cred *Credential) {
Signifier: sig,
signCallback: func(cred *CredentialUnion) {
cred.AccountID = accountID
},
}

@ -10,6 +10,10 @@ import (
// string.
type Blob []byte
func (b Blob) String() string {
return base64.StdEncoding.EncodeToString([]byte(b))
}
// MarshalYAML implements the yaml.Marshaler interface.
func (b Blob) MarshalYAML() (interface{}, error) {
return base64.StdEncoding.EncodeToString([]byte(b)), nil

Loading…
Cancel
Save