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: mediocregopherpublic/welcome
commit
7c891bd5f2
@ -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% |
@ -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----- |
@ -0,0 +1 @@ |
|||||||
|
dehub |
@ -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. |
@ -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 |
||||||
|
} |
@ -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) |
||||||
|
} |
||||||
|
}) |
||||||
|
} |
||||||
|
} |
@ -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 |
||||||
|
} |
@ -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) |
||||||
|
} |
||||||
|
}) |
||||||
|
} |
||||||
|
} |
@ -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) |
||||||
|
} |
@ -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() |
||||||
|
} |
@ -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 |
||||||
|
} |
@ -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) |
||||||
|
} |
||||||
|
} |
||||||
|
} |
@ -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") |
||||||
|
} |
@ -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 |
||||||
|
} |
@ -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) |
||||||
|
} |
@ -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 |
||||||
|
} |
@ -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 |
||||||
|
) |
@ -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= |
@ -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 |
||||||
|
} |
@ -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 |
||||||
|
} |
@ -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) |
||||||
|
} |
@ -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) |
||||||
|
} |
@ -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) |
||||||
|
} |
||||||
|
}) |
||||||
|
} |
||||||
|
} |
@ -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 |
@ -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 |
||||||
|
} |
@ -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 |
||||||
|
} |
@ -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) |
||||||
|
} |
||||||
|
}) |
||||||
|
} |
||||||
|
} |
@ -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 |
||||||
|
} |
@ -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) |
||||||
|
} |
||||||
|
}) |
||||||
|
} |
||||||
|
} |
Loading…
Reference in new issue