diff options
Diffstat (limited to 'identity')
-rw-r--r-- | identity/bare.go | 204 | ||||
-rw-r--r-- | identity/bare_test.go | 32 | ||||
-rw-r--r-- | identity/common.go | 53 | ||||
-rw-r--r-- | identity/identity.go | 584 | ||||
-rw-r--r-- | identity/identity_actions.go | 187 | ||||
-rw-r--r-- | identity/identity_actions_test.go | 151 | ||||
-rw-r--r-- | identity/identity_stub.go | 104 | ||||
-rw-r--r-- | identity/identity_stub_test.go | 23 | ||||
-rw-r--r-- | identity/identity_test.go | 244 | ||||
-rw-r--r-- | identity/interface.go | 58 | ||||
-rw-r--r-- | identity/key.go | 13 | ||||
-rw-r--r-- | identity/resolver.go | 22 | ||||
-rw-r--r-- | identity/version.go | 208 | ||||
-rw-r--r-- | identity/version_test.go | 42 |
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) +} |