aboutsummaryrefslogtreecommitdiffstats
path: root/identity
diff options
context:
space:
mode:
authorMichael Muré <michael.mure@consensys.net>2018-11-21 18:56:12 +0100
committerMichael Muré <batolettre@gmail.com>2019-03-01 22:35:36 +0100
commitfeab9412dffe5772048aad29893c4cb01d566387 (patch)
treeb7bc9751f2ebdf8d41f5621bbf372eaf7625c4b9 /identity
parent0aefae6fcca5786f2c898029c3d6282f760f2c63 (diff)
downloadgit-bug-feab9412dffe5772048aad29893c4cb01d566387.tar.gz
WIP identity in git
Diffstat (limited to 'identity')
-rw-r--r--identity/bare.go144
-rw-r--r--identity/identity.go285
-rw-r--r--identity/identity_test.go145
-rw-r--r--identity/interface.go30
-rw-r--r--identity/key.go7
-rw-r--r--identity/version.go105
6 files changed, 716 insertions, 0 deletions
diff --git a/identity/bare.go b/identity/bare.go
new file mode 100644
index 00000000..eec00e19
--- /dev/null
+++ b/identity/bare.go
@@ -0,0 +1,144 @@
+package identity
+
+import (
+ "encoding/json"
+ "fmt"
+ "strings"
+
+ "github.com/MichaelMure/git-bug/util/lamport"
+ "github.com/MichaelMure/git-bug/util/text"
+)
+
+type Bare struct {
+ name string
+ email string
+ login string
+ avatarUrl string
+}
+
+func NewBare(name string, email string) *Bare {
+ return &Bare{name: name, email: email}
+}
+
+func NewBareFull(name string, email string, login string, avatarUrl string) *Bare {
+ return &Bare{name: name, email: email, login: login, avatarUrl: avatarUrl}
+}
+
+type bareIdentityJson struct {
+ Name string `json:"name,omitempty"`
+ Email string `json:"email,omitempty"`
+ Login string `json:"login,omitempty"`
+ AvatarUrl string `json:"avatar_url,omitempty"`
+}
+
+func (i Bare) MarshalJSON() ([]byte, error) {
+ return json.Marshal(bareIdentityJson{
+ Name: i.name,
+ Email: i.email,
+ Login: i.login,
+ AvatarUrl: i.avatarUrl,
+ })
+}
+
+func (i Bare) UnmarshalJSON(data []byte) error {
+ aux := bareIdentityJson{}
+
+ if err := json.Unmarshal(data, &aux); err != nil {
+ return err
+ }
+
+ i.name = aux.Name
+ i.email = aux.Email
+ i.login = aux.Login
+ i.avatarUrl = aux.AvatarUrl
+
+ return nil
+}
+
+func (i Bare) Name() string {
+ return i.name
+}
+
+func (i Bare) Email() string {
+ return i.email
+}
+
+func (i Bare) Login() string {
+ return i.login
+}
+
+func (i Bare) AvatarUrl() string {
+ return i.avatarUrl
+}
+
+func (i Bare) Keys() []Key {
+ return []Key{}
+}
+
+func (i Bare) ValidKeysAtTime(time lamport.Time) []Key {
+ return []Key{}
+}
+
+// DisplayName return a non-empty string to display, representing the
+// identity, based on the non-empty values.
+func (i Bare) DisplayName() string {
+ switch {
+ case i.name == "" && i.login != "":
+ return i.login
+ case i.name != "" && i.login == "":
+ return i.name
+ case i.name != "" && i.login != "":
+ return fmt.Sprintf("%s (%s)", i.name, i.login)
+ }
+
+ panic("invalid person data")
+}
+
+// Match tell is the Person match the given query string
+func (i Bare) Match(query string) bool {
+ query = strings.ToLower(query)
+
+ return strings.Contains(strings.ToLower(i.name), query) ||
+ strings.Contains(strings.ToLower(i.login), query)
+}
+
+// Validate check if the Identity data is valid
+func (i Bare) Validate() error {
+ if text.Empty(i.name) && text.Empty(i.login) {
+ return fmt.Errorf("either name or login should be set")
+ }
+
+ if strings.Contains(i.name, "\n") {
+ return fmt.Errorf("name should be a single line")
+ }
+
+ if !text.Safe(i.name) {
+ return fmt.Errorf("name is not fully printable")
+ }
+
+ if strings.Contains(i.login, "\n") {
+ return fmt.Errorf("login should be a single line")
+ }
+
+ if !text.Safe(i.login) {
+ return fmt.Errorf("login is not fully printable")
+ }
+
+ if strings.Contains(i.email, "\n") {
+ return fmt.Errorf("email should be a single line")
+ }
+
+ if !text.Safe(i.email) {
+ return fmt.Errorf("email is not fully printable")
+ }
+
+ if i.avatarUrl != "" && !text.ValidUrl(i.avatarUrl) {
+ return fmt.Errorf("avatarUrl is not a valid URL")
+ }
+
+ return nil
+}
+
+func (i Bare) IsProtected() bool {
+ return false
+}
diff --git a/identity/identity.go b/identity/identity.go
new file mode 100644
index 00000000..f65e2a86
--- /dev/null
+++ b/identity/identity.go
@@ -0,0 +1,285 @@
+// Package identity contains the identity data model and low-level related functions
+package identity
+
+import (
+ "fmt"
+ "strings"
+
+ "github.com/MichaelMure/git-bug/repository"
+ "github.com/MichaelMure/git-bug/util/git"
+ "github.com/MichaelMure/git-bug/util/lamport"
+)
+
+const identityRefPattern = "refs/identities/"
+const versionEntryName = "version"
+const identityConfigKey = "git-bug.identity"
+
+type Identity struct {
+ id string
+ Versions []Version
+}
+
+func NewIdentity(name string, email string) (*Identity, error) {
+ return &Identity{
+ Versions: []Version{
+ {
+ Name: name,
+ Email: email,
+ Nonce: makeNonce(20),
+ },
+ },
+ }, nil
+}
+
+type identityJson struct {
+ Id string `json:"id"`
+}
+
+// TODO: marshal/unmarshal identity + load/write from OpBase
+
+func Read(repo repository.Repo, id string) (*Identity, error) {
+ // Todo
+ return &Identity{}, nil
+}
+
+// NewFromGitUser will query the repository for user detail and
+// build the corresponding Identity
+/*func NewFromGitUser(repo repository.Repo) (*Identity, error) {
+ name, err := repo.GetUserName()
+ if err != nil {
+ return nil, err
+ }
+ if name == "" {
+ return nil, errors.New("User name is not configured in git yet. Please use `git config --global user.name \"John Doe\"`")
+ }
+
+ email, err := repo.GetUserEmail()
+ if err != nil {
+ return nil, err
+ }
+ if email == "" {
+ return nil, errors.New("User name is not configured in git yet. Please use `git config --global user.email johndoe@example.com`")
+ }
+
+ return NewIdentity(name, email)
+}*/
+
+//
+func BuildFromGit(repo repository.Repo) *Identity {
+ version := Version{}
+
+ name, err := repo.GetUserName()
+ if err == nil {
+ version.Name = name
+ }
+
+ email, err := repo.GetUserEmail()
+ if err == nil {
+ version.Email = email
+ }
+
+ return &Identity{
+ Versions: []Version{
+ version,
+ },
+ }
+}
+
+// SetIdentity store the user identity's id in the git config
+func SetIdentity(repo repository.RepoCommon, identity Identity) error {
+ return repo.StoreConfig(identityConfigKey, identity.Id())
+}
+
+// GetIdentity read the current user identity, set with a git config entry
+func GetIdentity(repo repository.Repo) (*Identity, error) {
+ configs, err := repo.ReadConfigs(identityConfigKey)
+ if err != nil {
+ return nil, err
+ }
+
+ if len(configs) == 0 {
+ return nil, fmt.Errorf("no identity set")
+ }
+
+ if len(configs) > 1 {
+ return nil, fmt.Errorf("multiple identity config exist")
+ }
+
+ var id string
+ for _, val := range configs {
+ id = val
+ }
+
+ return Read(repo, id)
+}
+
+func (i *Identity) AddVersion(version Version) {
+ i.Versions = append(i.Versions, version)
+}
+
+func (i *Identity) Commit(repo repository.ClockedRepo) error {
+ // Todo: check for mismatch between memory and commited data
+
+ var lastCommit git.Hash = ""
+
+ for _, v := range i.Versions {
+ if v.commitHash != "" {
+ lastCommit = v.commitHash
+ // ignore already commited versions
+ continue
+ }
+
+ blobHash, err := v.Write(repo)
+ if err != nil {
+ return err
+ }
+
+ // Make a git tree referencing the blob
+ tree := []repository.TreeEntry{
+ {ObjectType: repository.Blob, Hash: blobHash, Name: versionEntryName},
+ }
+
+ treeHash, err := repo.StoreTree(tree)
+ if err != nil {
+ return err
+ }
+
+ var commitHash git.Hash
+ if lastCommit != "" {
+ commitHash, err = repo.StoreCommitWithParent(treeHash, lastCommit)
+ } else {
+ commitHash, err = repo.StoreCommit(treeHash)
+ }
+
+ if err != nil {
+ return err
+ }
+
+ lastCommit = commitHash
+
+ // if it was the first commit, use the commit hash as the Identity id
+ if i.id == "" {
+ i.id = string(commitHash)
+ }
+ }
+
+ if i.id == "" {
+ panic("identity with no id")
+ }
+
+ ref := fmt.Sprintf("%s%s", identityRefPattern, i.id)
+ err := repo.UpdateRef(ref, lastCommit)
+
+ if err != nil {
+ return err
+ }
+
+ return nil
+}
+
+// Validate check if the Identity data is valid
+func (i *Identity) Validate() error {
+ lastTime := lamport.Time(0)
+
+ for _, v := range i.Versions {
+ if err := v.Validate(); err != nil {
+ return err
+ }
+
+ if v.Time < lastTime {
+ return fmt.Errorf("non-chronological version (%d --> %d)", lastTime, v.Time)
+ }
+
+ lastTime = v.Time
+ }
+
+ return nil
+}
+
+func (i *Identity) LastVersion() Version {
+ if len(i.Versions) <= 0 {
+ panic("no version at all")
+ }
+
+ return i.Versions[len(i.Versions)-1]
+}
+
+// Id return the Identity identifier
+func (i *Identity) Id() string {
+ if i.id == "" {
+ // simply panic as it would be a coding error
+ // (using an id of an identity not stored yet)
+ panic("no id yet")
+ }
+ return i.id
+}
+
+// Name return the last version of the name
+func (i *Identity) Name() string {
+ return i.LastVersion().Name
+}
+
+// Email return the last version of the email
+func (i *Identity) Email() string {
+ return i.LastVersion().Email
+}
+
+// Login return the last version of the login
+func (i *Identity) Login() string {
+ return i.LastVersion().Login
+}
+
+// Login return the last version of the Avatar URL
+func (i *Identity) AvatarUrl() string {
+ return i.LastVersion().AvatarUrl
+}
+
+// Login return the last version of the valid keys
+func (i *Identity) Keys() []Key {
+ return i.LastVersion().Keys
+}
+
+// IsProtected return true if the chain of git commits started to be signed.
+// If that's the case, only signed commit with a valid key for this identity can be added.
+func (i *Identity) IsProtected() bool {
+ // Todo
+ return false
+}
+
+// ValidKeysAtTime return the set of keys valid at a given lamport time
+func (i *Identity) ValidKeysAtTime(time lamport.Time) []Key {
+ var result []Key
+
+ for _, v := range i.Versions {
+ if v.Time > time {
+ return result
+ }
+
+ result = v.Keys
+ }
+
+ return result
+}
+
+// Match tell is the Identity match the given query string
+func (i *Identity) Match(query string) bool {
+ query = strings.ToLower(query)
+
+ return strings.Contains(strings.ToLower(i.Name()), query) ||
+ strings.Contains(strings.ToLower(i.Login()), query)
+}
+
+// DisplayName return a non-empty string to display, representing the
+// identity, based on the non-empty values.
+func (i *Identity) DisplayName() string {
+ switch {
+ case i.Name() == "" && i.Login() != "":
+ return i.Login()
+ case i.Name() != "" && i.Login() == "":
+ return i.Name()
+ case i.Name() != "" && i.Login() != "":
+ return fmt.Sprintf("%s (%s)", i.Name(), i.Login())
+ }
+
+ panic("invalid person data")
+}
diff --git a/identity/identity_test.go b/identity/identity_test.go
new file mode 100644
index 00000000..161fd56f
--- /dev/null
+++ b/identity/identity_test.go
@@ -0,0 +1,145 @@
+package identity
+
+import (
+ "testing"
+
+ "github.com/MichaelMure/git-bug/repository"
+ "github.com/stretchr/testify/assert"
+)
+
+func TestIdentityCommit(t *testing.T) {
+ mockRepo := repository.NewMockRepoForTest()
+
+ // single version
+
+ identity := Identity{
+ Versions: []Version{
+ {
+ Name: "René Descartes",
+ Email: "rene.descartes@example.com",
+ },
+ },
+ }
+
+ err := identity.Commit(mockRepo)
+
+ assert.Nil(t, err)
+ assert.NotEmpty(t, identity.id)
+
+ // multiple version
+
+ identity = Identity{
+ Versions: []Version{
+ {
+ Time: 100,
+ Name: "René Descartes",
+ Email: "rene.descartes@example.com",
+ Keys: []Key{
+ {PubKey: "pubkeyA"},
+ },
+ },
+ {
+ Time: 200,
+ Name: "René Descartes",
+ Email: "rene.descartes@example.com",
+ Keys: []Key{
+ {PubKey: "pubkeyB"},
+ },
+ },
+ {
+ Time: 201,
+ Name: "René Descartes",
+ Email: "rene.descartes@example.com",
+ Keys: []Key{
+ {PubKey: "pubkeyC"},
+ },
+ },
+ },
+ }
+
+ err = identity.Commit(mockRepo)
+
+ assert.Nil(t, err)
+ assert.NotEmpty(t, identity.id)
+
+ // add more version
+
+ identity.AddVersion(Version{
+ Time: 201,
+ Name: "René Descartes",
+ Email: "rene.descartes@example.com",
+ Keys: []Key{
+ {PubKey: "pubkeyD"},
+ },
+ })
+
+ identity.AddVersion(Version{
+ Time: 300,
+ Name: "René Descartes",
+ Email: "rene.descartes@example.com",
+ Keys: []Key{
+ {PubKey: "pubkeyE"},
+ },
+ })
+
+ err = identity.Commit(mockRepo)
+
+ assert.Nil(t, err)
+ assert.NotEmpty(t, identity.id)
+}
+
+func TestIdentity_ValidKeysAtTime(t *testing.T) {
+ identity := Identity{
+ Versions: []Version{
+ {
+ Time: 100,
+ Name: "René Descartes",
+ Email: "rene.descartes@example.com",
+ Keys: []Key{
+ {PubKey: "pubkeyA"},
+ },
+ },
+ {
+ Time: 200,
+ Name: "René Descartes",
+ Email: "rene.descartes@example.com",
+ Keys: []Key{
+ {PubKey: "pubkeyB"},
+ },
+ },
+ {
+ Time: 201,
+ Name: "René Descartes",
+ Email: "rene.descartes@example.com",
+ Keys: []Key{
+ {PubKey: "pubkeyC"},
+ },
+ },
+ {
+ Time: 201,
+ Name: "René Descartes",
+ Email: "rene.descartes@example.com",
+ Keys: []Key{
+ {PubKey: "pubkeyD"},
+ },
+ },
+ {
+ Time: 300,
+ Name: "René Descartes",
+ Email: "rene.descartes@example.com",
+ Keys: []Key{
+ {PubKey: "pubkeyE"},
+ },
+ },
+ },
+ }
+
+ assert.Nil(t, identity.ValidKeysAtTime(10))
+ assert.Equal(t, identity.ValidKeysAtTime(100), []Key{{PubKey: "pubkeyA"}})
+ assert.Equal(t, identity.ValidKeysAtTime(140), []Key{{PubKey: "pubkeyA"}})
+ assert.Equal(t, identity.ValidKeysAtTime(200), []Key{{PubKey: "pubkeyB"}})
+ assert.Equal(t, identity.ValidKeysAtTime(201), []Key{{PubKey: "pubkeyD"}})
+ assert.Equal(t, identity.ValidKeysAtTime(202), []Key{{PubKey: "pubkeyD"}})
+ assert.Equal(t, identity.ValidKeysAtTime(300), []Key{{PubKey: "pubkeyE"}})
+ assert.Equal(t, identity.ValidKeysAtTime(3000), []Key{{PubKey: "pubkeyE"}})
+}
diff --git a/identity/interface.go b/identity/interface.go
new file mode 100644
index 00000000..14287655
--- /dev/null
+++ b/identity/interface.go
@@ -0,0 +1,30 @@
+package identity
+
+import "github.com/MichaelMure/git-bug/util/lamport"
+
+type Interface interface {
+ Name() string
+ Email() string
+ Login() string
+ AvatarUrl() string
+
+ // Login return the last version of the valid keys
+ Keys() []Key
+
+ // ValidKeysAtTime return the set of keys valid at a given lamport time
+ ValidKeysAtTime(time lamport.Time) []Key
+
+ // DisplayName return a non-empty string to display, representing the
+ // identity, based on the non-empty values.
+ DisplayName() string
+
+ // Match tell is the Person match the given query string
+ Match(query string) bool
+
+ // Validate check if the Identity data is valid
+ Validate() error
+
+ // IsProtected return true if the chain of git commits started to be signed.
+ // If that's the case, only signed commit with a valid key for this identity can be added.
+ IsProtected() bool
+}
diff --git a/identity/key.go b/identity/key.go
new file mode 100644
index 00000000..c498ec09
--- /dev/null
+++ b/identity/key.go
@@ -0,0 +1,7 @@
+package identity
+
+type Key struct {
+ // The GPG fingerprint of the key
+ Fingerprint string `json:"fingerprint"`
+ PubKey string `json:"pub_key"`
+}
diff --git a/identity/version.go b/identity/version.go
new file mode 100644
index 00000000..f76ec4c5
--- /dev/null
+++ b/identity/version.go
@@ -0,0 +1,105 @@
+package identity
+
+import (
+ "crypto/rand"
+ "encoding/json"
+ "fmt"
+ "strings"
+
+ "github.com/MichaelMure/git-bug/repository"
+ "github.com/MichaelMure/git-bug/util/git"
+
+ "github.com/MichaelMure/git-bug/util/lamport"
+ "github.com/MichaelMure/git-bug/util/text"
+)
+
+type Version struct {
+ // Private field so not serialized
+ commitHash git.Hash
+
+ // The lamport time at which this version become effective
+ // The reference time is the bug edition lamport clock
+ Time lamport.Time `json:"time"`
+
+ Name string `json:"name"`
+ Email string `json:"email"`
+ Login string `json:"login"`
+ AvatarUrl string `json:"avatar_url"`
+
+ // The set of keys valid at that time, from this version onward, until they get removed
+ // in a new version. This allow to have multiple key for the same identity (e.g. one per
+ // device) as well as revoke key.
+ Keys []Key `json:"pub_keys"`
+
+ // This optional array is here to ensure a better randomness of the identity id to avoid collisions.
+ // It has no functional purpose and should be ignored.
+ // It is advised to fill this array if there is not enough entropy, e.g. if there is no keys.
+ Nonce []byte `json:"nonce,omitempty"`
+}
+
+func (v *Version) Validate() error {
+ if text.Empty(v.Name) && text.Empty(v.Login) {
+ return fmt.Errorf("either name or login should be set")
+ }
+
+ if strings.Contains(v.Name, "\n") {
+ return fmt.Errorf("name should be a single line")
+ }
+
+ if !text.Safe(v.Name) {
+ return fmt.Errorf("name is not fully printable")
+ }
+
+ if strings.Contains(v.Login, "\n") {
+ return fmt.Errorf("login should be a single line")
+ }
+
+ if !text.Safe(v.Login) {
+ return fmt.Errorf("login is not fully printable")
+ }
+
+ if strings.Contains(v.Email, "\n") {
+ return fmt.Errorf("email should be a single line")
+ }
+
+ if !text.Safe(v.Email) {
+ return fmt.Errorf("email is not fully printable")
+ }
+
+ if v.AvatarUrl != "" && !text.ValidUrl(v.AvatarUrl) {
+ return fmt.Errorf("avatarUrl is not a valid URL")
+ }
+
+ if len(v.Nonce) > 64 {
+ return fmt.Errorf("nonce is too big")
+ }
+
+ return nil
+}
+
+// Write will serialize and store the Version as a git blob and return
+// its hash
+func (v *Version) Write(repo repository.Repo) (git.Hash, error) {
+ data, err := json.Marshal(v)
+
+ if err != nil {
+ return "", err
+ }
+
+ hash, err := repo.StoreData(data)
+
+ if err != nil {
+ return "", err
+ }
+
+ return hash, nil
+}
+
+func makeNonce(len int) []byte {
+ result := make([]byte, len)
+ _, err := rand.Read(result)
+ if err != nil {
+ panic(err)
+ }
+ return result
+}