Compare commits
69 Commits
public/wel
...
main
Author | SHA1 | Date |
---|---|---|
mediocregopher | cefe5633e3 | 4 years ago |
mediocregopher | ff041c74aa | 4 years ago |
mediocregopher | 68b4e68177 | 4 years ago |
mediocregopher | a709a43696 | 4 years ago |
mediocregopher | a1579dcc96 | 4 years ago |
mediocregopher | 3cd0b03202 | 4 years ago |
mediocregopher | 6176b9ffbc | 4 years ago |
mediocregopher | f085f13fa8 | 4 years ago |
mediocregopher | f52ea2a708 | 4 years ago |
mediocregopher | 38d396e90c | 4 years ago |
mediocregopher | 23fa9da972 | 4 years ago |
mediocregopher | 4389da48e4 | 4 years ago |
mediocregopher | 3ec0908b32 | 4 years ago |
mediocregopher | 422c444a50 | 4 years ago |
mediocregopher | b01fe1524a | 4 years ago |
mediocregopher | 351048e9aa | 4 years ago |
mediocregopher | c2c7fdf691 | 4 years ago |
mediocregopher | 84399603cf | 4 years ago |
mediocregopher | 1f892070bd | 4 years ago |
mediocregopher | 3d89fe5fd9 | 4 years ago |
mediocregopher | 43b564e711 | 4 years ago |
mediocregopher | a01f2b1512 | 4 years ago |
mediocregopher | 03459d4b20 | 4 years ago |
mediocregopher | e78efcce74 | 4 years ago |
mediocregopher | ca4887bf07 | 4 years ago |
mediocregopher | 54af1ee510 | 4 years ago |
mediocregopher | d189d46667 | 4 years ago |
mediocregopher | 921572d053 | 4 years ago |
mediocregopher | d5f172875e | 4 years ago |
mediocregopher | 4d56716fe8 | 4 years ago |
mediocregopher | e91f7b060e | 4 years ago |
mediocregopher | ee19d2c37e | 4 years ago |
mediocregopher | d6f5bf2e38 | 4 years ago |
mediocregopher | a3b98cc4ef | 4 years ago |
mediocregopher | 2390197ae3 | 4 years ago |
mediocregopher | a8c7f92328 | 4 years ago |
mediocregopher | aa1a4969f3 | 4 years ago |
mediocregopher | f3226d6171 | 4 years ago |
mediocregopher | 8cbdc03caa | 4 years ago |
mediocregopher | 1c2bc11fc3 | 4 years ago |
mediocregopher | a47404b4a7 | 4 years ago |
mediocregopher | eeb74ea22b | 4 years ago |
mediocregopher | b74186446e | 4 years ago |
mediocregopher | 1f422511d5 | 4 years ago |
mediocregopher | 5ebb6597a8 | 4 years ago |
mediocregopher | 326de2afc6 | 4 years ago |
mediocregopher | 69e336ea5e | 4 years ago |
mediocregopher | a580018e1e | 4 years ago |
mediocregopher | aff3daab19 | 4 years ago |
mediocregopher | c87baa5192 | 4 years ago |
mediocregopher | 51af20fbc0 | 4 years ago |
mediocregopher | cf05b3a072 | 4 years ago |
mediocregopher | 9bfd012221 | 4 years ago |
mediocregopher | 981fbb8327 | 4 years ago |
mediocregopher | 1db3f2bd1e | 4 years ago |
mediocregopher | 0af9c04e33 | 4 years ago |
mediocregopher | 76309b51cb | 4 years ago |
mediocregopher | 2add3a2501 | 4 years ago |
mediocregopher | 98d8aed08a | 4 years ago |
mediocregopher | 3344b8372d | 4 years ago |
mediocregopher | 492e7242c6 | 4 years ago |
mediocregopher | b565d26d1f | 4 years ago |
mediocregopher | 14c37ae1ba | 4 years ago |
mediocregopher | 85d79ff71a | 4 years ago |
mediocregopher | 72f2a74415 | 4 years ago |
mediocregopher | a5bee27892 | 4 years ago |
mediocregopher | 181802ba0e | 4 years ago |
mediocregopher | f0310bda75 | 4 years ago |
mediocregopher | 3f4d48ff89 | 4 years ago |
@ -1 +1,3 @@ |
|||||||
dehub |
/dehub |
||||||
|
/git-http-server |
||||||
|
/cmd/git-http-server/git-http-server |
||||||
|
@ -0,0 +1,43 @@ |
|||||||
|
FROM golang:1.14 |
||||||
|
WORKDIR /go/src/dehub |
||||||
|
COPY . . |
||||||
|
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o /usr/bin/dehub ./cmd/dehub |
||||||
|
|
||||||
|
WORKDIR /go/src/dehub/cmd/git-http-server |
||||||
|
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o /usr/bin/git-http-server . |
||||||
|
|
||||||
|
FROM debian:jessie |
||||||
|
|
||||||
|
# Setup Container |
||||||
|
VOLUME ["/repos"] |
||||||
|
EXPOSE 80 |
||||||
|
|
||||||
|
# Setup APT |
||||||
|
RUN echo 'debconf debconf/frontend select Noninteractive' | debconf-set-selections |
||||||
|
|
||||||
|
# Update, Install Prerequisites, Clean Up APT |
||||||
|
RUN DEBIAN_FRONTEND=noninteractive apt-get -y update && \ |
||||||
|
apt-get -y install git wget nginx-full fcgiwrap && \ |
||||||
|
apt-get clean |
||||||
|
|
||||||
|
# Setup Container User |
||||||
|
RUN useradd -M -s /bin/false git --uid 1000 |
||||||
|
|
||||||
|
# Setup nginx fcgi services to run as user git, group git |
||||||
|
RUN sed -i 's/FCGI_USER="www-data"/FCGI_USER="git"/g' /etc/init.d/fcgiwrap && \ |
||||||
|
sed -i 's/FCGI_GROUP="www-data"/FCGI_GROUP="git"/g' /etc/init.d/fcgiwrap && \ |
||||||
|
sed -i 's/FCGI_SOCKET_OWNER="www-data"/FCGI_SOCKET_OWNER="git"/g' /etc/init.d/fcgiwrap && \ |
||||||
|
sed -i 's/FCGI_SOCKET_GROUP="www-data"/FCGI_SOCKET_GROUP="git"/g' /etc/init.d/fcgiwrap |
||||||
|
|
||||||
|
# Copy binaries |
||||||
|
COPY --from=0 /usr/bin/dehub /usr/bin/dehub |
||||||
|
COPY --from=0 /usr/bin/git-http-server /usr/bin/git-http-server |
||||||
|
|
||||||
|
# Create config files for container startup and nginx |
||||||
|
COPY cmd/dehub-remote/nginx.conf /etc/nginx/nginx.conf |
||||||
|
|
||||||
|
# Create start.sh |
||||||
|
COPY cmd/dehub-remote/start.sh /start.sh |
||||||
|
RUN chmod +x /start.sh |
||||||
|
|
||||||
|
ENTRYPOINT ["/start.sh"] |
@ -0,0 +1,104 @@ |
|||||||
|
# dehub |
||||||
|
|
||||||
|
dehub aims to provide all the features of a git hosting platform, but without |
||||||
|
the hosting part. These features include: |
||||||
|
|
||||||
|
**User management** - Authentication that commits come from the user they say |
||||||
|
they do, and fine-grained control over which users can do what. |
||||||
|
|
||||||
|
**Pull requests and issues** - Facilitation of discussion via comment commits, |
||||||
|
and fine-grained (down to the file level) sign-off requirements. |
||||||
|
|
||||||
|
**Tags and releases** - Mark releases in the repo itself, and provide |
||||||
|
immutable and verifiable git tags so there's never any funny business. (Not yet |
||||||
|
implemented) |
||||||
|
|
||||||
|
**Plugins**: Extend all aspects of dehub functionality via executables managed |
||||||
|
in the repo itself (in the same style as git hooks). (Not yet implemented) |
||||||
|
|
||||||
|
## Key Concepts |
||||||
|
|
||||||
|
To implement these features, dehub combines two key concepts: |
||||||
|
|
||||||
|
First, repo configuration is defined in the repo itself. A file called |
||||||
|
`.dehub/config.yml` contains all information related to user accounts, their pgp |
||||||
|
keys, branch and file level access controls, and more. Every commit must adhere |
||||||
|
to the configuration of its parent in order to be considered _verifiable_. The |
||||||
|
configuration file is committed to the repo like any other file would be, and so |
||||||
|
is even able to define the access controls on itself. |
||||||
|
|
||||||
|
Second, the commit message of every dehub commit contains a YAML encoded |
||||||
|
payload, which allows dehub to extend git and provide multiple commit types, |
||||||
|
each with its own capabilities and restrictions. Some example dehub commit types |
||||||
|
are `change` commits, `comment` commits, and `credential` commits. |
||||||
|
|
||||||
|
## Infrastructure (or lack thereof) |
||||||
|
|
||||||
|
Because a dehub project is entirely housed within a traditional git project, |
||||||
|
which is merely a collection of files, any existing git or network filesystem |
||||||
|
infrastructure can be used to host any dehub project: |
||||||
|
|
||||||
|
* The most barebones [git |
||||||
|
daemon](https://git-scm.com/book/en/v2/Git-on-the-Server-Git-Daemon) server |
||||||
|
(with a simple pre-receive hook set up). |
||||||
|
|
||||||
|
* A remote SSH endpoint. |
||||||
|
|
||||||
|
* A mailing list (aka the old school way). |
||||||
|
|
||||||
|
* Network file syncing utilities such as dropbox, |
||||||
|
[syncthing](https://github.com/syncthing/syncthing), or |
||||||
|
[NFS](https://en.wikipedia.org/wiki/Network_File_System). |
||||||
|
|
||||||
|
* Existing git project hosts like GitHub, Bitbucket, or Keybase. |
||||||
|
|
||||||
|
* Decentralized filesystems such as IPFS. (Not yet implemented) |
||||||
|
|
||||||
|
## Getting Started {#getting-started} |
||||||
|
|
||||||
|
The dehub project itself can be found by cloning |
||||||
|
`https://dehub.dev/src/dehub.git`. |
||||||
|
|
||||||
|
Installation of the dehub tool is currently done via the `go get` command: |
||||||
|
|
||||||
|
``` |
||||||
|
go get -u -v dehub.dev/src/dehub.git/cmd/dehub |
||||||
|
``` |
||||||
|
|
||||||
|
This will install the binary to your `$GOBIN` path, which you'll want to put in |
||||||
|
your `$PATH`. Run `go env` if you're not sure where your `$GOBIN` is. |
||||||
|
|
||||||
|
Once installed, running `dehub -h` should show you the help output of the |
||||||
|
command. You can continue on to the tutorials if you're not sure where to go |
||||||
|
from here. |
||||||
|
|
||||||
|
### Tutorials {#tutorials} |
||||||
|
|
||||||
|
The following tutorials will guide you through the basic usage of dehub. Note |
||||||
|
that dehub is in the infancy of its development, and so a certain level of |
||||||
|
profiency with git and PGP is required in order to follow these tutorials. |
||||||
|
|
||||||
|
* [Tutorial 0: Say Hello!](/docs/tut0.html) |
||||||
|
* [Tutorial 1: Create Your Own Project](/docs/tut1.html) |
||||||
|
* [Tutorial 2: Access Controls](/docs/tut2.html) |
||||||
|
* [Tutorial 3: Commit Sign-Off](/docs/tut3.html) |
||||||
|
|
||||||
|
### Documentation |
||||||
|
|
||||||
|
The [SPEC](/docs/SPEC.html) is the best place to see every possible nitty-gritty |
||||||
|
detail of how dehub works. It attempts to be both human-readable and exhaustive |
||||||
|
in its coverage. |
||||||
|
|
||||||
|
### Other links |
||||||
|
|
||||||
|
[ROADMAP](/docs/ROADMAP.html) documents upcoming features and other work |
||||||
|
required on the project. If you're looking to contribute, this is a great place |
||||||
|
to start. |
||||||
|
|
||||||
|
[dehub-remote](/cmd/dehub-remote/) is a simple docker image which can be used to |
||||||
|
host a remote dehub project over http(s). The endpoint will automatically verify |
||||||
|
all pushed commits. |
||||||
|
|
||||||
|
[git-http-server](/cmd/git-http-server/) is a small server which makes a git |
||||||
|
repo's file tree available via http. It will automatically render markdown files |
||||||
|
to html as well. git-http-server is used to render dehub's website. |
@ -1,195 +0,0 @@ |
|||||||
# .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. |
|
@ -1,53 +1,155 @@ |
|||||||
|
// Package accessctl implements functionality related to allowing or denying
|
||||||
|
// actions in a repo based on who is taking what actions.
|
||||||
package accessctl |
package accessctl |
||||||
|
|
||||||
import ( |
import ( |
||||||
|
"errors" |
||||||
"fmt" |
"fmt" |
||||||
|
|
||||||
"github.com/bmatcuk/doublestar" |
"dehub.dev/src/dehub.git/sigcred" |
||||||
|
|
||||||
|
yaml "gopkg.in/yaml.v2" |
||||||
|
) |
||||||
|
|
||||||
|
// DefaultAccessControlsStr is the encoded form of the default access control
|
||||||
|
// set which is applied to all CommitRequests if no user-supplied ones match.
|
||||||
|
//
|
||||||
|
// The effect of these AccessControls is to allow all commit types on any branch
|
||||||
|
// (with the exception of the main branch, which only allows change commits), as
|
||||||
|
// long as the commit has one signature from a configured account.
|
||||||
|
var DefaultAccessControlsStr = ` |
||||||
|
- action: allow |
||||||
|
filters: |
||||||
|
- type: not |
||||||
|
filter: |
||||||
|
type: branch |
||||||
|
pattern: main |
||||||
|
- type: signature |
||||||
|
any_account: true |
||||||
|
count: 1 |
||||||
|
|
||||||
|
- action: deny |
||||||
|
filters: |
||||||
|
- type: commit_attributes |
||||||
|
non_fast_forward: true |
||||||
|
|
||||||
|
- action: allow |
||||||
|
filters: |
||||||
|
- type: branch |
||||||
|
pattern: main |
||||||
|
- type: payload_type |
||||||
|
payload_type: change |
||||||
|
- type: signature |
||||||
|
any_account: true |
||||||
|
count: 1 |
||||||
|
|
||||||
|
- action: deny |
||||||
|
` |
||||||
|
|
||||||
|
// DefaultAccessControls is the decoded form of DefaultAccessControlsStr.
|
||||||
|
var DefaultAccessControls = func() []AccessControl { |
||||||
|
var acl []AccessControl |
||||||
|
if err := yaml.Unmarshal([]byte(DefaultAccessControlsStr), &acl); err != nil { |
||||||
|
panic(err) |
||||||
|
} |
||||||
|
return acl |
||||||
|
}() |
||||||
|
|
||||||
|
// CommitRequest is used to describe a set of interactions which are being
|
||||||
|
// requested to be performed.
|
||||||
|
type CommitRequest struct { |
||||||
|
// Type describes what type of commit is being requested. Possibilities are
|
||||||
|
// determined by the requester.
|
||||||
|
Type string |
||||||
|
|
||||||
|
// Branch is the name of the branch the interactions are being attempted on.
|
||||||
|
// It is required.
|
||||||
|
Branch string |
||||||
|
|
||||||
|
// Credentials are the credentials attached to the commit.
|
||||||
|
Credentials []sigcred.CredentialUnion |
||||||
|
|
||||||
|
// FilesChanged is the set of file paths (relative to the repo root) which
|
||||||
|
// have been modified in some way.
|
||||||
|
FilesChanged []string |
||||||
|
|
||||||
|
// NonFastForward should be set to true if the branch HEAD and this commit
|
||||||
|
// are not directly related (i.e. neither is a direct ancestor of the
|
||||||
|
// other).
|
||||||
|
NonFastForward bool |
||||||
|
} |
||||||
|
|
||||||
|
// Action describes what action an AccessControl should perform
|
||||||
|
// when given a CommitRequest.
|
||||||
|
type Action string |
||||||
|
|
||||||
|
// Enumerates possible Action values
|
||||||
|
const ( |
||||||
|
ActionAllow Action = "allow" |
||||||
|
ActionDeny Action = "deny" |
||||||
|
|
||||||
|
// ActionNext is used internally when a request does not match an
|
||||||
|
// AccessControl's filters. It _could_ be used in the Config as well, but it
|
||||||
|
// would be pretty pointless to do so, so we don't talk about it.
|
||||||
|
ActionNext Action = "next" |
||||||
) |
) |
||||||
|
|
||||||
// AccessControl represents an access control object being defined in the
|
// AccessControl describes a set of Filters, and the Actions which should be
|
||||||
// Config.
|
// taken on a CommitRequest if those Filters all match on the CommitRequest.
|
||||||
type AccessControl struct { |
type AccessControl struct { |
||||||
Pattern string `yaml:"pattern"` |
Action Action `yaml:"action"` |
||||||
Condition Condition `yaml:"condition"` |
Filters []FilterUnion `yaml:"filters"` |
||||||
} |
} |
||||||
|
|
||||||
// ErrNoApplicableAccessControls is returned from ApplicableAccessControls when
|
// ActionForCommit returns what Action this AccessControl says to take for a
|
||||||
// a changed path has no applicable AccessControls which match it.
|
// given CommitRequest. It may return ActionNext if the request is not matched
|
||||||
type ErrNoApplicableAccessControls struct { |
// by the AccessControl's Filters.
|
||||||
Path string |
func (ac AccessControl) ActionForCommit(req CommitRequest) (Action, error) { |
||||||
|
for _, filterUn := range ac.Filters { |
||||||
|
if err := filterUn.Filter().MatchCommit(req); errors.As(err, new(ErrFilterNoMatch)) { |
||||||
|
return ActionNext, nil |
||||||
|
|
||||||
|
} else if err != nil { |
||||||
|
return "", fmt.Errorf("matching commit using filter of type %q: %w", filterUn.Type(), err) |
||||||
|
} |
||||||
|
} |
||||||
|
return ac.Action, nil |
||||||
} |
} |
||||||
|
|
||||||
func (err ErrNoApplicableAccessControls) Error() string { |
// ErrCommitRequestDenied is returned from AssertCanCommit when a particular
|
||||||
return fmt.Sprintf("no AccessControls which apply to changed file %q", err.Path) |
// AccessControl has explicitly disallowed the CommitRequest.
|
||||||
|
type ErrCommitRequestDenied struct { |
||||||
|
By AccessControl |
||||||
} |
} |
||||||
|
|
||||||
// ApplicableAccessControls returns a subset of the given AccessControls which
|
func (e ErrCommitRequestDenied) Error() string { |
||||||
// are applicable to the given file paths (ie those whose Conditions must be met
|
acB, err := yaml.Marshal(e.By) |
||||||
// in order for the changes to go through.
|
if err != nil { |
||||||
func ApplicableAccessControls(accessControls []AccessControl, filesChanged []string) ([]AccessControl, error) { |
panic(err) |
||||||
applicableSet := map[AccessControl]struct{}{} |
} |
||||||
for _, path := range filesChanged { |
return fmt.Sprintf("commit matched and denied by this access control:\n%s", string(acB)) |
||||||
var any bool |
} |
||||||
for _, ac := range accessControls { |
|
||||||
if ok, err := doublestar.PathMatch(ac.Pattern, path); err != nil { |
// AssertCanCommit asserts that the given CommitRequest is allowed by the given
|
||||||
return nil, fmt.Errorf("error matching path %q to patterrn %q: %w", |
// AccessControls.
|
||||||
path, ac.Pattern, err) |
func AssertCanCommit(acl []AccessControl, req CommitRequest) error { |
||||||
} else if ok { |
acl = append(acl, DefaultAccessControls...) |
||||||
applicableSet[ac] = struct{}{} |
for _, ac := range acl { |
||||||
any = true |
action, err := ac.ActionForCommit(req) |
||||||
break |
if err != nil { |
||||||
} |
return err |
||||||
} |
} |
||||||
if !any { |
switch action { |
||||||
return nil, ErrNoApplicableAccessControls{Path: path} |
case ActionNext: |
||||||
|
continue |
||||||
|
case ActionAllow: |
||||||
|
return nil |
||||||
|
case ActionDeny: |
||||||
|
return ErrCommitRequestDenied{By: ac} |
||||||
|
default: |
||||||
|
return fmt.Errorf("invalid action %q", action) |
||||||
} |
} |
||||||
} |
} |
||||||
|
|
||||||
applicable := make([]AccessControl, 0, len(applicableSet)) |
panic("should not be able to get here") |
||||||
for ac := range applicableSet { |
|
||||||
applicable = append(applicable, ac) |
|
||||||
} |
|
||||||
return applicable, nil |
|
||||||
} |
} |
||||||
|
@ -1,130 +0,0 @@ |
|||||||
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 |
|
||||||
} |
|
@ -1,110 +0,0 @@ |
|||||||
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,124 @@ |
|||||||
|
package accessctl |
||||||
|
|
||||||
|
import ( |
||||||
|
"errors" |
||||||
|
"fmt" |
||||||
|
|
||||||
|
"dehub.dev/src/dehub.git/typeobj" |
||||||
|
) |
||||||
|
|
||||||
|
// ErrFilterNoMatch is returned from a FilterInterface's Match method when the
|
||||||
|
// given request was not matched to the filter due to the request itself (as
|
||||||
|
// opposed to some error in the filter's definition).
|
||||||
|
type ErrFilterNoMatch struct { |
||||||
|
Err error |
||||||
|
} |
||||||
|
|
||||||
|
func (err ErrFilterNoMatch) Error() string { |
||||||
|
return fmt.Sprintf("matching with filter: %s", err.Err.Error()) |
||||||
|
} |
||||||
|
|
||||||
|
// Filter describes the methods that all Filters must implement.
|
||||||
|
type Filter interface { |
||||||
|
// MatchCommit returns nil if the CommitRequest is matched by the filter,
|
||||||
|
// otherwise it returns an error (ErrFilterNoMatch if the error is due to
|
||||||
|
// the CommitRequest).
|
||||||
|
MatchCommit(CommitRequest) error |
||||||
|
} |
||||||
|
|
||||||
|
// FilterUnion represents an access control filter being defined in the Config.
|
||||||
|
// Only one of its fields may be filled at a time.
|
||||||
|
type FilterUnion struct { |
||||||
|
Signature *FilterSignature `type:"signature"` |
||||||
|
Branch *FilterBranch `type:"branch"` |
||||||
|
FilesChanged *FilterFilesChanged `type:"files_changed"` |
||||||
|
PayloadType *FilterPayloadType `type:"payload_type"` |
||||||
|
CommitAttributes *FilterCommitAttributes `type:"commit_attributes"` |
||||||
|
Not *FilterNot `type:"not"` |
||||||
|
} |
||||||
|
|
||||||
|
// MarshalYAML implements the yaml.Marshaler interface.
|
||||||
|
func (f FilterUnion) MarshalYAML() (interface{}, error) { |
||||||
|
return typeobj.MarshalYAML(f) |
||||||
|
} |
||||||
|
|
||||||
|
// UnmarshalYAML implements the yaml.Unmarshaler interface.
|
||||||
|
func (f *FilterUnion) UnmarshalYAML(unmarshal func(interface{}) error) error { |
||||||
|
return typeobj.UnmarshalYAML(f, unmarshal) |
||||||
|
} |
||||||
|
|
||||||
|
// Filter returns the Filter encapsulated by this FilterUnion.
|
||||||
|
//
|
||||||
|
// This method will panic if a Filter field is not populated.
|
||||||
|
func (f FilterUnion) Filter() Filter { |
||||||
|
el, _, err := typeobj.Element(f) |
||||||
|
if err != nil { |
||||||
|
panic(err) |
||||||
|
} |
||||||
|
return el.(Filter) |
||||||
|
} |
||||||
|
|
||||||
|
// Type returns the Filter's type (as would be used in its YAML "type" field).
|
||||||
|
//
|
||||||
|
// This will panic if a Filter field is not populated.
|
||||||
|
func (f FilterUnion) Type() string { |
||||||
|
_, typeStr, err := typeobj.Element(f) |
||||||
|
if err != nil { |
||||||
|
panic(err) |
||||||
|
} |
||||||
|
return typeStr |
||||||
|
} |
||||||
|
|
||||||
|
// FilterPayloadType filters by what type of payload is being requested. Exactly
|
||||||
|
// one of its fields should be filled.
|
||||||
|
type FilterPayloadType struct { |
||||||
|
Type string `yaml:"payload_type"` |
||||||
|
Types []string `yaml:"payload_types"` |
||||||
|
} |
||||||
|
|
||||||
|
var _ Filter = FilterPayloadType{} |
||||||
|
|
||||||
|
// MatchCommit implements the method for FilterInterface.
|
||||||
|
func (f FilterPayloadType) MatchCommit(req CommitRequest) error { |
||||||
|
switch { |
||||||
|
case f.Type != "": |
||||||
|
if f.Type != req.Type { |
||||||
|
return ErrFilterNoMatch{ |
||||||
|
Err: fmt.Errorf("payload type %q does not match filter's type %q", |
||||||
|
req.Type, f.Type), |
||||||
|
} |
||||||
|
} |
||||||
|
return nil |
||||||
|
|
||||||
|
case len(f.Types) > 0: |
||||||
|
for _, typ := range f.Types { |
||||||
|
if typ == req.Type { |
||||||
|
return nil |
||||||
|
} |
||||||
|
} |
||||||
|
return ErrFilterNoMatch{ |
||||||
|
Err: fmt.Errorf("payload type %q does not match any of filter's types %+v", |
||||||
|
req.Type, f.Types), |
||||||
|
} |
||||||
|
|
||||||
|
default: |
||||||
|
return errors.New(`one of the following fields must be set: "payload_type", "payload_types"`) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// FilterCommitAttributes filters by one more attributes a commit can have. If
|
||||||
|
// more than one field is filled in then all relevant attributes must be present
|
||||||
|
// on the commit for this filter to match.
|
||||||
|
type FilterCommitAttributes struct { |
||||||
|
NonFastForward bool `yaml:"non_fast_forward"` |
||||||
|
} |
||||||
|
|
||||||
|
var _ Filter = FilterCommitAttributes{} |
||||||
|
|
||||||
|
// MatchCommit implements the method for FilterInterface.
|
||||||
|
func (f FilterCommitAttributes) MatchCommit(req CommitRequest) error { |
||||||
|
if f.NonFastForward && !req.NonFastForward { |
||||||
|
return ErrFilterNoMatch{Err: errors.New("commit is a fast-forward")} |
||||||
|
} |
||||||
|
return nil |
||||||
|
} |
@ -0,0 +1,26 @@ |
|||||||
|
package accessctl |
||||||
|
|
||||||
|
import ( |
||||||
|
"errors" |
||||||
|
) |
||||||
|
|
||||||
|
// FilterNot wraps another Filter. If that filter matches, FilterNot does not
|
||||||
|
// match, and vice-versa.
|
||||||
|
type FilterNot struct { |
||||||
|
Filter FilterUnion `yaml:"filter"` |
||||||
|
} |
||||||
|
|
||||||
|
var _ Filter = FilterNot{} |
||||||
|
|
||||||
|
// MatchCommit implements the method for FilterInterface.
|
||||||
|
func (f FilterNot) MatchCommit(req CommitRequest) error { |
||||||
|
if err := f.Filter.Filter().MatchCommit(req); errors.As(err, new(ErrFilterNoMatch)) { |
||||||
|
return nil |
||||||
|
} else if err != nil { |
||||||
|
return err |
||||||
|
} |
||||||
|
return ErrFilterNoMatch{Err: errors.New("sub-filter did match")} |
||||||
|
} |
||||||
|
|
||||||
|
// TODO FilterAll
|
||||||
|
// TODO FilterAny
|
@ -0,0 +1,32 @@ |
|||||||
|
package accessctl |
||||||
|
|
||||||
|
import "testing" |
||||||
|
|
||||||
|
func TestFilterNot(t *testing.T) { |
||||||
|
runCommitMatchTests(t, []filterCommitMatchTest{ |
||||||
|
{ |
||||||
|
descr: "sub-filter does match", |
||||||
|
filter: FilterNot{ |
||||||
|
Filter: FilterUnion{ |
||||||
|
PayloadType: &FilterPayloadType{Type: "foo"}, |
||||||
|
}, |
||||||
|
}, |
||||||
|
req: CommitRequest{ |
||||||
|
Type: "foo", |
||||||
|
}, |
||||||
|
match: false, |
||||||
|
}, |
||||||
|
{ |
||||||
|
descr: "sub-filter does not match", |
||||||
|
filter: FilterNot{ |
||||||
|
Filter: FilterUnion{ |
||||||
|
PayloadType: &FilterPayloadType{Type: "foo"}, |
||||||
|
}, |
||||||
|
}, |
||||||
|
req: CommitRequest{ |
||||||
|
Type: "bar", |
||||||
|
}, |
||||||
|
match: true, |
||||||
|
}, |
||||||
|
}) |
||||||
|
} |
@ -0,0 +1,96 @@ |
|||||||
|
package accessctl |
||||||
|
|
||||||
|
import ( |
||||||
|
"errors" |
||||||
|
"fmt" |
||||||
|
|
||||||
|
"github.com/bmatcuk/doublestar" |
||||||
|
) |
||||||
|
|
||||||
|
// StringMatcher is used to match against a string. It can use one of several
|
||||||
|
// methods to match. Only one field should be filled at a time.
|
||||||
|
type StringMatcher struct { |
||||||
|
// Pattern, if set, indicates that the Match method should succeed if this
|
||||||
|
// doublestar pattern matches against the string.
|
||||||
|
Pattern string `yaml:"pattern,omitempty"` |
||||||
|
|
||||||
|
// Patterns, if set, indicates that the Match method should succeed if at
|
||||||
|
// least one of these doublestar patterns matches against the string.
|
||||||
|
Patterns []string `yaml:"patterns,omitempty"` |
||||||
|
} |
||||||
|
|
||||||
|
func doublestarMatch(pattern, str string) (bool, error) { |
||||||
|
ok, err := doublestar.Match(pattern, str) |
||||||
|
if err != nil { |
||||||
|
return false, fmt.Errorf("matching %q on pattern %q: %w", |
||||||
|
str, pattern, err) |
||||||
|
} |
||||||
|
return ok, nil |
||||||
|
} |
||||||
|
|
||||||
|
// Match operates similarly to the Match method of the FilterInterface, except
|
||||||
|
// it only takes in strings.
|
||||||
|
func (m StringMatcher) Match(str string) error { |
||||||
|
switch { |
||||||
|
case m.Pattern != "": |
||||||
|
if ok, err := doublestarMatch(m.Pattern, str); err != nil { |
||||||
|
return err |
||||||
|
} else if !ok { |
||||||
|
return ErrFilterNoMatch{ |
||||||
|
Err: fmt.Errorf("pattern %q does not match %q", m.Pattern, str), |
||||||
|
} |
||||||
|
} |
||||||
|
return nil |
||||||
|
|
||||||
|
case len(m.Patterns) > 0: |
||||||
|
for _, pattern := range m.Patterns { |
||||||
|
if ok, err := doublestarMatch(pattern, str); err != nil { |
||||||
|
return err |
||||||
|
} else if ok { |
||||||
|
return nil |
||||||
|
} |
||||||
|
} |
||||||
|
return ErrFilterNoMatch{ |
||||||
|
Err: fmt.Errorf("no patterns in %+v match %q", m.Patterns, str), |
||||||
|
} |
||||||
|
|
||||||
|
default: |
||||||
|
return errors.New(`one of the following fields must be set: "pattern", "patterns"`) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// FilterBranch matches a CommitRequest's Branch field using a double-star
|
||||||
|
// pattern.
|
||||||
|
type FilterBranch struct { |
||||||
|
StringMatcher StringMatcher `yaml:",inline"` |
||||||
|
} |
||||||
|
|
||||||
|
var _ Filter = FilterBranch{} |
||||||
|
|
||||||
|
// MatchCommit implements the method for FilterInterface.
|
||||||
|
func (f FilterBranch) MatchCommit(req CommitRequest) error { |
||||||
|
return f.StringMatcher.Match(req.Branch) |
||||||
|
} |
||||||
|
|
||||||
|
// FilterFilesChanged matches a CommitRequest's FilesChanged field using a
|
||||||
|
// double-star pattern. It only matches if all of the CommitRequest's
|
||||||
|
// FilesChanged match.
|
||||||
|
type FilterFilesChanged struct { |
||||||
|
StringMatcher StringMatcher `yaml:",inline"` |
||||||
|
} |
||||||
|
|
||||||
|
var _ Filter = FilterFilesChanged{} |
||||||
|
|
||||||
|
// MatchCommit implements the method for FilterInterface.
|
||||||
|
func (f FilterFilesChanged) MatchCommit(req CommitRequest) error { |
||||||
|
for _, path := range req.FilesChanged { |
||||||
|
if err := f.StringMatcher.Match(path); errors.As(err, new(ErrFilterNoMatch)) { |
||||||
|
continue |
||||||
|
} else if err != nil { |
||||||
|
return err |
||||||
|
} |
||||||
|
return nil |
||||||
|
} |
||||||
|
|
||||||
|
return ErrFilterNoMatch{Err: errors.New("no paths matched")} |
||||||
|
} |
@ -0,0 +1,199 @@ |
|||||||
|
package accessctl |
||||||
|
|
||||||
|
import ( |
||||||
|
"errors" |
||||||
|
"testing" |
||||||
|
) |
||||||
|
|
||||||
|
func TestStringMatcher(t *testing.T) { |
||||||
|
tests := []struct { |
||||||
|
descr string |
||||||
|
matcher StringMatcher |
||||||
|
str string |
||||||
|
match bool |
||||||
|
}{ |
||||||
|
// Pattern
|
||||||
|
{ |
||||||
|
descr: "pattern exact match", |
||||||
|
matcher: StringMatcher{ |
||||||
|
Pattern: "foo", |
||||||
|
}, |
||||||
|
str: "foo", |
||||||
|
match: true, |
||||||
|
}, |
||||||
|
{ |
||||||
|
descr: "pattern exact no match", |
||||||
|
matcher: StringMatcher{ |
||||||
|
Pattern: "foo", |
||||||
|
}, |
||||||
|
str: "bar", |
||||||
|
match: false, |
||||||
|
}, |
||||||
|
{ |
||||||
|
descr: "pattern single star match", |
||||||
|
matcher: StringMatcher{ |
||||||
|
Pattern: "foo/*", |
||||||
|
}, |
||||||
|
str: "foo/bar", |
||||||
|
match: true, |
||||||
|
}, |
||||||
|
{ |
||||||
|
descr: "pattern single star no match 1", |
||||||
|
matcher: StringMatcher{ |
||||||
|
Pattern: "foo/*", |
||||||
|
}, |
||||||
|
str: "foo", |
||||||
|
match: false, |
||||||
|
}, |
||||||
|
{ |
||||||
|
descr: "pattern single star no match 2", |
||||||
|
matcher: StringMatcher{ |
||||||
|
Pattern: "foo/*", |
||||||
|
}, |
||||||
|
str: "foo/bar/baz", |
||||||
|
match: false, |
||||||
|
}, |
||||||
|
{ |
||||||
|
descr: "pattern double star match 1", |
||||||
|
matcher: StringMatcher{ |
||||||
|
Pattern: "foo/**", |
||||||
|
}, |
||||||
|
str: "foo/bar", |
||||||
|
match: true, |
||||||
|
}, |
||||||
|
{ |
||||||
|
descr: "pattern double star match 2", |
||||||
|
matcher: StringMatcher{ |
||||||
|
Pattern: "foo/**", |
||||||
|
}, |
||||||
|
str: "foo/bar/baz", |
||||||
|
match: true, |
||||||
|
}, |
||||||
|
{ |
||||||
|
descr: "pattern double star no match", |
||||||
|
matcher: StringMatcher{ |
||||||
|
Pattern: "foo/**", |
||||||
|
}, |
||||||
|
str: "foo", |
||||||
|
match: false, |
||||||
|
}, |
||||||
|
|
||||||
|
// Patterns, assumes individual pattern matching works correctly
|
||||||
|
{ |
||||||
|
descr: "patterns single match", |
||||||
|
matcher: StringMatcher{ |
||||||
|
Patterns: []string{"foo"}, |
||||||
|
}, |
||||||
|
str: "foo", |
||||||
|
match: true, |
||||||
|
}, |
||||||
|
{ |
||||||
|
descr: "patterns single no match", |
||||||
|
matcher: StringMatcher{ |
||||||
|
Patterns: []string{"foo"}, |
||||||
|
}, |
||||||
|
str: "bar", |
||||||
|
match: false, |
||||||
|
}, |
||||||
|
{ |
||||||
|
descr: "patterns multi first match", |
||||||
|
matcher: StringMatcher{ |
||||||
|
Patterns: []string{"foo", "bar"}, |
||||||
|
}, |
||||||
|
str: "foo", |
||||||
|
match: true, |
||||||
|
}, |
||||||
|
{ |
||||||
|
descr: "patterns multi second match", |
||||||
|
matcher: StringMatcher{ |
||||||
|
Patterns: []string{"foo", "bar"}, |
||||||
|
}, |
||||||
|
str: "bar", |
||||||
|
match: true, |
||||||
|
}, |
||||||
|
{ |
||||||
|
descr: "patterns multi no match", |
||||||
|
matcher: StringMatcher{ |
||||||
|
Patterns: []string{"foo", "bar"}, |
||||||
|
}, |
||||||
|
str: "baz", |
||||||
|
match: false, |
||||||
|
}, |
||||||
|
} |
||||||
|
|
||||||
|
for _, test := range tests { |
||||||
|
t.Run(test.descr, func(t *testing.T) { |
||||||
|
err := test.matcher.Match(test.str) |
||||||
|
if test.match && err != nil { |
||||||
|
t.Fatalf("expected to match, got %v", err) |
||||||
|
} else if !test.match && !errors.As(err, new(ErrFilterNoMatch)) { |
||||||
|
t.Fatalf("expected ErrFilterNoMatch, got %#v", err) |
||||||
|
} |
||||||
|
}) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
func TestFilterFilesChanged(t *testing.T) { |
||||||
|
mkReq := func(paths ...string) CommitRequest { |
||||||
|
return CommitRequest{FilesChanged: paths} |
||||||
|
} |
||||||
|
|
||||||
|
runCommitMatchTests(t, []filterCommitMatchTest{ |
||||||
|
{ |
||||||
|
descr: "no paths", |
||||||
|
filter: FilterFilesChanged{ |
||||||
|
StringMatcher: StringMatcher{Pattern: "foo"}, |
||||||
|
}, |
||||||
|
req: mkReq(), |
||||||
|
match: false, |
||||||
|
}, |
||||||
|
{ |
||||||
|
descr: "all paths against one pattern", |
||||||
|
filter: FilterFilesChanged{ |
||||||
|
StringMatcher: StringMatcher{Pattern: "foo/*"}, |
||||||
|
}, |
||||||
|
req: mkReq("foo/bar", "foo/baz"), |
||||||
|
match: true, |
||||||
|
}, |
||||||
|
{ |
||||||
|
descr: "all paths against multiple patterns", |
||||||
|
filter: FilterFilesChanged{ |
||||||
|
StringMatcher: StringMatcher{Patterns: []string{"foo", "bar"}}, |
||||||
|
}, |
||||||
|
req: mkReq("foo", "bar"), |
||||||
|
match: true, |
||||||
|
}, |
||||||
|
{ |
||||||
|
descr: "some paths against one pattern", |
||||||
|
filter: FilterFilesChanged{ |
||||||
|
StringMatcher: StringMatcher{Pattern: "foo"}, |
||||||
|
}, |
||||||
|
req: mkReq("foo", "bar"), |
||||||
|
match: true, |
||||||
|
}, |
||||||
|
{ |
||||||
|
descr: "some paths against many patterns", |
||||||
|
filter: FilterFilesChanged{ |
||||||
|
StringMatcher: StringMatcher{Patterns: []string{"foo", "bar"}}, |
||||||
|
}, |
||||||
|
req: mkReq("foo", "baz"), |
||||||
|
match: true, |
||||||
|
}, |
||||||
|
{ |
||||||
|
descr: "no paths against one pattern", |
||||||
|
filter: FilterFilesChanged{ |
||||||
|
StringMatcher: StringMatcher{Pattern: "foo"}, |
||||||
|
}, |
||||||
|
req: mkReq("baz", "buz"), |
||||||
|
match: false, |
||||||
|
}, |
||||||
|
{ |
||||||
|
descr: "no paths against many patterns", |
||||||
|
filter: FilterFilesChanged{ |
||||||
|
StringMatcher: StringMatcher{Patterns: []string{"foo", "bar"}}, |
||||||
|
}, |
||||||
|
req: mkReq("baz", "buz"), |
||||||
|
match: false, |
||||||
|
}, |
||||||
|
}) |
||||||
|
} |
@ -0,0 +1,113 @@ |
|||||||
|
package accessctl |
||||||
|
|
||||||
|
import ( |
||||||
|
"errors" |
||||||
|
"fmt" |
||||||
|
"math" |
||||||
|
"strconv" |
||||||
|
"strings" |
||||||
|
) |
||||||
|
|
||||||
|
// FilterSignature represents the configuration of a Filter which requires one
|
||||||
|
// or more signature credentials to be present on a commit.
|
||||||
|
//
|
||||||
|
// Either AccountIDs, AnyAccount, or Any must be filled in; all are mutually
|
||||||
|
// exclusive.
|
||||||
|
type FilterSignature struct { |
||||||
|
AccountIDs []string `yaml:"account_ids,omitempty"` |
||||||
|
Any bool `yaml:"any,omitempty"` |
||||||
|
AnyAccount bool `yaml:"any_account,omitempty"` |
||||||
|
Count string `yaml:"count,omitempty"` |
||||||
|
} |
||||||
|
|
||||||
|
var _ Filter = FilterSignature{} |
||||||
|
|
||||||
|
func (f FilterSignature) targetNum() (int, error) { |
||||||
|
if f.Count == "" { |
||||||
|
return 1, nil |
||||||
|
} else if !strings.HasSuffix(f.Count, "%") { |
||||||
|
return strconv.Atoi(f.Count) |
||||||
|
} else if f.AnyAccount { |
||||||
|
return 0, errors.New("cannot use AnyAccount and a percent Count together") |
||||||
|
} |
||||||
|
|
||||||
|
percentStr := strings.TrimRight(f.Count, "%") |
||||||
|
percent, err := strconv.ParseFloat(percentStr, 64) |
||||||
|
if err != nil { |
||||||
|
return 0, fmt.Errorf("could not parse Count as percent %q: %w", f.Count, err) |
||||||
|
} |
||||||
|
target := float64(len(f.AccountIDs)) * percent / 100 |
||||||
|
target = math.Ceil(target) |
||||||
|
return int(target), nil |
||||||
|
} |
||||||
|
|
||||||
|
// ErrFilterSignatureUnsatisfied is returned from FilterSignature's
|
||||||
|
// Match method when the filter has not been satisfied.
|
||||||
|
type ErrFilterSignatureUnsatisfied struct { |
||||||
|
TargetNumAccounts, NumAccounts int |
||||||
|
} |
||||||
|
|
||||||
|
func (err ErrFilterSignatureUnsatisfied) Error() string { |
||||||
|
return fmt.Sprintf("not enough valid signature credentials, filter requires %d but only had %d", |
||||||
|
err.TargetNumAccounts, err.NumAccounts) |
||||||
|
} |
||||||
|
|
||||||
|
// MatchCommit returns true if the CommitRequest contains a sufficient number of
|
||||||
|
// signature Credentials.
|
||||||
|
func (f FilterSignature) MatchCommit(req CommitRequest) error { |
||||||
|
targetN, err := f.targetNum() |
||||||
|
if err != nil { |
||||||
|
return fmt.Errorf("computing target number of accounts: %w", err) |
||||||
|
} |
||||||
|
|
||||||
|
var numSigs int |
||||||
|
credAccountIDs := map[string]struct{}{} |
||||||
|
for _, cred := range req.Credentials { |
||||||
|
// TODO support other kinds of signatures
|
||||||
|
if cred.PGPSignature == nil { |
||||||
|
continue |
||||||
|
} |
||||||
|
numSigs++ |
||||||
|
if cred.AccountID != "" { |
||||||
|
credAccountIDs[cred.AccountID] = struct{}{} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
if numSigs == 0 { |
||||||
|
return ErrFilterNoMatch{ |
||||||
|
Err: ErrFilterSignatureUnsatisfied{TargetNumAccounts: targetN}, |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
var n int |
||||||
|
if f.Any { |
||||||
|
return nil |
||||||
|
} else if f.AnyAccount { |
||||||
|
// TODO this doesn't actually check that the accounts are defined in the
|
||||||
|
// Config. It works for now as long as the Credentials are valid, since
|
||||||
|
// only an Account defined in the Config could create a valid
|
||||||
|
// Credential, but once that's not the case this will need to be
|
||||||
|
// revisited.
|
||||||
|
n = len(credAccountIDs) |
||||||
|
} else { |
||||||
|
targetAccountIDs := map[string]struct{}{} |
||||||
|
for _, accountID := range f.AccountIDs { |
||||||
|
targetAccountIDs[accountID] = struct{}{} |
||||||
|
} |
||||||
|
for accountID := range targetAccountIDs { |
||||||
|
if _, ok := credAccountIDs[accountID]; ok { |
||||||
|
n++ |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
if n >= targetN { |
||||||
|
return nil |
||||||
|
} |
||||||
|
return ErrFilterNoMatch{ |
||||||
|
Err: ErrFilterSignatureUnsatisfied{ |
||||||
|
NumAccounts: n, |
||||||
|
TargetNumAccounts: targetN, |
||||||
|
}, |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,124 @@ |
|||||||
|
package accessctl |
||||||
|
|
||||||
|
import ( |
||||||
|
"testing" |
||||||
|
|
||||||
|
"dehub.dev/src/dehub.git/sigcred" |
||||||
|
) |
||||||
|
|
||||||
|
func TestFilterSignature(t *testing.T) { |
||||||
|
mkReq := func(accountIDs ...string) CommitRequest { |
||||||
|
creds := make([]sigcred.CredentialUnion, len(accountIDs)) |
||||||
|
for i := range accountIDs { |
||||||
|
creds[i].PGPSignature = new(sigcred.CredentialPGPSignature) |
||||||
|
creds[i].AccountID = accountIDs[i] |
||||||
|
} |
||||||
|
return CommitRequest{Credentials: creds} |
||||||
|
} |
||||||
|
|
||||||
|
runCommitMatchTests(t, []filterCommitMatchTest{ |
||||||
|
{ |
||||||
|
descr: "no cred accounts", |
||||||
|
filter: FilterSignature{ |
||||||
|
AnyAccount: true, |
||||||
|
Count: "1", |
||||||
|
}, |
||||||
|
matchErr: ErrFilterSignatureUnsatisfied{ |
||||||
|
TargetNumAccounts: 1, |
||||||
|
NumAccounts: 0, |
||||||
|
}, |
||||||
|
}, |
||||||
|
{ |
||||||
|
descr: "one cred account", |
||||||
|
filter: FilterSignature{ |
||||||
|
AnyAccount: true, |
||||||
|
Count: "1", |
||||||
|
}, |
||||||
|
req: mkReq("foo"), |
||||||
|
match: true, |
||||||
|
}, |
||||||
|
{ |
||||||
|
descr: "one matching cred account", |
||||||
|
filter: FilterSignature{ |
||||||
|
AccountIDs: []string{"foo", "bar"}, |
||||||
|
Count: "1", |
||||||
|
}, |
||||||
|
req: mkReq("foo"), |
||||||
|
match: true, |
||||||
|
}, |
||||||
|
{ |
||||||
|
descr: "no matching cred account", |
||||||
|
filter: FilterSignature{ |
||||||
|
AccountIDs: []string{"foo", "bar"}, |
||||||
|
Count: "1", |
||||||
|
}, |
||||||
|
req: mkReq("baz"), |
||||||
|
matchErr: ErrFilterSignatureUnsatisfied{ |
||||||
|
TargetNumAccounts: 1, |
||||||
|
NumAccounts: 0, |
||||||
|
}, |
||||||
|
}, |
||||||
|
{ |
||||||
|
descr: "two matching cred accounts", |
||||||
|
filter: FilterSignature{ |
||||||
|
AccountIDs: []string{"foo", "bar"}, |
||||||
|
Count: "2", |
||||||
|
}, |
||||||
|
req: mkReq("foo", "bar"), |
||||||
|
match: true, |
||||||
|
}, |
||||||
|
{ |
||||||
|
descr: "one matching cred account, missing one", |
||||||
|
filter: FilterSignature{ |
||||||
|
AccountIDs: []string{"foo", "bar"}, |
||||||
|
Count: "2", |
||||||
|
}, |
||||||
|
req: mkReq("foo", "baz"), |
||||||
|
matchErr: ErrFilterSignatureUnsatisfied{ |
||||||
|
TargetNumAccounts: 2, |
||||||
|
NumAccounts: 1, |
||||||
|
}, |
||||||
|
}, |
||||||
|
{ |
||||||
|
descr: "50 percent matching cred accounts", |
||||||
|
filter: FilterSignature{ |
||||||
|
AccountIDs: []string{"foo", "bar", "baz"}, |
||||||
|
Count: "50%", |
||||||
|
}, |
||||||
|
req: mkReq("foo", "bar"), |
||||||
|
match: true, |
||||||
|
}, |
||||||
|
{ |
||||||
|
descr: "not 50 percent matching cred accounts", |
||||||
|
filter: FilterSignature{ |
||||||
|
AccountIDs: []string{"foo", "bar", "baz"}, |
||||||
|
Count: "50%", |
||||||
|
}, |
||||||
|
req: mkReq("foo"), |
||||||
|
matchErr: ErrFilterSignatureUnsatisfied{ |
||||||
|
TargetNumAccounts: 2, |
||||||
|
NumAccounts: 1, |
||||||
|
}, |
||||||
|
}, |
||||||
|
{ |
||||||
|
descr: "any sig at all", |
||||||
|
filter: FilterSignature{ |
||||||
|
Any: true, |
||||||
|
}, |
||||||
|
req: CommitRequest{ |
||||||
|
Credentials: []sigcred.CredentialUnion{ |
||||||
|
{PGPSignature: new(sigcred.CredentialPGPSignature)}, |
||||||
|
}, |
||||||
|
}, |
||||||
|
match: true, |
||||||
|
}, |
||||||
|
{ |
||||||
|
descr: "not any sig at all", |
||||||
|
filter: FilterSignature{Any: true}, |
||||||
|
req: CommitRequest{}, |
||||||
|
matchErr: ErrFilterSignatureUnsatisfied{ |
||||||
|
TargetNumAccounts: 1, |
||||||
|
}, |
||||||
|
}, |
||||||
|
}) |
||||||
|
} |
@ -0,0 +1,137 @@ |
|||||||
|
package accessctl |
||||||
|
|
||||||
|
import ( |
||||||
|
"errors" |
||||||
|
"reflect" |
||||||
|
"testing" |
||||||
|
) |
||||||
|
|
||||||
|
type filterCommitMatchTest struct { |
||||||
|
descr string |
||||||
|
filter Filter |
||||||
|
req CommitRequest |
||||||
|
match bool |
||||||
|
|
||||||
|
// assumes match == false, and will ensure that the returned wrapped error
|
||||||
|
// is this one.
|
||||||
|
matchErr error |
||||||
|
} |
||||||
|
|
||||||
|
func runCommitMatchTests(t *testing.T, tests []filterCommitMatchTest) { |
||||||
|
for _, test := range tests { |
||||||
|
t.Run(test.descr, func(t *testing.T) { |
||||||
|
err := test.filter.MatchCommit(test.req) |
||||||
|
shouldMatch := test.match && test.matchErr == nil |
||||||
|
if shouldMatch && err != nil { |
||||||
|
t.Fatalf("expected to match, got %v", err) |
||||||
|
|
||||||
|
} else if shouldMatch { |
||||||
|
return |
||||||
|
|
||||||
|
} else if fErr := new(ErrFilterNoMatch); !errors.As(err, fErr) { |
||||||
|
t.Fatalf("expected ErrFilterNoMatch, got: %#v", err) |
||||||
|
|
||||||
|
} else if test.matchErr != nil && !reflect.DeepEqual(fErr.Err, test.matchErr) { |
||||||
|
t.Fatalf("expected err %#v, not %#v", test.matchErr, fErr.Err) |
||||||
|
} |
||||||
|
}) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
func TestFilterPayloadType(t *testing.T) { |
||||||
|
mkReq := func(commitType string) CommitRequest { |
||||||
|
return CommitRequest{Type: commitType} |
||||||
|
} |
||||||
|
|
||||||
|
runCommitMatchTests(t, []filterCommitMatchTest{ |
||||||
|
{ |
||||||
|
descr: "single match", |
||||||
|
filter: FilterPayloadType{ |
||||||
|
Type: "foo", |
||||||
|
}, |
||||||
|
req: mkReq("foo"), |
||||||
|
match: true, |
||||||
|
}, |
||||||
|
{ |
||||||
|
descr: "single no match", |
||||||
|
filter: FilterPayloadType{ |
||||||
|
Type: "foo", |
||||||
|
}, |
||||||
|
req: mkReq("bar"), |
||||||
|
match: false, |
||||||
|
}, |
||||||
|
{ |
||||||
|
descr: "multi match first", |
||||||
|
filter: FilterPayloadType{ |
||||||
|
Types: []string{"foo", "bar"}, |
||||||
|
}, |
||||||
|
req: mkReq("foo"), |
||||||
|
match: true, |
||||||
|
}, |
||||||
|
{ |
||||||
|
descr: "multi match second", |
||||||
|
filter: FilterPayloadType{ |
||||||
|
Types: []string{"foo", "bar"}, |
||||||
|
}, |
||||||
|
req: mkReq("bar"), |
||||||
|
match: true, |
||||||
|
}, |
||||||
|
{ |
||||||
|
descr: "multi no match", |
||||||
|
filter: FilterPayloadType{ |
||||||
|
Types: []string{"foo", "bar"}, |
||||||
|
}, |
||||||
|
req: mkReq("baz"), |
||||||
|
match: false, |
||||||
|
}, |
||||||
|
}) |
||||||
|
} |
||||||
|
|
||||||
|
func TestFilterCommitAttributes(t *testing.T) { |
||||||
|
mkReq := func(nonFF bool) CommitRequest { |
||||||
|
return CommitRequest{NonFastForward: nonFF} |
||||||
|
} |
||||||
|
|
||||||
|
runCommitMatchTests(t, []filterCommitMatchTest{ |
||||||
|
{ |
||||||
|
descr: "ff with empty filter", |
||||||
|
filter: FilterCommitAttributes{}, |
||||||
|
req: mkReq(false), |
||||||
|
match: true, |
||||||
|
}, |
||||||
|
{ |
||||||
|
descr: "non-ff with empty filter", |
||||||
|
filter: FilterCommitAttributes{}, |
||||||
|
req: mkReq(true), |
||||||
|
match: true, |
||||||
|
}, |
||||||
|
{ |
||||||
|
descr: "ff with non-ff filter", |
||||||
|
filter: FilterCommitAttributes{NonFastForward: true}, |
||||||
|
req: mkReq(false), |
||||||
|
match: false, |
||||||
|
}, |
||||||
|
{ |
||||||
|
descr: "non-ff with non-ff filter", |
||||||
|
filter: FilterCommitAttributes{NonFastForward: true}, |
||||||
|
req: mkReq(true), |
||||||
|
match: true, |
||||||
|
}, |
||||||
|
{ |
||||||
|
descr: "ff with inverted non-ff filter", |
||||||
|
filter: FilterNot{Filter: FilterUnion{ |
||||||
|
CommitAttributes: &FilterCommitAttributes{NonFastForward: true}, |
||||||
|
}}, |
||||||
|
req: mkReq(false), |
||||||
|
match: true, |
||||||
|
}, |
||||||
|
{ |
||||||
|
descr: "non-ff with inverted non-ff filter", |
||||||
|
filter: FilterNot{Filter: FilterUnion{ |
||||||
|
CommitAttributes: &FilterCommitAttributes{NonFastForward: true}, |
||||||
|
}}, |
||||||
|
req: mkReq(true), |
||||||
|
match: false, |
||||||
|
}, |
||||||
|
}) |
||||||
|
} |
@ -1,75 +0,0 @@ |
|||||||
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,39 @@ |
|||||||
|
# dehub-remote |
||||||
|
|
||||||
|
This directory provides a simple Docker image which can be spun up to run a |
||||||
|
dehub-enabled git http remote server. Commits which are pushed to this server |
||||||
|
will be automatically verified using `dehub verify`. |
||||||
|
|
||||||
|
The docker image is also being hosted on docker hub at |
||||||
|
[mediocregopher/dehub-remote][dehub-remote]. Proper image tagging/versioning |
||||||
|
coming soon! |
||||||
|
|
||||||
|
[dehub-remote]: https://hub.docker.com/repository/docker/mediocregopher/dehub-remote |
||||||
|
|
||||||
|
## Usage |
||||||
|
|
||||||
|
Running the following: |
||||||
|
|
||||||
|
``` |
||||||
|
docker run \ |
||||||
|
--name dehub \ |
||||||
|
-v /opt/dehub/repos:/repos \ |
||||||
|
-p 8080:80 \ |
||||||
|
mediocregopher/dehub-remote repo-a.git repo-b.git |
||||||
|
``` |
||||||
|
|
||||||
|
Will start an http server on port 8080, using `/opt/dehub/repos` to store all |
||||||
|
repo folders. It will then initialize repo directories at |
||||||
|
`/opt/dehub/repos/repo-a.git` and `/opt/dehub/repos/repo-b.git`, if they arent |
||||||
|
already there. |
||||||
|
|
||||||
|
## Extras |
||||||
|
|
||||||
|
For convenience the docker image also includes the |
||||||
|
[git-http-server](../git-http-server/) binary. |
||||||
|
|
||||||
|
## Contributors |
||||||
|
|
||||||
|
The Dockerfile being used is based on |
||||||
|
[gitbox](https://github.com/nmarus/docker-gitbox), so thank you to nmarus for |
||||||
|
the great work there. |
@ -0,0 +1,44 @@ |
|||||||
|
user git git; |
||||||
|
worker_processes 1; |
||||||
|
pid /run/nginx.pid; |
||||||
|
|
||||||
|
events { |
||||||
|
worker_connections 1024; |
||||||
|
} |
||||||
|
|
||||||
|
http { |
||||||
|
|
||||||
|
sendfile on; |
||||||
|
tcp_nopush on; |
||||||
|
tcp_nodelay on; |
||||||
|
keepalive_timeout 15; |
||||||
|
types_hash_max_size 2048; |
||||||
|
|
||||||
|
include /etc/nginx/mime.types; |
||||||
|
default_type application/octet-stream; |
||||||
|
|
||||||
|
access_log /var/log/nginx/access.log; |
||||||
|
error_log /var/log/nginx/error.log; |
||||||
|
|
||||||
|
server_names_hash_bucket_size 64; |
||||||
|
|
||||||
|
server { |
||||||
|
listen 80; |
||||||
|
server_name MYSERVER default; |
||||||
|
root /var/www; |
||||||
|
|
||||||
|
access_log /var/log/nginx/MYSERVER.access.log combined; |
||||||
|
error_log /var/log/nginx/MYSERVER.error.log error; |
||||||
|
|
||||||
|
#git SMART HTTP |
||||||
|
location / { |
||||||
|
client_max_body_size 0; |
||||||
|
fastcgi_param SCRIPT_FILENAME /usr/lib/git-core/git-http-backend; |
||||||
|
fastcgi_param GIT_HTTP_EXPORT_ALL ""; |
||||||
|
fastcgi_param GIT_PROJECT_ROOT /repos; |
||||||
|
fastcgi_param PATH_INFO $uri; |
||||||
|
include /etc/nginx/fastcgi_params; |
||||||
|
fastcgi_pass unix:/var/run/fcgiwrap.socket; |
||||||
|
} |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,86 @@ |
|||||||
|
#!/bin/bash |
||||||
|
|
||||||
|
set -e |
||||||
|
|
||||||
|
QUIET=false |
||||||
|
#SFLOG="/start.log" |
||||||
|
|
||||||
|
#print timestamp |
||||||
|
timestamp() { |
||||||
|
date +"%Y-%m-%d %T" |
||||||
|
} |
||||||
|
|
||||||
|
#screen/file logger |
||||||
|
sflog() { |
||||||
|
#if $1 is not null |
||||||
|
if [ ! -z ${1+x} ]; then |
||||||
|
message=$1 |
||||||
|
else |
||||||
|
#exit function |
||||||
|
return 1; |
||||||
|
fi |
||||||
|
#if $QUIET is not true |
||||||
|
if ! $($QUIET); then |
||||||
|
echo "${message}" |
||||||
|
fi |
||||||
|
#if $SFLOG is not null |
||||||
|
if [ ! -z ${SFLOG+x} ]; then |
||||||
|
#if $2 is regular file or does not exist |
||||||
|
if [ -f ${SFLOG} ] || [ ! -e ${SFLOG} ]; then |
||||||
|
echo "$(timestamp) ${message}" >> ${SFLOG} |
||||||
|
fi |
||||||
|
fi |
||||||
|
} |
||||||
|
|
||||||
|
#start services function |
||||||
|
startc() { |
||||||
|
sflog "Services for container are being started..." |
||||||
|
/etc/init.d/fcgiwrap start > /dev/null |
||||||
|
/etc/init.d/nginx start > /dev/null |
||||||
|
sflog "The container services have started..." |
||||||
|
} |
||||||
|
|
||||||
|
#stop services function |
||||||
|
stopc() { |
||||||
|
sflog "Services for container are being stopped..." |
||||||
|
/etc/init.d/nginx stop > /dev/null |
||||||
|
/etc/init.d/fcgiwrap stop > /dev/null |
||||||
|
sflog "Services for container have successfully stopped. Exiting." |
||||||
|
exit 0 |
||||||
|
} |
||||||
|
|
||||||
|
#trap "docker stop <container>" and shuts services down cleanly |
||||||
|
trap "(stopc)" TERM INT |
||||||
|
|
||||||
|
#startup |
||||||
|
|
||||||
|
#test for ENV varibale $FQDN |
||||||
|
if [ ! -z ${FQDN+x} ]; then |
||||||
|
sflog "FQDN is set to ${FQDN}" |
||||||
|
else |
||||||
|
export FQDN=dehub |
||||||
|
sflog "FQDN is set to ${FQDN}" |
||||||
|
fi |
||||||
|
|
||||||
|
#modify config files with fqdn |
||||||
|
sed -i "s,MYSERVER,${FQDN},g" /etc/nginx/nginx.conf &> /dev/null |
||||||
|
|
||||||
|
# create the individual repo directories |
||||||
|
while [ ! -z "$1" ]; do |
||||||
|
dir="/repos/$1"; |
||||||
|
if [ ! -d "$dir" ]; then |
||||||
|
echo "Initializing repo $1" |
||||||
|
mkdir "$dir" |
||||||
|
dehub init -path "$dir" -bare -remote |
||||||
|
chown -R git:git "$dir" |
||||||
|
fi |
||||||
|
shift |
||||||
|
done |
||||||
|
|
||||||
|
#start init.d services |
||||||
|
startc |
||||||
|
|
||||||
|
#pause script to keep container running... |
||||||
|
sflog "Services for container successfully started." |
||||||
|
sflog "Dumping logs" |
||||||
|
tail -f /var/log/nginx/*.log |
@ -0,0 +1,273 @@ |
|||||||
|
package main |
||||||
|
|
||||||
|
import ( |
||||||
|
"context" |
||||||
|
"errors" |
||||||
|
"fmt" |
||||||
|
|
||||||
|
"dehub.dev/src/dehub.git" |
||||||
|
"dehub.dev/src/dehub.git/cmd/dehub/dcmd" |
||||||
|
"dehub.dev/src/dehub.git/sigcred" |
||||||
|
|
||||||
|
"gopkg.in/src-d/go-git.v4/plumbing" |
||||||
|
) |
||||||
|
|
||||||
|
func cmdCommit(ctx context.Context, cmd *dcmd.Cmd) { |
||||||
|
flag := cmd.FlagSet() |
||||||
|
accountID := flag.String("as", "", "Account to accredit commit with") |
||||||
|
pgpKeyID := flag.String("anon-pgp-key", "", "ID of pgp key to sign with instead of using an account") |
||||||
|
|
||||||
|
var proj proj |
||||||
|
proj.initFlags(flag) |
||||||
|
|
||||||
|
accreditAndCommit := func(payUn dehub.PayloadUnion) error { |
||||||
|
|
||||||
|
var sig sigcred.Signifier |
||||||
|
if *accountID != "" { |
||||||
|
cfg, err := proj.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].Signifier(*accountID) |
||||||
|
} else { |
||||||
|
var err error |
||||||
|
if sig, err = sigcred.LoadSignifierPGP(*pgpKeyID, true); err != nil { |
||||||
|
return fmt.Errorf("loading pgp key %q: %w", *pgpKeyID, err) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
payUn, err := proj.AccreditPayload(payUn, sig) |
||||||
|
if err != nil { |
||||||
|
return fmt.Errorf("accrediting payload: %w", err) |
||||||
|
} |
||||||
|
|
||||||
|
commit, err := proj.Commit(payUn) |
||||||
|
if err != nil { |
||||||
|
return fmt.Errorf("committing to git: %w", err) |
||||||
|
} |
||||||
|
|
||||||
|
fmt.Printf("committed to HEAD as %s\n", commit.Hash) |
||||||
|
return nil |
||||||
|
} |
||||||
|
|
||||||
|
var hasStaged bool |
||||||
|
body := func() (context.Context, error) { |
||||||
|
if *accountID == "" && *pgpKeyID == "" { |
||||||
|
return nil, errors.New("-as or -anon-pgp-key is required") |
||||||
|
} |
||||||
|
|
||||||
|
if err := proj.openProj(); err != nil { |
||||||
|
return nil, err |
||||||
|
} |
||||||
|
|
||||||
|
var err error |
||||||
|
if hasStaged, err = proj.HasStagedChanges(); err != nil { |
||||||
|
return nil, fmt.Errorf("determining if any changes have been staged: %w", err) |
||||||
|
} |
||||||
|
return ctx, nil |
||||||
|
} |
||||||
|
|
||||||
|
cmd.SubCmd("change", "Commit file changes", |
||||||
|
func(ctx context.Context, cmd *dcmd.Cmd) { |
||||||
|
flag := cmd.FlagSet() |
||||||
|
description := flag.String("descr", "", "Description of changes") |
||||||
|
amend := flag.Bool("amend", false, "Add changes to HEAD commit, amend its message, and re-accredit it") |
||||||
|
cmd.Run(func() (context.Context, error) { |
||||||
|
if !hasStaged && !*amend { |
||||||
|
return nil, errors.New("no changes have been staged for commit") |
||||||
|
} |
||||||
|
|
||||||
|
var prevMsg string |
||||||
|
if *amend { |
||||||
|
oldHead, err := proj.softReset("change") |
||||||
|
if err != nil { |
||||||
|
return nil, err |
||||||
|
} |
||||||
|
prevMsg = oldHead.Payload.Change.Description |
||||||
|
} |
||||||
|
|
||||||
|
if *description == "" { |
||||||
|
var err error |
||||||
|
if *description, err = tmpFileMsg(defaultCommitFileMsgTpl, prevMsg); err != nil { |
||||||
|
return nil, fmt.Errorf("error collecting commit message from user: %w", err) |
||||||
|
|
||||||
|
} else if *description == "" { |
||||||
|
return nil, errors.New("empty description, not doing anything") |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
payUn, err := proj.NewPayloadChange(*description) |
||||||
|
if err != nil { |
||||||
|
return nil, fmt.Errorf("could not construct change payload: %w", err) |
||||||
|
|
||||||
|
} else if err := accreditAndCommit(payUn); err != nil { |
||||||
|
return nil, err |
||||||
|
} |
||||||
|
return nil, nil |
||||||
|
}) |
||||||
|
}, |
||||||
|
) |
||||||
|
|
||||||
|
cmd.SubCmd("credential", "Commit credential of one or more change commits", |
||||||
|
func(ctx context.Context, cmd *dcmd.Cmd) { |
||||||
|
flag := cmd.FlagSet() |
||||||
|
startRev := flag.String("start", "", "Revision of the starting commit to accredit (when accrediting a range of changes)") |
||||||
|
endRev := flag.String("end", "HEAD", "Revision of the ending commit to accredit (when accrediting a range of changes)") |
||||||
|
rev := flag.String("rev", "", "Revision of commit to accredit (when accrediting a single commit)") |
||||||
|
description := flag.String("descr", "", "Description of changes being accredited") |
||||||
|
cmd.Run(func() (context.Context, error) { |
||||||
|
if *rev == "" && *startRev == "" { |
||||||
|
return nil, errors.New("-rev or -start is required") |
||||||
|
} else if hasStaged { |
||||||
|
return nil, errors.New("credential commit cannot have staged changes") |
||||||
|
} |
||||||
|
|
||||||
|
var commits []dehub.Commit |
||||||
|
if *rev != "" { |
||||||
|
commit, err := proj.GetCommitByRevision(plumbing.Revision(*rev)) |
||||||
|
if err != nil { |
||||||
|
return nil, fmt.Errorf("resolving revision %q: %w", *rev, err) |
||||||
|
} |
||||||
|
commits = []dehub.Commit{commit} |
||||||
|
} else { |
||||||
|
var err error |
||||||
|
commits, err = proj.GetCommitRangeByRevision( |
||||||
|
plumbing.Revision(*startRev), |
||||||
|
plumbing.Revision(*endRev), |
||||||
|
) |
||||||
|
if err != nil { |
||||||
|
return nil, fmt.Errorf("resolving revisions %q to %q: %w", |
||||||
|
*startRev, *endRev, err) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
var credPayUn dehub.PayloadUnion |
||||||
|
if len(commits) == 0 { |
||||||
|
return nil, errors.New("cannot create credential based on empty range of commits") |
||||||
|
} else if len(commits) == 1 && commits[0].Payload.Credential != nil { |
||||||
|
credPayUn = commits[0].Payload |
||||||
|
} else { |
||||||
|
if *description == "" { |
||||||
|
lastDescr, err := dehub.LastChangeDescription(commits) |
||||||
|
if err != nil { |
||||||
|
return nil, fmt.Errorf("determining change description of commit(s): %w", err) |
||||||
|
} |
||||||
|
|
||||||
|
*description, err = tmpFileMsg(defaultCommitFileMsgTpl, lastDescr) |
||||||
|
if err != nil { |
||||||
|
return nil, fmt.Errorf("collecting credential description from user: %w", err) |
||||||
|
} else if *description == "" { |
||||||
|
return nil, errors.New("empty description, not doing anything") |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
var err error |
||||||
|
credPayUn, err = proj.NewPayloadCredentialFromChanges(*description, commits) |
||||||
|
if err != nil { |
||||||
|
return nil, fmt.Errorf("constructing credential commit: %w", err) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
if err := accreditAndCommit(credPayUn); err != nil { |
||||||
|
return nil, err |
||||||
|
} |
||||||
|
return nil, nil |
||||||
|
}) |
||||||
|
}, |
||||||
|
) |
||||||
|
|
||||||
|
cmd.SubCmd("comment", "Commit a comment to a branch", |
||||||
|
func(ctx context.Context, cmd *dcmd.Cmd) { |
||||||
|
flag := cmd.FlagSet() |
||||||
|
comment := flag.String("comment", "", "Comment message") |
||||||
|
amend := flag.Bool("amend", false, "Amend the comment message currently in HEAD") |
||||||
|
cmd.Run(func() (context.Context, error) { |
||||||
|
if hasStaged { |
||||||
|
return nil, errors.New("comment commit cannot have staged changes") |
||||||
|
} |
||||||
|
|
||||||
|
var prevComment string |
||||||
|
if *amend { |
||||||
|
oldHead, err := proj.softReset("comment") |
||||||
|
if err != nil { |
||||||
|
return nil, err |
||||||
|
} |
||||||
|
prevComment = oldHead.Payload.Comment.Comment |
||||||
|
} |
||||||
|
|
||||||
|
if *comment == "" { |
||||||
|
var err error |
||||||
|
if *comment, err = tmpFileMsg(defaultCommitFileMsgTpl, prevComment); err != nil { |
||||||
|
return nil, fmt.Errorf("collecting comment message from user: %w", err) |
||||||
|
|
||||||
|
} else if *comment == "" { |
||||||
|
return nil, errors.New("empty comment message, not doing anything") |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
payUn, err := proj.NewPayloadComment(*comment) |
||||||
|
if err != nil { |
||||||
|
return nil, fmt.Errorf("constructing comment commit: %w", err) |
||||||
|
} |
||||||
|
return nil, accreditAndCommit(payUn) |
||||||
|
}) |
||||||
|
}, |
||||||
|
) |
||||||
|
|
||||||
|
cmd.Run(body) |
||||||
|
} |
||||||
|
|
||||||
|
func cmdCombine(ctx context.Context, cmd *dcmd.Cmd) { |
||||||
|
flag := cmd.FlagSet() |
||||||
|
onto := flag.String("onto", "", "Branch the new commit should be put onto") |
||||||
|
startRev := flag.String("start", "", "Revision of the starting commit to combine") |
||||||
|
endRev := flag.String("end", "", "Revision of the ending commit to combine") |
||||||
|
|
||||||
|
var proj proj |
||||||
|
proj.initFlags(flag) |
||||||
|
|
||||||
|
cmd.Run(func() (context.Context, error) { |
||||||
|
if *onto == "" || |
||||||
|
*startRev == "" || |
||||||
|
*endRev == "" { |
||||||
|
return nil, errors.New("-onto, -start, and -end are required") |
||||||
|
} |
||||||
|
|
||||||
|
if err := proj.openProj(); err != nil { |
||||||
|
return nil, err |
||||||
|
} |
||||||
|
|
||||||
|
commits, err := proj.GetCommitRangeByRevision( |
||||||
|
plumbing.Revision(*startRev), |
||||||
|
plumbing.Revision(*endRev), |
||||||
|
) |
||||||
|
if err != nil { |
||||||
|
return nil, fmt.Errorf("error getting commits %q to %q: %w", |
||||||
|
*startRev, *endRev, err) |
||||||
|
} |
||||||
|
|
||||||
|
ontoBranch := plumbing.NewBranchReferenceName(*onto) |
||||||
|
commit, err := proj.CombinePayloadChanges(commits, ontoBranch) |
||||||
|
if err != nil { |
||||||
|
return nil, err |
||||||
|
} |
||||||
|
|
||||||
|
fmt.Printf("new commit %q added to branch %q\n", commit.Hash, ontoBranch.Short()) |
||||||
|
return nil, nil |
||||||
|
}) |
||||||
|
} |
@ -0,0 +1,66 @@ |
|||||||
|
package main |
||||||
|
|
||||||
|
import ( |
||||||
|
"bufio" |
||||||
|
"context" |
||||||
|
"errors" |
||||||
|
"fmt" |
||||||
|
"io" |
||||||
|
"os" |
||||||
|
"strings" |
||||||
|
|
||||||
|
"dehub.dev/src/dehub.git/cmd/dehub/dcmd" |
||||||
|
|
||||||
|
"gopkg.in/src-d/go-git.v4/plumbing" |
||||||
|
) |
||||||
|
|
||||||
|
func cmdHook(ctx context.Context, cmd *dcmd.Cmd) { |
||||||
|
flag := cmd.FlagSet() |
||||||
|
var proj proj |
||||||
|
proj.initFlags(flag) |
||||||
|
|
||||||
|
body := func() (context.Context, error) { |
||||||
|
if err := proj.openProj(); err != nil { |
||||||
|
return nil, err |
||||||
|
} |
||||||
|
return ctx, nil |
||||||
|
} |
||||||
|
|
||||||
|
cmd.SubCmd("pre-receive", "Use dehub as a server-side pre-receive hook", |
||||||
|
func(ctx context.Context, cmd *dcmd.Cmd) { |
||||||
|
cmd.Run(func() (context.Context, error) { |
||||||
|
br := bufio.NewReader(os.Stdin) |
||||||
|
for { |
||||||
|
line, err := br.ReadString('\n') |
||||||
|
if errors.Is(err, io.EOF) { |
||||||
|
break |
||||||
|
} else if err != nil { |
||||||
|
return nil, fmt.Errorf("error reading next line from stdin: %w", err) |
||||||
|
} |
||||||
|
fmt.Printf("Processing line %q\n", strings.TrimSpace(line)) |
||||||
|
|
||||||
|
lineParts := strings.Fields(line) |
||||||
|
if len(lineParts) < 3 { |
||||||
|
return nil, fmt.Errorf("malformed pre-receive hook stdin line %q", line) |
||||||
|
} |
||||||
|
|
||||||
|
endHash := plumbing.NewHash(lineParts[1]) |
||||||
|
branchName := plumbing.ReferenceName(lineParts[2]) |
||||||
|
|
||||||
|
if !branchName.IsBranch() { |
||||||
|
return nil, fmt.Errorf("reference %q is not a branch, can't push to it", branchName) |
||||||
|
} else if endHash == plumbing.ZeroHash { |
||||||
|
return nil, errors.New("deleting remote branches is not currently supported") |
||||||
|
} |
||||||
|
|
||||||
|
return nil, proj.VerifyCanSetBranchHEADTo(branchName, endHash) |
||||||
|
} |
||||||
|
|
||||||
|
fmt.Println("All pushed commits have been verified, well done.") |
||||||
|
return nil, nil |
||||||
|
}) |
||||||
|
}, |
||||||
|
) |
||||||
|
|
||||||
|
cmd.Run(body) |
||||||
|
} |
@ -0,0 +1,27 @@ |
|||||||
|
package main |
||||||
|
|
||||||
|
import ( |
||||||
|
"context" |
||||||
|
"fmt" |
||||||
|
|
||||||
|
"dehub.dev/src/dehub.git" |
||||||
|
"dehub.dev/src/dehub.git/cmd/dehub/dcmd" |
||||||
|
) |
||||||
|
|
||||||
|
func cmdInit(ctx context.Context, cmd *dcmd.Cmd) { |
||||||
|
flag := cmd.FlagSet() |
||||||
|
path := flag.String("path", ".", "Path to initialize the project at") |
||||||
|
bare := flag.Bool("bare", false, "Initialize the git repo as a bare repository") |
||||||
|
remote := flag.Bool("remote", false, "Configure the git repo to allow it to be used as a remote endpoint") |
||||||
|
|
||||||
|
cmd.Run(func() (context.Context, error) { |
||||||
|
_, err := dehub.InitProject(*path, |
||||||
|
dehub.InitBareRepo(*bare), |
||||||
|
dehub.InitRemoteRepo(*remote), |
||||||
|
) |
||||||
|
if err != nil { |
||||||
|
return nil, fmt.Errorf("initializing repo at %q: %w", *path, err) |
||||||
|
} |
||||||
|
return nil, nil |
||||||
|
}) |
||||||
|
} |
@ -0,0 +1,75 @@ |
|||||||
|
package main |
||||||
|
|
||||||
|
import ( |
||||||
|
"errors" |
||||||
|
"flag" |
||||||
|
"fmt" |
||||||
|
"os" |
||||||
|
|
||||||
|
"dehub.dev/src/dehub.git" |
||||||
|
"gopkg.in/src-d/go-git.v4/plumbing" |
||||||
|
) |
||||||
|
|
||||||
|
type proj struct { |
||||||
|
bare bool |
||||||
|
|
||||||
|
*dehub.Project |
||||||
|
} |
||||||
|
|
||||||
|
func (proj *proj) initFlags(flag *flag.FlagSet) { |
||||||
|
flag.BoolVar(&proj.bare, "bare", false, "If set then the project being opened will be expected to have a bare git repo") |
||||||
|
} |
||||||
|
|
||||||
|
func (proj *proj) openProj() error { |
||||||
|
var err error |
||||||
|
if proj.Project, err = dehub.OpenProject(".", dehub.OpenBareRepo(proj.bare)); err != nil { |
||||||
|
wd, _ := os.Getwd() |
||||||
|
return fmt.Errorf("opening repo at %q: %w", wd, err) |
||||||
|
} |
||||||
|
return nil |
||||||
|
} |
||||||
|
|
||||||
|
// softReset resets to HEAD^ (or to an orphaned index, if HEAD has no parents),
|
||||||
|
// returning the old HEAD.
|
||||||
|
func (proj *proj) softReset(expType string) (dehub.Commit, error) { |
||||||
|
head, err := proj.GetHeadCommit() |
||||||
|
if err != nil { |
||||||
|
return head, fmt.Errorf("getting HEAD commit: %w", err) |
||||||
|
} else if typ := head.Payload.Type(); expType != "" && typ != expType { |
||||||
|
return head, fmt.Errorf("expected HEAD to be have a %q payload, but found a %q payload", |
||||||
|
expType, typ) |
||||||
|
} |
||||||
|
|
||||||
|
branchName, branchErr := proj.ReferenceToBranchName(plumbing.HEAD) |
||||||
|
numParents := head.Object.NumParents() |
||||||
|
if numParents > 1 { |
||||||
|
return head, errors.New("cannot reset to parent of a commit with multiple parents") |
||||||
|
|
||||||
|
} else if numParents == 0 { |
||||||
|
// if there are no parents then HEAD is the only commit in the branch.
|
||||||
|
// Don't handle ErrNoBranchReference because there's not really anything
|
||||||
|
// which can be done for that; we can't set head to "no commit".
|
||||||
|
// Otherwise, just remove the branch reference, HEAD will still point to
|
||||||
|
// it and all of HEAD's changes will be in the index.
|
||||||
|
if branchErr != nil { |
||||||
|
return head, branchErr |
||||||
|
} else if err := proj.GitRepo.Storer.RemoveReference(branchName); err != nil { |
||||||
|
return head, fmt.Errorf("removing reference %q: %w", branchName, err) |
||||||
|
} |
||||||
|
return head, nil |
||||||
|
} |
||||||
|
|
||||||
|
refName := branchName |
||||||
|
if errors.Is(branchErr, dehub.ErrNoBranchReference) { |
||||||
|
refName = plumbing.HEAD |
||||||
|
} else if err != nil { |
||||||
|
return head, fmt.Errorf("resolving HEAD: %w", err) |
||||||
|
} |
||||||
|
|
||||||
|
parentHash := head.Object.ParentHashes[0] |
||||||
|
newHeadRef := plumbing.NewHashReference(refName, parentHash) |
||||||
|
if err := proj.GitRepo.Storer.SetReference(newHeadRef); err != nil { |
||||||
|
return head, fmt.Errorf("storing reference %q: %w", newHeadRef, err) |
||||||
|
} |
||||||
|
return head, nil |
||||||
|
} |
@ -0,0 +1,48 @@ |
|||||||
|
package main |
||||||
|
|
||||||
|
import ( |
||||||
|
"context" |
||||||
|
"fmt" |
||||||
|
|
||||||
|
"dehub.dev/src/dehub.git" |
||||||
|
"dehub.dev/src/dehub.git/cmd/dehub/dcmd" |
||||||
|
|
||||||
|
"gopkg.in/src-d/go-git.v4/plumbing" |
||||||
|
) |
||||||
|
|
||||||
|
func cmdVerify(ctx context.Context, cmd *dcmd.Cmd) { |
||||||
|
flag := cmd.FlagSet() |
||||||
|
rev := flag.String("rev", "HEAD", "Revision of commit to verify") |
||||||
|
branch := flag.String("branch", "", "Branch that the revision is on. If not given then the currently checked out branch is assumed") |
||||||
|
|
||||||
|
var proj proj |
||||||
|
proj.initFlags(flag) |
||||||
|
|
||||||
|
cmd.Run(func() (context.Context, error) { |
||||||
|
if err := proj.openProj(); err != nil { |
||||||
|
return nil, err |
||||||
|
} |
||||||
|
|
||||||
|
commit, err := proj.GetCommitByRevision(plumbing.Revision(*rev)) |
||||||
|
if err != nil { |
||||||
|
return nil, fmt.Errorf("resolving revision %q: %w", *rev, err) |
||||||
|
} |
||||||
|
|
||||||
|
var branchName plumbing.ReferenceName |
||||||
|
if *branch == "" { |
||||||
|
if branchName, err = proj.ReferenceToBranchName(plumbing.HEAD); err != nil { |
||||||
|
return nil, fmt.Errorf("determining branch at HEAD: %w", err) |
||||||
|
} |
||||||
|
} else { |
||||||
|
branchName = plumbing.NewBranchReferenceName(*branch) |
||||||
|
} |
||||||
|
|
||||||
|
if err := proj.VerifyCommits(branchName, []dehub.Commit{commit}); err != nil { |
||||||
|
return nil, fmt.Errorf("could not verify commit at %q (%s): %w", |
||||||
|
*rev, commit.Hash, err) |
||||||
|
} |
||||||
|
|
||||||
|
fmt.Printf("commit at %q (%s) is good to go!\n", *rev, commit.Hash) |
||||||
|
return nil, nil |
||||||
|
}) |
||||||
|
} |
@ -0,0 +1,191 @@ |
|||||||
|
// Package dcmd implements command and sub-command parsing and runtime
|
||||||
|
// management. It wraps the stdlib flag package as well, to incorporate
|
||||||
|
// configuration into the mix.
|
||||||
|
package dcmd |
||||||
|
|
||||||
|
import ( |
||||||
|
"context" |
||||||
|
"errors" |
||||||
|
"flag" |
||||||
|
"fmt" |
||||||
|
"os" |
||||||
|
"sort" |
||||||
|
"strings" |
||||||
|
) |
||||||
|
|
||||||
|
func exitErr(err error) { |
||||||
|
fmt.Fprintf(os.Stderr, "exiting: %v\n", err) |
||||||
|
os.Stderr.Sync() |
||||||
|
os.Stdout.Sync() |
||||||
|
os.Exit(1) |
||||||
|
} |
||||||
|
|
||||||
|
type subCmd struct { |
||||||
|
name, descr string |
||||||
|
run func(context.Context, *Cmd) |
||||||
|
} |
||||||
|
|
||||||
|
// Cmd wraps a flag.FlagSet instance to provide extra functionality that dehub
|
||||||
|
// wants, specifically around sub-command support.
|
||||||
|
type Cmd struct { |
||||||
|
flagSet *flag.FlagSet |
||||||
|
binary string // only gets set on root Cmd, during Run
|
||||||
|
subCmds []subCmd |
||||||
|
|
||||||
|
// these fields get set by the parent Cmd, if this is a sub-command.
|
||||||
|
name string |
||||||
|
args []string |
||||||
|
parent *Cmd |
||||||
|
} |
||||||
|
|
||||||
|
// New initializes and returns an empty Cmd instance.
|
||||||
|
func New() *Cmd { |
||||||
|
return &Cmd{} |
||||||
|
} |
||||||
|
|
||||||
|
func (cmd *Cmd) getFlagSet() *flag.FlagSet { |
||||||
|
if cmd.flagSet == nil { |
||||||
|
cmd.flagSet = flag.NewFlagSet(cmd.name, flag.ContinueOnError) |
||||||
|
} |
||||||
|
return cmd.flagSet |
||||||
|
} |
||||||
|
|
||||||
|
func (cmd *Cmd) numFlags() int { |
||||||
|
var n int |
||||||
|
cmd.getFlagSet().VisitAll(func(*flag.Flag) { |
||||||
|
n++ |
||||||
|
}) |
||||||
|
return n |
||||||
|
} |
||||||
|
|
||||||
|
// FlagSet returns a flag.Cmd instance on which parameter creation methods can
|
||||||
|
// be called, e.g. String(...) or Int(...).
|
||||||
|
func (cmd *Cmd) FlagSet() *flag.FlagSet { |
||||||
|
return cmd.getFlagSet() |
||||||
|
} |
||||||
|
|
||||||
|
// SubCmd registers a sub-command of this Cmd.
|
||||||
|
//
|
||||||
|
// A new Cmd will be instantiated when this sub-command is picked on the
|
||||||
|
// command-line during this Cmd's Run method. The Context returned from that Run
|
||||||
|
// and the new Cmd will be passed into the callback given here. The sub-command
|
||||||
|
// should then be performed in the same manner as this Cmd is performed
|
||||||
|
// (including setting flags, adding sub-sub-commands, etc...)
|
||||||
|
func (cmd *Cmd) SubCmd(name, descr string, run func(context.Context, *Cmd)) { |
||||||
|
cmd.subCmds = append(cmd.subCmds, subCmd{ |
||||||
|
name: name, |
||||||
|
descr: descr, |
||||||
|
run: run, |
||||||
|
}) |
||||||
|
|
||||||
|
// it's not the most efficient to do this here, but it is the easiest
|
||||||
|
sort.Slice(cmd.subCmds, func(i, j int) bool { |
||||||
|
return cmd.subCmds[i].name < cmd.subCmds[j].name |
||||||
|
}) |
||||||
|
} |
||||||
|
|
||||||
|
func (cmd *Cmd) printUsageHead(subCmdTitle string) { |
||||||
|
hasFlags := cmd.numFlags() > 0 |
||||||
|
|
||||||
|
var title string |
||||||
|
if cmd.parent == nil { |
||||||
|
title = fmt.Sprintf("USAGE: %s", cmd.binary) |
||||||
|
if hasFlags { |
||||||
|
title += " [flags]" |
||||||
|
} |
||||||
|
} else { |
||||||
|
title = fmt.Sprintf("%s", cmd.name) |
||||||
|
if hasFlags { |
||||||
|
title += fmt.Sprintf(" [%s flags]", cmd.name) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
if subCmdTitle != "" { |
||||||
|
title += " " + subCmdTitle |
||||||
|
} else if len(cmd.subCmds) > 0 { |
||||||
|
title += fmt.Sprint(" <sub-command> [sub-command flags]") |
||||||
|
} |
||||||
|
|
||||||
|
if cmd.parent == nil { |
||||||
|
fmt.Printf("\n%s\n\n", title) |
||||||
|
} else { |
||||||
|
cmd.parent.printUsageHead(title) |
||||||
|
} |
||||||
|
|
||||||
|
if hasFlags { |
||||||
|
if cmd.parent == nil { |
||||||
|
fmt.Print("### FLAGS ###\n\n") |
||||||
|
} else { |
||||||
|
fmt.Printf("### %s FLAGS ###\n\n", strings.ToUpper(cmd.name)) |
||||||
|
} |
||||||
|
cmd.getFlagSet().PrintDefaults() |
||||||
|
fmt.Print("\n") |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Run performs the comand. It starts by parsing all flags in the Cmd's FlagSet,
|
||||||
|
// and possibly exiting with a usage message if appropriate. It will then
|
||||||
|
// perform the given body callback, and then perform any sub-commands (if
|
||||||
|
// selected).
|
||||||
|
//
|
||||||
|
// The context returned from the callback will be passed into the callback
|
||||||
|
// (given to SubCmd) of any sub-commands which are run, and so on.
|
||||||
|
func (cmd *Cmd) Run(body func() (context.Context, error)) { |
||||||
|
args := cmd.args |
||||||
|
if cmd.parent == nil { |
||||||
|
cmd.binary, args = os.Args[0], os.Args[1:] |
||||||
|
} |
||||||
|
|
||||||
|
fs := cmd.getFlagSet() |
||||||
|
fs.Usage = func() { |
||||||
|
cmd.printUsageHead("") |
||||||
|
if len(cmd.subCmds) == 0 { |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
fmt.Printf("### SUB-COMMANDS ###\n\n") |
||||||
|
for _, subCmd := range cmd.subCmds { |
||||||
|
fmt.Printf("\t%s : %s\n", subCmd.name, subCmd.descr) |
||||||
|
} |
||||||
|
fmt.Println("") |
||||||
|
} |
||||||
|
|
||||||
|
if err := fs.Parse(args); err != nil { |
||||||
|
exitErr(err) |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
ctx, err := body() |
||||||
|
if err != nil { |
||||||
|
exitErr(err) |
||||||
|
} |
||||||
|
|
||||||
|
// body has run, now do sub-command (if there is one)
|
||||||
|
subArgs := fs.Args() |
||||||
|
if len(cmd.subCmds) == 0 { |
||||||
|
return |
||||||
|
} else if len(subArgs) == 0 && len(cmd.subCmds) > 0 { |
||||||
|
fs.Usage() |
||||||
|
exitErr(errors.New("no sub-command selected")) |
||||||
|
} |
||||||
|
|
||||||
|
// now find that sub-command
|
||||||
|
subCmdName := strings.ToLower(subArgs[0]) |
||||||
|
var subCmd subCmd |
||||||
|
var subCmdOk bool |
||||||
|
for _, subCmd = range cmd.subCmds { |
||||||
|
if subCmdOk = subCmd.name == subCmdName; subCmdOk { |
||||||
|
break |
||||||
|
} |
||||||
|
} |
||||||
|
if !subCmdOk { |
||||||
|
fs.Usage() |
||||||
|
exitErr(fmt.Errorf("unknown command %q", subCmdName)) |
||||||
|
} |
||||||
|
|
||||||
|
subCmdCmd := New() |
||||||
|
subCmdCmd.name = subCmd.name |
||||||
|
subCmdCmd.args = subArgs[1:] |
||||||
|
subCmdCmd.parent = cmd |
||||||
|
subCmd.run(ctx, subCmdCmd) |
||||||
|
} |
@ -1,136 +1,20 @@ |
|||||||
package main |
package main |
||||||
|
|
||||||
import ( |
import ( |
||||||
"dehub" |
"context" |
||||||
"errors" |
|
||||||
"flag" |
|
||||||
"fmt" |
|
||||||
"os" |
|
||||||
"strings" |
|
||||||
|
|
||||||
"gopkg.in/src-d/go-git.v4/plumbing" |
"dehub.dev/src/dehub.git/cmd/dehub/dcmd" |
||||||
) |
) |
||||||
|
|
||||||
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() { |
func main() { |
||||||
if len(os.Args) < 2 { |
cmd := dcmd.New() |
||||||
printHelp() |
cmd.SubCmd("init", "Initialize a new project in a directory", cmdInit) |
||||||
return |
cmd.SubCmd("commit", "Commits staged changes to the head of the current branch", cmdCommit) |
||||||
} |
cmd.SubCmd("verify", "Verifies one or more commits as having the proper credentials", cmdVerify) |
||||||
|
cmd.SubCmd("hook", "Use dehub as a git hook", cmdHook) |
||||||
subCmdName := strings.ToLower(os.Args[1]) |
cmd.SubCmd("combine", "Combine multiple change and credential commits into a single commit", cmdCombine) |
||||||
for _, subCmd := range subCmds { |
|
||||||
if subCmd.name != subCmdName { |
cmd.Run(func() (context.Context, error) { |
||||||
continue |
return context.Background(), nil |
||||||
} |
}) |
||||||
|
|
||||||
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,72 @@ |
|||||||
|
package main |
||||||
|
|
||||||
|
import ( |
||||||
|
"bufio" |
||||||
|
"bytes" |
||||||
|
"errors" |
||||||
|
"fmt" |
||||||
|
"io" |
||||||
|
"io/ioutil" |
||||||
|
"os" |
||||||
|
"os/exec" |
||||||
|
"strings" |
||||||
|
) |
||||||
|
|
||||||
|
const defaultCommitFileMsgTpl = `%s |
||||||
|
|
||||||
|
# Please enter the description for your commit(s). Lines starting with '#' will |
||||||
|
# be ignored, and an empty message aborts the commit.` |
||||||
|
|
||||||
|
func tmpFileMsg(tpl string, args ...interface{}) (string, error) { |
||||||
|
editor := os.Getenv("EDITOR") |
||||||
|
if editor == "" { |
||||||
|
return "", errors.New("EDITOR not set, please set it or use -msg in order to create your commit message") |
||||||
|
} else if _, err := os.Stat(editor); err != nil { |
||||||
|
return "", fmt.Errorf("could not stat EDITOR %q: %w", editor, err) |
||||||
|
} |
||||||
|
|
||||||
|
tmpf, err := ioutil.TempFile("", "dehub.*.txt") |
||||||
|
if err != nil { |
||||||
|
return "", fmt.Errorf("could not open temp file: %w", err) |
||||||
|
} |
||||||
|
tmpfName := tmpf.Name() |
||||||
|
defer os.Remove(tmpfName) |
||||||
|
|
||||||
|
tmpBody := bytes.NewBufferString(fmt.Sprintf(tpl, args...)) |
||||||
|
|
||||||
|
_, err = io.Copy(tmpf, tmpBody) |
||||||
|
tmpf.Close() |
||||||
|
if err != nil { |
||||||
|
return "", fmt.Errorf("could not write helper message to temp file %q: %w", tmpfName, err) |
||||||
|
} |
||||||
|
|
||||||
|
cmd := exec.Command(editor, tmpfName) |
||||||
|
cmd.Stdin = os.Stdin |
||||||
|
cmd.Stdout = os.Stdout |
||||||
|
cmd.Stderr = os.Stderr |
||||||
|
if err := cmd.Run(); err != nil { |
||||||
|
return "", fmt.Errorf("error running '%s %q': %w", editor, tmpfName, err) |
||||||
|
} |
||||||
|
|
||||||
|
body, err := ioutil.ReadFile(tmpfName) |
||||||
|
if err != nil { |
||||||
|
return "", fmt.Errorf("error retrieving message body from %q: %w", tmpfName, err) |
||||||
|
} |
||||||
|
|
||||||
|
bodyFiltered := new(bytes.Buffer) |
||||||
|
bodyBR := bufio.NewReader(bytes.NewBuffer(body)) |
||||||
|
for { |
||||||
|
line, err := bodyBR.ReadString('\n') |
||||||
|
if errors.Is(err, io.EOF) { |
||||||
|
break |
||||||
|
} else if err != nil { |
||||||
|
return "", fmt.Errorf("error reading from buffered body: %w", err) |
||||||
|
} |
||||||
|
|
||||||
|
if !strings.HasPrefix(strings.TrimSpace(line), "#") { |
||||||
|
bodyFiltered.WriteString(line) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return strings.TrimSpace(bodyFiltered.String()), nil |
||||||
|
} |
@ -0,0 +1,27 @@ |
|||||||
|
# git-http-server |
||||||
|
|
||||||
|
A simple http server which uses a git repo (bare or otherwise) as the underlying |
||||||
|
filesystem. |
||||||
|
|
||||||
|
* Automatically renders markdown files as html. |
||||||
|
* Will use `README.md` as the index, if available. |
||||||
|
* Can be set to use a specific branch. |
||||||
|
|
||||||
|
All configuration is done on the command-line. |
||||||
|
|
||||||
|
# Installation |
||||||
|
|
||||||
|
Installation of git-http-server is done in the same manner as the `dehub` |
||||||
|
command itself: |
||||||
|
|
||||||
|
``` |
||||||
|
go get dehub.dev/src/dehub.git/cmd/git-http-server |
||||||
|
``` |
||||||
|
|
||||||
|
# Markdown |
||||||
|
|
||||||
|
TODO |
||||||
|
|
||||||
|
# Templates |
||||||
|
|
||||||
|
TODO |
@ -0,0 +1,11 @@ |
|||||||
|
module dehub/cmd/git-http-server |
||||||
|
|
||||||
|
go 1.14 |
||||||
|
|
||||||
|
require ( |
||||||
|
github.com/gomarkdown/markdown v0.0.0-20200513213024-62c5e2c608cc |
||||||
|
golang.org/x/crypto v0.0.0-20200109152110-61a87790db17 // indirect |
||||||
|
gopkg.in/src-d/go-git.v4 v4.13.1 |
||||||
|
) |
||||||
|
|
||||||
|
replace dehub => ../../ |
@ -0,0 +1,79 @@ |
|||||||
|
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/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/gomarkdown/markdown v0.0.0-20200513213024-62c5e2c608cc h1:T+Fwk3llJdUIQeBI8fC/ARqRD5mWy3AE5I6ZU3VkIw8= |
||||||
|
github.com/gomarkdown/markdown v0.0.0-20200513213024-62c5e2c608cc/go.mod h1:aii0r/K0ZnHv7G0KF7xy1v0A7s2Ljrb5byB7MO5p6TU= |
||||||
|
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/dl v0.0.0-20190829154251-82a15e2f2ead/go.mod h1:IUMfjQLJQd4UTqG1Z90tenwKoCX93Gn3MAQJMOSBsDQ= |
||||||
|
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/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 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= |
@ -0,0 +1,154 @@ |
|||||||
|
package main |
||||||
|
|
||||||
|
import ( |
||||||
|
"bytes" |
||||||
|
"errors" |
||||||
|
"flag" |
||||||
|
"fmt" |
||||||
|
"io/ioutil" |
||||||
|
"log" |
||||||
|
"net/http" |
||||||
|
"path/filepath" |
||||||
|
"strings" |
||||||
|
"text/template" |
||||||
|
"time" |
||||||
|
|
||||||
|
"github.com/gomarkdown/markdown" |
||||||
|
"gopkg.in/src-d/go-git.v4" |
||||||
|
"gopkg.in/src-d/go-git.v4/plumbing" |
||||||
|
"gopkg.in/src-d/go-git.v4/plumbing/object" |
||||||
|
) |
||||||
|
|
||||||
|
type handler struct { |
||||||
|
repo *git.Repository |
||||||
|
branch plumbing.ReferenceName |
||||||
|
tpl *template.Template |
||||||
|
} |
||||||
|
|
||||||
|
func (h handler) getTree(r *http.Request) (*object.Tree, int, error) { |
||||||
|
rev := plumbing.Revision(r.FormValue("rev")) |
||||||
|
if rev == "" { |
||||||
|
rev = plumbing.Revision(h.branch) |
||||||
|
} |
||||||
|
|
||||||
|
hashPtr, err := h.repo.ResolveRevision(rev) |
||||||
|
if err != nil { |
||||||
|
return nil, 404, fmt.Errorf("resolving revision %q: %w", rev, err) |
||||||
|
} |
||||||
|
hash := *hashPtr // I don't know why ResolveRevision returns a pointer
|
||||||
|
|
||||||
|
commit, err := h.repo.CommitObject(hash) |
||||||
|
if err != nil { |
||||||
|
return nil, 404, fmt.Errorf("retrieving commit for revision %q (%q): %w", |
||||||
|
rev, hash, err) |
||||||
|
} |
||||||
|
|
||||||
|
tree, err := h.repo.TreeObject(commit.TreeHash) |
||||||
|
if err != nil { |
||||||
|
return nil, 500, fmt.Errorf("fetching tree %q of commit %q: %v", |
||||||
|
commit.TreeHash, hash, err) |
||||||
|
} |
||||||
|
return tree, 0, nil |
||||||
|
} |
||||||
|
|
||||||
|
func (h handler) ServeHTTP(rw http.ResponseWriter, r *http.Request) { |
||||||
|
path := r.URL.Path |
||||||
|
var mdPath string |
||||||
|
if strings.HasSuffix(path, "/") { |
||||||
|
mdPath = filepath.Join(path, "README.md") // do before modifying path
|
||||||
|
path = filepath.Join(path, "index.html") |
||||||
|
|
||||||
|
} else if strings.HasSuffix(path, "/index.html") { |
||||||
|
mdPath = filepath.Join(filepath.Dir(path), "README.md") |
||||||
|
|
||||||
|
} else if filepath.Ext(path) == ".html" { |
||||||
|
mdPath = strings.TrimSuffix(path, ".html") + ".md" |
||||||
|
} |
||||||
|
|
||||||
|
path = strings.TrimPrefix(path, "/") |
||||||
|
mdPath = strings.TrimPrefix(mdPath, "/") |
||||||
|
|
||||||
|
tree, errStatusCode, err := h.getTree(r) |
||||||
|
if err != nil { |
||||||
|
http.Error(rw, err.Error(), errStatusCode) |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
var usingMD bool |
||||||
|
f, err := tree.File(path) |
||||||
|
if errors.Is(err, object.ErrFileNotFound) { |
||||||
|
usingMD = true |
||||||
|
f, err = tree.File(mdPath) |
||||||
|
} |
||||||
|
|
||||||
|
if errors.Is(err, object.ErrFileNotFound) { |
||||||
|
http.Error(rw, fmt.Sprintf("%q not found", path), 404) |
||||||
|
return |
||||||
|
} else if err != nil { |
||||||
|
log.Printf("fetching file %q / %q: %v", path, mdPath, err) |
||||||
|
http.Error(rw, "internal error", 500) |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
fr, err := f.Blob.Reader() |
||||||
|
if err != nil { |
||||||
|
log.Printf("getting reader of file %q: %v", f.Name, err) |
||||||
|
http.Error(rw, "internal error", 500) |
||||||
|
return |
||||||
|
} |
||||||
|
defer fr.Close() |
||||||
|
|
||||||
|
b, err := ioutil.ReadAll(fr) |
||||||
|
if err != nil { |
||||||
|
log.Printf("reading in contents of file %q: %v", f.Name, err) |
||||||
|
http.Error(rw, "internal error", 500) |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
if !usingMD { |
||||||
|
http.ServeContent(rw, r, filepath.Base(path), time.Now(), bytes.NewReader(b)) |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
mdHTML := markdown.ToHTML(b, nil, nil) |
||||||
|
|
||||||
|
if h.tpl == nil { |
||||||
|
http.ServeContent(rw, r, filepath.Base(path), time.Now(), bytes.NewReader(mdHTML)) |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
h.tpl.Execute(rw, struct { |
||||||
|
Body string |
||||||
|
}{string(mdHTML)}) |
||||||
|
} |
||||||
|
|
||||||
|
func main() { |
||||||
|
addr := flag.String("addr", ":8000", "Address to listen for http requests on") |
||||||
|
branchName := flag.String("branch", "master", "git branch to serve the HEAD of") |
||||||
|
repoPath := flag.String("repo-path", ".", "Path to the git repository to server") |
||||||
|
tplPath := flag.String("tpl-path", "", "Path to an optional template file which can be used when rendering markdown") |
||||||
|
flag.Parse() |
||||||
|
|
||||||
|
repo, err := git.PlainOpen(*repoPath) |
||||||
|
if err != nil { |
||||||
|
log.Fatalf("opening git repo at path %q: %v", *repoPath, err) |
||||||
|
} |
||||||
|
branch := plumbing.NewBranchReferenceName(*branchName) |
||||||
|
|
||||||
|
// do an initial check for the branch, for funsies
|
||||||
|
if _, err := repo.Reference(branch, true); err != nil { |
||||||
|
log.Fatalf("resolving reference %q: %v", branch, err) |
||||||
|
} |
||||||
|
|
||||||
|
h := &handler{ |
||||||
|
repo: repo, |
||||||
|
branch: branch, |
||||||
|
} |
||||||
|
|
||||||
|
if *tplPath != "" { |
||||||
|
h.tpl = template.Must(template.ParseFiles(*tplPath)) |
||||||
|
} |
||||||
|
|
||||||
|
log.Printf("listening on %q", *addr) |
||||||
|
http.ListenAndServe(*addr, h) |
||||||
|
} |
@ -1,251 +1,222 @@ |
|||||||
package dehub |
package dehub |
||||||
|
|
||||||
import ( |
import ( |
||||||
"bytes" |
"encoding/hex" |
||||||
"dehub/accessctl" |
|
||||||
"dehub/fs" |
|
||||||
"dehub/sigcred" |
|
||||||
"dehub/yamlutil" |
|
||||||
"encoding/base64" |
|
||||||
"errors" |
"errors" |
||||||
"fmt" |
"fmt" |
||||||
|
"path/filepath" |
||||||
"strings" |
"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" |
||||||
"gopkg.in/src-d/go-git.v4/plumbing/object" |
"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
|
// Commit wraps a single git commit object, and also contains various fields
|
||||||
// message of a commit in the master branch.
|
// which are parsed out of it, including the payload. It is used as a
|
||||||
type MasterCommit struct { |
// convenience type, in place of having to manually retrieve and parse specific
|
||||||
Message string `yaml:"message"` |
// information out of commit objects.
|
||||||
ChangeHash yamlutil.Blob `yaml:"change_hash"` |
type Commit struct { |
||||||
Credentials []sigcred.Credential `yaml:"credentials"` |
Payload PayloadUnion |
||||||
} |
|
||||||
|
|
||||||
type mcYAML struct { |
|
||||||
Val MasterCommit `yaml:",inline"` |
|
||||||
} |
|
||||||
|
|
||||||
func msgHead(msg string) string { |
Hash plumbing.Hash |
||||||
i := strings.Index(msg, "\n") |
Object *object.Commit |
||||||
if i > 0 { |
TreeObject *object.Tree |
||||||
return msg[:i] |
|
||||||
} |
|
||||||
return msg |
|
||||||
} |
} |
||||||
|
|
||||||
// MarshalText implements the encoding.TextMarshaler interface by returning the
|
// GetCommit retrieves the Commit at the given hash, and all of its sub-data
|
||||||
// form the MasterCommit object takes in the git commit message.
|
// which can be pulled out of it.
|
||||||
func (mc MasterCommit) MarshalText() ([]byte, error) { |
func (proj *Project) GetCommit(h plumbing.Hash) (c Commit, err error) { |
||||||
masterCommitEncoded, err := yaml.Marshal(mcYAML{mc}) |
if c.Object, err = proj.GitRepo.CommitObject(h); err != nil { |
||||||
if err != nil { |
return c, fmt.Errorf("getting git commit object: %w", err) |
||||||
return nil, fmt.Errorf("failed to encode MasterCommit message: %w", err) |
} else if c.TreeObject, err = proj.GitRepo.TreeObject(c.Object.TreeHash); err != nil { |
||||||
} |
return c, fmt.Errorf("getting git tree object %q: %w", |
||||||
|
c.Object.TreeHash, err) |
||||||
fullMsg := msgHead(mc.Message) + "\n\n" + string(masterCommitEncoded) |
} else if err = c.Payload.UnmarshalText([]byte(c.Object.Message)); err != nil { |
||||||
return []byte(fullMsg), nil |
return c, fmt.Errorf("decoding commit message: %w", err) |
||||||
|
} |
||||||
|
c.Hash = c.Object.Hash |
||||||
|
return |
||||||
} |
} |
||||||
|
|
||||||
// UnmarshalText implements the encoding.TextUnmarshaler interface by decoding a
|
// ErrHeadIsZero is used to indicate that HEAD resolves to the zero hash. An
|
||||||
// MasterCommit object which has been encoded into a git commit message.
|
// example of when this can happen is if the project was just initialized and
|
||||||
func (mc *MasterCommit) UnmarshalText(msg []byte) error { |
// has no commits, or if an orphan branch is checked out.
|
||||||
i := bytes.Index(msg, []byte("\n")) |
var ErrHeadIsZero = errors.New("HEAD resolves to the zero hash") |
||||||
if i < 0 { |
|
||||||
return fmt.Errorf("commit message %q is malformed", msg) |
|
||||||
} |
|
||||||
msgHead, msg := msg[:i], msg[i:] |
|
||||||
|
|
||||||
var mcy mcYAML |
// GetHeadCommit returns the Commit which is currently referenced by HEAD.
|
||||||
if err := yaml.Unmarshal(msg, &mcy); err != nil { |
// This method may return ErrHeadIsZero if HEAD resolves to the zero hash.
|
||||||
return fmt.Errorf("could not unmarshal MasterCommit message: %w", err) |
func (proj *Project) GetHeadCommit() (Commit, error) { |
||||||
} |
headHash, err := proj.ReferenceToHash(plumbing.HEAD) |
||||||
|
|
||||||
*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 { |
if err != nil { |
||||||
return MasterCommit{}, plumbing.ZeroHash, err |
return Commit{}, fmt.Errorf("resolving HEAD: %w", err) |
||||||
|
} else if headHash == plumbing.ZeroHash { |
||||||
|
return Commit{}, ErrHeadIsZero |
||||||
} |
} |
||||||
|
|
||||||
// this is necessarily different than headTree for the case of there being
|
c, err := proj.GetCommit(headHash) |
||||||
// 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 { |
if err != nil { |
||||||
return MasterCommit{}, plumbing.ZeroHash, err |
return Commit{}, fmt.Errorf("getting commit %q: %w", headHash, err) |
||||||
} |
|
||||||
|
|
||||||
cfg, err := r.loadConfig(sigFS) |
|
||||||
if err != nil { |
|
||||||
return MasterCommit{}, plumbing.ZeroHash, fmt.Errorf("could not load config: %w", err) |
|
||||||
} |
} |
||||||
|
return c, nil |
||||||
|
} |
||||||
|
|
||||||
changeHash := genChangeHash(nil, msg, headTree, stagedTree) |
// GetCommitRange returns an ancestry of Commits, with the first being the
|
||||||
cred, err := sig.Sign(sigFS, changeHash) |
// commit immediately following the given starting hash, and the last being the
|
||||||
|
// given ending hash.
|
||||||
|
//
|
||||||
|
// If start is plumbing.ZeroHash then the root commit will be the starting hash.
|
||||||
|
func (proj *Project) GetCommitRange(start, end plumbing.Hash) ([]Commit, error) { |
||||||
|
curr, err := proj.GetCommit(end) |
||||||
if err != nil { |
if err != nil { |
||||||
return MasterCommit{}, plumbing.ZeroHash, fmt.Errorf("failed to sign commit hash: %w", err) |
return nil, fmt.Errorf("retrieving commit %q: %w", end, err) |
||||||
} |
} |
||||||
cred.AccountID = accountID |
|
||||||
|
|
||||||
// This isn't strictly necessary, but we want to save people the effort of
|
var commits []Commit |
||||||
// creating an invalid commit, pushing it, having it be rejected, then
|
var found bool |
||||||
// having to reset on the commit.
|
for { |
||||||
err = r.assertAccessControls( |
if found = start != plumbing.ZeroHash && curr.Hash == start; found { |
||||||
cfg.AccessControls, []sigcred.Credential{cred}, |
break |
||||||
headTree, stagedTree, |
} |
||||||
) |
|
||||||
if err != nil { |
|
||||||
return MasterCommit{}, plumbing.ZeroHash, fmt.Errorf("commit would not satisfy access controls: %w", err) |
|
||||||
} |
|
||||||
|
|
||||||
masterCommit := MasterCommit{ |
commits = append(commits, curr) |
||||||
Message: msg, |
numParents := curr.Object.NumParents() |
||||||
ChangeHash: changeHash, |
if numParents == 0 { |
||||||
Credentials: []sigcred.Credential{cred}, |
break |
||||||
} |
} else if numParents > 1 { |
||||||
|
return nil, fmt.Errorf("commit %q has more than one parent: %+v", |
||||||
|
curr.Hash, curr.Object.ParentHashes) |
||||||
|
} |
||||||
|
|
||||||
masterCommitB, err := masterCommit.MarshalText() |
parentHash := curr.Object.ParentHashes[0] |
||||||
if err != nil { |
parent, err := proj.GetCommit(parentHash) |
||||||
return masterCommit, plumbing.ZeroHash, err |
if err != nil { |
||||||
|
return nil, fmt.Errorf("retrieving commit %q: %w", parentHash, err) |
||||||
|
} |
||||||
|
curr = parent |
||||||
} |
} |
||||||
|
if !found && start != plumbing.ZeroHash { |
||||||
w, err := r.GitRepo.Worktree() |
return nil, fmt.Errorf("unable to find commit %q as an ancestor of %q", |
||||||
if err != nil { |
start, end) |
||||||
return masterCommit, plumbing.ZeroHash, fmt.Errorf("could not get git worktree: %w", err) |
|
||||||
} |
} |
||||||
|
|
||||||
hash, err := w.Commit(string(masterCommitB), &git.CommitOptions{ |
// reverse the commits to be in the expected order
|
||||||
Author: &object.Signature{ |
for l, r := 0, len(commits)-1; l < r; l, r = l+1, r-1 { |
||||||
Name: accountID, |
commits[l], commits[r] = commits[r], commits[l] |
||||||
When: time.Now(), |
|
||||||
}, |
|
||||||
}) |
|
||||||
if err != nil { |
|
||||||
return masterCommit, hash, fmt.Errorf("failed to commit changed: %w", err) |
|
||||||
} |
} |
||||||
|
return commits, nil |
||||||
return masterCommit, hash, nil |
|
||||||
|
|
||||||
} |
} |
||||||
|
|
||||||
func (r *Repo) assertAccessControls( |
var ( |
||||||
accessCtls []accessctl.AccessControl, creds []sigcred.Credential, |
hashStrLen = len(plumbing.ZeroHash.String()) |
||||||
from, to *object.Tree, |
errNotHex = errors.New("not a valid hex string") |
||||||
) error { |
) |
||||||
filesChanged, err := calcDiff(from, to) |
|
||||||
if err != nil { |
|
||||||
return err |
|
||||||
} |
|
||||||
|
|
||||||
pathsChanged := make([]string, len(filesChanged)) |
func (proj *Project) findCommitByShortHash(hashStr string) (plumbing.Hash, error) { |
||||||
for i := range filesChanged { |
paddedHashStr := hashStr |
||||||
pathsChanged[i] = filesChanged[i].path |
if len(hashStr)%2 > 0 { |
||||||
|
paddedHashStr += "0" |
||||||
} |
} |
||||||
|
|
||||||
accessCtls, err = accessctl.ApplicableAccessControls(accessCtls, pathsChanged) |
if hashB, err := hex.DecodeString(paddedHashStr); err != nil { |
||||||
if err != nil { |
return plumbing.ZeroHash, errNotHex |
||||||
return fmt.Errorf("could not determine applicable access controls: %w", err) |
} else if len(hashStr) == hashStrLen { |
||||||
|
var hash plumbing.Hash |
||||||
|
copy(hash[:], hashB) |
||||||
|
return hash, nil |
||||||
|
} else if len(hashStr) < 2 { |
||||||
|
return plumbing.ZeroHash, errors.New("hash string must be 2 characters long or more") |
||||||
} |
} |
||||||
|
|
||||||
for _, accessCtl := range accessCtls { |
for i := 2; i < hashStrLen; i++ { |
||||||
condInt, err := accessCtl.Condition.Interface() |
hashPrefix, hashTail := hashStr[:i], hashStr[i:] |
||||||
|
path := filepath.Join("objects", hashPrefix) |
||||||
|
fileInfos, err := proj.GitDirFS.ReadDir(path) |
||||||
if err != nil { |
if err != nil { |
||||||
return fmt.Errorf("could not cast Condition to interface: %w", err) |
return plumbing.ZeroHash, fmt.Errorf("listing files in %q: %w", path, 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 |
var matchedHash plumbing.Hash |
||||||
} |
for _, fileInfo := range fileInfos { |
||||||
|
objFileName := fileInfo.Name() |
||||||
// VerifyMasterCommit verifies that the commit at the given hash, which is
|
if !strings.HasPrefix(objFileName, hashTail) { |
||||||
// presumably on the master branch, is gucci.
|
continue |
||||||
func (r *Repo) VerifyMasterCommit(h plumbing.Hash) error { |
} |
||||||
commit, err := r.GitRepo.CommitObject(h) |
|
||||||
if err != nil { |
objHash := plumbing.NewHash(hashPrefix + objFileName) |
||||||
return fmt.Errorf("could not retrieve commit object: %w", err) |
obj, err := proj.GitRepo.Storer.EncodedObject(plumbing.AnyObject, objHash) |
||||||
} |
if err != nil { |
||||||
|
return plumbing.ZeroHash, fmt.Errorf("reading object %q off disk: %w", objHash, err) |
||||||
|
} else if obj.Type() != plumbing.CommitObject { |
||||||
|
continue |
||||||
|
|
||||||
|
} else if matchedHash == plumbing.ZeroHash { |
||||||
|
matchedHash = objHash |
||||||
|
continue |
||||||
|
} |
||||||
|
|
||||||
|
return plumbing.ZeroHash, fmt.Errorf("both %q and %q match", matchedHash, objHash) |
||||||
|
} |
||||||
|
|
||||||
commitTree, err := r.GitRepo.TreeObject(commit.TreeHash) |
if matchedHash != plumbing.ZeroHash { |
||||||
if err != nil { |
return matchedHash, nil |
||||||
return fmt.Errorf("could not retrieve tree object: %w", err) |
} |
||||||
} |
} |
||||||
|
|
||||||
var masterCommit MasterCommit |
return plumbing.ZeroHash, errors.New("failed to find a commit object with a matching prefix") |
||||||
if err := masterCommit.UnmarshalText([]byte(commit.Message)); err != nil { |
} |
||||||
return err |
|
||||||
} |
|
||||||
|
|
||||||
sigTree := commitTree // only for root commit
|
func (proj *Project) resolveRev(rev plumbing.Revision) (plumbing.Hash, error) { |
||||||
parentTree := &object.Tree{} |
if rev == plumbing.Revision(plumbing.ZeroHash.String()) { |
||||||
if commit.NumParents() > 0 { |
return plumbing.ZeroHash, nil |
||||||
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 { |
// pretend the revision is a short hash until proven otherwise
|
||||||
return fmt.Errorf("could not retrieve tree object of parent %q: %w", parent.Hash, err) |
shortHash := string(rev) |
||||||
|
hash, err := proj.findCommitByShortHash(shortHash) |
||||||
|
if errors.Is(err, errNotHex) { |
||||||
|
// ok, continue
|
||||||
|
} else if err != nil { |
||||||
|
return plumbing.ZeroHash, fmt.Errorf("resolving as short hash: %w", err) |
||||||
|
} else { |
||||||
|
// guess it _is_ a short hash, knew it!
|
||||||
|
return hash, nil |
||||||
} |
} |
||||||
sigTree = parentTree |
|
||||||
} |
} |
||||||
sigFS := fs.FromTree(sigTree) |
|
||||||
|
|
||||||
cfg, err := r.loadConfig(sigFS) |
h, err := proj.GitRepo.ResolveRevision(rev) |
||||||
if err != nil { |
if err != nil { |
||||||
return fmt.Errorf("error loading config: %w", err) |
return plumbing.ZeroHash, fmt.Errorf("resolving revision %q: %w", rev, err) |
||||||
} |
} |
||||||
|
return *h, nil |
||||||
|
} |
||||||
|
|
||||||
err = r.assertAccessControls( |
// GetCommitByRevision resolves the revision and returns the Commit it references.
|
||||||
cfg.AccessControls, masterCommit.Credentials, |
func (proj *Project) GetCommitByRevision(rev plumbing.Revision) (Commit, error) { |
||||||
parentTree, commitTree, |
hash, err := proj.resolveRev(rev) |
||||||
) |
|
||||||
if err != nil { |
if err != nil { |
||||||
return fmt.Errorf("failed to satisfy all access controls: %w", err) |
return Commit{}, err |
||||||
} |
} |
||||||
|
|
||||||
expectedChangeHash := genChangeHash(nil, masterCommit.Message, parentTree, commitTree) |
c, err := proj.GetCommit(hash) |
||||||
if !bytes.Equal(masterCommit.ChangeHash, expectedChangeHash) { |
if err != nil { |
||||||
return fmt.Errorf("malformed change_hash in commit body, is %s but should be %s", |
return Commit{}, fmt.Errorf("getting commit %q: %w", hash, err) |
||||||
base64.StdEncoding.EncodeToString(expectedChangeHash), |
|
||||||
base64.StdEncoding.EncodeToString(masterCommit.ChangeHash)) |
|
||||||
} |
} |
||||||
|
return c, nil |
||||||
|
} |
||||||
|
|
||||||
for _, cred := range masterCommit.Credentials { |
// GetCommitRangeByRevision is like GetCommitRange, first resolving the given
|
||||||
sig, err := r.signifierForCredential(sigFS, cred) |
// revisions into hashes before continuing with GetCommitRange's behavior.
|
||||||
if err != nil { |
func (proj *Project) GetCommitRangeByRevision(startRev, endRev plumbing.Revision) ([]Commit, error) { |
||||||
return fmt.Errorf("error finding signifier for credential %+v: %w", cred, err) |
start, err := proj.resolveRev(startRev) |
||||||
} else if err := sig.Verify(sigFS, expectedChangeHash, cred); err != nil { |
if err != nil { |
||||||
return fmt.Errorf("error verifying credential %+v: %w", cred, err) |
return nil, err |
||||||
} |
|
||||||
} |
} |
||||||
|
|
||||||
// TODO access controls
|
end, err := proj.resolveRev(endRev) |
||||||
|
if err != nil { |
||||||
|
return nil, err |
||||||
|
} |
||||||
|
|
||||||
return nil |
return proj.GetCommitRange(start, end) |
||||||
} |
} |
||||||
|
@ -1,170 +0,0 @@ |
|||||||
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,71 @@ |
|||||||
|
# Roadmap |
||||||
|
|
||||||
|
This document describes currently planned features and events related to the |
||||||
|
dehub project. It's intention is to help prioritize work. There are no dates |
||||||
|
set, only a sequence of milestones and the requirements to hit them. |
||||||
|
|
||||||
|
## Milestone: IPFS support |
||||||
|
|
||||||
|
* Big ol' question mark on this one. |
||||||
|
|
||||||
|
## Milestone: Versions |
||||||
|
|
||||||
|
* Tag commits |
||||||
|
* Add dehub version to payloads, make binary aware of it |
||||||
|
* Figure out a release system? |
||||||
|
|
||||||
|
## Milestone: Prime commits |
||||||
|
|
||||||
|
(Cloning/remote management is probably a pre-requisite of this, so it's a good |
||||||
|
thing it comes after IPFS support) |
||||||
|
|
||||||
|
* Ability to specify which commit is prime. |
||||||
|
* The prime commit is essentially the identifier of the entire project; even |
||||||
|
if two project instances share a commit tree, if they are using a |
||||||
|
different prime commit then they are not the same project. |
||||||
|
|
||||||
|
## Milestone: Minimal plugin support |
||||||
|
|
||||||
|
* SPEC and implement. Things which should be pluggable, initially: |
||||||
|
* Conditions |
||||||
|
* Signifiers |
||||||
|
* Filters |
||||||
|
* Payloads??? |
||||||
|
|
||||||
|
## Milestone: Minimal notifications support |
||||||
|
|
||||||
|
* Some way to store notification settings locally, and run a command which shows |
||||||
|
a sequence of events since the last time you ran it. |
||||||
|
* The command should keep a history of all of its outputs, and allow the |
||||||
|
user to see that history (in case they run the command, then clear the |
||||||
|
output by accident). |
||||||
|
* The user should be able to specifically get notifications on threads |
||||||
|
they're a part of, threads by branch name pattern, files by path pattern, |
||||||
|
and keywords in commit messages. |
||||||
|
|
||||||
|
# Misc Polish |
||||||
|
|
||||||
|
These tasks aren't necessarily scheduled for any particular milestone, but they |
||||||
|
are things that could use doing anyway. |
||||||
|
|
||||||
|
* Config validation. Every interface used by the config should have a |
||||||
|
`Validate() error` method, and Config itself should as well. |
||||||
|
|
||||||
|
* Maybe coalesce the `accessctl`, `fs`, and `sigcred` packages back into the |
||||||
|
root "dehub" package. |
||||||
|
|
||||||
|
* Polish commands |
||||||
|
* New flag system, some kind of interactivity support (e.g. user doesn't |
||||||
|
specify required argument, give them a prompt on the CLI to input it |
||||||
|
rather than an error). This is partially done, in that a new flag system |
||||||
|
has been started. Needs further work. |
||||||
|
|
||||||
|
* Review flags: |
||||||
|
* probably make some of them into positional arguments |
||||||
|
* add flag shortcuts |
||||||
|
* document everything better. |
||||||
|
|
||||||
|
* POSIX compatible-ish flags? |
||||||
|
|
||||||
|
* Possibly save state locally in order to speed things along, such as |
||||||
|
"account id" which probably isn't going to change often for a user. |
@ -0,0 +1,501 @@ |
|||||||
|
# SPEC |
||||||
|
|
||||||
|
This document describes the dehub protocol. |
||||||
|
|
||||||
|
This document assumes that the reader is familiar with git, both conceptually |
||||||
|
and in practical use of the git tool. All references to a git-specific concept |
||||||
|
retain their meaning; dehub concepts build upon git concepts, but do not |
||||||
|
override them. |
||||||
|
|
||||||
|
## Project {#project} |
||||||
|
|
||||||
|
A dehub project is comprised of: |
||||||
|
|
||||||
|
* A collection of files and directories. |
||||||
|
|
||||||
|
* Meta actions related to those files, e.g. discussion, proposed changes, etc. |
||||||
|
|
||||||
|
* Configuration defining which meta actions are allowed under which |
||||||
|
circumstances. |
||||||
|
|
||||||
|
All of these components are housed in a git repository. A dehub project does not |
||||||
|
require a central repository location (a "remote"), though it may use one if |
||||||
|
desired. |
||||||
|
|
||||||
|
## Commit Payload {#payload} |
||||||
|
|
||||||
|
All commits in a dehub [project](#project) contain a payload. The payload is |
||||||
|
encoded into the commit message as a YAML object. Here is the general structure |
||||||
|
of a commit message containing a payload: |
||||||
|
|
||||||
|
``` |
||||||
|
Human readable message head |
||||||
|
|
||||||
|
--- |
||||||
|
# Three dashes indicate the start of the yaml body. |
||||||
|
|
||||||
|
type: type of the payload # Always required |
||||||
|
fingerprint: std-base-64 string # Always required |
||||||
|
credentials:[...] # Not required but usually present |
||||||
|
|
||||||
|
type_specific_field_a: valueA |
||||||
|
type_specific_field_b: valueB |
||||||
|
``` |
||||||
|
|
||||||
|
The message head is a human readable description of what is being committed, and |
||||||
|
is terminated at the first newline. Everything after the message head must be |
||||||
|
valid YAML which encodes the payload. |
||||||
|
|
||||||
|
### Fingerprint {#fingerprint} |
||||||
|
|
||||||
|
Each [payload](#payload) object contains a `fingerprint` field. The fingerprint |
||||||
|
is an opaque byte string encoded using standard base-64. The algorithm used to |
||||||
|
generate the fingerprint will depend on the payload type, and can be found in |
||||||
|
each type's sub-section in this document. |
||||||
|
|
||||||
|
### Credential {#credential} |
||||||
|
|
||||||
|
The `credentials` field is not required, but in practice will be found on almost |
||||||
|
every [payload](#payload). The field's value will be an array of credential |
||||||
|
objects. Only one credential object is currently supported, `pgp_signature`: |
||||||
|
|
||||||
|
```yaml |
||||||
|
type: pgp_signature |
||||||
|
|
||||||
|
# One of these fields is required. If account_id is present, it relates the |
||||||
|
# signature to a pgp_public_key signifier defined for that account in the config |
||||||
|
# (see the Signifier sub-section). Otherwise, the public key will be included in |
||||||
|
# the credential itself as the value of pub_key_body. |
||||||
|
account_id: some_user_id # Optional |
||||||
|
pub_key_body: inlined ASCII-armored pgp public key |
||||||
|
|
||||||
|
# the ID (pgp fingerprint) of the key used to generate the signature |
||||||
|
pub_key_id: XXX |
||||||
|
|
||||||
|
# a signature of the payload's unencoded fingerprint, encoded using standard |
||||||
|
# base-64 |
||||||
|
body: std-base-64 signature |
||||||
|
``` |
||||||
|
|
||||||
|
### Payload Types {#payload-types} |
||||||
|
|
||||||
|
#### Change Payload {#change-payload} |
||||||
|
|
||||||
|
A change [payload](#payload) encompasses a set of changes to the files in the |
||||||
|
project. To construct the change payload one must reference the file tree of the |
||||||
|
commit which houses the payload as well as the file tree of its parent commit; |
||||||
|
specifically one must take the difference between them. |
||||||
|
|
||||||
|
A change payload looks like this: |
||||||
|
|
||||||
|
```yaml |
||||||
|
type: change |
||||||
|
fingerprint: std-base-64 string |
||||||
|
credentials: [...] |
||||||
|
description: |- |
||||||
|
The description will generally start with a single line, followed by a long-form body |
||||||
|
|
||||||
|
The description corresponds to the body of a commit message in a "normal" |
||||||
|
git repo. It gives a more-or-less long-form explanation of the changes being |
||||||
|
made to the project's files. |
||||||
|
``` |
||||||
|
|
||||||
|
##### Change Payload Fingerprint {#change-payload-fingerprint} |
||||||
|
|
||||||
|
The unencoded [fingerprint](#fingerprint) of a [change payload](#change-payload) |
||||||
|
is calculated as follows: |
||||||
|
|
||||||
|
* Concatenate the following: |
||||||
|
* A uvarint indicating the number of bytes in the description string. |
||||||
|
* The description string. |
||||||
|
* A uvarint indicating the number of files changed between this commit and |
||||||
|
its parent. |
||||||
|
* For each file changed, ordered lexographically-ascending based on its full |
||||||
|
relative path within the git repo: |
||||||
|
* A uvarint indicating the length of the full relative path of the file |
||||||
|
within the repo, as a string. |
||||||
|
* The full relative path of the file within the repo, as a string. |
||||||
|
* A little-endian uint32 representing the previous file mode of the file |
||||||
|
(or 0 if the file is not present in the parent commit's tree). |
||||||
|
* The 20-byte SHA1 hash of the contents of the previous version of the file |
||||||
|
(or 20 0 bytes if the file is not present in the parent commit's tree). |
||||||
|
* A little-endian uint32 representing the new file mode of the file (or 0 |
||||||
|
if the file is not present in the current commit's tree). |
||||||
|
* The 20-byte SHA1 hash of the contents of the new version of the file (or |
||||||
|
20 0 bytes if the file is not present in the current commit's tree). |
||||||
|
* Calculate the SHA-256 hash of the concatenation result. |
||||||
|
* Prepend a 0 byte to the result of the SHA-256 hash. |
||||||
|
|
||||||
|
This unencoded fingerprint is then standard base-64 encoded, and that is used as |
||||||
|
the value of the `fingerprint` field. |
||||||
|
|
||||||
|
#### Comment Payload {#comment-payload} |
||||||
|
|
||||||
|
A comment [payload](#payload) encompasses no file changes, and is used only to |
||||||
|
contain a comment made by a single user. |
||||||
|
|
||||||
|
A comment payload looks like this: |
||||||
|
|
||||||
|
```yaml: |
||||||
|
type: comment |
||||||
|
fingerprint: std-base-64 string |
||||||
|
credentials: [...] |
||||||
|
comment: |- |
||||||
|
Hey all, how's it going? |
||||||
|
|
||||||
|
Just wanted to pop by and say howdy. |
||||||
|
``` |
||||||
|
|
||||||
|
The message head of a comment payload will generally be a truncated form of the |
||||||
|
comment itself. |
||||||
|
|
||||||
|
##### Comment Payload Fingerprint {#comment-payload-fingerprint} |
||||||
|
|
||||||
|
The unencoded [fingerprint](#fingerprint) of a [comment |
||||||
|
payload](#comment-payload) is calculated as follows: |
||||||
|
|
||||||
|
* Concatenate the following: |
||||||
|
* A uvarint indicating the number of bytes in the comment string. |
||||||
|
* The comment string. |
||||||
|
* Calculate the SHA-256 hash of the concatenation result. |
||||||
|
* Prepend a 0 byte to the result of the SHA-256 hash. |
||||||
|
|
||||||
|
This unencoded fingerprint is then standard base-64 encoded, and that is used as |
||||||
|
the value of the `fingerprint` field. |
||||||
|
|
||||||
|
#### Credential Payload |
||||||
|
|
||||||
|
A credential [payload](#payload) contains only one or more credentials for an |
||||||
|
arbitrary [fingerprint](#fingerprint). Credential payloads can be combined with |
||||||
|
other payloads of the same fingerprint to create a new payload with many |
||||||
|
credentials. |
||||||
|
|
||||||
|
A credential payload looks like this: |
||||||
|
|
||||||
|
```yaml |
||||||
|
type: credential |
||||||
|
fingerprint: std-base-64 string |
||||||
|
credentials: [...] |
||||||
|
|
||||||
|
# This field is not required, but can be helpful in situations where the |
||||||
|
# fingerprint was generated based on multiple change payloads |
||||||
|
commits: |
||||||
|
- commit hash |
||||||
|
- commit hash |
||||||
|
- commit hash |
||||||
|
|
||||||
|
# This field is not required, but can be helpful to clarify which description |
||||||
|
# was used when generating a change fingerprint. |
||||||
|
change_description: blah blah blah |
||||||
|
``` |
||||||
|
|
||||||
|
## Project Configuration {#project-configuration} |
||||||
|
|
||||||
|
The `.dehub` directory contains all meta information related to the dehub |
||||||
|
[project](#project). All files within `.dehub` are tracked by the git repo like |
||||||
|
any other files in the project. |
||||||
|
|
||||||
|
### config.yml {#config-yml} |
||||||
|
|
||||||
|
The `.dehub/config.yml` file contains a yaml encoded configuration object: |
||||||
|
|
||||||
|
```yaml |
||||||
|
accounts: [...] |
||||||
|
access_controls: [...] |
||||||
|
``` |
||||||
|
|
||||||
|
Both fields are described in their own sub-section below. |
||||||
|
|
||||||
|
#### Account {#account} |
||||||
|
|
||||||
|
An account defines a specific user of a [project](#project). Every account has |
||||||
|
an ID; no two accounts within a project may share the same ID. |
||||||
|
|
||||||
|
An account looks like this: |
||||||
|
|
||||||
|
```yaml |
||||||
|
id: some_string |
||||||
|
signifiers: [...] |
||||||
|
``` |
||||||
|
|
||||||
|
##### Signifier {#signifier} |
||||||
|
|
||||||
|
A signifier is used to signify that an [account](#account) has taken some |
||||||
|
action. The most common use-case is to prove that an account created a |
||||||
|
particular [credential](#credential). An account may have more than one |
||||||
|
signifier. |
||||||
|
|
||||||
|
Currently there is only one signifier type, `pgp_public_key`: |
||||||
|
|
||||||
|
```yaml |
||||||
|
type: pgp_public_key |
||||||
|
|
||||||
|
# Path to ASCII-armored pgp public key, relative to repo root. |
||||||
|
path: .dehub/account.asc |
||||||
|
``` |
||||||
|
|
||||||
|
or |
||||||
|
|
||||||
|
```yaml |
||||||
|
type: pgp_public_key |
||||||
|
body: inlined ASCII-armored pgp public key |
||||||
|
``` |
||||||
|
|
||||||
|
#### Access Control {#access-control} |
||||||
|
|
||||||
|
An access control allows or denies a particular commit from becoming a part of |
||||||
|
a [project](#project). Each access control has an action (allow or deny) and a |
||||||
|
set of filters (filters are described in the next section): |
||||||
|
|
||||||
|
```yaml |
||||||
|
action: allow # or deny |
||||||
|
filters: [...] |
||||||
|
``` |
||||||
|
|
||||||
|
When a verifying a commit against a project's access controls, each access |
||||||
|
control's filters are applied to the commit in the order they appear in the |
||||||
|
configuration. The first access control for which all filters match is found, |
||||||
|
and its action is taken. |
||||||
|
|
||||||
|
An access control with no filters matches all commits. |
||||||
|
|
||||||
|
##### Filter {#filter} |
||||||
|
|
||||||
|
There are many kinds of [access control](#access-control) filters. Any filter |
||||||
|
can be applied to a commit, with no other input, and produce a boolean value. |
||||||
|
All filters have a `type` field which indicates their type. |
||||||
|
|
||||||
|
###### Signature Filter {#signature-filter} |
||||||
|
|
||||||
|
A [filter](#filter) of type `signature` asserts that a commit's |
||||||
|
[payload](#payload) contains [signature credentials](#credential) with certain |
||||||
|
properties. A signature filter must have one of these fields, which define the |
||||||
|
set of users or [accounts](#account) whose signatures are applicable. |
||||||
|
|
||||||
|
* `account_ids: [...]` - an array of account IDs, each having been defined in |
||||||
|
the accounts section of the [configuration](#config-yml). |
||||||
|
|
||||||
|
* `any_account: true` - matches any account defined in the accounts section of |
||||||
|
the configuration. |
||||||
|
|
||||||
|
* `any: true` - matches any signature, whether or not its signifier has been |
||||||
|
defined in the configuration. |
||||||
|
|
||||||
|
A `count` field may also be included. Its value may be an absolute number (e.g. |
||||||
|
`5`) or it may be a string indicating a percent (e.g. `"50%"`). If not included |
||||||
|
it will be assumed to be `1`. |
||||||
|
|
||||||
|
The count indicates how many accounts from the specified set must have a |
||||||
|
signature included. If a percent is given then that will be multiplied against |
||||||
|
the size of the set (rounded up) to determine the necessary number. |
||||||
|
|
||||||
|
Here are some example signature filters, and explanations for each: |
||||||
|
|
||||||
|
```yaml |
||||||
|
# requires that 2 of the 3 specified accounts has a signature credential on |
||||||
|
# the commit. |
||||||
|
type: signature |
||||||
|
account_ids: |
||||||
|
- amy |
||||||
|
- bill |
||||||
|
- colleen |
||||||
|
count: 2 |
||||||
|
``` |
||||||
|
|
||||||
|
```yaml |
||||||
|
# requires that every account defined in the configuration has a signature |
||||||
|
# credential on the commit. |
||||||
|
type: signature |
||||||
|
any_account: true |
||||||
|
count: 100% |
||||||
|
``` |
||||||
|
|
||||||
|
```yaml |
||||||
|
# requires at least one signature credential, not necessarily from an account. |
||||||
|
type: signature |
||||||
|
any: true |
||||||
|
``` |
||||||
|
|
||||||
|
###### Branch Filter {#branch-filter} |
||||||
|
|
||||||
|
A [filter](#filter) of type `branch` matches the commit based on which branch in |
||||||
|
the repo it is being or has been committed to. Matching is performed on the |
||||||
|
short name of the branch, using globstar pattern matching. |
||||||
|
|
||||||
|
A branch filter can have one or multiple patterns defined. The filter will match |
||||||
|
if at least one defined pattern matches the short form of the branch name. |
||||||
|
|
||||||
|
A branch filter with only one pattern can be defined like this: |
||||||
|
|
||||||
|
```yaml |
||||||
|
type: branch |
||||||
|
pattern: some_branch |
||||||
|
``` |
||||||
|
|
||||||
|
A branch filter with multiple patterns can be defined like this: |
||||||
|
|
||||||
|
```yaml |
||||||
|
type: branch |
||||||
|
patterns: |
||||||
|
- some_branch |
||||||
|
- branch*glob |
||||||
|
- amy/** |
||||||
|
``` |
||||||
|
|
||||||
|
###### Files Changed Filter {#files-changed-filter} |
||||||
|
|
||||||
|
A [filter](#filter) of type `files_changed` matches the commit based on which |
||||||
|
files were changed between the tree of the commit's parent and the commit's |
||||||
|
tree. Matching is performed on the paths of the changed files, relative to the |
||||||
|
repo root. |
||||||
|
|
||||||
|
A files changed filter can have one or multiple patterns defined. The filter |
||||||
|
will match if any of the changed files matches at least one defined pattern. |
||||||
|
|
||||||
|
A files changed filter with only one pattern can be defined like this: |
||||||
|
|
||||||
|
```yaml |
||||||
|
type: files_changed |
||||||
|
pattern: .dehub/* |
||||||
|
``` |
||||||
|
|
||||||
|
A files changed filter with multiple patterns can be defined like this: |
||||||
|
|
||||||
|
```yaml |
||||||
|
type: files_changed |
||||||
|
patterns: |
||||||
|
- some/dir/* |
||||||
|
- foo_files_* |
||||||
|
- **.jpg |
||||||
|
``` |
||||||
|
|
||||||
|
###### Payload Type Filter {#payload-type-filter} |
||||||
|
|
||||||
|
A [filter](#filter) of type `payload_type` matches a commit based on the type of |
||||||
|
its [payload](#payload). A payload type filter can have one or more types |
||||||
|
defined. The filter will match if the commit's payload type matches at least one |
||||||
|
of the defined types. |
||||||
|
|
||||||
|
A payload type filter with only one matching type can be defined like this: |
||||||
|
|
||||||
|
```yaml |
||||||
|
type: payload_type |
||||||
|
payload_type: comment |
||||||
|
``` |
||||||
|
|
||||||
|
A payload type filter with multiple matching types can be defined like this: |
||||||
|
|
||||||
|
```yaml |
||||||
|
type: payload_type |
||||||
|
payload_types: |
||||||
|
- comment |
||||||
|
- change |
||||||
|
``` |
||||||
|
|
||||||
|
###### Commit Attributes Filter {#commit-attributes-filter} |
||||||
|
|
||||||
|
A [filter](#filter) of type `commit_attributes` matches a commit based on |
||||||
|
certain attributes it has. A commit attributes filter may have one or more |
||||||
|
fields defined, each corresponding to a different attribute the commit may have. |
||||||
|
If more than one field is defined then all corresponding attributes on the |
||||||
|
commit must match for the filter to match. |
||||||
|
|
||||||
|
Currently the only possible attribute is `non_fast_forward: true`, which matches |
||||||
|
a commit which is not an ancestor of the HEAD of the branch it's being pushed |
||||||
|
onto. This attribute only makes sense in the context of a pre-receive git hook. |
||||||
|
|
||||||
|
A commit attributes filter looks like this: |
||||||
|
|
||||||
|
```yaml |
||||||
|
type: commit_attributes |
||||||
|
non_fast_forward: true |
||||||
|
``` |
||||||
|
|
||||||
|
###### Not Filter {#not-filter} |
||||||
|
|
||||||
|
A [filter](#filter) of type `not` matches a commit using the negation of a |
||||||
|
sub-filter, defined within the not filter. If the sub-filter returns true for |
||||||
|
the commit, then the not filter returns false, and vice-versa. |
||||||
|
|
||||||
|
A not filter looks like this: |
||||||
|
|
||||||
|
``` |
||||||
|
type: not |
||||||
|
filter: |
||||||
|
# a branch filter is used as the sub-filter in this example |
||||||
|
type: branch |
||||||
|
pattern: main |
||||||
|
``` |
||||||
|
|
||||||
|
##### Default Access Controls {#default-access-controls} |
||||||
|
|
||||||
|
These [access controls](#access-control) will be implicitly appended to the list |
||||||
|
defined in the [configuration](#config-yml): |
||||||
|
|
||||||
|
```yaml |
||||||
|
# Any account may add any commit to any non-main branch, provided there is at |
||||||
|
# least one signature credential. This includes non-fast-forwards. |
||||||
|
- action: allow |
||||||
|
filters: |
||||||
|
- type: not |
||||||
|
filter: |
||||||
|
type: branch |
||||||
|
pattern: main |
||||||
|
- type: signature |
||||||
|
any_account: true |
||||||
|
count: 1 |
||||||
|
|
||||||
|
# Non-fast-forwards are denied in all other cases. In effect, one cannot |
||||||
|
# force-push onto the main branch. |
||||||
|
- action: deny |
||||||
|
filters: |
||||||
|
- type: commit_attributes |
||||||
|
non_fast_forward: true |
||||||
|
|
||||||
|
# Any account may add any change commit to the main branch, provided there is |
||||||
|
# at least one signature credential. |
||||||
|
- action: allow |
||||||
|
filters: |
||||||
|
- type: branch |
||||||
|
pattern: main |
||||||
|
- type: payload_type |
||||||
|
payload_type: change |
||||||
|
- type: signature |
||||||
|
any_account: true |
||||||
|
count: 1 |
||||||
|
|
||||||
|
# All other actions are denied. |
||||||
|
- action: deny |
||||||
|
``` |
||||||
|
|
||||||
|
These default access controls provide a useful baseline of requirements that all |
||||||
|
[projects](#project) will (hopefully) find useful in their infancy. |
||||||
|
|
||||||
|
## Commit Verification {#commit-verification} |
||||||
|
|
||||||
|
The dehub protocol is designed such that every commit is "verifiable". A |
||||||
|
verifiable commit has the following properties: |
||||||
|
|
||||||
|
* Its [fingerprint](#fingerprint) is correctly formed. |
||||||
|
* All of its [credentials](#credential) are correctly formed. |
||||||
|
* If they are signatures, they are valid signatures of the commit's |
||||||
|
unencoded fingerprint. |
||||||
|
* The project's [access controls](#access-control) allow the commit. |
||||||
|
|
||||||
|
The [project's configuration](#config-yml) is referenced frequently when |
||||||
|
verifying a commit, such as when determining which access controls to apply and |
||||||
|
discovering [signifiers](#signifier) of [accounts](#account). In all cases the |
||||||
|
configuration as defined in the commit's _parent_ is used when verifying that |
||||||
|
commit. The exception is the [prime commit](#prime-commit), which uses its own |
||||||
|
configuration. |
||||||
|
|
||||||
|
### Prime Commit {#prime-commit} |
||||||
|
|
||||||
|
The prime commit is the trusted seed of the [project](#project). When a user |
||||||
|
clones and verifies a dehub project they must, implicitly or explicitly, trust |
||||||
|
the contents of the prime commit. All other commits must be ancestors of the |
||||||
|
prime commit. |
||||||
|
|
||||||
|
Manually specifying a prime commit is not currently spec'd, but it will be. |
||||||
|
|
||||||
|
By default the prime commit is the root commit of the `main` branch. |
@ -0,0 +1,128 @@ |
|||||||
|
# Tutorial 0: Say Hello! |
||||||
|
|
||||||
|
This tutorial will guide you through cloning a dehub project locally, creating a |
||||||
|
comment, and pushing that comment back up to the remote. The project in |
||||||
|
question: dehub itself! |
||||||
|
|
||||||
|
This tutorial assumes you have [dehub installed](/index.html#getting-started), |
||||||
|
you have git and gpg installed, and you have a gpg key already created. |
||||||
|
|
||||||
|
## Step 0: Clone the Project |
||||||
|
|
||||||
|
Cloning the dehub project is as simple as cloning its git repo: |
||||||
|
|
||||||
|
``` |
||||||
|
git clone https://dehub.dev/src/dehub.git |
||||||
|
cd dehub |
||||||
|
``` |
||||||
|
|
||||||
|
Once cloned, feel free to look around the project. You should initially find |
||||||
|
yourself on the `main` branch, the primary branch of most dehub projects |
||||||
|
(analogous to the `master` branch of most git repos). |
||||||
|
|
||||||
|
Calling `git log` will show the commits messages for all commits in the branch. |
||||||
|
You will notice the commit messages aren't formatted in the familiar way, for |
||||||
|
example: |
||||||
|
|
||||||
|
``` |
||||||
|
commit 351048e9aabef7dc0f99b00f02547e409859a33f |
||||||
|
Author: mediocregopher <> |
||||||
|
Date: Sat Apr 25 15:17:21 2020 -0600 |
||||||
|
|
||||||
|
Completely rewrite SPEC |
||||||
|
|
||||||
|
--- |
||||||
|
type: change |
||||||
|
description: |- |
||||||
|
Completely rewrite SPEC |
||||||
|
|
||||||
|
It's good this time, and complete. After this rewrite it will be necessary to |
||||||
|
update a lot of the code, since quite a few things got renamed. |
||||||
|
fingerprint: AG0s3yILU+0uIZltVY7A9/cgxr/pXk2MzGwExsY/hbIc |
||||||
|
credentials: |
||||||
|
- type: pgp_signature |
||||||
|
pub_key_id: 95C46FA6A41148AC |
||||||
|
body: BIG LONG STRING |
||||||
|
account: mediocregopher |
||||||
|
``` |
||||||
|
|
||||||
|
Instead of just being a human-readable description they are YAML encoded payload |
||||||
|
objects. We will dive into these payload objects more throughout this tutorial |
||||||
|
series. |
||||||
|
|
||||||
|
## Step 1: Checkout the Welcome Branch |
||||||
|
|
||||||
|
Next you're going to checkout the public welcome branch. This is done through a |
||||||
|
normal git checkout command: |
||||||
|
|
||||||
|
``` |
||||||
|
git checkout public/welcome |
||||||
|
``` |
||||||
|
|
||||||
|
You can do `git log` to see all the comments people have been leaving in this |
||||||
|
branch. The `public/welcome` branch is differentiated from the `main` branch in |
||||||
|
two ways: |
||||||
|
|
||||||
|
* It has been configured to allow comment commits from anonymous users to be |
||||||
|
pushed to it. Project configuration is covered in a future tutorial. |
||||||
|
|
||||||
|
* It has no code files tracked, its only purpose is for comments. |
||||||
|
|
||||||
|
## Step 2: Create Your Comment |
||||||
|
|
||||||
|
Now that you've poked around the welcome branch a bit, it's time to leave a |
||||||
|
comment of your own! This is as easy as doing: |
||||||
|
|
||||||
|
``` |
||||||
|
dehub commit --anon-pgp-key=KEY_NAME comment |
||||||
|
``` |
||||||
|
|
||||||
|
(`KEY_NAME` should be replaced with any selector which will match your pgp key, |
||||||
|
such as the key ID, the name on the key, or the email.) |
||||||
|
|
||||||
|
Your default text editor (defined by the EDITOR environment variable) will pop |
||||||
|
up and you can then write down your comment. When you save and close your editor |
||||||
|
dehub will sign the comment with your pgp key and create a commit with it. |
||||||
|
|
||||||
|
If you're having trouble thinking of something to say, here's some prompts to |
||||||
|
get you going: |
||||||
|
|
||||||
|
* Introduce yourself; say where you're from and what your interests are. |
||||||
|
|
||||||
|
* How did you find dehub? Why is it interesting to you? |
||||||
|
|
||||||
|
* If you're using dehub for a project, shill your project! |
||||||
|
|
||||||
|
* If you'd like to get involved in dehub's development, let us know what your |
||||||
|
skills are and how you can help. Remember, it takes more than expert |
||||||
|
programmers to make a project successful. |
||||||
|
|
||||||
|
Once you've created your commit you can call `git log` to verify that it's been |
||||||
|
created to your liking. If there's anything about the comment you'd like to |
||||||
|
change you can amend the commit like so: |
||||||
|
|
||||||
|
``` |
||||||
|
dehub commit --anon-pgp-key=KEY_NAME comment --amend |
||||||
|
``` |
||||||
|
|
||||||
|
## Step 3: Push Your Commit |
||||||
|
|
||||||
|
As of now your comment commit only exists on your local machine. For everyone |
||||||
|
else to see it you'll need to push it to the dehub server, exactly like with a |
||||||
|
normal git commit. Pushing is done in the same way as a normal git commit as |
||||||
|
well: `git push`. |
||||||
|
|
||||||
|
If you receive an error that's like `Updates were rejected because the tip of |
||||||
|
your current branch is behind` then someone else has pushed to the branch in |
||||||
|
between the last time you pulled and now. Do a `git pull --rebase` to pull in |
||||||
|
those new changes, and try pushing again. |
||||||
|
|
||||||
|
## Step 4: Follow the Conversation |
||||||
|
|
||||||
|
In order to see other people's responses to your comment, and all other parts of |
||||||
|
the conversation, all you need to do is call `git pull` with the |
||||||
|
`public/welcome` branch checked out. |
||||||
|
|
||||||
|
You now have all the tools needed to participate in a dehub discussion thread! |
||||||
|
Continue on to [Tutorial 1](tut1.html) to set up your own dehub project and |
||||||
|
learn about credentials and their verification. |
@ -0,0 +1,178 @@ |
|||||||
|
# Tutorial 1: Create Your Own Project |
||||||
|
|
||||||
|
This tutorial will guide you through starting a dehub project of your own, as |
||||||
|
well as introducing some basic concepts regarding how commit payloads work. You |
||||||
|
will use an example hello world project to do this. |
||||||
|
|
||||||
|
This tutorial assumes you have already completed [Tutorial 0](tut0.html). |
||||||
|
|
||||||
|
## Step 0: Init the Project |
||||||
|
|
||||||
|
A dehub project is initialized in the same way as a git project. An empty |
||||||
|
directory is created, and `dehub init` is run within that directory. |
||||||
|
|
||||||
|
``` |
||||||
|
mkdir hello-world |
||||||
|
cd hello-world |
||||||
|
dehub init |
||||||
|
``` |
||||||
|
|
||||||
|
`dehub init` does nearly exactly the same thing as `git init`, with the primary |
||||||
|
difference being that it sets the initial branch to be `main` instead of |
||||||
|
`master`. dehub makes a distinction between `main` and `master` in order to help |
||||||
|
prevent confusion between dehub and vanilla git projects, as well as to avoid |
||||||
|
conflicts when migrating vanilla git projects to dehub. |
||||||
|
|
||||||
|
## Step 1: Add the First Account |
||||||
|
|
||||||
|
A dehub project is not fully initialized until it has an account defined for it. |
||||||
|
dehub accounts refer to a specific user who has some kind of access to the |
||||||
|
project. Each account can have specific permissions for it, as well as multiple |
||||||
|
ways of signifying itself. |
||||||
|
|
||||||
|
For now, you'll add a basic account `tut` with a pgp key signifier. First, |
||||||
|
create the `.dehub` directory, which is where all dehub project configuration |
||||||
|
goes, and put your pgp key there: |
||||||
|
|
||||||
|
``` |
||||||
|
mkdir .dehub |
||||||
|
gpg -a --export KEY_ID > .dehub/tut.asc |
||||||
|
``` |
||||||
|
|
||||||
|
Next you'll create the `.dehub/config.yml` file, which is where accounts are |
||||||
|
actually defined (amongst many other things). The file should have the following |
||||||
|
contents: |
||||||
|
|
||||||
|
```yaml |
||||||
|
# contents of .dehub/config.yml |
||||||
|
--- |
||||||
|
accounts: |
||||||
|
- id: tut |
||||||
|
signifiers: |
||||||
|
- type: pgp_public_key_file |
||||||
|
path: ".dehub/tut.asc" |
||||||
|
``` |
||||||
|
|
||||||
|
Finally, you'll commit these changes and the project will have its first commit! |
||||||
|
Committing changes works very similarly to committing comments (as you did in |
||||||
|
[Tutorial 0](tut0.html)). Where a comment commit merely carries a user's |
||||||
|
comment, a change commit describes a set of changes to the tracked files in the |
||||||
|
git repo. |
||||||
|
|
||||||
|
``` |
||||||
|
git add --all |
||||||
|
dehub commit --as tut change |
||||||
|
``` |
||||||
|
|
||||||
|
Like when you made a comment commit, this will pop up with your editor asking |
||||||
|
for a description of the changes. Fill it in with something like `Initialize the |
||||||
|
project` and save+close the editor. Depending on your pgp key settings you'll |
||||||
|
likely be prompted for your pgp key password at this point. After that the |
||||||
|
commit has been created! |
||||||
|
|
||||||
|
## Step 2: Inspect the Payload |
||||||
|
|
||||||
|
In this step you're going to look at the commit you just created and learn about |
||||||
|
the contents of the payload. To view the commit do `git show`. Something similar |
||||||
|
to the following should be output as the commit message: |
||||||
|
|
||||||
|
``` |
||||||
|
commit 3cdcbc19546d4e6d817ebfba3e18afbc23283ec0 |
||||||
|
Author: username <> |
||||||
|
Date: Sat Apr 25 15:17:21 2020 -0600 |
||||||
|
|
||||||
|
Initialize the project |
||||||
|
|
||||||
|
--- |
||||||
|
type: change |
||||||
|
description: Initialize the project |
||||||
|
fingerprint: AG0s3yILU+0uIZltVY7A9/cgxr/pXk2MzGwExsY/hbIc |
||||||
|
credentials: |
||||||
|
- type: pgp_signature |
||||||
|
pub_key_id: 95C46FA6A41148AC |
||||||
|
body: BIG LONG STRING |
||||||
|
account: tut |
||||||
|
``` |
||||||
|
|
||||||
|
All commits in a dehub project will contain a similar looking message. The first |
||||||
|
line (the head) is always a human readable description of the commit. In this |
||||||
|
case our commit description itself, `Initialize the project`, was used. |
||||||
|
|
||||||
|
After the head comes the payload, which is always a YAML encoded object. All |
||||||
|
payloads have a `type` field indicating what type of payload they are. That type |
||||||
|
will determine what other fields the payload is expected to have. The other |
||||||
|
fields in this payload object are: |
||||||
|
|
||||||
|
* `description`: This is the description which was input into the editor when |
||||||
|
creating the change commit. |
||||||
|
|
||||||
|
* `fingerprint`: A unique descriptor for this set of changes. It is computed |
||||||
|
using both `description` and the files changed. |
||||||
|
|
||||||
|
* `credentials`: A set of credentials for this commit, each one declaring |
||||||
|
that this commit has been given approval by a user. This commit has one |
||||||
|
`pgp_signature` credential, created by the `tut` account. The `body` is a |
||||||
|
signature of the `fingerprint` created by the `tut`'s pgp key. |
||||||
|
|
||||||
|
## Step 3: Create Another Commit |
||||||
|
|
||||||
|
Now that the initial commit is created, and configuration has been added to the |
||||||
|
dehub project, you can continue on to use the project for what it was intended |
||||||
|
for: greeting the world! |
||||||
|
|
||||||
|
Add a simple "hello world" script to the project by doing: |
||||||
|
|
||||||
|
``` |
||||||
|
echo 'echo "hello world"' > hello.sh |
||||||
|
git add hello.sh |
||||||
|
dehub commit --as tut change --descr 'add hello.sh' |
||||||
|
``` |
||||||
|
|
||||||
|
You'll notice that this time around you used the `--descr` flag to declare the |
||||||
|
change's description, rather than opening up the editor |
||||||
|
|
||||||
|
Once again you can inspect the payload you just created using `git show`, if |
||||||
|
you'd like, or continue on to the next step to learn about commit verification. |
||||||
|
|
||||||
|
## Step 4: Verify Your Commits |
||||||
|
|
||||||
|
All this work to create YAML encoded payloads has been done for one primary |
||||||
|
purpose: to make commits verifiable. A verifiable commit is one which follows |
||||||
|
the access controls defined by its parent. |
||||||
|
|
||||||
|
Your dehub project doesn't have any explicitly defined access controls (that |
||||||
|
will be covered in a future tutorial), and so the defaults are used. By default, |
||||||
|
dehub requires that all commits in `main` are change commits which have been |
||||||
|
signed by at least one account. |
||||||
|
|
||||||
|
In order to verify the HEAD commit you can do: |
||||||
|
|
||||||
|
``` |
||||||
|
dehub verify |
||||||
|
``` |
||||||
|
|
||||||
|
This command looks at the project configuration defined in the parent of HEAD |
||||||
|
and verifies that HEAD conforms to it. The HEAD of your project is a change |
||||||
|
commit signed by the account `tut`, and so should be verifiable. |
||||||
|
|
||||||
|
Arbitrary commits can be verified using the `--rev` flag. This command will |
||||||
|
verify the parent of HEAD, i.e. the initial commit: |
||||||
|
|
||||||
|
``` |
||||||
|
dehub verify --rev HEAD^ |
||||||
|
``` |
||||||
|
|
||||||
|
The initial commit doesn't have a parent, and so is a special case for |
||||||
|
verification. The initial commit uses the configuration defined within itself in |
||||||
|
order to verify itself. This creates an exploit opportunity: if you clone a |
||||||
|
remote dehub project and an attacker intercepts that request they will be able |
||||||
|
to send you back a project with a different initial commit than what you |
||||||
|
expected. The whole project will still be verifiable, even though it's been |
||||||
|
compromised. For this reason it's important to manually verify that the initial |
||||||
|
commit of projects you clone are configured correctly, using the expected |
||||||
|
signifiers for the expected accounts. |
||||||
|
|
||||||
|
You are now able to initialize a project, configure accounts within it, commit |
||||||
|
changes to its files, and verify those commits. Well done! Continue on to |
||||||
|
[Tutorial 2](tut2.html), where you will learn how to configure dehub's access |
||||||
|
controls. |
@ -0,0 +1,262 @@ |
|||||||
|
# Tutorial 2: Access Controls |
||||||
|
|
||||||
|
Access controls, in the context of a dehub project, refer to configuration |
||||||
|
defining who is allowed to do what. These controls are defined within the dehub |
||||||
|
project itself, within the `.dehub/config.yml` file. This tutorial will guide |
||||||
|
you through the basics of how access controls work, how to define them, and some |
||||||
|
examples of what can be done with them. |
||||||
|
|
||||||
|
This tutorial assumes you have already completed [Tutorial 1](tut1.html), and |
||||||
|
builds on top of the project which was started there. |
||||||
|
|
||||||
|
## Step 0: Create a Restricted Account |
||||||
|
|
||||||
|
Inside the project you started in [Tutorial 1](tut1.html) you're going to add |
||||||
|
another account to the project, called `tot`. Initially, `tot` will have all the |
||||||
|
same permissions as `tut`, except being allowed to modify the project |
||||||
|
configuration. |
||||||
|
|
||||||
|
First, export your gpg key into the project for `tot` to use, the same key used |
||||||
|
for `tut`: |
||||||
|
|
||||||
|
``` |
||||||
|
gpg -a --export KEY_ID > .dehub/tot.asc |
||||||
|
``` |
||||||
|
|
||||||
|
(For the purposes of a tutorial it's fine for two accounts to share a |
||||||
|
key, but it's not something which generally makes sense to do.) |
||||||
|
|
||||||
|
Now, modify the `.dehub/config.yml` to have the following contents: |
||||||
|
|
||||||
|
```yaml |
||||||
|
# contents of .dehub/config.yml |
||||||
|
--- |
||||||
|
accounts: |
||||||
|
- id: tut |
||||||
|
signifiers: |
||||||
|
- type: pgp_public_key_file |
||||||
|
path: ".dehub/tut.asc" |
||||||
|
- id: tot |
||||||
|
signifiers: |
||||||
|
- type: pgp_public_key_file |
||||||
|
path: ".dehub/tot.asc" |
||||||
|
|
||||||
|
access_controls: |
||||||
|
- action: allow |
||||||
|
filters: |
||||||
|
- type: signature |
||||||
|
account_ids: |
||||||
|
- tut |
||||||
|
- type: files_changed |
||||||
|
pattern: .dehub/* |
||||||
|
|
||||||
|
- action: deny |
||||||
|
filters: |
||||||
|
- type: files_changed |
||||||
|
pattern: .dehub/* |
||||||
|
``` |
||||||
|
|
||||||
|
The `accounts` section has been modified to add the `tot` account, but the |
||||||
|
primary change here has been to add the `access_controls` section. The next |
||||||
|
sub-sections will explain what exactly is being done here, but for now go ahead |
||||||
|
and commit these changes: |
||||||
|
|
||||||
|
``` |
||||||
|
git add --all |
||||||
|
dehub commit --as tut change --descr 'add new restricted tot account' |
||||||
|
``` |
||||||
|
|
||||||
|
### Access Controls |
||||||
|
|
||||||
|
Each access control is an action+filters pair. For any commit being verified, |
||||||
|
the access controls defined in its parent commit are iterated through, in order, |
||||||
|
until one is found whose filters all match the commit being verified. The action |
||||||
|
for that access control, either `allow` or `deny`, is then taken. |
||||||
|
|
||||||
|
If no access controls are defined, or none match, then the default access |
||||||
|
controls are used. These are explicitly defined in the |
||||||
|
[SPEC](SPEC.html#default-access-controls), but the general effect of them is to |
||||||
|
require that all commits have one signature from any of the project's accounts. |
||||||
|
|
||||||
|
### Access Control Filters |
||||||
|
|
||||||
|
There are many different filter types, so only the ones used in the tutorial |
||||||
|
will be explained. An exhaustive listing can be found in the |
||||||
|
[SPEC](SPEC.html#filter). |
||||||
|
|
||||||
|
The `signature` filter matches commits which have a signature credential created |
||||||
|
by any one of the specified accounts. The `files_changed` filter matches commits |
||||||
|
which have changed files whose paths match the specified patterns (relative to |
||||||
|
the project's root). |
||||||
|
|
||||||
|
### Putting it Together |
||||||
|
|
||||||
|
The first of the new actions controls you've defined is: |
||||||
|
|
||||||
|
``` |
||||||
|
- action: allow |
||||||
|
filters: |
||||||
|
- type: signature |
||||||
|
account_ids: |
||||||
|
- tut |
||||||
|
- type: files_changed |
||||||
|
pattern: .dehub/* |
||||||
|
``` |
||||||
|
|
||||||
|
This allows any commits which have been signed by `tut` and which modify any of |
||||||
|
the files in `.dehub/*`. The second access control is: |
||||||
|
|
||||||
|
``` |
||||||
|
- action: deny |
||||||
|
filters: |
||||||
|
- type: files_changed |
||||||
|
pattern: .dehub/* |
||||||
|
``` |
||||||
|
|
||||||
|
This denies any commits which modify any of the files in `.dehub/*`. If a commit |
||||||
|
does not match the first access control, but does match this second access |
||||||
|
control, it can be assumed that the commit does _not_ have a signature from |
||||||
|
`tut` (because that's the only difference between them). Therefore, the effect |
||||||
|
of these two controls put together is to only allow `tut` to make changes to the |
||||||
|
`.dehub` directory's files. |
||||||
|
|
||||||
|
## Step 1: Test the Restrictions |
||||||
|
|
||||||
|
Let's say that your new user `tot` is having a bit of rebellious phase, and |
||||||
|
wants to kick `tut` out of the project. Change `.dehub/config.yml` to have the |
||||||
|
following contents (note that `accounts` has been left the same and so is mostly |
||||||
|
elided): |
||||||
|
|
||||||
|
``` |
||||||
|
# abbreviated contents of .dehub/config.yml |
||||||
|
--- |
||||||
|
accounts: |
||||||
|
... |
||||||
|
|
||||||
|
access_controls: |
||||||
|
- action: deny |
||||||
|
filters: |
||||||
|
- type: signature |
||||||
|
account_ids: |
||||||
|
- tut |
||||||
|
``` |
||||||
|
|
||||||
|
So edgy. Make the commit for `tot`, being sure that the value for the `--as` |
||||||
|
flag indicates you're committing _as_ `tot`: |
||||||
|
|
||||||
|
``` |
||||||
|
git add --all |
||||||
|
dehub commit --as tot change --descr 'tut is a butt' |
||||||
|
``` |
||||||
|
|
||||||
|
Somewhat unexpectedly, the commit has been created! You can see it by doing `git |
||||||
|
show`. This shouldn't be possible though, because the previous commit disallowed |
||||||
|
anyone but `tut` from changing files within the `.dehub/` directory. Is dehub |
||||||
|
broken? |
||||||
|
|
||||||
|
The fact is that, regardless of whether or not the `dehub` tool allows one to |
||||||
|
create this commit, `tot` can create this commit. The important thing is that |
||||||
|
`tut` is able to notice that it's been created and do something about it. In a |
||||||
|
real-world situation, both `tot` and `tut` would be using different computers, |
||||||
|
and when `tut` (or anyone else) receives the commit from `tot` they will try to |
||||||
|
verify it, fail to do so, and ignore it. |
||||||
|
|
||||||
|
If you perform `dehub verify` you will be greeted with the following error: |
||||||
|
|
||||||
|
``` |
||||||
|
exiting: blah blah blah: commit matched and denied by this access control: |
||||||
|
action: deny |
||||||
|
filters: |
||||||
|
- type: files_changed |
||||||
|
pattern: .dehub/* |
||||||
|
``` |
||||||
|
|
||||||
|
Because the parent of this commit's config disallows this commit (via the given |
||||||
|
access control) it is not verifiable. Go ahead and delete the commit by doing: |
||||||
|
|
||||||
|
``` |
||||||
|
git reset --hard "$(git rev-list HEAD | tail -3 | head -n1)" |
||||||
|
``` |
||||||
|
|
||||||
|
## Step 2: Different Restrictions |
||||||
|
|
||||||
|
In light of `tot`'s recent actions it might be prudent to pull back their |
||||||
|
permissions a bit. Go ahead and change the `.dehub/config.yml` to: |
||||||
|
|
||||||
|
|
||||||
|
``` |
||||||
|
# abbreviated contents of .dehub/config.yml |
||||||
|
--- |
||||||
|
accounts: |
||||||
|
... |
||||||
|
|
||||||
|
access_controls: |
||||||
|
- action: allow |
||||||
|
filters: |
||||||
|
- type: signature |
||||||
|
account_ids: |
||||||
|
- tot |
||||||
|
- type: branch |
||||||
|
pattern: tot/* |
||||||
|
|
||||||
|
- action: deny |
||||||
|
filters: |
||||||
|
- type: signature |
||||||
|
account_ids: |
||||||
|
- tot |
||||||
|
``` |
||||||
|
|
||||||
|
and commit the change: |
||||||
|
|
||||||
|
``` |
||||||
|
git add --all |
||||||
|
dehub commit --as tut change --descr 'restrict tot to non-main branches' |
||||||
|
``` |
||||||
|
|
||||||
|
After this, `tot` will still be able to interact with the project, but only |
||||||
|
within branches whose names have the prefix `tot/`; the `main` branch remains |
||||||
|
open to other accounts, such as `tut`, due to the default access controls. |
||||||
|
|
||||||
|
### Check the New Restrictions |
||||||
|
|
||||||
|
`tot` has decided to do something constructive and wants to make a shell script |
||||||
|
which wraps the `echo` command. So helpful. Make a new branch for `tot` to use, |
||||||
|
and create a commit on it: |
||||||
|
|
||||||
|
``` |
||||||
|
git checkout -b tot/echo-script |
||||||
|
echo 'echo "$@"' > echo.sh |
||||||
|
git add echo.sh |
||||||
|
dehub commit --as tot change --descr "added echo.sh script" |
||||||
|
``` |
||||||
|
|
||||||
|
Check that the commit verifies (it should, since it's on a branch with the |
||||||
|
prefix `tot/`): |
||||||
|
|
||||||
|
``` |
||||||
|
dehub verify |
||||||
|
``` |
||||||
|
|
||||||
|
Now, as a final sanity check, you'll cherry-pick the commit onto `main` and |
||||||
|
ensure that it does _not_ verify there. |
||||||
|
|
||||||
|
``` |
||||||
|
git checkout main |
||||||
|
git cherry-pick tot/echo-script |
||||||
|
``` |
||||||
|
|
||||||
|
Running `dehub verify` now should fail, even though the commit remains the same. |
||||||
|
The only difference is the branch name; the commit is allowed in branches with |
||||||
|
the prefix `tot/`, and disallowed otherwise. |
||||||
|
|
||||||
|
Finally, reverse that cherry-pick to make `main` verifiable again: |
||||||
|
|
||||||
|
``` |
||||||
|
git reset --hard "$(git rev-list HEAD | tail -4 | head -n1)" |
||||||
|
``` |
||||||
|
|
||||||
|
You now have an understanding of how dehub's access controls work. Access |
||||||
|
controls are extremely flexible and can be formulated to fit a wide-variety of |
||||||
|
use-cases. In [Tutorial 3](tut3.html) we'll see how access controls can be |
||||||
|
formulated to allow for commit sign-offs, where multiple accounts must accredit |
||||||
|
a commit before it can be verified, and how such a commit can be created. |
@ -0,0 +1,246 @@ |
|||||||
|
# Tutorial 3: Commit Sign-Off |
||||||
|
|
||||||
|
Commit sign-off is a common pattern in vanilla git projects, where a commit must |
||||||
|
be approved by one or more people (besides the commit author themselves) in |
||||||
|
order to be allowed into the primary branch. |
||||||
|
|
||||||
|
dehub is able to accomplish this same pattern using only the access controls |
||||||
|
which have already been covered in this tutorial series and a command which has |
||||||
|
not: `dehub combine`. This tutorial will guide you through using `dehub combine` |
||||||
|
to facilitate commit sign-off. |
||||||
|
|
||||||
|
This tutorial assumes you have already completed [Tutorial 2](tut2.html), and |
||||||
|
builds on top of the project which was started there. |
||||||
|
|
||||||
|
## Step 0: Loosen the Previous Restrictions |
||||||
|
|
||||||
|
In the [previous tutorial](tut2.html) you took an existing project, added a new |
||||||
|
user `tot` to it, and then restricted `tot` to only be allowed to make commits |
||||||
|
in a certain subset of branches which excluded the `main` branch. |
||||||
|
|
||||||
|
As seen in that tutorial, `tot` is not able to create commits for the `main` |
||||||
|
branch _at all_. In this tutorial we're going to open `main` back up to `tot`, |
||||||
|
but only with a very important caveat: `tot`'s commits must be approved by |
||||||
|
someone else. |
||||||
|
|
||||||
|
In the `hello-world` project which was used for previous tutorials, with the |
||||||
|
`main` branch checked out, go ahead and modify `.dehub/config.yml` to have the |
||||||
|
following contents: |
||||||
|
|
||||||
|
``` |
||||||
|
# contents of .dehub/config.yml |
||||||
|
--- |
||||||
|
accounts: |
||||||
|
- id: tut |
||||||
|
signifiers: |
||||||
|
- type: pgp_public_key_file |
||||||
|
path: ".dehub/tut.asc" |
||||||
|
- id: tot |
||||||
|
signifiers: |
||||||
|
- type: pgp_public_key_file |
||||||
|
path: ".dehub/tot.asc" |
||||||
|
|
||||||
|
access_controls: |
||||||
|
- action: allow |
||||||
|
filters: |
||||||
|
- type: signature |
||||||
|
account_ids: |
||||||
|
- tot |
||||||
|
- type: branch |
||||||
|
pattern: tot/* |
||||||
|
|
||||||
|
- action: deny |
||||||
|
filters: |
||||||
|
- type: branch |
||||||
|
pattern: main |
||||||
|
- type: not |
||||||
|
filter: |
||||||
|
type: signature |
||||||
|
any_account: true |
||||||
|
count: 2 |
||||||
|
``` |
||||||
|
|
||||||
|
and commit the changes: |
||||||
|
|
||||||
|
``` |
||||||
|
git add .dehub/config.yml |
||||||
|
dehub commit --as tut change --descr 'require commit sign-offs in main' |
||||||
|
``` |
||||||
|
|
||||||
|
The primary change was to replace the old access control denying `tot` the |
||||||
|
ability to commit to anything (outside of `tot/*` branches) with this one: |
||||||
|
|
||||||
|
``` |
||||||
|
- action: deny |
||||||
|
filters: |
||||||
|
- type: branch |
||||||
|
pattern: main |
||||||
|
- type: not |
||||||
|
filter: |
||||||
|
type: signature |
||||||
|
any_account: true |
||||||
|
count: 2 |
||||||
|
``` |
||||||
|
|
||||||
|
There are two new things here. The first is the new fields on the `signature` |
||||||
|
filter: `any_account` replaces the `account_ids` field, and refers to any |
||||||
|
account which is defined in the `accounts` section; `count` declares how many |
||||||
|
accounts must have a signature on the commit for the filter to match (if not |
||||||
|
specified it defaults to 1). |
||||||
|
|
||||||
|
The second new thing is the `not` filter: `not` wraps any other filter, and |
||||||
|
reverses whether or not it matches. In this case, it's wrapping our `signature` |
||||||
|
filter, such that this access control will match only if the commit _does not_ |
||||||
|
have signature credentials from 2 different accounts. |
||||||
|
|
||||||
|
The total effect of this access control is to deny any commits to `main` which |
||||||
|
have not been signed-off by 2 different accounts. |
||||||
|
|
||||||
|
## Step 1: Some Changes to Merge |
||||||
|
|
||||||
|
In the previous tutorial `tot` created a new script, `echo.sh`, in a new branch |
||||||
|
called `tot/echo-script`. Check that branch out, rebase it on `main` (this will |
||||||
|
help in later steps), and add another script to it: |
||||||
|
|
||||||
|
``` |
||||||
|
git checkout tot/echo-script |
||||||
|
git rebase main |
||||||
|
echo 'echo "$@" | awk "{ print toupper(\$0) }"' > echo-upper.sh |
||||||
|
git add echo-upper.sh |
||||||
|
dehub commit --as tot change --descr 'echo-upper.sh' |
||||||
|
``` |
||||||
|
|
||||||
|
Now the `tot/echo-script` branch contains two commits which aren't on `main`, |
||||||
|
both of them signed by `tot`. What will happen next is that the branch's commits |
||||||
|
will be combined into a single commit, be given accreditation by both `tut` and |
||||||
|
`tot`, and added to the `main` branch. |
||||||
|
|
||||||
|
## Step 2: Accreditation |
||||||
|
|
||||||
|
First, `tot` will accredit both commits, and unify the two descriptions in the |
||||||
|
process. To do this, you will create your first `credential` commit: |
||||||
|
|
||||||
|
``` |
||||||
|
dehub commit --as tot credential --start HEAD^^ --descr 'add echo.sh and echo-upper.sh' |
||||||
|
``` |
||||||
|
|
||||||
|
A `credential` commit, at its core, contains nothing except credentials for any |
||||||
|
arbitrary fingerprint. To view the credential commit you just made |
||||||
|
do: `git show`. You should see a commit message like: |
||||||
|
|
||||||
|
``` |
||||||
|
Credential of AO3dn4Se61hq6OWy4Lm6m3MxdT2ru6TrIobuHaWJJidt |
||||||
|
|
||||||
|
--- |
||||||
|
type: credential |
||||||
|
commits: |
||||||
|
- f085f13fa839ece122476601d970460ac249dc69 # these will be different |
||||||
|
- 40a81ffb4f52dc4149570672f7f7fc053f12226a |
||||||
|
change_description: add echo.sh and echo-upper.sh |
||||||
|
fingerprint: AO3dn4Se61hq6OWy4Lm6m3MxdT2ru6TrIobuHaWJJidt |
||||||
|
credentials: |
||||||
|
- type: pgp_signature |
||||||
|
pub_key_id: XXX |
||||||
|
body: BIG LONG STRING |
||||||
|
account: tot |
||||||
|
``` |
||||||
|
|
||||||
|
You'll notice that the credential commit's fingerprint is different than either |
||||||
|
of the two commits it accredits. This is the fingerprint is based on the |
||||||
|
_combination_ of the two commits; it is based on the total of the file changes |
||||||
|
and the description provided by the user. The two commits are enumerated in the |
||||||
|
`commits` field of the payload, and the description provided by the user is |
||||||
|
stored in the `change_description` field. |
||||||
|
|
||||||
|
The combined commits have now been accredited by `tot`, but not `tut`, and so |
||||||
|
they still lack a necessary credential. Have `tut` make a credential now: |
||||||
|
|
||||||
|
``` |
||||||
|
dehub commit --as tut credential --rev HEAD |
||||||
|
``` |
||||||
|
|
||||||
|
This form of the `credential` sub-command only accredits a single commit. When a |
||||||
|
single commit is accredited and it itself is a credential commit then the new |
||||||
|
commit which is created is merely a copy of the specified credential commit with |
||||||
|
the caller's own credential appended to the `credentials` list. You can see this |
||||||
|
with `git show`, which should look like: |
||||||
|
|
||||||
|
``` |
||||||
|
Credential of AO3dn4Se61hq6OWy4Lm6m3MxdT2ru6TrIobuHaWJJidt |
||||||
|
|
||||||
|
--- |
||||||
|
type: credential |
||||||
|
commits: |
||||||
|
- f085f13fa839ece122476601d970460ac249dc69 # these will be different |
||||||
|
- 40a81ffb4f52dc4149570672f7f7fc053f12226a |
||||||
|
change_description: add echo.sh and echo-upper.sh |
||||||
|
fingerprint: AO3dn4Se61hq6OWy4Lm6m3MxdT2ru6TrIobuHaWJJidt |
||||||
|
credentials: |
||||||
|
- type: pgp_signature |
||||||
|
pub_key_id: XXX |
||||||
|
body: BIG LONG STRING |
||||||
|
account: tot |
||||||
|
- type: pgp_signature |
||||||
|
pub_key_id: XXX |
||||||
|
body: BIG LONG STRING |
||||||
|
account: tut |
||||||
|
``` |
||||||
|
|
||||||
|
There are now enough credentials to combine the commits in the `tot/echo-script` |
||||||
|
branch into a single commit on the `main` branch. |
||||||
|
|
||||||
|
## Step 3: Combination |
||||||
|
|
||||||
|
At this point the `tot/echo-script` branch has the following elements in place: |
||||||
|
|
||||||
|
* Two change commits, which we want to combine and bring over to `main`. |
||||||
|
* A credential commit made by `tot` for the combined changes. |
||||||
|
* A credential commit made by `tut` for the combined changes, which includes |
||||||
|
`tot`'s credentials. |
||||||
|
|
||||||
|
Combining the commits and placing them on `main` is done with a single command: |
||||||
|
|
||||||
|
``` |
||||||
|
dehub combine --start HEAD^^^^ --end HEAD --onto main |
||||||
|
``` |
||||||
|
|
||||||
|
This `combine` command combines all changes made within the given commit range, |
||||||
|
the last change description found in that range (in this case it will be from |
||||||
|
`tut`'s credential commit), and all credentials for that set of changes. The |
||||||
|
command combines them into a single commit which it places on the `main` branch. |
||||||
|
You can see the commit you've just created by doing: |
||||||
|
|
||||||
|
``` |
||||||
|
git checkout main |
||||||
|
git show |
||||||
|
``` |
||||||
|
|
||||||
|
The commit should contain both of the new files, and the message should look |
||||||
|
something like: |
||||||
|
|
||||||
|
``` |
||||||
|
add echo.sh and echo-upper.sh |
||||||
|
|
||||||
|
--- |
||||||
|
type: change |
||||||
|
description: add echo.sh and echo-upper.sh |
||||||
|
fingerprint: ALOcEuKJkgIdz27z0fjF1NEbK6Y9cEh2RH4/sL3uf3oa |
||||||
|
credentials: |
||||||
|
- type: pgp_signature |
||||||
|
pub_key_id: XXX |
||||||
|
body: BIG LONG BODY |
||||||
|
account: tot |
||||||
|
- type: pgp_signature |
||||||
|
pub_key_id: XXX |
||||||
|
body: BIG LONG BODY |
||||||
|
account: tut |
||||||
|
``` |
||||||
|
|
||||||
|
The commit is accredited by two different accounts, and so is allowed to be on |
||||||
|
the `main` branch. This can be verified by doing `dehub verify`. |
||||||
|
|
||||||
|
You now are able to require commit sign-off and create signed-off commits! The |
||||||
|
access control settings surrounding commit sign-offs are entirely up to you and |
||||||
|
your project's needs. You can require sign-off from specific accounts, any |
||||||
|
accounts, only on specific files, only in certain branches, etc... all using the |
||||||
|
same basic access control building blocks. |
@ -0,0 +1,83 @@ |
|||||||
|
package dehub |
||||||
|
|
||||||
|
import ( |
||||||
|
"crypto/sha256" |
||||||
|
"encoding/binary" |
||||||
|
"fmt" |
||||||
|
"hash" |
||||||
|
"sort" |
||||||
|
) |
||||||
|
|
||||||
|
var ( |
||||||
|
defaultHashHelperAlgo = sha256.New |
||||||
|
) |
||||||
|
|
||||||
|
type hashHelper struct { |
||||||
|
hash 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) sum(prefix []byte) []byte { |
||||||
|
out := make([]byte, len(prefix), len(prefix)+s.hash.Size()) |
||||||
|
copy(out, prefix) |
||||||
|
return s.hash.Sum(out) |
||||||
|
} |
||||||
|
|
||||||
|
func (s *hashHelper) writeUint(i uint64) { |
||||||
|
n := binary.PutUvarint(s.varintBuf, i) |
||||||
|
if _, err := s.hash.Write(s.varintBuf[:n]); err != nil { |
||||||
|
panic(fmt.Sprintf("error writing %x to %T: %v", s.varintBuf[:n], s.hash, err)) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
func (s *hashHelper) writeStr(str string) { |
||||||
|
s.writeUint(uint64(len(str))) |
||||||
|
s.hash.Write([]byte(str)) |
||||||
|
} |
||||||
|
|
||||||
|
func (s *hashHelper) writeChangedFiles(changedFiles []ChangedFile) { |
||||||
|
sort.Slice(changedFiles, func(i, j int) bool { |
||||||
|
return changedFiles[i].Path < changedFiles[j].Path |
||||||
|
}) |
||||||
|
|
||||||
|
s.writeUint(uint64(len(changedFiles))) |
||||||
|
for _, fileChanged := range changedFiles { |
||||||
|
s.writeStr(fileChanged.Path) |
||||||
|
s.hash.Write(fileChanged.FromMode.Bytes()) |
||||||
|
s.hash.Write(fileChanged.FromHash[:]) |
||||||
|
s.hash.Write(fileChanged.ToMode.Bytes()) |
||||||
|
s.hash.Write(fileChanged.ToHash[:]) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
var ( |
||||||
|
changeHashVersion = []byte{0} |
||||||
|
commentHashVersion = []byte{0} |
||||||
|
) |
||||||
|
|
||||||
|
// if h is nil it then defaultHashHelperAlgo will be used
|
||||||
|
func genChangeFingerprint(h hash.Hash, msg string, changedFiles []ChangedFile) []byte { |
||||||
|
s := newHashHelper(h) |
||||||
|
s.writeStr(msg) |
||||||
|
s.writeChangedFiles(changedFiles) |
||||||
|
return s.sum(changeHashVersion) |
||||||
|
} |
||||||
|
|
||||||
|
// if h is nil it then defaultHashHelperAlgo will be used
|
||||||
|
func genCommentFingerprint(h hash.Hash, comment string) []byte { |
||||||
|
s := newHashHelper(h) |
||||||
|
s.writeStr(comment) |
||||||
|
return s.sum(commentHashVersion) |
||||||
|
} |
@ -0,0 +1,237 @@ |
|||||||
|
package dehub |
||||||
|
|
||||||
|
import ( |
||||||
|
"bytes" |
||||||
|
"encoding/binary" |
||||||
|
"hash" |
||||||
|
"testing" |
||||||
|
|
||||||
|
"gopkg.in/src-d/go-git.v4/plumbing" |
||||||
|
"gopkg.in/src-d/go-git.v4/plumbing/filemode" |
||||||
|
) |
||||||
|
|
||||||
|
type testHash struct { |
||||||
|
bytes.Buffer |
||||||
|
} |
||||||
|
|
||||||
|
var _ hash.Hash = new(testHash) |
||||||
|
|
||||||
|
func (th *testHash) Sum(b []byte) []byte { |
||||||
|
return append(b, th.Buffer.Bytes()...) |
||||||
|
} |
||||||
|
|
||||||
|
func (th *testHash) Size() int { |
||||||
|
return th.Buffer.Len() |
||||||
|
} |
||||||
|
|
||||||
|
func (th *testHash) BlockSize() int { |
||||||
|
return 1 |
||||||
|
} |
||||||
|
|
||||||
|
func (th *testHash) assertContents(t *testing.T, parts [][]byte) { |
||||||
|
b := th.Buffer.Bytes() |
||||||
|
for _, part := range parts { |
||||||
|
if len(part) > len(b) || !bytes.Equal(part, b[:len(part)]) { |
||||||
|
t.Fatalf("expected %q but only found %q", part, b) |
||||||
|
} |
||||||
|
b = b[len(part):] |
||||||
|
} |
||||||
|
if len(b) != 0 { |
||||||
|
t.Fatalf("unexpected extra bytes written to testHash: %q", b) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
func uvarint(i uint64) []byte { |
||||||
|
buf := make([]byte, binary.MaxVarintLen64) |
||||||
|
n := binary.PutUvarint(buf, i) |
||||||
|
return buf[:n] |
||||||
|
} |
||||||
|
|
||||||
|
func TestGenCommentFingerprint(t *testing.T) { |
||||||
|
type test struct { |
||||||
|
descr string |
||||||
|
comment string |
||||||
|
exp [][]byte |
||||||
|
} |
||||||
|
|
||||||
|
tests := []test{ |
||||||
|
{ |
||||||
|
descr: "empty comment", |
||||||
|
comment: "", |
||||||
|
exp: [][]byte{uvarint(0)}, |
||||||
|
}, |
||||||
|
{ |
||||||
|
descr: "normal comment", |
||||||
|
comment: "this is a normal comment", |
||||||
|
exp: [][]byte{uvarint(24), []byte("this is a normal comment")}, |
||||||
|
}, |
||||||
|
{ |
||||||
|
descr: "comment with unicode", |
||||||
|
comment: "sick comment ⚡", |
||||||
|
exp: [][]byte{uvarint(16), []byte("sick comment ⚡")}, |
||||||
|
}, |
||||||
|
} |
||||||
|
|
||||||
|
for _, test := range tests { |
||||||
|
t.Run(test.descr, func(t *testing.T) { |
||||||
|
th := new(testHash) |
||||||
|
genCommentFingerprint(th, test.comment) |
||||||
|
th.assertContents(t, test.exp) |
||||||
|
}) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
func TestGenChangeFingerprint(t *testing.T) { |
||||||
|
type test struct { |
||||||
|
descr string |
||||||
|
msg string |
||||||
|
changedFiles []ChangedFile |
||||||
|
exp [][]byte |
||||||
|
} |
||||||
|
|
||||||
|
hash := func(i byte) plumbing.Hash { |
||||||
|
var h plumbing.Hash |
||||||
|
h[0] = i |
||||||
|
return h |
||||||
|
} |
||||||
|
hashB := func(i byte) []byte { |
||||||
|
h := hash(i) |
||||||
|
return h[:] |
||||||
|
} |
||||||
|
|
||||||
|
tests := []test{ |
||||||
|
{ |
||||||
|
descr: "empty", |
||||||
|
msg: "", |
||||||
|
changedFiles: nil, |
||||||
|
exp: [][]byte{uvarint(0), uvarint(0)}, |
||||||
|
}, |
||||||
|
{ |
||||||
|
descr: "empty changes", |
||||||
|
msg: "some msg", |
||||||
|
changedFiles: nil, |
||||||
|
exp: [][]byte{uvarint(8), []byte("some msg"), uvarint(0)}, |
||||||
|
}, |
||||||
|
{ |
||||||
|
descr: "empty msg", |
||||||
|
msg: "", |
||||||
|
changedFiles: []ChangedFile{{ |
||||||
|
Path: "foo", |
||||||
|
ToMode: filemode.Regular, ToHash: hash(1), |
||||||
|
}}, |
||||||
|
exp: [][]byte{uvarint(0), uvarint(1), |
||||||
|
uvarint(3), []byte("foo"), |
||||||
|
filemode.Empty.Bytes(), hashB(0), |
||||||
|
filemode.Regular.Bytes(), hashB(1)}, |
||||||
|
}, |
||||||
|
{ |
||||||
|
descr: "files added", |
||||||
|
msg: "a", |
||||||
|
changedFiles: []ChangedFile{ |
||||||
|
{ |
||||||
|
Path: "foo", |
||||||
|
ToMode: filemode.Regular, ToHash: hash(1), |
||||||
|
}, |
||||||
|
{ |
||||||
|
Path: "somedir/bar", |
||||||
|
ToMode: filemode.Executable, ToHash: hash(2), |
||||||
|
}, |
||||||
|
}, |
||||||
|
exp: [][]byte{uvarint(1), []byte("a"), uvarint(2), |
||||||
|
uvarint(3), []byte("foo"), |
||||||
|
filemode.Empty.Bytes(), hashB(0), |
||||||
|
filemode.Regular.Bytes(), hashB(1), |
||||||
|
uvarint(11), []byte("somedir/bar"), |
||||||
|
filemode.Empty.Bytes(), hashB(0), |
||||||
|
filemode.Executable.Bytes(), hashB(2), |
||||||
|
}, |
||||||
|
}, |
||||||
|
{ |
||||||
|
descr: "files added (unordered)", |
||||||
|
msg: "a", |
||||||
|
changedFiles: []ChangedFile{ |
||||||
|
{ |
||||||
|
Path: "somedir/bar", |
||||||
|
ToMode: filemode.Executable, ToHash: hash(2), |
||||||
|
}, |
||||||
|
{ |
||||||
|
Path: "foo", |
||||||
|
ToMode: filemode.Regular, ToHash: hash(1), |
||||||
|
}, |
||||||
|
}, |
||||||
|
exp: [][]byte{uvarint(1), []byte("a"), uvarint(2), |
||||||
|
uvarint(3), []byte("foo"), |
||||||
|
filemode.Empty.Bytes(), hashB(0), |
||||||
|
filemode.Regular.Bytes(), hashB(1), |
||||||
|
uvarint(11), []byte("somedir/bar"), |
||||||
|
filemode.Empty.Bytes(), hashB(0), |
||||||
|
filemode.Executable.Bytes(), hashB(2), |
||||||
|
}, |
||||||
|
}, |
||||||
|
{ |
||||||
|
descr: "file modified", |
||||||
|
msg: "a", |
||||||
|
changedFiles: []ChangedFile{{ |
||||||
|
Path: "foo", |
||||||
|
FromMode: filemode.Regular, FromHash: hash(1), |
||||||
|
ToMode: filemode.Executable, ToHash: hash(2), |
||||||
|
}}, |
||||||
|
exp: [][]byte{uvarint(1), []byte("a"), uvarint(1), |
||||||
|
uvarint(3), []byte("foo"), |
||||||
|
filemode.Regular.Bytes(), hashB(1), |
||||||
|
filemode.Executable.Bytes(), hashB(2), |
||||||
|
}, |
||||||
|
}, |
||||||
|
{ |
||||||
|
descr: "file removed", |
||||||
|
msg: "a", |
||||||
|
changedFiles: []ChangedFile{{ |
||||||
|
Path: "foo", |
||||||
|
FromMode: filemode.Regular, FromHash: hash(1), |
||||||
|
}}, |
||||||
|
exp: [][]byte{uvarint(1), []byte("a"), uvarint(1), |
||||||
|
uvarint(3), []byte("foo"), |
||||||
|
filemode.Regular.Bytes(), hashB(1), |
||||||
|
filemode.Empty.Bytes(), hashB(0), |
||||||
|
}, |
||||||
|
}, |
||||||
|
{ |
||||||
|
descr: "files added, modified, and removed", |
||||||
|
msg: "aaa", |
||||||
|
changedFiles: []ChangedFile{ |
||||||
|
{ |
||||||
|
Path: "foo", |
||||||
|
ToMode: filemode.Regular, ToHash: hash(1), |
||||||
|
}, |
||||||
|
{ |
||||||
|
Path: "bar", |
||||||
|
FromMode: filemode.Regular, FromHash: hash(2), |
||||||
|
ToMode: filemode.Regular, ToHash: hash(3), |
||||||
|
}, |
||||||
|
{ |
||||||
|
Path: "baz", |
||||||
|
FromMode: filemode.Executable, FromHash: hash(4), |
||||||
|
}, |
||||||
|
}, |
||||||
|
exp: [][]byte{uvarint(3), []byte("aaa"), uvarint(3), |
||||||
|
uvarint(3), []byte("bar"), |
||||||
|
filemode.Regular.Bytes(), hashB(2), |
||||||
|
filemode.Regular.Bytes(), hashB(3), |
||||||
|
uvarint(3), []byte("baz"), |
||||||
|
filemode.Executable.Bytes(), hashB(4), |
||||||
|
filemode.Empty.Bytes(), hashB(0), |
||||||
|
uvarint(3), []byte("foo"), |
||||||
|
filemode.Empty.Bytes(), hashB(0), |
||||||
|
filemode.Regular.Bytes(), hashB(1), |
||||||
|
}, |
||||||
|
}, |
||||||
|
} |
||||||
|
|
||||||
|
for _, test := range tests { |
||||||
|
t.Run(test.descr, func(t *testing.T) { |
||||||
|
th := new(testHash) |
||||||
|
genChangeFingerprint(th, test.msg, test.changedFiles) |
||||||
|
th.assertContents(t, test.exp) |
||||||
|
}) |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,628 @@ |
|||||||
|
package dehub |
||||||
|
|
||||||
|
import ( |
||||||
|
"bytes" |
||||||
|
"errors" |
||||||
|
"fmt" |
||||||
|
"sort" |
||||||
|
"strings" |
||||||
|
"time" |
||||||
|
|
||||||
|
"dehub.dev/src/dehub.git/accessctl" |
||||||
|
"dehub.dev/src/dehub.git/fs" |
||||||
|
"dehub.dev/src/dehub.git/sigcred" |
||||||
|
"dehub.dev/src/dehub.git/typeobj" |
||||||
|
"dehub.dev/src/dehub.git/yamlutil" |
||||||
|
|
||||||
|
"gopkg.in/src-d/go-git.v4" |
||||||
|
"gopkg.in/src-d/go-git.v4/plumbing" |
||||||
|
"gopkg.in/src-d/go-git.v4/plumbing/object" |
||||||
|
yaml "gopkg.in/yaml.v2" |
||||||
|
) |
||||||
|
|
||||||
|
// Payload describes the methods which must be implemented by the different
|
||||||
|
// payload types. None of the methods should modify the underlying object.
|
||||||
|
type Payload interface { |
||||||
|
// MessageHead returns the head of the commit message (i.e. the first line).
|
||||||
|
// The PayloadCommon of the outer PayloadUnion is passed in for added
|
||||||
|
// context, if necessary.
|
||||||
|
MessageHead(PayloadCommon) string |
||||||
|
|
||||||
|
// Fingerprint returns the raw fingerprint which can be signed when
|
||||||
|
// accrediting this payload. The ChangedFile objects given describe the file
|
||||||
|
// changes between the parent commit and this commit.
|
||||||
|
//
|
||||||
|
// If this method returns nil it means that the payload has no fingerprint
|
||||||
|
// in-and-of-itself.
|
||||||
|
Fingerprint([]ChangedFile) ([]byte, error) |
||||||
|
} |
||||||
|
|
||||||
|
// PayloadCommon describes the fields common to all Payloads.
|
||||||
|
type PayloadCommon struct { |
||||||
|
Fingerprint yamlutil.Blob `yaml:"fingerprint"` |
||||||
|
Credentials []sigcred.CredentialUnion `yaml:"credentials"` |
||||||
|
|
||||||
|
// LegacyChangeHash is no longer used, use Fingerprint instead.
|
||||||
|
LegacyChangeHash yamlutil.Blob `yaml:"change_hash,omitempty"` |
||||||
|
} |
||||||
|
|
||||||
|
func (cc PayloadCommon) credIDs() []string { |
||||||
|
m := map[string]struct{}{} |
||||||
|
for _, cred := range cc.Credentials { |
||||||
|
if cred.AccountID != "" { |
||||||
|
m[cred.AccountID] = struct{}{} |
||||||
|
} else if cred.AnonID != "" { |
||||||
|
m[cred.AnonID] = struct{}{} |
||||||
|
} |
||||||
|
} |
||||||
|
s := make([]string, 0, len(m)) |
||||||
|
for id := range m { |
||||||
|
s = append(s, id) |
||||||
|
} |
||||||
|
sort.Strings(s) |
||||||
|
return s |
||||||
|
} |
||||||
|
|
||||||
|
func abbrevCommitMessage(msg string) string { |
||||||
|
i := strings.Index(msg, "\n") |
||||||
|
if i > 0 { |
||||||
|
msg = msg[:i] |
||||||
|
} |
||||||
|
if len(msg) > 80 { |
||||||
|
msg = msg[:77] + "..." |
||||||
|
} |
||||||
|
return msg |
||||||
|
} |
||||||
|
|
||||||
|
// PayloadUnion represents a single Payload of variable type. Only one field
|
||||||
|
// should be set on a PayloadUnion, unless otherwise noted.
|
||||||
|
type PayloadUnion struct { |
||||||
|
Change *PayloadChange `type:"change,default"` |
||||||
|
Credential *PayloadCredential `type:"credential"` |
||||||
|
Comment *PayloadComment `type:"comment"` |
||||||
|
|
||||||
|
// Common may be set in addition to one of the other fields.
|
||||||
|
Common PayloadCommon `yaml:",inline"` |
||||||
|
} |
||||||
|
|
||||||
|
// MarshalYAML implements the yaml.Marshaler interface.
|
||||||
|
func (p PayloadUnion) MarshalYAML() (interface{}, error) { |
||||||
|
return typeobj.MarshalYAML(p) |
||||||
|
} |
||||||
|
|
||||||
|
// UnmarshalYAML implements the yaml.Unmarshaler interface.
|
||||||
|
func (p *PayloadUnion) UnmarshalYAML(unmarshal func(interface{}) error) error { |
||||||
|
if err := typeobj.UnmarshalYAML(p, unmarshal); err != nil { |
||||||
|
return err |
||||||
|
} else if len(p.Common.LegacyChangeHash) > 0 { |
||||||
|
p.Common.Fingerprint = p.Common.LegacyChangeHash |
||||||
|
p.Common.LegacyChangeHash = nil |
||||||
|
} |
||||||
|
return nil |
||||||
|
} |
||||||
|
|
||||||
|
// Payload returns the Payload instance encapsulated by this PayloadUnion.
|
||||||
|
//
|
||||||
|
// This will panic if a Payload field is not populated.
|
||||||
|
func (p PayloadUnion) Payload() Payload { |
||||||
|
el, _, err := typeobj.Element(p) |
||||||
|
if err != nil { |
||||||
|
panic(err) |
||||||
|
} |
||||||
|
return el.(Payload) |
||||||
|
} |
||||||
|
|
||||||
|
// Type returns the Payload's type (as would be used in its YAML "type" field).
|
||||||
|
//
|
||||||
|
// This will panic if a Payload field is not populated.
|
||||||
|
func (p PayloadUnion) Type() string { |
||||||
|
_, typeStr, err := typeobj.Element(p) |
||||||
|
if err != nil { |
||||||
|
panic(err) |
||||||
|
} |
||||||
|
return typeStr |
||||||
|
} |
||||||
|
|
||||||
|
// MarshalText implements the encoding.TextMarshaler interface by returning the
|
||||||
|
// form the payload in the git commit message.
|
||||||
|
func (p PayloadUnion) MarshalText() ([]byte, error) { |
||||||
|
msgHead := abbrevCommitMessage(p.Payload().MessageHead(p.Common)) |
||||||
|
msgBodyB, err := yaml.Marshal(p) |
||||||
|
if err != nil { |
||||||
|
return nil, fmt.Errorf("marshaling payload %+v as yaml: %w", p, err) |
||||||
|
} |
||||||
|
|
||||||
|
w := new(bytes.Buffer) |
||||||
|
w.WriteString(msgHead) |
||||||
|
w.WriteString("\n\n---\n") |
||||||
|
w.Write(msgBodyB) |
||||||
|
return w.Bytes(), nil |
||||||
|
} |
||||||
|
|
||||||
|
// UnmarshalText implements the encoding.TextUnmarshaler interface by decoding a
|
||||||
|
// payload object which has been encoded into a git commit message.
|
||||||
|
func (p *PayloadUnion) UnmarshalText(msg []byte) error { |
||||||
|
i := bytes.Index(msg, []byte("\n")) |
||||||
|
if i < 0 { |
||||||
|
return fmt.Errorf("commit message %q is malformed, it has no body", msg) |
||||||
|
} |
||||||
|
msgBody := msg[i:] |
||||||
|
|
||||||
|
if err := yaml.Unmarshal(msgBody, p); err != nil { |
||||||
|
return fmt.Errorf("unmarshaling commit payload from yaml: %w", err) |
||||||
|
} |
||||||
|
return nil |
||||||
|
} |
||||||
|
|
||||||
|
// AccreditPayload returns the given PayloadUnion with an appended Credential
|
||||||
|
// provided by the given SignifierInterface.
|
||||||
|
func (proj *Project) AccreditPayload(payUn PayloadUnion, sig sigcred.Signifier) (PayloadUnion, error) { |
||||||
|
headFS, err := proj.headFS() |
||||||
|
if err != nil { |
||||||
|
return payUn, fmt.Errorf("retrieving HEAD fs: %w", err) |
||||||
|
} |
||||||
|
|
||||||
|
cred, err := sig.Sign(headFS, payUn.Common.Fingerprint) |
||||||
|
if err != nil { |
||||||
|
return payUn, fmt.Errorf("signing fingerprint %q: %w", payUn.Common.Fingerprint, err) |
||||||
|
} |
||||||
|
payUn.Common.Credentials = append(payUn.Common.Credentials, cred) |
||||||
|
return payUn, nil |
||||||
|
} |
||||||
|
|
||||||
|
// CommitDirectParams are the parameters to the CommitDirect method. All are
|
||||||
|
// required, unless otherwise noted.
|
||||||
|
type CommitDirectParams struct { |
||||||
|
PayloadUnion PayloadUnion |
||||||
|
Author string |
||||||
|
ParentHash plumbing.Hash // can be zero if the commit has no parents (Q_Q)
|
||||||
|
GitTree *object.Tree |
||||||
|
} |
||||||
|
|
||||||
|
// CommitDirect constructs a git commit object and and stores it, returning the
|
||||||
|
// resulting Commit. This method does not interact with HEAD at all.
|
||||||
|
func (proj *Project) CommitDirect(params CommitDirectParams) (Commit, error) { |
||||||
|
msgB, err := params.PayloadUnion.MarshalText() |
||||||
|
if err != nil { |
||||||
|
return Commit{}, fmt.Errorf("encoding payload to message string: %w", err) |
||||||
|
} |
||||||
|
|
||||||
|
author := object.Signature{ |
||||||
|
Name: params.Author, |
||||||
|
When: time.Now(), |
||||||
|
} |
||||||
|
commit := &object.Commit{ |
||||||
|
Author: author, |
||||||
|
Committer: author, |
||||||
|
Message: string(msgB), |
||||||
|
TreeHash: params.GitTree.Hash, |
||||||
|
} |
||||||
|
if params.ParentHash != plumbing.ZeroHash { |
||||||
|
commit.ParentHashes = []plumbing.Hash{params.ParentHash} |
||||||
|
} |
||||||
|
|
||||||
|
commitObj := proj.GitRepo.Storer.NewEncodedObject() |
||||||
|
if err := commit.Encode(commitObj); err != nil { |
||||||
|
return Commit{}, fmt.Errorf("encoding commit object: %w", err) |
||||||
|
} |
||||||
|
|
||||||
|
commitHash, err := proj.GitRepo.Storer.SetEncodedObject(commitObj) |
||||||
|
if err != nil { |
||||||
|
return Commit{}, fmt.Errorf("setting encoded object: %w", err) |
||||||
|
} |
||||||
|
|
||||||
|
return proj.GetCommit(commitHash) |
||||||
|
} |
||||||
|
|
||||||
|
// Commit uses the given PayloadUnion to create a git commit object and commits
|
||||||
|
// it to the current HEAD, returning the full Commit.
|
||||||
|
func (proj *Project) Commit(payUn PayloadUnion) (Commit, error) { |
||||||
|
headRef, err := proj.TraverseReferenceChain(plumbing.HEAD, func(ref *plumbing.Reference) bool { |
||||||
|
return ref.Type() == plumbing.HashReference |
||||||
|
}) |
||||||
|
if err != nil { |
||||||
|
return Commit{}, fmt.Errorf("resolving HEAD to a hash reference: %w", err) |
||||||
|
} |
||||||
|
headRefName := headRef.Name() |
||||||
|
|
||||||
|
headHash, err := proj.ReferenceToHash(headRefName) |
||||||
|
if err != nil { |
||||||
|
return Commit{}, fmt.Errorf("resolving ref %q (HEAD): %w", headRefName, err) |
||||||
|
} |
||||||
|
|
||||||
|
// TODO this is also used in the same way in NewCommitChange. It might make
|
||||||
|
// sense to refactor this logic out, it might not be needed in fs at all.
|
||||||
|
_, stagedTree, err := fs.FromStagedChangesTree(proj.GitRepo) |
||||||
|
if err != nil { |
||||||
|
return Commit{}, fmt.Errorf("getting staged changes: %w", err) |
||||||
|
} |
||||||
|
|
||||||
|
commit, err := proj.CommitDirect(CommitDirectParams{ |
||||||
|
PayloadUnion: payUn, |
||||||
|
Author: strings.Join(payUn.Common.credIDs(), ", "), |
||||||
|
ParentHash: headHash, |
||||||
|
GitTree: stagedTree, |
||||||
|
}) |
||||||
|
if err != nil { |
||||||
|
return Commit{}, err |
||||||
|
} |
||||||
|
|
||||||
|
// now set the branch to this new commit
|
||||||
|
newHeadRef := plumbing.NewHashReference(headRefName, commit.Hash) |
||||||
|
if err := proj.GitRepo.Storer.SetReference(newHeadRef); err != nil { |
||||||
|
return Commit{}, fmt.Errorf("setting reference %q to new commit hash %q: %w", |
||||||
|
headRefName, commit.Hash, err) |
||||||
|
} |
||||||
|
|
||||||
|
return commit, nil |
||||||
|
} |
||||||
|
|
||||||
|
// HasStagedChanges returns true if there are file changes which have been
|
||||||
|
// staged (e.g. via "git add").
|
||||||
|
func (proj *Project) HasStagedChanges() (bool, error) { |
||||||
|
w, err := proj.GitRepo.Worktree() |
||||||
|
if err != nil { |
||||||
|
return false, fmt.Errorf("retrieving worktree: %w", err) |
||||||
|
} |
||||||
|
|
||||||
|
status, err := w.Status() |
||||||
|
if err != nil { |
||||||
|
return false, fmt.Errorf("retrieving worktree status: %w", err) |
||||||
|
} |
||||||
|
|
||||||
|
var any bool |
||||||
|
for _, fileStatus := range status { |
||||||
|
if fileStatus.Staging != git.Unmodified && |
||||||
|
fileStatus.Staging != git.Untracked { |
||||||
|
any = true |
||||||
|
break |
||||||
|
} |
||||||
|
} |
||||||
|
return any, nil |
||||||
|
} |
||||||
|
|
||||||
|
// VerifyCommits verifies that the given commits, which are presumably on the
|
||||||
|
// given branch, are gucci.
|
||||||
|
func (proj *Project) VerifyCommits(branchName plumbing.ReferenceName, commits []Commit) error { |
||||||
|
// this isn't strictly necessary for this method, but it helps discover bugs
|
||||||
|
// in other parts of the code.
|
||||||
|
if len(commits) == 0 { |
||||||
|
return errors.New("cannot call VerifyCommits with empty commit slice") |
||||||
|
} |
||||||
|
|
||||||
|
// First determine the root of the main branch. All commits need to be an
|
||||||
|
// ancestor of it. If the main branch has not been created yet then there
|
||||||
|
// might not be a root commit yet.
|
||||||
|
var rootCommitObj *object.Commit |
||||||
|
mainCommit, err := proj.GetCommitByRevision(plumbing.Revision(MainRefName)) |
||||||
|
if errors.Is(err, plumbing.ErrReferenceNotFound) { |
||||||
|
|
||||||
|
// main branch hasn't been created yet. The commits can only be verified
|
||||||
|
// if they are for the main branch and they include the root commit.
|
||||||
|
if branchName != MainRefName { |
||||||
|
return fmt.Errorf("cannot verify commits in branch %q when no main branch exists", branchName) |
||||||
|
} |
||||||
|
|
||||||
|
for _, commit := range commits { |
||||||
|
if commit.Object.NumParents() == 0 { |
||||||
|
rootCommitObj = commit.Object |
||||||
|
break |
||||||
|
} |
||||||
|
} |
||||||
|
if rootCommitObj == nil { |
||||||
|
return errors.New("root commit of main branch cannot be determined") |
||||||
|
} |
||||||
|
|
||||||
|
} else if err != nil { |
||||||
|
return fmt.Errorf("retrieving commit at HEAD of %q: %w", MainRefName.Short(), err) |
||||||
|
|
||||||
|
} else { |
||||||
|
rootCommitObj = mainCommit.Object |
||||||
|
for { |
||||||
|
if rootCommitObj.NumParents() == 0 { |
||||||
|
break |
||||||
|
} else if rootCommitObj.NumParents() > 1 { |
||||||
|
return fmt.Errorf("commit %q in main branch has more than one parent", rootCommitObj.Hash) |
||||||
|
} else if rootCommitObj, err = rootCommitObj.Parent(0); err != nil { |
||||||
|
return fmt.Errorf("retrieving parent commit of %q: %w", rootCommitObj.Hash, err) |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// We also need the HEAD of the given branch, if it exists.
|
||||||
|
branchCommit, err := proj.GetCommitByRevision(plumbing.Revision(branchName)) |
||||||
|
if err != nil && !errors.Is(err, plumbing.ErrReferenceNotFound) { |
||||||
|
return fmt.Errorf("retrieving commit at HEAD of %q: %w", branchName.Short(), err) |
||||||
|
} |
||||||
|
|
||||||
|
for i, commit := range commits { |
||||||
|
// It's not a requirement that the given Commits are in ancestral order,
|
||||||
|
// but usually they are; if the previous commit is the parent of this
|
||||||
|
// one we can skip a bunch of work.
|
||||||
|
var parentTree *object.Tree |
||||||
|
var isNonFF bool |
||||||
|
if i > 0 && commits[i-1].Hash == commit.Object.ParentHashes[0] { |
||||||
|
parentTree = commits[i-1].TreeObject |
||||||
|
|
||||||
|
} else if commit.Hash == rootCommitObj.Hash { |
||||||
|
// looking at the root commit, assume it's ok
|
||||||
|
|
||||||
|
} else { |
||||||
|
var err error |
||||||
|
isAncestor := func(older, younger *object.Commit) bool { |
||||||
|
var isAncestor bool |
||||||
|
if err != nil { |
||||||
|
return false |
||||||
|
} else if isAncestor, err = older.IsAncestor(younger); err != nil { |
||||||
|
err = fmt.Errorf("determining if %q is an ancestor of %q: %w", |
||||||
|
younger.Hash, older.Hash, err) |
||||||
|
return false |
||||||
|
|
||||||
|
} |
||||||
|
return isAncestor |
||||||
|
} |
||||||
|
|
||||||
|
ancestorOfRoot := isAncestor(rootCommitObj, commit.Object) |
||||||
|
if branchCommit.Hash != plumbing.ZeroHash { // checking if the var was set
|
||||||
|
// this could only be a nonFF if the branch actually exists.
|
||||||
|
isNonFF = !isAncestor(branchCommit.Object, commit.Object) |
||||||
|
} |
||||||
|
|
||||||
|
if err != nil { |
||||||
|
return err |
||||||
|
} else if !ancestorOfRoot { |
||||||
|
return fmt.Errorf("commit %q must be direct descendant of root commit of %q (%q)", |
||||||
|
commit.Hash, MainRefName.Short(), rootCommitObj.Hash, |
||||||
|
) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
if err := proj.verifyCommit(branchName, commit, parentTree, isNonFF); err != nil { |
||||||
|
return fmt.Errorf("verifying commit %q: %w", commit.Hash, err) |
||||||
|
} |
||||||
|
} |
||||||
|
return nil |
||||||
|
} |
||||||
|
|
||||||
|
// parentTree returns the tree of the parent commit of the given commit. If the
|
||||||
|
// given commit has no parents then a bare tree is returned.
|
||||||
|
func (proj *Project) parentTree(commitObj *object.Commit) (*object.Tree, error) { |
||||||
|
switch commitObj.NumParents() { |
||||||
|
case 0: |
||||||
|
return new(object.Tree), nil |
||||||
|
case 1: |
||||||
|
if parentCommitObj, err := commitObj.Parent(0); err != nil { |
||||||
|
return nil, fmt.Errorf("getting parent commit %q: %w", |
||||||
|
commitObj.ParentHashes[0], err) |
||||||
|
} else if parentTree, err := proj.GitRepo.TreeObject(parentCommitObj.TreeHash); err != nil { |
||||||
|
return nil, fmt.Errorf("getting parent tree object %q: %w", |
||||||
|
parentCommitObj.TreeHash, err) |
||||||
|
} else { |
||||||
|
return parentTree, nil |
||||||
|
} |
||||||
|
default: |
||||||
|
return nil, errors.New("commit has multiple parents") |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// if parentTree is nil then it will be inferred.
|
||||||
|
func (proj *Project) verifyCommit( |
||||||
|
branchName plumbing.ReferenceName, |
||||||
|
commit Commit, |
||||||
|
parentTree *object.Tree, |
||||||
|
isNonFF bool, |
||||||
|
) error { |
||||||
|
if parentTree == nil { |
||||||
|
var err error |
||||||
|
if parentTree, err = proj.parentTree(commit.Object); err != nil { |
||||||
|
return fmt.Errorf("retrieving parent tree of commit: %w", err) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
var sigFS fs.FS |
||||||
|
if commit.Object.NumParents() == 0 { |
||||||
|
sigFS = fs.FromTree(commit.TreeObject) |
||||||
|
} else { |
||||||
|
sigFS = fs.FromTree(parentTree) |
||||||
|
} |
||||||
|
|
||||||
|
cfg, err := proj.loadConfig(sigFS) |
||||||
|
if err != nil { |
||||||
|
return fmt.Errorf("loading config of parent %q: %w", commit.Object.ParentHashes[0], err) |
||||||
|
} |
||||||
|
|
||||||
|
// assert access controls
|
||||||
|
changedFiles, err := ChangedFilesBetweenTrees(parentTree, commit.TreeObject) |
||||||
|
if err != nil { |
||||||
|
return fmt.Errorf("calculating diff from tree %q to tree %q: %w", |
||||||
|
parentTree.Hash, commit.TreeObject.Hash, err) |
||||||
|
|
||||||
|
} else if len(changedFiles) > 0 && commit.Payload.Change == nil { |
||||||
|
return errors.New("files changes but commit is not a change commit") |
||||||
|
} |
||||||
|
|
||||||
|
pathsChanged := make([]string, len(changedFiles)) |
||||||
|
for i := range changedFiles { |
||||||
|
pathsChanged[i] = changedFiles[i].Path |
||||||
|
} |
||||||
|
|
||||||
|
commitType := commit.Payload.Type() |
||||||
|
err = accessctl.AssertCanCommit(cfg.AccessControls, accessctl.CommitRequest{ |
||||||
|
Type: commitType, |
||||||
|
Branch: branchName.Short(), |
||||||
|
Credentials: commit.Payload.Common.Credentials, |
||||||
|
FilesChanged: pathsChanged, |
||||||
|
NonFastForward: isNonFF, |
||||||
|
}) |
||||||
|
if err != nil { |
||||||
|
return fmt.Errorf("asserting access controls: %w", err) |
||||||
|
} |
||||||
|
|
||||||
|
// ensure the fingerprint is what it's expected to be
|
||||||
|
storedFingerprint := commit.Payload.Common.Fingerprint |
||||||
|
expectedFingerprint, err := commit.Payload.Payload().Fingerprint(changedFiles) |
||||||
|
if err != nil { |
||||||
|
return fmt.Errorf("calculating expected payload fingerprint: %w", err) |
||||||
|
} else if expectedFingerprint == nil { |
||||||
|
// the payload doesn't have a fingerprint of its own, it's just carrying
|
||||||
|
// one, so no point in checking if it's "correct".
|
||||||
|
} else if !bytes.Equal(storedFingerprint, expectedFingerprint) { |
||||||
|
return fmt.Errorf("unexpected fingerprint in payload, is %q but should be %q", |
||||||
|
storedFingerprint, yamlutil.Blob(expectedFingerprint)) |
||||||
|
} |
||||||
|
|
||||||
|
// verify all credentials
|
||||||
|
for _, cred := range commit.Payload.Common.Credentials { |
||||||
|
if cred.AccountID == "" { |
||||||
|
if err := cred.SelfVerify(storedFingerprint); err != nil { |
||||||
|
return fmt.Errorf("verifying credential %+v: %w", cred, err) |
||||||
|
} |
||||||
|
} else { |
||||||
|
sig, err := proj.signifierForCredential(sigFS, cred) |
||||||
|
if err != nil { |
||||||
|
return fmt.Errorf("finding signifier for credential %+v: %w", cred, err) |
||||||
|
} else if err := sig.Verify(sigFS, storedFingerprint, cred); err != nil { |
||||||
|
return fmt.Errorf("verifying credential %+v: %w", cred, err) |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return nil |
||||||
|
} |
||||||
|
|
||||||
|
// LastChangeDescription iterates over the given commits in reverse order and
|
||||||
|
// returns the first change description it comes across. A change description
|
||||||
|
// may come from a change payload or a credential payload which covers a set of
|
||||||
|
// changes.
|
||||||
|
//
|
||||||
|
// This function will return an error if no given commits contain a change
|
||||||
|
// description.
|
||||||
|
func LastChangeDescription(commits []Commit) (string, error) { |
||||||
|
for i := range commits { |
||||||
|
i = len(commits) - 1 - i |
||||||
|
payUn := commits[i].Payload |
||||||
|
if payUn.Change != nil { |
||||||
|
return payUn.Change.Description, nil |
||||||
|
} else if payUn.Credential != nil && payUn.Credential.ChangeDescription != "" { |
||||||
|
return payUn.Credential.ChangeDescription, nil |
||||||
|
} |
||||||
|
} |
||||||
|
return "", errors.New("no commits in range contain a change description") |
||||||
|
} |
||||||
|
|
||||||
|
type changeRangeInfo struct { |
||||||
|
changeCommits []Commit |
||||||
|
authors map[string]struct{} |
||||||
|
startTree, endTree *object.Tree |
||||||
|
changeDescription string |
||||||
|
} |
||||||
|
|
||||||
|
// changeRangeInfo returns various pieces of information about a range of
|
||||||
|
// commits' changes.
|
||||||
|
func (proj *Project) changeRangeInfo(commits []Commit) (changeRangeInfo, error) { |
||||||
|
info := changeRangeInfo{ |
||||||
|
authors: map[string]struct{}{}, |
||||||
|
} |
||||||
|
|
||||||
|
for _, commit := range commits { |
||||||
|
if commit.Payload.Change != nil { |
||||||
|
info.changeCommits = append(info.changeCommits, commit) |
||||||
|
for _, cred := range commit.Payload.Common.Credentials { |
||||||
|
info.authors[cred.AccountID] = struct{}{} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
if len(info.changeCommits) == 0 { |
||||||
|
return changeRangeInfo{}, errors.New("no change commits found in range") |
||||||
|
} |
||||||
|
|
||||||
|
// startTree has to be the tree of the parent of the first commit, which
|
||||||
|
// isn't included in commits. Determine it the hard way.
|
||||||
|
var err error |
||||||
|
if info.startTree, err = proj.parentTree(commits[0].Object); err != nil { |
||||||
|
return changeRangeInfo{}, fmt.Errorf("getting tree of parent of %q: %w", |
||||||
|
commits[0].Hash, err) |
||||||
|
} else if info.changeDescription, err = LastChangeDescription(commits); err != nil { |
||||||
|
return changeRangeInfo{}, err |
||||||
|
} |
||||||
|
|
||||||
|
lastChangeCommit := info.changeCommits[len(info.changeCommits)-1] |
||||||
|
info.endTree = lastChangeCommit.TreeObject |
||||||
|
return info, nil |
||||||
|
} |
||||||
|
|
||||||
|
func (info changeRangeInfo) changeFingerprint(descr string) ([]byte, error) { |
||||||
|
changedFiles, err := ChangedFilesBetweenTrees(info.startTree, info.endTree) |
||||||
|
if err != nil { |
||||||
|
return nil, fmt.Errorf("calculating diff of commit trees %q and %q: %w", |
||||||
|
info.startTree.Hash, info.endTree.Hash, err) |
||||||
|
} |
||||||
|
|
||||||
|
return genChangeFingerprint(nil, descr, changedFiles), nil |
||||||
|
} |
||||||
|
|
||||||
|
// VerifyCanSetBranchHEADTo is used to verify that a branch's HEAD can be set to
|
||||||
|
// the given hash. It verifies any new commits which are being added, and
|
||||||
|
// handles verifying non-fast-forward commits as well.
|
||||||
|
//
|
||||||
|
// If the given hash matches the current HEAD of the branch then this performs
|
||||||
|
// no further checks and returns nil.
|
||||||
|
func (proj *Project) VerifyCanSetBranchHEADTo(branchName plumbing.ReferenceName, hash plumbing.Hash) error { |
||||||
|
oldCommitRef, err := proj.GitRepo.Reference(branchName, true) |
||||||
|
if errors.Is(err, plumbing.ErrReferenceNotFound) { |
||||||
|
// if the branch is being created then just pull all of its commits and
|
||||||
|
// verify them.
|
||||||
|
// TODO optimize this so that it tries to use the merge-base with main,
|
||||||
|
// so we're not re-verifying a ton of commits unecessarily
|
||||||
|
commits, err := proj.GetCommitRange(plumbing.ZeroHash, hash) |
||||||
|
if err != nil { |
||||||
|
return fmt.Errorf("retrieving %q and all its ancestors: %w", hash, err) |
||||||
|
} |
||||||
|
return proj.VerifyCommits(branchName, commits) |
||||||
|
|
||||||
|
} else if err != nil { |
||||||
|
return fmt.Errorf("resolving branch reference to a hash: %w", err) |
||||||
|
|
||||||
|
} else if oldCommitRef.Hash() == hash { |
||||||
|
// if the HEAD is already at the given hash then it must be fine.
|
||||||
|
return nil |
||||||
|
} |
||||||
|
|
||||||
|
oldCommitObj, err := proj.GitRepo.CommitObject(oldCommitRef.Hash()) |
||||||
|
if err != nil { |
||||||
|
return fmt.Errorf("retrieving commit object %q: %w", oldCommitRef.Hash(), err) |
||||||
|
} |
||||||
|
|
||||||
|
newCommit, err := proj.GetCommit(hash) |
||||||
|
if err != nil { |
||||||
|
return fmt.Errorf("retrieving commit %q: %w", hash, err) |
||||||
|
} |
||||||
|
|
||||||
|
if isAncestor, err := newCommit.Object.IsAncestor(oldCommitObj); err != nil { |
||||||
|
return fmt.Errorf("determining if %q is an ancestor of %q: %w", |
||||||
|
newCommit.Hash, oldCommitObj.Hash, err) |
||||||
|
} else if isAncestor { |
||||||
|
// if the new commit is an ancestor of the old one then the branch is
|
||||||
|
// being force-pushed to a previous commit. This is weird to handle
|
||||||
|
// using VerifyCommits, so just call verifyCommit directly.
|
||||||
|
return proj.verifyCommit(branchName, newCommit, nil, true) |
||||||
|
} |
||||||
|
|
||||||
|
mbCommits, err := oldCommitObj.MergeBase(newCommit.Object) |
||||||
|
if err != nil { |
||||||
|
return fmt.Errorf("determining merge-base between %q and %q: %w", |
||||||
|
oldCommitObj.Hash, newCommit.Hash, err) |
||||||
|
} else if len(mbCommits) == 0 { |
||||||
|
return fmt.Errorf("%q and %q have no ancestors in common", |
||||||
|
oldCommitObj.Hash, newCommit.Hash) |
||||||
|
} else if len(mbCommits) == 2 { |
||||||
|
return fmt.Errorf("%q and %q have more than one ancestor in common", |
||||||
|
oldCommitObj.Hash, newCommit.Hash) |
||||||
|
} |
||||||
|
|
||||||
|
commits, err := proj.GetCommitRange(mbCommits[0].Hash, hash) |
||||||
|
if err != nil { |
||||||
|
return fmt.Errorf("retrieving commits %q to %q: %w", mbCommits[0].Hash, hash, err) |
||||||
|
} |
||||||
|
return proj.VerifyCommits(branchName, commits) |
||||||
|
} |
@ -0,0 +1,176 @@ |
|||||||
|
package dehub |
||||||
|
|
||||||
|
import ( |
||||||
|
"bytes" |
||||||
|
"errors" |
||||||
|
"fmt" |
||||||
|
"sort" |
||||||
|
"strings" |
||||||
|
|
||||||
|
"dehub.dev/src/dehub.git/fs" |
||||||
|
"dehub.dev/src/dehub.git/sigcred" |
||||||
|
|
||||||
|
"gopkg.in/src-d/go-git.v4/plumbing" |
||||||
|
"gopkg.in/src-d/go-git.v4/plumbing/object" |
||||||
|
) |
||||||
|
|
||||||
|
// PayloadChange describes the structure of a change payload.
|
||||||
|
type PayloadChange struct { |
||||||
|
Description string `yaml:"description"` |
||||||
|
|
||||||
|
// LegacyMessage is no longer used, use Description instead
|
||||||
|
LegacyMessage string `yaml:"message,omitempty"` |
||||||
|
} |
||||||
|
|
||||||
|
var _ Payload = PayloadChange{} |
||||||
|
|
||||||
|
// NewPayloadChange constructs a PayloadUnion populated with a PayloadChange
|
||||||
|
// encompassing the currently staged file changes. The Credentials of the
|
||||||
|
// returned PayloadUnion will _not_ be filled in.
|
||||||
|
func (proj *Project) NewPayloadChange(description string) (PayloadUnion, error) { |
||||||
|
headTree := new(object.Tree) |
||||||
|
if head, err := proj.GetHeadCommit(); err != nil && !errors.Is(err, ErrHeadIsZero) { |
||||||
|
return PayloadUnion{}, fmt.Errorf("getting HEAD commit: %w", err) |
||||||
|
} else if err == nil { |
||||||
|
headTree = head.TreeObject |
||||||
|
} |
||||||
|
|
||||||
|
_, stagedTree, err := fs.FromStagedChangesTree(proj.GitRepo) |
||||||
|
if err != nil { |
||||||
|
return PayloadUnion{}, err |
||||||
|
} |
||||||
|
|
||||||
|
changedFiles, err := ChangedFilesBetweenTrees(headTree, stagedTree) |
||||||
|
if err != nil { |
||||||
|
return PayloadUnion{}, fmt.Errorf("calculating diff between HEAD and staged changes: %w", err) |
||||||
|
} |
||||||
|
|
||||||
|
payCh := PayloadChange{Description: description} |
||||||
|
fingerprint, err := payCh.Fingerprint(changedFiles) |
||||||
|
if err != nil { |
||||||
|
return PayloadUnion{}, err |
||||||
|
} |
||||||
|
|
||||||
|
return PayloadUnion{ |
||||||
|
Change: &payCh, |
||||||
|
Common: PayloadCommon{Fingerprint: fingerprint}, |
||||||
|
}, nil |
||||||
|
} |
||||||
|
|
||||||
|
// MessageHead implements the method for the Payload interface.
|
||||||
|
func (payCh PayloadChange) MessageHead(PayloadCommon) string { |
||||||
|
return payCh.Description |
||||||
|
} |
||||||
|
|
||||||
|
// Fingerprint implements the method for the Payload interface.
|
||||||
|
func (payCh PayloadChange) Fingerprint(changedFiles []ChangedFile) ([]byte, error) { |
||||||
|
return genChangeFingerprint(nil, payCh.Description, changedFiles), nil |
||||||
|
} |
||||||
|
|
||||||
|
// UnmarshalYAML implements the yaml.Unmarshaler interface.
|
||||||
|
func (payCh *PayloadChange) UnmarshalYAML(unmarshal func(interface{}) error) error { |
||||||
|
var wrap struct { |
||||||
|
Inner PayloadChange `yaml:",inline"` |
||||||
|
} |
||||||
|
if err := unmarshal(&wrap); err != nil { |
||||||
|
return err |
||||||
|
} |
||||||
|
*payCh = wrap.Inner |
||||||
|
if payCh.LegacyMessage != "" { |
||||||
|
payCh.Description = payCh.LegacyMessage |
||||||
|
payCh.LegacyMessage = "" |
||||||
|
} |
||||||
|
return nil |
||||||
|
} |
||||||
|
|
||||||
|
// CombinePayloadChanges takes all changes in the given range, combines them
|
||||||
|
// into a single PayloadChange, and commits it. The resulting payload will have
|
||||||
|
// the same message as the latest change payload in the range. If the
|
||||||
|
// fingerprint of the PayloadChange produced by this method has any matching
|
||||||
|
// Credentials in the range, those will be included in the payload as well.
|
||||||
|
//
|
||||||
|
// The combined commit is committed to the project with the given revision as
|
||||||
|
// its parent. If the diff across the given range and the diff from onto to the
|
||||||
|
// end of the range are different then this will return an error.
|
||||||
|
func (proj *Project) CombinePayloadChanges(commits []Commit, onto plumbing.ReferenceName) (Commit, error) { |
||||||
|
info, err := proj.changeRangeInfo(commits) |
||||||
|
if err != nil { |
||||||
|
return Commit{}, err |
||||||
|
} |
||||||
|
|
||||||
|
commitsFingerprint, err := info.changeFingerprint(info.changeDescription) |
||||||
|
if err != nil { |
||||||
|
return Commit{}, err |
||||||
|
} |
||||||
|
|
||||||
|
authors := make([]string, 0, len(info.authors)) |
||||||
|
for author := range info.authors { |
||||||
|
authors = append(authors, author) |
||||||
|
} |
||||||
|
sort.Strings(authors) |
||||||
|
|
||||||
|
ontoBranchName, err := proj.ReferenceToBranchName(onto) |
||||||
|
if err != nil { |
||||||
|
return Commit{}, fmt.Errorf("resolving %q into a branch name: %w", onto, err) |
||||||
|
} |
||||||
|
|
||||||
|
// now determine the change hash from onto->end, to ensure that it remains
|
||||||
|
// the same as from start->end
|
||||||
|
ontoCommit, err := proj.GetCommitByRevision(plumbing.Revision(onto)) |
||||||
|
if err != nil { |
||||||
|
return Commit{}, fmt.Errorf("resolving revision %q: %w", onto, err) |
||||||
|
} |
||||||
|
|
||||||
|
ontoEndChangedFiles, err := ChangedFilesBetweenTrees(ontoCommit.TreeObject, info.endTree) |
||||||
|
if err != nil { |
||||||
|
return Commit{}, fmt.Errorf("calculating file changes between %q and %q: %w", |
||||||
|
ontoCommit.Hash, commits[len(commits)-1].Hash, err) |
||||||
|
} |
||||||
|
|
||||||
|
ontoEndChangeFingerprint := genChangeFingerprint(nil, info.changeDescription, ontoEndChangedFiles) |
||||||
|
if !bytes.Equal(ontoEndChangeFingerprint, commitsFingerprint) { |
||||||
|
// TODO figure out what files to show as being the "problem files" in
|
||||||
|
// the error message
|
||||||
|
return Commit{}, fmt.Errorf("combining onto %q would produce a different change fingerprint, aborting combine", onto.Short()) |
||||||
|
} |
||||||
|
|
||||||
|
var creds []sigcred.CredentialUnion |
||||||
|
for _, commit := range commits { |
||||||
|
if bytes.Equal(commit.Payload.Common.Fingerprint, commitsFingerprint) { |
||||||
|
creds = append(creds, commit.Payload.Common.Credentials...) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// this is mostly to make tests easier
|
||||||
|
sort.Slice(creds, func(i, j int) bool { |
||||||
|
return creds[i].AccountID < creds[j].AccountID |
||||||
|
}) |
||||||
|
|
||||||
|
payUn := PayloadUnion{ |
||||||
|
Change: &PayloadChange{ |
||||||
|
Description: info.changeDescription, |
||||||
|
}, |
||||||
|
Common: PayloadCommon{ |
||||||
|
Fingerprint: commitsFingerprint, |
||||||
|
Credentials: creds, |
||||||
|
}, |
||||||
|
} |
||||||
|
|
||||||
|
commit, err := proj.CommitDirect(CommitDirectParams{ |
||||||
|
PayloadUnion: payUn, |
||||||
|
Author: strings.Join(authors, ","), |
||||||
|
ParentHash: ontoCommit.Hash, |
||||||
|
GitTree: info.endTree, |
||||||
|
}) |
||||||
|
if err != nil { |
||||||
|
return Commit{}, fmt.Errorf("storing commit: %w", err) |
||||||
|
} |
||||||
|
|
||||||
|
// set the onto branch to this new commit
|
||||||
|
newHeadRef := plumbing.NewHashReference(ontoBranchName, commit.Hash) |
||||||
|
if err := proj.GitRepo.Storer.SetReference(newHeadRef); err != nil { |
||||||
|
return Commit{}, fmt.Errorf("setting reference %q to new commit hash %q: %w", |
||||||
|
ontoBranchName, commit.Hash, err) |
||||||
|
} |
||||||
|
return commit, nil |
||||||
|
} |
@ -0,0 +1,183 @@ |
|||||||
|
package dehub |
||||||
|
|
||||||
|
import ( |
||||||
|
"reflect" |
||||||
|
"strings" |
||||||
|
"testing" |
||||||
|
|
||||||
|
"github.com/davecgh/go-spew/spew" |
||||||
|
"gopkg.in/src-d/go-git.v4/plumbing" |
||||||
|
) |
||||||
|
|
||||||
|
func TestPayloadChangeVerify(t *testing.T) { |
||||||
|
type step struct { |
||||||
|
descr string |
||||||
|
msgHead string // defaults to msg
|
||||||
|
tree map[string]string |
||||||
|
} |
||||||
|
testCases := []struct { |
||||||
|
descr string |
||||||
|
steps []step |
||||||
|
}{ |
||||||
|
{ |
||||||
|
descr: "single commit", |
||||||
|
steps: []step{ |
||||||
|
{ |
||||||
|
descr: "first commit", |
||||||
|
tree: map[string]string{"a": "0", "b": "1"}, |
||||||
|
}, |
||||||
|
}, |
||||||
|
}, |
||||||
|
{ |
||||||
|
descr: "multiple commits", |
||||||
|
steps: []step{ |
||||||
|
{ |
||||||
|
descr: "first commit", |
||||||
|
tree: map[string]string{"a": "0", "b": "1"}, |
||||||
|
}, |
||||||
|
{ |
||||||
|
descr: "second commit, changing a", |
||||||
|
tree: map[string]string{"a": "1"}, |
||||||
|
}, |
||||||
|
{ |
||||||
|
descr: "third commit, empty", |
||||||
|
}, |
||||||
|
{ |
||||||
|
descr: "fourth commit, adding c, removing b", |
||||||
|
tree: map[string]string{"b": "", "c": "2"}, |
||||||
|
}, |
||||||
|
}, |
||||||
|
}, |
||||||
|
{ |
||||||
|
descr: "big body commits", |
||||||
|
steps: []step{ |
||||||
|
{ |
||||||
|
descr: "first commit, single line but with newline\n", |
||||||
|
}, |
||||||
|
{ |
||||||
|
descr: "second commit, single line but with two newlines\n\n", |
||||||
|
msgHead: "second commit, single line but with two newlines\n\n", |
||||||
|
}, |
||||||
|
{ |
||||||
|
descr: "third commit, multi-line with one newline\nanother line!", |
||||||
|
msgHead: "third commit, multi-line with one newline\n\n", |
||||||
|
}, |
||||||
|
{ |
||||||
|
descr: "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) |
||||||
|
rootSig := h.stageNewAccount("root", false) |
||||||
|
|
||||||
|
for _, step := range test.steps { |
||||||
|
h.stage(step.tree) |
||||||
|
|
||||||
|
commit := h.assertCommitChange(verifyShouldSucceed, step.descr, rootSig) |
||||||
|
if step.msgHead == "" { |
||||||
|
step.msgHead = strings.TrimSpace(step.descr) + "\n\n" |
||||||
|
} |
||||||
|
|
||||||
|
if !strings.HasPrefix(commit.Object.Message, step.msgHead) { |
||||||
|
t.Fatalf("commit message %q does not start with expected head %q", |
||||||
|
commit.Object.Message, step.msgHead) |
||||||
|
} |
||||||
|
|
||||||
|
var payUn PayloadUnion |
||||||
|
if err := payUn.UnmarshalText([]byte(commit.Object.Message)); err != nil { |
||||||
|
t.Fatalf("error unmarshaling commit message: %v", err) |
||||||
|
} else if !reflect.DeepEqual(payUn, commit.Payload) { |
||||||
|
t.Fatalf("returned change payload:\n%s\ndoes not match actual one:\n%s", |
||||||
|
spew.Sdump(commit.Payload), spew.Sdump(payUn)) |
||||||
|
} |
||||||
|
} |
||||||
|
}) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
func TestCombinePayloadChanges(t *testing.T) { |
||||||
|
h := newHarness(t) |
||||||
|
|
||||||
|
// commit initial config, so the root user can modify it in the next commit
|
||||||
|
rootSig := h.stageNewAccount("root", false) |
||||||
|
h.assertCommitChange(verifyShouldSucceed, "initial commit", rootSig) |
||||||
|
|
||||||
|
// add a toot user and modify the access controls such that both accounts
|
||||||
|
// are required for the main branch
|
||||||
|
tootSig := h.stageNewAccount("toot", false) |
||||||
|
h.stageAccessControls(` |
||||||
|
- action: allow |
||||||
|
filters: |
||||||
|
- type: branch |
||||||
|
pattern: main |
||||||
|
- type: payload_type |
||||||
|
payload_type: change |
||||||
|
- type: signature |
||||||
|
any_account: true |
||||||
|
count: 2 |
||||||
|
|
||||||
|
- action: allow |
||||||
|
filters: |
||||||
|
- type: not |
||||||
|
filter: |
||||||
|
type: branch |
||||||
|
pattern: main |
||||||
|
- type: signature |
||||||
|
any_account: true |
||||||
|
count: 1 |
||||||
|
`) |
||||||
|
tootCommit := h.assertCommitChange(verifyShouldSucceed, "add toot", rootSig) |
||||||
|
|
||||||
|
// make a single change commit in another branch using root. Then add a
|
||||||
|
// credential using toot, and combine them onto main.
|
||||||
|
otherBranch := plumbing.NewBranchReferenceName("other") |
||||||
|
h.checkout(otherBranch) |
||||||
|
h.stage(map[string]string{"foo": "bar"}) |
||||||
|
fooCommit := h.assertCommitChange(verifyShouldSucceed, "add foo file", rootSig) |
||||||
|
|
||||||
|
// now adding a credential commit from toot should work
|
||||||
|
credCommitPayUn, err := h.proj.NewPayloadCredential(fooCommit.Payload.Common.Fingerprint) |
||||||
|
if err != nil { |
||||||
|
t.Fatal(err) |
||||||
|
} |
||||||
|
credCommit := h.tryCommit(verifyShouldSucceed, credCommitPayUn, tootSig) |
||||||
|
|
||||||
|
allCommits, err := h.proj.GetCommitRange(tootCommit.Hash, credCommit.Hash) |
||||||
|
if err != nil { |
||||||
|
t.Fatalf("getting commits: %v", err) |
||||||
|
} |
||||||
|
|
||||||
|
combinedCommit, err := h.proj.CombinePayloadChanges(allCommits, MainRefName) |
||||||
|
if err != nil { |
||||||
|
t.Fatal(err) |
||||||
|
} |
||||||
|
|
||||||
|
// that new commit should have both credentials
|
||||||
|
creds := combinedCommit.Payload.Common.Credentials |
||||||
|
if len(creds) != 2 { |
||||||
|
t.Fatalf("combined commit has %d credentials, not 2", len(creds)) |
||||||
|
} else if creds[0].AccountID != "root" { |
||||||
|
t.Fatalf("combined commit first credential should be from root, is from %q", creds[0].AccountID) |
||||||
|
} else if creds[1].AccountID != "toot" { |
||||||
|
t.Fatalf("combined commit second credential should be from toot, is from %q", creds[1].AccountID) |
||||||
|
} |
||||||
|
|
||||||
|
// double check that the HEAD commit of main got properly set
|
||||||
|
h.checkout(MainRefName) |
||||||
|
mainHead, err := h.proj.GetHeadCommit() |
||||||
|
if err != nil { |
||||||
|
t.Fatal(err) |
||||||
|
} else if mainHead.Hash != combinedCommit.Hash { |
||||||
|
t.Fatalf("mainHead's should be pointed at %s but is pointed at %s", |
||||||
|
combinedCommit.Hash, mainHead.Hash) |
||||||
|
} else if err = h.proj.VerifyCommits(MainRefName, []Commit{combinedCommit}); err != nil { |
||||||
|
t.Fatalf("unable to verify combined commit: %v", err) |
||||||
|
} else if author := combinedCommit.Object.Author.Name; author != "root" { |
||||||
|
t.Fatalf("unexpected author value %q", author) |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,39 @@ |
|||||||
|
package dehub |
||||||
|
|
||||||
|
import ( |
||||||
|
"errors" |
||||||
|
) |
||||||
|
|
||||||
|
// PayloadComment describes the structure of a comment payload.
|
||||||
|
type PayloadComment struct { |
||||||
|
Comment string `yaml:"comment"` |
||||||
|
} |
||||||
|
|
||||||
|
var _ Payload = PayloadComment{} |
||||||
|
|
||||||
|
// NewPayloadComment constructs a PayloadUnion populated with a PayloadComment.
|
||||||
|
// The Credentials of the returned PayloadUnion will _not_ be filled in.
|
||||||
|
func (proj *Project) NewPayloadComment(comment string) (PayloadUnion, error) { |
||||||
|
payCom := PayloadComment{Comment: comment} |
||||||
|
fingerprint, err := payCom.Fingerprint(nil) |
||||||
|
if err != nil { |
||||||
|
return PayloadUnion{}, err |
||||||
|
} |
||||||
|
return PayloadUnion{ |
||||||
|
Comment: &payCom, |
||||||
|
Common: PayloadCommon{Fingerprint: fingerprint}, |
||||||
|
}, nil |
||||||
|
} |
||||||
|
|
||||||
|
// MessageHead implements the method for the Payload interface.
|
||||||
|
func (payCom PayloadComment) MessageHead(common PayloadCommon) string { |
||||||
|
return `"` + payCom.Comment + `"` |
||||||
|
} |
||||||
|
|
||||||
|
// Fingerprint implements the method for the Payload interface.
|
||||||
|
func (payCom PayloadComment) Fingerprint(changes []ChangedFile) ([]byte, error) { |
||||||
|
if len(changes) > 0 { |
||||||
|
return nil, errors.New("PayloadComment cannot have any changed files") |
||||||
|
} |
||||||
|
return genCommentFingerprint(nil, payCom.Comment), nil |
||||||
|
} |
@ -0,0 +1,82 @@ |
|||||||
|
package dehub |
||||||
|
|
||||||
|
import ( |
||||||
|
"errors" |
||||||
|
) |
||||||
|
|
||||||
|
// PayloadCredential describes the structure of a credential payload.
|
||||||
|
type PayloadCredential struct { |
||||||
|
// CommitHashes represents the commits which this credential is accrediting.
|
||||||
|
// It is only present for informational purposes, as commits don't not have
|
||||||
|
// any bearing on the CredentialedHash itself.
|
||||||
|
CommitHashes []string `yaml:"commits,omitempty"` |
||||||
|
|
||||||
|
// ChangeDescription represents the description which has been credentialed.
|
||||||
|
// This field is only relevant if the Credential in the payload is for a
|
||||||
|
// change set.
|
||||||
|
ChangeDescription string `yaml:"change_description"` |
||||||
|
} |
||||||
|
|
||||||
|
var _ Payload = PayloadCredential{} |
||||||
|
|
||||||
|
// NewPayloadCredential constructs and returns a PayloadUnion populated with a
|
||||||
|
// PayloadCredential for the given fingerprint. The Credentials of the returned
|
||||||
|
// PayloadUnion will _not_ be filled in.
|
||||||
|
func (proj *Project) NewPayloadCredential(fingerprint []byte) (PayloadUnion, error) { |
||||||
|
return PayloadUnion{ |
||||||
|
Credential: &PayloadCredential{}, |
||||||
|
Common: PayloadCommon{Fingerprint: fingerprint}, |
||||||
|
}, nil |
||||||
|
} |
||||||
|
|
||||||
|
// NewPayloadCredentialFromChanges constructs and returns a PayloadUnion
|
||||||
|
// populated with a PayloadCredential. The fingerprint of the payload will be a
|
||||||
|
// change fingerprint generated from the given description and all changes in
|
||||||
|
// the given range of Commits.
|
||||||
|
//
|
||||||
|
// If an empty description is given then the description of the last change
|
||||||
|
// payload in the range is used when generating the fingerprint.
|
||||||
|
func (proj *Project) NewPayloadCredentialFromChanges(descr string, commits []Commit) (PayloadUnion, error) { |
||||||
|
info, err := proj.changeRangeInfo(commits) |
||||||
|
if err != nil { |
||||||
|
return PayloadUnion{}, err |
||||||
|
} |
||||||
|
|
||||||
|
if descr == "" { |
||||||
|
descr = info.changeDescription |
||||||
|
} |
||||||
|
fingerprint, err := info.changeFingerprint(descr) |
||||||
|
if err != nil { |
||||||
|
return PayloadUnion{}, err |
||||||
|
} |
||||||
|
|
||||||
|
payCred, err := proj.NewPayloadCredential(fingerprint) |
||||||
|
if err != nil { |
||||||
|
return PayloadUnion{}, err |
||||||
|
} |
||||||
|
|
||||||
|
payCred.Credential.ChangeDescription = descr |
||||||
|
for _, commit := range info.changeCommits { |
||||||
|
payCred.Credential.CommitHashes = append( |
||||||
|
payCred.Credential.CommitHashes, |
||||||
|
commit.Hash.String(), |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
return payCred, nil |
||||||
|
} |
||||||
|
|
||||||
|
// MessageHead implements the method for the Payload interface.
|
||||||
|
func (payCred PayloadCredential) MessageHead(common PayloadCommon) string { |
||||||
|
return "Credential of " + common.Fingerprint.String() |
||||||
|
} |
||||||
|
|
||||||
|
// Fingerprint implements the method for the Payload interface.
|
||||||
|
func (payCred PayloadCredential) Fingerprint(changes []ChangedFile) ([]byte, error) { |
||||||
|
if len(changes) > 0 { |
||||||
|
return nil, errors.New("PayloadCredential cannot have any changed files") |
||||||
|
} |
||||||
|
// a PayloadCredential can't compute its own fingerprint, it's stored in the
|
||||||
|
// common.
|
||||||
|
return nil, nil |
||||||
|
} |
@ -0,0 +1,50 @@ |
|||||||
|
package dehub |
||||||
|
|
||||||
|
import ( |
||||||
|
"testing" |
||||||
|
|
||||||
|
"gopkg.in/src-d/go-git.v4/plumbing" |
||||||
|
) |
||||||
|
|
||||||
|
func TestPayloadCredentialVerify(t *testing.T) { |
||||||
|
h := newHarness(t) |
||||||
|
rootSig := h.stageNewAccount("root", false) |
||||||
|
|
||||||
|
// create a new account and modify the config so that that account is only
|
||||||
|
// allowed to add verifications to a single branch
|
||||||
|
tootSig := h.stageNewAccount("toot", false) |
||||||
|
tootBranch := plumbing.NewBranchReferenceName("toot_branch") |
||||||
|
h.stageAccessControls(` |
||||||
|
- action: allow |
||||||
|
filters: |
||||||
|
- type: branch |
||||||
|
pattern: ` + tootBranch.Short() + ` |
||||||
|
- type: signature |
||||||
|
count: 1 |
||||||
|
account_ids: |
||||||
|
- root |
||||||
|
- toot |
||||||
|
|
||||||
|
- action: allow |
||||||
|
filters: |
||||||
|
- type: signature |
||||||
|
count: 1 |
||||||
|
account_ids: |
||||||
|
- root |
||||||
|
`) |
||||||
|
rootGitCommit := h.assertCommitChange(verifyShouldSucceed, "initial commit", rootSig) |
||||||
|
|
||||||
|
// toot user wants to create a credential commit for the root commit, for
|
||||||
|
// whatever reason.
|
||||||
|
rootChangeFingerprint := rootGitCommit.Payload.Common.Fingerprint |
||||||
|
credCommitPayUn, err := h.proj.NewPayloadCredential(rootChangeFingerprint) |
||||||
|
if err != nil { |
||||||
|
t.Fatalf("creating credential commit for fingerprint %x: %v", rootChangeFingerprint, err) |
||||||
|
|
||||||
|
} |
||||||
|
h.tryCommit(verifyShouldFail, credCommitPayUn, tootSig) |
||||||
|
|
||||||
|
// toot tries again in their own branch, and should be allowed.
|
||||||
|
h.checkout(tootBranch) |
||||||
|
h.tryCommit(verifyShouldSucceed, credCommitPayUn, tootSig) |
||||||
|
} |
@ -0,0 +1,452 @@ |
|||||||
|
package dehub |
||||||
|
|
||||||
|
import ( |
||||||
|
"errors" |
||||||
|
"regexp" |
||||||
|
"testing" |
||||||
|
|
||||||
|
"dehub.dev/src/dehub.git/accessctl" |
||||||
|
"dehub.dev/src/dehub.git/sigcred" |
||||||
|
"gopkg.in/src-d/go-git.v4" |
||||||
|
"gopkg.in/src-d/go-git.v4/plumbing" |
||||||
|
) |
||||||
|
|
||||||
|
func TestConfigChange(t *testing.T) { |
||||||
|
h := newHarness(t) |
||||||
|
rootSig := h.stageNewAccount("root", false) |
||||||
|
|
||||||
|
var commits []Commit |
||||||
|
|
||||||
|
// commit the initial staged changes, which merely include the config and
|
||||||
|
// public key
|
||||||
|
commit := h.assertCommitChange(verifyShouldSucceed, "commit configuration", rootSig) |
||||||
|
commits = append(commits, commit) |
||||||
|
|
||||||
|
// create a new account and add it to the configuration. That commit should
|
||||||
|
// not be verifiable, though
|
||||||
|
tootSig := h.stageNewAccount("toot", false) |
||||||
|
h.stageCfg() |
||||||
|
h.assertCommitChange(verifyShouldFail, "add toot user", tootSig) |
||||||
|
|
||||||
|
// now add with the root user, this should work.
|
||||||
|
h.stageCfg() |
||||||
|
commit = h.assertCommitChange(verifyShouldSucceed, "add toot user", rootSig) |
||||||
|
commits = append(commits, commit) |
||||||
|
|
||||||
|
// _now_ the toot user should be able to do things.
|
||||||
|
h.stage(map[string]string{"foo/bar": "what a cool file"}) |
||||||
|
commit = h.assertCommitChange(verifyShouldSucceed, "add a cool file", tootSig) |
||||||
|
commits = append(commits, commit) |
||||||
|
|
||||||
|
if err := h.proj.VerifyCommits(MainRefName, commits); err != nil { |
||||||
|
t.Fatal(err) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
func TestMainAncestryRequirement(t *testing.T) { |
||||||
|
otherBranch := plumbing.NewBranchReferenceName("other") |
||||||
|
t.Run("empty repo", func(t *testing.T) { |
||||||
|
h := newHarness(t) |
||||||
|
rootSig := h.stageNewAccount("root", false) |
||||||
|
h.checkout(otherBranch) |
||||||
|
|
||||||
|
// stage and try to add to the "other" branch, it shouldn't work though
|
||||||
|
h.stageCfg() |
||||||
|
h.assertCommitChange(verifyShouldFail, "starting new branch at other", rootSig) |
||||||
|
}) |
||||||
|
|
||||||
|
t.Run("new branch, single commit", func(t *testing.T) { |
||||||
|
h := newHarness(t) |
||||||
|
rootSig := h.stageNewAccount("root", false) |
||||||
|
h.assertCommitChange(verifyShouldSucceed, "add cfg", rootSig) |
||||||
|
|
||||||
|
// set HEAD to this other branch which doesn't really exist
|
||||||
|
ref := plumbing.NewSymbolicReference(plumbing.HEAD, otherBranch) |
||||||
|
if err := h.proj.GitRepo.Storer.SetReference(ref); err != nil { |
||||||
|
h.t.Fatal(err) |
||||||
|
} |
||||||
|
|
||||||
|
h.stageCfg() |
||||||
|
h.assertCommitChange(verifyShouldFail, "starting new branch at other", rootSig) |
||||||
|
}) |
||||||
|
} |
||||||
|
|
||||||
|
func TestAnonymousCommits(t *testing.T) { |
||||||
|
h := newHarness(t) |
||||||
|
anonSig := h.stageNewAccount("anon", true) |
||||||
|
|
||||||
|
h.stageAccessControls(` |
||||||
|
- action: allow |
||||||
|
filters: |
||||||
|
- type: signature |
||||||
|
any: true |
||||||
|
`) |
||||||
|
h.assertCommitChange(verifyShouldSucceed, "this will work", anonSig) |
||||||
|
} |
||||||
|
|
||||||
|
func TestNonFastForwardCommits(t *testing.T) { |
||||||
|
h := newHarness(t) |
||||||
|
rootSig := h.stageNewAccount("root", false) |
||||||
|
initCommit := h.assertCommitChange(verifyShouldSucceed, "init", rootSig) |
||||||
|
|
||||||
|
// add another commit
|
||||||
|
h.stage(map[string]string{"foo": "foo"}) |
||||||
|
fooCommit := h.assertCommitChange(verifyShouldSucceed, "foo", rootSig) |
||||||
|
|
||||||
|
commitOn := func(hash plumbing.Hash, msg string) Commit { |
||||||
|
ref := plumbing.NewHashReference(plumbing.HEAD, hash) |
||||||
|
if err := h.proj.GitRepo.Storer.SetReference(ref); err != nil { |
||||||
|
h.t.Fatal(err) |
||||||
|
} else if commitChange, err := h.proj.NewPayloadChange("bar"); err != nil { |
||||||
|
h.t.Fatal(err) |
||||||
|
} else if commitChange, err = h.proj.AccreditPayload(commitChange, rootSig); err != nil { |
||||||
|
h.t.Fatal(err) |
||||||
|
} else if gitCommit, err := h.proj.Commit(commitChange); err != nil { |
||||||
|
h.t.Fatal(err) |
||||||
|
} else { |
||||||
|
return gitCommit |
||||||
|
} |
||||||
|
panic("can't get here") |
||||||
|
} |
||||||
|
|
||||||
|
// checkout initCommit directly, make a new commit on top of it, and try to
|
||||||
|
// verify that (this is too fancy for the harness, must be done manually).
|
||||||
|
h.stage(map[string]string{"bar": "bar"}) |
||||||
|
barCommit := commitOn(initCommit.Hash, "bar") |
||||||
|
err := h.proj.VerifyCommits(MainRefName, []Commit{barCommit}) |
||||||
|
if !errors.As(err, new(accessctl.ErrCommitRequestDenied)) { |
||||||
|
h.t.Fatalf("expected ErrCommitRequestDenied, got: %v", err) |
||||||
|
} |
||||||
|
|
||||||
|
// check main back out (fooCommit should be checked out), and modify the
|
||||||
|
// config to allow nonFF commits, and add another bogus commit on top.
|
||||||
|
h.checkout(MainRefName) |
||||||
|
h.stageAccessControls(` |
||||||
|
- action: allow |
||||||
|
filters: |
||||||
|
- type: commit_attributes |
||||||
|
non_fast_forward: true`) |
||||||
|
h.stageCfg() |
||||||
|
allowNonFFCommit := h.assertCommitChange(verifyShouldSucceed, "allow non-ff", rootSig) |
||||||
|
|
||||||
|
h.stage(map[string]string{"foo": "foo foo"}) |
||||||
|
h.assertCommitChange(verifyShouldSucceed, "foo foo", rootSig) |
||||||
|
|
||||||
|
// checking out allowNonFFCommit directly and performing a nonFF commit
|
||||||
|
// should work now.
|
||||||
|
h.stage(map[string]string{"baz": "baz"}) |
||||||
|
bazCommit := commitOn(allowNonFFCommit.Hash, "baz") |
||||||
|
if err = h.proj.VerifyCommits(MainRefName, []Commit{bazCommit}); err != nil { |
||||||
|
h.t.Fatal(err) |
||||||
|
} |
||||||
|
|
||||||
|
// verifying the full history should also work
|
||||||
|
gitCommits := []Commit{initCommit, fooCommit, allowNonFFCommit, bazCommit} |
||||||
|
if err = h.proj.VerifyCommits(MainRefName, gitCommits); err != nil { |
||||||
|
h.t.Fatal(err) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
func TestVerifyCanSetBranchHEADTo(t *testing.T) { |
||||||
|
type toTest struct { |
||||||
|
// branchName and hash are the arguments passed into
|
||||||
|
// VerifyCanSetBranchHEADTo.
|
||||||
|
branchName plumbing.ReferenceName |
||||||
|
hash plumbing.Hash |
||||||
|
|
||||||
|
// if set then the branch will have its HEAD reset to this hash prior to
|
||||||
|
// calling VerifyCanSetBranchHEADTo.
|
||||||
|
resetTo plumbing.Hash |
||||||
|
} |
||||||
|
|
||||||
|
type test struct { |
||||||
|
descr string |
||||||
|
init func(h *harness, rootSig sigcred.Signifier) toTest |
||||||
|
|
||||||
|
// If true then the verify call is expected to fail. The string is a
|
||||||
|
// regex which should match the unwrapped error returned.
|
||||||
|
expErr string |
||||||
|
} |
||||||
|
|
||||||
|
tests := []test{ |
||||||
|
{ |
||||||
|
descr: "creation of main", |
||||||
|
init: func(h *harness, rootSig sigcred.Signifier) toTest { |
||||||
|
// checkout other and build on top of that, so that when
|
||||||
|
// VerifyCanSetBranchHEADTo is called main won't exist.
|
||||||
|
other := plumbing.NewBranchReferenceName("other") |
||||||
|
h.checkout(other) |
||||||
|
|
||||||
|
initCommit := h.assertCommitChange(verifySkip, "init", rootSig) |
||||||
|
return toTest{ |
||||||
|
branchName: MainRefName, |
||||||
|
hash: initCommit.Hash, |
||||||
|
} |
||||||
|
}, |
||||||
|
}, |
||||||
|
{ |
||||||
|
descr: "main ff", |
||||||
|
init: func(h *harness, rootSig sigcred.Signifier) toTest { |
||||||
|
initCommit := h.assertCommitChange(verifySkip, "init", rootSig) |
||||||
|
h.stage(map[string]string{"foo": "foo"}) |
||||||
|
nextCommit := h.assertCommitChange(verifySkip, "next", rootSig) |
||||||
|
return toTest{ |
||||||
|
branchName: MainRefName, |
||||||
|
hash: nextCommit.Hash, |
||||||
|
resetTo: initCommit.Hash, |
||||||
|
} |
||||||
|
}, |
||||||
|
}, |
||||||
|
{ |
||||||
|
descr: "new branch, no main", |
||||||
|
init: func(h *harness, rootSig sigcred.Signifier) toTest { |
||||||
|
// checkout other and build on top of that, so that when
|
||||||
|
// VerifyCanSetBranchHEADTo is called main won't exist.
|
||||||
|
other := plumbing.NewBranchReferenceName("other") |
||||||
|
h.checkout(other) |
||||||
|
|
||||||
|
initCommit := h.assertCommitChange(verifySkip, "init", rootSig) |
||||||
|
return toTest{ |
||||||
|
branchName: plumbing.NewBranchReferenceName("other2"), |
||||||
|
hash: initCommit.Hash, |
||||||
|
} |
||||||
|
}, |
||||||
|
expErr: `^cannot verify commits in branch "refs/heads/other2" when no main branch exists$`, |
||||||
|
}, |
||||||
|
{ |
||||||
|
// this case isn't generally possible, unless someone manually
|
||||||
|
// creates a branch in an empty repo on the remote
|
||||||
|
descr: "existing branch, no main", |
||||||
|
init: func(h *harness, rootSig sigcred.Signifier) toTest { |
||||||
|
// checkout other and build on top of that, so that when
|
||||||
|
// VerifyCanSetBranchHEADTo is called main won't exist.
|
||||||
|
other := plumbing.NewBranchReferenceName("other") |
||||||
|
h.checkout(other) |
||||||
|
|
||||||
|
initCommit := h.assertCommitChange(verifySkip, "init", rootSig) |
||||||
|
h.stage(map[string]string{"foo": "foo"}) |
||||||
|
fooCommit := h.assertCommitChange(verifySkip, "foo", rootSig) |
||||||
|
|
||||||
|
return toTest{ |
||||||
|
branchName: other, |
||||||
|
hash: fooCommit.Hash, |
||||||
|
resetTo: initCommit.Hash, |
||||||
|
} |
||||||
|
}, |
||||||
|
expErr: `^cannot verify commits in branch "refs/heads/other" when no main branch exists$`, |
||||||
|
}, |
||||||
|
{ |
||||||
|
descr: "new branch, not ancestor of main", |
||||||
|
init: func(h *harness, rootSig sigcred.Signifier) toTest { |
||||||
|
h.assertCommitChange(verifySkip, "init", rootSig) |
||||||
|
|
||||||
|
// create new branch with no HEAD, and commit on that.
|
||||||
|
other := plumbing.NewBranchReferenceName("other") |
||||||
|
ref := plumbing.NewSymbolicReference(plumbing.HEAD, other) |
||||||
|
if err := h.proj.GitRepo.Storer.SetReference(ref); err != nil { |
||||||
|
t.Fatal(err) |
||||||
|
} |
||||||
|
|
||||||
|
h.stageCfg() |
||||||
|
h.stage(map[string]string{"foo": "foo"}) |
||||||
|
badInitCommit := h.assertCommitChange(verifySkip, "a different init", rootSig) |
||||||
|
return toTest{ |
||||||
|
branchName: plumbing.NewBranchReferenceName("other2"), |
||||||
|
hash: badInitCommit.Hash, |
||||||
|
} |
||||||
|
}, |
||||||
|
expErr: `^commit "[0-9a-f]+" must be direct descendant of root commit of "main" \("[0-9a-f]+"\)$`, |
||||||
|
}, |
||||||
|
{ |
||||||
|
// this case isn't generally possible, unless someone manually
|
||||||
|
// creates a branch in an empty repo on the remote
|
||||||
|
descr: "existing branch, not ancestor of main", |
||||||
|
init: func(h *harness, rootSig sigcred.Signifier) toTest { |
||||||
|
h.assertCommitChange(verifySkip, "init", rootSig) |
||||||
|
|
||||||
|
// create new branch with no HEAD, and commit on that.
|
||||||
|
other := plumbing.NewBranchReferenceName("other") |
||||||
|
ref := plumbing.NewSymbolicReference(plumbing.HEAD, other) |
||||||
|
if err := h.proj.GitRepo.Storer.SetReference(ref); err != nil { |
||||||
|
t.Fatal(err) |
||||||
|
} |
||||||
|
|
||||||
|
h.stageCfg() |
||||||
|
h.stage(map[string]string{"foo": "foo"}) |
||||||
|
badInitCommit := h.assertCommitChange(verifySkip, "a different init", rootSig) |
||||||
|
|
||||||
|
h.stage(map[string]string{"bar": "bar"}) |
||||||
|
barCommit := h.assertCommitChange(verifySkip, "bar", rootSig) |
||||||
|
|
||||||
|
return toTest{ |
||||||
|
branchName: other, |
||||||
|
hash: barCommit.Hash, |
||||||
|
resetTo: badInitCommit.Hash, |
||||||
|
} |
||||||
|
}, |
||||||
|
expErr: `^commit "[0-9a-f]+" must be direct descendant of root commit of "main" \("[0-9a-f]+"\)$`, |
||||||
|
}, |
||||||
|
{ |
||||||
|
descr: "new branch off of main", |
||||||
|
init: func(h *harness, rootSig sigcred.Signifier) toTest { |
||||||
|
initCommit := h.assertCommitChange(verifySkip, "init", rootSig) |
||||||
|
other := plumbing.NewBranchReferenceName("other") |
||||||
|
|
||||||
|
h.checkout(other) |
||||||
|
h.stage(map[string]string{"foo": "foo"}) |
||||||
|
fooCommit := h.assertCommitChange(verifySkip, "foo", rootSig) |
||||||
|
|
||||||
|
return toTest{ |
||||||
|
branchName: other, |
||||||
|
hash: fooCommit.Hash, |
||||||
|
resetTo: initCommit.Hash, |
||||||
|
} |
||||||
|
}, |
||||||
|
}, |
||||||
|
{ |
||||||
|
descr: "new branch off of older main commit", |
||||||
|
init: func(h *harness, rootSig sigcred.Signifier) toTest { |
||||||
|
initCommit := h.assertCommitChange(verifySkip, "init", rootSig) |
||||||
|
|
||||||
|
h.stage(map[string]string{"foo": "foo"}) |
||||||
|
h.assertCommitChange(verifySkip, "foo", rootSig) |
||||||
|
|
||||||
|
other := plumbing.NewBranchReferenceName("other") |
||||||
|
h.checkout(other) |
||||||
|
h.reset(initCommit.Hash, git.HardReset) |
||||||
|
h.stage(map[string]string{"bar": "bar"}) |
||||||
|
barCommit := h.assertCommitChange(verifySkip, "bar", rootSig) |
||||||
|
|
||||||
|
return toTest{ |
||||||
|
branchName: other, |
||||||
|
hash: barCommit.Hash, |
||||||
|
resetTo: initCommit.Hash, |
||||||
|
} |
||||||
|
}, |
||||||
|
}, |
||||||
|
{ |
||||||
|
descr: "branch ff", |
||||||
|
init: func(h *harness, rootSig sigcred.Signifier) toTest { |
||||||
|
h.assertCommitChange(verifySkip, "init", rootSig) |
||||||
|
|
||||||
|
other := plumbing.NewBranchReferenceName("other") |
||||||
|
h.checkout(other) |
||||||
|
|
||||||
|
var commits []Commit |
||||||
|
for _, str := range []string{"foo", "bar", "baz", "biz", "buz"} { |
||||||
|
h.stage(map[string]string{str: str}) |
||||||
|
commit := h.assertCommitChange(verifySkip, str, rootSig) |
||||||
|
commits = append(commits, commit) |
||||||
|
} |
||||||
|
|
||||||
|
return toTest{ |
||||||
|
branchName: other, |
||||||
|
hash: commits[len(commits)-1].Hash, |
||||||
|
resetTo: commits[0].Hash, |
||||||
|
} |
||||||
|
}, |
||||||
|
}, |
||||||
|
{ |
||||||
|
descr: "main nonff", |
||||||
|
init: func(h *harness, rootSig sigcred.Signifier) toTest { |
||||||
|
initCommit := h.assertCommitChange(verifySkip, "init", rootSig) |
||||||
|
h.stage(map[string]string{"foo": "foo"}) |
||||||
|
h.assertCommitChange(verifySkip, "foo", rootSig) |
||||||
|
|
||||||
|
// start another branch back at init and make a new commit on it
|
||||||
|
other := plumbing.NewBranchReferenceName("other") |
||||||
|
h.checkout(other) |
||||||
|
h.reset(initCommit.Hash, git.HardReset) |
||||||
|
h.stage(map[string]string{"bar": "bar"}) |
||||||
|
barCommit := h.assertCommitChange(verifySkip, "bar", rootSig) |
||||||
|
|
||||||
|
return toTest{ |
||||||
|
branchName: MainRefName, |
||||||
|
hash: barCommit.Hash, |
||||||
|
} |
||||||
|
}, |
||||||
|
expErr: `^commit matched and denied by this access control:`, |
||||||
|
}, |
||||||
|
{ |
||||||
|
descr: "branch nonff", |
||||||
|
init: func(h *harness, rootSig sigcred.Signifier) toTest { |
||||||
|
h.assertCommitChange(verifySkip, "init", rootSig) |
||||||
|
|
||||||
|
other := plumbing.NewBranchReferenceName("other") |
||||||
|
h.checkout(other) |
||||||
|
h.stage(map[string]string{"foo": "foo"}) |
||||||
|
fooCommit := h.assertCommitChange(verifySkip, "foo", rootSig) |
||||||
|
h.stage(map[string]string{"bar": "bar"}) |
||||||
|
h.assertCommitChange(verifySkip, "bar", rootSig) |
||||||
|
|
||||||
|
other2 := plumbing.NewBranchReferenceName("other2") |
||||||
|
h.checkout(other2) |
||||||
|
h.reset(fooCommit.Hash, git.HardReset) |
||||||
|
h.stage(map[string]string{"baz": "baz"}) |
||||||
|
bazCommit := h.assertCommitChange(verifySkip, "baz", rootSig) |
||||||
|
|
||||||
|
return toTest{ |
||||||
|
branchName: other, |
||||||
|
hash: bazCommit.Hash, |
||||||
|
} |
||||||
|
}, |
||||||
|
}, |
||||||
|
{ |
||||||
|
descr: "branch nonff to previous commit", |
||||||
|
init: func(h *harness, rootSig sigcred.Signifier) toTest { |
||||||
|
h.assertCommitChange(verifySkip, "init", rootSig) |
||||||
|
|
||||||
|
other := plumbing.NewBranchReferenceName("other") |
||||||
|
h.checkout(other) |
||||||
|
h.stage(map[string]string{"foo": "foo"}) |
||||||
|
fooCommit := h.assertCommitChange(verifySkip, "foo", rootSig) |
||||||
|
h.stage(map[string]string{"bar": "bar"}) |
||||||
|
h.assertCommitChange(verifySkip, "bar", rootSig) |
||||||
|
|
||||||
|
return toTest{ |
||||||
|
branchName: other, |
||||||
|
hash: fooCommit.Hash, |
||||||
|
} |
||||||
|
}, |
||||||
|
}, |
||||||
|
} |
||||||
|
|
||||||
|
for _, test := range tests { |
||||||
|
t.Run(test.descr, func(t *testing.T) { |
||||||
|
h := newHarness(t) |
||||||
|
rootSig := h.stageNewAccount("root", false) |
||||||
|
toTest := test.init(h, rootSig) |
||||||
|
|
||||||
|
if toTest.resetTo != plumbing.ZeroHash { |
||||||
|
ref := plumbing.NewHashReference(toTest.branchName, toTest.resetTo) |
||||||
|
if err := h.proj.GitRepo.Storer.SetReference(ref); err != nil { |
||||||
|
t.Fatal(err) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
err := h.proj.VerifyCanSetBranchHEADTo(toTest.branchName, toTest.hash) |
||||||
|
if test.expErr == "" { |
||||||
|
if err != nil { |
||||||
|
t.Fatalf("unexpected error: %v", err) |
||||||
|
} |
||||||
|
return |
||||||
|
} else if err == nil { |
||||||
|
t.Fatal("expected verification to fail") |
||||||
|
} |
||||||
|
|
||||||
|
ogErr := err |
||||||
|
for { |
||||||
|
if unwrappedErr := errors.Unwrap(err); unwrappedErr != nil { |
||||||
|
err = unwrappedErr |
||||||
|
} else { |
||||||
|
break |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
errRegex := regexp.MustCompile(test.expErr) |
||||||
|
if !errRegex.MatchString(err.Error()) { |
||||||
|
t.Fatalf("\nexpected error of form %q\nbut got: %v", test.expErr, ogErr) |
||||||
|
} |
||||||
|
}) |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,326 @@ |
|||||||
|
// Package dehub TODO needs package docs
|
||||||
|
package dehub |
||||||
|
|
||||||
|
import ( |
||||||
|
"bytes" |
||||||
|
"errors" |
||||||
|
"fmt" |
||||||
|
"io" |
||||||
|
"os" |
||||||
|
"path/filepath" |
||||||
|
|
||||||
|
"dehub.dev/src/dehub.git/fs" |
||||||
|
|
||||||
|
"gopkg.in/src-d/go-billy.v4" |
||||||
|
"gopkg.in/src-d/go-billy.v4/memfs" |
||||||
|
"gopkg.in/src-d/go-git.v4" |
||||||
|
"gopkg.in/src-d/go-git.v4/plumbing" |
||||||
|
"gopkg.in/src-d/go-git.v4/plumbing/cache" |
||||||
|
"gopkg.in/src-d/go-git.v4/plumbing/format/config" |
||||||
|
"gopkg.in/src-d/go-git.v4/storage" |
||||||
|
"gopkg.in/src-d/go-git.v4/storage/filesystem" |
||||||
|
) |
||||||
|
|
||||||
|
const ( |
||||||
|
// DehubDir defines the name of the directory where all dehub-related files
|
||||||
|
// are expected to be found within the git repo.
|
||||||
|
DehubDir = ".dehub" |
||||||
|
) |
||||||
|
|
||||||
|
var ( |
||||||
|
// ConfigPath defines the expected path to the Project's configuration file.
|
||||||
|
ConfigPath = filepath.Join(DehubDir, "config.yml") |
||||||
|
|
||||||
|
// Main defines the name of the main branch.
|
||||||
|
Main = "main" |
||||||
|
|
||||||
|
// MainRefName defines the reference name of the main branch.
|
||||||
|
MainRefName = plumbing.NewBranchReferenceName(Main) |
||||||
|
) |
||||||
|
|
||||||
|
type openOpts struct { |
||||||
|
bare bool |
||||||
|
} |
||||||
|
|
||||||
|
// OpenOption is an option which can be passed to the OpenProject function to
|
||||||
|
// affect the Project's behavior.
|
||||||
|
type OpenOption func(*openOpts) |
||||||
|
|
||||||
|
// OpenBareRepo returns an OpenOption which, if true is given, causes the
|
||||||
|
// OpenProject function to expect to open a bare git repo.
|
||||||
|
func OpenBareRepo(bare bool) OpenOption { |
||||||
|
return func(o *openOpts) { |
||||||
|
o.bare = bare |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Project implements accessing and modifying a local dehub project, as well as
|
||||||
|
// extending the functionality of the underlying git repo in ways which are
|
||||||
|
// specifically useful for dehub projects.
|
||||||
|
type Project struct { |
||||||
|
// GitRepo is the git repository which houses the project.
|
||||||
|
GitRepo *git.Repository |
||||||
|
|
||||||
|
// GitDirFS corresponds to the .git directory (or the entire repo directory
|
||||||
|
// if it's a bare repo)
|
||||||
|
GitDirFS billy.Filesystem |
||||||
|
} |
||||||
|
|
||||||
|
func extractGitDirFS(storer storage.Storer) (billy.Filesystem, error) { |
||||||
|
dotGitFSer, ok := storer.(interface{ Filesystem() billy.Filesystem }) |
||||||
|
if !ok { |
||||||
|
return nil, fmt.Errorf("git storage object of type %T does not expose its underlying filesystem", |
||||||
|
storer) |
||||||
|
} |
||||||
|
return dotGitFSer.Filesystem(), nil |
||||||
|
} |
||||||
|
|
||||||
|
// OpenProject opens the dehub project in the given directory and returns a
|
||||||
|
// Project instance for it.
|
||||||
|
//
|
||||||
|
// The given path is expected to have a git repo already initialized.
|
||||||
|
func OpenProject(path string, options ...OpenOption) (*Project, error) { |
||||||
|
var opts openOpts |
||||||
|
for _, opt := range options { |
||||||
|
opt(&opts) |
||||||
|
} |
||||||
|
|
||||||
|
proj := Project{} |
||||||
|
var err error |
||||||
|
openOpts := &git.PlainOpenOptions{ |
||||||
|
DetectDotGit: !opts.bare, |
||||||
|
} |
||||||
|
if proj.GitRepo, err = git.PlainOpenWithOptions(path, openOpts); err != nil { |
||||||
|
return nil, fmt.Errorf("opening git repo: %w", err) |
||||||
|
|
||||||
|
} else if proj.GitDirFS, err = extractGitDirFS(proj.GitRepo.Storer); err != nil { |
||||||
|
return nil, err |
||||||
|
} |
||||||
|
|
||||||
|
return &proj, nil |
||||||
|
} |
||||||
|
|
||||||
|
type initOpts struct { |
||||||
|
bare bool |
||||||
|
remote bool |
||||||
|
} |
||||||
|
|
||||||
|
// InitOption is an option which can be passed into the Init functions to affect
|
||||||
|
// their behavior.
|
||||||
|
type InitOption func(*initOpts) |
||||||
|
|
||||||
|
// InitBareRepo returns an InitOption which, if true is given, causes the Init
|
||||||
|
// function to initialize the project's git repo without a worktree.
|
||||||
|
func InitBareRepo(bare bool) InitOption { |
||||||
|
return func(o *initOpts) { |
||||||
|
o.bare = bare |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// InitRemoteRepo returns an InitOption which, if true is given, causes the Init
|
||||||
|
// function to initialize the project's git repo with certain git configuration
|
||||||
|
// options set which make the repo able to be used as a remote repo.
|
||||||
|
func InitRemoteRepo(remote bool) InitOption { |
||||||
|
return func(o *initOpts) { |
||||||
|
o.remote = remote |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// InitProject will initialize a new project at the given path. If bare is true
|
||||||
|
// then the project's git repo will not have a worktree.
|
||||||
|
func InitProject(path string, options ...InitOption) (*Project, error) { |
||||||
|
var opts initOpts |
||||||
|
for _, opt := range options { |
||||||
|
opt(&opts) |
||||||
|
} |
||||||
|
|
||||||
|
var proj Project |
||||||
|
var err error |
||||||
|
if proj.GitRepo, err = git.PlainInit(path, opts.bare); err != nil { |
||||||
|
return nil, fmt.Errorf("initializing git repo: %w", err) |
||||||
|
|
||||||
|
} else if proj.GitDirFS, err = extractGitDirFS(proj.GitRepo.Storer); err != nil { |
||||||
|
return nil, err |
||||||
|
|
||||||
|
} else if err = proj.init(opts); err != nil { |
||||||
|
return nil, fmt.Errorf("initializing repo with dehub defaults: %w", err) |
||||||
|
} |
||||||
|
|
||||||
|
return &proj, nil |
||||||
|
} |
||||||
|
|
||||||
|
// InitMemProject initializes an empty project which only exists in memory.
|
||||||
|
func InitMemProject(options ...InitOption) *Project { |
||||||
|
var opts initOpts |
||||||
|
for _, opt := range options { |
||||||
|
opt(&opts) |
||||||
|
} |
||||||
|
|
||||||
|
fs := memfs.New() |
||||||
|
dotGitFS, err := fs.Chroot(git.GitDirName) |
||||||
|
if err != nil { |
||||||
|
panic(err) |
||||||
|
} |
||||||
|
storage := filesystem.NewStorage(dotGitFS, cache.NewObjectLRUDefault()) |
||||||
|
|
||||||
|
var worktree billy.Filesystem |
||||||
|
if !opts.bare { |
||||||
|
worktree = fs |
||||||
|
} |
||||||
|
|
||||||
|
r, err := git.Init(storage, worktree) |
||||||
|
if err != nil { |
||||||
|
panic(err) |
||||||
|
} |
||||||
|
|
||||||
|
proj := &Project{GitRepo: r, GitDirFS: dotGitFS} |
||||||
|
if err := proj.init(opts); err != nil { |
||||||
|
panic(err) |
||||||
|
} |
||||||
|
return proj |
||||||
|
} |
||||||
|
|
||||||
|
func (proj *Project) initRemotePreReceive(bare bool) error { |
||||||
|
if err := proj.GitDirFS.MkdirAll("hooks", 0755); err != nil { |
||||||
|
return fmt.Errorf("creating hooks directory: %w", err) |
||||||
|
} |
||||||
|
|
||||||
|
preRcvFlags := os.O_WRONLY | os.O_CREATE | os.O_TRUNC |
||||||
|
preRcv, err := proj.GitDirFS.OpenFile("hooks/pre-receive", preRcvFlags, 0755) |
||||||
|
if err != nil { |
||||||
|
return fmt.Errorf("opening hooks/pre-receive file: %w", err) |
||||||
|
} |
||||||
|
defer preRcv.Close() |
||||||
|
|
||||||
|
var preRcvBody string |
||||||
|
if bare { |
||||||
|
preRcvBody = "#!/bin/sh\nexec dehub hook -bare pre-receive\n" |
||||||
|
} else { |
||||||
|
preRcvBody = "#!/bin/sh\nexec dehub hook pre-receive\n" |
||||||
|
} |
||||||
|
|
||||||
|
if _, err := io.Copy(preRcv, bytes.NewBufferString(preRcvBody)); err != nil { |
||||||
|
return fmt.Errorf("writing to hooks/pre-receive: %w", err) |
||||||
|
} |
||||||
|
return nil |
||||||
|
} |
||||||
|
|
||||||
|
func (proj *Project) init(opts initOpts) error { |
||||||
|
headRef := plumbing.NewSymbolicReference(plumbing.HEAD, MainRefName) |
||||||
|
if err := proj.GitRepo.Storer.SetReference(headRef); err != nil { |
||||||
|
return fmt.Errorf("setting HEAD reference to %q: %w", MainRefName, err) |
||||||
|
} |
||||||
|
|
||||||
|
if opts.remote { |
||||||
|
cfg, err := proj.GitRepo.Config() |
||||||
|
if err != nil { |
||||||
|
return fmt.Errorf("opening git cfg: %w", err) |
||||||
|
} |
||||||
|
|
||||||
|
cfg.Raw = cfg.Raw.AddOption("http", config.NoSubsection, "receivepack", "true") |
||||||
|
if err := proj.GitRepo.Storer.SetConfig(cfg); err != nil { |
||||||
|
return fmt.Errorf("storing modified git config: %w", err) |
||||||
|
} |
||||||
|
|
||||||
|
if err := proj.initRemotePreReceive(opts.bare); err != nil { |
||||||
|
return fmt.Errorf("initializing pre-receive hook for remote-enabled repo: %w", err) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return nil |
||||||
|
} |
||||||
|
|
||||||
|
func (proj *Project) billyFilesystem() (billy.Filesystem, error) { |
||||||
|
w, err := proj.GitRepo.Worktree() |
||||||
|
if err != nil { |
||||||
|
return nil, fmt.Errorf("opening git worktree: %w", err) |
||||||
|
} |
||||||
|
return w.Filesystem, nil |
||||||
|
} |
||||||
|
|
||||||
|
var errTraverseRefNoMatch = errors.New("failed to find reference matching given predicate") |
||||||
|
|
||||||
|
// TraverseReferenceChain resolves a chain of references, calling the given
|
||||||
|
// predicate on each one, and returning the first one for which the predicate
|
||||||
|
// returns true. This method will return an error if it reaches the end of the
|
||||||
|
// chain and the predicate still has not returned true.
|
||||||
|
//
|
||||||
|
// If a reference name is encountered which does not actually exist, then it is
|
||||||
|
// assumed to be a hash reference to the zero hash.
|
||||||
|
func (proj *Project) TraverseReferenceChain(refName plumbing.ReferenceName, pred func(*plumbing.Reference) bool) (*plumbing.Reference, error) { |
||||||
|
// TODO infinite loop checking
|
||||||
|
// TODO check that this (and the methods which use it) are actually useful
|
||||||
|
for { |
||||||
|
ref, err := proj.GitRepo.Storer.Reference(refName) |
||||||
|
if errors.Is(err, plumbing.ErrReferenceNotFound) { |
||||||
|
ref = plumbing.NewHashReference(refName, plumbing.ZeroHash) |
||||||
|
} else if err != nil { |
||||||
|
return nil, fmt.Errorf("resolving reference %q: %w", refName, err) |
||||||
|
} |
||||||
|
|
||||||
|
if pred(ref) { |
||||||
|
return ref, nil |
||||||
|
} else if ref.Type() != plumbing.SymbolicReference { |
||||||
|
return nil, errTraverseRefNoMatch |
||||||
|
} |
||||||
|
refName = ref.Target() |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// ErrNoBranchReference is returned from ReferenceToBranchName if no reference
|
||||||
|
// in the reference chain is for a branch.
|
||||||
|
var ErrNoBranchReference = errors.New("no branch reference found") |
||||||
|
|
||||||
|
// ReferenceToBranchName traverses a chain of references looking for the first
|
||||||
|
// branch reference, and returns that name, or returns ErrNoBranchReference if
|
||||||
|
// no branch reference is part of the chain.
|
||||||
|
func (proj *Project) ReferenceToBranchName(refName plumbing.ReferenceName) (plumbing.ReferenceName, error) { |
||||||
|
// first check if the given refName is a branch, if so just return that.
|
||||||
|
if refName.IsBranch() { |
||||||
|
return refName, nil |
||||||
|
} |
||||||
|
|
||||||
|
ref, err := proj.TraverseReferenceChain(refName, func(ref *plumbing.Reference) bool { |
||||||
|
return ref.Target().IsBranch() |
||||||
|
}) |
||||||
|
if errors.Is(err, errTraverseRefNoMatch) { |
||||||
|
return "", ErrNoBranchReference |
||||||
|
} else if err != nil { |
||||||
|
return "", fmt.Errorf("traversing reference chain: %w", err) |
||||||
|
} |
||||||
|
return ref.Target(), nil |
||||||
|
} |
||||||
|
|
||||||
|
// ReferenceToHash fully resolves a reference to a hash. If a reference cannot
|
||||||
|
// be resolved then plumbing.ZeroHash is returned.
|
||||||
|
func (proj *Project) ReferenceToHash(refName plumbing.ReferenceName) (plumbing.Hash, error) { |
||||||
|
ref, err := proj.TraverseReferenceChain(refName, func(ref *plumbing.Reference) bool { |
||||||
|
return ref.Type() == plumbing.HashReference |
||||||
|
}) |
||||||
|
if errors.Is(err, errTraverseRefNoMatch) { |
||||||
|
return plumbing.ZeroHash, errors.New("no hash in reference chain (is this even possible???)") |
||||||
|
} else if errors.Is(err, plumbing.ErrReferenceNotFound) { |
||||||
|
return plumbing.ZeroHash, nil |
||||||
|
} else if err != nil { |
||||||
|
return plumbing.ZeroHash, fmt.Errorf("traversing reference chain: %w", err) |
||||||
|
} |
||||||
|
return ref.Hash(), nil |
||||||
|
} |
||||||
|
|
||||||
|
// headFS returns an FS based on the HEAD commit, or if there is no HEAD commit
|
||||||
|
// (it's an empty repo) an FS based on the raw filesystem.
|
||||||
|
func (proj *Project) headFS() (fs.FS, error) { |
||||||
|
head, err := proj.GetHeadCommit() |
||||||
|
if errors.Is(err, ErrHeadIsZero) { |
||||||
|
bfs, err := proj.billyFilesystem() |
||||||
|
if err != nil { |
||||||
|
return nil, fmt.Errorf("getting underlying filesystem: %w", err) |
||||||
|
} |
||||||
|
return fs.FromBillyFilesystem(bfs), nil |
||||||
|
|
||||||
|
} else if err != nil { |
||||||
|
return nil, fmt.Errorf("could not get HEAD tree: %w", err) |
||||||
|
} |
||||||
|
|
||||||
|
return fs.FromTree(head.TreeObject), nil |
||||||
|
} |
@ -0,0 +1,289 @@ |
|||||||
|
package dehub |
||||||
|
|
||||||
|
import ( |
||||||
|
"bytes" |
||||||
|
"errors" |
||||||
|
"io" |
||||||
|
"math/rand" |
||||||
|
"path/filepath" |
||||||
|
"testing" |
||||||
|
|
||||||
|
"dehub.dev/src/dehub.git/sigcred" |
||||||
|
|
||||||
|
"gopkg.in/src-d/go-git.v4" |
||||||
|
"gopkg.in/src-d/go-git.v4/plumbing" |
||||||
|
yaml "gopkg.in/yaml.v2" |
||||||
|
) |
||||||
|
|
||||||
|
type harness struct { |
||||||
|
t *testing.T |
||||||
|
rand *rand.Rand |
||||||
|
proj *Project |
||||||
|
cfg *Config |
||||||
|
} |
||||||
|
|
||||||
|
func newHarness(t *testing.T) *harness { |
||||||
|
rand := rand.New(rand.NewSource(0xb4eadb01)) |
||||||
|
return &harness{ |
||||||
|
t: t, |
||||||
|
rand: rand, |
||||||
|
proj: InitMemProject(), |
||||||
|
cfg: new(Config), |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
func (h *harness) stage(tree map[string]string) { |
||||||
|
w, err := h.proj.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("removing %q: %v", path, err) |
||||||
|
} |
||||||
|
continue |
||||||
|
} |
||||||
|
|
||||||
|
dir := filepath.Dir(path) |
||||||
|
if err := fs.MkdirAll(dir, 0666); err != nil { |
||||||
|
h.t.Fatalf("making directory %q: %v", dir, err) |
||||||
|
} |
||||||
|
|
||||||
|
f, err := fs.Create(path) |
||||||
|
if err != nil { |
||||||
|
h.t.Fatalf("creating file %q: %v", path, err) |
||||||
|
|
||||||
|
} else if _, err := io.Copy(f, bytes.NewBufferString(content)); err != nil { |
||||||
|
h.t.Fatalf("writing to file %q: %v", path, err) |
||||||
|
|
||||||
|
} else if err := f.Close(); err != nil { |
||||||
|
h.t.Fatalf("closing file %q: %v", path, err) |
||||||
|
|
||||||
|
} else if _, err := w.Add(path); err != nil { |
||||||
|
h.t.Fatalf("adding file %q to index: %v", path, err) |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
func (h *harness) stageCfg() { |
||||||
|
cfgBody, err := yaml.Marshal(h.cfg) |
||||||
|
if err != nil { |
||||||
|
h.t.Fatal(err) |
||||||
|
} |
||||||
|
h.stage(map[string]string{ConfigPath: string(cfgBody)}) |
||||||
|
} |
||||||
|
|
||||||
|
func (h *harness) stageNewAccount(accountID string, anon bool) sigcred.Signifier { |
||||||
|
sig, pubKeyBody := sigcred.TestSignifierPGP(accountID, anon, h.rand) |
||||||
|
if !anon { |
||||||
|
h.cfg.Accounts = append(h.cfg.Accounts, Account{ |
||||||
|
ID: accountID, |
||||||
|
Signifiers: []sigcred.SignifierUnion{{PGPPublicKey: &sigcred.SignifierPGP{ |
||||||
|
Body: string(pubKeyBody), |
||||||
|
}}}, |
||||||
|
}) |
||||||
|
h.stageCfg() |
||||||
|
} |
||||||
|
return sig |
||||||
|
} |
||||||
|
|
||||||
|
func (h *harness) stageAccessControls(aclYAML string) { |
||||||
|
if err := yaml.Unmarshal([]byte(aclYAML), &h.cfg.AccessControls); err != nil { |
||||||
|
h.t.Fatal(err) |
||||||
|
} |
||||||
|
h.stageCfg() |
||||||
|
} |
||||||
|
|
||||||
|
func (h *harness) checkout(branch plumbing.ReferenceName) { |
||||||
|
w, err := h.proj.GitRepo.Worktree() |
||||||
|
if err != nil { |
||||||
|
h.t.Fatal(err) |
||||||
|
} |
||||||
|
|
||||||
|
head, err := h.proj.GetHeadCommit() |
||||||
|
if errors.Is(err, ErrHeadIsZero) { |
||||||
|
// if HEAD is not resolvable to any hash than the Checkout method
|
||||||
|
// doesn't work, just set HEAD manually.
|
||||||
|
ref := plumbing.NewSymbolicReference(plumbing.HEAD, branch) |
||||||
|
if err := h.proj.GitRepo.Storer.SetReference(ref); err != nil { |
||||||
|
h.t.Fatal(err) |
||||||
|
} |
||||||
|
return |
||||||
|
} else if err != nil { |
||||||
|
h.t.Fatal(err) |
||||||
|
} |
||||||
|
|
||||||
|
_, err = h.proj.GitRepo.Storer.Reference(branch) |
||||||
|
if errors.Is(err, plumbing.ErrReferenceNotFound) { |
||||||
|
err = w.Checkout(&git.CheckoutOptions{ |
||||||
|
Hash: head.Hash, |
||||||
|
Branch: branch, |
||||||
|
Create: true, |
||||||
|
}) |
||||||
|
} else if err != nil { |
||||||
|
h.t.Fatalf("checking if branch already exists: %v", branch) |
||||||
|
} else { |
||||||
|
err = w.Checkout(&git.CheckoutOptions{ |
||||||
|
Branch: branch, |
||||||
|
}) |
||||||
|
} |
||||||
|
|
||||||
|
if err != nil { |
||||||
|
h.t.Fatalf("checking out branch: %v", err) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
func (h *harness) reset(to plumbing.Hash, mode git.ResetMode) { |
||||||
|
w, err := h.proj.GitRepo.Worktree() |
||||||
|
if err != nil { |
||||||
|
h.t.Fatal(err) |
||||||
|
} |
||||||
|
|
||||||
|
err = w.Reset(&git.ResetOptions{ |
||||||
|
Commit: to, |
||||||
|
Mode: mode, |
||||||
|
}) |
||||||
|
if err != nil { |
||||||
|
h.t.Fatal(err) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
type verifyExpectation int |
||||||
|
|
||||||
|
const ( |
||||||
|
verifyShouldSucceed verifyExpectation = 1 |
||||||
|
verifyShouldFail verifyExpectation = 0 |
||||||
|
verifySkip verifyExpectation = -1 |
||||||
|
) |
||||||
|
|
||||||
|
func (h *harness) tryCommit( |
||||||
|
verifyExp verifyExpectation, |
||||||
|
payUn PayloadUnion, |
||||||
|
accountSig sigcred.Signifier, |
||||||
|
) Commit { |
||||||
|
if accountSig != nil { |
||||||
|
var err error |
||||||
|
if payUn, err = h.proj.AccreditPayload(payUn, accountSig); err != nil { |
||||||
|
h.t.Fatalf("accrediting payload: %v", err) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
commit, err := h.proj.Commit(payUn) |
||||||
|
if err != nil { |
||||||
|
h.t.Fatalf("committing PayloadChange: %v", err) |
||||||
|
} else if verifyExp == verifySkip { |
||||||
|
return commit |
||||||
|
} |
||||||
|
|
||||||
|
branch, err := h.proj.ReferenceToBranchName(plumbing.HEAD) |
||||||
|
if err != nil { |
||||||
|
h.t.Fatalf("determining checked out branch: %v", err) |
||||||
|
} |
||||||
|
|
||||||
|
shouldSucceed := verifyExp > 0 |
||||||
|
|
||||||
|
err = h.proj.VerifyCommits(branch, []Commit{commit}) |
||||||
|
if shouldSucceed && err != nil { |
||||||
|
h.t.Fatalf("verifying commit %q: %v", commit.Hash, err) |
||||||
|
} else if shouldSucceed { |
||||||
|
return commit |
||||||
|
} else if !shouldSucceed && err == nil { |
||||||
|
h.t.Fatalf("verifying commit %q should have failed", commit.Hash) |
||||||
|
} |
||||||
|
|
||||||
|
var parentHash plumbing.Hash |
||||||
|
if commit.Object.NumParents() > 0 { |
||||||
|
parentHash = commit.Object.ParentHashes[0] |
||||||
|
} |
||||||
|
|
||||||
|
h.reset(parentHash, git.HardReset) |
||||||
|
return commit |
||||||
|
} |
||||||
|
|
||||||
|
func (h *harness) assertCommitChange( |
||||||
|
verifyExp verifyExpectation, |
||||||
|
msg string, |
||||||
|
sig sigcred.Signifier, |
||||||
|
) Commit { |
||||||
|
payUn, err := h.proj.NewPayloadChange(msg) |
||||||
|
if err != nil { |
||||||
|
h.t.Fatalf("creating PayloadChange: %v", err) |
||||||
|
} |
||||||
|
return h.tryCommit(verifyExp, payUn, sig) |
||||||
|
} |
||||||
|
|
||||||
|
func TestHasStagedChanges(t *testing.T) { |
||||||
|
h := newHarness(t) |
||||||
|
rootSig := h.stageNewAccount("root", false) |
||||||
|
assertHasStaged := func(expHasStaged bool) { |
||||||
|
hasStaged, err := h.proj.HasStagedChanges() |
||||||
|
if err != nil { |
||||||
|
t.Fatalf("error calling HasStagedChanges: %v", err) |
||||||
|
} else if hasStaged != expHasStaged { |
||||||
|
t.Fatalf("expected HasStagedChanges to return %v", expHasStaged) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// the harness starts with some staged changes
|
||||||
|
assertHasStaged(true) |
||||||
|
|
||||||
|
h.stage(map[string]string{"foo": "bar"}) |
||||||
|
assertHasStaged(true) |
||||||
|
h.assertCommitChange(verifyShouldSucceed, "first commit", rootSig) |
||||||
|
assertHasStaged(false) |
||||||
|
|
||||||
|
h.stage(map[string]string{"foo": ""}) // delete foo
|
||||||
|
assertHasStaged(true) |
||||||
|
h.assertCommitChange(verifyShouldSucceed, "second commit", rootSig) |
||||||
|
assertHasStaged(false) |
||||||
|
} |
||||||
|
|
||||||
|
// TestThisProjectStillVerifies opens this actual project and ensures that all
|
||||||
|
// commits in it still verify.
|
||||||
|
func TestThisProjectStillVerifies(t *testing.T) { |
||||||
|
proj, err := OpenProject(".") |
||||||
|
if err != nil { |
||||||
|
t.Fatalf("error opening repo: %v", err) |
||||||
|
} |
||||||
|
|
||||||
|
headCommit, err := proj.GetHeadCommit() |
||||||
|
if err != nil { |
||||||
|
t.Fatalf("getting repo head: %v", err) |
||||||
|
} |
||||||
|
|
||||||
|
allCommits, err := proj.GetCommitRange(plumbing.ZeroHash, headCommit.Hash) |
||||||
|
if err != nil { |
||||||
|
t.Fatalf("getting all commits (up to %q): %v", headCommit.Hash, err) |
||||||
|
} |
||||||
|
|
||||||
|
checkedOutBranch, err := proj.ReferenceToBranchName(plumbing.HEAD) |
||||||
|
if err != nil { |
||||||
|
t.Fatalf("error determining checked out branch: %v", err) |
||||||
|
} |
||||||
|
|
||||||
|
if err := proj.VerifyCommits(checkedOutBranch, allCommits); err != nil { |
||||||
|
t.Fatal(err) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
func TestShortHashResolving(t *testing.T) { |
||||||
|
// TODO ideally this test would test that conflicting hashes are noticed,
|
||||||
|
// but that's hard...
|
||||||
|
h := newHarness(t) |
||||||
|
rootSig := h.stageNewAccount("root", false) |
||||||
|
hash := h.assertCommitChange(verifyShouldSucceed, "first commit", rootSig).Hash |
||||||
|
hashStr := hash.String() |
||||||
|
t.Log(hashStr) |
||||||
|
|
||||||
|
for i := 2; i < len(hashStr); i++ { |
||||||
|
gotCommit, err := h.proj.GetCommitByRevision(plumbing.Revision(hashStr[:i])) |
||||||
|
if err != nil { |
||||||
|
t.Fatalf("resolving %q: %v", hashStr[:i], err) |
||||||
|
} else if gotCommit.Hash != hash { |
||||||
|
t.Fatalf("expected hash %q but got %q", |
||||||
|
gotCommit.Hash, hash) |
||||||
|
} |
||||||
|
} |
||||||
|
} |
@ -1,104 +0,0 @@ |
|||||||
// 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 |
|
||||||
} |
|
@ -1,118 +0,0 @@ |
|||||||
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 |
|
||||||
} |
|
@ -1,25 +1,77 @@ |
|||||||
package sigcred |
package sigcred |
||||||
|
|
||||||
import "dehub/typeobj" |
import ( |
||||||
|
"fmt" |
||||||
|
|
||||||
// Credential represents a credential which has been attached to a commit which
|
"dehub.dev/src/dehub.git/typeobj" |
||||||
// hopefully will allow it to be included in the master branch. Exactly one
|
) |
||||||
// field tagged with "type" should be set.
|
|
||||||
type Credential struct { |
// CredentialUnion represents a credential, signifying a user's approval of a
|
||||||
|
// payload. Exactly one field tagged with "type" should be set.
|
||||||
|
type CredentialUnion struct { |
||||||
PGPSignature *CredentialPGPSignature `type:"pgp_signature"` |
PGPSignature *CredentialPGPSignature `type:"pgp_signature"` |
||||||
|
|
||||||
// AccountID specifies the account which generated this Credential. The
|
// AccountID specifies the account which generated this CredentialUnion.
|
||||||
// Credentials produced by the Signifier.Sign method do not fill this field
|
//
|
||||||
// in.
|
// NOTE that credentials produced by the direct implementations of Signifier
|
||||||
AccountID string `yaml:"account"` |
// won't fill in this field, unless specifically documented. The Signifier
|
||||||
|
// produced by the Signifier() method of SignifierUnion _will_ fill this
|
||||||
|
// field in, however.
|
||||||
|
AccountID string `yaml:"account,omitempty"` |
||||||
|
|
||||||
|
// AnonID specifies an identifier for the anonymous user which produced this
|
||||||
|
// credential. This field is mutually exclusive with AccountID, and won't be
|
||||||
|
// set by any Signifier implementation unless specifically documented.
|
||||||
|
AnonID string `yaml:"-"` |
||||||
} |
} |
||||||
|
|
||||||
// MarshalYAML implements the yaml.Marshaler interface.
|
// MarshalYAML implements the yaml.Marshaler interface.
|
||||||
func (c Credential) MarshalYAML() (interface{}, error) { |
func (c CredentialUnion) MarshalYAML() (interface{}, error) { |
||||||
return typeobj.MarshalYAML(c) |
return typeobj.MarshalYAML(c) |
||||||
} |
} |
||||||
|
|
||||||
// UnmarshalYAML implements the yaml.Unmarshaler interface.
|
// UnmarshalYAML implements the yaml.Unmarshaler interface.
|
||||||
func (c *Credential) UnmarshalYAML(unmarshal func(interface{}) error) error { |
func (c *CredentialUnion) UnmarshalYAML(unmarshal func(interface{}) error) error { |
||||||
return typeobj.UnmarshalYAML(c, unmarshal) |
return typeobj.UnmarshalYAML(c, unmarshal) |
||||||
} |
} |
||||||
|
|
||||||
|
// ErrNotSelfVerifying is returned from the SelfVerify method of CredentialUnion
|
||||||
|
// when the credential does not implement the SelfVerifyingCredential interface.
|
||||||
|
// It may also be returned from the SelfVerify method of the
|
||||||
|
// SelfVerifyingCredential itself, if the credential can only self-verify under
|
||||||
|
// certain circumstances.
|
||||||
|
type ErrNotSelfVerifying struct { |
||||||
|
// Subject is a descriptor of the value which could not be verified. It may
|
||||||
|
// be a type name or some other identifying piece of information.
|
||||||
|
Subject string |
||||||
|
} |
||||||
|
|
||||||
|
func (e ErrNotSelfVerifying) Error() string { |
||||||
|
return fmt.Sprintf("%s cannot verify itself", e.Subject) |
||||||
|
} |
||||||
|
|
||||||
|
// SelfVerify will attempt to cast the credential as a SelfVerifyingCredential,
|
||||||
|
// and returns the result of the SelfVerify method being called on it.
|
||||||
|
func (c CredentialUnion) SelfVerify(data []byte) error { |
||||||
|
el, _, err := typeobj.Element(c) |
||||||
|
if err != nil { |
||||||
|
return err |
||||||
|
} else if selfVerifyingCred, ok := el.(SelfVerifyingCredential); !ok { |
||||||
|
return ErrNotSelfVerifying{Subject: fmt.Sprintf("credential of type %T", el)} |
||||||
|
} else if err := selfVerifyingCred.SelfVerify(data); err != nil { |
||||||
|
return fmt.Errorf("self-verifying credential of type %T: %w", el, err) |
||||||
|
} |
||||||
|
return nil |
||||||
|
} |
||||||
|
|
||||||
|
// SelfVerifyingCredential is one which is able to prove its own authenticity by
|
||||||
|
// some means or another. It is not required for a Credential to implement this
|
||||||
|
// interface.
|
||||||
|
type SelfVerifyingCredential interface { |
||||||
|
// SelfVerify should return nil if the Credential has successfully verified
|
||||||
|
// that it has accredited the given data, or an error describing why it
|
||||||
|
// could not do so. It may return ErrNotSelfVerifying if the Credential can
|
||||||
|
// only self-verify under certain circumstances, and those circumstances are
|
||||||
|
// not met.
|
||||||
|
SelfVerify(data []byte) error |
||||||
|
} |
||||||
|
@ -0,0 +1,58 @@ |
|||||||
|
package sigcred |
||||||
|
|
||||||
|
import ( |
||||||
|
"errors" |
||||||
|
"math/rand" |
||||||
|
"testing" |
||||||
|
"time" |
||||||
|
) |
||||||
|
|
||||||
|
func TestSelfVerifyingCredentials(t *testing.T) { |
||||||
|
seed := time.Now().UnixNano() |
||||||
|
t.Logf("seed: %d", seed) |
||||||
|
rand := rand.New(rand.NewSource(seed)) |
||||||
|
|
||||||
|
tests := []struct { |
||||||
|
descr string |
||||||
|
mkCred func(toSign []byte) (CredentialUnion, error) |
||||||
|
expErr bool |
||||||
|
}{ |
||||||
|
{ |
||||||
|
descr: "pgp sig no body", |
||||||
|
mkCred: func(toSign []byte) (CredentialUnion, error) { |
||||||
|
privKey, _ := TestSignifierPGP("", false, rand) |
||||||
|
return privKey.Sign(nil, toSign) |
||||||
|
}, |
||||||
|
expErr: true, |
||||||
|
}, |
||||||
|
{ |
||||||
|
descr: "pgp sig with body", |
||||||
|
mkCred: func(toSign []byte) (CredentialUnion, error) { |
||||||
|
privKey, _ := TestSignifierPGP("", true, rand) |
||||||
|
return privKey.Sign(nil, toSign) |
||||||
|
}, |
||||||
|
}, |
||||||
|
} |
||||||
|
|
||||||
|
for _, test := range tests { |
||||||
|
t.Run(test.descr, func(t *testing.T) { |
||||||
|
data := make([]byte, rand.Intn(1024)) |
||||||
|
if _, err := rand.Read(data); err != nil { |
||||||
|
t.Fatal(err) |
||||||
|
} |
||||||
|
|
||||||
|
cred, err := test.mkCred(data) |
||||||
|
if err != nil { |
||||||
|
t.Fatal(err) |
||||||
|
} |
||||||
|
|
||||||
|
err = cred.SelfVerify(data) |
||||||
|
isNotSelfVerifying := errors.As(err, new(ErrNotSelfVerifying)) |
||||||
|
if test.expErr && !isNotSelfVerifying { |
||||||
|
t.Fatalf("expected ErrNotSelfVerifying but got: %v", err) |
||||||
|
} else if !test.expErr && err != nil { |
||||||
|
t.Fatalf("unexpected error: %v", err) |
||||||
|
} |
||||||
|
}) |
||||||
|
} |
||||||
|
} |
@ -1,50 +1,95 @@ |
|||||||
package sigcred |
package sigcred |
||||||
|
|
||||||
import ( |
import ( |
||||||
"dehub/fs" |
"dehub.dev/src/dehub.git/fs" |
||||||
"dehub/typeobj" |
"dehub.dev/src/dehub.git/typeobj" |
||||||
) |
) |
||||||
|
|
||||||
// Signifier reprsents a single signing method being defined in the Config. Only
|
// Signifier describes the methods that all signifiers must implement.
|
||||||
// one field should be set on each Signifier.
|
type Signifier interface { |
||||||
type Signifier struct { |
// Sign returns a credential containing a signature of the given data.
|
||||||
PGPPublicKey *SignifierPGP `type:"pgp_public_key"` |
//
|
||||||
PGPPublicKeyFile *SignifierPGPFile `type:"pgp_public_key_file"` |
// tree can be used to find the Signifier at a particular snapshot.
|
||||||
|
Sign(fs.FS, []byte) (CredentialUnion, error) |
||||||
|
|
||||||
|
// Signed returns true if the Signifier was used to sign the credential.
|
||||||
|
Signed(fs.FS, CredentialUnion) (bool, error) |
||||||
|
|
||||||
|
// Verify asserts that the Signifier produced the given credential for the
|
||||||
|
// given data set, or returns an error.
|
||||||
|
//
|
||||||
|
// tree can be used to find the Signifier at a particular snapshot.
|
||||||
|
Verify(fs.FS, []byte, CredentialUnion) error |
||||||
|
} |
||||||
|
|
||||||
|
// SignifierUnion represents a single signifier for an account. Only one field
|
||||||
|
// should be set on each SignifierUnion.
|
||||||
|
type SignifierUnion struct { |
||||||
|
PGPPublicKey *SignifierPGP `type:"pgp_public_key"` |
||||||
|
|
||||||
|
// LegacyPGPPublicKeyFile is deprecated, only PGPPublicKey should be used
|
||||||
|
LegacyPGPPublicKeyFile *SignifierPGPFile `type:"pgp_public_key_file"` |
||||||
} |
} |
||||||
|
|
||||||
// MarshalYAML implements the yaml.Marshaler interface.
|
// MarshalYAML implements the yaml.Marshaler interface.
|
||||||
func (s Signifier) MarshalYAML() (interface{}, error) { |
func (s SignifierUnion) MarshalYAML() (interface{}, error) { |
||||||
return typeobj.MarshalYAML(s) |
return typeobj.MarshalYAML(s) |
||||||
} |
} |
||||||
|
|
||||||
// UnmarshalYAML implements the yaml.Unmarshaler interface.
|
// UnmarshalYAML implements the yaml.Unmarshaler interface.
|
||||||
func (s *Signifier) UnmarshalYAML(unmarshal func(interface{}) error) error { |
func (s *SignifierUnion) UnmarshalYAML(unmarshal func(interface{}) error) error { |
||||||
return typeobj.UnmarshalYAML(s, unmarshal) |
if err := typeobj.UnmarshalYAML(s, unmarshal); err != nil { |
||||||
|
return err |
||||||
|
} |
||||||
|
|
||||||
|
// TODO deprecate PGPPublicKeyFile
|
||||||
|
if s.LegacyPGPPublicKeyFile != nil { |
||||||
|
s.PGPPublicKey = &SignifierPGP{Path: s.LegacyPGPPublicKeyFile.Path} |
||||||
|
s.LegacyPGPPublicKeyFile = nil |
||||||
|
} |
||||||
|
return nil |
||||||
} |
} |
||||||
|
|
||||||
// Interface returns the SignifierInterface instance encapsulated by this
|
// Signifier returns the Signifier instance encapsulated by this SignifierUnion.
|
||||||
// Signifier object.
|
//
|
||||||
func (s Signifier) Interface() (SignifierInterface, error) { |
// This will panic if no Signifier field is populated.
|
||||||
|
//
|
||||||
|
// accountID is given so as to automatically fill the AccountID field of
|
||||||
|
// credentials returned from Sign, since the underlying implementation doesn't
|
||||||
|
// know what account it's signing for.
|
||||||
|
func (s SignifierUnion) Signifier(accountID string) Signifier { |
||||||
el, _, err := typeobj.Element(s) |
el, _, err := typeobj.Element(s) |
||||||
if err != nil { |
if err != nil { |
||||||
return nil, err |
panic(err) |
||||||
} |
} |
||||||
return el.(SignifierInterface), nil |
return accountSignifier(accountID, el.(Signifier)) |
||||||
} |
} |
||||||
|
|
||||||
// SignifierInterface describes the methods that all Signifiers must implement.
|
type signifierMiddleware struct { |
||||||
type SignifierInterface interface { |
Signifier |
||||||
// Sign returns a Credential containing a signature of the given data.
|
signCallback func(*CredentialUnion) |
||||||
//
|
} |
||||||
// 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.
|
func (sm signifierMiddleware) Sign(fs fs.FS, data []byte) (CredentialUnion, error) { |
||||||
Signed(fs fs.FS, cred Credential) (bool, error) |
cred, err := sm.Signifier.Sign(fs, data) |
||||||
|
if err != nil || sm.signCallback == nil { |
||||||
|
return cred, err |
||||||
|
} |
||||||
|
sm.signCallback(&cred) |
||||||
|
return cred, nil |
||||||
|
} |
||||||
|
|
||||||
// Verify asserts that the Signifier produced the given Credential for the
|
// accountSignifier wraps a Signifier to always set the accountID field on
|
||||||
// given data set, or returns an error.
|
// credentials it produces via the Sign method.
|
||||||
//
|
//
|
||||||
// tree can be used to find the Signifier at a particular snapshot.
|
// TODO accountSignifier shouldn't be necessary, it's very ugly. It indicates
|
||||||
Verify(fs fs.FS, data []byte, cred Credential) error |
// that CredentialUnion probably shouldn't have AccountID on it, which makes
|
||||||
|
// sense. Some refactoring is required here.
|
||||||
|
func accountSignifier(accountID string, sig Signifier) Signifier { |
||||||
|
return signifierMiddleware{ |
||||||
|
Signifier: sig, |
||||||
|
signCallback: func(cred *CredentialUnion) { |
||||||
|
cred.AccountID = accountID |
||||||
|
}, |
||||||
|
} |
||||||
} |
} |
||||||
|
Loading…
Reference in new issue