From b01fe1524ac520877d73e20f5b3c1fee9cd53e38 Mon Sep 17 00:00:00 2001 From: mediocregopher <> Date: Sun, 26 Apr 2020 14:23:03 -0600 Subject: [PATCH] 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 --- ROADMAP.md | 31 +- accessctl/access_control.go | 25 +- accessctl/access_control_test.go | 47 +- accessctl/filter.go | 57 +- accessctl/filter_logical.go | 11 +- accessctl/filter_logical_test.go | 8 +- accessctl/filter_pattern.go | 4 +- accessctl/filter_sig.go | 2 +- accessctl/filter_sig_test.go | 4 +- accessctl/filter_test.go | 18 +- cmd/dehub/cmd_commit.go | 93 ++- cmd/dehub/cmd_hook.go | 8 +- cmd/dehub/cmd_misc.go | 12 +- cmd/dehub/cmd_util.go | 32 +- cmd/dehub/cmd_verify.go | 17 +- cmd/dehub/main.go | 2 +- commit.go | 676 ++++-------------- commit_change.go | 156 ---- commit_comment.go | 49 -- commit_credential.go | 81 --- config.go | 31 +- hash.go => fingerprint.go | 4 +- hash_test.go => fingerprint_test.go | 8 +- payload.go | 604 ++++++++++++++++ payload_change.go | 171 +++++ ...t_change_test.go => payload_change_test.go | 81 +-- payload_comment.go | 43 ++ payload_credential.go | 73 ++ ...tial_test.go => payload_credential_test.go | 12 +- commit_test.go => payload_test.go | 114 +-- project.go | 326 +++++++++ repo_test.go => project_test.go | 105 ++- repo.go | 544 -------------- sigcred/credential.go | 39 +- sigcred/credential_test.go | 6 +- sigcred/pgp.go | 48 +- sigcred/pgp_test.go | 6 +- sigcred/signifier.go | 91 +-- yamlutil/yamlutil.go | 4 + 39 files changed, 1806 insertions(+), 1837 deletions(-) delete mode 100644 commit_change.go delete mode 100644 commit_comment.go delete mode 100644 commit_credential.go rename hash.go => fingerprint.go (92%) rename hash_test.go => fingerprint_test.go (96%) create mode 100644 payload.go create mode 100644 payload_change.go rename commit_change_test.go => payload_change_test.go (58%) create mode 100644 payload_comment.go create mode 100644 payload_credential.go rename commit_credential_test.go => payload_credential_test.go (70%) rename commit_test.go => payload_test.go (76%) create mode 100644 project.go rename repo_test.go => project_test.go (66%) delete mode 100644 repo.go diff --git a/ROADMAP.md b/ROADMAP.md index 783c135..71b3da5 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -20,18 +20,18 @@ to accept help from people asking to help. ## Milestone: Versions * Tag commits -* Add dehub version to the SPEC, make binary aware of it +* Add dehub version to payloads, make binary aware of it * Figure out a release system? -## Milestone: Checkpoints +## Milestone: Prime commits -* Ability to set change commits as being a "checkpoint", so that they mark a new - root commit. A couple of considerations: - - Only a checkpoint on the main branch should be considered when determining - the project "root". - - Must be a flag on change commits, to allow hard-forks of projects where - the config file is completely replaced. - - Not sure if it should be subject to ACL or not. +(Cloning/remote management is probably a pre-requisite of this, so it's a good +thing it comes after IPFS support) + +* Ability to specify which commit is prime. + * The prime commit is essentially the identifier of the entire project; even + if two project instances share a commit tree, if they are using a + different prime commit then they are not the same project. ## Milestone: Minimal plugin support @@ -39,7 +39,7 @@ to accept help from people asking to help. * Conditions * Signifiers * Filters - * Commits??? + * Payloads??? ## Milestone: Minimal notifications support @@ -63,14 +63,6 @@ are things that could use doing anyway. * Maybe coalesce the `accessctl`, `fs`, and `sigcred` packages back into the root "dehub" package. -* Polish all error messages. A good error message has the following qualities: - * If wrapping an error which was returned from a sub-call: - * Uses `fmt.Errorf` with the `%w` format directive at the end. - * Phrased as if the sentence starts with the word "while", e.g. "opening - file: %w". - * Only includes information the caller of that function/method couldn't - already know. - * Polish commands * New flag system, some kind of interactivity support (e.g. user doesn't specify required argument, give them a prompt on the CLI to input it @@ -86,6 +78,3 @@ are things that could use doing anyway. * Possibly save state locally in order to speed things along, such as "account id" which probably isn't going to change often for a user. - -* More/better tests - * Commits need much better test coverage. diff --git a/accessctl/access_control.go b/accessctl/access_control.go index b94acdc..dedb685 100644 --- a/accessctl/access_control.go +++ b/accessctl/access_control.go @@ -37,8 +37,8 @@ var DefaultAccessControlsStr = ` filters: - type: branch pattern: main - - type: commit_type - commit_type: change + - type: payload_type + payload_type: change - type: signature any_account: true count: 1 @@ -66,8 +66,8 @@ type CommitRequest struct { // It is required. Branch string - // Credentials are the Credential objects attached to the commit. - Credentials []sigcred.Credential + // Credentials are the credentials attached to the commit. + Credentials []sigcred.CredentialUnion // FilesChanged is the set of file paths (relative to the repo root) which // have been modified in some way. @@ -97,27 +97,20 @@ const ( // AccessControl describes a set of Filters, and the Actions which should be // taken on a CommitRequest if those Filters all match on the CommitRequest. type AccessControl struct { - Action Action `yaml:"action"` - Filters []Filter `yaml:"filters"` + Action Action `yaml:"action"` + Filters []FilterUnion `yaml:"filters"` } // ActionForCommit returns what Action this AccessControl says to take for a // given CommitRequest. It may return ActionNext if the request is not matched // by the AccessControl's Filters. func (ac AccessControl) ActionForCommit(req CommitRequest) (Action, error) { - for _, filter := range ac.Filters { - filterI, err := filter.Interface() - if err != nil { - return "", fmt.Errorf("casting %+v to a FilterInterface: %w", filter, err) - - } else if err := filterI.MatchCommit(req); errors.As(err, new(ErrFilterNoMatch)) { + for _, filterUn := range ac.Filters { + if err := filterUn.Filter().MatchCommit(req); errors.As(err, new(ErrFilterNoMatch)) { return ActionNext, nil } else if err != nil { - // ignore the error here, if we could get the FilterInterface then - // we should be able to get the type. - filterTypeStr, _ := filter.Type() - return "", fmt.Errorf("matching commit using filter of type %q: %w", filterTypeStr, err) + return "", fmt.Errorf("matching commit using filter of type %q: %w", filterUn.Type(), err) } } return ac.Action, nil diff --git a/accessctl/access_control_test.go b/accessctl/access_control_test.go index 8b8da09..366fc20 100644 --- a/accessctl/access_control_test.go +++ b/accessctl/access_control_test.go @@ -1,9 +1,10 @@ package accessctl import ( - "dehub.dev/src/dehub.git/sigcred" "errors" "testing" + + "dehub.dev/src/dehub.git/sigcred" ) func TestAssertCanCommit(t *testing.T) { @@ -18,14 +19,14 @@ func TestAssertCanCommit(t *testing.T) { acl: []AccessControl{ { Action: ActionAllow, - Filters: []Filter{{ - CommitType: &FilterCommitType{Type: "foo"}, + Filters: []FilterUnion{{ + PayloadType: &FilterPayloadType{Type: "foo"}, }}, }, { Action: ActionDeny, - Filters: []Filter{{ - CommitType: &FilterCommitType{Type: "foo"}, + Filters: []FilterUnion{{ + PayloadType: &FilterPayloadType{Type: "foo"}, }}, }, }, @@ -37,14 +38,14 @@ func TestAssertCanCommit(t *testing.T) { acl: []AccessControl{ { Action: ActionDeny, - Filters: []Filter{{ - CommitType: &FilterCommitType{Type: "foo"}, + Filters: []FilterUnion{{ + PayloadType: &FilterPayloadType{Type: "foo"}, }}, }, { Action: ActionAllow, - Filters: []Filter{{ - CommitType: &FilterCommitType{Type: "foo"}, + Filters: []FilterUnion{{ + PayloadType: &FilterPayloadType{Type: "foo"}, }}, }, }, @@ -56,14 +57,14 @@ func TestAssertCanCommit(t *testing.T) { acl: []AccessControl{ { Action: ActionDeny, - Filters: []Filter{{ - CommitType: &FilterCommitType{Type: "bar"}, + Filters: []FilterUnion{{ + PayloadType: &FilterPayloadType{Type: "bar"}, }}, }, { Action: ActionAllow, - Filters: []Filter{{ - CommitType: &FilterCommitType{Type: "foo"}, + Filters: []FilterUnion{{ + PayloadType: &FilterPayloadType{Type: "foo"}, }}, }, }, @@ -75,14 +76,14 @@ func TestAssertCanCommit(t *testing.T) { acl: []AccessControl{ { Action: ActionDeny, - Filters: []Filter{{ - CommitType: &FilterCommitType{Type: "bar"}, + Filters: []FilterUnion{{ + PayloadType: &FilterPayloadType{Type: "bar"}, }}, }, { Action: ActionDeny, - Filters: []Filter{{ - CommitType: &FilterCommitType{Type: "foo"}, + Filters: []FilterUnion{{ + PayloadType: &FilterPayloadType{Type: "foo"}, }}, }, }, @@ -94,15 +95,15 @@ func TestAssertCanCommit(t *testing.T) { acl: []AccessControl{ { Action: ActionDeny, - Filters: []Filter{{ - CommitType: &FilterCommitType{Type: "bar"}, + Filters: []FilterUnion{{ + PayloadType: &FilterPayloadType{Type: "bar"}, }}, }, }, req: CommitRequest{ Branch: "not_main", Type: "foo", - Credentials: []sigcred.Credential{{ + Credentials: []sigcred.CredentialUnion{{ PGPSignature: new(sigcred.CredentialPGPSignature), AccountID: "a", }}, @@ -114,15 +115,15 @@ func TestAssertCanCommit(t *testing.T) { acl: []AccessControl{ { Action: ActionDeny, - Filters: []Filter{{ - CommitType: &FilterCommitType{Type: "bar"}, + Filters: []FilterUnion{{ + PayloadType: &FilterPayloadType{Type: "bar"}, }}, }, }, req: CommitRequest{ Branch: "main", Type: "foo", - Credentials: []sigcred.Credential{{ + Credentials: []sigcred.CredentialUnion{{ PGPSignature: new(sigcred.CredentialPGPSignature), AccountID: "a", }}, diff --git a/accessctl/filter.go b/accessctl/filter.go index 3109125..3b31c80 100644 --- a/accessctl/filter.go +++ b/accessctl/filter.go @@ -18,70 +18,73 @@ func (err ErrFilterNoMatch) Error() string { return fmt.Sprintf("matching with filter: %s", err.Err.Error()) } -// FilterInterface describes the methods that all Filters must implement. -type FilterInterface interface { +// Filter describes the methods that all Filters must implement. +type Filter interface { // MatchCommit returns nil if the CommitRequest is matched by the filter, // otherwise it returns an error (ErrFilterNoMatch if the error is due to // the CommitRequest). MatchCommit(CommitRequest) error } -// Filter represents an access control filter being defined in the Config. Only -// one of its fields may be filled at a time. -type Filter struct { +// FilterUnion represents an access control filter being defined in the Config. +// Only one of its fields may be filled at a time. +type FilterUnion struct { Signature *FilterSignature `type:"signature"` Branch *FilterBranch `type:"branch"` FilesChanged *FilterFilesChanged `type:"files_changed"` - CommitType *FilterCommitType `type:"commit_type"` + PayloadType *FilterPayloadType `type:"payload_type"` CommitAttributes *FilterCommitAttributes `type:"commit_attributes"` Not *FilterNot `type:"not"` } // MarshalYAML implements the yaml.Marshaler interface. -func (f Filter) MarshalYAML() (interface{}, error) { +func (f FilterUnion) MarshalYAML() (interface{}, error) { return typeobj.MarshalYAML(f) } // UnmarshalYAML implements the yaml.Unmarshaler interface. -func (f *Filter) UnmarshalYAML(unmarshal func(interface{}) error) error { +func (f *FilterUnion) UnmarshalYAML(unmarshal func(interface{}) error) error { return typeobj.UnmarshalYAML(f, unmarshal) } -// Interface returns the FilterInterface encapsulated by this Filter. -func (f Filter) Interface() (FilterInterface, error) { +// Filter returns the Filter encapsulated by this FilterUnion. +// +// This method will panic if a Filter field is not populated. +func (f FilterUnion) Filter() Filter { el, _, err := typeobj.Element(f) if err != nil { - return nil, err + panic(err) } - return el.(FilterInterface), nil + return el.(Filter) } -// Type returns a string describing what type of Filter this object -// encapsulates, based on which of its fields are filled in. -func (f Filter) Type() (string, error) { +// Type returns the Filter's type (as would be used in its YAML "type" field). +// +// This will panic if a Filter field is not populated. +func (f FilterUnion) Type() string { _, typeStr, err := typeobj.Element(f) if err != nil { - return "", err + panic(err) } - return typeStr, nil + return typeStr } -// FilterCommitType filters by what type of commit is being requested. Exactly +// FilterPayloadType filters by what type of payload is being requested. Exactly // one of its fields should be filled. -type FilterCommitType struct { - Type string `yaml:"commit_type"` - Types []string `yaml:"commit_types"` +type FilterPayloadType struct { + Type string `yaml:"payload_type"` + Types []string `yaml:"payload_types"` } -var _ FilterInterface = FilterCommitType{} +var _ Filter = FilterPayloadType{} // MatchCommit implements the method for FilterInterface. -func (f FilterCommitType) MatchCommit(req CommitRequest) error { +func (f FilterPayloadType) MatchCommit(req CommitRequest) error { switch { case f.Type != "": if f.Type != req.Type { return ErrFilterNoMatch{ - Err: fmt.Errorf("commit type %q does not match filter's type %q", + Err: fmt.Errorf("payload type %q does not match filter's type %q", req.Type, f.Type), } } @@ -94,12 +97,12 @@ func (f FilterCommitType) MatchCommit(req CommitRequest) error { } } return ErrFilterNoMatch{ - Err: fmt.Errorf("commit type %q does not match any of filter's types %+v", + Err: fmt.Errorf("payload type %q does not match any of filter's types %+v", req.Type, f.Types), } default: - return errors.New(`one of the following fields must be set: "commit_type", "commit_types"`) + return errors.New(`one of the following fields must be set: "payload_type", "payload_types"`) } } @@ -110,7 +113,7 @@ type FilterCommitAttributes struct { NonFastForward bool `yaml:"non_fast_forward"` } -var _ FilterInterface = FilterCommitAttributes{} +var _ Filter = FilterCommitAttributes{} // MatchCommit implements the method for FilterInterface. func (f FilterCommitAttributes) MatchCommit(req CommitRequest) error { diff --git a/accessctl/filter_logical.go b/accessctl/filter_logical.go index 506d3e1..450d5db 100644 --- a/accessctl/filter_logical.go +++ b/accessctl/filter_logical.go @@ -2,24 +2,19 @@ package accessctl import ( "errors" - "fmt" ) // FilterNot wraps another Filter. If that filter matches, FilterNot does not // match, and vice-versa. type FilterNot struct { - Filter Filter `yaml:"filter"` + Filter FilterUnion `yaml:"filter"` } -var _ FilterInterface = FilterNot{} +var _ Filter = FilterNot{} // MatchCommit implements the method for FilterInterface. func (f FilterNot) MatchCommit(req CommitRequest) error { - fI, err := f.Filter.Interface() - if err != nil { - return fmt.Errorf("casting %+v to a FilterInterface: %w", f.Filter, err) - - } else if err := fI.MatchCommit(req); errors.As(err, new(ErrFilterNoMatch)) { + if err := f.Filter.Filter().MatchCommit(req); errors.As(err, new(ErrFilterNoMatch)) { return nil } else if err != nil { return err diff --git a/accessctl/filter_logical_test.go b/accessctl/filter_logical_test.go index 2a2b883..edc656d 100644 --- a/accessctl/filter_logical_test.go +++ b/accessctl/filter_logical_test.go @@ -7,8 +7,8 @@ func TestFilterNot(t *testing.T) { { descr: "sub-filter does match", filter: FilterNot{ - Filter: Filter{ - CommitType: &FilterCommitType{Type: "foo"}, + Filter: FilterUnion{ + PayloadType: &FilterPayloadType{Type: "foo"}, }, }, req: CommitRequest{ @@ -19,8 +19,8 @@ func TestFilterNot(t *testing.T) { { descr: "sub-filter does not match", filter: FilterNot{ - Filter: Filter{ - CommitType: &FilterCommitType{Type: "foo"}, + Filter: FilterUnion{ + PayloadType: &FilterPayloadType{Type: "foo"}, }, }, req: CommitRequest{ diff --git a/accessctl/filter_pattern.go b/accessctl/filter_pattern.go index 6dc1d89..26ca1cd 100644 --- a/accessctl/filter_pattern.go +++ b/accessctl/filter_pattern.go @@ -65,7 +65,7 @@ type FilterBranch struct { StringMatcher StringMatcher `yaml:",inline"` } -var _ FilterInterface = FilterBranch{} +var _ Filter = FilterBranch{} // MatchCommit implements the method for FilterInterface. func (f FilterBranch) MatchCommit(req CommitRequest) error { @@ -79,7 +79,7 @@ type FilterFilesChanged struct { StringMatcher StringMatcher `yaml:",inline"` } -var _ FilterInterface = FilterFilesChanged{} +var _ Filter = FilterFilesChanged{} // MatchCommit implements the method for FilterInterface. func (f FilterFilesChanged) MatchCommit(req CommitRequest) error { diff --git a/accessctl/filter_sig.go b/accessctl/filter_sig.go index cd5f8d4..45b52ad 100644 --- a/accessctl/filter_sig.go +++ b/accessctl/filter_sig.go @@ -20,7 +20,7 @@ type FilterSignature struct { Count string `yaml:"count,omitempty"` } -var _ FilterInterface = FilterSignature{} +var _ Filter = FilterSignature{} func (f FilterSignature) targetNum() (int, error) { if f.Count == "" { diff --git a/accessctl/filter_sig_test.go b/accessctl/filter_sig_test.go index 00ae25b..c4ce59f 100644 --- a/accessctl/filter_sig_test.go +++ b/accessctl/filter_sig_test.go @@ -8,7 +8,7 @@ import ( func TestFilterSignature(t *testing.T) { mkReq := func(accountIDs ...string) CommitRequest { - creds := make([]sigcred.Credential, len(accountIDs)) + creds := make([]sigcred.CredentialUnion, len(accountIDs)) for i := range accountIDs { creds[i].PGPSignature = new(sigcred.CredentialPGPSignature) creds[i].AccountID = accountIDs[i] @@ -106,7 +106,7 @@ func TestFilterSignature(t *testing.T) { Any: true, }, req: CommitRequest{ - Credentials: []sigcred.Credential{ + Credentials: []sigcred.CredentialUnion{ {PGPSignature: new(sigcred.CredentialPGPSignature)}, }, }, diff --git a/accessctl/filter_test.go b/accessctl/filter_test.go index 3cad9ff..f6edcfa 100644 --- a/accessctl/filter_test.go +++ b/accessctl/filter_test.go @@ -8,7 +8,7 @@ import ( type filterCommitMatchTest struct { descr string - filter FilterInterface + filter Filter req CommitRequest match bool @@ -38,7 +38,7 @@ func runCommitMatchTests(t *testing.T, tests []filterCommitMatchTest) { } } -func TestFilterCommitType(t *testing.T) { +func TestFilterPayloadType(t *testing.T) { mkReq := func(commitType string) CommitRequest { return CommitRequest{Type: commitType} } @@ -46,7 +46,7 @@ func TestFilterCommitType(t *testing.T) { runCommitMatchTests(t, []filterCommitMatchTest{ { descr: "single match", - filter: FilterCommitType{ + filter: FilterPayloadType{ Type: "foo", }, req: mkReq("foo"), @@ -54,7 +54,7 @@ func TestFilterCommitType(t *testing.T) { }, { descr: "single no match", - filter: FilterCommitType{ + filter: FilterPayloadType{ Type: "foo", }, req: mkReq("bar"), @@ -62,7 +62,7 @@ func TestFilterCommitType(t *testing.T) { }, { descr: "multi match first", - filter: FilterCommitType{ + filter: FilterPayloadType{ Types: []string{"foo", "bar"}, }, req: mkReq("foo"), @@ -70,7 +70,7 @@ func TestFilterCommitType(t *testing.T) { }, { descr: "multi match second", - filter: FilterCommitType{ + filter: FilterPayloadType{ Types: []string{"foo", "bar"}, }, req: mkReq("bar"), @@ -78,7 +78,7 @@ func TestFilterCommitType(t *testing.T) { }, { descr: "multi no match", - filter: FilterCommitType{ + filter: FilterPayloadType{ Types: []string{"foo", "bar"}, }, req: mkReq("baz"), @@ -119,7 +119,7 @@ func TestFilterCommitAttributes(t *testing.T) { }, { descr: "ff with inverted non-ff filter", - filter: FilterNot{Filter: Filter{ + filter: FilterNot{Filter: FilterUnion{ CommitAttributes: &FilterCommitAttributes{NonFastForward: true}, }}, req: mkReq(false), @@ -127,7 +127,7 @@ func TestFilterCommitAttributes(t *testing.T) { }, { descr: "non-ff with inverted non-ff filter", - filter: FilterNot{Filter: Filter{ + filter: FilterNot{Filter: FilterUnion{ CommitAttributes: &FilterCommitAttributes{NonFastForward: true}, }}, req: mkReq(true), diff --git a/cmd/dehub/cmd_commit.go b/cmd/dehub/cmd_commit.go index fcf0b1a..862652a 100644 --- a/cmd/dehub/cmd_commit.go +++ b/cmd/dehub/cmd_commit.go @@ -17,14 +17,14 @@ func cmdCommit(ctx context.Context, cmd *dcmd.Cmd) { accountID := flag.String("as", "", "Account to accredit commit with") pgpKeyID := flag.String("anon-pgp-key", "", "ID of pgp key to sign with instead of using an account") - var repo repo - repo.initFlags(flag) + var proj proj + proj.initFlags(flag) - accreditAndCommit := func(commit dehub.Commit) error { + accreditAndCommit := func(payUn dehub.PayloadUnion) error { - var sigInt sigcred.SignifierInterface + var sig sigcred.Signifier if *accountID != "" { - cfg, err := repo.LoadConfig() + cfg, err := proj.LoadConfig() if err != nil { return err } @@ -43,30 +43,25 @@ func cmdCommit(ctx context.Context, cmd *dcmd.Cmd) { return fmt.Errorf("account %q has %d signifiers, only one is supported right now", *accountID, l) } - sig := account.Signifiers[0] - sigInt, err = sig.Interface(*accountID) - if err != nil { - return fmt.Errorf("casting %#v to SignifierInterface: %w", sig, err) - - } + sig = account.Signifiers[0].Signifier(*accountID) } else { var err error - if sigInt, err = sigcred.LoadSignifierPGP(*pgpKeyID, true); err != nil { + if sig, err = sigcred.LoadSignifierPGP(*pgpKeyID, true); err != nil { return fmt.Errorf("loading pgp key %q: %w", *pgpKeyID, err) } } - commit, err := repo.AccreditCommit(commit, sigInt) + payUn, err := proj.AccreditPayload(payUn, sig) if err != nil { - return fmt.Errorf("accrediting commit: %w", err) + return fmt.Errorf("accrediting payload: %w", err) } - gitCommit, err := repo.Commit(commit) + commit, err := proj.Commit(payUn) if err != nil { return fmt.Errorf("committing to git: %w", err) } - fmt.Printf("committed to HEAD as %s\n", gitCommit.GitCommit.Hash) + fmt.Printf("committed to HEAD as %s\n", commit.Hash) return nil } @@ -76,12 +71,12 @@ func cmdCommit(ctx context.Context, cmd *dcmd.Cmd) { return nil, errors.New("-as or -anon-pgp-key is required") } - if err := repo.openRepo(); err != nil { + if err := proj.openProj(); err != nil { return nil, err } var err error - if hasStaged, err = repo.HasStagedChanges(); err != nil { + if hasStaged, err = proj.HasStagedChanges(); err != nil { return nil, fmt.Errorf("determining if any changes have been staged: %w", err) } return ctx, nil @@ -90,7 +85,7 @@ func cmdCommit(ctx context.Context, cmd *dcmd.Cmd) { cmd.SubCmd("change", "Commit file changes", func(ctx context.Context, cmd *dcmd.Cmd) { flag := cmd.FlagSet() - msg := flag.String("msg", "", "Commit message") + description := flag.String("descr", "", "Description of changes") amend := flag.Bool("amend", false, "Add changes to HEAD commit, amend its message, and re-accredit it") cmd.Run(func() (context.Context, error) { if !hasStaged && !*amend { @@ -99,28 +94,28 @@ func cmdCommit(ctx context.Context, cmd *dcmd.Cmd) { var prevMsg string if *amend { - oldHead, err := repo.softReset("change") + oldHead, err := proj.softReset("change") if err != nil { return nil, err } - prevMsg = oldHead.Commit.Change.Message + prevMsg = oldHead.Payload.Change.Description } - if *msg == "" { + if *description == "" { var err error - if *msg, err = tmpFileMsg(defaultCommitFileMsgTpl, prevMsg); err != nil { + if *description, err = tmpFileMsg(defaultCommitFileMsgTpl, prevMsg); err != nil { return nil, fmt.Errorf("error collecting commit message from user: %w", err) - } else if *msg == "" { + } else if *description == "" { return nil, errors.New("empty commit message, not doing anything") } } - commit, err := repo.NewCommitChange(*msg) + payUn, err := proj.NewPayloadChange(*description) if err != nil { - return nil, fmt.Errorf("could not construct change commit: %w", err) + return nil, fmt.Errorf("could not construct change payload: %w", err) - } else if err := accreditAndCommit(commit); err != nil { + } else if err := accreditAndCommit(payUn); err != nil { return nil, err } return nil, nil @@ -141,31 +136,30 @@ func cmdCommit(ctx context.Context, cmd *dcmd.Cmd) { return nil, errors.New("credential commit cannot have staged changes") } - var credCommit dehub.Commit + var credPayUn dehub.PayloadUnion if *rev != "" { - gitCommit, err := repo.GetGitRevision(plumbing.Revision(*rev)) + commit, err := proj.GetCommitByRevision(plumbing.Revision(*rev)) if err != nil { return nil, fmt.Errorf("resolving revision %q: %w", *rev, err) } - gitCommits := []dehub.GitCommit{gitCommit} - if credCommit, err = repo.NewCommitCredentialFromChanges(gitCommits); err != nil { + if credPayUn, err = proj.NewPayloadCredentialFromChanges([]dehub.Commit{commit}); err != nil { return nil, fmt.Errorf("constructing credential commit: %w", err) } } else { - gitCommits, err := repo.GetGitRevisionRange( + commits, err := proj.GetCommitRangeByRevision( plumbing.Revision(*startRev), plumbing.Revision(*endRev), ) if err != nil { return nil, fmt.Errorf("resolving revisions %q to %q: %w", *startRev, *endRev, err) - } else if credCommit, err = repo.NewCommitCredentialFromChanges(gitCommits); err != nil { + } else if credPayUn, err = proj.NewPayloadCredentialFromChanges(commits); err != nil { return nil, fmt.Errorf("constructing credential commit: %w", err) } } - if err := accreditAndCommit(credCommit); err != nil { + if err := accreditAndCommit(credPayUn); err != nil { return nil, err } return nil, nil @@ -176,37 +170,37 @@ func cmdCommit(ctx context.Context, cmd *dcmd.Cmd) { cmd.SubCmd("comment", "Commit a comment to a branch", func(ctx context.Context, cmd *dcmd.Cmd) { flag := cmd.FlagSet() - msg := flag.String("msg", "", "Comment message") + comment := flag.String("comment", "", "Comment message") amend := flag.Bool("amend", false, "Amend the comment message currently in HEAD") cmd.Run(func() (context.Context, error) { if hasStaged { return nil, errors.New("comment commit cannot have staged changes") } - var prevMsg string + var prevComment string if *amend { - oldHead, err := repo.softReset("comment") + oldHead, err := proj.softReset("comment") if err != nil { return nil, err } - prevMsg = oldHead.Commit.Comment.Message + prevComment = oldHead.Payload.Comment.Comment } - if *msg == "" { + if *comment == "" { var err error - if *msg, err = tmpFileMsg(defaultCommitFileMsgTpl, prevMsg); err != nil { + if *comment, err = tmpFileMsg(defaultCommitFileMsgTpl, prevComment); err != nil { return nil, fmt.Errorf("collecting comment message from user: %w", err) - } else if *msg == "" { + } else if *comment == "" { return nil, errors.New("empty comment message, not doing anything") } } - commit, err := repo.NewCommitComment(*msg) + payUn, err := proj.NewPayloadComment(*comment) if err != nil { return nil, fmt.Errorf("constructing comment commit: %w", err) } - return nil, accreditAndCommit(commit) + return nil, accreditAndCommit(payUn) }) }, ) @@ -220,8 +214,8 @@ func cmdCombine(ctx context.Context, cmd *dcmd.Cmd) { startRev := flag.String("start", "", "Revision of the starting commit to combine") endRev := flag.String("end", "", "Revision of the ending commit to combine") - var repo repo - repo.initFlags(flag) + var proj proj + proj.initFlags(flag) cmd.Run(func() (context.Context, error) { if *onto == "" || @@ -230,11 +224,11 @@ func cmdCombine(ctx context.Context, cmd *dcmd.Cmd) { return nil, errors.New("-onto, -start, and -end are required") } - if err := repo.openRepo(); err != nil { + if err := proj.openProj(); err != nil { return nil, err } - commits, err := repo.GetGitRevisionRange( + commits, err := proj.GetCommitRangeByRevision( plumbing.Revision(*startRev), plumbing.Revision(*endRev), ) @@ -244,13 +238,12 @@ func cmdCombine(ctx context.Context, cmd *dcmd.Cmd) { } ontoBranch := plumbing.NewBranchReferenceName(*onto) - gitCommit, err := repo.CombineCommitChanges(commits, ontoBranch) + commit, err := proj.CombinePayloadChanges(commits, ontoBranch) if err != nil { return nil, err } - fmt.Printf("new commit %q added to branch %q\n", - gitCommit.GitCommit.Hash, ontoBranch.Short()) + fmt.Printf("new commit %q added to branch %q\n", commit.Hash, ontoBranch.Short()) return nil, nil }) } diff --git a/cmd/dehub/cmd_hook.go b/cmd/dehub/cmd_hook.go index 1751ad6..a4032cc 100644 --- a/cmd/dehub/cmd_hook.go +++ b/cmd/dehub/cmd_hook.go @@ -18,15 +18,15 @@ func cmdHook(ctx context.Context, cmd *dcmd.Cmd) { flag := cmd.FlagSet() preRcv := flag.Bool("pre-receive", false, "Use dehub as a server-side pre-receive hook") - var repo repo - repo.initFlags(flag) + var proj proj + proj.initFlags(flag) cmd.Run(func() (context.Context, error) { if !*preRcv { return nil, errors.New("must set the hook type") } - if err := repo.openRepo(); err != nil { + if err := proj.openProj(); err != nil { return nil, err } @@ -54,7 +54,7 @@ func cmdHook(ctx context.Context, cmd *dcmd.Cmd) { return nil, errors.New("deleting remote branches is not currently supported") } - return nil, repo.VerifyCanSetBranchHEADTo(branchName, endHash) + return nil, proj.VerifyCanSetBranchHEADTo(branchName, endHash) } fmt.Println("All pushed commits have been verified, well done.") diff --git a/cmd/dehub/cmd_misc.go b/cmd/dehub/cmd_misc.go index 29e9a6f..18ddb3b 100644 --- a/cmd/dehub/cmd_misc.go +++ b/cmd/dehub/cmd_misc.go @@ -10,14 +10,14 @@ import ( func cmdInit(ctx context.Context, cmd *dcmd.Cmd) { flag := cmd.FlagSet() - path := flag.String("path", ".", "Path to initialize the repo at") - bare := flag.Bool("bare", false, "Initialize the repo as a bare repository") - remote := flag.Bool("remote", false, "Configure the directory to allow it to be used as a remote endpoint") + path := flag.String("path", ".", "Path to initialize the project at") + bare := flag.Bool("bare", false, "Initialize the git repo as a bare repository") + remote := flag.Bool("remote", false, "Configure the git repo to allow it to be used as a remote endpoint") cmd.Run(func() (context.Context, error) { - _, err := dehub.InitRepo(*path, - dehub.InitBare(*bare), - dehub.InitRemote(*remote), + _, err := dehub.InitProject(*path, + dehub.InitBareRepo(*bare), + dehub.InitRemoteRepo(*remote), ) if err != nil { return nil, fmt.Errorf("initializing repo at %q: %w", *path, err) diff --git a/cmd/dehub/cmd_util.go b/cmd/dehub/cmd_util.go index 7662f86..ae3d291 100644 --- a/cmd/dehub/cmd_util.go +++ b/cmd/dehub/cmd_util.go @@ -10,19 +10,19 @@ import ( "gopkg.in/src-d/go-git.v4/plumbing" ) -type repo struct { +type proj struct { bare bool - *dehub.Repo + *dehub.Project } -func (r *repo) initFlags(flag *flag.FlagSet) { - flag.BoolVar(&r.bare, "bare", false, "If set then the repo being opened will be expected to be bare") +func (proj *proj) initFlags(flag *flag.FlagSet) { + flag.BoolVar(&proj.bare, "bare", false, "If set then the project being opened will be expected to have a bare git repo") } -func (r *repo) openRepo() error { +func (proj *proj) openProj() error { var err error - if r.Repo, err = dehub.OpenRepo(".", dehub.OpenBare(r.bare)); err != nil { + if proj.Project, err = dehub.OpenProject(".", dehub.OpenBareRepo(proj.bare)); err != nil { wd, _ := os.Getwd() return fmt.Errorf("opening repo at %q: %w", wd, err) } @@ -31,19 +31,17 @@ func (r *repo) openRepo() error { // softReset resets to HEAD^ (or to an orphaned index, if HEAD has no parents), // returning the old HEAD. -func (r *repo) softReset(expType string) (dehub.GitCommit, error) { - head, err := r.GetGitHead() +func (proj *proj) softReset(expType string) (dehub.Commit, error) { + head, err := proj.GetHeadCommit() if err != nil { return head, fmt.Errorf("getting HEAD commit: %w", err) - } else if typ, err := head.Commit.Type(); err != nil { - return head, fmt.Errorf("determining commit type of HEAD:% w", err) - } else if expType != "" && typ != expType { - return head, fmt.Errorf("expected HEAD to be a %q commit, but found %q", + } else if typ := head.Payload.Type(); expType != "" && typ != expType { + return head, fmt.Errorf("expected HEAD to be have a %q payload, but found a %q payload", expType, typ) } - branchName, branchErr := r.ReferenceToBranchName(plumbing.HEAD) - numParents := head.GitCommit.NumParents() + branchName, branchErr := proj.ReferenceToBranchName(plumbing.HEAD) + numParents := head.Object.NumParents() if numParents > 1 { return head, errors.New("cannot reset to parent of a commit with multiple parents") @@ -55,7 +53,7 @@ func (r *repo) softReset(expType string) (dehub.GitCommit, error) { // it and all of HEAD's changes will be in the index. if branchErr != nil { return head, branchErr - } else if err := r.GitRepo.Storer.RemoveReference(branchName); err != nil { + } else if err := proj.GitRepo.Storer.RemoveReference(branchName); err != nil { return head, fmt.Errorf("removing reference %q: %w", branchName, err) } return head, nil @@ -68,9 +66,9 @@ func (r *repo) softReset(expType string) (dehub.GitCommit, error) { return head, fmt.Errorf("resolving HEAD: %w", err) } - parentHash := head.GitCommit.ParentHashes[0] + parentHash := head.Object.ParentHashes[0] newHeadRef := plumbing.NewHashReference(refName, parentHash) - if err := r.GitRepo.Storer.SetReference(newHeadRef); err != nil { + if err := proj.GitRepo.Storer.SetReference(newHeadRef); err != nil { return head, fmt.Errorf("storing reference %q: %w", newHeadRef, err) } return head, nil diff --git a/cmd/dehub/cmd_verify.go b/cmd/dehub/cmd_verify.go index 54e25f1..d7bdf47 100644 --- a/cmd/dehub/cmd_verify.go +++ b/cmd/dehub/cmd_verify.go @@ -15,35 +15,34 @@ func cmdVerify(ctx context.Context, cmd *dcmd.Cmd) { rev := flag.String("rev", "HEAD", "Revision of commit to verify") branch := flag.String("branch", "", "Branch that the revision is on. If not given then the currently checked out branch is assumed") - var repo repo - repo.initFlags(flag) + var proj proj + proj.initFlags(flag) cmd.Run(func() (context.Context, error) { - if err := repo.openRepo(); err != nil { + if err := proj.openProj(); err != nil { return nil, err } - gitCommit, err := repo.GetGitRevision(plumbing.Revision(*rev)) + commit, err := proj.GetCommitByRevision(plumbing.Revision(*rev)) if err != nil { return nil, fmt.Errorf("resolving revision %q: %w", *rev, err) } - gitCommitHash := gitCommit.GitCommit.Hash var branchName plumbing.ReferenceName if *branch == "" { - if branchName, err = repo.ReferenceToBranchName(plumbing.HEAD); err != nil { + if branchName, err = proj.ReferenceToBranchName(plumbing.HEAD); err != nil { return nil, fmt.Errorf("determining branch at HEAD: %w", err) } } else { branchName = plumbing.NewBranchReferenceName(*branch) } - if err := repo.VerifyCommits(branchName, []dehub.GitCommit{gitCommit}); err != nil { + if err := proj.VerifyCommits(branchName, []dehub.Commit{commit}); err != nil { return nil, fmt.Errorf("could not verify commit at %q (%s): %w", - *rev, gitCommitHash, err) + *rev, commit.Hash, err) } - fmt.Printf("commit at %q (%s) is good to go!\n", *rev, gitCommitHash) + fmt.Printf("commit at %q (%s) is good to go!\n", *rev, commit.Hash) return nil, nil }) } diff --git a/cmd/dehub/main.go b/cmd/dehub/main.go index 569bf73..53ccb76 100644 --- a/cmd/dehub/main.go +++ b/cmd/dehub/main.go @@ -8,7 +8,7 @@ import ( func main() { cmd := dcmd.New() - cmd.SubCmd("init", "Initialize a new repository in a directory", cmdInit) + cmd.SubCmd("init", "Initialize a new project in a directory", cmdInit) cmd.SubCmd("commit", "Commits staged changes to the head of the current branch", cmdCommit) cmd.SubCmd("verify", "Verifies one or more commits as having the proper credentials", cmdVerify) cmd.SubCmd("hook", "Use dehub as a git hook", cmdHook) diff --git a/commit.go b/commit.go index 00594fc..fbf418c 100644 --- a/commit.go +++ b/commit.go @@ -1,610 +1,222 @@ package dehub import ( - "bytes" - "encoding/base64" + "encoding/hex" "errors" "fmt" - "reflect" - "sort" + "path/filepath" "strings" - "time" - "dehub.dev/src/dehub.git/accessctl" - "dehub.dev/src/dehub.git/fs" - "dehub.dev/src/dehub.git/sigcred" - "dehub.dev/src/dehub.git/typeobj" - - "gopkg.in/src-d/go-git.v4" "gopkg.in/src-d/go-git.v4/plumbing" "gopkg.in/src-d/go-git.v4/plumbing/object" - yaml "gopkg.in/yaml.v2" ) -// CommitInterface describes the methods which must be implemented by the -// different commit types. None of the methods should modify the underlying -// object. -type CommitInterface interface { - // MessageHead returns the head of the commit message (i.e. the first line). - // The CommitCommon of the outer Commit is passed in for added context, if - // necessary. - MessageHead(CommitCommon) (string, error) - - // ExpectedHash returns the raw hash which Signifiers can sign to accredit - // this commit. The ChangedFile objects given describe the file changes - // between the parent commit and this commit. - ExpectedHash([]ChangedFile) ([]byte, error) - - // StoredHash returns the signable Hash embedded in the commit, which should - // hopefully correspond to the ExpectedHash. - StoredHash() []byte -} - -// CommitCommon describes the fields common to all Commit objects. -type CommitCommon struct { - // Credentials represent all created Credentials for this commit, and can be - // set on all Commit objects regardless of other fields being set. - Credentials []sigcred.Credential `yaml:"credentials"` -} - -func (cc CommitCommon) credIDs() []string { - m := map[string]struct{}{} - for _, cred := range cc.Credentials { - if cred.AccountID != "" { - m[cred.AccountID] = struct{}{} - } else if cred.AnonID != "" { - m[cred.AnonID] = struct{}{} - } - } - s := make([]string, 0, len(m)) - for id := range m { - s = append(s, id) - } - sort.Strings(s) - return s -} - -func abbrevCommitMessage(msg string) string { - i := strings.Index(msg, "\n") - if i > 0 { - msg = msg[:i] - } - if len(msg) > 80 { - msg = msg[:80] + "..." - } - return msg -} - -// Commit represents a single Commit which is being added to a branch. Only one -// field should be set on a Commit, unless otherwise noted. +// Commit wraps a single git commit object, and also contains various fields +// which are parsed out of it, including the payload. It is used as a +// convenience type, in place of having to manually retrieve and parse specific +// information out of commit objects. type Commit struct { - Change *CommitChange `type:"change,default"` - Credential *CommitCredential `type:"credential"` - Comment *CommitComment `type:"comment"` - - Common CommitCommon `yaml:",inline"` -} - -// MarshalYAML implements the yaml.Marshaler interface. -func (c Commit) MarshalYAML() (interface{}, error) { - return typeobj.MarshalYAML(c) -} - -// UnmarshalYAML implements the yaml.Unmarshaler interface. -func (c *Commit) UnmarshalYAML(unmarshal func(interface{}) error) error { - return typeobj.UnmarshalYAML(c, unmarshal) -} - -// Interface returns the CommitInterface instance encapsulated by this Commit -// object. -func (c Commit) Interface() (CommitInterface, error) { - el, _, err := typeobj.Element(c) - if err != nil { - return nil, err - } - return el.(CommitInterface), nil -} - -// Type returns the Commit's type (as would be used in its YAML "type" field). -func (c Commit) Type() (string, error) { - _, typeStr, err := typeobj.Element(c) - if err != nil { - return "", err - } - return typeStr, nil -} - -// MarshalText implements the encoding.TextMarshaler interface by returning the -// form the Commit object takes in the git commit message. -func (c Commit) MarshalText() ([]byte, error) { - commitInt, err := c.Interface() - if err != nil { - return nil, fmt.Errorf("could not cast Commit %+v to interface : %w", c, err) - } - - msgHead, err := commitInt.MessageHead(c.Common) - if err != nil { - return nil, fmt.Errorf("error constructing message head: %w", err) - } - - msgBodyB, err := yaml.Marshal(c) - if err != nil { - return nil, fmt.Errorf("error marshaling commit %+v as yaml: %w", c, err) - } + Payload PayloadUnion - w := new(bytes.Buffer) - w.WriteString(msgHead) - w.WriteString("\n\n---\n") - w.Write(msgBodyB) - return w.Bytes(), nil + Hash plumbing.Hash + Object *object.Commit + TreeObject *object.Tree } -// UnmarshalText implements the encoding.TextUnmarshaler interface by decoding a -// Commit object which has been encoded into a git commit message. -func (c *Commit) UnmarshalText(msg []byte) error { - i := bytes.Index(msg, []byte("\n")) - if i < 0 { - return fmt.Errorf("commit message %q is malformed, it has no body", msg) +// GetCommit retrieves the Commit at the given hash, and all of its sub-data +// which can be pulled out of it. +func (proj *Project) GetCommit(h plumbing.Hash) (c Commit, err error) { + if c.Object, err = proj.GitRepo.CommitObject(h); err != nil { + return c, fmt.Errorf("getting git commit object: %w", err) + } else if c.TreeObject, err = proj.GitRepo.TreeObject(c.Object.TreeHash); err != nil { + return c, fmt.Errorf("getting git tree object %q: %w", + c.Object.TreeHash, err) + } else if c.Payload.UnmarshalText([]byte(c.Object.Message)); err != nil { + return c, fmt.Errorf("decoding commit message: %w", err) } - msgBody := msg[i:] - - if err := yaml.Unmarshal(msgBody, c); err != nil { - return fmt.Errorf("could not unmarshal Commit message from yaml: %w", err) - - } else if reflect.DeepEqual(*c, Commit{}) { - // a basic check, but worthwhile - return errors.New("commit message is malformed, could not unmarshal yaml object") - } - - return nil + c.Hash = c.Object.Hash + return } -// AccreditCommit returns the given Commit with an appended Credential provided -// by the given SignifierInterface. -func (r *Repo) AccreditCommit(commit Commit, sigInt sigcred.SignifierInterface) (Commit, error) { - commitInt, err := commit.Interface() - if err != nil { - return commit, fmt.Errorf("could not cast commit %+v to interface: %w", commit, err) - } +// ErrHeadIsZero is used to indicate that HEAD resolves to the zero hash. An +// example of when this can happen is if the project was just initialized and +// has no commits, or if an orphan branch is checked out. +var ErrHeadIsZero = errors.New("HEAD resolves to the zero hash") - headFS, err := r.headFS() +// GetHeadCommit returns the Commit which is currently referenced by HEAD. +// This method may return ErrHeadIsZero if HEAD resolves to the zero hash. +func (proj *Project) GetHeadCommit() (Commit, error) { + headHash, err := proj.ReferenceToHash(plumbing.HEAD) if err != nil { - return commit, fmt.Errorf("could not grab snapshot of HEAD fs: %w", err) + 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 { - 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 commit, nil + return c, nil } -// CommitBareParams are the parameters to the CommitBare method. All are -// required, unless otherwise noted. -type CommitBareParams struct { - Commit Commit - Author string - ParentHash plumbing.Hash // can be zero if the commit has no parents (Q_Q) - GitTree *object.Tree -} - -// CommitBare constructs a git commit object and and stores it, returning the -// resulting GitCommit. This method does not interact with HEAD at all. -func (r *Repo) CommitBare(params CommitBareParams) (GitCommit, error) { - msgB, err := params.Commit.MarshalText() - if err != nil { - return GitCommit{}, fmt.Errorf("encoding %T to message string: %w", - params.Commit, err) - } - - author := object.Signature{ - Name: params.Author, - When: time.Now(), - } - commit := &object.Commit{ - Author: author, - Committer: author, - Message: string(msgB), - TreeHash: params.GitTree.Hash, - } - if params.ParentHash != plumbing.ZeroHash { - commit.ParentHashes = []plumbing.Hash{params.ParentHash} - } - - 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) +// GetCommitRange returns an ancestry of Commits, with the first being the +// commit immediately following the given starting hash, and the last being the +// given ending hash. +// +// If start is plumbing.ZeroHash then the root commit will be the starting hash. +func (proj *Project) GetCommitRange(start, end plumbing.Hash) ([]Commit, error) { + curr, err := proj.GetCommit(end) if err != nil { - return GitCommit{}, fmt.Errorf("setting encoded object: %w", err) + return nil, fmt.Errorf("retrieving commit %q: %w", end, err) } - return r.GetGitCommit(commitHash) -} - -// Commit uses the given Commit to create a git commit object and commits it to -// the current HEAD, returning the full GitCommit. -func (r *Repo) Commit(commit Commit) (GitCommit, error) { - headRef, err := r.TraverseReferenceChain(plumbing.HEAD, func(ref *plumbing.Reference) bool { - return ref.Type() == plumbing.HashReference - }) - if err != nil { - return GitCommit{}, fmt.Errorf("resolving HEAD to a hash reference: %w", err) - } - headRefName := headRef.Name() + var commits []Commit + var found bool + for { + if found = start != plumbing.ZeroHash && curr.Hash == start; found { + break + } - headHash, err := r.ReferenceToHash(headRefName) - if err != nil { - return GitCommit{}, fmt.Errorf("resolving ref %q (HEAD): %w", headRefName, err) - } + commits = append(commits, curr) + numParents := curr.Object.NumParents() + if numParents == 0 { + break + } else if numParents > 1 { + return nil, fmt.Errorf("commit %q has more than one parent: %+v", + curr.Hash, curr.Object.ParentHashes) + } - // TODO this is also used in the same way in NewCommitChange. It might make - // sense to refactor this logic out, it might not be needed in fs at all. - _, stagedTree, err := fs.FromStagedChangesTree(r.GitRepo) - if err != nil { - return GitCommit{}, fmt.Errorf("getting staged changes: %w", err) + parentHash := curr.Object.ParentHashes[0] + parent, err := proj.GetCommit(parentHash) + if err != nil { + return nil, fmt.Errorf("retrieving commit %q: %w", parentHash, err) + } + curr = parent } - - gitCommit, err := r.CommitBare(CommitBareParams{ - Commit: commit, - Author: strings.Join(commit.Common.credIDs(), ", "), - ParentHash: headHash, - GitTree: stagedTree, - }) - if err != nil { - return GitCommit{}, err + if !found && start != plumbing.ZeroHash { + return nil, fmt.Errorf("unable to find commit %q as an ancestor of %q", + start, end) } - // now set the branch to this new commit - newHeadRef := plumbing.NewHashReference(headRefName, gitCommit.GitCommit.Hash) - if err := r.GitRepo.Storer.SetReference(newHeadRef); err != nil { - return GitCommit{}, fmt.Errorf("setting reference %q to new commit hash %q: %w", - headRefName, gitCommit.GitCommit.Hash, err) + // 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 gitCommit, nil + return commits, nil } -// HasStagedChanges returns true if there are file changes which have been -// staged (e.g. via "git add"). -func (r *Repo) HasStagedChanges() (bool, error) { - w, err := r.GitRepo.Worktree() - if err != nil { - return false, fmt.Errorf("error retrieving worktree: %w", err) - } - - status, err := w.Status() - if err != nil { - return false, fmt.Errorf("error retrieving worktree status: %w", err) - } +var ( + hashStrLen = len(plumbing.ZeroHash.String()) + errNotHex = errors.New("not a valid hex string") +) - var any bool - for _, fileStatus := range status { - if fileStatus.Staging != git.Unmodified && - fileStatus.Staging != git.Untracked { - any = true - break - } +func (proj *Project) findCommitByShortHash(hashStr string) (plumbing.Hash, error) { + paddedHashStr := hashStr + if len(hashStr)%2 > 0 { + paddedHashStr += "0" } - return any, nil -} -// VerifyCommits verifies that the given commits, which are presumably on the -// given branch, are gucci. -func (r *Repo) VerifyCommits(branchName plumbing.ReferenceName, gitCommits []GitCommit) error { - // this isn't strictly necessary for this method, but it helps discover bugs - // in other parts of the code. - if len(gitCommits) == 0 { - return errors.New("cannot call VerifyCommits with empty commit slice") + 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") } - // First determine the root of the main branch. All commits need to be an - // ancestor of it. If the main branch has not been created yet then there - // might not be a root commit yet. - var rootCommit *object.Commit - mainGitCommit, err := r.GetGitRevision(plumbing.Revision(MainRefName)) - if errors.Is(err, plumbing.ErrReferenceNotFound) { - - // main branch hasn't been created yet. The commits can only be verified - // if they are for the main branch and they include the root commit. - if branchName != MainRefName { - return fmt.Errorf("cannot verify commits in branch %q when no main branch exists", branchName) - } - - for _, gitCommit := range gitCommits { - if gitCommit.GitCommit.NumParents() == 0 { - rootCommit = gitCommit.GitCommit - break - } - } - if rootCommit == nil { - return errors.New("root commit of main branch cannot be determined") + for i := 2; i < hashStrLen; i++ { + hashPrefix, hashTail := hashStr[:i], hashStr[i:] + path := filepath.Join("objects", hashPrefix) + fileInfos, err := proj.GitDirFS.ReadDir(path) + if err != nil { + return plumbing.ZeroHash, fmt.Errorf("listing files in %q: %w", path, err) } - } else if err != nil { - return fmt.Errorf("retrieving commit at HEAD of %q: %w", MainRefName.Short(), err) - - } else { - rootCommit = mainGitCommit.GitCommit - for { - if rootCommit.NumParents() == 0 { - break - } else if rootCommit.NumParents() > 1 { - return fmt.Errorf("commit %q in main branch has more than one parent", rootCommit.Hash) - } else if rootCommit, err = rootCommit.Parent(0); err != nil { - return fmt.Errorf("retrieving parent commit of %q: %w", rootCommit.Hash, err) + var matchedHash plumbing.Hash + for _, fileInfo := range fileInfos { + objFileName := fileInfo.Name() + if !strings.HasPrefix(objFileName, hashTail) { + continue } - } - } - - // 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 - } - return isAncestor - } + objHash := plumbing.NewHash(hashPrefix + objFileName) + 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) - if branchGitCommit.GitCommit != nil { - // if the branch doesn't actually exist then this couldn't - // possibly be a nonFF - isNonFF = !isAncestor(branchGitCommit.GitCommit, gitCommit.GitCommit) + } else if matchedHash == plumbing.ZeroHash { + matchedHash = objHash + continue } - if err != nil { - return err - } else if !ancestorOfRoot { - return fmt.Errorf("commit %q must be direct descendant of root commit of %q (%q)", - gitCommit.GitCommit.Hash, MainRefName.Short(), rootCommit.Hash, - ) - } + return plumbing.ZeroHash, fmt.Errorf("both %q and %q match", matchedHash, objHash) } - if err := r.verifyCommit(branchName, gitCommit, parentTree, isNonFF); err != nil { - return fmt.Errorf("verifying commit %q: %w", - gitCommit.GitCommit.Hash, err) + if matchedHash != plumbing.ZeroHash { + return matchedHash, nil } } - 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 (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") - } + return plumbing.ZeroHash, errors.New("failed to find a commit object with a matching prefix") } -// if parentTree is nil then it will be inferred. -func (r *Repo) verifyCommit( - branchName plumbing.ReferenceName, - gitCommit GitCommit, - parentTree *object.Tree, - isNonFF bool, -) error { - parentTree, err := r.parentTree(gitCommit.GitCommit) - if err != nil { - return fmt.Errorf("retrieving parent tree of commit: %w", err) - } - - var 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)) +func (proj *Project) resolveRev(rev plumbing.Revision) (plumbing.Hash, error) { + if rev == plumbing.Revision(plumbing.ZeroHash.String()) { + return plumbing.ZeroHash, nil } - // verify all credentials - for _, cred := range gitCommit.Commit.Common.Credentials { - if cred.AccountID == "" { - if err := cred.SelfVerify(expectedCommitHash); err != nil { - return fmt.Errorf("verifying credential %+v: %w", cred, err) - } + { + // pretend the revision is a short hash until proven otherwise + shortHash := string(rev) + hash, err := proj.findCommitByShortHash(shortHash) + if errors.Is(err, errNotHex) { + // ok, continue + } else if err != nil { + return plumbing.ZeroHash, fmt.Errorf("resolving as short hash: %w", err) } else { - sig, err := r.signifierForCredential(sigFS, cred) - if err != nil { - return fmt.Errorf("finding signifier for credential %+v: %w", cred, err) - } else if err := sig.Verify(sigFS, expectedCommitHash, cred); err != nil { - return fmt.Errorf("verifying credential %+v: %w", cred, err) - } + // guess it _is_ a short hash, knew it! + return hash, nil } } - return nil -} - -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) + h, err := proj.GitRepo.ResolveRevision(rev) if err != nil { - return changeRangeInfo{}, fmt.Errorf("calculating diff of commit trees %q and %q: %w", - info.startTree.Hash, info.endTree.Hash, err) + return plumbing.ZeroHash, fmt.Errorf("resolving revision %q: %w", rev, err) } - - info.changeHash = genChangeHash(nil, info.msg, changedFiles) - return info, nil + return *h, nil } -// VerifyCanSetBranchHEADTo is used to verify that a branch's HEAD can be set to -// the given hash. It verifies any new commits which are being added, and -// handles verifying non-fast-forward commits as well. -// -// If the given hash matches the current HEAD of the branch then this performs -// no further checks and returns nil. -func (r *Repo) VerifyCanSetBranchHEADTo(branchName plumbing.ReferenceName, hash plumbing.Hash) error { - oldCommitRef, err := r.GitRepo.Reference(branchName, true) - if errors.Is(err, plumbing.ErrReferenceNotFound) { - // if the branch is being created then just pull all of its commits and - // verify them. - // TODO optimize this so that it tries to use the merge-base with main, - // so we're not re-verifying a ton of commits unecessarily - commits, err := r.GetGitCommitRange(plumbing.ZeroHash, hash) - 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()) +// GetCommitByRevision resolves the revision and returns the Commit it references. +func (proj *Project) GetCommitByRevision(rev plumbing.Revision) (Commit, error) { + hash, err := proj.resolveRev(rev) if err != nil { - return fmt.Errorf("retrieving commit object %q: %w", oldCommitRef.Hash(), err) + return Commit{}, err } - newCommitObj, err := r.GitRepo.CommitObject(hash) + c, err := proj.GetCommit(hash) 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 { - return fmt.Errorf("determining merge-base between %q and %q: %w", - oldCommitObj.Hash, newCommitObj.Hash, err) - } else if len(mbCommits) == 0 { - return fmt.Errorf("%q and %q have no ancestors in common", - oldCommitObj.Hash, newCommitObj.Hash) - } else if len(mbCommits) == 2 { - return fmt.Errorf("%q and %q have more than one ancestor in common", - oldCommitObj.Hash, newCommitObj.Hash) + return nil, err } - commits, err := r.GetGitCommitRange(mbCommits[0].Hash, hash) + end, err := proj.resolveRev(endRev) 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) } diff --git a/commit_change.go b/commit_change.go deleted file mode 100644 index 6424a35..0000000 --- a/commit_change.go +++ /dev/null @@ -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 -} diff --git a/commit_comment.go b/commit_comment.go deleted file mode 100644 index ad6a8e9..0000000 --- a/commit_comment.go +++ /dev/null @@ -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 -} diff --git a/commit_credential.go b/commit_credential.go deleted file mode 100644 index 8364c41..0000000 --- a/commit_credential.go +++ /dev/null @@ -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 -} diff --git a/config.go b/config.go index 9a618d1..f351c23 100644 --- a/config.go +++ b/config.go @@ -13,9 +13,9 @@ import ( // Account represents a single account defined in the Config. type Account struct { - ID string `yaml:"id"` - Signifiers []sigcred.Signifier `yaml:"signifiers"` - Meta map[string]string `yaml:"meta,omitempty"` + ID string `yaml:"id"` + Signifiers []sigcred.SignifierUnion `yaml:"signifiers"` + Meta map[string]string `yaml:"meta,omitempty"` } // 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"` } -func (r *Repo) loadConfig(fs fs.FS) (Config, error) { +func (proj *Project) loadConfig(fs fs.FS) (Config, error) { rc, err := fs.Open(ConfigPath) if err != nil { return Config{}, fmt.Errorf("could not open config.yml: %w", err) @@ -53,18 +53,18 @@ func (r *Repo) loadConfig(fs fs.FS) (Config, error) { return cfg, nil } -// LoadConfig loads the Config object from the HEAD of the repo, or directly -// from the filesystem if there is no HEAD yet. -func (r *Repo) LoadConfig() (Config, error) { - headFS, err := r.headFS() +// LoadConfig loads the Config object from the HEAD of the project's git repo, +// or directly from the filesystem if there is no HEAD yet. +func (proj *Project) LoadConfig() (Config, error) { + headFS, err := proj.headFS() if err != nil { return Config{}, fmt.Errorf("error retrieving repo HEAD: %w", err) } - return r.loadConfig(headFS) + return proj.loadConfig(headFS) } -func (r *Repo) signifierForCredential(fs fs.FS, cred sigcred.Credential) (sigcred.SignifierInterface, error) { - cfg, err := r.loadConfig(fs) +func (proj *Project) signifierForCredential(fs fs.FS, cred sigcred.CredentialUnion) (sigcred.Signifier, error) { + cfg, err := proj.loadConfig(fs) if err != nil { return nil, fmt.Errorf("error loading config: %w", err) } @@ -81,13 +81,12 @@ func (r *Repo) signifierForCredential(fs fs.FS, cred sigcred.Credential) (sigcre return nil, fmt.Errorf("no account object for account id %q present in config", cred.AccountID) } - for i, sig := range account.Signifiers { - if sigInt, err := sig.Interface(cred.AccountID); err != nil { - return nil, fmt.Errorf("error converting signifier index:%d to inteface: %w", i, err) - } else if ok, err := sigInt.Signed(fs, cred); err != nil { + for i, sigUn := range account.Signifiers { + sig := sigUn.Signifier(cred.AccountID) + if ok, err := sig.Signed(fs, cred); err != nil { return nil, fmt.Errorf("error checking if signfier index:%d signed credential: %w", i, err) } else if ok { - return sigInt, nil + return sig, nil } } diff --git a/hash.go b/fingerprint.go similarity index 92% rename from hash.go rename to fingerprint.go index 525ad20..e060924 100644 --- a/hash.go +++ b/fingerprint.go @@ -68,7 +68,7 @@ var ( ) // if h is nil it then defaultHashHelperAlgo will be used -func genChangeHash(h hash.Hash, msg string, changedFiles []ChangedFile) []byte { +func genChangeFingerprint(h hash.Hash, msg string, changedFiles []ChangedFile) []byte { s := newHashHelper(h) s.writeStr(msg) s.writeChangedFiles(changedFiles) @@ -76,7 +76,7 @@ func genChangeHash(h hash.Hash, msg string, changedFiles []ChangedFile) []byte { } // if h is nil it then defaultHashHelperAlgo will be used -func genCommentHash(h hash.Hash, comment string) []byte { +func genCommentFingerprint(h hash.Hash, comment string) []byte { s := newHashHelper(h) s.writeStr(comment) return s.sum(commentHashVersion) diff --git a/hash_test.go b/fingerprint_test.go similarity index 96% rename from hash_test.go rename to fingerprint_test.go index c3b6306..f732f47 100644 --- a/hash_test.go +++ b/fingerprint_test.go @@ -47,7 +47,7 @@ func uvarint(i uint64) []byte { return buf[:n] } -func TestGenCommentHash(t *testing.T) { +func TestGenCommentFingerprint(t *testing.T) { type test struct { descr string comment string @@ -75,13 +75,13 @@ func TestGenCommentHash(t *testing.T) { for _, test := range tests { t.Run(test.descr, func(t *testing.T) { th := new(testHash) - genCommentHash(th, test.comment) + genCommentFingerprint(th, test.comment) th.assertContents(t, test.exp) }) } } -func TestGenChangeHash(t *testing.T) { +func TestGenChangeFingerprint(t *testing.T) { type test struct { descr string msg string @@ -230,7 +230,7 @@ func TestGenChangeHash(t *testing.T) { for _, test := range tests { t.Run(test.descr, func(t *testing.T) { th := new(testHash) - genChangeHash(th, test.msg, test.changedFiles) + genChangeFingerprint(th, test.msg, test.changedFiles) th.assertContents(t, test.exp) }) } diff --git a/payload.go b/payload.go new file mode 100644 index 0000000..b04fd27 --- /dev/null +++ b/payload.go @@ -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) +} diff --git a/payload_change.go b/payload_change.go new file mode 100644 index 0000000..034ce31 --- /dev/null +++ b/payload_change.go @@ -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 +} diff --git a/commit_change_test.go b/payload_change_test.go similarity index 58% rename from commit_change_test.go rename to payload_change_test.go index c0c6f40..0549f03 100644 --- a/commit_change_test.go +++ b/payload_change_test.go @@ -9,9 +9,9 @@ import ( "gopkg.in/src-d/go-git.v4/plumbing" ) -func TestChangeCommitVerify(t *testing.T) { +func TestPayloadChangeVerify(t *testing.T) { type step struct { - msg string + descr string msgHead string // defaults to msg tree map[string]string } @@ -23,8 +23,8 @@ func TestChangeCommitVerify(t *testing.T) { descr: "single commit", steps: []step{ { - msg: "first commit", - tree: map[string]string{"a": "0", "b": "1"}, + descr: "first commit", + tree: map[string]string{"a": "0", "b": "1"}, }, }, }, @@ -32,19 +32,19 @@ func TestChangeCommitVerify(t *testing.T) { descr: "multiple commits", steps: []step{ { - msg: "first commit", - tree: map[string]string{"a": "0", "b": "1"}, + descr: "first commit", + tree: map[string]string{"a": "0", "b": "1"}, }, { - msg: "second commit, changing a", - tree: map[string]string{"a": "1"}, + descr: "second commit, changing a", + tree: map[string]string{"a": "1"}, }, { - msg: "third commit, empty", + descr: "third commit, empty", }, { - msg: "fourth commit, adding c, removing b", - tree: map[string]string{"b": "", "c": "2"}, + descr: "fourth commit, adding c, removing b", + tree: map[string]string{"b": "", "c": "2"}, }, }, }, @@ -52,18 +52,18 @@ func TestChangeCommitVerify(t *testing.T) { descr: "big body commits", steps: []step{ { - msg: "first commit, single line but with newline\n", + descr: "first commit, single line but with newline\n", }, { - msg: "second commit, single line but with two newlines\n\n", + descr: "second commit, single line but with two newlines\n\n", msgHead: "second commit, single line but with two newlines\n\n", }, { - msg: "third commit, multi-line with one newline\nanother line!", + descr: "third commit, multi-line with one newline\nanother line!", msgHead: "third commit, multi-line with one newline\n\n", }, { - msg: "fourth commit, multi-line with two newlines\n\nanother line!", + descr: "fourth commit, multi-line with two newlines\n\nanother line!", msgHead: "fourth commit, multi-line with two newlines\n\n", }, }, @@ -78,29 +78,29 @@ func TestChangeCommitVerify(t *testing.T) { for _, step := range test.steps { h.stage(step.tree) - gitCommit := h.assertCommitChange(verifyShouldSucceed, step.msg, rootSig) + commit := h.assertCommitChange(verifyShouldSucceed, step.descr, rootSig) if step.msgHead == "" { - step.msgHead = strings.TrimSpace(step.msg) + "\n\n" + step.msgHead = strings.TrimSpace(step.descr) + "\n\n" } - if !strings.HasPrefix(gitCommit.GitCommit.Message, step.msgHead) { + if !strings.HasPrefix(commit.Object.Message, step.msgHead) { t.Fatalf("commit message %q does not start with expected head %q", - gitCommit.GitCommit.Message, step.msgHead) + commit.Object.Message, step.msgHead) } - var actualCommit Commit - if err := actualCommit.UnmarshalText([]byte(gitCommit.GitCommit.Message)); err != nil { - t.Fatalf("error unmarshaling commit body: %v", err) - } else if !reflect.DeepEqual(actualCommit, gitCommit.Commit) { - t.Fatalf("returned change commit:\n%s\ndoes not match actual one:\n%s", - spew.Sdump(gitCommit.Commit), spew.Sdump(actualCommit)) + var payUn PayloadUnion + if err := payUn.UnmarshalText([]byte(commit.Object.Message)); err != nil { + t.Fatalf("error unmarshaling commit message: %v", err) + } else if !reflect.DeepEqual(payUn, commit.Payload) { + t.Fatalf("returned change payload:\n%s\ndoes not match actual one:\n%s", + spew.Sdump(commit.Payload), spew.Sdump(payUn)) } } }) } } -func TestCombineCommitChanges(t *testing.T) { +func TestCombinePayloadChanges(t *testing.T) { h := newHarness(t) // commit initial config, so the root user can modify it in the next commit @@ -115,8 +115,8 @@ func TestCombineCommitChanges(t *testing.T) { filters: - type: branch pattern: main - - type: commit_type - commit_type: change + - type: payload_type + payload_type: change - type: signature any_account: true count: 2 @@ -141,27 +141,24 @@ func TestCombineCommitChanges(t *testing.T) { fooCommit := h.assertCommitChange(verifyShouldSucceed, "add foo file", rootSig) // now adding a credential commit from toot should work - credCommitObj, err := h.repo.NewCommitCredential(fooCommit.Interface.StoredHash()) + credCommitPayUn, err := h.proj.NewPayloadCredential(fooCommit.Payload.Common.Fingerprint) if err != nil { t.Fatal(err) } - credCommit := h.tryCommit(verifyShouldSucceed, credCommitObj, tootSig) + credCommit := h.tryCommit(verifyShouldSucceed, credCommitPayUn, tootSig) - allCommits, err := h.repo.GetGitCommitRange( - tootCommit.GitCommit.Hash, - credCommit.GitCommit.Hash, - ) + allCommits, err := h.proj.GetCommitRange(tootCommit.Hash, credCommit.Hash) if err != nil { - t.Fatalf("error getting commits: %v", err) + t.Fatalf("getting commits: %v", err) } - combinedCommit, err := h.repo.CombineCommitChanges(allCommits, MainRefName) + combinedCommit, err := h.proj.CombinePayloadChanges(allCommits, MainRefName) if err != nil { t.Fatal(err) } // that new commit should have both credentials - creds := combinedCommit.Commit.Common.Credentials + creds := combinedCommit.Payload.Common.Credentials if len(creds) != 2 { t.Fatalf("combined commit has %d credentials, not 2", len(creds)) } else if creds[0].AccountID != "root" { @@ -172,15 +169,15 @@ func TestCombineCommitChanges(t *testing.T) { // double check that the HEAD commit of main got properly set h.checkout(MainRefName) - mainHead, err := h.repo.GetGitHead() + mainHead, err := h.proj.GetHeadCommit() if err != nil { t.Fatal(err) - } else if mainHead.GitCommit.Hash != combinedCommit.GitCommit.Hash { + } else if mainHead.Hash != combinedCommit.Hash { t.Fatalf("mainHead's should be pointed at %s but is pointed at %s", - combinedCommit.GitCommit.Hash, mainHead.GitCommit.Hash) - } else if err = h.repo.VerifyCommits(MainRefName, []GitCommit{combinedCommit}); err != nil { + combinedCommit.Hash, mainHead.Hash) + } else if err = h.proj.VerifyCommits(MainRefName, []Commit{combinedCommit}); err != nil { t.Fatalf("unable to verify combined commit: %v", err) - } else if author := combinedCommit.GitCommit.Author.Name; author != "root" { + } else if author := combinedCommit.Object.Author.Name; author != "root" { t.Fatalf("unexpected author value %q", author) } } diff --git a/payload_comment.go b/payload_comment.go new file mode 100644 index 0000000..a4c1eb4 --- /dev/null +++ b/payload_comment.go @@ -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 +} diff --git a/payload_credential.go b/payload_credential.go new file mode 100644 index 0000000..1800944 --- /dev/null +++ b/payload_credential.go @@ -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 +} diff --git a/commit_credential_test.go b/payload_credential_test.go similarity index 70% rename from commit_credential_test.go rename to payload_credential_test.go index 2cb0396..72ab6dd 100644 --- a/commit_credential_test.go +++ b/payload_credential_test.go @@ -6,7 +6,7 @@ import ( "gopkg.in/src-d/go-git.v4/plumbing" ) -func TestCredentialCommitVerify(t *testing.T) { +func TestPayloadCredentialVerify(t *testing.T) { h := newHarness(t) rootSig := h.stageNewAccount("root", false) @@ -36,15 +36,15 @@ func TestCredentialCommitVerify(t *testing.T) { // toot user wants to create a credential commit for the root commit, for // whatever reason. - rootChangeHash := rootGitCommit.Commit.Change.ChangeHash - credCommit, err := h.repo.NewCommitCredential(rootChangeHash) + rootChangeFingerprint := rootGitCommit.Payload.Common.Fingerprint + credCommitPayUn, err := h.proj.NewPayloadCredential(rootChangeFingerprint) if err != nil { - t.Fatalf("creating credential commit for hash %x: %v", rootChangeHash, err) + t.Fatalf("creating credential commit for fingerprint %x: %v", rootChangeFingerprint, err) } - h.tryCommit(verifyShouldFail, credCommit, tootSig) + h.tryCommit(verifyShouldFail, credCommitPayUn, tootSig) // toot tries again in their own branch, and should be allowed. h.checkout(tootBranch) - h.tryCommit(verifyShouldSucceed, credCommit, tootSig) + h.tryCommit(verifyShouldSucceed, credCommitPayUn, tootSig) } diff --git a/commit_test.go b/payload_test.go similarity index 76% rename from commit_test.go rename to payload_test.go index d9f3c9f..53c7151 100644 --- a/commit_test.go +++ b/payload_test.go @@ -15,12 +15,12 @@ func TestConfigChange(t *testing.T) { h := newHarness(t) rootSig := h.stageNewAccount("root", false) - var gitCommits []GitCommit + var commits []Commit // commit the initial staged changes, which merely include the config and // public key - gitCommit := h.assertCommitChange(verifyShouldSucceed, "commit configuration", rootSig) - gitCommits = append(gitCommits, gitCommit) + commit := h.assertCommitChange(verifyShouldSucceed, "commit configuration", rootSig) + commits = append(commits, commit) // create a new account and add it to the configuration. That commit should // not be verifiable, though @@ -30,15 +30,15 @@ func TestConfigChange(t *testing.T) { // now add with the root user, this should work. h.stageCfg() - gitCommit = h.assertCommitChange(verifyShouldSucceed, "add toot user", rootSig) - gitCommits = append(gitCommits, gitCommit) + commit = h.assertCommitChange(verifyShouldSucceed, "add toot user", rootSig) + commits = append(commits, commit) // _now_ the toot user should be able to do things. h.stage(map[string]string{"foo/bar": "what a cool file"}) - gitCommit = h.assertCommitChange(verifyShouldSucceed, "add a cool file", tootSig) - gitCommits = append(gitCommits, gitCommit) + commit = h.assertCommitChange(verifyShouldSucceed, "add a cool file", tootSig) + commits = append(commits, commit) - if err := h.repo.VerifyCommits(MainRefName, gitCommits); err != nil { + if err := h.proj.VerifyCommits(MainRefName, commits); err != nil { t.Fatal(err) } } @@ -62,7 +62,7 @@ func TestMainAncestryRequirement(t *testing.T) { // set HEAD to this other branch which doesn't really exist ref := plumbing.NewSymbolicReference(plumbing.HEAD, otherBranch) - if err := h.repo.GitRepo.Storer.SetReference(ref); err != nil { + if err := h.proj.GitRepo.Storer.SetReference(ref); err != nil { h.t.Fatal(err) } @@ -93,15 +93,15 @@ func TestNonFastForwardCommits(t *testing.T) { h.stage(map[string]string{"foo": "foo"}) fooCommit := h.assertCommitChange(verifyShouldSucceed, "foo", rootSig) - commitOn := func(hash plumbing.Hash, msg string) GitCommit { + commitOn := func(hash plumbing.Hash, msg string) Commit { ref := plumbing.NewHashReference(plumbing.HEAD, hash) - if err := h.repo.GitRepo.Storer.SetReference(ref); err != nil { + if err := h.proj.GitRepo.Storer.SetReference(ref); err != nil { h.t.Fatal(err) - } else if commitChange, err := h.repo.NewCommitChange("bar"); err != nil { + } else if commitChange, err := h.proj.NewPayloadChange("bar"); err != nil { h.t.Fatal(err) - } else if commitChange, err = h.repo.AccreditCommit(commitChange, rootSig); err != nil { + } else if commitChange, err = h.proj.AccreditPayload(commitChange, rootSig); err != nil { h.t.Fatal(err) - } else if gitCommit, err := h.repo.Commit(commitChange); err != nil { + } else if gitCommit, err := h.proj.Commit(commitChange); err != nil { h.t.Fatal(err) } else { return gitCommit @@ -112,8 +112,8 @@ func TestNonFastForwardCommits(t *testing.T) { // checkout initCommit directly, make a new commit on top of it, and try to // verify that (this is too fancy for the harness, must be done manually). h.stage(map[string]string{"bar": "bar"}) - barCommit := commitOn(initCommit.GitCommit.Hash, "bar") - err := h.repo.VerifyCommits(MainRefName, []GitCommit{barCommit}) + barCommit := commitOn(initCommit.Hash, "bar") + err := h.proj.VerifyCommits(MainRefName, []Commit{barCommit}) if !errors.As(err, new(accessctl.ErrCommitRequestDenied)) { h.t.Fatalf("expected ErrCommitRequestDenied, got: %v", err) } @@ -135,14 +135,14 @@ func TestNonFastForwardCommits(t *testing.T) { // checking out allowNonFFCommit directly and performing a nonFF commit // should work now. h.stage(map[string]string{"baz": "baz"}) - bazCommit := commitOn(allowNonFFCommit.GitCommit.Hash, "baz") - if err = h.repo.VerifyCommits(MainRefName, []GitCommit{bazCommit}); err != nil { + bazCommit := commitOn(allowNonFFCommit.Hash, "baz") + if err = h.proj.VerifyCommits(MainRefName, []Commit{bazCommit}); err != nil { h.t.Fatal(err) } // verifying the full history should also work - gitCommits := []GitCommit{initCommit, fooCommit, allowNonFFCommit, bazCommit} - if err = h.repo.VerifyCommits(MainRefName, gitCommits); err != nil { + gitCommits := []Commit{initCommit, fooCommit, allowNonFFCommit, bazCommit} + if err = h.proj.VerifyCommits(MainRefName, gitCommits); err != nil { h.t.Fatal(err) } } @@ -161,7 +161,7 @@ func TestCanSetBranchHEADTo(t *testing.T) { type test struct { descr string - init func(h *harness, rootSig sigcred.SignifierInterface) toTest + init func(h *harness, rootSig sigcred.Signifier) toTest // If true then the verify call is expected to fail. The string is a // regex which should match the unwrapped error returned. @@ -171,7 +171,7 @@ func TestCanSetBranchHEADTo(t *testing.T) { tests := []test{ { descr: "creation of main", - init: func(h *harness, rootSig sigcred.SignifierInterface) toTest { + init: func(h *harness, rootSig sigcred.Signifier) toTest { // checkout other and build on top of that, so that when // VerifyCanSetBranchHEADTo is called main won't exist. other := plumbing.NewBranchReferenceName("other") @@ -180,26 +180,26 @@ func TestCanSetBranchHEADTo(t *testing.T) { initCommit := h.assertCommitChange(verifySkip, "init", rootSig) return toTest{ branchName: MainRefName, - hash: initCommit.GitCommit.Hash, + hash: initCommit.Hash, } }, }, { descr: "main ff", - init: func(h *harness, rootSig sigcred.SignifierInterface) toTest { + init: func(h *harness, rootSig sigcred.Signifier) toTest { initCommit := h.assertCommitChange(verifySkip, "init", rootSig) h.stage(map[string]string{"foo": "foo"}) nextCommit := h.assertCommitChange(verifySkip, "next", rootSig) return toTest{ branchName: MainRefName, - hash: nextCommit.GitCommit.Hash, - resetTo: initCommit.GitCommit.Hash, + hash: nextCommit.Hash, + resetTo: initCommit.Hash, } }, }, { descr: "new branch, no main", - init: func(h *harness, rootSig sigcred.SignifierInterface) toTest { + init: func(h *harness, rootSig sigcred.Signifier) toTest { // checkout other and build on top of that, so that when // VerifyCanSetBranchHEADTo is called main won't exist. other := plumbing.NewBranchReferenceName("other") @@ -208,7 +208,7 @@ func TestCanSetBranchHEADTo(t *testing.T) { initCommit := h.assertCommitChange(verifySkip, "init", rootSig) return toTest{ branchName: plumbing.NewBranchReferenceName("other2"), - hash: initCommit.GitCommit.Hash, + hash: initCommit.Hash, } }, expErr: `^cannot verify commits in branch "refs/heads/other2" when no main branch exists$`, @@ -217,7 +217,7 @@ func TestCanSetBranchHEADTo(t *testing.T) { // this case isn't generally possible, unless someone manually // creates a branch in an empty repo on the remote descr: "existing branch, no main", - init: func(h *harness, rootSig sigcred.SignifierInterface) toTest { + init: func(h *harness, rootSig sigcred.Signifier) toTest { // checkout other and build on top of that, so that when // VerifyCanSetBranchHEADTo is called main won't exist. other := plumbing.NewBranchReferenceName("other") @@ -229,21 +229,21 @@ func TestCanSetBranchHEADTo(t *testing.T) { return toTest{ branchName: other, - hash: fooCommit.GitCommit.Hash, - resetTo: initCommit.GitCommit.Hash, + hash: fooCommit.Hash, + resetTo: initCommit.Hash, } }, expErr: `^cannot verify commits in branch "refs/heads/other" when no main branch exists$`, }, { descr: "new branch, not ancestor of main", - init: func(h *harness, rootSig sigcred.SignifierInterface) toTest { + init: func(h *harness, rootSig sigcred.Signifier) toTest { h.assertCommitChange(verifySkip, "init", rootSig) // create new branch with no HEAD, and commit on that. other := plumbing.NewBranchReferenceName("other") ref := plumbing.NewSymbolicReference(plumbing.HEAD, other) - if err := h.repo.GitRepo.Storer.SetReference(ref); err != nil { + if err := h.proj.GitRepo.Storer.SetReference(ref); err != nil { t.Fatal(err) } @@ -252,7 +252,7 @@ func TestCanSetBranchHEADTo(t *testing.T) { badInitCommit := h.assertCommitChange(verifySkip, "a different init", rootSig) return toTest{ branchName: plumbing.NewBranchReferenceName("other2"), - hash: badInitCommit.GitCommit.Hash, + hash: badInitCommit.Hash, } }, expErr: `^commit "[0-9a-f]+" must be direct descendant of root commit of "main" \("[0-9a-f]+"\)$`, @@ -261,13 +261,13 @@ func TestCanSetBranchHEADTo(t *testing.T) { // this case isn't generally possible, unless someone manually // creates a branch in an empty repo on the remote descr: "existing branch, not ancestor of main", - init: func(h *harness, rootSig sigcred.SignifierInterface) toTest { + init: func(h *harness, rootSig sigcred.Signifier) toTest { h.assertCommitChange(verifySkip, "init", rootSig) // create new branch with no HEAD, and commit on that. other := plumbing.NewBranchReferenceName("other") ref := plumbing.NewSymbolicReference(plumbing.HEAD, other) - if err := h.repo.GitRepo.Storer.SetReference(ref); err != nil { + if err := h.proj.GitRepo.Storer.SetReference(ref); err != nil { t.Fatal(err) } @@ -280,15 +280,15 @@ func TestCanSetBranchHEADTo(t *testing.T) { return toTest{ branchName: other, - hash: barCommit.GitCommit.Hash, - resetTo: badInitCommit.GitCommit.Hash, + hash: barCommit.Hash, + resetTo: badInitCommit.Hash, } }, expErr: `^commit "[0-9a-f]+" must be direct descendant of root commit of "main" \("[0-9a-f]+"\)$`, }, { descr: "new branch off of main", - init: func(h *harness, rootSig sigcred.SignifierInterface) toTest { + init: func(h *harness, rootSig sigcred.Signifier) toTest { initCommit := h.assertCommitChange(verifySkip, "init", rootSig) other := plumbing.NewBranchReferenceName("other") @@ -298,14 +298,14 @@ func TestCanSetBranchHEADTo(t *testing.T) { return toTest{ branchName: other, - hash: fooCommit.GitCommit.Hash, - resetTo: initCommit.GitCommit.Hash, + hash: fooCommit.Hash, + resetTo: initCommit.Hash, } }, }, { descr: "new branch off of older main commit", - init: func(h *harness, rootSig sigcred.SignifierInterface) toTest { + init: func(h *harness, rootSig sigcred.Signifier) toTest { initCommit := h.assertCommitChange(verifySkip, "init", rootSig) h.stage(map[string]string{"foo": "foo"}) @@ -313,26 +313,26 @@ func TestCanSetBranchHEADTo(t *testing.T) { other := plumbing.NewBranchReferenceName("other") h.checkout(other) - h.reset(initCommit.GitCommit.Hash, git.HardReset) + h.reset(initCommit.Hash, git.HardReset) h.stage(map[string]string{"bar": "bar"}) barCommit := h.assertCommitChange(verifySkip, "bar", rootSig) return toTest{ branchName: other, - hash: barCommit.GitCommit.Hash, - resetTo: initCommit.GitCommit.Hash, + hash: barCommit.Hash, + resetTo: initCommit.Hash, } }, }, { descr: "branch ff", - init: func(h *harness, rootSig sigcred.SignifierInterface) toTest { + init: func(h *harness, rootSig sigcred.Signifier) toTest { h.assertCommitChange(verifySkip, "init", rootSig) other := plumbing.NewBranchReferenceName("other") h.checkout(other) - var commits []GitCommit + var commits []Commit for _, str := range []string{"foo", "bar", "baz", "biz", "buz"} { h.stage(map[string]string{str: str}) commit := h.assertCommitChange(verifySkip, str, rootSig) @@ -341,14 +341,14 @@ func TestCanSetBranchHEADTo(t *testing.T) { return toTest{ branchName: other, - hash: commits[len(commits)-1].GitCommit.Hash, - resetTo: commits[0].GitCommit.Hash, + hash: commits[len(commits)-1].Hash, + resetTo: commits[0].Hash, } }, }, { descr: "main nonff", - init: func(h *harness, rootSig sigcred.SignifierInterface) toTest { + init: func(h *harness, rootSig sigcred.Signifier) toTest { initCommit := h.assertCommitChange(verifySkip, "init", rootSig) h.stage(map[string]string{"foo": "foo"}) h.assertCommitChange(verifySkip, "foo", rootSig) @@ -356,20 +356,20 @@ func TestCanSetBranchHEADTo(t *testing.T) { // start another branch back at init and make a new commit on it other := plumbing.NewBranchReferenceName("other") h.checkout(other) - h.reset(initCommit.GitCommit.Hash, git.HardReset) + h.reset(initCommit.Hash, git.HardReset) h.stage(map[string]string{"bar": "bar"}) barCommit := h.assertCommitChange(verifySkip, "bar", rootSig) return toTest{ branchName: MainRefName, - hash: barCommit.GitCommit.Hash, + hash: barCommit.Hash, } }, expErr: `^commit matched and denied by this access control:`, }, { descr: "branch nonff", - init: func(h *harness, rootSig sigcred.SignifierInterface) toTest { + init: func(h *harness, rootSig sigcred.Signifier) toTest { h.assertCommitChange(verifySkip, "init", rootSig) other := plumbing.NewBranchReferenceName("other") @@ -381,13 +381,13 @@ func TestCanSetBranchHEADTo(t *testing.T) { other2 := plumbing.NewBranchReferenceName("other2") h.checkout(other2) - h.reset(fooCommit.GitCommit.Hash, git.HardReset) + h.reset(fooCommit.Hash, git.HardReset) h.stage(map[string]string{"baz": "baz"}) bazCommit := h.assertCommitChange(verifySkip, "baz", rootSig) return toTest{ branchName: other, - hash: bazCommit.GitCommit.Hash, + hash: bazCommit.Hash, } }, }, @@ -401,12 +401,12 @@ func TestCanSetBranchHEADTo(t *testing.T) { if toTest.resetTo != plumbing.ZeroHash { ref := plumbing.NewHashReference(toTest.branchName, toTest.resetTo) - if err := h.repo.GitRepo.Storer.SetReference(ref); err != nil { + if err := h.proj.GitRepo.Storer.SetReference(ref); err != nil { t.Fatal(err) } } - err := h.repo.VerifyCanSetBranchHEADTo(toTest.branchName, toTest.hash) + err := h.proj.VerifyCanSetBranchHEADTo(toTest.branchName, toTest.hash) if test.expErr == "" { if err != nil { t.Fatalf("unexpected error: %v", err) diff --git a/project.go b/project.go new file mode 100644 index 0000000..d894831 --- /dev/null +++ b/project.go @@ -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 +} diff --git a/repo_test.go b/project_test.go similarity index 66% rename from repo_test.go rename to project_test.go index 5fb6272..d0b0540 100644 --- a/repo_test.go +++ b/project_test.go @@ -18,7 +18,7 @@ import ( type harness struct { t *testing.T rand *rand.Rand - repo *Repo + proj *Project cfg *Config } @@ -27,13 +27,13 @@ func newHarness(t *testing.T) *harness { return &harness{ t: t, rand: rand, - repo: InitMemRepo(), + proj: InitMemProject(), cfg: new(Config), } } func (h *harness) stage(tree map[string]string) { - w, err := h.repo.GitRepo.Worktree() + w, err := h.proj.GitRepo.Worktree() if err != nil { h.t.Fatal(err) } @@ -41,28 +41,28 @@ func (h *harness) stage(tree map[string]string) { for path, content := range tree { if content == "" { if _, err := w.Remove(path); err != nil { - h.t.Fatalf("error removing %q: %v", path, err) + h.t.Fatalf("removing %q: %v", path, err) } continue } dir := filepath.Dir(path) if err := fs.MkdirAll(dir, 0666); err != nil { - h.t.Fatalf("error making directory %q: %v", dir, err) + h.t.Fatalf("making directory %q: %v", dir, err) } f, err := fs.Create(path) if err != nil { - h.t.Fatalf("error creating file %q: %v", path, err) + h.t.Fatalf("creating file %q: %v", path, err) } else if _, err := io.Copy(f, bytes.NewBufferString(content)); err != nil { - h.t.Fatalf("error writing to file %q: %v", path, err) + h.t.Fatalf("writing to file %q: %v", path, err) } else if err := f.Close(); err != nil { - h.t.Fatalf("error closing file %q: %v", path, err) + h.t.Fatalf("closing file %q: %v", path, err) } else if _, err := w.Add(path); err != nil { - h.t.Fatalf("error adding file %q to index: %v", path, err) + h.t.Fatalf("adding file %q to index: %v", path, err) } } } @@ -75,12 +75,12 @@ func (h *harness) stageCfg() { h.stage(map[string]string{ConfigPath: string(cfgBody)}) } -func (h *harness) stageNewAccount(accountID string, anon bool) sigcred.SignifierInterface { +func (h *harness) stageNewAccount(accountID string, anon bool) sigcred.Signifier { sig, pubKeyBody := sigcred.TestSignifierPGP(accountID, anon, h.rand) if !anon { h.cfg.Accounts = append(h.cfg.Accounts, Account{ ID: accountID, - Signifiers: []sigcred.Signifier{{PGPPublicKey: &sigcred.SignifierPGP{ + Signifiers: []sigcred.SignifierUnion{{PGPPublicKey: &sigcred.SignifierPGP{ Body: string(pubKeyBody), }}}, }) @@ -97,17 +97,17 @@ func (h *harness) stageAccessControls(aclYAML string) { } func (h *harness) checkout(branch plumbing.ReferenceName) { - w, err := h.repo.GitRepo.Worktree() + w, err := h.proj.GitRepo.Worktree() if err != nil { h.t.Fatal(err) } - head, err := h.repo.GetGitHead() + head, err := h.proj.GetHeadCommit() if errors.Is(err, ErrHeadIsZero) { // if HEAD is not resolvable to any hash than the Checkout method // doesn't work, just set HEAD manually. ref := plumbing.NewSymbolicReference(plumbing.HEAD, branch) - if err := h.repo.GitRepo.Storer.SetReference(ref); err != nil { + if err := h.proj.GitRepo.Storer.SetReference(ref); err != nil { h.t.Fatal(err) } return @@ -115,10 +115,10 @@ func (h *harness) checkout(branch plumbing.ReferenceName) { h.t.Fatal(err) } - _, err = h.repo.GitRepo.Storer.Reference(branch) + _, err = h.proj.GitRepo.Storer.Reference(branch) if errors.Is(err, plumbing.ErrReferenceNotFound) { err = w.Checkout(&git.CheckoutOptions{ - Hash: head.GitCommit.Hash, + Hash: head.Hash, Branch: branch, Create: true, }) @@ -136,7 +136,7 @@ func (h *harness) checkout(branch plumbing.ReferenceName) { } func (h *harness) reset(to plumbing.Hash, mode git.ResetMode) { - w, err := h.repo.GitRepo.Worktree() + w, err := h.proj.GitRepo.Worktree() if err != nil { h.t.Fatal(err) } @@ -160,65 +160,65 @@ const ( func (h *harness) tryCommit( verifyExp verifyExpectation, - commit Commit, - accountSig sigcred.SignifierInterface, -) GitCommit { + payUn PayloadUnion, + accountSig sigcred.Signifier, +) Commit { if accountSig != nil { var err error - if commit, err = h.repo.AccreditCommit(commit, accountSig); err != nil { - h.t.Fatalf("accrediting commit: %v", err) + if payUn, err = h.proj.AccreditPayload(payUn, accountSig); err != nil { + h.t.Fatalf("accrediting payload: %v", err) } } - gitCommit, err := h.repo.Commit(commit) + commit, err := h.proj.Commit(payUn) if err != nil { - h.t.Fatalf("failed to commit ChangeCommit: %v", err) + h.t.Fatalf("committing PayloadChange: %v", err) } else if verifyExp == verifySkip { - return gitCommit + return commit } - branch, err := h.repo.ReferenceToBranchName(plumbing.HEAD) + branch, err := h.proj.ReferenceToBranchName(plumbing.HEAD) if err != nil { h.t.Fatalf("determining checked out branch: %v", err) } shouldSucceed := verifyExp > 0 - err = h.repo.VerifyCommits(branch, []GitCommit{gitCommit}) + err = h.proj.VerifyCommits(branch, []Commit{commit}) if shouldSucceed && err != nil { - h.t.Fatalf("verifying commit %q: %v", gitCommit.GitCommit.Hash, err) + h.t.Fatalf("verifying commit %q: %v", commit.Hash, err) } else if shouldSucceed { - return gitCommit + return commit } else if !shouldSucceed && err == nil { - h.t.Fatalf("verifying commit %q should have failed", gitCommit.GitCommit.Hash) + h.t.Fatalf("verifying commit %q should have failed", commit.Hash) } var parentHash plumbing.Hash - if gitCommit.GitCommit.NumParents() > 0 { - parentHash = gitCommit.GitCommit.ParentHashes[0] + if commit.Object.NumParents() > 0 { + parentHash = commit.Object.ParentHashes[0] } h.reset(parentHash, git.HardReset) - return gitCommit + return commit } func (h *harness) assertCommitChange( verifyExp verifyExpectation, msg string, - sig sigcred.SignifierInterface, -) GitCommit { - commit, err := h.repo.NewCommitChange(msg) + sig sigcred.Signifier, +) Commit { + payUn, err := h.proj.NewPayloadChange(msg) if err != nil { - h.t.Fatalf("creating ChangeCommit: %v", err) + h.t.Fatalf("creating PayloadChange: %v", err) } - return h.tryCommit(verifyExp, commit, sig) + return h.tryCommit(verifyExp, payUn, sig) } func TestHasStagedChanges(t *testing.T) { h := newHarness(t) rootSig := h.stageNewAccount("root", false) assertHasStaged := func(expHasStaged bool) { - hasStaged, err := h.repo.HasStagedChanges() + hasStaged, err := h.proj.HasStagedChanges() if err != nil { t.Fatalf("error calling HasStagedChanges: %v", err) } else if hasStaged != expHasStaged { @@ -240,31 +240,30 @@ func TestHasStagedChanges(t *testing.T) { assertHasStaged(false) } -// TestThisRepoStillVerifies opens this actual repository and ensures that all -// commits in it still verify, given this codebase. -func TestThisRepoStillVerifies(t *testing.T) { - repo, err := OpenRepo(".") +// TestThisProjectStillVerifies opens this actual project and ensures that all +// commits in it still verify. +func TestThisProjectStillVerifies(t *testing.T) { + proj, err := OpenProject(".") if err != nil { t.Fatalf("error opening repo: %v", err) } - headGitCommit, err := repo.GetGitHead() + headCommit, err := proj.GetHeadCommit() if err != nil { t.Fatalf("getting repo head: %v", err) } - allCommits, err := repo.GetGitCommitRange(plumbing.ZeroHash, headGitCommit.GitCommit.Hash) + allCommits, err := proj.GetCommitRange(plumbing.ZeroHash, headCommit.Hash) if err != nil { - t.Fatalf("getting all commits (up to %q): %v", - headGitCommit.GitCommit.Hash, err) + t.Fatalf("getting all commits (up to %q): %v", headCommit.Hash, err) } - checkedOutBranch, err := repo.ReferenceToBranchName(plumbing.HEAD) + checkedOutBranch, err := proj.ReferenceToBranchName(plumbing.HEAD) if err != nil { t.Fatalf("error determining checked out branch: %v", err) } - if err := repo.VerifyCommits(checkedOutBranch, allCommits); err != nil { + if err := proj.VerifyCommits(checkedOutBranch, allCommits); err != nil { t.Fatal(err) } } @@ -274,17 +273,17 @@ func TestShortHashResolving(t *testing.T) { // but that's hard... h := newHarness(t) rootSig := h.stageNewAccount("root", false) - hash := h.assertCommitChange(verifyShouldSucceed, "first commit", rootSig).GitCommit.Hash + hash := h.assertCommitChange(verifyShouldSucceed, "first commit", rootSig).Hash hashStr := hash.String() t.Log(hashStr) for i := 2; i < len(hashStr); i++ { - gotCommit, err := h.repo.GetGitRevision(plumbing.Revision(hashStr[:i])) + gotCommit, err := h.proj.GetCommitByRevision(plumbing.Revision(hashStr[:i])) if err != nil { t.Fatalf("resolving %q: %v", hashStr[:i], err) - } else if gotCommit.GitCommit.Hash != hash { + } else if gotCommit.Hash != hash { t.Fatalf("expected hash %q but got %q", - gotCommit.GitCommit.Hash, hash) + gotCommit.Hash, hash) } } } diff --git a/repo.go b/repo.go deleted file mode 100644 index edda58c..0000000 --- a/repo.go +++ /dev/null @@ -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) -} diff --git a/sigcred/credential.go b/sigcred/credential.go index f0fa510..5e57ee3 100644 --- a/sigcred/credential.go +++ b/sigcred/credential.go @@ -6,40 +6,39 @@ import ( "dehub.dev/src/dehub.git/typeobj" ) -// Credential represents a credential which has been attached to a commit which -// hopefully will allow it to be included in the main. Exactly one field tagged -// with "type" should be set. -type Credential struct { +// CredentialUnion represents a credential, signifying a user's approval of a +// payload. Exactly one field tagged with "type" should be set. +type CredentialUnion struct { PGPSignature *CredentialPGPSignature `type:"pgp_signature"` - // AccountID specifies the account which generated this Credential. + // AccountID specifies the account which generated this CredentialUnion. // - // NOTE that Credentials produced by the direct implementations of - // SignifierInterface won't fill in this field, unless specifically - // documented. The SignifierInterface produced by the Interface() method of - // Signifier _will_ fill this field in, however. + // NOTE that credentials produced by the direct implementations of Signifier + // won't fill in this field, unless specifically documented. The Signifier + // produced by the Signifier() method of SignifierUnion _will_ fill this + // field in, however. AccountID string `yaml:"account,omitempty"` // AnonID specifies an identifier for the anonymous user which produced this // credential. This field is mutually exclusive with AccountID, and won't be - // set by any SignifierInterface unless specifically documented. + // set by any Signifier implementation unless specifically documented. AnonID string `yaml:"-"` } // MarshalYAML implements the yaml.Marshaler interface. -func (c Credential) MarshalYAML() (interface{}, error) { +func (c CredentialUnion) MarshalYAML() (interface{}, error) { return typeobj.MarshalYAML(c) } // UnmarshalYAML implements the yaml.Unmarshaler interface. -func (c *Credential) UnmarshalYAML(unmarshal func(interface{}) error) error { +func (c *CredentialUnion) UnmarshalYAML(unmarshal func(interface{}) error) error { return typeobj.UnmarshalYAML(c, unmarshal) } -// ErrNotSelfVerifying is returned from the SelfVerify method of Credential when -// the Credential does not implement the SelfVerifyingCredential interface. It -// may also be returned from the SelfVerify method of the -// SelfVerifyingCredential itself, if the Credential can only self-verify under +// ErrNotSelfVerifying is returned from the SelfVerify method of CredentialUnion +// when the credential does not implement the SelfVerifyingCredential interface. +// It may also be returned from the SelfVerify method of the +// SelfVerifyingCredential itself, if the credential can only self-verify under // certain circumstances. type ErrNotSelfVerifying struct { // Subject is a descriptor of the value which could not be verified. It may @@ -51,16 +50,16 @@ func (e ErrNotSelfVerifying) Error() string { return fmt.Sprintf("%s cannot verify itself", e.Subject) } -// SelfVerify will attempt to cast the Credential as a SelfVerifyingCredential, +// SelfVerify will attempt to cast the credential as a SelfVerifyingCredential, // and returns the result of the SelfVerify method being called on it. -func (c Credential) SelfVerify(data []byte) error { +func (c CredentialUnion) SelfVerify(data []byte) error { el, _, err := typeobj.Element(c) if err != nil { return err } else if selfVerifyingCred, ok := el.(SelfVerifyingCredential); !ok { - return ErrNotSelfVerifying{Subject: fmt.Sprintf("Credential of type %T", el)} + return ErrNotSelfVerifying{Subject: fmt.Sprintf("credential of type %T", el)} } else if err := selfVerifyingCred.SelfVerify(data); err != nil { - return fmt.Errorf("self-verifying Credential of type %T: %w", el, err) + return fmt.Errorf("self-verifying credential of type %T: %w", el, err) } return nil } diff --git a/sigcred/credential_test.go b/sigcred/credential_test.go index cde2535..5471b39 100644 --- a/sigcred/credential_test.go +++ b/sigcred/credential_test.go @@ -14,12 +14,12 @@ func TestSelfVerifyingCredentials(t *testing.T) { tests := []struct { descr string - mkCred func(toSign []byte) (Credential, error) + mkCred func(toSign []byte) (CredentialUnion, error) expErr bool }{ { descr: "pgp sig no body", - mkCred: func(toSign []byte) (Credential, error) { + mkCred: func(toSign []byte) (CredentialUnion, error) { privKey, _ := TestSignifierPGP("", false, rand) return privKey.Sign(nil, toSign) }, @@ -27,7 +27,7 @@ func TestSelfVerifyingCredentials(t *testing.T) { }, { descr: "pgp sig with body", - mkCred: func(toSign []byte) (Credential, error) { + mkCred: func(toSign []byte) (CredentialUnion, error) { privKey, _ := TestSignifierPGP("", true, rand) return privKey.Sign(nil, toSign) }, diff --git a/sigcred/pgp.go b/sigcred/pgp.go index fdfa449..f538151 100644 --- a/sigcred/pgp.go +++ b/sigcred/pgp.go @@ -38,7 +38,7 @@ func (c *CredentialPGPSignature) SelfVerify(data []byte) error { } sig := SignifierPGP{Body: c.PubKeyBody} - return sig.Verify(nil, data, Credential{PGPSignature: c}) + return sig.Verify(nil, data, CredentialUnion{PGPSignature: c}) } type pgpKey struct { @@ -59,9 +59,9 @@ func newPGPPubKey(r io.Reader) (pgpKey, error) { return pgpKey{entity: entity}, nil } -func (s pgpKey) Sign(_ fs.FS, data []byte) (Credential, error) { +func (s pgpKey) Sign(_ fs.FS, data []byte) (CredentialUnion, error) { if s.entity.PrivateKey == nil { - return Credential{}, errors.New("private key not loaded") + return CredentialUnion{}, errors.New("private key not loaded") } h := sha256.New() @@ -70,15 +70,15 @@ func (s pgpKey) Sign(_ fs.FS, data []byte) (Credential, error) { sig.Hash = crypto.SHA256 sig.PubKeyAlgo = s.entity.PrimaryKey.PubKeyAlgo if err := sig.Sign(h, s.entity.PrivateKey, nil); err != nil { - return Credential{}, fmt.Errorf("signing data: %w", err) + return CredentialUnion{}, fmt.Errorf("signing data: %w", err) } body := new(bytes.Buffer) if err := sig.Serialize(body); err != nil { - return Credential{}, fmt.Errorf("serializing signature: %w", err) + return CredentialUnion{}, fmt.Errorf("serializing signature: %w", err) } - return Credential{ + return CredentialUnion{ PGPSignature: &CredentialPGPSignature{ PubKeyID: s.entity.PrimaryKey.KeyIdString(), Body: body.Bytes(), @@ -86,7 +86,7 @@ func (s pgpKey) Sign(_ fs.FS, data []byte) (Credential, error) { }, nil } -func (s pgpKey) Signed(_ fs.FS, cred Credential) (bool, error) { +func (s pgpKey) Signed(_ fs.FS, cred CredentialUnion) (bool, error) { if cred.PGPSignature == nil { return false, nil } @@ -94,7 +94,7 @@ func (s pgpKey) Signed(_ fs.FS, cred Credential) (bool, error) { return cred.PGPSignature.PubKeyID == s.entity.PrimaryKey.KeyIdString(), nil } -func (s pgpKey) Verify(_ fs.FS, data []byte, cred Credential) error { +func (s pgpKey) Verify(_ fs.FS, data []byte, cred CredentialUnion) error { credSig := cred.PGPSignature if credSig == nil { return fmt.Errorf("SignifierPGPFile cannot verify %+v", cred) @@ -145,7 +145,7 @@ func (s pgpKey) userID() (*packet.UserId, error) { return identity.UserId, nil } -func anonPGPSignifier(pgpKey pgpKey, sigInt SignifierInterface) (SignifierInterface, error) { +func anonPGPSignifier(pgpKey pgpKey, sig Signifier) (Signifier, error) { keyID := pgpKey.entity.PrimaryKey.KeyIdString() userID, err := pgpKey.userID() if err != nil { @@ -158,20 +158,20 @@ func anonPGPSignifier(pgpKey pgpKey, sigInt SignifierInterface) (SignifierInterf } return signifierMiddleware{ - SignifierInterface: sigInt, - signCallback: func(cred *Credential) { + Signifier: sig, + signCallback: func(cred *CredentialUnion) { cred.PGPSignature.PubKeyBody = string(pubKeyBody) cred.AnonID = fmt.Sprintf("%s %q", keyID, userID.Email) }, }, nil } -// TestSignifierPGP returns a direct implementation of the SignifierInterface -// which uses a random private key generated in memory, as well as an armored -// version of its public key. +// TestSignifierPGP returns a direct implementation of Signifier which uses a +// random private key generated in memory, as well as an armored version of its +// public key. // // NOTE that the key returned is very weak, and should only be used for tests. -func TestSignifierPGP(name string, anon bool, randReader io.Reader) (SignifierInterface, []byte) { +func TestSignifierPGP(name string, anon bool, randReader io.Reader) (Signifier, []byte) { entity, err := openpgp.NewEntity(name, "", name+"@example.com", &packet.Config{ Rand: randReader, RSABits: 512, @@ -209,7 +209,7 @@ type SignifierPGP struct { Path string `yaml:"path,omitempty"` } -var _ SignifierInterface = SignifierPGP{} +var _ Signifier = SignifierPGP{} func cmdGPG(stdin []byte, args ...string) ([]byte, error) { args = append([]string{"--openpgp"}, args...) @@ -229,8 +229,8 @@ func cmdGPG(stdin []byte, args ...string) ([]byte, error) { // // If this is being called for an anonymous user to use, then anon can be set to // true. This will have the effect of setting the PubKeyBody and AnonID of all -// produced Credentials. -func LoadSignifierPGP(keyID string, anon bool) (SignifierInterface, error) { +// produced credentials. +func LoadSignifierPGP(keyID string, anon bool) (Signifier, error) { pubKey, err := cmdGPG(nil, "-a", "--export", keyID) if err != nil { return nil, fmt.Errorf("loading public key: %w", err) @@ -270,19 +270,19 @@ func (s SignifierPGP) load(fs fs.FS) (pgpKey, error) { // Sign will sign the given arbitrary bytes using the private key corresponding // to the pgp public key embedded in this Signifier. -func (s SignifierPGP) Sign(fs fs.FS, data []byte) (Credential, error) { +func (s SignifierPGP) Sign(fs fs.FS, data []byte) (CredentialUnion, error) { sigPGP, err := s.load(fs) if err != nil { - return Credential{}, err + return CredentialUnion{}, err } keyID := sigPGP.entity.PrimaryKey.KeyIdString() sig, err := cmdGPG(data, "--detach-sign", "--local-user", keyID) if err != nil { - return Credential{}, fmt.Errorf("signing with pgp key: %w", err) + return CredentialUnion{}, fmt.Errorf("signing with pgp key: %w", err) } - return Credential{ + return CredentialUnion{ PGPSignature: &CredentialPGPSignature{ PubKeyID: keyID, Body: sig, @@ -292,7 +292,7 @@ func (s SignifierPGP) Sign(fs fs.FS, data []byte) (Credential, error) { // Signed returns true if the private key corresponding to the pgp public key // embedded in this Signifier was used to produce the given Credential. -func (s SignifierPGP) Signed(fs fs.FS, cred Credential) (bool, error) { +func (s SignifierPGP) Signed(fs fs.FS, cred CredentialUnion) (bool, error) { sigPGP, err := s.load(fs) if err != nil { return false, err @@ -303,7 +303,7 @@ func (s SignifierPGP) Signed(fs fs.FS, cred Credential) (bool, error) { // Verify asserts that the given signature was produced by this key signing the // given piece of data. -func (s SignifierPGP) Verify(fs fs.FS, data []byte, cred Credential) error { +func (s SignifierPGP) Verify(fs fs.FS, data []byte, cred CredentialUnion) error { sigPGP, err := s.load(fs) if err != nil { return err diff --git a/sigcred/pgp_test.go b/sigcred/pgp_test.go index 9942582..4667440 100644 --- a/sigcred/pgp_test.go +++ b/sigcred/pgp_test.go @@ -15,17 +15,17 @@ import ( func TestPGPVerification(t *testing.T) { tests := []struct { descr string - init func(pubKeyBody []byte) (SignifierInterface, fs.FS) + init func(pubKeyBody []byte) (Signifier, fs.FS) }{ { descr: "SignifierPGP Body", - init: func(pubKeyBody []byte) (SignifierInterface, fs.FS) { + init: func(pubKeyBody []byte) (Signifier, fs.FS) { return SignifierPGP{Body: string(pubKeyBody)}, nil }, }, { descr: "SignifierPGP Path", - init: func(pubKeyBody []byte) (SignifierInterface, fs.FS) { + init: func(pubKeyBody []byte) (Signifier, fs.FS) { pubKeyPath := "some/dir/pubkey.asc" fs := fs.Stub{pubKeyPath: pubKeyBody} return SignifierPGP{Path: pubKeyPath}, fs diff --git a/sigcred/signifier.go b/sigcred/signifier.go index 66922d6..3246c7a 100644 --- a/sigcred/signifier.go +++ b/sigcred/signifier.go @@ -5,72 +5,73 @@ import ( "dehub.dev/src/dehub.git/typeobj" ) -// Signifier reprsents a single signing method being defined in the Config. Only -// one field should be set on each Signifier. -type Signifier struct { +// Signifier describes the methods that all signifiers must implement. +type Signifier interface { + // Sign returns a credential containing a signature of the given data. + // + // tree can be used to find the Signifier at a particular snapshot. + Sign(fs.FS, []byte) (CredentialUnion, error) + + // Signed returns true if the Signifier was used to sign the credential. + Signed(fs.FS, CredentialUnion) (bool, error) + + // Verify asserts that the Signifier produced the given credential for the + // given data set, or returns an error. + // + // tree can be used to find the Signifier at a particular snapshot. + Verify(fs.FS, []byte, CredentialUnion) error +} + +// SignifierUnion represents a single signifier for an account. Only one field +// should be set on each SignifierUnion. +type SignifierUnion struct { PGPPublicKey *SignifierPGP `type:"pgp_public_key"` - // PGPPublicKeyFile is deprecated, only PGPPublicKey should be used - PGPPublicKeyFile *SignifierPGPFile `type:"pgp_public_key_file"` + // LegacyPGPPublicKeyFile is deprecated, only PGPPublicKey should be used + LegacyPGPPublicKeyFile *SignifierPGPFile `type:"pgp_public_key_file"` } // MarshalYAML implements the yaml.Marshaler interface. -func (s Signifier) MarshalYAML() (interface{}, error) { +func (s SignifierUnion) MarshalYAML() (interface{}, error) { return typeobj.MarshalYAML(s) } // UnmarshalYAML implements the yaml.Unmarshaler interface. -func (s *Signifier) UnmarshalYAML(unmarshal func(interface{}) error) error { +func (s *SignifierUnion) UnmarshalYAML(unmarshal func(interface{}) error) error { if err := typeobj.UnmarshalYAML(s, unmarshal); err != nil { return err } // TODO deprecate PGPPublicKeyFile - if s.PGPPublicKeyFile != nil { - s.PGPPublicKey = &SignifierPGP{Path: s.PGPPublicKeyFile.Path} - s.PGPPublicKeyFile = nil + if s.LegacyPGPPublicKeyFile != nil { + s.PGPPublicKey = &SignifierPGP{Path: s.LegacyPGPPublicKeyFile.Path} + s.LegacyPGPPublicKeyFile = nil } return nil } -// Interface returns the SignifierInterface instance encapsulated by this -// Signifier object. +// Signifier returns the Signifier instance encapsulated by this SignifierUnion. +// +// This will panic if no Signifier field is populated. // // accountID is given so as to automatically fill the AccountID field of -// Credentials returned from Sign, since the underlying implementation doesn't +// credentials returned from Sign, since the underlying implementation doesn't // know what account it's signing for. -func (s Signifier) Interface(accountID string) (SignifierInterface, error) { +func (s SignifierUnion) Signifier(accountID string) Signifier { el, _, err := typeobj.Element(s) if err != nil { - return nil, err + panic(err) } - return accountSignifier(accountID, el.(SignifierInterface)), nil -} - -// SignifierInterface describes the methods that all Signifiers must implement. -type SignifierInterface interface { - // Sign returns a Credential containing a signature of the given data. - // - // tree can be used to find the Signifier at a particular snapshot. - Sign(fs fs.FS, data []byte) (Credential, error) - - // Signed returns true if the Signifier was used to sign the Credential. - Signed(fs fs.FS, cred Credential) (bool, error) - - // Verify asserts that the Signifier produced the given Credential for the - // given data set, or returns an error. - // - // tree can be used to find the Signifier at a particular snapshot. - Verify(fs fs.FS, data []byte, cred Credential) error + return accountSignifier(accountID, el.(Signifier)) } type signifierMiddleware struct { - SignifierInterface - signCallback func(*Credential) + Signifier + signCallback func(*CredentialUnion) } -func (sm signifierMiddleware) Sign(fs fs.FS, data []byte) (Credential, error) { - cred, err := sm.SignifierInterface.Sign(fs, data) +func (sm signifierMiddleware) Sign(fs fs.FS, data []byte) (CredentialUnion, error) { + cred, err := sm.Signifier.Sign(fs, data) if err != nil || sm.signCallback == nil { return cred, err } @@ -78,16 +79,16 @@ func (sm signifierMiddleware) Sign(fs fs.FS, data []byte) (Credential, error) { return cred, nil } -// accountSignifier wraps a SignifierInterface to always set the accountID field -// on Credentials it produces via the Sign method. +// accountSignifier wraps a Signifier to always set the accountID field on +// credentials it produces via the Sign method. // -// TODO accountSignifier shouldn't be necessary, it's very ugly. Which indicates -// that Credential probably shouldn't have AccountID on it, which makes sense. -// Some refactoring is required here. -func accountSignifier(accountID string, sigInt SignifierInterface) SignifierInterface { +// TODO accountSignifier shouldn't be necessary, it's very ugly. It indicates +// that CredentialUnion probably shouldn't have AccountID on it, which makes +// sense. Some refactoring is required here. +func accountSignifier(accountID string, sig Signifier) Signifier { return signifierMiddleware{ - SignifierInterface: sigInt, - signCallback: func(cred *Credential) { + Signifier: sig, + signCallback: func(cred *CredentialUnion) { cred.AccountID = accountID }, } diff --git a/yamlutil/yamlutil.go b/yamlutil/yamlutil.go index f4ef7b4..2fd314e 100644 --- a/yamlutil/yamlutil.go +++ b/yamlutil/yamlutil.go @@ -10,6 +10,10 @@ import ( // string. type Blob []byte +func (b Blob) String() string { + return base64.StdEncoding.EncodeToString([]byte(b)) +} + // MarshalYAML implements the yaml.Marshaler interface. func (b Blob) MarshalYAML() (interface{}, error) { return base64.StdEncoding.EncodeToString([]byte(b)), nil