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