commit 7c891bd5f2d48a345c390bd1264dacdf14e27394 Author: mediocregopher <> Date: Sat Feb 15 15:13:50 2020 -0700 Initial commit, can create master commit and verify previous master commits message: Initial commit, can create master commit and verify previous master commits change_hash: ADgeVBdfi1hA0TTDrBIkYHaQQYoxZaInZz1p/BAH35Ng credentials: - type: pgp_signature pub_key_id: 95C46FA6A41148AC body: iQIzBAABAgAdFiEEJ6tQKp6olvZKJ0lwlcRvpqQRSKwFAl5IbRgACgkQlcRvpqQRSKzWjg/+P0a3einWQ8wFUe05qXUbmMQ4K86Oa4I85pF6kubZlFy/UbcjiPnTPRMKAhmGZi4WCz1sW1F2al4qKvtq3nvn6+hZY8dj0SjPgGG2lkMMLEVy1hjsO7d9S9ZEfUv0cHOcvkphgVQk+InkegBXvFS45mwKQLDOiW5tPcTFDHTHBmC/nlCV/sKCrZEmQGU7KaELJKOf26LSY2zXe6fbVCa8njpIycYS7Wulu2OODcI5n6Ye2U6DvxN6MvuNvziyX7VMePS1xEdJYpltsNMhSkMMGLU7dovxbrhD617uwOsm1847YX9HTJ3Ixs+M0yobHmz8ob4OBcZx8r3AoiyDo+HNMmAZ96ue8pPHmI+2O9jEmbmbH61yq4crhUVAP8PncSTdq0tiYKj/zaSTJ8CT2W0uicX/3v9EtIFn0thqe/qZzHh6upixvpXDpNjZZ5SxiVm8MITnWzInQRbo9yvFsfgd7LqMGKZeGv5q5rgNTRM4fwGrJDuslwj8V2B4uw1ofPncL+LHmXArXWiewvvJFU2uRpfvsl+u4is2dl2SGVpe7ixm+a088gllOQCMRgLbuaN8dQ/eqdkfdxUg+SYQlx6vykrdJOSQrs9zaX/JuxnaNBTi/yLY1FqFXaXBGID6qX1cnPilw+J6vEZYt1MBtzXX+UEjHyVowIhMRsnts6Wq3Z8= account: mediocregopher diff --git a/.dehub/config.yml b/.dehub/config.yml new file mode 100644 index 0000000..d844302 --- /dev/null +++ b/.dehub/config.yml @@ -0,0 +1,14 @@ +--- +accounts: + - id: mediocregopher + signifiers: + - type: pgp_public_key_file + path: ".dehub/mediocregopher.asc" + +access_controls: + - pattern: "**" + condition: + type: signature + account_ids: + - mediocregopher + count: 100% diff --git a/.dehub/mediocregopher.asc b/.dehub/mediocregopher.asc new file mode 100644 index 0000000..e28bfb8 --- /dev/null +++ b/.dehub/mediocregopher.asc @@ -0,0 +1,51 @@ +-----BEGIN PGP PUBLIC KEY BLOCK----- + +mQINBFKnIg4BEAC5GIlQMaXZkx/Ppt32yPztV9xhqy+NU2g9MeXnxXo6en+K7OCu ++w0DE5CRLzaq5gLOUMOL5hz+TNwWfCaO7NixYPvsTwfvb41K8kT3UrdHtOAgmEZM +RPa9PyIEFF3WPJa5QfLoYPVSKmryQprxdTxfMO05Pa8E99pVYJXENH6p/YgGj6Td +4kK1d43EzOrvTQlPtYfZnmESjQ8qqlrPfoFBCutfhNHuJu+fAR7sJa123pYEaeiu +eJZjqxuM7yihdEp+umQGl9QsFlAvzvHPE/heO26AV11f+RRqzuYklf/L2NGGkOpi +XpX6zK3YMW/8AHiXl+ev6eD65pN48yhzlvxiVCERBFhhSeK2GIsVtkk8ZLJ5w5PL +xCm3RFR8+M/gjeAmYm0zZix3uPF5AD88KU1ffKeCZrzzzOHbePpdx3t5fapUXVcv +PTNATFbCWSVUQgMztV7nhpL5B7HV2d7V1GxtrlrvR//K388Los2/mq107+0yRhSc +OkOtclgQ0d93EqmjE5gRyg7lHts2r3KKhzuUdseJoOCgazvN5dlnuUZ4WAHDyXmY +VOOtjpAmRjU7ZfTHx+rXSh76Y7gy/5Lc6PbakNs15wszGKPv6FRWV/qHmwD8HVED +CGwaxm9Q/r0YCtyBY0HTsBiBdHDSFUtmuyfDDVrgPFIBew0l+iL8lUj6dQARAQAB +tClCcmlhbiBQaWNjaWFubyA8bWVkaW9jcmVnb3BoZXJAZ21haWwuY29tPokCOQQT +AQIAIwIbAwcLCQgHAwIBBhUIAgkKCwQWAgMBAh4BAheABQJWiK/oAAoJEJXEb6ak +EUisCD8P/1FBrmfko5b5om2xUquzPmLwAyzWz4KUxAp8PGI0hMit21v/+u5yoSLC +Pr6JBikbJ+A1VjJUBtAF6uPJ8zXLkelb8jQc/leySEZ95p71+7M4XHtgtTVq8stl +Sj+N3Ccsv4KT20+c62O3CV/hQlHJeWXN/L03WyViMUW6pjIaohT7hN31djJspfZD +ZqYn/WZNfFf/InfMXLnYsyfKnf2F+/uh5t80CvaYfD/tavJYbLVxJ0baOZ+NaUh6 +P9W4mBEQofoU6JVH4jHM+dOZYL6ZNDmwJDxt/6bw4LLhxm45VeCxqXsWdWsEs4zG +uaUGmnDaO1o2OThr3G6aW61RjmfaIhFpwtbNVBnqyj4Gr26gARR2qeSeo2I24mm9 +QxMW3LpQfEUS2tjgty+JYGQctYFzGPYPT6YEezkmt6q3K9czhY5NVzO7H/G0V3dT +nSA9qWGHrfTvriHiCVn29bR4GD2jAXiBkpgt8T77eRwnR6CwsDMX/oL7SdiRWQzs +oGIk5pXFjKKZgRcoP/HpnknuCp9syUhn0ZL9MRMGuEp6FMB62cmrIF5uGWQR1FTk +bpP3f/2XvC54+boSeHgZsTJfYz2fVJBnWXsP9Ysnk7TcpSXbQ2XWP/M0CF17BnVc +HkJeNf7YBHELlXyhb3d5ZDfPVwvWb3wOdIpJjh/JexTM+gF5TReVuQINBFKnIg4B +EADudzImkE0O0dOLVO+OPWIGBnE3cRYEIG1IDnpJgjpyj+hpqFgzP+MfqUzhQAvv +Q0ZbB5Sb3+/ZDPoY2vQx1SXaLT4lPlyb/dKqX20VEQb2FRY9SgtA3ZV1kMexChNZ +5jGmXKz9RbJyGUz5Ms0pnozjwe5h/5iSKhT3DMNOvdNY1BOca00ycPpNUkNfX8j8 +kuNc1qRzYAaNsAmGVLGhMSIi5v7C2LK0lEE313+c4eP/eWG+6Pexqjr29rVYXZ3s +99TuMcfWhJ6fbKWKmMBS0kNMZDEemoE7RAcDyprFgvSJFj+K0HCDq4F4954hpKcy +FJEY0q5beYOQ8pd/nBNsxDpWIu+/L+JYoXvfJcjCLTDi5IjMBz6PXSHYrnneb9+3 +gDXUloFcF4fjadT/zFjYQKGRmj0/wf1HACs/Zht9qv4uc9MSijy/bDfRZ8vVb/wl +0MnuaDhRFlrmV7MSD0aRW6amUAtnJVhgD92WUJfQs1yIXS7ZpFEgAopJMXlEnxlv +z7UQn4dGSEqrcbk7Ge2wX9cMcVEPzFhNfFU9MNBDaXz60mILAZ8w3ukMkqb/kqVx +T+1nkbKMhbiD7iuILjyKvN8OC+qbsXckiSgv+KcS1cb0W0An5wugQgNCUrU1thN+ +LShRq0NiQqQj1wC4EHs4+lRnIkP+rIz1h300mIxestpPNQARAQABiQIfBBgBAgAJ +AhsMBQJWiLBmAAoJEJXEb6akEUisCvUQAKfK1Zfzs+GsNBLnpKNVYn8xKByDZZkH +vYf0Nq1ySPX3H9wvkCJ+dXOZChG9dzIvpqaL3Twi/fGliyh6o0jKlUOkHdlxya/w +owCMQ5RMC+Z8V+HqrJczVeF6altq4rx5TDHeV9g0tkUkyA0l3r+RUBt63sNTdZLt +fLGkWCUh5yfxxsGlpVFBE7GF0uagi3/ISejotOgVfo8ZY8RMVK0UTB5fNXwH/Clh +t31JWgNTotUoacokufWiEVJ0yzWLXJ+bDIvX2nrEPUJG8yxyeVbzCVEAX0qwxE81 +XR/BTyJLbzGrE+uhG7H6noM8wkoM+N1tHiRv3B1x++YCyuMQ98sBFZUaln+OO1G9 +RuW5M6+RQdDE/XlH27WTbHLWtPZkcNS25YTleY89bLvOguKc0Gw5O3k66rB8r/xO +O0lgnEI0I7EJLTODO++f4iyoUmPkDknXe5PoBbHhQqe9QWbzh2U5/B8Abn7zxSJu +Hc4CGlHq+ztlnbaWWvtBe9t29o8iH+wGiHi+qqT9RNr97NDONMt2U5xbsbmaFvxN +GhO2r3xQsROxHhPO6nHWFThBE+GjUdBQAKUV2UsK/dttap8DjIgfb1rfJxPMQShF +TQkMRx4JkFjWH5QripM+uFYPtChI3yZaf85s4r6pHkLSL/qhitWoW2hu43La5hrV +uIQiD0KB791m +=OXq+ +-----END PGP PUBLIC KEY BLOCK----- diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bb70765 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +dehub diff --git a/SPEC.md b/SPEC.md new file mode 100644 index 0000000..110a0f5 --- /dev/null +++ b/SPEC.md @@ -0,0 +1,195 @@ +# .dehub + +The `.dehub` directory contains all meta information related to +decentralized repository management and access control. + +## config.yml + +The `.dehub/config.yml` file takes the following structure: + +```yaml +# accounts defines all accounts which are known to the repo. +accounts: + + # Each account is an object with an id and at least one identifier. The id + # must be unique for each account. + - id: some_user_id: + + # signifiers describes different methods the account might use to + # identify itself. Generally, these will be different public keys which + # commits will be signed with. At least one is required. + signifiers: + - type: "pgp_public_key" + body: "FULL PGP PUBLIC KEY STRING" + + - type: "pgp_public_key_file" + path: ".dehub/some_user_id.asc" + + - type: "keybase" + user: "some_keybase_user_id" + +# access_controls defines under what conditions different files in the repo may +# be modified. For each file modified in a commit, all access control patterns +# are applied sequentially until one matches, and the associated access control +# conditions are checked. A commit is only allowed if the conditions of all +# modified files are met. +access_controls: + + # pattern is a glob pattern describing what files this access control + # applies to. Single star matches all characters except path separators, + # double star matches everything. + - pattern: ".dehub/**" + + # signature conditions indicate that a commit must be signed by one or + # more accounts to be allowed. + condition: + type: signature + + # account_ids lists all accounts whose signature will count towards + # meeting the condition + account_ids: + - some_user_id + + # count describes how many signatures are required. It can be either a + # contrete integer (e.g. 2, meaning any 2 accounts listed by + # account_ids) or a percent. + count: 100% + + # This catch-all pattern for the rest of the repo requires that changes to + # any files not under `.dehub/` are signed by at least one of the + # defined accounts. + - pattern: "**" + condition: + type: signature + any_account: true # indicates any account defined in accounts is valid + count: 1 +``` + +# Master commit + +All new commits being appended to the HEAD of the `master` branch are subject to +the following requirements: + +* Must conform to all requirements defined by the `access_controls` section of + the `config.yml`, as found in the HEAD. If the commit is the initial commit of + the repo then it instead uses the `config.yml` found in itself. + +* Must not be a merge commit (this may be amended later, but at present it + simplifies implementation). + +* The commit message must conform to the format and semantics defined below. + +## Master Commit Message + +The commit message for a commit being appended to the HEAD of the `master` +branch must conform to the following format: a single line (the message head) +giving a short description of the change, then two newlines, then a body which +is a yaml formatted string: + +```yaml +This is the message head. It will be re-iterated within the yaml body. + +# Now the yaml body begins +--- +message: > + This is the message head. It will be re-iterated within the yaml body. + + The rest of this field is for the message body, which corresponds to the + body of a normal commit message which might give a more long-form + explanation of the commit's changes. + + Since the message is used in generating the signature it's necessary for it + to be encoded here fully formed, even though the message head is then + duplicated. Otherwise the exact bytes of the message would be ambiguous. + This situation is ugly, but not unbearable. + +# See the Commit Signatures section below for how this is computed. The +# change_hash is always recomputed when verifying a commit, but is reproduced in +# the commit message itself for cases of forward compatibility, e.g. if the +algorithm to compute the hash changes. +change_hash: XXX + +# Credentials are the set of credentials which count towards requirements +# specified in the `access_controls` section of the `config.yml` file. +credentials: + + - type: pgp_signature + account_id: some_user_id + pub_key_id: XXX + body: "base-64 signature body" +``` + +## Commit Signatures + +When a commit is being signed by a signifier there is an expected data format +for the data to be signed. The format is a SHA-256 hash of the following pieces +of data concatenated together (the "change_hash"): + +* A uvarint indicating the number of bytes in the commit message. +* The message. +* A uvarint indicating the number of files changed. +* For each file changed in the commit, ordered lexographically-ascending based + on its full relative path within the repo, the following is then written: + * A uvarint indicating the length of the full relative path of the file + within the repo. + * The full relative path of the file within the repo. + * A little-endian uint32 representing the previous file mode of the file (or 0 + if the file is being inserted). + * The 20-byte SHA1 hash of the previous version of the file's contents (or 20 + 0 bytes if the file is being inserted). + * A little-endian uint32 representing the new file mode of the file (or 0 + if the file is being deleted). + * The 20-byte SHA1 hash of the new version of the file's contents (or 20 + 0 bytes if the file is being deleted). + +The raw output from the SHA-256 is then prepended with a `0` byte (for forward +compatibility) and signed, and the result used as the signature body. + +# Merge Requests + +A merge request (MR) may be pushed to the repository as a new branch at any +time. All MR branch names follow the naming convention `DHMR-short-description`. +An MR branch has the following qualities: + +* Meta commits (see sub-section) will only contain a commit message head/body, + but no file changes. + +* The most recent substantial commit (as opposed to meta commits) should always + contain the full commit message head and body. + +## Meta Commits + +Meta commits are those which add information about the changes being requested, +but do not modify the changes themselves. + +### Signature Commits + +Signature commits sign the changes requested in order to count towards their +access control requirements. The message head of these are arbitrary, but the +body must be formatted as such: + +```yaml +# This object matches the one found in the `credentials` section of the master +# commit message. +type: pgp_signature +account_id: some_user_id ``` +pub_key_id: XXX +body: "base-64 signature body" # see Commit Signatures sub-section. +``` + +If a signature commit is added to a MR branch, and a substantial commit is +added after it, then that signature commit will no longer be valid, as it was +only signing the the prior changeset. The signer will need to create and push a +new signature commit, if they agree with the new changes. + +## Merging MRs + +When an MR has accumulated enough meta commits to fulfuill access control +requirements it may be coalesced into a single commit destined for the master +branch. See the Master Commit Message sub-section for details on how commits in +the master branch must be formatted. + +# TODO + +* access control patterns related to who may push to MR branches, and what types + of commits they can push. diff --git a/accessctl/access_control.go b/accessctl/access_control.go new file mode 100644 index 0000000..7d50428 --- /dev/null +++ b/accessctl/access_control.go @@ -0,0 +1,53 @@ +package accessctl + +import ( + "fmt" + + "github.com/bmatcuk/doublestar" +) + +// AccessControl represents an access control object being defined in the +// Config. +type AccessControl struct { + Pattern string `yaml:"pattern"` + Condition Condition `yaml:"condition"` +} + +// ErrNoApplicableAccessControls is returned from ApplicableAccessControls when +// a changed path has no applicable AccessControls which match it. +type ErrNoApplicableAccessControls struct { + Path string +} + +func (err ErrNoApplicableAccessControls) Error() string { + return fmt.Sprintf("no AccessControls which apply to changed file %q", err.Path) +} + +// ApplicableAccessControls returns a subset of the given AccessControls which +// are applicable to the given file paths (ie those whose Conditions must be met +// in order for the changes to go through. +func ApplicableAccessControls(accessControls []AccessControl, filesChanged []string) ([]AccessControl, error) { + applicableSet := map[AccessControl]struct{}{} + for _, path := range filesChanged { + var any bool + for _, ac := range accessControls { + if ok, err := doublestar.PathMatch(ac.Pattern, path); err != nil { + return nil, fmt.Errorf("error matching path %q to patterrn %q: %w", + path, ac.Pattern, err) + } else if ok { + applicableSet[ac] = struct{}{} + any = true + break + } + } + if !any { + return nil, ErrNoApplicableAccessControls{Path: path} + } + } + + applicable := make([]AccessControl, 0, len(applicableSet)) + for ac := range applicableSet { + applicable = append(applicable, ac) + } + return applicable, nil +} diff --git a/accessctl/access_control_test.go b/accessctl/access_control_test.go new file mode 100644 index 0000000..07326ba --- /dev/null +++ b/accessctl/access_control_test.go @@ -0,0 +1,118 @@ +package accessctl + +import ( + "errors" + "reflect" + "sort" + "testing" +) + +func TestApplicableAccessControls(t *testing.T) { + tests := []struct { + descr string + patterns, filesChanged []string + exp []string + expErrPath string + }{ + { + descr: "empty input empty output", + }, + { + descr: "empty patterns", + filesChanged: []string{"foo", "bar"}, + expErrPath: "foo", + }, + { + descr: "empty filesChanged", + patterns: []string{"patternA", "patternB"}, + }, + { + descr: "no applicable files", + filesChanged: []string{"foo"}, + patterns: []string{"bar"}, + expErrPath: "foo", + }, + { + descr: "all applicable files", + filesChanged: []string{"foo", "bar"}, + patterns: []string{"**"}, + exp: []string{"**"}, + }, + { + descr: "pattern precedent", + filesChanged: []string{"foo"}, + patterns: []string{"foo", "**"}, + exp: []string{"foo"}, + }, + { + descr: "pattern precedent inv", + filesChanged: []string{"foo"}, + patterns: []string{"**", "foo"}, + exp: []string{"**"}, + }, + { + descr: "individual matches", + filesChanged: []string{"foo", "bar/baz"}, + patterns: []string{"foo", "bar/baz"}, + exp: []string{"foo", "bar/baz"}, + }, + { + descr: "star match dir", + filesChanged: []string{"foo", "bar/baz"}, + patterns: []string{"foo", "bar/*"}, + exp: []string{"foo", "bar/*"}, + }, + { + descr: "star not match dir", + filesChanged: []string{"foo", "bar/baz/biz"}, + patterns: []string{"foo", "bar/*"}, + expErrPath: "bar/baz/biz", + }, + { + descr: "doublestar match dir", + filesChanged: []string{"foo", "bar/bar", "bar/baz/biz"}, + patterns: []string{"foo", "bar/**"}, + exp: []string{"foo", "bar/**"}, + }, + } + + for _, test := range tests { + t.Run(test.descr, func(t *testing.T) { + accessControls := make([]AccessControl, len(test.patterns)) + for i := range test.patterns { + accessControls[i] = AccessControl{Pattern: test.patterns[i]} + } + + out, err := ApplicableAccessControls(accessControls, test.filesChanged) + if err != nil && test.expErrPath == "" { + t.Fatalf("unexpected error: %v", err) + } else if test.expErrPath != "" { + if noAppErr := (ErrNoApplicableAccessControls{}); !errors.As(err, &noAppErr) { + t.Fatalf("expected ErrNoApplicableAccessControls for path %q, but got %v", test.expErrPath, err) + } else if test.expErrPath != noAppErr.Path { + t.Fatalf("expected ErrNoApplicableAccessControls for path %q, but got one for path %q", test.expErrPath, noAppErr.Path) + } + return + } + + outPatterns := make([]string, len(out)) + for i := range out { + outPatterns[i] = out[i].Pattern + } + + clean := func(s []string) []string { + if len(s) == 0 { + return nil + } + sort.Strings(s) + return s + } + + outPatterns = clean(outPatterns) + test.exp = clean(test.exp) + if !reflect.DeepEqual(outPatterns, test.exp) { + t.Fatalf("expected: %+v\ngot: %+v", test.exp, outPatterns) + } + }) + } +} diff --git a/accessctl/condition.go b/accessctl/condition.go new file mode 100644 index 0000000..7a56354 --- /dev/null +++ b/accessctl/condition.go @@ -0,0 +1,130 @@ +package accessctl + +import ( + "dehub/sigcred" + "dehub/typeobj" + "errors" + "fmt" + "math" + "strconv" + "strings" +) + +// ConditionInterface describes the methods that all Signifiers must implement. +type ConditionInterface interface { + + // Satisfied asserts that the Condition is satisfied by the given set of + // Credentials. If it is not (or something else went wrong) then an error is + // returned. + // + // NOTE that Satisfied assumes the Credential has already been Verify'd. + Satisfied([]sigcred.Credential) error +} + +// Condition represents an access control condition being defined in the Config. +// Only one of its fields may be filled in at a time. +type Condition struct { + Signature *ConditionSignature `type:"signature"` +} + +// MarshalYAML implements the yaml.Marshaler interface. +func (c Condition) MarshalYAML() (interface{}, error) { + return typeobj.MarshalYAML(c) +} + +// UnmarshalYAML implements the yaml.Unmarshaler interface. +func (c *Condition) UnmarshalYAML(unmarshal func(interface{}) error) error { + return typeobj.UnmarshalYAML(c, unmarshal) +} + +// Interface returns the ConditionInterface encapsulated by this Condition +// object. +func (c Condition) Interface() (ConditionInterface, error) { + el, _, err := typeobj.Element(c) + if err != nil { + return nil, err + } + return el.(ConditionInterface), nil +} + +// ConditionSignature represents the configuration of an access control +// condition which requires one or more signatures to be present on a commit. +// +// Either AccountIDs or AccountIDsByMeta must be filled. +type ConditionSignature struct { + AccountIDs []string `yaml:"account_ids,omitempty"` + AnyAccount bool `yaml:"any_account,omitempty"` + Count string `yaml:"count"` +} + +var _ ConditionInterface = ConditionSignature{} + +func (condSig ConditionSignature) targetNum() (int, error) { + if !strings.HasSuffix(condSig.Count, "%") { + return strconv.Atoi(condSig.Count) + } else if condSig.AnyAccount { + return 0, errors.New("cannot use AnyAccount and a percent Count together") + } + + percentStr := strings.TrimRight(condSig.Count, "%") + percent, err := strconv.ParseFloat(percentStr, 64) + if err != nil { + return 0, fmt.Errorf("could not parse Count as percent %q: %w", condSig.Count, err) + } + targetF := float64(len(condSig.AccountIDs)) * percent / 100 + targetF = math.Ceil(targetF) + return int(targetF), nil +} + +// ErrConditionSignatureUnsatisfied is returned from ConditionSignature's +// Satisfied method when the Condition has not been satisfied. +type ErrConditionSignatureUnsatisfied struct { + TargetNumAccounts, NumAccounts int +} + +func (err ErrConditionSignatureUnsatisfied) Error() string { + return fmt.Sprintf("not enough valid signature credentials, requires %d but only had %d", + err.TargetNumAccounts, err.NumAccounts) +} + +// Satisfied asserts that the given Credentials contains enough signatures to be +// satisfied. +func (condSig ConditionSignature) Satisfied(creds []sigcred.Credential) error { + targetN, err := condSig.targetNum() + if err != nil { + return fmt.Errorf("could not compute ConditionSignature target number of accounts: %w", err) + } + + credAccountIDs := map[string]struct{}{} + for _, cred := range creds { + // TODO currently only signature credentials are implemented, so we can + // just assume that the given AccountID has provided a sig. In the + // future this may not be true. + credAccountIDs[cred.AccountID] = struct{}{} + } + + var n int + if condSig.AnyAccount { + // TODO this doesn't actually check that the accounts are defined in the + // Config. + n = len(credAccountIDs) + } else { + targetAccountIDs := map[string]struct{}{} + for _, accountID := range condSig.AccountIDs { + targetAccountIDs[accountID] = struct{}{} + } + for accountID := range targetAccountIDs { + if _, ok := credAccountIDs[accountID]; ok { + n++ + } + } + } + + if n < targetN { + return ErrConditionSignatureUnsatisfied{ + TargetNumAccounts: targetN, + NumAccounts: n, + } + } + return nil +} diff --git a/accessctl/condition_test.go b/accessctl/condition_test.go new file mode 100644 index 0000000..a20fc59 --- /dev/null +++ b/accessctl/condition_test.go @@ -0,0 +1,110 @@ +package accessctl + +import ( + "dehub/sigcred" + "reflect" + "testing" +) + +func TestConditionSignatureSatisfied(t *testing.T) { + tests := []struct { + descr string + cond ConditionSignature + credAccountIDs []string + err error + }{ + { + descr: "no cred accounts", + cond: ConditionSignature{ + AnyAccount: true, + Count: "1", + }, + err: ErrConditionSignatureUnsatisfied{ + TargetNumAccounts: 1, + NumAccounts: 0, + }, + }, + { + descr: "one cred account", + cond: ConditionSignature{ + AnyAccount: true, + Count: "1", + }, + credAccountIDs: []string{"foo"}, + }, + { + descr: "one matching cred account", + cond: ConditionSignature{ + AccountIDs: []string{"foo", "bar"}, + Count: "1", + }, + credAccountIDs: []string{"foo"}, + }, + { + descr: "no matching cred account", + cond: ConditionSignature{ + AccountIDs: []string{"foo", "bar"}, + Count: "1", + }, + credAccountIDs: []string{"baz"}, + err: ErrConditionSignatureUnsatisfied{ + TargetNumAccounts: 1, + NumAccounts: 0, + }, + }, + { + descr: "two matching cred accounts", + cond: ConditionSignature{ + AccountIDs: []string{"foo", "bar"}, + Count: "2", + }, + credAccountIDs: []string{"foo", "bar"}, + }, + { + descr: "one matching cred account, missing one", + cond: ConditionSignature{ + AccountIDs: []string{"foo", "bar"}, + Count: "2", + }, + credAccountIDs: []string{"foo", "baz"}, + err: ErrConditionSignatureUnsatisfied{ + TargetNumAccounts: 2, + NumAccounts: 1, + }, + }, + { + descr: "50 percent matching cred accounts", + cond: ConditionSignature{ + AccountIDs: []string{"foo", "bar", "baz"}, + Count: "50%", + }, + credAccountIDs: []string{"foo", "bar"}, + }, + { + descr: "not 50 percent matching cred accounts", + cond: ConditionSignature{ + AccountIDs: []string{"foo", "bar", "baz"}, + Count: "50%", + }, + credAccountIDs: []string{"foo"}, + err: ErrConditionSignatureUnsatisfied{ + TargetNumAccounts: 2, + NumAccounts: 1, + }, + }, + } + + for _, test := range tests { + t.Run(test.descr, func(t *testing.T) { + creds := make([]sigcred.Credential, len(test.credAccountIDs)) + for i := range test.credAccountIDs { + creds[i].AccountID = test.credAccountIDs[i] + } + + err := test.cond.Satisfied(creds) + if !reflect.DeepEqual(err, test.err) { + t.Fatalf("Satisfied returned %#v\nexpected %#v", err, test.err) + } + }) + } +} diff --git a/change_hash.go b/change_hash.go new file mode 100644 index 0000000..8c4feb0 --- /dev/null +++ b/change_hash.go @@ -0,0 +1,75 @@ +package dehub + +import ( + "crypto/sha256" + "encoding/binary" + "fmt" + "hash" + "sort" + + "gopkg.in/src-d/go-git.v4/plumbing/object" +) + +var ( + defaultHashHelperAlgo = sha256.New +) + +type hashHelper struct { + hash.Hash + varintBuf []byte +} + +// if h is nil it then defaultHashHelperAlgo will be used +func newHashHelper(h hash.Hash) *hashHelper { + if h == nil { + h = defaultHashHelperAlgo() + } + s := &hashHelper{ + Hash: h, + varintBuf: make([]byte, binary.MaxVarintLen64), + } + return s +} + +func (s *hashHelper) writeUint(i uint64) { + n := binary.PutUvarint(s.varintBuf, i) + if _, err := s.Write(s.varintBuf[:n]); err != nil { + panic(fmt.Sprintf("error writing %x to sha256 sum: %v", s.varintBuf[:n], err)) + } +} + +func (s *hashHelper) writeStr(str string) { + s.writeUint(uint64(len(str))) + s.Write([]byte(str)) +} + +func (s *hashHelper) writeTreeDiff(from, to *object.Tree) { + filesChanged, err := calcDiff(from, to) + if err != nil { + panic(err.Error()) + } + + sort.Slice(filesChanged, func(i, j int) bool { + return filesChanged[i].path < filesChanged[j].path + }) + + s.writeUint(uint64(len(filesChanged))) + for _, fileChanged := range filesChanged { + s.writeStr(fileChanged.path) + s.Write(fileChanged.fromMode.Bytes()) + s.Write(fileChanged.fromHash[:]) + s.Write(fileChanged.toMode.Bytes()) + s.Write(fileChanged.toHash[:]) + } + +} + +var changeHashVersion = []byte{0} + +// if h is nil it then defaultHashHelperAlgo will be used +func genChangeHash(h hash.Hash, msg string, from, to *object.Tree) []byte { + s := newHashHelper(h) + s.writeStr(msg) + s.writeTreeDiff(from, to) + return s.Sum(changeHashVersion) +} diff --git a/cmd/dehub/main.go b/cmd/dehub/main.go new file mode 100644 index 0000000..431fc0a --- /dev/null +++ b/cmd/dehub/main.go @@ -0,0 +1,136 @@ +package main + +import ( + "dehub" + "errors" + "flag" + "fmt" + "os" + "strings" + + "gopkg.in/src-d/go-git.v4/plumbing" +) + +type subCmdCtx struct { + repo *dehub.Repo + args []string +} + +var subCmds = []struct { + name, descr string + body func(sctx subCmdCtx) error +}{ + { + name: "commit", + descr: "commits staged changes to the head of the current branch", + body: func(sctx subCmdCtx) error { + flag := flag.NewFlagSet("commit", flag.ExitOnError) + msg := flag.String("msg", "", "Commit message to use") + accountID := flag.String("account-id", "", "Account to sign commit as") + flag.Parse(sctx.args) + + if *msg == "" || *accountID == "" { + return errors.New("-msg and -account-id are both required") + } + + cfg, err := sctx.repo.LoadConfig() + if err != nil { + return err + } + + var account dehub.Account + var ok bool + for _, account = range cfg.Accounts { + if account.ID == *accountID { + ok = true + break + } + } + if !ok { + return fmt.Errorf("account ID %q not found in config", *accountID) + } else if l := len(account.Signifiers); l == 0 || l > 1 { + return fmt.Errorf("account %q has %d signifiers, only one is supported right now", *accountID, l) + } + + sig := account.Signifiers[0] + sigInt, err := sig.Interface() + if err != nil { + return fmt.Errorf("could not cast %+v to SignifierInterface: %w", sig, err) + } + + _, hash, err := sctx.repo.CommitMaster(*msg, *accountID, sigInt) + if err != nil { + return err + } + fmt.Printf("changes committed to HEAD as %s\n", hash) + return nil + }, + }, + { + name: "verify", + descr: "verifies one or more commits as having the proper credentials", + body: func(sctx subCmdCtx) error { + flag := flag.NewFlagSet("verify", flag.ExitOnError) + rev := flag.String("rev", "HEAD", "Revision of commit to verify") + flag.Parse(sctx.args) + + h, err := sctx.repo.GitRepo.ResolveRevision(plumbing.Revision(*rev)) + if err != nil { + return fmt.Errorf("could not resolve revision %q: %w", *rev, err) + } + + if err := sctx.repo.VerifyMasterCommit(*h); err != nil { + return fmt.Errorf("could not verify commit at %q (%s): %w", *rev, *h, err) + } + + fmt.Printf("commit at %q (%s) is good to go!\n", *rev, *h) + return nil + }, + }, +} + +func printHelp() { + fmt.Printf("USAGE: %s [-h]\n\n", os.Args[0]) + fmt.Println("COMMANDS") + for _, subCmd := range subCmds { + fmt.Printf("\t%s : %s\n", subCmd.name, subCmd.descr) + } +} + +func exitErr(err error) { + fmt.Fprintf(os.Stderr, "exiting: %v\n", err) + os.Stderr.Sync() + os.Stdout.Sync() + os.Exit(1) +} + +func main() { + if len(os.Args) < 2 { + printHelp() + return + } + + subCmdName := strings.ToLower(os.Args[1]) + for _, subCmd := range subCmds { + if subCmd.name != subCmdName { + continue + } + + r, err := dehub.OpenRepo(".") + if err != nil { + exitErr(err) + } + + err = subCmd.body(subCmdCtx{ + repo: r, + args: os.Args[2:], + }) + if err != nil { + exitErr(err) + } + return + } + + fmt.Printf("unknown command %q\n\n", subCmdName) + printHelp() +} diff --git a/commit.go b/commit.go new file mode 100644 index 0000000..10b4739 --- /dev/null +++ b/commit.go @@ -0,0 +1,251 @@ +package dehub + +import ( + "bytes" + "dehub/accessctl" + "dehub/fs" + "dehub/sigcred" + "dehub/yamlutil" + "encoding/base64" + "errors" + "fmt" + "strings" + "time" + + "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" +) + +// MasterCommit describes the structure of the object encoded into the git +// message of a commit in the master branch. +type MasterCommit struct { + Message string `yaml:"message"` + ChangeHash yamlutil.Blob `yaml:"change_hash"` + Credentials []sigcred.Credential `yaml:"credentials"` +} + +type mcYAML struct { + Val MasterCommit `yaml:",inline"` +} + +func msgHead(msg string) string { + i := strings.Index(msg, "\n") + if i > 0 { + return msg[:i] + } + return msg +} + +// MarshalText implements the encoding.TextMarshaler interface by returning the +// form the MasterCommit object takes in the git commit message. +func (mc MasterCommit) MarshalText() ([]byte, error) { + masterCommitEncoded, err := yaml.Marshal(mcYAML{mc}) + if err != nil { + return nil, fmt.Errorf("failed to encode MasterCommit message: %w", err) + } + + fullMsg := msgHead(mc.Message) + "\n\n" + string(masterCommitEncoded) + return []byte(fullMsg), nil +} + +// UnmarshalText implements the encoding.TextUnmarshaler interface by decoding a +// MasterCommit object which has been encoded into a git commit message. +func (mc *MasterCommit) UnmarshalText(msg []byte) error { + i := bytes.Index(msg, []byte("\n")) + if i < 0 { + return fmt.Errorf("commit message %q is malformed", msg) + } + msgHead, msg := msg[:i], msg[i:] + + var mcy mcYAML + if err := yaml.Unmarshal(msg, &mcy); err != nil { + return fmt.Errorf("could not unmarshal MasterCommit message: %w", err) + } + + *mc = mcy.Val + if !strings.HasPrefix(mc.Message, string(msgHead)) { + return errors.New("encoded MasterCommit is malformed, it might not be an encoded MasterCommit") + } + + return nil +} + +// CommitMaster constructs a MasterCommit using the given SignifierInterface to +// create a Credential for it. It returns the commit's hash after having set it +// to HEAD. +// +// TODO this method is a prototype and does not reflect the method's final form. +func (r *Repo) CommitMaster(msg, accountID string, sig sigcred.SignifierInterface) (MasterCommit, plumbing.Hash, error) { + _, headTree, err := r.head() + if errors.Is(err, plumbing.ErrReferenceNotFound) { + headTree = &object.Tree{} + } else if err != nil { + return MasterCommit{}, plumbing.ZeroHash, err + } + + _, stagedTree, err := fs.FromStagedChangesTree(r.GitRepo) + if err != nil { + return MasterCommit{}, plumbing.ZeroHash, err + } + + // this is necessarily different than headTree for the case of there being + // no HEAD (ie it's the first commit). In that case we want headTree to be + // empty (because it's being used to generate the change hash), but we want + // the signifier to use the raw fs (because that's where the signifier's + // data might be). + sigFS, err := r.headOrRawFS() + if err != nil { + return MasterCommit{}, plumbing.ZeroHash, err + } + + cfg, err := r.loadConfig(sigFS) + if err != nil { + return MasterCommit{}, plumbing.ZeroHash, fmt.Errorf("could not load config: %w", err) + } + + changeHash := genChangeHash(nil, msg, headTree, stagedTree) + cred, err := sig.Sign(sigFS, changeHash) + if err != nil { + return MasterCommit{}, plumbing.ZeroHash, fmt.Errorf("failed to sign commit hash: %w", err) + } + cred.AccountID = accountID + + // This isn't strictly necessary, but we want to save people the effort of + // creating an invalid commit, pushing it, having it be rejected, then + // having to reset on the commit. + err = r.assertAccessControls( + cfg.AccessControls, []sigcred.Credential{cred}, + headTree, stagedTree, + ) + if err != nil { + return MasterCommit{}, plumbing.ZeroHash, fmt.Errorf("commit would not satisfy access controls: %w", err) + } + + masterCommit := MasterCommit{ + Message: msg, + ChangeHash: changeHash, + Credentials: []sigcred.Credential{cred}, + } + + masterCommitB, err := masterCommit.MarshalText() + if err != nil { + return masterCommit, plumbing.ZeroHash, err + } + + w, err := r.GitRepo.Worktree() + if err != nil { + return masterCommit, plumbing.ZeroHash, fmt.Errorf("could not get git worktree: %w", err) + } + + hash, err := w.Commit(string(masterCommitB), &git.CommitOptions{ + Author: &object.Signature{ + Name: accountID, + When: time.Now(), + }, + }) + if err != nil { + return masterCommit, hash, fmt.Errorf("failed to commit changed: %w", err) + } + + return masterCommit, hash, nil + +} + +func (r *Repo) assertAccessControls( + accessCtls []accessctl.AccessControl, creds []sigcred.Credential, + from, to *object.Tree, +) error { + filesChanged, err := calcDiff(from, to) + if err != nil { + return err + } + + pathsChanged := make([]string, len(filesChanged)) + for i := range filesChanged { + pathsChanged[i] = filesChanged[i].path + } + + accessCtls, err = accessctl.ApplicableAccessControls(accessCtls, pathsChanged) + if err != nil { + return fmt.Errorf("could not determine applicable access controls: %w", err) + } + + for _, accessCtl := range accessCtls { + condInt, err := accessCtl.Condition.Interface() + if err != nil { + return fmt.Errorf("could not cast Condition to interface: %w", err) + } else if err := condInt.Satisfied(creds); err != nil { + return fmt.Errorf("access control for pattern %q not satisfied: %w", + accessCtl.Pattern, err) + } + } + + return nil +} + +// VerifyMasterCommit verifies that the commit at the given hash, which is +// presumably on the master branch, is gucci. +func (r *Repo) VerifyMasterCommit(h plumbing.Hash) error { + commit, err := r.GitRepo.CommitObject(h) + if err != nil { + return fmt.Errorf("could not retrieve commit object: %w", err) + } + + commitTree, err := r.GitRepo.TreeObject(commit.TreeHash) + if err != nil { + return fmt.Errorf("could not retrieve tree object: %w", err) + } + + var masterCommit MasterCommit + if err := masterCommit.UnmarshalText([]byte(commit.Message)); err != nil { + return err + } + + sigTree := commitTree // only for root commit + parentTree := &object.Tree{} + if commit.NumParents() > 0 { + parent, err := commit.Parent(0) + if err != nil { + return fmt.Errorf("could not retrieve parent of commit: %w", err) + } else if parentTree, err = r.GitRepo.TreeObject(parent.TreeHash); err != nil { + return fmt.Errorf("could not retrieve tree object of parent %q: %w", parent.Hash, err) + } + sigTree = parentTree + } + sigFS := fs.FromTree(sigTree) + + cfg, err := r.loadConfig(sigFS) + if err != nil { + return fmt.Errorf("error loading config: %w", err) + } + + err = r.assertAccessControls( + cfg.AccessControls, masterCommit.Credentials, + parentTree, commitTree, + ) + if err != nil { + return fmt.Errorf("failed to satisfy all access controls: %w", err) + } + + expectedChangeHash := genChangeHash(nil, masterCommit.Message, parentTree, commitTree) + if !bytes.Equal(masterCommit.ChangeHash, expectedChangeHash) { + return fmt.Errorf("malformed change_hash in commit body, is %s but should be %s", + base64.StdEncoding.EncodeToString(expectedChangeHash), + base64.StdEncoding.EncodeToString(masterCommit.ChangeHash)) + } + + for _, cred := range masterCommit.Credentials { + sig, err := r.signifierForCredential(sigFS, cred) + if err != nil { + return fmt.Errorf("error finding signifier for credential %+v: %w", cred, err) + } else if err := sig.Verify(sigFS, expectedChangeHash, cred); err != nil { + return fmt.Errorf("error verifying credential %+v: %w", cred, err) + } + } + + // TODO access controls + + return nil +} diff --git a/commit_test.go b/commit_test.go new file mode 100644 index 0000000..2f2549d --- /dev/null +++ b/commit_test.go @@ -0,0 +1,170 @@ +package dehub + +import ( + "dehub/accessctl" + "dehub/sigcred" + "errors" + "reflect" + "strings" + "testing" + + "github.com/davecgh/go-spew/spew" + "gopkg.in/src-d/go-git.v4/plumbing" + yaml "gopkg.in/yaml.v2" +) + +func TestMasterCommitVerify(t *testing.T) { + type step struct { + msg string + msgHead string // defaults to msg + tree map[string]string + } + testCases := []struct { + descr string + steps []step + }{ + { + descr: "single commit", + steps: []step{ + { + msg: "first commit", + tree: map[string]string{"a": "0", "b": "1"}, + }, + }, + }, + { + descr: "multiple commits", + steps: []step{ + { + msg: "first commit", + tree: map[string]string{"a": "0", "b": "1"}, + }, + { + msg: "second commit, changing a", + tree: map[string]string{"a": "1"}, + }, + { + msg: "third commit, empty", + }, + { + msg: "fourth commit, adding c, removing b", + tree: map[string]string{"b": "", "c": "2"}, + }, + }, + }, + { + descr: "big body commits", + steps: []step{ + { + msg: "first commit, single line but with newline\n", + }, + { + msg: "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!", + msgHead: "third commit, multi-line with one newline\n\n", + }, + { + msg: "fourth commit, multi-line with two newlines\n\nanother line!", + msgHead: "fourth commit, multi-line with two newlines\n\n", + }, + }, + }, + } + + for _, test := range testCases { + t.Run(test.descr, func(t *testing.T) { + h := newHarness(t) + for _, step := range test.steps { + h.stage(step.tree) + account := h.cfg.Accounts[0] + + masterCommit, hash, err := h.repo.CommitMaster(step.msg, account.ID, h.sig) + if err != nil { + t.Fatalf("failed to make MasterCommit: %v", err) + } else if err := h.repo.VerifyMasterCommit(hash); err != nil { + t.Fatalf("could not verify hash %v: %v", hash, err) + } + + commit, err := h.repo.GitRepo.CommitObject(hash) + if err != nil { + t.Fatalf("failed to retrieve commit %v: %v", hash, err) + } else if step.msgHead == "" { + step.msgHead = strings.TrimSpace(step.msg) + "\n\n" + } + + if !strings.HasPrefix(commit.Message, step.msgHead) { + t.Fatalf("commit message %q does not start with expected head %q", commit.Message, step.msgHead) + } + + var actualMasterCommit MasterCommit + if err := actualMasterCommit.UnmarshalText([]byte(commit.Message)); err != nil { + t.Fatalf("error unmarshaling commit body: %v", err) + } else if !reflect.DeepEqual(actualMasterCommit, masterCommit) { + t.Fatalf("returned master commit:\n%s\ndoes not match actual one:\n%s", + spew.Sdump(masterCommit), spew.Sdump(actualMasterCommit)) + } + } + }) + } +} + +func TestConfigChange(t *testing.T) { + h := newHarness(t) + + var hashes []plumbing.Hash + + // commit the initial staged changes, which merely include the config and + // public key + _, hash, err := h.repo.CommitMaster("commit configuration", h.cfg.Accounts[0].ID, h.sig) + if err != nil { + t.Fatal(err) + } + hashes = append(hashes, hash) + + // create a new account and add it to the configuration. It should not be + // able to actually make that commit though. + newSig, newPubKeyBody := sigcred.SignifierPGPTmp(h.rand) + h.cfg.Accounts = append(h.cfg.Accounts, Account{ + ID: "toot", + Signifiers: []sigcred.Signifier{{PGPPublicKey: &sigcred.SignifierPGP{ + Body: string(newPubKeyBody), + }}}, + }) + h.cfg.AccessControls[0].Condition.Signature.AccountIDs = []string{"root", "toot"} + h.cfg.AccessControls[0].Condition.Signature.Count = "1" + + cfgBody, err := yaml.Marshal(h.cfg) + if err != nil { + t.Fatal(err) + } + h.stage(map[string]string{ConfigPath: string(cfgBody)}) + + _, _, err = h.repo.CommitMaster("add toot user", h.cfg.Accounts[1].ID, newSig) + if aclErr := (accessctl.ErrConditionSignatureUnsatisfied{}); !errors.As(err, &aclErr) { + t.Fatalf("CommitMaster should have returned an ErrConditionSignatureUnsatisfied, but returned %v", err) + } + + // now add with the root user, this should work. + _, hash, err = h.repo.CommitMaster("add toot user", h.cfg.Accounts[0].ID, h.sig) + if err != nil { + t.Fatalf("got an unexpected error committing with root: %v", err) + } + hashes = append(hashes, hash) + + // _now_ the toot user should be able to do things. + h.stage(map[string]string{"foo/bar": "what a cool file"}) + _, hash, err = h.repo.CommitMaster("add a cool file", h.cfg.Accounts[1].ID, newSig) + if err != nil { + t.Fatalf("got an unexpected error committing with toot: %v", err) + } + hashes = append(hashes, hash) + + for i, hash := range hashes { + if err := h.repo.VerifyMasterCommit(hash); err != nil { + t.Fatalf("commit %d (%v) should have been verified but wasn't: %v", i, hash, err) + } + } +} diff --git a/config.go b/config.go new file mode 100644 index 0000000..62901c4 --- /dev/null +++ b/config.go @@ -0,0 +1,83 @@ +package dehub + +import ( + "dehub/accessctl" + "dehub/fs" + "dehub/sigcred" + "errors" + "fmt" + + yaml "gopkg.in/yaml.v2" +) + +// 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"` +} + +// Config represents the structure of the main dehub configuration file, and is +// used to marshal/unmarshal the yaml file. +type Config struct { + Accounts []Account `yaml:"accounts"` + AccessControls []accessctl.AccessControl `yaml:"access_controls"` +} + +func (r *Repo) 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) + } + defer rc.Close() + + var cfg Config + if err := yaml.NewDecoder(rc).Decode(&cfg); err != nil { + return cfg, fmt.Errorf("could not decode config.yml: %w", err) + } + + // TODO validate Config + + 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.headOrRawFS() + if err != nil { + return Config{}, fmt.Errorf("error retrieving repo HEAD: %w", err) + } + return r.loadConfig(headFS) +} + +func (r *Repo) signifierForCredential(fs fs.FS, cred sigcred.Credential) (sigcred.SignifierInterface, error) { + cfg, err := r.loadConfig(fs) + if err != nil { + return nil, fmt.Errorf("error loading config: %w", err) + } + + var account Account + var ok bool + for _, account = range cfg.Accounts { + if account.ID == cred.AccountID { + ok = true + break + } + } + if !ok { + 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(); 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 { + return nil, fmt.Errorf("error checking if signfier index:%d signed credential: %w", i, err) + } else if ok { + return sigInt, nil + } + } + + return nil, errors.New("no signifier found for credential") +} diff --git a/diff.go b/diff.go new file mode 100644 index 0000000..d3aaa32 --- /dev/null +++ b/diff.go @@ -0,0 +1,41 @@ +package dehub + +import ( + "fmt" + + "gopkg.in/src-d/go-git.v4/plumbing" + "gopkg.in/src-d/go-git.v4/plumbing/filemode" + "gopkg.in/src-d/go-git.v4/plumbing/object" +) + +type fileChanged struct { + path string + fromMode, toMode filemode.FileMode + fromHash, toHash plumbing.Hash +} + +func calcDiff(from, to *object.Tree) ([]fileChanged, error) { + + changes, err := object.DiffTree(from, to) + if err != nil { + return nil, fmt.Errorf("could not calculate tree diff: %w", err) + } + + filesChanged := make([]fileChanged, len(changes)) + for i, change := range changes { + if from := change.From; from.Name != "" { + filesChanged[i].path = from.Name + filesChanged[i].fromMode = from.TreeEntry.Mode + filesChanged[i].fromHash = from.TreeEntry.Hash + } + if to := change.To; to.Name != "" { + if exPath := filesChanged[i].path; exPath != "" && exPath != to.Name { + panic(fmt.Sprintf("DiffTree entry changed path from %q to %q", exPath, to.Name)) + } + filesChanged[i].path = to.Name + filesChanged[i].toMode = to.TreeEntry.Mode + filesChanged[i].toHash = to.TreeEntry.Hash + } + } + return filesChanged, nil +} diff --git a/fs/build_tree_helper.go b/fs/build_tree_helper.go new file mode 100644 index 0000000..3aaafe8 --- /dev/null +++ b/fs/build_tree_helper.go @@ -0,0 +1,117 @@ +package fs + +import ( + "path" + "sort" + "strings" + + "gopkg.in/src-d/go-billy.v4" + "gopkg.in/src-d/go-git.v4/plumbing" + "gopkg.in/src-d/go-git.v4/plumbing/filemode" + "gopkg.in/src-d/go-git.v4/plumbing/format/index" + "gopkg.in/src-d/go-git.v4/plumbing/object" + "gopkg.in/src-d/go-git.v4/storage" +) + +// This file is largely copied from the git-go project's worktree_commit.go @ v4.13.1 + +// buildTreeHelper converts a given index.Index file into multiple git objects +// reading the blobs from the given filesystem and creating the trees from the +// index structure. The created objects are pushed to a given Storer. +type buildTreeHelper struct { + fs billy.Filesystem + s storage.Storer + + trees map[string]*object.Tree + entries map[string]*object.TreeEntry +} + +// BuildTree builds the tree objects and push its to the storer, the hash +// of the root tree is returned. +func (h *buildTreeHelper) BuildTree(idx *index.Index) (plumbing.Hash, error) { + const rootNode = "" + h.trees = map[string]*object.Tree{rootNode: {}} + h.entries = map[string]*object.TreeEntry{} + + for _, e := range idx.Entries { + if err := h.commitIndexEntry(e); err != nil { + return plumbing.ZeroHash, err + } + } + + return h.copyTreeToStorageRecursive(rootNode, h.trees[rootNode]) +} + +func (h *buildTreeHelper) commitIndexEntry(e *index.Entry) error { + parts := strings.Split(e.Name, "/") + + var fullpath string + for _, part := range parts { + parent := fullpath + fullpath = path.Join(fullpath, part) + + h.doBuildTree(e, parent, fullpath) + } + + return nil +} + +func (h *buildTreeHelper) doBuildTree(e *index.Entry, parent, fullpath string) { + if _, ok := h.trees[fullpath]; ok { + return + } + + if _, ok := h.entries[fullpath]; ok { + return + } + + te := object.TreeEntry{Name: path.Base(fullpath)} + + if fullpath == e.Name { + te.Mode = e.Mode + te.Hash = e.Hash + } else { + te.Mode = filemode.Dir + h.trees[fullpath] = &object.Tree{} + } + + h.trees[parent].Entries = append(h.trees[parent].Entries, te) +} + +type sortableEntries []object.TreeEntry + +func (sortableEntries) sortName(te object.TreeEntry) string { + if te.Mode == filemode.Dir { + return te.Name + "/" + } + return te.Name +} +func (se sortableEntries) Len() int { return len(se) } +func (se sortableEntries) Less(i int, j int) bool { return se.sortName(se[i]) < se.sortName(se[j]) } +func (se sortableEntries) Swap(i int, j int) { se[i], se[j] = se[j], se[i] } + +func (h *buildTreeHelper) copyTreeToStorageRecursive(parent string, t *object.Tree) (plumbing.Hash, error) { + sort.Sort(sortableEntries(t.Entries)) + for i, e := range t.Entries { + if e.Mode != filemode.Dir && !e.Hash.IsZero() { + continue + } + + path := path.Join(parent, e.Name) + + var err error + e.Hash, err = h.copyTreeToStorageRecursive(path, h.trees[path]) + if err != nil { + return plumbing.ZeroHash, err + } + + t.Entries[i] = e + } + + o := h.s.NewEncodedObject() + if err := t.Encode(o); err != nil { + return plumbing.ZeroHash, err + } + + return h.s.SetEncodedObject(o) +} diff --git a/fs/fs.go b/fs/fs.go new file mode 100644 index 0000000..5905268 --- /dev/null +++ b/fs/fs.go @@ -0,0 +1,100 @@ +// Package fs implements abstractions for interacting with a filesystem, either +// via a git tree, a staged index, or directly. +package fs + +import ( + "bytes" + "fmt" + "io" + "io/ioutil" + "os" + + "gopkg.in/src-d/go-billy.v4" + "gopkg.in/src-d/go-git.v4" + "gopkg.in/src-d/go-git.v4/plumbing/object" +) + +// FS is a simple interface for reading off a snapshot of a filesystem. +type FS interface { + Open(path string) (io.ReadCloser, error) +} + +type treeFS struct { + tree *object.Tree +} + +// FromTree wraps a git tree object to implement the FS interface. All paths +// will be relative to the root of the tree. +func FromTree(t *object.Tree) FS { + return treeFS{tree: t} +} + +func (gt treeFS) Open(path string) (io.ReadCloser, error) { + f, err := gt.tree.File(path) + if err != nil { + return nil, err + } + + return f.Blob.Reader() +} + +type billyFS struct { + fs billy.Filesystem +} + +// FromBillyFilesystem wraps a billy.Filesystem to implement the FS interface. +// All paths will be relative to the filesystem's root. +func FromBillyFilesystem(bfs billy.Filesystem) FS { + return billyFS{fs: bfs} +} + +func (bfs billyFS) Open(path string) (io.ReadCloser, error) { + return bfs.fs.Open(path) +} + +// FromStagedChangesTree processes the current set of staged changes into a tree +// object, and returns an FS for that tree. All paths will be relative to the +// root of the git repo. +func FromStagedChangesTree(repo *git.Repository) (FS, *object.Tree, error) { + w, err := repo.Worktree() + if err != nil { + return nil, nil, fmt.Errorf("could not open git worktree: %w", err) + } + + storer := repo.Storer + idx, err := storer.Index() + if err != nil { + return nil, nil, fmt.Errorf("could not open git staging index: %w", err) + } + + th := &buildTreeHelper{ + fs: w.Filesystem, + s: storer, + } + + treeHash, err := th.BuildTree(idx) + if err != nil { + return nil, nil, fmt.Errorf("could not build staging index tree: %w", err) + } + + tree, err := repo.TreeObject(treeHash) + if err != nil { + return nil, nil, fmt.Errorf("could not get staged tree object (%q): %w", treeHash, err) + } + + return FromTree(tree), tree, nil +} + +// Stub is an implementation of FS based on a map of paths to the file contents +// at that path. Paths should be "clean" or they will not match with anything. +type Stub map[string][]byte + +// Open implements the method for the FS interface. +func (s Stub) Open(path string) (io.ReadCloser, error) { + body, ok := s[path] + if !ok { + return nil, os.ErrNotExist + } + + return ioutil.NopCloser(bytes.NewReader(body)), nil +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..e86a563 --- /dev/null +++ b/go.mod @@ -0,0 +1,12 @@ +module dehub + +go 1.13 + +require ( + github.com/bmatcuk/doublestar v1.2.2 + github.com/davecgh/go-spew v1.1.1 + golang.org/x/crypto v0.0.0-20200109152110-61a87790db17 + gopkg.in/src-d/go-billy.v4 v4.3.2 + gopkg.in/src-d/go-git.v4 v4.13.1 + gopkg.in/yaml.v2 v2.2.7 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..1ea192f --- /dev/null +++ b/go.sum @@ -0,0 +1,82 @@ +github.com/alcortesm/tgz v0.0.0-20161220082320-9c5fe88206d7 h1:uSoVVbwJiQipAclBbw+8quDsfcvFjOpI5iCf4p/cqCs= +github.com/alcortesm/tgz v0.0.0-20161220082320-9c5fe88206d7/go.mod h1:6zEj6s6u/ghQa61ZWa/C2Aw3RkjiTBOix7dkqa1VLIs= +github.com/anmitsu/go-shlex v0.0.0-20161002113705-648efa622239 h1:kFOfPq6dUM1hTo4JG6LR5AXSUEsOjtdm0kw0FtQtMJA= +github.com/anmitsu/go-shlex v0.0.0-20161002113705-648efa622239/go.mod h1:2FmKhYUyUczH0OGQWaF5ceTx0UBShxjsH6f8oGKYe2c= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= +github.com/bmatcuk/doublestar v1.2.2 h1:oC24CykoSAB8zd7XgruHo33E0cHJf/WhQA/7BeXj+x0= +github.com/bmatcuk/doublestar v1.2.2/go.mod h1:wiQtGV+rzVYxB7WIlirSN++5HPtPlXEo9MEoZQC/PmE= +github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/emirpasic/gods v1.12.0 h1:QAUIPSaCu4G+POclxeqb3F+WPpdKqFGlw36+yOzGlrg= +github.com/emirpasic/gods v1.12.0/go.mod h1:YfzfFFoVP/catgzJb4IKIqXjX78Ha8FMSDh3ymbK86o= +github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568 h1:BHsljHzVlRcyQhjrss6TZTdY2VfCqZPbv5k3iBFa2ZQ= +github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568/go.mod h1:xEzjJPgXI435gkrCt3MPfRiAkVrwSbHsst4LCFVfpJc= +github.com/gliderlabs/ssh v0.2.2 h1:6zsha5zo/TWhRhwqCD3+EarCAgZ2yN28ipRnGPnwkI0= +github.com/gliderlabs/ssh v0.2.2/go.mod h1:U7qILu1NlMHj9FlMhZLlkCdDnU1DBEAqr0aevW3Awn0= +github.com/google/go-cmp v0.3.0 h1:crn/baboCvb5fXaQ0IJ1SGTsTVrWpDsCWC8EGETZijY= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= +github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= +github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= +github.com/kevinburke/ssh_config v0.0.0-20190725054713-01f96b0aa0cd h1:Coekwdh0v2wtGp9Gmz1Ze3eVRAWJMLokvN3QjdzCHLY= +github.com/kevinburke/ssh_config v0.0.0-20190725054713-01f96b0aa0cd/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= +github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/pty v1.1.8/go.mod h1:O1sed60cT9XZ5uDucP5qwvh+TE3NnUj51EiZO/lmSfw= +github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= +github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/pelletier/go-buffruneio v0.2.0/go.mod h1:JkE26KsDizTr40EUHkXVtNPvgGtbSNq5BcowyYOWdKo= +github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/sergi/go-diff v1.0.0 h1:Kpca3qRNrduNnOQeazBd0ysaKrUJiIuISHxogkT9RPQ= +github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= +github.com/src-d/gcfg v1.4.0 h1:xXbNR5AlLSA315x2UO+fTSSAXCDf+Ar38/6oyGbDKQ4= +github.com/src-d/gcfg v1.4.0/go.mod h1:p/UMsR43ujA89BJY9duynAwIpvqEujIH/jFlfL7jWoI= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE= +github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/xanzy/ssh-agent v0.2.1 h1:TCbipTQL2JiiCprBWx9frJ2eJlCYT00NmctrHxVAr70= +github.com/xanzy/ssh-agent v0.2.1/go.mod h1:mLlQY/MoOhWBj+gOGMQkOeiEvkx+8pJSI+0Bx9h2kr4= +golang.org/x/crypto v0.0.0-20190219172222-a4c6cb3142f2/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4 h1:HuIa8hRrWRSrqYzx1qI49NNxhdi2PrY7gxVSq1JjLDc= +golang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200109152110-61a87790db17 h1:nVJ3guKA9qdkEQ3TUdXI9QSINo2CUPM/cySEvw2w8I0= +golang.org/x/crypto v0.0.0-20200109152110-61a87790db17/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190724013045-ca1201d0de80 h1:Ao/3l156eZf2AW5wK8a7/smtodRU+gha3+BeqJ69lRk= +golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190221075227-b4e8571b14e0/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e h1:D5TXcfTk7xF7hvieo4QErS3qqCB4teTffacDWr7CI+0= +golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190729092621-ff9f1409240a/go.mod h1:jcCCGcm9btYwXyDqrUWc6MKQKKGJCWEQ3AfLSRIbEuI= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/src-d/go-billy.v4 v4.3.2 h1:0SQA1pRztfTFx2miS8sA97XvooFeNOmvUenF4o0EcVg= +gopkg.in/src-d/go-billy.v4 v4.3.2/go.mod h1:nDjArDMp+XMs1aFAESLRjfGSgfvoYN0hDfzEk0GjC98= +gopkg.in/src-d/go-git-fixtures.v3 v3.5.0 h1:ivZFOIltbce2Mo8IjzUHAFoq/IylO9WHhNOAJK+LsJg= +gopkg.in/src-d/go-git-fixtures.v3 v3.5.0/go.mod h1:dLBcvytrw/TYZsNTWCnkNF2DSIlzWYqTe3rJR56Ac7g= +gopkg.in/src-d/go-git.v4 v4.13.1 h1:SRtFyV8Kxc0UP7aCHcijOMQGPxHSmMOPrzulQWolkYE= +gopkg.in/src-d/go-git.v4 v4.13.1/go.mod h1:nx5NYcxdKxq5fpltdHnPa2Exj4Sx0EclMWZQbYDu2z8= +gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= +gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= +gopkg.in/yaml.v2 v2.2.7 h1:VUgggvou5XRW9mHwD/yXxIYSMtY0zoKQf/v226p2nyo= +gopkg.in/yaml.v2 v2.2.7/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/repo.go b/repo.go new file mode 100644 index 0000000..67c6d4d --- /dev/null +++ b/repo.go @@ -0,0 +1,104 @@ +// Package dehub TODO needs package docs +package dehub + +import ( + "dehub/fs" + "errors" + "fmt" + "path/filepath" + + "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/object" + "gopkg.in/src-d/go-git.v4/storage/memory" +) + +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") +) + +// Repo is an object which allows accessing and modifying the dehub repo. +type Repo struct { + GitRepo *git.Repository +} + +// 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) (*Repo, error) { + r := Repo{} + var err error + openOpts := &git.PlainOpenOptions{ + DetectDotGit: true, + } + if r.GitRepo, err = git.PlainOpenWithOptions(path, openOpts); err != nil { + return nil, fmt.Errorf("could not open git repo: %w", err) + } + return &r, nil +} + +// InitMemRepo initializes an empty repository which only exists in memory. +func InitMemRepo() *Repo { + r, err := git.Init(memory.NewStorage(), memfs.New()) + if err != nil { + panic(err) + } + + return &Repo{GitRepo: r} +} + +func (r *Repo) billyFilesystem() (billy.Filesystem, error) { + w, err := r.GitRepo.Worktree() + if err != nil { + return nil, fmt.Errorf("could not open git worktree: %w", err) + } + return w.Filesystem, nil +} + +func (r *Repo) head() (*object.Commit, *object.Tree, error) { + head, err := r.GitRepo.Head() + if err != nil { + return nil, nil, fmt.Errorf("could not get repo HEAD: %w", err) + } + + headHash := head.Hash() + headCommit, err := r.GitRepo.CommitObject(headHash) + if err != nil { + return nil, nil, fmt.Errorf("could not get commit at HEAD (%q): %w", headHash, err) + } + + headTree, err := r.GitRepo.TreeObject(headCommit.TreeHash) + if err != nil { + return nil, nil, fmt.Errorf("could not get tree object at HEAD (commit:%q tree:%q): %w", + headHash, headCommit.TreeHash, err) + } + + return headCommit, headTree, nil +} + +// headOrRawFS 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) headOrRawFS() (fs.FS, error) { + _, headTree, err := r.head() + if errors.Is(err, plumbing.ErrReferenceNotFound) { + bfs, err := r.billyFilesystem() + if err != nil { + return nil, fmt.Errorf("could not get 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(headTree), nil +} diff --git a/repo_test.go b/repo_test.go new file mode 100644 index 0000000..a187b67 --- /dev/null +++ b/repo_test.go @@ -0,0 +1,118 @@ +package dehub + +import ( + "bytes" + "dehub/accessctl" + "dehub/sigcred" + "io" + "math/rand" + "path/filepath" + "testing" + + "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" +) + +type harness struct { + t *testing.T + rand *rand.Rand + repo *Repo + cfg *Config + sig sigcred.SignifierInterface +} + +func newHarness(t *testing.T) *harness { + rand := rand.New(rand.NewSource(0xb4eadb01)) + sig, pubKeyBody := sigcred.SignifierPGPTmp(rand) + pubKeyPath := filepath.Join(DehubDir, "root.asc") + + cfg := &Config{ + Accounts: []Account{{ + ID: "root", + Signifiers: []sigcred.Signifier{{PGPPublicKeyFile: &sigcred.SignifierPGPFile{ + Path: pubKeyPath, + }}}, + }}, + AccessControls: []accessctl.AccessControl{ + { + Pattern: "**", + Condition: accessctl.Condition{ + Signature: &accessctl.ConditionSignature{ + AccountIDs: []string{"root"}, + Count: "100%", + }, + }, + }, + }, + } + cfgBody, err := yaml.Marshal(cfg) + if err != nil { + t.Fatal(err) + } + + h := &harness{ + t: t, + rand: rand, + repo: InitMemRepo(), + cfg: cfg, + sig: sig, + } + h.stage(map[string]string{ + ConfigPath: string(cfgBody), + pubKeyPath: string(pubKeyBody), + }) + + return h +} + +func (h *harness) stage(tree map[string]string) { + w, err := h.repo.GitRepo.Worktree() + if err != nil { + h.t.Fatal(err) + } + fs := w.Filesystem + for path, content := range tree { + if content == "" { + if _, err := w.Remove(path); err != nil { + h.t.Fatalf("error 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) + } + + f, err := fs.Create(path) + if err != nil { + h.t.Fatalf("error 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) + + } else if err := f.Close(); err != nil { + h.t.Fatalf("error 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) + } + } +} + +func (h *harness) commit(msg string) plumbing.Hash { + w, err := h.repo.GitRepo.Worktree() + if err != nil { + h.t.Fatal(err) + } + hash, err := w.Commit(msg, &git.CommitOptions{ + Author: &object.Signature{Name: "god"}, + }) + if err != nil { + h.t.Fatal(err) + } + + return hash +} diff --git a/sigcred/credential.go b/sigcred/credential.go new file mode 100644 index 0000000..8d1a22f --- /dev/null +++ b/sigcred/credential.go @@ -0,0 +1,25 @@ +package sigcred + +import "dehub/typeobj" + +// Credential represents a credential which has been attached to a commit which +// hopefully will allow it to be included in the master branch. Exactly one +// field tagged with "type" should be set. +type Credential struct { + PGPSignature *CredentialPGPSignature `type:"pgp_signature"` + + // AccountID specifies the account which generated this Credential. The + // Credentials produced by the Signifier.Sign method do not fill this field + // in. + AccountID string `yaml:"account"` +} + +// MarshalYAML implements the yaml.Marshaler interface. +func (c Credential) MarshalYAML() (interface{}, error) { + return typeobj.MarshalYAML(c) +} + +// UnmarshalYAML implements the yaml.Unmarshaler interface. +func (c *Credential) UnmarshalYAML(unmarshal func(interface{}) error) error { + return typeobj.UnmarshalYAML(c, unmarshal) +} diff --git a/sigcred/pgp.go b/sigcred/pgp.go new file mode 100644 index 0000000..d419178 --- /dev/null +++ b/sigcred/pgp.go @@ -0,0 +1,279 @@ +package sigcred + +import ( + "bytes" + "crypto" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/sha256" + "dehub/fs" + "dehub/yamlutil" + "fmt" + "io" + "io/ioutil" + "os/exec" + "path/filepath" + "strings" + "time" + + "golang.org/x/crypto/openpgp/armor" + "golang.org/x/crypto/openpgp/packet" +) + +// CredentialPGPSignature describes a PGP signature which has been used to sign +// a commit. +type CredentialPGPSignature struct { + PubKeyID string `yaml:"pub_key_id"` + Body yamlutil.Blob `yaml:"body"` +} + +type pgpPubKey struct { + pubKey *packet.PublicKey +} + +func newPGPPubKey(r io.Reader) (pgpPubKey, error) { + // TODO support non-armored keys as well + block, err := armor.Decode(r) + if err != nil { + return pgpPubKey{}, fmt.Errorf("could not decode armored PGP public key: %w", err) + } + + pkt, err := packet.Read(block.Body) + if err != nil { + return pgpPubKey{}, fmt.Errorf("could not read PGP public key: %w", err) + } + + pubKey, ok := pkt.(*packet.PublicKey) + if !ok { + return pgpPubKey{}, fmt.Errorf("packet is not a public key, it's a %T", pkt) + } + + return pgpPubKey{pubKey: pubKey}, nil +} + +func (s pgpPubKey) Signed(_ fs.FS, cred Credential) (bool, error) { + if cred.PGPSignature == nil { + return false, nil + } + + return cred.PGPSignature.PubKeyID == s.pubKey.KeyIdString(), nil +} + +func (s pgpPubKey) Verify(_ fs.FS, data []byte, cred Credential) error { + credSig := cred.PGPSignature + if credSig == nil { + return fmt.Errorf("SignifierPGPFile cannot verify %+v", cred) + } + + pkt, err := packet.Read(bytes.NewBuffer(credSig.Body)) + if err != nil { + return fmt.Errorf("could not read signature packet: %w", err) + } + + sigPkt, ok := pkt.(*packet.Signature) + if !ok { + return fmt.Errorf("signature bytes were parsed as a %T, not a signature", pkt) + } + + // The gpg process which is invoked during normal signing automatically + // hashes whatever is piped to it. The VerifySignature method in the openpgp + // package expects you to do it yourself. + h := sigPkt.Hash.New() + h.Write(data) + return s.pubKey.VerifySignature(h, sigPkt) +} + +func (s pgpPubKey) encode() ([]byte, error) { + body := new(bytes.Buffer) + armorEncoder, err := armor.Encode(body, "PGP PUBLIC KEY", nil) + if err != nil { + return nil, fmt.Errorf("error initializing armor encoder: %w", err) + } else if err := s.pubKey.Serialize(armorEncoder); err != nil { + return nil, fmt.Errorf("error encoding public key: %w", err) + } else if err := armorEncoder.Close(); err != nil { + return nil, fmt.Errorf("error closing armor encoder: %w", err) + } + return body.Bytes(), nil +} + +func (s pgpPubKey) asSignfier() (SignifierPGP, error) { + body, err := s.encode() + if err != nil { + return SignifierPGP{}, err + } + + return SignifierPGP{ + Body: string(body), + }, nil +} + +type pgpPrivKey struct { + pgpPubKey + privKey *packet.PrivateKey +} + +// SignifierPGPTmp 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. +func SignifierPGPTmp(randReader io.Reader) (SignifierInterface, []byte) { + rawPrivKey, err := ecdsa.GenerateKey(elliptic.P521(), randReader) + if err != nil { + panic(err) + } + + privKeyRaw := packet.NewECDSAPrivateKey(time.Now(), rawPrivKey) + privKey := pgpPrivKey{ + pgpPubKey: pgpPubKey{ + pubKey: &privKeyRaw.PublicKey, + }, + privKey: privKeyRaw, + } + + pubKeyBody, err := privKey.pgpPubKey.encode() + if err != nil { + panic(err) + } + return privKey, pubKeyBody +} + +func (s pgpPrivKey) Sign(_ fs.FS, data []byte) (Credential, error) { + h := sha256.New() + h.Write(data) + var sig packet.Signature + sig.Hash = crypto.SHA256 + sig.PubKeyAlgo = s.pubKey.PubKeyAlgo + if err := sig.Sign(h, s.privKey, nil); err != nil { + return Credential{}, fmt.Errorf("failed to sign data: %w", err) + } + + body := new(bytes.Buffer) + if err := sig.Serialize(body); err != nil { + return Credential{}, fmt.Errorf("failed to serialize signature: %w", err) + } + + return Credential{ + PGPSignature: &CredentialPGPSignature{ + PubKeyID: s.pubKey.KeyIdString(), + Body: body.Bytes(), + }, + }, nil +} + +// SignifierPGP describes a pgp public key whose corresponding private key will +// be used as a signing key. +type SignifierPGP struct { + Body string `yaml:"body"` +} + +var _ SignifierInterface = SignifierPGP{} + +func (s SignifierPGP) load() (pgpPubKey, error) { + return newPGPPubKey(strings.NewReader(s.Body)) +} + +// 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) { + sigPGP, err := s.load() + if err != nil { + return Credential{}, err + } + + stderr := new(bytes.Buffer) + cmd := exec.Command("gpg", + "--openpgp", + "--detach-sign", + "--local-user", sigPGP.pubKey.KeyIdString()) + cmd.Stdin = bytes.NewBuffer(data) + cmd.Stderr = stderr + sig, err := cmd.Output() + if err != nil { + return Credential{}, fmt.Errorf("error signing with gpg (%v): %s", err, stderr.String()) + } + + return Credential{ + PGPSignature: &CredentialPGPSignature{ + PubKeyID: sigPGP.pubKey.KeyIdString(), + Body: sig, + }, + }, nil +} + +// 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) { + sigPGP, err := s.load() + if err != nil { + return false, err + } + + return sigPGP.Signed(fs, cred) +} + +// 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 { + sigPGP, err := s.load() + if err != nil { + return err + } + return sigPGP.Verify(fs, data, cred) +} + +// SignifierPGPFile is the same as SignifierPGP, except that the public key is +// found in the repo rather than encoded into the object. +type SignifierPGPFile struct { + Path string `yaml:"path"` +} + +var _ SignifierInterface = SignifierPGPFile{} + +func (s SignifierPGPFile) load(fs fs.FS) (SignifierPGP, error) { + path := filepath.Clean(s.Path) + fr, err := fs.Open(path) + if err != nil { + return SignifierPGP{}, fmt.Errorf("could not open PGP public key file at %q: %w", path, err) + } + defer fr.Close() + + pubKeyB, err := ioutil.ReadAll(fr) + if err != nil { + return SignifierPGP{}, fmt.Errorf("could not read PGP public key from file blob at %q: %w", s.Path, err) + } + + return SignifierPGP{Body: string(pubKeyB)}, nil +} + +// Sign will sign the given arbitrary bytes using the private key corresponding +// to the pgp public key located by this Signifier. +func (s SignifierPGPFile) Sign(fs fs.FS, data []byte) (Credential, error) { + sigPGP, err := s.load(fs) + if err != nil { + return Credential{}, err + } + return sigPGP.Sign(fs, data) +} + +// Signed returns true if the private key corresponding to the pgp public key +// located by this Signifier was used to produce the given Credential. +func (s SignifierPGPFile) Signed(fs fs.FS, cred Credential) (bool, error) { + if cred.PGPSignature == nil { + return false, nil + } + + sigPGP, err := s.load(fs) + if err != nil { + return false, err + } + return sigPGP.Signed(fs, cred) +} + +// Verify asserts that the given signature was produced by this key signing the +// given piece of data. +func (s SignifierPGPFile) Verify(fs fs.FS, data []byte, cred Credential) error { + sigPGP, err := s.load(fs) + if err != nil { + return err + } + return sigPGP.Verify(fs, data, cred) +} diff --git a/sigcred/pgp_test.go b/sigcred/pgp_test.go new file mode 100644 index 0000000..d24edfc --- /dev/null +++ b/sigcred/pgp_test.go @@ -0,0 +1,66 @@ +package sigcred + +import ( + "dehub/fs" + "math/rand" + "testing" + "time" +) + +// There are not currently tests for testing pgp signature creation, as they +// require calls out to the gpg executable. Wrapping tests in docker containers +// would make this doable. + +func TestPGPVerification(t *testing.T) { + tests := []struct { + descr string + init func(pubKeyBody []byte) (SignifierInterface, fs.FS) + }{ + { + descr: "SignifierPGP", + init: func(pubKeyBody []byte) (SignifierInterface, fs.FS) { + return SignifierPGP{Body: string(pubKeyBody)}, nil + }, + }, + { + descr: "SignifierPGPFile", + init: func(pubKeyBody []byte) (SignifierInterface, fs.FS) { + pubKeyPath := "some/dir/pubkey.asc" + fs := fs.Stub{pubKeyPath: pubKeyBody} + sigPGPFile := SignifierPGPFile{Path: pubKeyPath} + return sigPGPFile, fs + }, + }, + } + + for _, test := range tests { + t.Run(test.descr, func(t *testing.T) { + seed := time.Now().UnixNano() + t.Logf("seed: %d", seed) + rand := rand.New(rand.NewSource(seed)) + privKey, pubKeyBody := SignifierPGPTmp(rand) + + sig, fs := test.init(pubKeyBody) + data := make([]byte, rand.Intn(1024)) + if _, err := rand.Read(data); err != nil { + t.Fatal(err) + } + + cred, err := privKey.Sign(nil, data) + if err != nil { + t.Fatal(err) + } + + signed, err := sig.Signed(fs, cred) + if err != nil { + t.Fatal(err) + } else if !signed { + t.Fatal("expected signed to be true") + } + + if err := sig.Verify(fs, data, cred); err != nil { + t.Fatal(err) + } + }) + } +} diff --git a/sigcred/sigcred.go b/sigcred/sigcred.go new file mode 100644 index 0000000..e89e601 --- /dev/null +++ b/sigcred/sigcred.go @@ -0,0 +1,5 @@ +// Package sigcred implements the Signifier and Credential types, which +// interplay together to provide the ability to sign arbitrary blobs of data +// (producing Credentials) and to verify those Credentials within the context of +// a dehub repo. +package sigcred diff --git a/sigcred/signifier.go b/sigcred/signifier.go new file mode 100644 index 0000000..ef99c48 --- /dev/null +++ b/sigcred/signifier.go @@ -0,0 +1,50 @@ +package sigcred + +import ( + "dehub/fs" + "dehub/typeobj" +) + +// Signifier reprsents a single signing method being defined in the Config. Only +// one field should be set on each Signifier. +type Signifier struct { + PGPPublicKey *SignifierPGP `type:"pgp_public_key"` + PGPPublicKeyFile *SignifierPGPFile `type:"pgp_public_key_file"` +} + +// MarshalYAML implements the yaml.Marshaler interface. +func (s Signifier) MarshalYAML() (interface{}, error) { + return typeobj.MarshalYAML(s) +} + +// UnmarshalYAML implements the yaml.Unmarshaler interface. +func (s *Signifier) UnmarshalYAML(unmarshal func(interface{}) error) error { + return typeobj.UnmarshalYAML(s, unmarshal) +} + +// Interface returns the SignifierInterface instance encapsulated by this +// Signifier object. +func (s Signifier) Interface() (SignifierInterface, error) { + el, _, err := typeobj.Element(s) + if err != nil { + return nil, err + } + return 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 +} diff --git a/typeobj/typeobj.go b/typeobj/typeobj.go new file mode 100644 index 0000000..6b6044d --- /dev/null +++ b/typeobj/typeobj.go @@ -0,0 +1,158 @@ +// Package typeobj implements a set of utility functions intended to be used on +// union structs whose fields are tagged with the "type" tag and which expect +// only one of the fields to be set. For example: +// +// type OuterType struct { +// A *InnerTypeA `type:"a"` +// B *InnerTypeB `type:"b"` +// C *InnerTypeC `type:"c"` +// } +// +package typeobj + +import ( + "errors" + "fmt" + "reflect" +) + +// UnmarshalYAML is intended to be used within the UnmarshalYAML method of a +// union struct. It will use the given input data's "type" field and match that +// to the struct field tagged with that value. it will then unmarshal the input +// data into that inner field. +func UnmarshalYAML(i interface{}, unmarshal func(interface{}) error) error { + val := reflect.Indirect(reflect.ValueOf(i)) + if !val.CanSet() { + return fmt.Errorf("cannot unmarshal into value of type %T", i) + } + + // unmarshal in all non-typeobj fields. construct a type which wraps the + // given one, hiding its UnmarshalYAML method (if it has one), and unmarshal + // onto that directly. The "type" field is also unmarshaled at this stage. + valWrap := reflect.New(reflect.StructOf([]reflect.StructField{ + reflect.StructField{ + Name: "Type", + Type: typeOfString, + Tag: `yaml:"type"`, + }, + { + Name: "Val", + Type: val.Type(), + Tag: `yaml:",inline"`, + }, + })) + if err := unmarshal(valWrap.Interface()); err != nil { + return err + } + typeVal := valWrap.Elem().Field(0).String() + val.Set(valWrap.Elem().Field(1)) + + typ := val.Type() + for i := 0; i < val.NumField(); i++ { + fieldVal, fieldTyp := val.Field(i), typ.Field(i) + if fieldTyp.Tag.Get("type") != typeVal { + continue + } + + var valInto interface{} + if fieldVal.Kind() == reflect.Ptr { + newFieldVal := reflect.New(fieldTyp.Type.Elem()) + fieldVal.Set(newFieldVal) + valInto = newFieldVal.Interface() + } else { + valInto = fieldVal.Addr().Interface() + } + return unmarshal(valInto) + } + + return fmt.Errorf("invalid type value %q", typeVal) +} + +// val should be of kind struct +func element(val reflect.Value) (reflect.Value, string, []int, error) { + typ := val.Type() + numFields := val.NumField() + + var fieldVal reflect.Value + var typeTag string + nonTypeFields := make([]int, 0, numFields) + for i := 0; i < numFields; i++ { + innerFieldVal := val.Field(i) + innerTypeTag := typ.Field(i).Tag.Get("type") + if innerTypeTag == "" { + nonTypeFields = append(nonTypeFields, i) + } else if innerFieldVal.IsZero() { + continue + } else { + fieldVal = innerFieldVal + typeTag = innerTypeTag + } + } + + if fieldVal.IsZero() { + return reflect.Value{}, "", nil, errors.New(`no non-zero fields tagged with "type"`) + } + return fieldVal, typeTag, nonTypeFields, nil +} + +// Element returns the value of the first non-zero field tagged with "type", as +// well as the value of the "type" tag. +func Element(i interface{}) (interface{}, string, error) { + val := reflect.Indirect(reflect.ValueOf(i)) + fieldVal, tag, _, err := element(val) + if err != nil { + return fieldVal, tag, err + } + return fieldVal.Interface(), tag, nil +} + +var typeOfString = reflect.TypeOf("string") + +// MarshalYAML is intended to be used within the MarshalYAML method of a union +// struct. It will find the first field of the given struct which has a "type" +// tag and is non-zero. It will then marshal that field's value, inlining an +// extra YAML field "type" whose value is the value of the "type" tag on the +// struct field, and return that. +func MarshalYAML(i interface{}) (interface{}, error) { + val := reflect.Indirect(reflect.ValueOf(i)) + typ := val.Type() + fieldVal, typeTag, nonTypeFields, err := element(val) + if err != nil { + return nil, err + } + + fieldVal = reflect.Indirect(fieldVal) + if fieldVal.Kind() != reflect.Struct { + return nil, fmt.Errorf("cannot marshal non-struct type %T", fieldVal.Interface()) + } + + structFields := make([]reflect.StructField, 0, len(nonTypeFields)+2) + structFields = append(structFields, + reflect.StructField{ + Name: "Type", + Type: typeOfString, + Tag: `yaml:"type"`, + }, + reflect.StructField{ + Name: "Val", + Type: fieldVal.Type(), + Tag: `yaml:",inline"`, + }, + ) + + nonTypeFieldVals := make([]reflect.Value, len(nonTypeFields)) + for i, fieldIndex := range nonTypeFields { + fieldVal, fieldType := val.Field(fieldIndex), typ.Field(fieldIndex) + structFields = append(structFields, fieldType) + nonTypeFieldVals[i] = fieldVal + } + + outVal := reflect.New(reflect.StructOf(structFields)) + outVal.Elem().Field(0).Set(reflect.ValueOf(typeTag)) + outVal.Elem().Field(1).Set(fieldVal) + for i, fieldVal := range nonTypeFieldVals { + outVal.Elem().Field(2 + i).Set(fieldVal) + } + + return outVal.Interface(), nil +} diff --git a/typeobj/typeobj_test.go b/typeobj/typeobj_test.go new file mode 100644 index 0000000..0939578 --- /dev/null +++ b/typeobj/typeobj_test.go @@ -0,0 +1,114 @@ +package typeobj + +import ( + "reflect" + "testing" + + "github.com/davecgh/go-spew/spew" + "gopkg.in/yaml.v2" +) + +type foo struct { + A int `yaml:"a"` +} + +type bar struct { + B int `yaml:"b"` +} + +type outer struct { + Foo foo `type:"foo"` + Bar *bar `type:"bar"` + + Other string `yaml:"other_field,omitempty"` +} + +func (o outer) MarshalYAML() (interface{}, error) { + return MarshalYAML(o) +} + +func (o *outer) UnmarshalYAML(unmarshal func(interface{}) error) error { + return UnmarshalYAML(o, unmarshal) +} + +func TestTypeObj(t *testing.T) { + + type test struct { + descr string + str string + err bool + other string + + obj outer + typeTag string + elem interface{} + } + + tests := []test{ + { + descr: "no type set", + str: `{}`, + err: true, + }, + { + descr: "unknown type set", + str: "type: baz", + err: true, + }, + { + descr: "foo set", + str: "type: foo\na: 1\n", + obj: outer{Foo: foo{A: 1}}, + typeTag: "foo", + elem: foo{A: 1}, + }, + { + descr: "bar set", + str: "type: bar\nb: 1\n", + obj: outer{Bar: &bar{B: 1}}, + typeTag: "bar", + elem: &bar{B: 1}, + }, + { + descr: "foo and other_field set", + str: "type: foo\na: 1\nother_field: aaa\n", + obj: outer{Foo: foo{A: 1}, Other: "aaa"}, + typeTag: "foo", + elem: foo{A: 1}, + }, + } + + for _, test := range tests { + t.Run(test.descr, func(t *testing.T) { + var o outer + err := yaml.Unmarshal([]byte(test.str), &o) + if test.err && err != nil { + return + } else if test.err && err == nil { + t.Fatal("expected error when unmarshaling but there was none") + } else if !test.err && err != nil { + t.Fatalf("unmarshaling %q returned unexpected error: %v", test.str, err) + } + + if !reflect.DeepEqual(o, test.obj) { + t.Fatalf("test expected value:\n%s\nbut got value:\n%s", spew.Sprint(test.obj), spew.Sprint(o)) + } + + elem, typeTag, err := Element(o) + if err != nil { + t.Fatalf("error when calling Element(%s): %v", spew.Sprint(o), err) + } else if !reflect.DeepEqual(elem, test.elem) { + t.Fatalf("test expected elem value:\n%s\nbut got value:\n%s", spew.Sprint(test.elem), spew.Sprint(elem)) + } else if typeTag != test.typeTag { + t.Fatalf("test expected typeTag value %q but got %q", test.typeTag, typeTag) + } + + b, err := yaml.Marshal(o) + if err != nil { + t.Fatalf("error marshaling %s: %v", spew.Sprint(o), err) + } else if test.str != string(b) { + t.Fatalf("test expected to marshal to %q, but instead marshaled to %q", test.str, b) + } + }) + } +} diff --git a/yamlutil/yamlutil.go b/yamlutil/yamlutil.go new file mode 100644 index 0000000..f4ef7b4 --- /dev/null +++ b/yamlutil/yamlutil.go @@ -0,0 +1,32 @@ +// Package yamlutil contains utility types which are useful for dealing with the +// yaml package. +package yamlutil + +import ( + "encoding/base64" +) + +// Blob encodes and decodes a byte slice as a standard base-64 encoded yaml +// string. +type Blob []byte + +// MarshalYAML implements the yaml.Marshaler interface. +func (b Blob) MarshalYAML() (interface{}, error) { + return base64.StdEncoding.EncodeToString([]byte(b)), nil +} + +// UnmarshalYAML implements the yaml.Unmarshaler interface. +func (b *Blob) UnmarshalYAML(unmarshal func(interface{}) error) error { + var b64 string + if err := unmarshal(&b64); err != nil { + return err + } + + b64Dec, err := base64.StdEncoding.DecodeString(b64) + if err != nil { + return err + } + + *b = b64Dec + return nil +} diff --git a/yamlutil/yamlutil_test.go b/yamlutil/yamlutil_test.go new file mode 100644 index 0000000..4f4854a --- /dev/null +++ b/yamlutil/yamlutil_test.go @@ -0,0 +1,55 @@ +package yamlutil + +import ( + "bytes" + "testing" + + yaml "gopkg.in/yaml.v2" +) + +func TestBlob(t *testing.T) { + testCases := []struct { + descr string + in Blob + exp string + }{ + { + descr: "empty", + in: Blob(""), + exp: `""`, + }, + { + descr: "zero", + in: Blob{0}, + exp: "AA==", + }, + { + descr: "zeros", + in: Blob{0, 0, 0}, + exp: "AAAA", + }, + { + descr: "foo", + in: Blob("foo"), + exp: "Zm9v", + }, + } + + for _, test := range testCases { + t.Run(test.descr, func(t *testing.T) { + out, err := yaml.Marshal(test.in) + if err != nil { + t.Fatalf("error marshaling %q: %v", test.in, err) + } else if test.exp+"\n" != string(out) { + t.Fatalf("marshal exp:%q got:%q", test.exp+"\n", out) + } + + var blob Blob + if err := yaml.Unmarshal(out, &blob); err != nil { + t.Fatalf("error unmarshaling %q: %v", out, err) + } else if !bytes.Equal([]byte(blob), []byte(test.in)) { + t.Fatalf("unmarshal exp:%q got:%q", test.in, blob) + } + }) + } +}