aboutsummaryrefslogtreecommitdiffstats
path: root/identity
diff options
context:
space:
mode:
Diffstat (limited to 'identity')
-rw-r--r--identity/bare.go204
-rw-r--r--identity/bare_test.go32
-rw-r--r--identity/common.go53
-rw-r--r--identity/identity.go584
-rw-r--r--identity/identity_actions.go187
-rw-r--r--identity/identity_actions_test.go151
-rw-r--r--identity/identity_stub.go104
-rw-r--r--identity/identity_stub_test.go23
-rw-r--r--identity/identity_test.go244
-rw-r--r--identity/interface.go58
-rw-r--r--identity/key.go13
-rw-r--r--identity/resolver.go22
-rw-r--r--identity/version.go208
-rw-r--r--identity/version_test.go42
14 files changed, 1925 insertions, 0 deletions
diff --git a/identity/bare.go b/identity/bare.go
new file mode 100644
index 00000000..6af794dd
--- /dev/null
+++ b/identity/bare.go
@@ -0,0 +1,204 @@
+package identity
+
+import (
+ "crypto/sha256"
+ "encoding/json"
+ "fmt"
+ "strings"
+
+ "github.com/MichaelMure/git-bug/repository"
+ "github.com/MichaelMure/git-bug/util/lamport"
+ "github.com/MichaelMure/git-bug/util/text"
+ "github.com/MichaelMure/git-bug/util/timestamp"
+)
+
+var _ Interface = &Bare{}
+
+// Bare is a very minimal identity, designed to be fully embedded directly along
+// other data.
+//
+// in particular, this identity is designed to be compatible with the handling of
+// identities in the early version of git-bug.
+type Bare struct {
+ id string
+ 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
+}
+
+// Id return the Identity identifier
+func (i *Bare) Id() string {
+ // We don't have a proper ID at hand, so let's hash all the data to get one.
+ // Hopefully the
+
+ if i.id != "" {
+ return i.id
+ }
+
+ data, err := json.Marshal(i)
+ if err != nil {
+ panic(err)
+ }
+
+ h := fmt.Sprintf("%x", sha256.New().Sum(data)[:16])
+ i.id = string(h)
+
+ return i.id
+}
+
+// HumanId return the Identity identifier truncated for human consumption
+func (i *Bare) HumanId() string {
+ return FormatHumanID(i.Id())
+}
+
+// Name return the last version of the name
+func (i *Bare) Name() string {
+ return i.name
+}
+
+// Email return the last version of the email
+func (i *Bare) Email() string {
+ return i.email
+}
+
+// Login return the last version of the login
+func (i *Bare) Login() string {
+ return i.login
+}
+
+// AvatarUrl return the last version of the Avatar URL
+func (i *Bare) AvatarUrl() string {
+ return i.avatarUrl
+}
+
+// Keys return the last version of the valid keys
+func (i *Bare) Keys() []Key {
+ return []Key{}
+}
+
+// ValidKeysAtTime return the set of keys valid at a given lamport time
+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")
+}
+
+// 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
+}
+
+// Write the identity into the Repository. In particular, this ensure that
+// the Id is properly set.
+func (i *Bare) Commit(repo repository.ClockedRepo) error {
+ // Nothing to do, everything is directly embedded
+ return nil
+}
+
+// If needed, write the identity into the Repository. In particular, this
+// ensure that the Id is properly set.
+func (i *Bare) CommitAsNeeded(repo repository.ClockedRepo) error {
+ // Nothing to do, everything is directly embedded
+ return nil
+}
+
+// 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 *Bare) IsProtected() bool {
+ return false
+}
+
+// LastModificationLamportTime return the Lamport time at which the last version of the identity became valid.
+func (i *Bare) LastModificationLamport() lamport.Time {
+ return 0
+}
+
+// LastModification return the timestamp at which the last version of the identity became valid.
+func (i *Bare) LastModification() timestamp.Timestamp {
+ return 0
+}
diff --git a/identity/bare_test.go b/identity/bare_test.go
new file mode 100644
index 00000000..7db9f644
--- /dev/null
+++ b/identity/bare_test.go
@@ -0,0 +1,32 @@
+package identity
+
+import (
+ "encoding/json"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestBare_Id(t *testing.T) {
+ i := NewBare("name", "email")
+ id := i.Id()
+ assert.Equal(t, "7b226e616d65223a226e616d65222c22", id)
+}
+
+func TestBareSerialize(t *testing.T) {
+ before := &Bare{
+ login: "login",
+ email: "email",
+ name: "name",
+ avatarUrl: "avatar",
+ }
+
+ data, err := json.Marshal(before)
+ assert.NoError(t, err)
+
+ var after Bare
+ err = json.Unmarshal(data, &after)
+ assert.NoError(t, err)
+
+ assert.Equal(t, before, &after)
+}
diff --git a/identity/common.go b/identity/common.go
new file mode 100644
index 00000000..2f2b9042
--- /dev/null
+++ b/identity/common.go
@@ -0,0 +1,53 @@
+package identity
+
+import (
+ "encoding/json"
+ "errors"
+ "fmt"
+ "strings"
+)
+
+var ErrIdentityNotExist = errors.New("identity doesn't exist")
+
+type ErrMultipleMatch struct {
+ Matching []string
+}
+
+func (e ErrMultipleMatch) Error() string {
+ return fmt.Sprintf("Multiple matching identities found:\n%s", strings.Join(e.Matching, "\n"))
+}
+
+// Custom unmarshaling function to allow package user to delegate
+// the decoding of an Identity and distinguish between an Identity
+// and a Bare.
+//
+// If the given message has a "id" field, it's considered being a proper Identity.
+func UnmarshalJSON(raw json.RawMessage) (Interface, error) {
+ aux := &IdentityStub{}
+
+ // First try to decode and load as a normal Identity
+ err := json.Unmarshal(raw, &aux)
+ if err == nil && aux.Id() != "" {
+ return aux, nil
+ }
+
+ // abort if we have an error other than the wrong type
+ if _, ok := err.(*json.UnmarshalTypeError); err != nil && !ok {
+ return nil, err
+ }
+
+ // Fallback on a legacy Bare identity
+ var b Bare
+
+ err = json.Unmarshal(raw, &b)
+ if err == nil && (b.name != "" || b.login != "") {
+ return &b, nil
+ }
+
+ // abort if we have an error other than the wrong type
+ if _, ok := err.(*json.UnmarshalTypeError); err != nil && !ok {
+ return nil, err
+ }
+
+ return nil, fmt.Errorf("unknown identity type")
+}
diff --git a/identity/identity.go b/identity/identity.go
new file mode 100644
index 00000000..3dddfaec
--- /dev/null
+++ b/identity/identity.go
@@ -0,0 +1,584 @@
+// Package identity contains the identity data model and low-level related functions
+package identity
+
+import (
+ "encoding/json"
+ "fmt"
+ "os"
+ "strings"
+ "time"
+
+ "github.com/MichaelMure/git-bug/util/timestamp"
+ "github.com/pkg/errors"
+
+ "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 identityRemoteRefPattern = "refs/remotes/%s/identities/"
+const versionEntryName = "version"
+const identityConfigKey = "git-bug.identity"
+
+const idLength = 40
+const humanIdLength = 7
+
+var ErrNonFastForwardMerge = errors.New("non fast-forward identity merge")
+var ErrNoIdentitySet = errors.New("user identity first needs to be created using \"git bug user create\" or \"git bug user adopt\"")
+var ErrMultipleIdentitiesSet = errors.New("multiple user identities set")
+
+var _ Interface = &Identity{}
+
+type Identity struct {
+ // Id used as unique identifier
+ id string
+
+ // all the successive version of the identity
+ versions []*Version
+
+ // not serialized
+ lastCommit git.Hash
+}
+
+func NewIdentity(name string, email string) *Identity {
+ return &Identity{
+ versions: []*Version{
+ {
+ name: name,
+ email: email,
+ nonce: makeNonce(20),
+ },
+ },
+ }
+}
+
+func NewIdentityFull(name string, email string, login string, avatarUrl string) *Identity {
+ return &Identity{
+ versions: []*Version{
+ {
+ name: name,
+ email: email,
+ login: login,
+ avatarURL: avatarUrl,
+ nonce: makeNonce(20),
+ },
+ },
+ }
+}
+
+// MarshalJSON will only serialize the id
+func (i *Identity) MarshalJSON() ([]byte, error) {
+ return json.Marshal(&IdentityStub{
+ id: i.Id(),
+ })
+}
+
+// UnmarshalJSON will only read the id
+// Users of this package are expected to run Load() to load
+// the remaining data from the identities data in git.
+func (i *Identity) UnmarshalJSON(data []byte) error {
+ panic("identity should be loaded with identity.UnmarshalJSON")
+}
+
+// ReadLocal load a local Identity from the identities data available in git
+func ReadLocal(repo repository.Repo, id string) (*Identity, error) {
+ ref := fmt.Sprintf("%s%s", identityRefPattern, id)
+ return read(repo, ref)
+}
+
+// ReadRemote load a remote Identity from the identities data available in git
+func ReadRemote(repo repository.Repo, remote string, id string) (*Identity, error) {
+ ref := fmt.Sprintf(identityRemoteRefPattern, remote) + id
+ return read(repo, ref)
+}
+
+// read will load and parse an identity from git
+func read(repo repository.Repo, ref string) (*Identity, error) {
+ refSplit := strings.Split(ref, "/")
+ id := refSplit[len(refSplit)-1]
+
+ if len(id) != idLength {
+ return nil, fmt.Errorf("invalid ref length")
+ }
+
+ hashes, err := repo.ListCommits(ref)
+
+ // TODO: this is not perfect, it might be a command invoke error
+ if err != nil {
+ return nil, ErrIdentityNotExist
+ }
+
+ i := &Identity{
+ id: id,
+ }
+
+ for _, hash := range hashes {
+ entries, err := repo.ListEntries(hash)
+ if err != nil {
+ return nil, errors.Wrap(err, "can't list git tree entries")
+ }
+
+ if len(entries) != 1 {
+ return nil, fmt.Errorf("invalid identity data at hash %s", hash)
+ }
+
+ entry := entries[0]
+
+ if entry.Name != versionEntryName {
+ return nil, fmt.Errorf("invalid identity data at hash %s", hash)
+ }
+
+ data, err := repo.ReadData(entry.Hash)
+ if err != nil {
+ return nil, errors.Wrap(err, "failed to read git blob data")
+ }
+
+ var version Version
+ err = json.Unmarshal(data, &version)
+
+ if err != nil {
+ return nil, errors.Wrapf(err, "failed to decode Identity version json %s", hash)
+ }
+
+ // tag the version with the commit hash
+ version.commitHash = hash
+ i.lastCommit = hash
+
+ i.versions = append(i.versions, &version)
+ }
+
+ return i, nil
+}
+
+type StreamedIdentity struct {
+ Identity *Identity
+ Err error
+}
+
+// ReadAllLocalIdentities read and parse all local Identity
+func ReadAllLocalIdentities(repo repository.ClockedRepo) <-chan StreamedIdentity {
+ return readAllIdentities(repo, identityRefPattern)
+}
+
+// ReadAllRemoteIdentities read and parse all remote Identity for a given remote
+func ReadAllRemoteIdentities(repo repository.ClockedRepo, remote string) <-chan StreamedIdentity {
+ refPrefix := fmt.Sprintf(identityRemoteRefPattern, remote)
+ return readAllIdentities(repo, refPrefix)
+}
+
+// Read and parse all available bug with a given ref prefix
+func readAllIdentities(repo repository.ClockedRepo, refPrefix string) <-chan StreamedIdentity {
+ out := make(chan StreamedIdentity)
+
+ go func() {
+ defer close(out)
+
+ refs, err := repo.ListRefs(refPrefix)
+ if err != nil {
+ out <- StreamedIdentity{Err: err}
+ return
+ }
+
+ for _, ref := range refs {
+ b, err := read(repo, ref)
+
+ if err != nil {
+ out <- StreamedIdentity{Err: err}
+ return
+ }
+
+ out <- StreamedIdentity{Identity: b}
+ }
+ }()
+
+ return out
+}
+
+// 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), nil
+}
+
+// IsUserIdentitySet tell if the user identity is correctly set.
+func IsUserIdentitySet(repo repository.RepoCommon) (bool, error) {
+ configs, err := repo.ReadConfigs(identityConfigKey)
+ if err != nil {
+ return false, err
+ }
+
+ if len(configs) > 1 {
+ return false, ErrMultipleIdentitiesSet
+ }
+
+ return len(configs) == 1, nil
+}
+
+// SetUserIdentity store the user identity's id in the git config
+func SetUserIdentity(repo repository.RepoCommon, identity *Identity) error {
+ return repo.StoreConfig(identityConfigKey, identity.Id())
+}
+
+// GetUserIdentity read the current user identity, set with a git config entry
+func GetUserIdentity(repo repository.Repo) (*Identity, error) {
+ configs, err := repo.ReadConfigs(identityConfigKey)
+ if err != nil {
+ return nil, err
+ }
+
+ if len(configs) == 0 {
+ return nil, ErrNoIdentitySet
+ }
+
+ if len(configs) > 1 {
+ return nil, ErrMultipleIdentitiesSet
+ }
+
+ var id string
+ for _, val := range configs {
+ id = val
+ }
+
+ i, err := ReadLocal(repo, id)
+ if err == ErrIdentityNotExist {
+ innerErr := repo.RmConfigs(identityConfigKey)
+ if innerErr != nil {
+ _, _ = fmt.Fprintln(os.Stderr, errors.Wrap(innerErr, "can't clear user identity").Error())
+ }
+ return nil, err
+ }
+
+ return i, nil
+}
+
+func (i *Identity) AddVersion(version *Version) {
+ i.versions = append(i.versions, version)
+}
+
+// Write the identity into the Repository. In particular, this ensure that
+// the Id is properly set.
+func (i *Identity) Commit(repo repository.ClockedRepo) error {
+ // Todo: check for mismatch between memory and commit data
+
+ if !i.NeedCommit() {
+ return fmt.Errorf("can't commit an identity with no pending version")
+ }
+
+ if err := i.Validate(); err != nil {
+ return errors.Wrap(err, "can't commit an identity with invalid data")
+ }
+
+ for _, v := range i.versions {
+ if v.commitHash != "" {
+ i.lastCommit = v.commitHash
+ // ignore already commit versions
+ continue
+ }
+
+ // get the times where new versions starts to be valid
+ v.time = repo.EditTime()
+ v.unixTime = time.Now().Unix()
+
+ 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 i.lastCommit != "" {
+ commitHash, err = repo.StoreCommitWithParent(treeHash, i.lastCommit)
+ } else {
+ commitHash, err = repo.StoreCommit(treeHash)
+ }
+
+ if err != nil {
+ return err
+ }
+
+ i.lastCommit = commitHash
+ v.commitHash = 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, i.lastCommit)
+
+ if err != nil {
+ return err
+ }
+
+ return nil
+}
+
+func (i *Identity) CommitAsNeeded(repo repository.ClockedRepo) error {
+ if !i.NeedCommit() {
+ return nil
+ }
+ return i.Commit(repo)
+}
+
+func (i *Identity) NeedCommit() bool {
+ for _, v := range i.versions {
+ if v.commitHash == "" {
+ return true
+ }
+ }
+
+ return false
+}
+
+// Merge will merge a different version of the same Identity
+//
+// To make sure that an Identity history can't be altered, a strict fast-forward
+// only policy is applied here. As an Identity should be tied to a single user, this
+// should work in practice but it does leave a possibility that a user would edit his
+// Identity from two different repo concurrently and push the changes in a non-centralized
+// network of repositories. In this case, it would result in some of the repo accepting one
+// version and some other accepting another, preventing the network in general to converge
+// to the same result. This would create a sort of partition of the network, and manual
+// cleaning would be required.
+//
+// An alternative approach would be to have a determinist rebase:
+// - any commits present in both local and remote version would be kept, never changed.
+// - newer commits would be merged in a linear chain of commits, ordered based on the
+// Lamport time
+//
+// However, this approach leave the possibility, in the case of a compromised crypto keys,
+// of forging a new version with a bogus Lamport time to be inserted before a legit version,
+// invalidating the correct version and hijacking the Identity. There would only be a short
+// period of time where this would be possible (before the network converge) but I'm not
+// confident enough to implement that. I choose the strict fast-forward only approach,
+// despite it's potential problem with two different version as mentioned above.
+func (i *Identity) Merge(repo repository.Repo, other *Identity) (bool, error) {
+ if i.id != other.id {
+ return false, errors.New("merging unrelated identities is not supported")
+ }
+
+ if i.lastCommit == "" || other.lastCommit == "" {
+ return false, errors.New("can't merge identities that has never been stored")
+ }
+
+ modified := false
+ for j, otherVersion := range other.versions {
+ // if there is more version in other, take them
+ if len(i.versions) == j {
+ i.versions = append(i.versions, otherVersion)
+ i.lastCommit = otherVersion.commitHash
+ modified = true
+ }
+
+ // we have a non fast-forward merge.
+ // as explained in the doc above, refusing to merge
+ if i.versions[j].commitHash != otherVersion.commitHash {
+ return false, ErrNonFastForwardMerge
+ }
+ }
+
+ if modified {
+ err := repo.UpdateRef(identityRefPattern+i.id, i.lastCommit)
+ if err != nil {
+ return false, err
+ }
+ }
+
+ return false, nil
+}
+
+// Validate check if the Identity data is valid
+func (i *Identity) Validate() error {
+ lastTime := lamport.Time(0)
+
+ if len(i.versions) == 0 {
+ return fmt.Errorf("no version")
+ }
+
+ for _, v := range i.versions {
+ if err := v.Validate(); err != nil {
+ return err
+ }
+
+ if v.commitHash != "" && v.time < lastTime {
+ return fmt.Errorf("non-chronological version (%d --> %d)", lastTime, v.time)
+ }
+
+ lastTime = v.time
+ }
+
+ // The identity ID should be the hash of the first commit
+ if i.versions[0].commitHash != "" && string(i.versions[0].commitHash) != i.id {
+ return fmt.Errorf("identity id should be the first commit hash")
+ }
+
+ 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
+}
+
+// HumanId return the Identity identifier truncated for human consumption
+func (i *Identity) HumanId() string {
+ return FormatHumanID(i.Id())
+}
+
+func FormatHumanID(id string) string {
+ format := fmt.Sprintf("%%.%ds", humanIdLength)
+ return fmt.Sprintf(format, 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
+}
+
+// AvatarUrl return the last version of the Avatar URL
+func (i *Identity) AvatarUrl() string {
+ return i.lastVersion().avatarURL
+}
+
+// Keys return the last version of the valid keys
+func (i *Identity) Keys() []Key {
+ return i.lastVersion().keys
+}
+
+// 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
+}
+
+// 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")
+}
+
+// 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
+}
+
+// LastModificationLamportTime return the Lamport time at which the last version of the identity became valid.
+func (i *Identity) LastModificationLamport() lamport.Time {
+ return i.lastVersion().time
+}
+
+// LastModification return the timestamp at which the last version of the identity became valid.
+func (i *Identity) LastModification() timestamp.Timestamp {
+ return timestamp.Timestamp(i.lastVersion().unixTime)
+}
+
+// SetMetadata store arbitrary metadata along the last defined Version.
+// If the Version has been commit to git already, it won't be overwritten.
+func (i *Identity) SetMetadata(key string, value string) {
+ i.lastVersion().SetMetadata(key, value)
+}
+
+// ImmutableMetadata return all metadata for this Identity, accumulated from each Version.
+// If multiple value are found, the first defined takes precedence.
+func (i *Identity) ImmutableMetadata() map[string]string {
+ metadata := make(map[string]string)
+
+ for _, version := range i.versions {
+ for key, value := range version.metadata {
+ if _, has := metadata[key]; !has {
+ metadata[key] = value
+ }
+ }
+ }
+
+ return metadata
+}
+
+// MutableMetadata return all metadata for this Identity, accumulated from each Version.
+// If multiple value are found, the last defined takes precedence.
+func (i *Identity) MutableMetadata() map[string]string {
+ metadata := make(map[string]string)
+
+ for _, version := range i.versions {
+ for key, value := range version.metadata {
+ metadata[key] = value
+ }
+ }
+
+ return metadata
+}
diff --git a/identity/identity_actions.go b/identity/identity_actions.go
new file mode 100644
index 00000000..53997eef
--- /dev/null
+++ b/identity/identity_actions.go
@@ -0,0 +1,187 @@
+package identity
+
+import (
+ "fmt"
+ "strings"
+
+ "github.com/MichaelMure/git-bug/repository"
+ "github.com/pkg/errors"
+)
+
+// Fetch retrieve updates from a remote
+// This does not change the local identities state
+func Fetch(repo repository.Repo, remote string) (string, error) {
+ remoteRefSpec := fmt.Sprintf(identityRemoteRefPattern, remote)
+ fetchRefSpec := fmt.Sprintf("%s*:%s*", identityRefPattern, remoteRefSpec)
+
+ return repo.FetchRefs(remote, fetchRefSpec)
+}
+
+// Push update a remote with the local changes
+func Push(repo repository.Repo, remote string) (string, error) {
+ return repo.PushRefs(remote, identityRefPattern+"*")
+}
+
+// Pull will do a Fetch + MergeAll
+// This function will return an error if a merge fail
+func Pull(repo repository.ClockedRepo, remote string) error {
+ _, err := Fetch(repo, remote)
+ if err != nil {
+ return err
+ }
+
+ for merge := range MergeAll(repo, remote) {
+ if merge.Err != nil {
+ return merge.Err
+ }
+ if merge.Status == MergeStatusInvalid {
+ return errors.Errorf("merge failure: %s", merge.Reason)
+ }
+ }
+
+ return nil
+}
+
+// MergeAll will merge all the available remote identity
+func MergeAll(repo repository.ClockedRepo, remote string) <-chan MergeResult {
+ out := make(chan MergeResult)
+
+ go func() {
+ defer close(out)
+
+ remoteRefSpec := fmt.Sprintf(identityRemoteRefPattern, remote)
+ remoteRefs, err := repo.ListRefs(remoteRefSpec)
+
+ if err != nil {
+ out <- MergeResult{Err: err}
+ return
+ }
+
+ for _, remoteRef := range remoteRefs {
+ refSplitted := strings.Split(remoteRef, "/")
+ id := refSplitted[len(refSplitted)-1]
+
+ remoteIdentity, err := read(repo, remoteRef)
+
+ if err != nil {
+ out <- newMergeInvalidStatus(id, errors.Wrap(err, "remote identity is not readable").Error())
+ continue
+ }
+
+ // Check for error in remote data
+ if err := remoteIdentity.Validate(); err != nil {
+ out <- newMergeInvalidStatus(id, errors.Wrap(err, "remote identity is invalid").Error())
+ continue
+ }
+
+ localRef := identityRefPattern + remoteIdentity.Id()
+ localExist, err := repo.RefExist(localRef)
+
+ if err != nil {
+ out <- newMergeError(err, id)
+ continue
+ }
+
+ // the identity is not local yet, simply create the reference
+ if !localExist {
+ err := repo.CopyRef(remoteRef, localRef)
+
+ if err != nil {
+ out <- newMergeError(err, id)
+ return
+ }
+
+ out <- newMergeStatus(MergeStatusNew, id, remoteIdentity)
+ continue
+ }
+
+ localIdentity, err := read(repo, localRef)
+
+ if err != nil {
+ out <- newMergeError(errors.Wrap(err, "local identity is not readable"), id)
+ return
+ }
+
+ updated, err := localIdentity.Merge(repo, remoteIdentity)
+
+ if err != nil {
+ out <- newMergeInvalidStatus(id, errors.Wrap(err, "merge failed").Error())
+ return
+ }
+
+ if updated {
+ out <- newMergeStatus(MergeStatusUpdated, id, localIdentity)
+ } else {
+ out <- newMergeStatus(MergeStatusNothing, id, localIdentity)
+ }
+ }
+ }()
+
+ return out
+}
+
+// MergeStatus represent the result of a merge operation of a bug
+type MergeStatus int
+
+const (
+ _ MergeStatus = iota
+ MergeStatusNew
+ MergeStatusInvalid
+ MergeStatusUpdated
+ MergeStatusNothing
+)
+
+// Todo: share a generalized MergeResult with the bug package ?
+type MergeResult struct {
+ // Err is set when a terminal error occur in the process
+ Err error
+
+ Id string
+ Status MergeStatus
+
+ // Only set for invalid status
+ Reason string
+
+ // Not set for invalid status
+ Identity *Identity
+}
+
+func (mr MergeResult) String() string {
+ switch mr.Status {
+ case MergeStatusNew:
+ return "new"
+ case MergeStatusInvalid:
+ return fmt.Sprintf("invalid data: %s", mr.Reason)
+ case MergeStatusUpdated:
+ return "updated"
+ case MergeStatusNothing:
+ return "nothing to do"
+ default:
+ panic("unknown merge status")
+ }
+}
+
+func newMergeError(err error, id string) MergeResult {
+ return MergeResult{
+ Err: err,
+ Id: id,
+ }
+}
+
+func newMergeStatus(status MergeStatus, id string, identity *Identity) MergeResult {
+ return MergeResult{
+ Id: id,
+ Status: status,
+
+ // Identity is not set for an invalid merge result
+ Identity: identity,
+ }
+}
+
+func newMergeInvalidStatus(id string, reason string) MergeResult {
+ return MergeResult{
+ Id: id,
+ Status: MergeStatusInvalid,
+ Reason: reason,
+ }
+}
diff --git a/identity/identity_actions_test.go b/identity/identity_actions_test.go
new file mode 100644
index 00000000..42563374
--- /dev/null
+++ b/identity/identity_actions_test.go
@@ -0,0 +1,151 @@
+package identity
+
+import (
+ "testing"
+
+ "github.com/MichaelMure/git-bug/util/test"
+ "github.com/stretchr/testify/require"
+)
+
+func TestPushPull(t *testing.T) {
+ repoA, repoB, remote := test.SetupReposAndRemote(t)
+ defer test.CleanupRepos(repoA, repoB, remote)
+
+ identity1 := NewIdentity("name1", "email1")
+ err := identity1.Commit(repoA)
+ require.NoError(t, err)
+
+ // A --> remote --> B
+ _, err = Push(repoA, "origin")
+ require.NoError(t, err)
+
+ err = Pull(repoB, "origin")
+ require.NoError(t, err)
+
+ identities := allIdentities(t, ReadAllLocalIdentities(repoB))
+
+ if len(identities) != 1 {
+ t.Fatal("Unexpected number of bugs")
+ }
+
+ // B --> remote --> A
+ identity2 := NewIdentity("name2", "email2")
+ err = identity2.Commit(repoB)
+ require.NoError(t, err)
+
+ _, err = Push(repoB, "origin")
+ require.NoError(t, err)
+
+ err = Pull(repoA, "origin")
+ require.NoError(t, err)
+
+ identities = allIdentities(t, ReadAllLocalIdentities(repoA))
+
+ if len(identities) != 2 {
+ t.Fatal("Unexpected number of bugs")
+ }
+
+ // Update both
+
+ identity1.AddVersion(&Version{
+ name: "name1b",
+ email: "email1b",
+ })
+ err = identity1.Commit(repoA)
+ require.NoError(t, err)
+
+ identity2.AddVersion(&Version{
+ name: "name2b",
+ email: "email2b",
+ })
+ err = identity2.Commit(repoB)
+ require.NoError(t, err)
+
+ // A --> remote --> B
+
+ _, err = Push(repoA, "origin")
+ require.NoError(t, err)
+
+ err = Pull(repoB, "origin")
+ require.NoError(t, err)
+
+ identities = allIdentities(t, ReadAllLocalIdentities(repoB))
+
+ if len(identities) != 2 {
+ t.Fatal("Unexpected number of bugs")
+ }
+
+ // B --> remote --> A
+
+ _, err = Push(repoB, "origin")
+ require.NoError(t, err)
+
+ err = Pull(repoA, "origin")
+ require.NoError(t, err)
+
+ identities = allIdentities(t, ReadAllLocalIdentities(repoA))
+
+ if len(identities) != 2 {
+ t.Fatal("Unexpected number of bugs")
+ }
+
+ // Concurrent update
+
+ identity1.AddVersion(&Version{
+ name: "name1c",
+ email: "email1c",
+ })
+ err = identity1.Commit(repoA)
+ require.NoError(t, err)
+
+ identity1B, err := ReadLocal(repoB, identity1.Id())
+ require.NoError(t, err)
+
+ identity1B.AddVersion(&Version{
+ name: "name1concurrent",
+ email: "email1concurrent",
+ })
+ err = identity1B.Commit(repoB)
+ require.NoError(t, err)
+
+ // A --> remote --> B
+
+ _, err = Push(repoA, "origin")
+ require.NoError(t, err)
+
+ // Pulling a non-fast-forward update should fail
+ err = Pull(repoB, "origin")
+ require.Error(t, err)
+
+ identities = allIdentities(t, ReadAllLocalIdentities(repoB))
+
+ if len(identities) != 2 {
+ t.Fatal("Unexpected number of bugs")
+ }
+
+ // B --> remote --> A
+
+ // Pushing a non-fast-forward update should fail
+ _, err = Push(repoB, "origin")
+ require.Error(t, err)
+
+ err = Pull(repoA, "origin")
+ require.NoError(t, err)
+
+ identities = allIdentities(t, ReadAllLocalIdentities(repoA))
+
+ if len(identities) != 2 {
+ t.Fatal("Unexpected number of bugs")
+ }
+}
+
+func allIdentities(t testing.TB, identities <-chan StreamedIdentity) []*Identity {
+ var result []*Identity
+ for streamed := range identities {
+ if streamed.Err != nil {
+ t.Fatal(streamed.Err)
+ }
+ result = append(result, streamed.Identity)
+ }
+ return result
+}
diff --git a/identity/identity_stub.go b/identity/identity_stub.go
new file mode 100644
index 00000000..592eab30
--- /dev/null
+++ b/identity/identity_stub.go
@@ -0,0 +1,104 @@
+package identity
+
+import (
+ "encoding/json"
+
+ "github.com/MichaelMure/git-bug/repository"
+ "github.com/MichaelMure/git-bug/util/lamport"
+ "github.com/MichaelMure/git-bug/util/timestamp"
+)
+
+var _ Interface = &IdentityStub{}
+
+// IdentityStub is an almost empty Identity, holding only the id.
+// When a normal Identity is serialized into JSON, only the id is serialized.
+// All the other data are stored in git in a chain of commit + a ref.
+// When this JSON is deserialized, an IdentityStub is returned instead, to be replaced
+// later by the proper Identity, loaded from the Repo.
+type IdentityStub struct {
+ id string
+}
+
+func (i *IdentityStub) MarshalJSON() ([]byte, error) {
+ return json.Marshal(struct {
+ Id string `json:"id"`
+ }{
+ Id: i.id,
+ })
+}
+
+func (i *IdentityStub) UnmarshalJSON(data []byte) error {
+ aux := struct {
+ Id string `json:"id"`
+ }{}
+
+ if err := json.Unmarshal(data, &aux); err != nil {
+ return err
+ }
+
+ i.id = aux.Id
+
+ return nil
+}
+
+// Id return the Identity identifier
+func (i *IdentityStub) Id() string {
+ return i.id
+}
+
+// HumanId return the Identity identifier truncated for human consumption
+func (i *IdentityStub) HumanId() string {
+ return FormatHumanID(i.Id())
+}
+
+func (IdentityStub) Name() string {
+ panic("identities needs to be properly loaded with identity.ReadLocal()")
+}
+
+func (IdentityStub) Email() string {
+ panic("identities needs to be properly loaded with identity.ReadLocal()")
+}
+
+func (IdentityStub) Login() string {
+ panic("identities needs to be properly loaded with identity.ReadLocal()")
+}
+
+func (IdentityStub) AvatarUrl() string {
+ panic("identities needs to be properly loaded with identity.ReadLocal()")
+}
+
+func (IdentityStub) Keys() []Key {
+ panic("identities needs to be properly loaded with identity.ReadLocal()")
+}
+
+func (IdentityStub) ValidKeysAtTime(time lamport.Time) []Key {
+ panic("identities needs to be properly loaded with identity.ReadLocal()")
+}
+
+func (IdentityStub) DisplayName() string {
+ panic("identities needs to be properly loaded with identity.ReadLocal()")
+}
+
+func (IdentityStub) Validate() error {
+ panic("identities needs to be properly loaded with identity.ReadLocal()")
+}
+
+func (IdentityStub) Commit(repo repository.ClockedRepo) error {
+ panic("identities needs to be properly loaded with identity.ReadLocal()")
+}
+
+func (i *IdentityStub) CommitAsNeeded(repo repository.ClockedRepo) error {
+ panic("identities needs to be properly loaded with identity.ReadLocal()")
+}
+
+func (IdentityStub) IsProtected() bool {
+ panic("identities needs to be properly loaded with identity.ReadLocal()")
+}
+
+func (i *IdentityStub) LastModificationLamport() lamport.Time {
+ panic("identities needs to be properly loaded with identity.ReadLocal()")
+}
+
+func (i *IdentityStub) LastModification() timestamp.Timestamp {
+ panic("identities needs to be properly loaded with identity.ReadLocal()")
+}
diff --git a/identity/identity_stub_test.go b/identity/identity_stub_test.go
new file mode 100644
index 00000000..3d94cd3e
--- /dev/null
+++ b/identity/identity_stub_test.go
@@ -0,0 +1,23 @@
+package identity
+
+import (
+ "encoding/json"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestIdentityStubSerialize(t *testing.T) {
+ before := &IdentityStub{
+ id: "id1234",
+ }
+
+ data, err := json.Marshal(before)
+ assert.NoError(t, err)
+
+ var after IdentityStub
+ err = json.Unmarshal(data, &after)
+ assert.NoError(t, err)
+
+ assert.Equal(t, before, &after)
+}
diff --git a/identity/identity_test.go b/identity/identity_test.go
new file mode 100644
index 00000000..2ddb64ea
--- /dev/null
+++ b/identity/identity_test.go
@@ -0,0 +1,244 @@
+package identity
+
+import (
+ "encoding/json"
+ "testing"
+
+ "github.com/MichaelMure/git-bug/repository"
+ "github.com/stretchr/testify/assert"
+)
+
+// Test the commit and load of an Identity with multiple versions
+func TestIdentityCommitLoad(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)
+
+ loaded, err := ReadLocal(mockRepo, identity.id)
+ assert.Nil(t, err)
+ commitsAreSet(t, loaded)
+ assert.Equal(t, identity, loaded)
+
+ // 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)
+
+ loaded, err = ReadLocal(mockRepo, identity.id)
+ assert.Nil(t, err)
+ commitsAreSet(t, loaded)
+ assert.Equal(t, identity, loaded)
+
+ // 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)
+
+ loaded, err = ReadLocal(mockRepo, identity.id)
+ assert.Nil(t, err)
+ commitsAreSet(t, loaded)
+ assert.Equal(t, identity, loaded)
+}
+
+func commitsAreSet(t *testing.T, identity *Identity) {
+ for _, version := range identity.versions {
+ assert.NotEmpty(t, version.commitHash)
+ }
+}
+
+// Test that the correct crypto keys are returned for a given lamport time
+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"}})
+}
+
+// Test the immutable or mutable metadata search
+func TestMetadata(t *testing.T) {
+ mockRepo := repository.NewMockRepoForTest()
+
+ identity := NewIdentity("René Descartes", "rene.descartes@example.com")
+
+ identity.SetMetadata("key1", "value1")
+ assertHasKeyValue(t, identity.ImmutableMetadata(), "key1", "value1")
+ assertHasKeyValue(t, identity.MutableMetadata(), "key1", "value1")
+
+ err := identity.Commit(mockRepo)
+ assert.NoError(t, err)
+
+ assertHasKeyValue(t, identity.ImmutableMetadata(), "key1", "value1")
+ assertHasKeyValue(t, identity.MutableMetadata(), "key1", "value1")
+
+ // try override
+ identity.AddVersion(&Version{
+ name: "René Descartes",
+ email: "rene.descartes@example.com",
+ })
+
+ identity.SetMetadata("key1", "value2")
+ assertHasKeyValue(t, identity.ImmutableMetadata(), "key1", "value1")
+ assertHasKeyValue(t, identity.MutableMetadata(), "key1", "value2")
+
+ err = identity.Commit(mockRepo)
+ assert.NoError(t, err)
+
+ // reload
+ loaded, err := ReadLocal(mockRepo, identity.id)
+ assert.Nil(t, err)
+
+ assertHasKeyValue(t, loaded.ImmutableMetadata(), "key1", "value1")
+ assertHasKeyValue(t, loaded.MutableMetadata(), "key1", "value2")
+}
+
+func assertHasKeyValue(t *testing.T, metadata map[string]string, key, value string) {
+ val, ok := metadata[key]
+ assert.True(t, ok)
+ assert.Equal(t, val, value)
+}
+
+func TestJSON(t *testing.T) {
+ mockRepo := repository.NewMockRepoForTest()
+
+ identity := &Identity{
+ versions: []*Version{
+ {
+ name: "René Descartes",
+ email: "rene.descartes@example.com",
+ },
+ },
+ }
+
+ // commit to make sure we have an ID
+ err := identity.Commit(mockRepo)
+ assert.Nil(t, err)
+ assert.NotEmpty(t, identity.id)
+
+ // serialize
+ data, err := json.Marshal(identity)
+ assert.NoError(t, err)
+
+ // deserialize, got a IdentityStub with the same id
+ var i Interface
+ i, err = UnmarshalJSON(data)
+ assert.NoError(t, err)
+ assert.Equal(t, identity.id, i.Id())
+
+ // make sure we can load the identity properly
+ i, err = ReadLocal(mockRepo, i.Id())
+ assert.NoError(t, err)
+}
diff --git a/identity/interface.go b/identity/interface.go
new file mode 100644
index 00000000..88f1d9a7
--- /dev/null
+++ b/identity/interface.go
@@ -0,0 +1,58 @@
+package identity
+
+import (
+ "github.com/MichaelMure/git-bug/repository"
+ "github.com/MichaelMure/git-bug/util/lamport"
+ "github.com/MichaelMure/git-bug/util/timestamp"
+)
+
+type Interface interface {
+ // Id return the Identity identifier
+ Id() string
+
+ // HumanId return the Identity identifier truncated for human consumption
+ HumanId() string
+
+ // Name return the last version of the name
+ Name() string
+
+ // Email return the last version of the email
+ Email() string
+
+ // Login return the last version of the login
+ Login() string
+
+ // AvatarUrl return the last version of the Avatar URL
+ AvatarUrl() string
+
+ // Keys 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
+
+ // Validate check if the Identity data is valid
+ Validate() error
+
+ // Write the identity into the Repository. In particular, this ensure that
+ // the Id is properly set.
+ Commit(repo repository.ClockedRepo) error
+
+ // If needed, write the identity into the Repository. In particular, this
+ // ensure that the Id is properly set.
+ CommitAsNeeded(repo repository.ClockedRepo) 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
+
+ // LastModificationLamportTime return the Lamport time at which the last version of the identity became valid.
+ LastModificationLamport() lamport.Time
+
+ // LastModification return the timestamp at which the last version of the identity became valid.
+ LastModification() timestamp.Timestamp
+}
diff --git a/identity/key.go b/identity/key.go
new file mode 100644
index 00000000..90edfb60
--- /dev/null
+++ b/identity/key.go
@@ -0,0 +1,13 @@
+package identity
+
+type Key struct {
+ // The GPG fingerprint of the key
+ Fingerprint string `json:"fingerprint"`
+ PubKey string `json:"pub_key"`
+}
+
+func (k *Key) Validate() error {
+ // Todo
+
+ return nil
+}
diff --git a/identity/resolver.go b/identity/resolver.go
new file mode 100644
index 00000000..7facfc0c
--- /dev/null
+++ b/identity/resolver.go
@@ -0,0 +1,22 @@
+package identity
+
+import "github.com/MichaelMure/git-bug/repository"
+
+// Resolver define the interface of an Identity resolver, able to load
+// an identity from, for example, a repo or a cache.
+type Resolver interface {
+ ResolveIdentity(id string) (Interface, error)
+}
+
+// DefaultResolver is a Resolver loading Identities directly from a Repo
+type SimpleResolver struct {
+ repo repository.Repo
+}
+
+func NewSimpleResolver(repo repository.Repo) *SimpleResolver {
+ return &SimpleResolver{repo: repo}
+}
+
+func (r *SimpleResolver) ResolveIdentity(id string) (Interface, error) {
+ return ReadLocal(r.repo, id)
+}
diff --git a/identity/version.go b/identity/version.go
new file mode 100644
index 00000000..95530767
--- /dev/null
+++ b/identity/version.go
@@ -0,0 +1,208 @@
+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"
+ "github.com/pkg/errors"
+)
+
+const formatVersion = 1
+
+// Version is a complete set of information about an Identity at a point in time.
+type Version struct {
+ // The lamport time at which this version become effective
+ // The reference time is the bug edition lamport clock
+ // It must be the first field in this struct due to https://github.com/golang/go/issues/599
+ time lamport.Time
+ unixTime int64
+
+ name string
+ email string
+ login string
+ avatarURL string
+
+ // 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
+
+ // 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
+
+ // A set of arbitrary key/value to store metadata about a version or about an Identity in general.
+ metadata map[string]string
+
+ // Not serialized
+ commitHash git.Hash
+}
+
+type VersionJSON struct {
+ // Additional field to version the data
+ FormatVersion uint `json:"version"`
+
+ Time lamport.Time `json:"time"`
+ UnixTime int64 `json:"unix_time"`
+ Name string `json:"name,omitempty"`
+ Email string `json:"email,omitempty"`
+ Login string `json:"login,omitempty"`
+ AvatarUrl string `json:"avatar_url,omitempty"`
+ Keys []Key `json:"pub_keys,omitempty"`
+ Nonce []byte `json:"nonce,omitempty"`
+ Metadata map[string]string `json:"metadata,omitempty"`
+}
+
+func (v *Version) MarshalJSON() ([]byte, error) {
+ return json.Marshal(VersionJSON{
+ FormatVersion: formatVersion,
+ Time: v.time,
+ UnixTime: v.unixTime,
+ Name: v.name,
+ Email: v.email,
+ Login: v.login,
+ AvatarUrl: v.avatarURL,
+ Keys: v.keys,
+ Nonce: v.nonce,
+ Metadata: v.metadata,
+ })
+}
+
+func (v *Version) UnmarshalJSON(data []byte) error {
+ var aux VersionJSON
+
+ if err := json.Unmarshal(data, &aux); err != nil {
+ return err
+ }
+
+ if aux.FormatVersion != formatVersion {
+ return fmt.Errorf("unknown format version %v", aux.FormatVersion)
+ }
+
+ v.time = aux.Time
+ v.unixTime = aux.UnixTime
+ v.name = aux.Name
+ v.email = aux.Email
+ v.login = aux.Login
+ v.avatarURL = aux.AvatarUrl
+ v.keys = aux.Keys
+ v.nonce = aux.Nonce
+ v.metadata = aux.Metadata
+
+ return nil
+}
+
+func (v *Version) Validate() error {
+ // time must be set after a commit
+ if v.commitHash != "" && v.unixTime == 0 {
+ return fmt.Errorf("unix time not set")
+ }
+ if v.commitHash != "" && v.time == 0 {
+ return fmt.Errorf("lamport time not set")
+ }
+
+ 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")
+ }
+
+ for _, k := range v.keys {
+ if err := k.Validate(); err != nil {
+ return errors.Wrap(err, "invalid key")
+ }
+ }
+
+ 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) {
+ // make sure we don't write invalid data
+ err := v.Validate()
+ if err != nil {
+ return "", errors.Wrap(err, "validation 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
+}
+
+// SetMetadata store arbitrary metadata about a version or an Identity in general
+// If the Version has been commit to git already, it won't be overwritten.
+func (v *Version) SetMetadata(key string, value string) {
+ if v.metadata == nil {
+ v.metadata = make(map[string]string)
+ }
+
+ v.metadata[key] = value
+}
+
+// GetMetadata retrieve arbitrary metadata about the Version
+func (v *Version) GetMetadata(key string) (string, bool) {
+ val, ok := v.metadata[key]
+ return val, ok
+}
+
+// AllMetadata return all metadata for this Identity
+func (v *Version) AllMetadata() map[string]string {
+ return v.metadata
+}
diff --git a/identity/version_test.go b/identity/version_test.go
new file mode 100644
index 00000000..8c4c8d99
--- /dev/null
+++ b/identity/version_test.go
@@ -0,0 +1,42 @@
+package identity
+
+import (
+ "encoding/json"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestVersionSerialize(t *testing.T) {
+ before := &Version{
+ login: "login",
+ name: "name",
+ email: "email",
+ avatarURL: "avatarUrl",
+ keys: []Key{
+ {
+ Fingerprint: "fingerprint1",
+ PubKey: "pubkey1",
+ },
+ {
+ Fingerprint: "fingerprint2",
+ PubKey: "pubkey2",
+ },
+ },
+ nonce: makeNonce(20),
+ metadata: map[string]string{
+ "key1": "value1",
+ "key2": "value2",
+ },
+ time: 3,
+ }
+
+ data, err := json.Marshal(before)
+ assert.NoError(t, err)
+
+ var after Version
+ err = json.Unmarshal(data, &after)
+ assert.NoError(t, err)
+
+ assert.Equal(t, before, &after)
+}