diff options
Diffstat (limited to 'identity')
-rw-r--r-- | identity/bare.go | 144 | ||||
-rw-r--r-- | identity/identity.go | 285 | ||||
-rw-r--r-- | identity/identity_test.go | 145 | ||||
-rw-r--r-- | identity/interface.go | 30 | ||||
-rw-r--r-- | identity/key.go | 7 | ||||
-rw-r--r-- | identity/version.go | 105 |
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 +} |