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
This commit is contained in:
mediocregopher 2020-02-15 15:13:50 -07:00
commit 7c891bd5f2
29 changed files with 2745 additions and 0 deletions

14
.dehub/config.yml Normal file
View File

@ -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%

51
.dehub/mediocregopher.asc Normal file
View File

@ -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-----

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
dehub

195
SPEC.md Normal file
View File

@ -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.

View File

@ -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
}

View File

@ -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)
}
})
}
}

130
accessctl/condition.go Normal file
View File

@ -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
}

110
accessctl/condition_test.go Normal file
View File

@ -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)
}
})
}
}

75
change_hash.go Normal file
View File

@ -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)
}

136
cmd/dehub/main.go Normal file
View File

@ -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 <command> [-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()
}

251
commit.go Normal file
View File

@ -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
}

170
commit_test.go Normal file
View File

@ -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)
}
}
}

83
config.go Normal file
View File

@ -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")
}

41
diff.go Normal file
View File

@ -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
}

117
fs/build_tree_helper.go Normal file
View File

@ -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)
}

100
fs/fs.go Normal file
View File

@ -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
}

12
go.mod Normal file
View File

@ -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
)

82
go.sum Normal file
View File

@ -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=

104
repo.go Normal file
View File

@ -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
}

118
repo_test.go Normal file
View File

@ -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
}

25
sigcred/credential.go Normal file
View File

@ -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)
}

279
sigcred/pgp.go Normal file
View File

@ -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)
}

66
sigcred/pgp_test.go Normal file
View File

@ -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)
}
})
}
}

5
sigcred/sigcred.go Normal file
View File

@ -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

50
sigcred/signifier.go Normal file
View File

@ -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
}

158
typeobj/typeobj.go Normal file
View File

@ -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
}

114
typeobj/typeobj_test.go Normal file
View File

@ -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)
}
})
}
}

32
yamlutil/yamlutil.go Normal file
View File

@ -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
}

55
yamlutil/yamlutil_test.go Normal file
View File

@ -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)
}
})
}
}