aboutsummaryrefslogtreecommitdiffstats
path: root/entities/identity
diff options
context:
space:
mode:
authorMichael Muré <batolettre@gmail.com>2022-08-20 10:14:09 +0200
committerGitHub <noreply@github.com>2022-08-20 10:14:09 +0200
commit58df94d38d754bff4dcca11e2ae4b99326a9a87e (patch)
tree8701efc87732439f993eb4f1d00585fc419b87ab /entities/identity
parent5ca686b59751e3c87740b84108c54fc675a074cf (diff)
parent5511c230b678a181cc596238bf6669428d1b1902 (diff)
downloadgit-bug-58df94d38d754bff4dcca11e2ae4b99326a9a87e.tar.gz
Merge pull request #852 from MichaelMure/move-around
move {bug,identity} to /entities, move input to /commands
Diffstat (limited to 'entities/identity')
-rw-r--r--entities/identity/common.go37
-rw-r--r--entities/identity/identity.go620
-rw-r--r--entities/identity/identity_actions.go125
-rw-r--r--entities/identity/identity_actions_test.go157
-rw-r--r--entities/identity/identity_stub.go101
-rw-r--r--entities/identity/identity_stub_test.go26
-rw-r--r--entities/identity/identity_test.go292
-rw-r--r--entities/identity/identity_user.go68
-rw-r--r--entities/identity/interface.go62
-rw-r--r--entities/identity/key.go234
-rw-r--r--entities/identity/key_test.go60
-rw-r--r--entities/identity/resolver.go34
-rw-r--r--entities/identity/version.go273
-rw-r--r--entities/identity/version_test.go78
14 files changed, 2167 insertions, 0 deletions
diff --git a/entities/identity/common.go b/entities/identity/common.go
new file mode 100644
index 00000000..5c6445e9
--- /dev/null
+++ b/entities/identity/common.go
@@ -0,0 +1,37 @@
+package identity
+
+import (
+ "encoding/json"
+ "errors"
+ "fmt"
+
+ "github.com/MichaelMure/git-bug/entity"
+)
+
+var ErrIdentityNotExist = errors.New("identity doesn't exist")
+
+func NewErrMultipleMatch(matching []entity.Id) *entity.ErrMultipleMatch {
+ return entity.NewErrMultipleMatch("identity", matching)
+}
+
+// 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
+ }
+
+ return nil, fmt.Errorf("unknown identity type")
+}
diff --git a/entities/identity/identity.go b/entities/identity/identity.go
new file mode 100644
index 00000000..0a7642af
--- /dev/null
+++ b/entities/identity/identity.go
@@ -0,0 +1,620 @@
+// Package identity contains the identity data model and low-level related functions
+package identity
+
+import (
+ "encoding/json"
+ "fmt"
+ "reflect"
+
+ "github.com/pkg/errors"
+
+ "github.com/MichaelMure/git-bug/entity"
+ "github.com/MichaelMure/git-bug/repository"
+ "github.com/MichaelMure/git-bug/util/lamport"
+ "github.com/MichaelMure/git-bug/util/timestamp"
+)
+
+const identityRefPattern = "refs/identities/"
+const identityRemoteRefPattern = "refs/remotes/%s/identities/"
+const versionEntryName = "version"
+const identityConfigKey = "git-bug.identity"
+
+var ErrNonFastForwardMerge = errors.New("non fast-forward identity merge")
+var ErrNoIdentitySet = errors.New("No identity is set.\n" +
+ "To interact with bugs, an identity first needs to be created using " +
+ "\"git bug user create\"")
+var ErrMultipleIdentitiesSet = errors.New("multiple user identities set")
+
+func NewErrMultipleMatchIdentity(matching []entity.Id) *entity.ErrMultipleMatch {
+ return entity.NewErrMultipleMatch("identity", matching)
+}
+
+var _ Interface = &Identity{}
+var _ entity.Interface = &Identity{}
+
+type Identity struct {
+ // all the successive version of the identity
+ versions []*version
+}
+
+func NewIdentity(repo repository.RepoClock, name string, email string) (*Identity, error) {
+ return NewIdentityFull(repo, name, email, "", "", nil)
+}
+
+func NewIdentityFull(repo repository.RepoClock, name string, email string, login string, avatarUrl string, keys []*Key) (*Identity, error) {
+ v, err := newVersion(repo, name, email, login, avatarUrl, keys)
+ if err != nil {
+ return nil, err
+ }
+ return &Identity{
+ versions: []*version{v},
+ }, nil
+}
+
+// NewFromGitUser will query the repository for user detail and
+// build the corresponding Identity
+func NewFromGitUser(repo repository.ClockedRepo) (*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(repo, name, email)
+}
+
+// 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 entity.Id) (*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) {
+ id := entity.RefToId(ref)
+
+ if err := id.Validate(); err != nil {
+ return nil, errors.Wrap(err, "invalid ref")
+ }
+
+ hashes, err := repo.ListCommits(ref)
+ if err != nil {
+ return nil, ErrIdentityNotExist
+ }
+ if len(hashes) == 0 {
+ return nil, fmt.Errorf("empty identity")
+ }
+
+ i := &Identity{}
+
+ for _, hash := range hashes {
+ entries, err := repo.ReadTree(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.versions = append(i.versions, &version)
+ }
+
+ if id != i.versions[0].Id() {
+ return nil, fmt.Errorf("identity ID doesn't math the first version ID")
+ }
+
+ return i, nil
+}
+
+// ListLocalIds list all the available local identity ids
+func ListLocalIds(repo repository.Repo) ([]entity.Id, error) {
+ refs, err := repo.ListRefs(identityRefPattern)
+ if err != nil {
+ return nil, err
+ }
+
+ return entity.RefsToIds(refs), nil
+}
+
+// RemoveIdentity will remove a local identity from its entity.Id
+func RemoveIdentity(repo repository.ClockedRepo, id entity.Id) error {
+ var fullMatches []string
+
+ refs, err := repo.ListRefs(identityRefPattern + id.String())
+ if err != nil {
+ return err
+ }
+ if len(refs) > 1 {
+ return NewErrMultipleMatchIdentity(entity.RefsToIds(refs))
+ }
+ if len(refs) == 1 {
+ // we have the identity locally
+ fullMatches = append(fullMatches, refs[0])
+ }
+
+ remotes, err := repo.GetRemotes()
+ if err != nil {
+ return err
+ }
+
+ for remote := range remotes {
+ remotePrefix := fmt.Sprintf(identityRemoteRefPattern+id.String(), remote)
+ remoteRefs, err := repo.ListRefs(remotePrefix)
+ if err != nil {
+ return err
+ }
+ if len(remoteRefs) > 1 {
+ return NewErrMultipleMatchIdentity(entity.RefsToIds(refs))
+ }
+ if len(remoteRefs) == 1 {
+ // found the identity in a remote
+ fullMatches = append(fullMatches, remoteRefs[0])
+ }
+ }
+
+ if len(fullMatches) == 0 {
+ return ErrIdentityNotExist
+ }
+
+ for _, ref := range fullMatches {
+ err = repo.RemoveRef(ref)
+ if err != nil {
+ return err
+ }
+ }
+
+ return nil
+}
+
+type StreamedIdentity struct {
+ Identity *Identity
+ Err error
+}
+
+// ReadAllLocal read and parse all local Identity
+func ReadAllLocal(repo repository.ClockedRepo) <-chan StreamedIdentity {
+ return readAll(repo, identityRefPattern)
+}
+
+// ReadAllRemote read and parse all remote Identity for a given remote
+func ReadAllRemote(repo repository.ClockedRepo, remote string) <-chan StreamedIdentity {
+ refPrefix := fmt.Sprintf(identityRemoteRefPattern, remote)
+ return readAll(repo, refPrefix)
+}
+
+// readAll read and parse all available bug with a given ref prefix
+func readAll(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
+}
+
+type Mutator struct {
+ Name string
+ Login string
+ Email string
+ AvatarUrl string
+ Keys []*Key
+}
+
+// Mutate allow to create a new version of the Identity in one go
+func (i *Identity) Mutate(repo repository.RepoClock, f func(orig *Mutator)) error {
+ copyKeys := func(keys []*Key) []*Key {
+ result := make([]*Key, len(keys))
+ for i, key := range keys {
+ result[i] = key.Clone()
+ }
+ return result
+ }
+
+ orig := Mutator{
+ Name: i.Name(),
+ Email: i.Email(),
+ Login: i.Login(),
+ AvatarUrl: i.AvatarUrl(),
+ Keys: copyKeys(i.Keys()),
+ }
+ mutated := orig
+ mutated.Keys = copyKeys(orig.Keys)
+
+ f(&mutated)
+
+ if reflect.DeepEqual(orig, mutated) {
+ return nil
+ }
+
+ v, err := newVersion(repo,
+ mutated.Name,
+ mutated.Email,
+ mutated.Login,
+ mutated.AvatarUrl,
+ mutated.Keys,
+ )
+ if err != nil {
+ return err
+ }
+
+ i.versions = append(i.versions, v)
+ return nil
+}
+
+// Write the identity into the Repository. In particular, this ensure that
+// the Id is properly set.
+func (i *Identity) Commit(repo repository.ClockedRepo) error {
+ 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")
+ }
+
+ var lastCommit repository.Hash
+ for _, v := range i.versions {
+ if v.commitHash != "" {
+ lastCommit = v.commitHash
+ // ignore already commit 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 repository.Hash
+ if lastCommit != "" {
+ commitHash, err = repo.StoreCommit(treeHash, lastCommit)
+ } else {
+ commitHash, err = repo.StoreCommit(treeHash)
+ }
+ if err != nil {
+ return err
+ }
+
+ lastCommit = commitHash
+ v.commitHash = commitHash
+ }
+
+ ref := fmt.Sprintf("%s%s", identityRefPattern, i.Id().String())
+ return repo.UpdateRef(ref, lastCommit)
+}
+
+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 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 when 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 its 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")
+ }
+
+ modified := false
+ var lastCommit repository.Hash
+ 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)
+ 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().String(), lastCommit)
+ if err != nil {
+ return false, err
+ }
+ }
+
+ return false, nil
+}
+
+// Validate check if the Identity data is valid
+func (i *Identity) Validate() error {
+ lastTimes := make(map[string]lamport.Time)
+
+ if len(i.versions) == 0 {
+ return fmt.Errorf("no version")
+ }
+
+ for _, v := range i.versions {
+ if err := v.Validate(); err != nil {
+ return err
+ }
+
+ // check for always increasing lamport time
+ // check that a new version didn't drop a clock
+ for name, previous := range lastTimes {
+ if now, ok := v.times[name]; ok {
+ if now < previous {
+ return fmt.Errorf("non-chronological lamport clock %s (%d --> %d)", name, previous, now)
+ }
+ } else {
+ return fmt.Errorf("version has less lamport clocks than before (missing %s)", name)
+ }
+ }
+
+ for name, now := range v.times {
+ lastTimes[name] = now
+ }
+ }
+
+ 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() entity.Id {
+ // id is the id of the first version
+ return i.versions[0].Id()
+}
+
+// Name return the last version of the name
+func (i *Identity) Name() string {
+ return i.lastVersion().name
+}
+
+// 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")
+}
+
+// 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
+}
+
+// SigningKey return the key that should be used to sign new messages. If no key is available, return nil.
+func (i *Identity) SigningKey(repo repository.RepoKeyring) (*Key, error) {
+ keys := i.Keys()
+ for _, key := range keys {
+ err := key.ensurePrivateKey(repo)
+ if err == errNoPrivateKey {
+ continue
+ }
+ if err != nil {
+ return nil, err
+ }
+ return key, nil
+ }
+ return nil, nil
+}
+
+// ValidKeysAtTime return the set of keys valid at a given lamport time
+func (i *Identity) ValidKeysAtTime(clockName string, time lamport.Time) []*Key {
+ var result []*Key
+
+ var lastTime lamport.Time
+ for _, v := range i.versions {
+ refTime, ok := v.times[clockName]
+ if !ok {
+ refTime = lastTime
+ }
+ lastTime = refTime
+
+ if refTime > time {
+ return result
+ }
+
+ result = v.keys
+ }
+
+ return result
+}
+
+// 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)
+}
+
+// LastModificationLamports return the lamport times at which the last version of the identity became valid.
+func (i *Identity) LastModificationLamports() map[string]lamport.Time {
+ return i.lastVersion().times
+}
+
+// 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
+}
+
+// SetMetadata store arbitrary metadata along the last not-commit version.
+// If the version has been commit to git already, a new identical version is added and will need to be
+// commit.
+func (i *Identity) SetMetadata(key string, value string) {
+ // once commit, data is immutable so we create a new version
+ if i.lastVersion().commitHash != "" {
+ i.versions = append(i.versions, i.lastVersion().Clone())
+ }
+ // if Id() has been called, we can't change the first version anymore, so we create a new version
+ if len(i.versions) == 1 && i.versions[0].id != entity.UnsetId && i.versions[0].id != "" {
+ i.versions = append(i.versions, i.lastVersion().Clone())
+ }
+
+ 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/entities/identity/identity_actions.go b/entities/identity/identity_actions.go
new file mode 100644
index 00000000..b58bb2d9
--- /dev/null
+++ b/entities/identity/identity_actions.go
@@ -0,0 +1,125 @@
+package identity
+
+import (
+ "fmt"
+ "strings"
+
+ "github.com/pkg/errors"
+
+ "github.com/MichaelMure/git-bug/entity"
+ "github.com/MichaelMure/git-bug/repository"
+)
+
+// Fetch retrieve updates from a remote
+// This does not change the local identities state
+func Fetch(repo repository.Repo, remote string) (string, error) {
+ return repo.FetchRefs(remote, "identities")
+}
+
+// Push update a remote with the local changes
+func Push(repo repository.Repo, remote string) (string, error) {
+ return repo.PushRefs(remote, "identities")
+}
+
+// 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 == entity.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 entity.MergeResult {
+ out := make(chan entity.MergeResult)
+
+ go func() {
+ defer close(out)
+
+ remoteRefSpec := fmt.Sprintf(identityRemoteRefPattern, remote)
+ remoteRefs, err := repo.ListRefs(remoteRefSpec)
+
+ if err != nil {
+ out <- entity.MergeResult{Err: err}
+ return
+ }
+
+ for _, remoteRef := range remoteRefs {
+ refSplit := strings.Split(remoteRef, "/")
+ id := entity.Id(refSplit[len(refSplit)-1])
+
+ if err := id.Validate(); err != nil {
+ out <- entity.NewMergeInvalidStatus(id, errors.Wrap(err, "invalid ref").Error())
+ continue
+ }
+
+ remoteIdentity, err := read(repo, remoteRef)
+
+ if err != nil {
+ out <- entity.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 <- entity.NewMergeInvalidStatus(id, errors.Wrap(err, "remote identity is invalid").Error())
+ continue
+ }
+
+ localRef := identityRefPattern + remoteIdentity.Id().String()
+ localExist, err := repo.RefExist(localRef)
+
+ if err != nil {
+ out <- entity.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 <- entity.NewMergeError(err, id)
+ return
+ }
+
+ out <- entity.NewMergeNewStatus(id, remoteIdentity)
+ continue
+ }
+
+ localIdentity, err := read(repo, localRef)
+
+ if err != nil {
+ out <- entity.NewMergeError(errors.Wrap(err, "local identity is not readable"), id)
+ return
+ }
+
+ updated, err := localIdentity.Merge(repo, remoteIdentity)
+
+ if err != nil {
+ out <- entity.NewMergeInvalidStatus(id, errors.Wrap(err, "merge failed").Error())
+ return
+ }
+
+ if updated {
+ out <- entity.NewMergeUpdatedStatus(id, localIdentity)
+ } else {
+ out <- entity.NewMergeNothingStatus(id)
+ }
+ }
+ }()
+
+ return out
+}
diff --git a/entities/identity/identity_actions_test.go b/entities/identity/identity_actions_test.go
new file mode 100644
index 00000000..351fb7a4
--- /dev/null
+++ b/entities/identity/identity_actions_test.go
@@ -0,0 +1,157 @@
+package identity
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/require"
+
+ "github.com/MichaelMure/git-bug/repository"
+)
+
+func TestIdentityPushPull(t *testing.T) {
+ repoA, repoB, _ := repository.SetupGoGitReposAndRemote(t)
+
+ identity1, err := NewIdentity(repoA, "name1", "email1")
+ require.NoError(t, err)
+ 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, ReadAllLocal(repoB))
+
+ if len(identities) != 1 {
+ t.Fatal("Unexpected number of bugs")
+ }
+
+ // B --> remote --> A
+ identity2, err := NewIdentity(repoB, "name2", "email2")
+ require.NoError(t, err)
+ 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, ReadAllLocal(repoA))
+
+ if len(identities) != 2 {
+ t.Fatal("Unexpected number of bugs")
+ }
+
+ // Update both
+
+ err = identity1.Mutate(repoA, func(orig *Mutator) {
+ orig.Name = "name1b"
+ orig.Email = "email1b"
+ })
+ require.NoError(t, err)
+ err = identity1.Commit(repoA)
+ require.NoError(t, err)
+
+ err = identity2.Mutate(repoB, func(orig *Mutator) {
+ orig.Name = "name2b"
+ orig.Email = "email2b"
+ })
+ require.NoError(t, err)
+ 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, ReadAllLocal(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, ReadAllLocal(repoA))
+
+ if len(identities) != 2 {
+ t.Fatal("Unexpected number of bugs")
+ }
+
+ // Concurrent update
+
+ err = identity1.Mutate(repoA, func(orig *Mutator) {
+ orig.Name = "name1c"
+ orig.Email = "email1c"
+ })
+ require.NoError(t, err)
+ err = identity1.Commit(repoA)
+ require.NoError(t, err)
+
+ identity1B, err := ReadLocal(repoB, identity1.Id())
+ require.NoError(t, err)
+
+ err = identity1B.Mutate(repoB, func(orig *Mutator) {
+ orig.Name = "name1concurrent"
+ orig.Email = "name1concurrent"
+ })
+ require.NoError(t, err)
+ 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, ReadAllLocal(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, ReadAllLocal(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/entities/identity/identity_stub.go b/entities/identity/identity_stub.go
new file mode 100644
index 00000000..fb5c90a5
--- /dev/null
+++ b/entities/identity/identity_stub.go
@@ -0,0 +1,101 @@
+package identity
+
+import (
+ "encoding/json"
+
+ "github.com/MichaelMure/git-bug/entity"
+ "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 entity.Id
+}
+
+func (i *IdentityStub) MarshalJSON() ([]byte, error) {
+ // TODO: add a type marker
+ return json.Marshal(struct {
+ Id entity.Id `json:"id"`
+ }{
+ Id: i.id,
+ })
+}
+
+func (i *IdentityStub) UnmarshalJSON(data []byte) error {
+ aux := struct {
+ Id entity.Id `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() entity.Id {
+ return i.id
+}
+
+func (IdentityStub) Name() string {
+ 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) 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 (i *IdentityStub) SigningKey(repo repository.RepoKeyring) (*Key, error) {
+ panic("identities needs to be properly loaded with identity.ReadLocal()")
+}
+
+func (IdentityStub) ValidKeysAtTime(_ string, _ lamport.Time) []*Key {
+ 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()")
+}
+
+func (i *IdentityStub) LastModificationLamports() map[string]lamport.Time {
+ 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 (IdentityStub) Validate() error {
+ panic("identities needs to be properly loaded with identity.ReadLocal()")
+}
+
+func (i *IdentityStub) NeedCommit() bool {
+ return false
+}
diff --git a/entities/identity/identity_stub_test.go b/entities/identity/identity_stub_test.go
new file mode 100644
index 00000000..b01a718c
--- /dev/null
+++ b/entities/identity/identity_stub_test.go
@@ -0,0 +1,26 @@
+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)
+
+ // enforce creating the Id
+ before.Id()
+
+ assert.Equal(t, before, &after)
+}
diff --git a/entities/identity/identity_test.go b/entities/identity/identity_test.go
new file mode 100644
index 00000000..f0c3bbe9
--- /dev/null
+++ b/entities/identity/identity_test.go
@@ -0,0 +1,292 @@
+package identity
+
+import (
+ "encoding/json"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+
+ "github.com/MichaelMure/git-bug/repository"
+ "github.com/MichaelMure/git-bug/util/lamport"
+)
+
+// Test the commit and load of an Identity with multiple versions
+func TestIdentityCommitLoad(t *testing.T) {
+ repo := makeIdentityTestRepo(t)
+
+ // single version
+
+ identity, err := NewIdentity(repo, "René Descartes", "rene.descartes@example.com")
+ require.NoError(t, err)
+
+ idBeforeCommit := identity.Id()
+
+ err = identity.Commit(repo)
+ require.NoError(t, err)
+
+ commitsAreSet(t, identity)
+ require.NotEmpty(t, identity.Id())
+ require.Equal(t, idBeforeCommit, identity.Id())
+ require.Equal(t, idBeforeCommit, identity.versions[0].Id())
+
+ loaded, err := ReadLocal(repo, identity.Id())
+ require.NoError(t, err)
+ commitsAreSet(t, loaded)
+ require.Equal(t, identity, loaded)
+
+ // multiple versions
+
+ identity, err = NewIdentityFull(repo, "René Descartes", "rene.descartes@example.com", "", "", []*Key{generatePublicKey()})
+ require.NoError(t, err)
+
+ idBeforeCommit = identity.Id()
+
+ err = identity.Mutate(repo, func(orig *Mutator) {
+ orig.Keys = []*Key{generatePublicKey()}
+ })
+ require.NoError(t, err)
+
+ err = identity.Mutate(repo, func(orig *Mutator) {
+ orig.Keys = []*Key{generatePublicKey()}
+ })
+ require.NoError(t, err)
+
+ require.Equal(t, idBeforeCommit, identity.Id())
+
+ err = identity.Commit(repo)
+ require.NoError(t, err)
+
+ commitsAreSet(t, identity)
+ require.NotEmpty(t, identity.Id())
+ require.Equal(t, idBeforeCommit, identity.Id())
+ require.Equal(t, idBeforeCommit, identity.versions[0].Id())
+
+ loaded, err = ReadLocal(repo, identity.Id())
+ require.NoError(t, err)
+ commitsAreSet(t, loaded)
+ require.Equal(t, identity, loaded)
+
+ // add more version
+
+ err = identity.Mutate(repo, func(orig *Mutator) {
+ orig.Email = "rene@descartes.com"
+ orig.Keys = []*Key{generatePublicKey()}
+ })
+ require.NoError(t, err)
+
+ err = identity.Mutate(repo, func(orig *Mutator) {
+ orig.Email = "rene@descartes.com"
+ orig.Keys = []*Key{generatePublicKey(), generatePublicKey()}
+ })
+ require.NoError(t, err)
+
+ err = identity.Commit(repo)
+ require.NoError(t, err)
+
+ commitsAreSet(t, identity)
+ require.NotEmpty(t, identity.Id())
+ require.Equal(t, idBeforeCommit, identity.Id())
+ require.Equal(t, idBeforeCommit, identity.versions[0].Id())
+
+ loaded, err = ReadLocal(repo, identity.Id())
+ require.NoError(t, err)
+ commitsAreSet(t, loaded)
+ require.Equal(t, identity, loaded)
+}
+
+func TestIdentityMutate(t *testing.T) {
+ repo := makeIdentityTestRepo(t)
+
+ identity, err := NewIdentity(repo, "René Descartes", "rene.descartes@example.com")
+ require.NoError(t, err)
+
+ require.Len(t, identity.versions, 1)
+
+ err = identity.Mutate(repo, func(orig *Mutator) {
+ orig.Email = "rene@descartes.fr"
+ orig.Name = "René"
+ orig.Login = "rene"
+ })
+ require.NoError(t, err)
+
+ require.Len(t, identity.versions, 2)
+ require.Equal(t, identity.Email(), "rene@descartes.fr")
+ require.Equal(t, identity.Name(), "René")
+ require.Equal(t, identity.Login(), "rene")
+}
+
+func commitsAreSet(t *testing.T, identity *Identity) {
+ for _, version := range identity.versions {
+ require.NotEmpty(t, version.commitHash)
+ }
+}
+
+// Test that the correct crypto keys are returned for a given lamport time
+func TestIdentity_ValidKeysAtTime(t *testing.T) {
+ pubKeyA := generatePublicKey()
+ pubKeyB := generatePublicKey()
+ pubKeyC := generatePublicKey()
+ pubKeyD := generatePublicKey()
+ pubKeyE := generatePublicKey()
+
+ identity := Identity{
+ versions: []*version{
+ {
+ times: map[string]lamport.Time{"foo": 100},
+ keys: []*Key{pubKeyA},
+ },
+ {
+ times: map[string]lamport.Time{"foo": 200},
+ keys: []*Key{pubKeyB},
+ },
+ {
+ times: map[string]lamport.Time{"foo": 201},
+ keys: []*Key{pubKeyC},
+ },
+ {
+ times: map[string]lamport.Time{"foo": 201},
+ keys: []*Key{pubKeyD},
+ },
+ {
+ times: map[string]lamport.Time{"foo": 300},
+ keys: []*Key{pubKeyE},
+ },
+ },
+ }
+
+ require.Nil(t, identity.ValidKeysAtTime("foo", 10))
+ require.Equal(t, identity.ValidKeysAtTime("foo", 100), []*Key{pubKeyA})
+ require.Equal(t, identity.ValidKeysAtTime("foo", 140), []*Key{pubKeyA})
+ require.Equal(t, identity.ValidKeysAtTime("foo", 200), []*Key{pubKeyB})
+ require.Equal(t, identity.ValidKeysAtTime("foo", 201), []*Key{pubKeyD})
+ require.Equal(t, identity.ValidKeysAtTime("foo", 202), []*Key{pubKeyD})
+ require.Equal(t, identity.ValidKeysAtTime("foo", 300), []*Key{pubKeyE})
+ require.Equal(t, identity.ValidKeysAtTime("foo", 3000), []*Key{pubKeyE})
+}
+
+// Test the immutable or mutable metadata search
+func TestMetadata(t *testing.T) {
+ repo := makeIdentityTestRepo(t)
+
+ identity, err := NewIdentity(repo, "René Descartes", "rene.descartes@example.com")
+ require.NoError(t, err)
+
+ identity.SetMetadata("key1", "value1")
+ assertHasKeyValue(t, identity.ImmutableMetadata(), "key1", "value1")
+ assertHasKeyValue(t, identity.MutableMetadata(), "key1", "value1")
+
+ err = identity.Commit(repo)
+ require.NoError(t, err)
+
+ assertHasKeyValue(t, identity.ImmutableMetadata(), "key1", "value1")
+ assertHasKeyValue(t, identity.MutableMetadata(), "key1", "value1")
+
+ // try override
+ err = identity.Mutate(repo, func(orig *Mutator) {
+ orig.Email = "rene@descartes.fr"
+ })
+ require.NoError(t, err)
+
+ identity.SetMetadata("key1", "value2")
+ assertHasKeyValue(t, identity.ImmutableMetadata(), "key1", "value1")
+ assertHasKeyValue(t, identity.MutableMetadata(), "key1", "value2")
+
+ err = identity.Commit(repo)
+ require.NoError(t, err)
+
+ // reload
+ loaded, err := ReadLocal(repo, identity.Id())
+ require.NoError(t, err)
+
+ assertHasKeyValue(t, loaded.ImmutableMetadata(), "key1", "value1")
+ assertHasKeyValue(t, loaded.MutableMetadata(), "key1", "value2")
+
+ // set metadata after commit
+ versionCount := len(identity.versions)
+ identity.SetMetadata("foo", "bar")
+ require.True(t, identity.NeedCommit())
+ require.Len(t, identity.versions, versionCount+1)
+
+ err = identity.Commit(repo)
+ require.NoError(t, err)
+ require.Len(t, identity.versions, versionCount+1)
+}
+
+func assertHasKeyValue(t *testing.T, metadata map[string]string, key, value string) {
+ val, ok := metadata[key]
+ require.True(t, ok)
+ require.Equal(t, val, value)
+}
+
+func TestJSON(t *testing.T) {
+ repo := makeIdentityTestRepo(t)
+
+ identity, err := NewIdentity(repo, "René Descartes", "rene.descartes@example.com")
+ require.NoError(t, err)
+
+ // commit to make sure we have an Id
+ err = identity.Commit(repo)
+ require.NoError(t, err)
+ require.NotEmpty(t, identity.Id())
+
+ // serialize
+ data, err := json.Marshal(identity)
+ require.NoError(t, err)
+
+ // deserialize, got a IdentityStub with the same id
+ var i Interface
+ i, err = UnmarshalJSON(data)
+ require.NoError(t, err)
+ require.Equal(t, identity.Id(), i.Id())
+
+ // make sure we can load the identity properly
+ i, err = ReadLocal(repo, i.Id())
+ require.NoError(t, err)
+}
+
+func TestIdentityRemove(t *testing.T) {
+ repo := repository.CreateGoGitTestRepo(t, false)
+ remoteA := repository.CreateGoGitTestRepo(t, true)
+ remoteB := repository.CreateGoGitTestRepo(t, true)
+
+ err := repo.AddRemote("remoteA", remoteA.GetLocalRemote())
+ require.NoError(t, err)
+
+ err = repo.AddRemote("remoteB", remoteB.GetLocalRemote())
+ require.NoError(t, err)
+
+ // generate an identity for testing
+ rene, err := NewIdentity(repo, "René Descartes", "rene@descartes.fr")
+ require.NoError(t, err)
+
+ err = rene.Commit(repo)
+ require.NoError(t, err)
+
+ _, err = Push(repo, "remoteA")
+ require.NoError(t, err)
+
+ _, err = Push(repo, "remoteB")
+ require.NoError(t, err)
+
+ _, err = Fetch(repo, "remoteA")
+ require.NoError(t, err)
+
+ _, err = Fetch(repo, "remoteB")
+ require.NoError(t, err)
+
+ err = RemoveIdentity(repo, rene.Id())
+ require.NoError(t, err)
+
+ _, err = ReadLocal(repo, rene.Id())
+ require.Error(t, ErrIdentityNotExist, err)
+
+ _, err = ReadRemote(repo, "remoteA", string(rene.Id()))
+ require.Error(t, ErrIdentityNotExist, err)
+
+ _, err = ReadRemote(repo, "remoteB", string(rene.Id()))
+ require.Error(t, ErrIdentityNotExist, err)
+
+ ids, err := ListLocalIds(repo)
+ require.NoError(t, err)
+ require.Len(t, ids, 0)
+}
diff --git a/entities/identity/identity_user.go b/entities/identity/identity_user.go
new file mode 100644
index 00000000..cd67459e
--- /dev/null
+++ b/entities/identity/identity_user.go
@@ -0,0 +1,68 @@
+package identity
+
+import (
+ "fmt"
+ "os"
+
+ "github.com/pkg/errors"
+
+ "github.com/MichaelMure/git-bug/entity"
+ "github.com/MichaelMure/git-bug/repository"
+)
+
+// SetUserIdentity store the user identity's id in the git config
+func SetUserIdentity(repo repository.RepoConfig, identity *Identity) error {
+ return repo.LocalConfig().StoreString(identityConfigKey, identity.Id().String())
+}
+
+// GetUserIdentity read the current user identity, set with a git config entry
+func GetUserIdentity(repo repository.Repo) (*Identity, error) {
+ id, err := GetUserIdentityId(repo)
+ if err != nil {
+ return nil, err
+ }
+
+ i, err := ReadLocal(repo, id)
+ if err == ErrIdentityNotExist {
+ innerErr := repo.LocalConfig().RemoveAll(identityConfigKey)
+ if innerErr != nil {
+ _, _ = fmt.Fprintln(os.Stderr, errors.Wrap(innerErr, "can't clear user identity").Error())
+ }
+ return nil, err
+ }
+
+ return i, nil
+}
+
+func GetUserIdentityId(repo repository.Repo) (entity.Id, error) {
+ val, err := repo.LocalConfig().ReadString(identityConfigKey)
+ if err == repository.ErrNoConfigEntry {
+ return entity.UnsetId, ErrNoIdentitySet
+ }
+ if err == repository.ErrMultipleConfigEntry {
+ return entity.UnsetId, ErrMultipleIdentitiesSet
+ }
+ if err != nil {
+ return entity.UnsetId, err
+ }
+
+ var id = entity.Id(val)
+
+ if err := id.Validate(); err != nil {
+ return entity.UnsetId, err
+ }
+
+ return id, nil
+}
+
+// IsUserIdentitySet say if the user has set his identity
+func IsUserIdentitySet(repo repository.Repo) (bool, error) {
+ _, err := repo.LocalConfig().ReadString(identityConfigKey)
+ if err == repository.ErrNoConfigEntry {
+ return false, nil
+ }
+ if err != nil {
+ return false, err
+ }
+ return true, nil
+}
diff --git a/entities/identity/interface.go b/entities/identity/interface.go
new file mode 100644
index 00000000..c6e22e00
--- /dev/null
+++ b/entities/identity/interface.go
@@ -0,0 +1,62 @@
+package identity
+
+import (
+ "github.com/MichaelMure/git-bug/entity"
+ "github.com/MichaelMure/git-bug/repository"
+ "github.com/MichaelMure/git-bug/util/lamport"
+ "github.com/MichaelMure/git-bug/util/timestamp"
+)
+
+type Interface interface {
+ entity.Interface
+
+ // Name return the last version of the name
+ // Can be empty.
+ Name() string
+
+ // DisplayName return a non-empty string to display, representing the
+ // identity, based on the non-empty values.
+ DisplayName() string
+
+ // Email return the last version of the email
+ // Can be empty.
+ Email() string
+
+ // Login return the last version of the login
+ // Can be empty.
+ // Warning: this login can be defined when importing from a bridge but should *not* be
+ // used to identify an identity as multiple bridge with different login can map to the same
+ // identity. Use the metadata system for that usage instead.
+ Login() string
+
+ // AvatarUrl return the last version of the Avatar URL
+ // Can be empty.
+ AvatarUrl() string
+
+ // Keys return the last version of the valid keys
+ // Can be empty.
+ Keys() []*Key
+
+ // SigningKey return the key that should be used to sign new messages. If no key is available, return nil.
+ SigningKey(repo repository.RepoKeyring) (*Key, error)
+
+ // ValidKeysAtTime return the set of keys valid at a given lamport time for a given clock of another entity
+ // Can be empty.
+ ValidKeysAtTime(clockName string, time lamport.Time) []*Key
+
+ // LastModification return the timestamp at which the last version of the identity became valid.
+ LastModification() timestamp.Timestamp
+
+ // LastModificationLamports return the lamport times at which the last version of the identity became valid.
+ LastModificationLamports() map[string]lamport.Time
+
+ // 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
+
+ // Validate check if the Identity data is valid
+ Validate() error
+
+ // NeedCommit indicate that the in-memory state changed and need to be committed in the repository
+ NeedCommit() bool
+}
diff --git a/entities/identity/key.go b/entities/identity/key.go
new file mode 100644
index 00000000..82b9b95c
--- /dev/null
+++ b/entities/identity/key.go
@@ -0,0 +1,234 @@
+package identity
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "io"
+ "strings"
+ "time"
+
+ "github.com/ProtonMail/go-crypto/openpgp"
+ "github.com/ProtonMail/go-crypto/openpgp/armor"
+ "github.com/ProtonMail/go-crypto/openpgp/packet"
+ "github.com/pkg/errors"
+
+ "github.com/MichaelMure/git-bug/repository"
+)
+
+var errNoPrivateKey = fmt.Errorf("no private key")
+
+type Key struct {
+ public *packet.PublicKey
+ private *packet.PrivateKey
+}
+
+// GenerateKey generate a keypair (public+private)
+// The type and configuration of the key is determined by the default value in go's OpenPGP.
+func GenerateKey() *Key {
+ entity, err := openpgp.NewEntity("", "", "", &packet.Config{
+ // The armored format doesn't include the creation time, which makes the round-trip data not being fully equal.
+ // We don't care about the creation time so we can set it to the zero value.
+ Time: func() time.Time {
+ return time.Time{}
+ },
+ })
+ if err != nil {
+ panic(err)
+ }
+ return &Key{
+ public: entity.PrimaryKey,
+ private: entity.PrivateKey,
+ }
+}
+
+// generatePublicKey generate only a public key (only useful for testing)
+// See GenerateKey for the details.
+func generatePublicKey() *Key {
+ k := GenerateKey()
+ k.private = nil
+ return k
+}
+
+func (k *Key) Public() *packet.PublicKey {
+ return k.public
+}
+
+func (k *Key) Private() *packet.PrivateKey {
+ return k.private
+}
+
+func (k *Key) Validate() error {
+ if k.public == nil {
+ return fmt.Errorf("nil public key")
+ }
+ if !k.public.CanSign() {
+ return fmt.Errorf("public key can't sign")
+ }
+
+ if k.private != nil {
+ if !k.private.CanSign() {
+ return fmt.Errorf("private key can't sign")
+ }
+ }
+
+ return nil
+}
+
+func (k *Key) Clone() *Key {
+ clone := &Key{}
+
+ pub := *k.public
+ clone.public = &pub
+
+ if k.private != nil {
+ priv := *k.private
+ clone.private = &priv
+ }
+
+ return clone
+}
+
+func (k *Key) MarshalJSON() ([]byte, error) {
+ // Serialize only the public key, in the armored format.
+ var buf bytes.Buffer
+ w, err := armor.Encode(&buf, openpgp.PublicKeyType, nil)
+ if err != nil {
+ return nil, err
+ }
+
+ err = k.public.Serialize(w)
+ if err != nil {
+ return nil, err
+ }
+ err = w.Close()
+ if err != nil {
+ return nil, err
+ }
+ return json.Marshal(buf.String())
+}
+
+func (k *Key) UnmarshalJSON(data []byte) error {
+ // De-serialize only the public key, in the armored format.
+ var armored string
+ err := json.Unmarshal(data, &armored)
+ if err != nil {
+ return err
+ }
+
+ block, err := armor.Decode(strings.NewReader(armored))
+ if err == io.EOF {
+ return fmt.Errorf("no armored data found")
+ }
+ if err != nil {
+ return err
+ }
+
+ if block.Type != openpgp.PublicKeyType {
+ return fmt.Errorf("invalid key type")
+ }
+
+ p, err := packet.Read(block.Body)
+ if err != nil {
+ return errors.Wrap(err, "failed to read public key packet")
+ }
+
+ public, ok := p.(*packet.PublicKey)
+ if !ok {
+ return errors.New("got no packet.publicKey")
+ }
+
+ // The armored format doesn't include the creation time, which makes the round-trip data not being fully equal.
+ // We don't care about the creation time so we can set it to the zero value.
+ public.CreationTime = time.Time{}
+
+ k.public = public
+ return nil
+}
+
+func (k *Key) loadPrivate(repo repository.RepoKeyring) error {
+ item, err := repo.Keyring().Get(k.public.KeyIdString())
+ if err == repository.ErrKeyringKeyNotFound {
+ return errNoPrivateKey
+ }
+ if err != nil {
+ return err
+ }
+
+ block, err := armor.Decode(bytes.NewReader(item.Data))
+ if err == io.EOF {
+ return fmt.Errorf("no armored data found")
+ }
+ if err != nil {
+ return err
+ }
+
+ if block.Type != openpgp.PrivateKeyType {
+ return fmt.Errorf("invalid key type")
+ }
+
+ p, err := packet.Read(block.Body)
+ if err != nil {
+ return errors.Wrap(err, "failed to read private key packet")
+ }
+
+ private, ok := p.(*packet.PrivateKey)
+ if !ok {
+ return errors.New("got no packet.privateKey")
+ }
+
+ // The armored format doesn't include the creation time, which makes the round-trip data not being fully equal.
+ // We don't care about the creation time so we can set it to the zero value.
+ private.CreationTime = time.Time{}
+
+ k.private = private
+ return nil
+}
+
+// ensurePrivateKey attempt to load the corresponding private key if it is not loaded already.
+// If no private key is found, returns errNoPrivateKey
+func (k *Key) ensurePrivateKey(repo repository.RepoKeyring) error {
+ if k.private != nil {
+ return nil
+ }
+
+ return k.loadPrivate(repo)
+}
+
+func (k *Key) storePrivate(repo repository.RepoKeyring) error {
+ var buf bytes.Buffer
+ w, err := armor.Encode(&buf, openpgp.PrivateKeyType, nil)
+ if err != nil {
+ return err
+ }
+ err = k.private.Serialize(w)
+ if err != nil {
+ return err
+ }
+ err = w.Close()
+ if err != nil {
+ return err
+ }
+
+ return repo.Keyring().Set(repository.Item{
+ Key: k.public.KeyIdString(),
+ Data: buf.Bytes(),
+ })
+}
+
+func (k *Key) PGPEntity() *openpgp.Entity {
+ uid := packet.NewUserId("", "", "")
+ return &openpgp.Entity{
+ PrimaryKey: k.public,
+ PrivateKey: k.private,
+ Identities: map[string]*openpgp.Identity{
+ uid.Id: {
+ Name: uid.Id,
+ UserId: uid,
+ SelfSignature: &packet.Signature{
+ IsPrimaryId: func() *bool { b := true; return &b }(),
+ },
+ },
+ },
+ }
+}
diff --git a/entities/identity/key_test.go b/entities/identity/key_test.go
new file mode 100644
index 00000000..6e320dc2
--- /dev/null
+++ b/entities/identity/key_test.go
@@ -0,0 +1,60 @@
+package identity
+
+import (
+ "crypto/rsa"
+ "encoding/json"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+
+ "github.com/MichaelMure/git-bug/repository"
+)
+
+func TestPublicKeyJSON(t *testing.T) {
+ k := generatePublicKey()
+
+ dataJSON, err := json.Marshal(k)
+ require.NoError(t, err)
+
+ var read Key
+ err = json.Unmarshal(dataJSON, &read)
+ require.NoError(t, err)
+
+ require.Equal(t, k, &read)
+}
+
+func TestStoreLoad(t *testing.T) {
+ repo := repository.NewMockRepoKeyring()
+
+ // public + private
+ k := GenerateKey()
+
+ // Store
+
+ dataJSON, err := json.Marshal(k)
+ require.NoError(t, err)
+
+ err = k.storePrivate(repo)
+ require.NoError(t, err)
+
+ // Load
+
+ var read Key
+ err = json.Unmarshal(dataJSON, &read)
+ require.NoError(t, err)
+
+ err = read.ensurePrivateKey(repo)
+ require.NoError(t, err)
+
+ require.Equal(t, k.public, read.public)
+
+ require.IsType(t, (*rsa.PrivateKey)(nil), k.private.PrivateKey)
+
+ // See https://github.com/golang/crypto/pull/175
+ rsaPriv := read.private.PrivateKey.(*rsa.PrivateKey)
+ back := rsaPriv.Primes[0]
+ rsaPriv.Primes[0] = rsaPriv.Primes[1]
+ rsaPriv.Primes[1] = back
+
+ require.True(t, k.private.PrivateKey.(*rsa.PrivateKey).Equal(read.private.PrivateKey))
+}
diff --git a/entities/identity/resolver.go b/entities/identity/resolver.go
new file mode 100644
index 00000000..5468a8f8
--- /dev/null
+++ b/entities/identity/resolver.go
@@ -0,0 +1,34 @@
+package identity
+
+import (
+ "github.com/MichaelMure/git-bug/entity"
+ "github.com/MichaelMure/git-bug/repository"
+)
+
+var _ entity.Resolver = &SimpleResolver{}
+
+// SimpleResolver 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) Resolve(id entity.Id) (entity.Interface, error) {
+ return ReadLocal(r.repo, id)
+}
+
+var _ entity.Resolver = &StubResolver{}
+
+// StubResolver is a Resolver that doesn't load anything, only returning IdentityStub instances
+type StubResolver struct{}
+
+func NewStubResolver() *StubResolver {
+ return &StubResolver{}
+}
+
+func (s *StubResolver) Resolve(id entity.Id) (entity.Interface, error) {
+ return &IdentityStub{id: id}, nil
+}
diff --git a/entities/identity/version.go b/entities/identity/version.go
new file mode 100644
index 00000000..9a52d089
--- /dev/null
+++ b/entities/identity/version.go
@@ -0,0 +1,273 @@
+package identity
+
+import (
+ "crypto/rand"
+ "encoding/json"
+ "fmt"
+ "time"
+
+ "github.com/pkg/errors"
+
+ "github.com/MichaelMure/git-bug/entity"
+ "github.com/MichaelMure/git-bug/repository"
+ "github.com/MichaelMure/git-bug/util/lamport"
+ "github.com/MichaelMure/git-bug/util/text"
+)
+
+// 1: original format
+// 2: Identity Ids are generated from the first version serialized data instead of from the first git commit
+// + Identity hold multiple lamport clocks from other entities, instead of just bug edit
+const formatVersion = 2
+
+// version is a complete set of information about an Identity at a point in time.
+type version struct {
+ name string
+ email string // as defined in git or from a bridge when importing the identity
+ login string // from a bridge when importing the identity
+ avatarURL string
+
+ // The lamport times of the other entities at which this version become effective
+ times map[string]lamport.Time
+ unixTime int64
+
+ // 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
+
+ // mandatory random bytes to ensure a better randomness of the data of the first
+ // version of an identity, used to later generate the ID
+ // len(Nonce) should be > 20 and < 64 bytes
+ // It has no functional purpose and should be ignored.
+ // TODO: optional after first version?
+ 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. Store the version's id in memory.
+ id entity.Id
+ // Not serialized
+ commitHash repository.Hash
+}
+
+func newVersion(repo repository.RepoClock, name string, email string, login string, avatarURL string, keys []*Key) (*version, error) {
+ clocks, err := repo.AllClocks()
+ if err != nil {
+ return nil, err
+ }
+
+ times := make(map[string]lamport.Time)
+ for name, clock := range clocks {
+ times[name] = clock.Time()
+ }
+
+ return &version{
+ id: entity.UnsetId,
+ name: name,
+ email: email,
+ login: login,
+ avatarURL: avatarURL,
+ times: times,
+ unixTime: time.Now().Unix(),
+ keys: keys,
+ nonce: makeNonce(20),
+ }, nil
+}
+
+type versionJSON struct {
+ // Additional field to version the data
+ FormatVersion uint `json:"version"`
+
+ Times map[string]lamport.Time `json:"times"`
+ 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"`
+ Metadata map[string]string `json:"metadata,omitempty"`
+}
+
+// Id return the identifier of the version
+func (v *version) Id() entity.Id {
+ if v.id == "" {
+ // something went really wrong
+ panic("version's id not set")
+ }
+ if v.id == entity.UnsetId {
+ // This means we are trying to get the version's Id *before* it has been stored.
+ // As the Id is computed based on the actual bytes written on the disk, we are going to predict
+ // those and then get the Id. This is safe as it will be the exact same code writing on disk later.
+ data, err := json.Marshal(v)
+ if err != nil {
+ panic(err)
+ }
+ v.id = entity.DeriveId(data)
+ }
+ return v.id
+}
+
+// Make a deep copy
+func (v *version) Clone() *version {
+ // copy direct fields
+ clone := *v
+
+ // reset some fields
+ clone.commitHash = ""
+ clone.id = entity.UnsetId
+
+ clone.times = make(map[string]lamport.Time)
+ for name, t := range v.times {
+ clone.times[name] = t
+ }
+
+ clone.keys = make([]*Key, len(v.keys))
+ for i, key := range v.keys {
+ clone.keys[i] = key.Clone()
+ }
+
+ clone.nonce = make([]byte, len(v.nonce))
+ copy(clone.nonce, v.nonce)
+
+ // not copying metadata
+
+ return &clone
+}
+
+func (v *version) MarshalJSON() ([]byte, error) {
+ return json.Marshal(versionJSON{
+ FormatVersion: formatVersion,
+ Times: v.times,
+ 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 entity.NewErrInvalidFormat(aux.FormatVersion, formatVersion)
+ }
+
+ v.id = entity.DeriveId(data)
+ v.times = aux.Times
+ 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 text.Empty(v.name) && text.Empty(v.login) {
+ return fmt.Errorf("either name or login should be set")
+ }
+ if !text.SafeOneLine(v.name) {
+ return fmt.Errorf("name has unsafe characters")
+ }
+
+ if !text.SafeOneLine(v.login) {
+ return fmt.Errorf("login has unsafe characters")
+ }
+
+ if !text.SafeOneLine(v.email) {
+ return fmt.Errorf("email has unsafe characters")
+ }
+
+ 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")
+ }
+ if len(v.nonce) < 20 {
+ return fmt.Errorf("nonce is too small")
+ }
+
+ 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) (repository.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
+ }
+
+ // make sure we set the Id when writing in the repo
+ v.id = entity.DeriveId(data)
+
+ 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.
+// Beware: changing the metadata on a version will change it's ID
+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 version
+func (v *version) AllMetadata() map[string]string {
+ return v.metadata
+}
diff --git a/entities/identity/version_test.go b/entities/identity/version_test.go
new file mode 100644
index 00000000..385ad4d7
--- /dev/null
+++ b/entities/identity/version_test.go
@@ -0,0 +1,78 @@
+package identity
+
+import (
+ "encoding/json"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "github.com/MichaelMure/git-bug/entity"
+ "github.com/MichaelMure/git-bug/repository"
+ "github.com/MichaelMure/git-bug/util/lamport"
+)
+
+func makeIdentityTestRepo(t *testing.T) repository.ClockedRepo {
+ repo := repository.NewMockRepo()
+
+ clock1, err := repo.GetOrCreateClock("foo")
+ require.NoError(t, err)
+ err = clock1.Witness(42)
+ require.NoError(t, err)
+
+ clock2, err := repo.GetOrCreateClock("bar")
+ require.NoError(t, err)
+ err = clock2.Witness(34)
+ require.NoError(t, err)
+
+ return repo
+}
+
+func TestVersionJSON(t *testing.T) {
+ repo := makeIdentityTestRepo(t)
+
+ keys := []*Key{
+ generatePublicKey(),
+ generatePublicKey(),
+ }
+
+ before, err := newVersion(repo, "name", "email", "login", "avatarUrl", keys)
+ require.NoError(t, err)
+
+ before.SetMetadata("key1", "value1")
+ before.SetMetadata("key2", "value2")
+
+ expected := &version{
+ id: entity.UnsetId,
+ name: "name",
+ email: "email",
+ login: "login",
+ avatarURL: "avatarUrl",
+ unixTime: time.Now().Unix(),
+ times: map[string]lamport.Time{
+ "foo": 42,
+ "bar": 34,
+ },
+ keys: keys,
+ nonce: before.nonce,
+ metadata: map[string]string{
+ "key1": "value1",
+ "key2": "value2",
+ },
+ }
+
+ require.Equal(t, expected, before)
+
+ data, err := json.Marshal(before)
+ assert.NoError(t, err)
+
+ var after version
+ err = json.Unmarshal(data, &after)
+ assert.NoError(t, err)
+
+ // make sure we now have an Id
+ expected.Id()
+
+ assert.Equal(t, expected, &after)
+}