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:
commit
7c891bd5f2
14
.dehub/config.yml
Normal file
14
.dehub/config.yml
Normal 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
51
.dehub/mediocregopher.asc
Normal 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
1
.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
||||
dehub
|
195
SPEC.md
Normal file
195
SPEC.md
Normal 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.
|
53
accessctl/access_control.go
Normal file
53
accessctl/access_control.go
Normal 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
|
||||
}
|
118
accessctl/access_control_test.go
Normal file
118
accessctl/access_control_test.go
Normal 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
130
accessctl/condition.go
Normal 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
110
accessctl/condition_test.go
Normal 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
75
change_hash.go
Normal 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
136
cmd/dehub/main.go
Normal 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
251
commit.go
Normal 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
170
commit_test.go
Normal 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
83
config.go
Normal 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
41
diff.go
Normal 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
117
fs/build_tree_helper.go
Normal 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( |