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. 25
      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. 676
      commit.go
  18. 156
      commit_change.go
  19. 49
      commit_comment.go
  20. 81
      commit_credential.go
  21. 31
      config.go
  22. 4
      fingerprint.go
  23. 8
      fingerprint_test.go
  24. 604
      payload.go
  25. 171
      payload_change.go
  26. 81
      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 ## Milestone: Versions
* Tag commits * 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? * 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 (Cloning/remote management is probably a pre-requisite of this, so it's a good
root commit. A couple of considerations: thing it comes after IPFS support)
- Only a checkpoint on the main branch should be considered when determining
the project "root". * Ability to specify which commit is prime.
- Must be a flag on change commits, to allow hard-forks of projects where * The prime commit is essentially the identifier of the entire project; even
the config file is completely replaced. if two project instances share a commit tree, if they are using a
- Not sure if it should be subject to ACL or not. different prime commit then they are not the same project.
## Milestone: Minimal plugin support ## Milestone: Minimal plugin support
@ -39,7 +39,7 @@ to accept help from people asking to help.
* Conditions * Conditions
* Signifiers * Signifiers
* Filters * Filters
* Commits??? * Payloads???
## Milestone: Minimal notifications support ## 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 * Maybe coalesce the `accessctl`, `fs`, and `sigcred` packages back into the
root "dehub" package. 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 * Polish commands
* New flag system, some kind of interactivity support (e.g. user doesn't * 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 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 * 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. "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: filters:
- type: branch - type: branch
pattern: main pattern: main
- type: commit_type - type: payload_type
commit_type: change payload_type: change
- type: signature - type: signature
any_account: true any_account: true
count: 1 count: 1
@ -66,8 +66,8 @@ type CommitRequest struct {
// It is required. // It is required.
Branch string Branch string
// Credentials are the Credential objects attached to the commit. // Credentials are the credentials attached to the commit.
Credentials []sigcred.Credential Credentials []sigcred.CredentialUnion
// FilesChanged is the set of file paths (relative to the repo root) which // FilesChanged is the set of file paths (relative to the repo root) which
// have been modified in some way. // have been modified in some way.
@ -97,27 +97,20 @@ const (
// AccessControl describes a set of Filters, and the Actions which should be // AccessControl describes a set of Filters, and the Actions which should be
// taken on a CommitRequest if those Filters all match on the CommitRequest. // taken on a CommitRequest if those Filters all match on the CommitRequest.
type AccessControl struct { type AccessControl struct {
Action Action `yaml:"action"` Action Action `yaml:"action"`
Filters []Filter `yaml:"filters"` Filters []FilterUnion `yaml:"filters"`
} }
// ActionForCommit returns what Action this AccessControl says to take for a // ActionForCommit returns what Action this AccessControl says to take for a
// given CommitRequest. It may return ActionNext if the request is not matched // given CommitRequest. It may return ActionNext if the request is not matched
// by the AccessControl's Filters. // by the AccessControl's Filters.
func (ac AccessControl) ActionForCommit(req CommitRequest) (Action, error) { func (ac AccessControl) ActionForCommit(req CommitRequest) (Action, error) {
for _, filter := range ac.Filters { for _, filterUn := range ac.Filters {
filterI, err := filter.Interface() if err := filterUn.Filter().MatchCommit(req); errors.As(err, new(ErrFilterNoMatch)) {
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)) {
return ActionNext, nil return ActionNext, nil
} else if err != nil { } else if err != nil {
// ignore the error here, if we could get the FilterInterface then return "", fmt.Errorf("matching commit using filter of type %q: %w", filterUn.Type(), err)
// 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 ac.Action, nil return ac.Action, nil

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

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

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

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

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

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

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

@ -8,7 +8,7 @@ import (
type filterCommitMatchTest struct { type filterCommitMatchTest struct {
descr string descr string
filter FilterInterface filter Filter
req CommitRequest req CommitRequest
match bool 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 { mkReq := func(commitType string) CommitRequest {
return CommitRequest{Type: commitType} return CommitRequest{Type: commitType}
} }
@ -46,7 +46,7 @@ func TestFilterCommitType(t *testing.T) {
runCommitMatchTests(t, []filterCommitMatchTest{ runCommitMatchTests(t, []filterCommitMatchTest{
{ {
descr: "single match", descr: "single match",
filter: FilterCommitType{ filter: FilterPayloadType{
Type: "foo", Type: "foo",
}, },
req: mkReq("foo"), req: mkReq("foo"),
@ -54,7 +54,7 @@ func TestFilterCommitType(t *testing.T) {
}, },
{ {
descr: "single no match", descr: "single no match",
filter: FilterCommitType{ filter: FilterPayloadType{
Type: "foo", Type: "foo",
}, },
req: mkReq("bar"), req: mkReq("bar"),
@ -62,7 +62,7 @@ func TestFilterCommitType(t *testing.T) {
}, },
{ {
descr: "multi match first", descr: "multi match first",
filter: FilterCommitType{ filter: FilterPayloadType{
Types: []string{"foo", "bar"}, Types: []string{"foo", "bar"},
}, },
req: mkReq("foo"), req: mkReq("foo"),
@ -70,7 +70,7 @@ func TestFilterCommitType(t *testing.T) {
}, },
{ {
descr: "multi match second", descr: "multi match second",
filter: FilterCommitType{ filter: FilterPayloadType{
Types: []string{"foo", "bar"}, Types: []string{"foo", "bar"},
}, },
req: mkReq("bar"), req: mkReq("bar"),
@ -78,7 +78,7 @@ func TestFilterCommitType(t *testing.T) {
}, },
{ {
descr: "multi no match", descr: "multi no match",
filter: FilterCommitType{ filter: FilterPayloadType{
Types: []string{"foo", "bar"}, Types: []string{"foo", "bar"},
}, },
req: mkReq("baz"), req: mkReq("baz"),
@ -119,7 +119,7 @@ func TestFilterCommitAttributes(t *testing.T) {
}, },
{ {
descr: "ff with inverted non-ff filter", descr: "ff with inverted non-ff filter",
filter: FilterNot{Filter: Filter{ filter: FilterNot{Filter: FilterUnion{
CommitAttributes: &FilterCommitAttributes{NonFastForward: true}, CommitAttributes: &FilterCommitAttributes{NonFastForward: true},
}}, }},
req: mkReq(false), req: mkReq(false),
@ -127,7 +127,7 @@ func TestFilterCommitAttributes(t *testing.T) {
}, },
{ {
descr: "non-ff with inverted non-ff filter", descr: "non-ff with inverted non-ff filter",
filter: FilterNot{Filter: Filter{ filter: FilterNot{Filter: FilterUnion{
CommitAttributes: &FilterCommitAttributes{NonFastForward: true}, CommitAttributes: &FilterCommitAttributes{NonFastForward: true},
}}, }},
req: mkReq(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") 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") pgpKeyID := flag.String("anon-pgp-key", "", "ID of pgp key to sign with instead of using an account")
var repo repo var proj proj
repo.initFlags(flag) 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 != "" { if *accountID != "" {
cfg, err := repo.LoadConfig() cfg, err := proj.LoadConfig()
if err != nil { if err != nil {
return err 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) return fmt.Errorf("account %q has %d signifiers, only one is supported right now", *accountID, l)
} }
sig := account.Signifiers[0] sig = account.Signifiers[0].Signifier(*accountID)
sigInt, err = sig.Interface(*accountID)
if err != nil {
return fmt.Errorf("casting %#v to SignifierInterface: %w", sig, err)
}
} else { } else {
var err error 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) 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 { 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 { if err != nil {
return fmt.Errorf("committing to git: %w", err) 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 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") 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 return nil, err
} }
var err error 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 nil, fmt.Errorf("determining if any changes have been staged: %w", err)
} }
return ctx, nil return ctx, nil
@ -90,7 +85,7 @@ func cmdCommit(ctx context.Context, cmd *dcmd.Cmd) {
cmd.SubCmd("change", "Commit file changes", cmd.SubCmd("change", "Commit file changes",
func(ctx context.Context, cmd *dcmd.Cmd) { func(ctx context.Context, cmd *dcmd.Cmd) {
flag := cmd.FlagSet() 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") amend := flag.Bool("amend", false, "Add changes to HEAD commit, amend its message, and re-accredit it")
cmd.Run(func() (context.Context, error) { cmd.Run(func() (context.Context, error) {
if !hasStaged && !*amend { if !hasStaged && !*amend {
@ -99,28 +94,28 @@ func cmdCommit(ctx context.Context, cmd *dcmd.Cmd) {
var prevMsg string var prevMsg string
if *amend { if *amend {
oldHead, err := repo.softReset("change") oldHead, err := proj.softReset("change")
if err != nil { if err != nil {
return nil, err return nil, err
} }
prevMsg = oldHead.Commit.Change.Message prevMsg = oldHead.Payload.Change.Description
} }
if *msg == "" { if *description == "" {
var err error 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) 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") return nil, errors.New("empty commit message, not doing anything")
} }
} }
commit, err := repo.NewCommitChange(*msg) payUn, err := proj.NewPayloadChange(*description)
if err != nil { 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, err
} }
return nil, nil 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") return nil, errors.New("credential commit cannot have staged changes")
} }
var credCommit dehub.Commit var credPayUn dehub.PayloadUnion
if *rev != "" { if *rev != "" {
gitCommit, err := repo.GetGitRevision(plumbing.Revision(*rev)) commit, err := proj.GetCommitByRevision(plumbing.Revision(*rev))
if err != nil { if err != nil {
return nil, fmt.Errorf("resolving revision %q: %w", *rev, err) return nil, fmt.Errorf("resolving revision %q: %w", *rev, err)
} }
gitCommits := []dehub.GitCommit{gitCommit} if credPayUn, err = proj.NewPayloadCredentialFromChanges([]dehub.Commit{commit}); err != nil {
if credCommit, err = repo.NewCommitCredentialFromChanges(gitCommits); err != nil {
return nil, fmt.Errorf("constructing credential commit: %w", err) return nil, fmt.Errorf("constructing credential commit: %w", err)
} }
} else { } else {
gitCommits, err := repo.GetGitRevisionRange( commits, err := proj.GetCommitRangeByRevision(
plumbing.Revision(*startRev), plumbing.Revision(*startRev),
plumbing.Revision(*endRev), plumbing.Revision(*endRev),
) )
if err != nil { if err != nil {
return nil, fmt.Errorf("resolving revisions %q to %q: %w", return nil, fmt.Errorf("resolving revisions %q to %q: %w",
*startRev, *endRev, err) *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) 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, err
} }
return nil, nil return nil, nil
@ -176,37 +170,37 @@ func cmdCommit(ctx context.Context, cmd *dcmd.Cmd) {
cmd.SubCmd("comment", "Commit a comment to a branch", cmd.SubCmd("comment", "Commit a comment to a branch",
func(ctx context.Context, cmd *dcmd.Cmd) { func(ctx context.Context, cmd *dcmd.Cmd) {
flag := cmd.FlagSet() 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") amend := flag.Bool("amend", false, "Amend the comment message currently in HEAD")
cmd.Run(func() (context.Context, error) { cmd.Run(func() (context.Context, error) {
if hasStaged { if hasStaged {
return nil, errors.New("comment commit cannot have staged changes") return nil, errors.New("comment commit cannot have staged changes")
} }
var prevMsg string var prevComment string
if *amend { if *amend {
oldHead, err := repo.softReset("comment") oldHead, err := proj.softReset("comment")
if err != nil { if err != nil {
return nil, err return nil, err
} }
prevMsg = oldHead.Commit.Comment.Message prevComment = oldHead.Payload.Comment.Comment
} }
if *msg == "" { if *comment == "" {
var err error 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) 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") return nil, errors.New("empty comment message, not doing anything")
} }
} }
commit, err := repo.NewCommitComment(*msg) payUn, err := proj.NewPayloadComment(*comment)
if err != nil { if err != nil {
return nil, fmt.Errorf("constructing comment commit: %w", err) 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") startRev := flag.String("start", "", "Revision of the starting commit to combine")
endRev := flag.String("end", "", "Revision of the ending commit to combine") endRev := flag.String("end", "", "Revision of the ending commit to combine")
var repo repo var proj proj
repo.initFlags(flag) proj.initFlags(flag)
cmd.Run(func() (context.Context, error) { cmd.Run(func() (context.Context, error) {
if *onto == "" || if *onto == "" ||
@ -230,11 +224,11 @@ func cmdCombine(ctx context.Context, cmd *dcmd.Cmd) {
return nil, errors.New("-onto, -start, and -end are required") 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 return nil, err
} }
commits, err := repo.GetGitRevisionRange( commits, err := proj.GetCommitRangeByRevision(
plumbing.Revision(*startRev), plumbing.Revision(*startRev),
plumbing.Revision(*endRev), plumbing.Revision(*endRev),
) )
@ -244,13 +238,12 @@ func cmdCombine(ctx context.Context, cmd *dcmd.Cmd) {
} }
ontoBranch := plumbing.NewBranchReferenceName(*onto) ontoBranch := plumbing.NewBranchReferenceName(*onto)
gitCommit, err := repo.CombineCommitChanges(commits, ontoBranch) commit, err := proj.CombinePayloadChanges(commits, ontoBranch)
if err != nil { if err != nil {
return nil, err return nil, err
} }
fmt.Printf("new commit %q added to branch %q\n", fmt.Printf("new commit %q added to branch %q\n", commit.Hash, ontoBranch.Short())
gitCommit.GitCommit.Hash, ontoBranch.Short())
return nil, nil return nil, nil
}) })
} }

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

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

@ -10,19 +10,19 @@ import (
"gopkg.in/src-d/go-git.v4/plumbing" "gopkg.in/src-d/go-git.v4/plumbing"
) )
type repo struct { type proj struct {
bare bool bare bool
*dehub.Repo *dehub.Project
} }
func (r *repo) initFlags(flag *flag.FlagSet) { func (proj *proj) initFlags(flag *flag.FlagSet) {
flag.BoolVar(&r.bare, "bare", false, "If set then the repo being opened will be expected to be bare") 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 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() wd, _ := os.Getwd()
return fmt.Errorf("opening repo at %q: %w", wd, err) 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), // softReset resets to HEAD^ (or to an orphaned index, if HEAD has no parents),
// returning the old HEAD. // returning the old HEAD.
func (r *repo) softReset(expType string) (dehub.GitCommit, error) { func (proj *proj) softReset(expType string) (dehub.Commit, error) {
head, err := r.GetGitHead() head, err := proj.GetHeadCommit()
if err != nil { if err != nil {
return head, fmt.Errorf("getting HEAD commit: %w", err) return head, fmt.Errorf("getting HEAD commit: %w", err)
} else if typ, err := head.Commit.Type(); err != nil { } else if typ := head.Payload.Type(); expType != "" && typ != expType {
return head, fmt.Errorf("determining commit type of HEAD:% w", err) return head, fmt.Errorf("expected HEAD to be have a %q payload, but found a %q payload",
} else if expType != "" && typ != expType {
return head, fmt.Errorf("expected HEAD to be a %q commit, but found %q",
expType, typ) expType, typ)
} }
branchName, branchErr := r.ReferenceToBranchName(plumbing.HEAD) branchName, branchErr := proj.ReferenceToBranchName(plumbing.HEAD)
numParents := head.GitCommit.NumParents() numParents := head.Object.NumParents()
if numParents > 1 { if numParents > 1 {
return head, errors.New("cannot reset to parent of a commit with multiple parents") 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. // it and all of HEAD's changes will be in the index.
if branchErr != nil { if branchErr != nil {
return head, branchErr 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, fmt.Errorf("removing reference %q: %w", branchName, err)
} }
return head, nil return head, nil
@ -68,9 +66,9 @@ func (r *repo) softReset(expType string) (dehub.GitCommit, error) {
return head, fmt.Errorf("resolving HEAD: %w", err) return head, fmt.Errorf("resolving HEAD: %w", err)
} }
parentHash := head.GitCommit.ParentHashes[0] parentHash := head.Object.ParentHashes[0]
newHeadRef := plumbing.NewHashReference(refName, parentHash) 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, fmt.Errorf("storing reference %q: %w", newHeadRef, err)
} }
return head, nil 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") 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") branch := flag.String("branch", "", "Branch that the revision is on. If not given then the currently checked out branch is assumed")
var repo repo var proj proj
repo.initFlags(flag) proj.initFlags(flag)
cmd.Run(func() (context.Context, error) { cmd.Run(func() (context.Context, error) {
if err := repo.openRepo(); err != nil { if err := proj.openProj(); err != nil {
return nil, err return nil, err
} }
gitCommit, err := repo.GetGitRevision(plumbing.Revision(*rev)) commit, err := proj.GetCommitByRevision(plumbing.Revision(*rev))
if err != nil { if err != nil {
return nil, fmt.Errorf("resolving revision %q: %w", *rev, err) return nil, fmt.Errorf("resolving revision %q: %w", *rev, err)
} }
gitCommitHash := gitCommit.GitCommit.Hash
var branchName plumbing.ReferenceName var branchName plumbing.ReferenceName
if *branch == "" { 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) return nil, fmt.Errorf("determining branch at HEAD: %w", err)
} }
} else { } else {
branchName = plumbing.NewBranchReferenceName(*branch) 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", 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 return nil, nil
}) })
} }

@ -8,7 +8,7 @@ import (
func main() { func main() {
cmd := dcmd.New() 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("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("verify", "Verifies one or more commits as having the proper credentials", cmdVerify)
cmd.SubCmd("hook", "Use dehub as a git hook", cmdHook) cmd.SubCmd("hook", "Use dehub as a git hook", cmdHook)

@ -1,610 +1,222 @@
package dehub package dehub
import ( import (
"bytes" "encoding/hex"
"encoding/base64"
"errors" "errors"
"fmt" "fmt"
"reflect" "path/filepath"
"sort"
"strings" "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"
"gopkg.in/src-d/go-git.v4/plumbing/object" "gopkg.in/src-d/go-git.v4/plumbing/object"
yaml "gopkg.in/yaml.v2"
) )
// CommitInterface describes the methods which must be implemented by the // Commit wraps a single git commit object, and also contains various fields
// different commit types. None of the methods should modify the underlying // which are parsed out of it, including the payload. It is used as a
// object. // convenience type, in place of having to manually retrieve and parse specific
type CommitInterface interface { // information out of commit objects.
// 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.
type Commit struct { type Commit struct {
Change *CommitChange `type:"change,default"` Payload PayloadUnion
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)
}
w := new(bytes.Buffer) Hash plumbing.Hash
w.WriteString(msgHead) Object *object.Commit
w.WriteString("\n\n---\n") TreeObject *object.Tree
w.Write(msgBodyB)
return w.Bytes(), nil
} }
// UnmarshalText implements the encoding.TextUnmarshaler interface by decoding a // GetCommit retrieves the Commit at the given hash, and all of its sub-data
// Commit object which has been encoded into a git commit message. // which can be pulled out of it.
func (c *Commit) UnmarshalText(msg []byte) error { func (proj *Project) GetCommit(h plumbing.Hash) (c Commit, err error) {
i := bytes.Index(msg, []byte("\n")) if c.Object, err = proj.GitRepo.CommitObject(h); err != nil {
if i < 0 { return c, fmt.Errorf("getting git commit object: %w", err)
return fmt.Errorf("commit message %q is malformed, it has no body", msg) } 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)
} }
msgBody := msg[i:] c.Hash = c.Object.Hash
return
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")
}
return nil
} }
// AccreditCommit returns the given Commit with an appended Credential provided // ErrHeadIsZero is used to indicate that HEAD resolves to the zero hash. An
// by the given SignifierInterface. // example of when this can happen is if the project was just initialized and
func (r *Repo) AccreditCommit(commit Commit, sigInt sigcred.SignifierInterface) (Commit, error) { // has no commits, or if an orphan branch is checked out.
commitInt, err := commit.Interface() var ErrHeadIsZero = errors.New("HEAD resolves to the zero hash")
if err != nil {
return commit, fmt.Errorf("could not cast commit %+v to interface: %w", commit, err)
}
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 { if err != nil {
return commit, fmt.Errorf("could not grab snapshot of HEAD fs: %w", err) return Commit{}, fmt.Errorf("resolving HEAD: %w", err)
} else if headHash == plumbing.ZeroHash {
return Commit{}, ErrHeadIsZero
} }
cred, err := sigInt.Sign(headFS, commitInt.StoredHash()) c, err := proj.GetCommit(headHash)
if err != nil { if err != nil {
return commit, fmt.Errorf("could not accredit change commit: %w", err) return Commit{}, fmt.Errorf("getting commit %q: %w", headHash, err)
} }
commit.Common.Credentials = append(commit.Common.Credentials, cred) return c, nil
return commit, nil
} }
// CommitBareParams are the parameters to the CommitBare method. All are // GetCommitRange returns an ancestry of Commits, with the first being the
// required, unless otherwise noted. // commit immediately following the given starting hash, and the last being the
type CommitBareParams struct { // given ending hash.
Commit Commit //
Author string // If start is plumbing.ZeroHash then the root commit will be the starting hash.
ParentHash plumbing.Hash // can be zero if the commit has no parents (Q_Q) func (proj *Project) GetCommitRange(start, end plumbing.Hash) ([]Commit, error) {
GitTree *object.Tree curr, err := proj.GetCommit(end)
}
// 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}
}
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)
if err != nil { if err != nil {
return GitCommit{}, fmt.Errorf("setting encoded object: %w", err) return nil, fmt.Errorf("retrieving commit %q: %w", end, err)
} }
return r.GetGitCommit(commitHash) var commits []Commit
} var found bool
for {
// Commit uses the given Commit to create a git commit object and commits it to if found = start != plumbing.ZeroHash && curr.Hash == start; found {
// the current HEAD, returning the full GitCommit. break
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) commits = append(commits, curr)
if err != nil { numParents := curr.Object.NumParents()
return GitCommit{}, fmt.Errorf("resolving ref %q (HEAD): %w", headRefName, err) 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)
}
// TODO this is also used in the same way in NewCommitChange. It might make parentHash := curr.Object.ParentHashes[0]
// sense to refactor this logic out, it might not be needed in fs at all. parent, err := proj.GetCommit(parentHash)
_, stagedTree, err := fs.FromStagedChangesTree(r.GitRepo) if err != nil {
if err != nil { return nil, fmt.Errorf("retrieving commit %q: %w", parentHash, err)
return GitCommit{}, fmt.Errorf("getting staged changes: %w", err) }
curr = parent
} }
if !found && start != plumbing.ZeroHash {
gitCommit, err := r.CommitBare(CommitBareParams{ return nil, fmt.Errorf("unable to find commit %q as an ancestor of %q",
Commit: commit, start, end)
Author: strings.Join(commit.Common.credIDs(), ", "),
ParentHash: headHash,
GitTree: stagedTree,
})
if err != nil {
return GitCommit{}, err
} }
// now set the branch to this new commit // reverse the commits to be in the expected order
newHeadRef := plumbing.NewHashReference(headRefName, gitCommit.GitCommit.Hash) for l, r := 0, len(commits)-1; l < r; l, r = l+1, r-1 {
if err := r.GitRepo.Storer.SetReference(newHeadRef); err != nil { commits[l], commits[r] = commits[r], commits[l]
return GitCommit{}, fmt.Errorf("setting reference %q to new commit hash %q: %w",
headRefName, gitCommit.GitCommit.Hash, err)
} }
return commits, nil
return gitCommit, nil
} }
// HasStagedChanges returns true if there are file changes which have been var (
// staged (e.g. via "git add"). hashStrLen = len(plumbing.ZeroHash.String())
func (r *Repo) HasStagedChanges() (bool, error) { errNotHex = errors.New("not a valid hex string")
w, err := r.GitRepo.Worktree() )
if err != nil {
return false, fmt.Errorf("error retrieving worktree: %w", err)
}
status, err := w.Status()
if err != nil {
return false, fmt.Errorf("error retrieving worktree status: %w", err)
}
var any bool func (proj *Project) findCommitByShortHash(hashStr string) (plumbing.Hash, error) {
for _, fileStatus := range status { paddedHashStr := hashStr
if fileStatus.Staging != git.Unmodified && if len(hashStr)%2 > 0 {
fileStatus.Staging != git.Untracked { paddedHashStr += "0"
any = true
break
}
} }
return any, nil
}
// VerifyCommits verifies that the given commits, which are presumably on the if hashB, err := hex.DecodeString(paddedHashStr); err != nil {
// given branch, are gucci. return plumbing.ZeroHash, errNotHex
func (r *Repo) VerifyCommits(branchName plumbing.ReferenceName, gitCommits []GitCommit) error { } else if len(hashStr) == hashStrLen {
// this isn't strictly necessary for this method, but it helps discover bugs var hash plumbing.Hash
// in other parts of the code. copy(hash[:], hashB)
if len(gitCommits) == 0 { return hash, nil
return errors.New("cannot call VerifyCommits with empty commit slice") } else if len(hashStr) < 2 {
return plumbing.ZeroHash, errors.New("hash string must be 2 characters long or more")
} }
// First determine the root of the main branch. All commits need to be an for i := 2; i < hashStrLen; i++ {
// ancestor of it. If the main branch has not been created yet then there hashPrefix, hashTail := hashStr[:i], hashStr[i:]
// might not be a root commit yet. path := filepath.Join("objects", hashPrefix)
var rootCommit *object.Commit fileInfos, err := proj.GitDirFS.ReadDir(path)
mainGitCommit, err := r.GetGitRevision(plumbing.Revision(MainRefName)) if err != nil {
if errors.Is(err, plumbing.ErrReferenceNotFound) { return plumbing.ZeroHash, fmt.Errorf("listing files in %q: %w", path, err)
// 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")
} }
} else if err != nil { var matchedHash plumbing.Hash
return fmt.Errorf("retrieving commit at HEAD of %q: %w", MainRefName.Short(), err) for _, fileInfo := range fileInfos {
objFileName := fileInfo.Name()
} else { if !strings.HasPrefix(objFileName, hashTail) {
rootCommit = mainGitCommit.GitCommit continue
for {
if rootCommit.NumParents() == 0 {
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)
}
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
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
} objHash := plumbing.NewHash(hashPrefix + objFileName)
return isAncestor obj, err := proj.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
ancestorOfRoot := isAncestor(rootCommit, gitCommit.GitCommit) } else if matchedHash == plumbing.ZeroHash {
if branchGitCommit.GitCommit != nil { matchedHash = objHash
// if the branch doesn't actually exist then this couldn't continue
// possibly be a nonFF
isNonFF = !isAncestor(branchGitCommit.GitCommit, gitCommit.GitCommit)
} }
if err != nil { return plumbing.ZeroHash, fmt.Errorf("both %q and %q match", matchedHash, objHash)
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,
)
}
} }
if err := r.verifyCommit(branchName, gitCommit, parentTree, isNonFF); err != nil { if matchedHash != plumbing.ZeroHash {
return fmt.Errorf("verifying commit %q: %w", return matchedHash, nil
gitCommit.GitCommit.Hash, err)
} }
} }
return nil
}
// parentTree returns the tree of the parent commit of the given commit. If the return plumbing.ZeroHash, errors.New("failed to find a commit object with a matching prefix")
// 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 (proj *Project) resolveRev(rev plumbing.Revision) (plumbing.Hash, error) {
func (r *Repo) verifyCommit( if rev == plumbing.Revision(plumbing.ZeroHash.String()) {
branchName plumbing.ReferenceName, return plumbing.ZeroHash, nil
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 sigFS fs.FS
if gitCommit.Root() {
sigFS = fs.FromTree(gitCommit.GitTree)
} else {
sigFS = fs.FromTree(parentTree)
}
cfg, err := r.loadConfig(sigFS)
if err != nil {
return fmt.Errorf("loading config of parent %q: %w",
gitCommit.GitCommit.ParentHashes[0], err)
}
// assert access controls
changedFiles, err := ChangedFilesBetweenTrees(parentTree, gitCommit.GitTree)
if err != nil {
return fmt.Errorf("calculating diff from tree %q to tree %q: %w",
parentTree.Hash, gitCommit.GitTree.Hash, err)
} else if len(changedFiles) > 0 && gitCommit.Commit.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, err := gitCommit.Commit.Type()
if err != nil {
return fmt.Errorf("determining type of commit %+v: %w", gitCommit.Commit, err)
}
err = accessctl.AssertCanCommit(cfg.AccessControls, accessctl.CommitRequest{
Type: commitType,
Branch: branchName.Short(),
Credentials: gitCommit.Commit.Common.Credentials,
FilesChanged: pathsChanged,
NonFastForward: isNonFF,
})
if err != nil {
return fmt.Errorf("asserting access controls: %w", err)
}
// 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))
} }
// verify all credentials {
for _, cred := range gitCommit.Commit.Common.Credentials { // pretend the revision is a short hash until proven otherwise
if cred.AccountID == "" { shortHash := string(rev)
if err := cred.SelfVerify(expectedCommitHash); err != nil { hash, err := proj.findCommitByShortHash(shortHash)
return fmt.Errorf("verifying credential %+v: %w", cred, err) if errors.Is(err, errNotHex) {
} // ok, continue
} else if err != nil {
return plumbing.ZeroHash, fmt.Errorf("resolving as short hash: %w", err)
} else { } else {
sig, err := r.signifierForCredential(sigFS, cred) // guess it _is_ a short hash, knew it!
if err != nil { return hash, 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 h, err := proj.GitRepo.ResolveRevision(rev)
}
type changeRangeInfo struct {
changeCommits []GitCommit
authors map[string]struct{}
msg string
startTree, endTree *object.Tree
changeHash []byte
}
// 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{}{},
}
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{}{}
}
}
}
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)
if err != nil { if err != nil {
return changeRangeInfo{}, fmt.Errorf("calculating diff of commit trees %q and %q: %w", return plumbing.ZeroHash, fmt.Errorf("resolving revision %q: %w", rev, err)
info.startTree.Hash, info.endTree.Hash, err)
} }
return *h, nil
info.changeHash = genChangeHash(nil, info.msg, changedFiles)
return info, nil
} }
// VerifyCanSetBranchHEADTo is used to verify that a branch's HEAD can be set to // GetCommitByRevision resolves the revision and returns the Commit it references.
// the given hash. It verifies any new commits which are being added, and func (proj *Project) GetCommitByRevision(rev plumbing.Revision) (Commit, error) {
// handles verifying non-fast-forward commits as well. hash, err := proj.resolveRev(rev)
//
// 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)
if err != nil {
return fmt.Errorf("retrieving %q and all its ancestors: %w", hash, 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())
if err != nil { if err != nil {
return fmt.Errorf("retrieving commit object %q: %w", oldCommitRef.Hash(), err) return Commit{}, err
} }
newCommitObj, err := r.GitRepo.CommitObject(hash) c, err := proj.GetCommit(hash)
if err != nil { if err != nil {
return fmt.Errorf("retrieving commit object %q: %w", hash, err) return Commit{}, fmt.Errorf("getting commit %q: %w", hash, err)
} }
return c, nil
}
mbCommits, err := oldCommitObj.MergeBase(newCommitObj) // 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 { if err != nil {
return fmt.Errorf("determining merge-base between %q and %q: %w", return nil, err
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 := r.GetGitCommitRange(mbCommits[0].Hash, hash) end, err := proj.resolveRev(endRev)
if err != nil { if err != nil {
return fmt.Errorf("retrieving commits %q to %q: %w", mbCommits[0].Hash, hash, err) return nil, 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
}

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

@ -68,7 +68,7 @@ var (
) )
// if h is nil it then defaultHashHelperAlgo will be used // 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 := newHashHelper(h)
s.writeStr(msg) s.writeStr(msg)
s.writeChangedFiles(changedFiles) 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 // 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 := newHashHelper(h)
s.writeStr(comment) s.writeStr(comment)
return s.sum(commentHashVersion) return s.sum(commentHashVersion)

@ -47,7 +47,7 @@ func uvarint(i uint64) []byte {
return buf[:n] return buf[:n]
} }
func TestGenCommentHash(t *testing.T) { func TestGenCommentFingerprint(t *testing.T) {
type test struct { type test struct {
descr string descr string
comment string comment string
@ -75,13 +75,13 @@ func TestGenCommentHash(t *testing.T) {
for _, test := range tests { for _, test := range tests {
t.Run(test.descr, func(t *testing.T) { t.Run(test.descr, func(t *testing.T) {
th := new(testHash) th := new(testHash)
genCommentHash(th, test.comment) genCommentFingerprint(th, test.comment)
th.assertContents(t, test.exp) th.assertContents(t, test.exp)
}) })
} }
} }
func TestGenChangeHash(t *testing.T) { func TestGenChangeFingerprint(t *testing.T) {
type test struct { type test struct {
descr string descr string
msg string msg string
@ -230,7 +230,7 @@ func TestGenChangeHash(t *testing.T) {
for _, test := range tests { for _, test := range tests {
t.Run(test.descr, func(t *testing.T) { t.Run(test.descr, func(t *testing.T) {
th := new(testHash) th := new(testHash)
genChangeHash(th, test.msg, test.changedFiles) genChangeFingerprint(th, test.msg, test.changedFiles)
th.assertContents(t, test.exp) 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" "gopkg.in/src-d/go-git.v4/plumbing"
) )
func TestChangeCommitVerify(t *testing.T) { func TestPayloadChangeVerify(t *testing.T) {
type step struct { type step struct {
msg string descr string
msgHead string // defaults to msg msgHead string // defaults to msg
tree map[string]string tree map[string]string
} }
@ -23,8 +23,8 @@ func TestChangeCommitVerify(t *testing.T) {
descr: "single commit", descr: "single commit",
steps: []step{ steps: []step{
{ {
msg: "first commit", descr: "first commit",
tree: map[string]string{"a": "0", "b": "1"}, tree: map[string]string{"a": "0", "b": "1"},
}, },
}, },
}, },
@ -32,19 +32,19 @@ func TestChangeCommitVerify(t *testing.T) {
descr: "multiple commits", descr: "multiple commits",
steps: []step{ steps: []step{
{ {
msg: "first commit", descr: "first commit",
tree: map[string]string{"a": "0", "b": "1"}, tree: map[string]string{"a": "0", "b": "1"},
}, },
{ {
msg: "second commit, changing a", descr: "second commit, changing a",
tree: map[string]string{"a": "1"}, 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"}, tree: map[string]string{"b": "", "c": "2"},
}, },
}, },
}, },
@ -52,18 +52,18 @@ func TestChangeCommitVerify(t *testing.T) {
descr: "big body commits", descr: "big body commits",
steps: []step{ 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", 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", 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", msgHead: "fourth commit, multi-line with two newlines\n\n",
}, },
}, },
@ -78,29 +78,29 @@ func TestChangeCommitVerify(t *testing.T) {
for _, step := range test.steps { for _, step := range test.steps {
h.stage(step.tree) h.stage(step.tree)
gitCommit := h.assertCommitChange(verifyShouldSucceed, step.msg, rootSig) commit := h.assertCommitChange(verifyShouldSucceed, step.descr, rootSig)
if step.msgHead == "" { 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", 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 var payUn PayloadUnion
if err := actualCommit.UnmarshalText([]byte(gitCommit.GitCommit.Message)); err != nil { if err := payUn.UnmarshalText([]byte(commit.Object.Message)); err != nil {
t.Fatalf("error unmarshaling commit body: %v", err) t.Fatalf("error unmarshaling commit message: %v", err)
} else if !reflect.DeepEqual(actualCommit, gitCommit.Commit) { } else if !reflect.DeepEqual(payUn, commit.Payload) {
t.Fatalf("returned change commit:\n%s\ndoes not match actual one:\n%s", t.Fatalf("returned change payload:\n%s\ndoes not match actual one:\n%s",
spew.Sdump(gitCommit.Commit), spew.Sdump(actualCommit)) spew.Sdump(commit.Payload), spew.Sdump(payUn))
} }
} }
}) })
} }
} }
func TestCombineCommitChanges(t *testing.T) { func TestCombinePayloadChanges(t *testing.T) {
h := newHarness(t) h := newHarness(t)
// commit initial config, so the root user can modify it in the next commit // commit initial config, so the root user can modify it in the next commit
@ -115,8 +115,8 @@ func TestCombineCommitChanges(t *testing.T) {
filters: filters:
- type: branch - type: branch
pattern: main pattern: main
- type: commit_type - type: payload_type
commit_type: change payload_type: change
- type: signature - type: signature
any_account: true any_account: true
count: 2 count: 2
@ -141,27 +141,24 @@ func TestCombineCommitChanges(t *testing.T) {
fooCommit := h.assertCommitChange(verifyShouldSucceed, "add foo file", rootSig) fooCommit := h.assertCommitChange(verifyShouldSucceed, "add foo file", rootSig)
// now adding a credential commit from toot should work // 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 { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
credCommit := h.tryCommit(verifyShouldSucceed, credCommitObj, tootSig) credCommit := h.tryCommit(verifyShouldSucceed, credCommitPayUn, tootSig)
allCommits, err := h.repo.GetGitCommitRange( allCommits, err := h.proj.GetCommitRange(tootCommit.Hash, credCommit.Hash)
tootCommit.GitCommit.Hash,
credCommit.GitCommit.Hash,
)
if err != nil { 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 { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
// that new commit should have both credentials // that new commit should have both credentials
creds := combinedCommit.Commit.Common.Credentials creds := combinedCommit.Payload.Common.Credentials
if len(creds) != 2 { if len(creds) != 2 {
t.Fatalf("combined commit has %d credentials, not 2", len(creds)) t.Fatalf("combined commit has %d credentials, not 2", len(creds))
} else if creds[0].AccountID != "root" { } 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 // double check that the HEAD commit of main got properly set
h.checkout(MainRefName) h.checkout(MainRefName)
mainHead, err := h.repo.GetGitHead() mainHead, err := h.proj.GetHeadCommit()
if err != nil { if err != nil {
t.Fatal(err) 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", t.Fatalf("mainHead's should be pointed at %s but is pointed at %s",
combinedCommit.GitCommit.Hash, mainHead.GitCommit.Hash) combinedCommit.Hash, mainHead.Hash)
} else if err = h.repo.VerifyCommits(MainRefName, []GitCommit{combinedCommit}); err != nil { } else if err = h.proj.VerifyCommits(MainRefName, []Commit{combinedCommit}); err != nil {
t.Fatalf("unable to verify combined commit: %v", err) 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) 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" "gopkg.in/src-d/go-git.v4/plumbing"
) )
func TestCredentialCommitVerify(t *testing.T) { func TestPayloadCredentialVerify(t *testing.T) {
h := newHarness(t) h := newHarness(t)
rootSig := h.stageNewAccount("root", false) 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 // toot user wants to create a credential commit for the root commit, for
// whatever reason. // whatever reason.
rootChangeHash := rootGitCommit.Commit.Change.ChangeHash rootChangeFingerprint := rootGitCommit.Payload.Common.Fingerprint
credCommit, err := h.repo.NewCommitCredential(rootChangeHash) credCommitPayUn, err := h.proj.NewPayloadCredential(rootChangeFingerprint)
if err != nil { 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. // toot tries again in their own branch, and should be allowed.
h.checkout(tootBranch) 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) h := newHarness(t)
rootSig := h.stageNewAccount("root", false) rootSig := h.stageNewAccount("root", false)
var gitCommits []GitCommit var commits []Commit
// commit the initial staged changes, which merely include the config and // commit the initial staged changes, which merely include the config and
// public key // public key
gitCommit := h.assertCommitChange(verifyShouldSucceed, "commit configuration", rootSig) commit := h.assertCommitChange(verifyShouldSucceed, "commit configuration", rootSig)
gitCommits = append(gitCommits, gitCommit) commits = append(commits, commit)
// create a new account and add it to the configuration. That commit should // create a new account and add it to the configuration. That commit should
// not be verifiable, though // not be verifiable, though
@ -30,15 +30,15 @@ func TestConfigChange(t *testing.T) {
// now add with the root user, this should work. // now add with the root user, this should work.
h.stageCfg() h.stageCfg()
gitCommit = h.assertCommitChange(verifyShouldSucceed, "add toot user", rootSig) commit = h.assertCommitChange(verifyShouldSucceed, "add toot user", rootSig)
gitCommits = append(gitCommits, gitCommit) commits = append(commits, commit)
// _now_ the toot user should be able to do things. // _now_ the toot user should be able to do things.
h.stage(map[string]string{"foo/bar": "what a cool file"}) h.stage(map[string]string{"foo/bar": "what a cool file"})
gitCommit = h.assertCommitChange(verifyShouldSucceed, "add a cool file", tootSig) commit = h.assertCommitChange(verifyShouldSucceed, "add a cool file", tootSig)
gitCommits = append(gitCommits, gitCommit) 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) t.Fatal(err)
} }
} }
@ -62,7 +62,7 @@ func TestMainAncestryRequirement(t *testing.T) {
// set HEAD to this other branch which doesn't really exist // set HEAD to this other branch which doesn't really exist
ref := plumbing.NewSymbolicReference(plumbing.HEAD, otherBranch) 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) h.t.Fatal(err)
} }
@ -93,15 +93,15 @@ func TestNonFastForwardCommits(t *testing.T) {
h.stage(map[string]string{"foo": "foo"}) h.stage(map[string]string{"foo": "foo"})
fooCommit := h.assertCommitChange(verifyShouldSucceed, "foo", rootSig) 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) 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) 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) 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) 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) h.t.Fatal(err)
} else { } else {
return gitCommit 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 // 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). // verify that (this is too fancy for the harness, must be done manually).
h.stage(map[string]string{"bar": "bar"}) h.stage(map[string]string{"bar": "bar"})
barCommit := commitOn(initCommit.GitCommit.Hash, "bar") barCommit := commitOn(initCommit.Hash, "bar")
err := h.repo.VerifyCommits(MainRefName, []GitCommit{barCommit}) err := h.proj.VerifyCommits(MainRefName, []Commit{barCommit})
if !errors.As(err, new(accessctl.ErrCommitRequestDenied)) { if !errors.As(err, new(accessctl.ErrCommitRequestDenied)) {
h.t.Fatalf("expected ErrCommitRequestDenied, got: %v", err) 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 // checking out allowNonFFCommit directly and performing a nonFF commit
// should work now. // should work now.
h.stage(map[string]string{"baz": "baz"}) h.stage(map[string]string{"baz": "baz"})
bazCommit := commitOn(allowNonFFCommit.GitCommit.Hash, "baz") bazCommit := commitOn(allowNonFFCommit.Hash, "baz")
if err = h.repo.VerifyCommits(MainRefName, []GitCommit{bazCommit}); err != nil { if err = h.proj.VerifyCommits(MainRefName, []Commit{bazCommit}); err != nil {
h.t.Fatal(err) h.t.Fatal(err)
} }
// verifying the full history should also work // verifying the full history should also work
gitCommits := []GitCommit{initCommit, fooCommit, allowNonFFCommit, bazCommit} gitCommits := []Commit{initCommit, fooCommit, allowNonFFCommit, bazCommit}
if err = h.repo.VerifyCommits(MainRefName, gitCommits); err != nil { if err = h.proj.VerifyCommits(MainRefName, gitCommits); err != nil {
h.t.Fatal(err) h.t.Fatal(err)
} }
} }
@ -161,7 +161,7 @@ func TestCanSetBranchHEADTo(t *testing.T) {
type test struct { type test struct {
descr string 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 // If true then the verify call is expected to fail. The string is a
// regex which should match the unwrapped error returned. // regex which should match the unwrapped error returned.
@ -171,7 +171,7 @@ func TestCanSetBranchHEADTo(t *testing.T) {
tests := []test{ tests := []test{
{ {
descr: "creation of main", 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 // checkout other and build on top of that, so that when
// VerifyCanSetBranchHEADTo is called main won't exist. // VerifyCanSetBranchHEADTo is called main won't exist.
other := plumbing.NewBranchReferenceName("other") other := plumbing.NewBranchReferenceName("other")
@ -180,26 +180,26 @@ func TestCanSetBranchHEADTo(t *testing.T) {
initCommit := h.assertCommitChange(verifySkip, "init", rootSig) initCommit := h.assertCommitChange(verifySkip, "init", rootSig)
return toTest{ return toTest{
branchName: MainRefName, branchName: MainRefName,
hash: initCommit.GitCommit.Hash, hash: initCommit.Hash,
} }
}, },
}, },
{ {
descr: "main ff", 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) initCommit := h.assertCommitChange(verifySkip, "init", rootSig)
h.stage(map[string]string{"foo": "foo"}) h.stage(map[string]string{"foo": "foo"})
nextCommit := h.assertCommitChange(verifySkip, "next", rootSig) nextCommit := h.assertCommitChange(verifySkip, "next", rootSig)
return toTest{ return toTest{
branchName: MainRefName, branchName: MainRefName,
hash: nextCommit.GitCommit.Hash, hash: nextCommit.Hash,
resetTo: initCommit.GitCommit.Hash, resetTo: initCommit.Hash,
} }
}, },
}, },
{ {
descr: "new branch, no main", 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 // checkout other and build on top of that, so that when
// VerifyCanSetBranchHEADTo is called main won't exist. // VerifyCanSetBranchHEADTo is called main won't exist.
other := plumbing.NewBranchReferenceName("other") other := plumbing.NewBranchReferenceName("other")
@ -208,7 +208,7 @@ func TestCanSetBranchHEADTo(t *testing.T) {
initCommit := h.assertCommitChange(verifySkip, "init", rootSig) initCommit := h.assertCommitChange(verifySkip, "init", rootSig)
return toTest{ return toTest{
branchName: plumbing.NewBranchReferenceName("other2"), 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$`, 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 // this case isn't generally possible, unless someone manually
// creates a branch in an empty repo on the remote // creates a branch in an empty repo on the remote
descr: "existing branch, no main", 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 // checkout other and build on top of that, so that when
// VerifyCanSetBranchHEADTo is called main won't exist. // VerifyCanSetBranchHEADTo is called main won't exist.
other := plumbing.NewBranchReferenceName("other") other := plumbing.NewBranchReferenceName("other")
@ -229,21 +229,21 @@ func TestCanSetBranchHEADTo(t *testing.T) {
return toTest{ return toTest{
branchName: other, branchName: other,
hash: fooCommit.GitCommit.Hash, hash: fooCommit.Hash,
resetTo: initCommit.GitCommit.Hash, resetTo: initCommit.Hash,
} }
}, },
expErr: `^cannot verify commits in branch "refs/heads/other" when no main branch exists$`, expErr: `^cannot verify commits in branch "refs/heads/other" when no main branch exists$`,
}, },
{ {
descr: "new branch, not ancestor of main", 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) h.assertCommitChange(verifySkip, "init", rootSig)
// create new branch with no HEAD, and commit on that. // create new branch with no HEAD, and commit on that.
other := plumbing.NewBranchReferenceName("other") other := plumbing.NewBranchReferenceName("other")
ref := plumbing.NewSymbolicReference(plumbing.HEAD, 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) t.Fatal(err)
} }
@ -252,7 +252,7 @@ func TestCanSetBranchHEADTo(t *testing.T) {
badInitCommit := h.assertCommitChange(verifySkip, "a different init", rootSig) badInitCommit := h.assertCommitChange(verifySkip, "a different init", rootSig)
return toTest{ return toTest{
branchName: plumbing.NewBranchReferenceName("other2"), 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]+"\)$`, 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 // this case isn't generally possible, unless someone manually
// creates a branch in an empty repo on the remote // creates a branch in an empty repo on the remote
descr: "existing branch, not ancestor of main", 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) h.assertCommitChange(verifySkip, "init", rootSig)
// create new branch with no HEAD, and commit on that. // create new branch with no HEAD, and commit on that.
other := plumbing.NewBranchReferenceName("other") other := plumbing.NewBranchReferenceName("other")
ref := plumbing.NewSymbolicReference(plumbing.HEAD, 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) t.Fatal(err)
} }
@ -280,15 +280,15 @@ func TestCanSetBranchHEADTo(t *testing.T) {
return toTest{ return toTest{
branchName: other, branchName: other,
hash: barCommit.GitCommit.Hash, hash: barCommit.Hash,
resetTo: badInitCommit.GitCommit.Hash, resetTo: badInitCommit.Hash,
} }
}, },
expErr: `^commit "[0-9a-f]+" must be direct descendant of root commit of "main" \("[0-9a-f]+"\)$`, expErr: `^commit "[0-9a-f]+" must be direct descendant of root commit of "main" \("[0-9a-f]+"\)$`,
}, },
{ {
descr: "new branch off of main", 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) initCommit := h.assertCommitChange(verifySkip, "init", rootSig)
other := plumbing.NewBranchReferenceName("other") other := plumbing.NewBranchReferenceName("other")
@ -298,14 +298,14 @@ func TestCanSetBranchHEADTo(t *testing.T) {
return toTest{ return toTest{
branchName: other, branchName: other,
hash: fooCommit.GitCommit.Hash, hash: fooCommit.Hash,
resetTo: initCommit.GitCommit.Hash, resetTo: initCommit.Hash,
} }
}, },
}, },
{ {
descr: "new branch off of older main commit", 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) initCommit := h.assertCommitChange(verifySkip, "init", rootSig)
h.stage(map[string]string{"foo": "foo"}) h.stage(map[string]string{"foo": "foo"})
@ -313,26 +313,26 @@ func TestCanSetBranchHEADTo(t *testing.T) {
other := plumbing.NewBranchReferenceName("other") other := plumbing.NewBranchReferenceName("other")
h.checkout(other) h.checkout(other)
h.reset(initCommit.GitCommit.Hash, git.HardReset) h.reset(initCommit.Hash, git.HardReset)
h.stage(map[string]string{"bar": "bar"}) h.stage(map[string]string{"bar": "bar"})
barCommit := h.assertCommitChange(verifySkip, "bar", rootSig) barCommit := h.assertCommitChange(verifySkip, "bar", rootSig)
return toTest{ return toTest{
branchName: other, branchName: other,
hash: barCommit.GitCommit.Hash, hash: barCommit.Hash,
resetTo: initCommit.GitCommit.Hash, resetTo: initCommit.Hash,
} }
}, },
}, },
{ {
descr: "branch ff", descr: "branch ff",
init: func(h *harness, rootSig sigcred.SignifierInterface) toTest { init: func(h *harness, rootSig sigcred.Signifier) toTest {
h.assertCommitChange(verifySkip, "init", rootSig) h.assertCommitChange(verifySkip, "init", rootSig)
other := plumbing.NewBranchReferenceName("other") other := plumbing.NewBranchReferenceName("other")
h.checkout(other) h.checkout(other)
var commits []GitCommit var commits []Commit
for _, str := range []string{"foo", "bar", "baz", "biz", "buz"} { for _, str := range []string{"foo", "bar", "baz", "biz", "buz"} {
h.stage(map[string]string{str: str}) h.stage(map[string]string{str: str})
commit := h.assertCommitChange(verifySkip, str, rootSig) commit := h.assertCommitChange(verifySkip, str, rootSig)
@ -341,14 +341,14 @@ func TestCanSetBranchHEADTo(t *testing.T) {
return toTest{ return toTest{
branchName: other, branchName: other,
hash: commits[len(commits)-1].GitCommit.Hash, hash: commits[len(commits)-1].Hash,
resetTo: commits[0].GitCommit.Hash, resetTo: commits[0].Hash,
} }
}, },
}, },
{ {
descr: "main nonff", 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) initCommit := h.assertCommitChange(verifySkip, "init", rootSig)
h.stage(map[string]string{"foo": "foo"}) h.stage(map[string]string{"foo": "foo"})
h.assertCommitChange(verifySkip, "foo", rootSig) 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 // start another branch back at init and make a new commit on it
other := plumbing.NewBranchReferenceName("other") other := plumbing.NewBranchReferenceName("other")
h.checkout(other) h.checkout(other)
h.reset(initCommit.GitCommit.Hash, git.HardReset) h.reset(initCommit.Hash, git.HardReset)
h.stage(map[string]string{"bar": "bar"}) h.stage(map[string]string{"bar": "bar"})
barCommit := h.assertCommitChange(verifySkip, "bar", rootSig) barCommit := h.assertCommitChange(verifySkip, "bar", rootSig)
return toTest{ return toTest{
branchName: MainRefName, branchName: MainRefName,
hash: barCommit.GitCommit.Hash, hash: barCommit.Hash,
} }
}, },
expErr: `^commit matched and denied by this access control:`, expErr: `^commit matched and denied by this access control:`,
}, },
{ {
descr: "branch nonff", descr: "branch nonff",
init: func(h *harness, rootSig sigcred.SignifierInterface) toTest { init: func(h *harness, rootSig sigcred.Signifier) toTest {
h.assertCommitChange(verifySkip, "init", rootSig) h.assertCommitChange(verifySkip, "init", rootSig)
other := plumbing.NewBranchReferenceName("other") other := plumbing.NewBranchReferenceName("other")
@ -381,13 +381,13 @@ func TestCanSetBranchHEADTo(t *testing.T) {
other2 := plumbing.NewBranchReferenceName("other2") other2 := plumbing.NewBranchReferenceName("other2")
h.checkout(other2) h.checkout(other2)
h.reset(fooCommit.GitCommit.Hash, git.HardReset) h.reset(fooCommit.Hash, git.HardReset)
h.stage(map[string]string{"baz": "baz"}) h.stage(map[string]string{"baz": "baz"})
bazCommit := h.assertCommitChange(verifySkip, "baz", rootSig) bazCommit := h.assertCommitChange(verifySkip, "baz", rootSig)
return toTest{ return toTest{
branchName: other, branchName: other,
hash: bazCommit.GitCommit.Hash, hash: bazCommit.Hash,
} }
}, },
}, },
@ -401,12 +401,12 @@ func TestCanSetBranchHEADTo(t *testing.T) {
if toTest.resetTo != plumbing.ZeroHash { if toTest.resetTo != plumbing.ZeroHash {
ref := plumbing.NewHashReference(toTest.branchName, toTest.resetTo) 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) t.Fatal(err)
} }
} }
err := h.repo.VerifyCanSetBranchHEADTo(toTest.branchName, toTest.hash) err := h.proj.VerifyCanSetBranchHEADTo(toTest.branchName, toTest.hash)
if test.expErr == "" { if test.expErr == "" {
if err != nil { if err != nil {
t.Fatalf("unexpected error: %v", err) 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 { type harness struct {
t *testing.T t *testing.T
rand *rand.Rand rand *rand.Rand
repo *Repo proj *Project
cfg *Config cfg *Config
} }
@ -27,13 +27,13 @@ func newHarness(t *testing.T) *harness {
return &harness{ return &harness{
t: t, t: t,
rand: rand, rand: rand,
repo: InitMemRepo(), proj: InitMemProject(),
cfg: new(Config), cfg: new(Config),
} }
} }
func (h *harness) stage(tree map[string]string) { func (h *harness) stage(tree map[string]string) {
w, err := h.repo.GitRepo.Worktree() w, err := h.proj.GitRepo.Worktree()
if err != nil { if err != nil {
h.t.Fatal(err) h.t.Fatal(err)
} }
@ -41,28 +41,28 @@ func (h *harness) stage(tree map[string]string) {
for path, content := range tree { for path, content := range tree {
if content == "" { if content == "" {
if _, err := w.Remove(path); err != nil { 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 continue
} }
dir := filepath.Dir(path) dir := filepath.Dir(path)
if err := fs.MkdirAll(dir, 0666); err != nil { 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) f, err := fs.Create(path)
if err != nil { 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 { } 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 { } 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 { } 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)}) 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) sig, pubKeyBody := sigcred.TestSignifierPGP(accountID, anon, h.rand)
if !anon { if !anon {
h.cfg.Accounts = append(h.cfg.Accounts, Account{ h.cfg.Accounts = append(h.cfg.Accounts, Account{
ID: accountID, ID: accountID,
Signifiers: []sigcred.Signifier{{PGPPublicKey: &sigcred.SignifierPGP{ Signifiers: []sigcred.SignifierUnion{{PGPPublicKey: &sigcred.SignifierPGP{
Body: string(pubKeyBody), Body: string(pubKeyBody),
}}}, }}},
}) })
@ -97,17 +97,17 @@ func (h *harness) stageAccessControls(aclYAML string) {
} }
func (h *harness) checkout(branch plumbing.ReferenceName) { func (h *harness) checkout(branch plumbing.ReferenceName) {
w, err := h.repo.GitRepo.Worktree() w, err := h.proj.GitRepo.Worktree()
if err != nil { if err != nil {
h.t.Fatal(err) h.t.Fatal(err)
} }
head, err := h.repo.GetGitHead() head, err := h.proj.GetHeadCommit()
if errors.Is(err, ErrHeadIsZero) { if errors.Is(err, ErrHeadIsZero) {
// if HEAD is not resolvable to any hash than the Checkout method // if HEAD is not resolvable to any hash than the Checkout method
// doesn't work, just set HEAD manually. // doesn't work, just set HEAD manually.
ref := plumbing.NewSymbolicReference(plumbing.HEAD, branch) 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) h.t.Fatal(err)
} }
return return
@ -115,10 +115,10 @@ func (h *harness) checkout(branch plumbing.ReferenceName) {
h.t.Fatal(err) h.t.Fatal(err)
} }
_, err = h.repo.GitRepo.Storer.Reference(branch) _, err = h.proj.GitRepo.Storer.Reference(branch)
if errors.Is(err, plumbing.ErrReferenceNotFound) { if errors.Is(err, plumbing.ErrReferenceNotFound) {
err = w.Checkout(&git.CheckoutOptions{ err = w.Checkout(&git.CheckoutOptions{
Hash: head.GitCommit.Hash, Hash: head.Hash,
Branch: branch, Branch: branch,
Create: true, Create: true,
}) })
@ -136,7 +136,7 @@ func (h *harness) checkout(branch plumbing.ReferenceName) {
} }
func (h *harness) reset(to plumbing.Hash, mode git.ResetMode) { 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 { if err != nil {
h.t.Fatal(err) h.t.Fatal(err)
} }
@ -160,65 +160,65 @@ const (
func (h *harness) tryCommit( func (h *harness) tryCommit(
verifyExp verifyExpectation, verifyExp verifyExpectation,
commit Commit, payUn PayloadUnion,
accountSig sigcred.SignifierInterface, accountSig sigcred.Signifier,
) GitCommit { ) Commit {
if accountSig != nil { if accountSig != nil {
var err error var err error
if commit, err = h.repo.AccreditCommit(commit, accountSig); err != nil { if payUn, err = h.proj.AccreditPayload(payUn, accountSig); err != nil {
h.t.Fatalf("accrediting commit: %v", err) h.t.Fatalf("accrediting payload: %v", err)
} }
} }
gitCommit, err := h.repo.Commit(commit) commit, err := h.proj.Commit(payUn)
if err != nil { if err != nil {
h.t.Fatalf("failed to commit ChangeCommit: %v", err) h.t.Fatalf("committing PayloadChange: %v", err)
} else if verifyExp == verifySkip { } 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 { if err != nil {
h.t.Fatalf("determining checked out branch: %v", err) h.t.Fatalf("determining checked out branch: %v", err)
} }
shouldSucceed := verifyExp > 0 shouldSucceed := verifyExp > 0
err = h.repo.VerifyCommits(branch, []GitCommit{gitCommit}) err = h.proj.VerifyCommits(branch, []Commit{commit})
if shouldSucceed && err != nil { 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 { } else if shouldSucceed {
return gitCommit return commit
} else if !shouldSucceed && err == nil { } 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 var parentHash plumbing.Hash
if gitCommit.GitCommit.NumParents() > 0 { if commit.Object.NumParents() > 0 {
parentHash = gitCommit.GitCommit.ParentHashes[0] parentHash = commit.Object.ParentHashes[0]
} }
h.reset(parentHash, git.HardReset) h.reset(parentHash, git.HardReset)
return gitCommit return commit
} }
func (h *harness) assertCommitChange( func (h *harness) assertCommitChange(
verifyExp verifyExpectation, verifyExp verifyExpectation,
msg string, msg string,
sig sigcred.SignifierInterface, sig sigcred.Signifier,
) GitCommit { ) Commit {
commit, err := h.repo.NewCommitChange(msg) payUn, err := h.proj.NewPayloadChange(msg)
if err != nil { 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) { func TestHasStagedChanges(t *testing.T) {
h := newHarness(t) h := newHarness(t)
rootSig := h.stageNewAccount("root", false) rootSig := h.stageNewAccount("root", false)
assertHasStaged := func(expHasStaged bool) { assertHasStaged := func(expHasStaged bool) {
hasStaged, err := h.repo.HasStagedChanges() hasStaged, err := h.proj.HasStagedChanges()
if err != nil { if err != nil {
t.Fatalf("error calling HasStagedChanges: %v", err) t.Fatalf("error calling HasStagedChanges: %v", err)
} else if hasStaged != expHasStaged { } else if hasStaged != expHasStaged {
@ -240,31 +240,30 @@ func TestHasStagedChanges(t *testing.T) {
assertHasStaged(false) assertHasStaged(false)
} }
// TestThisRepoStillVerifies opens this actual repository and ensures that all // TestThisProjectStillVerifies opens this actual project and ensures that all
// commits in it still verify, given this codebase. // commits in it still verify.
func TestThisRepoStillVerifies(t *testing.T) { func TestThisProjectStillVerifies(t *testing.T) {
repo, err := OpenRepo(".") proj, err := OpenProject(".")
if err != nil { if err != nil {
t.Fatalf("error opening repo: %v", err) t.Fatalf("error opening repo: %v", err)
} }
headGitCommit, err := repo.GetGitHead() headCommit, err := proj.GetHeadCommit()
if err != nil { if err != nil {
t.Fatalf("getting repo head: %v", err) 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 { if err != nil {
t.Fatalf("getting all commits (up to %q): %v", t.Fatalf("getting all commits (up to %q): %v", headCommit.Hash, err)
headGitCommit.GitCommit.Hash, err)
} }
checkedOutBranch, err := repo.ReferenceToBranchName(plumbing.HEAD) checkedOutBranch, err := proj.ReferenceToBranchName(plumbing.HEAD)
if err != nil { if err != nil {
t.Fatalf("error determining checked out branch: %v", err) 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) t.Fatal(err)
} }
} }
@ -274,17 +273,17 @@ func TestShortHashResolving(t *testing.T) {
// but that's hard... // but that's hard...
h := newHarness(t) h := newHarness(t)
rootSig := h.stageNewAccount("root", false) 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() hashStr := hash.String()
t.Log(hashStr) t.Log(hashStr)
for i := 2; i < len(hashStr); i++ { 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 { if err != nil {
t.Fatalf("resolving %q: %v", hashStr[:i], err) 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", 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" "dehub.dev/src/dehub.git/typeobj"
) )
// Credential represents a credential which has been attached to a commit which // CredentialUnion represents a credential, signifying a user's approval of a
// hopefully will allow it to be included in the main. Exactly one field tagged // payload. Exactly one field tagged with "type" should be set.
// with "type" should be set. type CredentialUnion struct {
type Credential struct {
PGPSignature *CredentialPGPSignature `type:"pgp_signature"` 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 // NOTE that credentials produced by the direct implementations of Signifier
// SignifierInterface won't fill in this field, unless specifically // won't fill in this field, unless specifically documented. The Signifier
// documented. The SignifierInterface produced by the Interface() method of // produced by the Signifier() method of SignifierUnion _will_ fill this
// Signifier _will_ fill this field in, however. // field in, however.
AccountID string `yaml:"account,omitempty"` AccountID string `yaml:"account,omitempty"`
// AnonID specifies an identifier for the anonymous user which produced this // AnonID specifies an identifier for the anonymous user which produced this
// credential. This field is mutually exclusive with AccountID, and won't be // 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:"-"` AnonID string `yaml:"-"`
} }
// MarshalYAML implements the yaml.Marshaler interface. // MarshalYAML implements the yaml.Marshaler interface.
func (c Credential) MarshalYAML() (interface{}, error) { func (c CredentialUnion) MarshalYAML() (interface{}, error) {
return typeobj.MarshalYAML(c) return typeobj.MarshalYAML(c)
} }
// UnmarshalYAML implements the yaml.Unmarshaler interface. // 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) return typeobj.UnmarshalYAML(c, unmarshal)
} }
// ErrNotSelfVerifying is returned from the SelfVerify method of Credential when // ErrNotSelfVerifying is returned from the SelfVerify method of CredentialUnion
// the Credential does not implement the SelfVerifyingCredential interface. It // when the credential does not implement the SelfVerifyingCredential interface.
// may also be returned from the SelfVerify method of the // It may also be returned from the SelfVerify method of the
// SelfVerifyingCredential itself, if the Credential can only self-verify under // SelfVerifyingCredential itself, if the credential can only self-verify under
// certain circumstances. // certain circumstances.
type ErrNotSelfVerifying struct { type ErrNotSelfVerifying struct {
// Subject is a descriptor of the value which could not be verified. It may // 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) 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. // 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) el, _, err := typeobj.Element(c)
if err != nil { if err != nil {
return err return err
} else if selfVerifyingCred, ok := el.(SelfVerifyingCredential); !ok { } 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 { } 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 return nil
} }

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

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

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

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

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

Loading…
Cancel
Save