diff options
author | Michael Muré <batolettre@gmail.com> | 2022-08-01 17:16:45 +0200 |
---|---|---|
committer | GitHub <noreply@github.com> | 2022-08-01 17:16:45 +0200 |
commit | cd52872475f1b39f3fb6546606c1e78afb6c08e3 (patch) | |
tree | 9f04b1e1ee362e210e74aaa324d950058ed14bf2 /entity | |
parent | 2ade8fb1d570ddcb4aedc9386af46d208b129daa (diff) | |
parent | 56966fec5562c3a0e23340d0fbe754626c3beb64 (diff) | |
download | git-bug-cd52872475f1b39f3fb6546606c1e78afb6c08e3.tar.gz |
Merge pull request #835 from MichaelMure/op-base
entity/dag: proper base operation for simplified implementation
Diffstat (limited to 'entity')
-rw-r--r-- | entity/dag/common_test.go | 77 | ||||
-rw-r--r-- | entity/dag/entity.go | 2 | ||||
-rw-r--r-- | entity/dag/entity_test.go | 2 | ||||
-rw-r--r-- | entity/dag/example_test.go | 142 | ||||
-rw-r--r-- | entity/dag/op_noop.go | 39 | ||||
-rw-r--r-- | entity/dag/op_noop_test.go | 13 | ||||
-rw-r--r-- | entity/dag/op_set_metadata.go | 68 | ||||
-rw-r--r-- | entity/dag/op_set_metadata_test.go | 106 | ||||
-rw-r--r-- | entity/dag/operation.go | 232 | ||||
-rw-r--r-- | entity/dag/operation_pack.go | 7 | ||||
-rw-r--r-- | entity/dag/operation_pack_test.go | 62 | ||||
-rw-r--r-- | entity/dag/operation_testing.go | 57 |
12 files changed, 627 insertions, 180 deletions
diff --git a/entity/dag/common_test.go b/entity/dag/common_test.go index c2177683..774acba8 100644 --- a/entity/dag/common_test.go +++ b/entity/dag/common_test.go @@ -18,78 +18,73 @@ import ( Operations */ -type op1 struct { - author identity.Interface +const ( + _ OperationType = iota + Op1 + Op2 +) - OperationType int `json:"type"` - Field1 string `json:"field_1"` - Files []repository.Hash `json:"files"` +type op1 struct { + OpBase + Field1 string `json:"field_1"` + Files []repository.Hash `json:"files"` } func newOp1(author identity.Interface, field1 string, files ...repository.Hash) *op1 { - return &op1{author: author, OperationType: 1, Field1: field1, Files: files} + return &op1{OpBase: NewOpBase(Op1, author, 0), Field1: field1, Files: files} } -func (o *op1) Id() entity.Id { - data, _ := json.Marshal(o) - return entity.DeriveId(data) +func (op *op1) Id() entity.Id { + return IdOperation(op, &op.OpBase) } -func (o *op1) Validate() error { return nil } +func (op *op1) Validate() error { return nil } -func (o *op1) Author() identity.Interface { - return o.author -} - -func (o *op1) GetFiles() []repository.Hash { - return o.Files +func (op *op1) GetFiles() []repository.Hash { + return op.Files } type op2 struct { - author identity.Interface - - OperationType int `json:"type"` - Field2 string `json:"field_2"` + OpBase + Field2 string `json:"field_2"` } func newOp2(author identity.Interface, field2 string) *op2 { - return &op2{author: author, OperationType: 2, Field2: field2} + return &op2{OpBase: NewOpBase(Op2, author, 0), Field2: field2} } -func (o *op2) Id() entity.Id { - data, _ := json.Marshal(o) - return entity.DeriveId(data) +func (op *op2) Id() entity.Id { + return IdOperation(op, &op.OpBase) } -func (o *op2) Validate() error { return nil } - -func (o *op2) Author() identity.Interface { - return o.author -} +func (op *op2) Validate() error { return nil } -func unmarshaler(author identity.Interface, raw json.RawMessage, resolver identity.Resolver) (Operation, error) { +func unmarshaler(raw json.RawMessage, resolver identity.Resolver) (Operation, error) { var t struct { - OperationType int `json:"type"` + OperationType OperationType `json:"type"` } if err := json.Unmarshal(raw, &t); err != nil { return nil, err } + var op Operation + switch t.OperationType { - case 1: - op := &op1{} - err := json.Unmarshal(raw, &op) - op.author = author - return op, err - case 2: - op := &op2{} - err := json.Unmarshal(raw, &op) - op.author = author - return op, err + case Op1: + op = &op1{} + case Op2: + op = &op2{} default: return nil, fmt.Errorf("unknown operation type %v", t.OperationType) } + + err := json.Unmarshal(raw, &op) + if err != nil { + return nil, err + } + + return op, nil } /* diff --git a/entity/dag/entity.go b/entity/dag/entity.go index f3229b7e..4ccf0e0e 100644 --- a/entity/dag/entity.go +++ b/entity/dag/entity.go @@ -26,7 +26,7 @@ type Definition struct { // the Namespace in git references (bugs, prs, ...) Namespace string // a function decoding a JSON message into an Operation - OperationUnmarshaler func(author identity.Interface, raw json.RawMessage, resolver identity.Resolver) (Operation, error) + OperationUnmarshaler func(raw json.RawMessage, resolver identity.Resolver) (Operation, error) // the expected format version number, that can be used for data migration/upgrade FormatVersion uint } diff --git a/entity/dag/entity_test.go b/entity/dag/entity_test.go index 6d621bbe..e399b6c7 100644 --- a/entity/dag/entity_test.go +++ b/entity/dag/entity_test.go @@ -50,6 +50,8 @@ func TestWriteReadMultipleAuthor(t *testing.T) { } func assertEqualEntities(t *testing.T, a, b *Entity) { + t.Helper() + // testify doesn't support comparing functions and systematically fail if they are not nil // so we have to set them to nil temporarily diff --git a/entity/dag/example_test.go b/entity/dag/example_test.go index 5d4ea34d..94850bd9 100644 --- a/entity/dag/example_test.go +++ b/entity/dag/example_test.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "os" + "time" "github.com/MichaelMure/git-bug/entity" "github.com/MichaelMure/git-bug/entity/dag" @@ -64,10 +65,8 @@ type Operation interface { Apply(snapshot *Snapshot) } -type OperationType int - const ( - _ OperationType = iota + _ dag.OperationType = iota SetSignatureRequiredOp AddAdministratorOp RemoveAdministratorOp @@ -75,37 +74,30 @@ const ( // SetSignatureRequired is an operation to set/unset if git signature are required. type SetSignatureRequired struct { - author identity.Interface - OperationType OperationType `json:"type"` - Value bool `json:"value"` + dag.OpBase + Value bool `json:"value"` } func NewSetSignatureRequired(author identity.Interface, value bool) *SetSignatureRequired { - return &SetSignatureRequired{author: author, OperationType: SetSignatureRequiredOp, Value: value} + return &SetSignatureRequired{ + OpBase: dag.NewOpBase(SetSignatureRequiredOp, author, time.Now().Unix()), + Value: value, + } } func (ssr *SetSignatureRequired) Id() entity.Id { // the Id of the operation is the hash of the serialized data. - // we could memorize the Id when deserializing, but that will do - data, _ := json.Marshal(ssr) - return entity.DeriveId(data) + return dag.IdOperation(ssr, &ssr.OpBase) } func (ssr *SetSignatureRequired) Validate() error { - if ssr.author == nil { - return fmt.Errorf("author not set") - } - return ssr.author.Validate() -} - -func (ssr *SetSignatureRequired) Author() identity.Interface { - return ssr.author + return ssr.OpBase.Validate(ssr, SetSignatureRequiredOp) } // Apply is the function that makes changes on the snapshot func (ssr *SetSignatureRequired) Apply(snapshot *Snapshot) { // check that we are allowed to change the config - if _, ok := snapshot.Administrator[ssr.author]; !ok { + if _, ok := snapshot.Administrator[ssr.Author()]; !ok { return } snapshot.SignatureRequired = ssr.Value @@ -113,24 +105,20 @@ func (ssr *SetSignatureRequired) Apply(snapshot *Snapshot) { // AddAdministrator is an operation to add a new administrator in the set type AddAdministrator struct { - author identity.Interface - OperationType OperationType `json:"type"` - ToAdd []identity.Interface `json:"to_add"` -} - -// addAdministratorJson is a helper struct to deserialize identities with a concrete type. -type addAdministratorJson struct { - ToAdd []identity.IdentityStub `json:"to_add"` + dag.OpBase + ToAdd []identity.Interface `json:"to_add"` } func NewAddAdministratorOp(author identity.Interface, toAdd ...identity.Interface) *AddAdministrator { - return &AddAdministrator{author: author, OperationType: AddAdministratorOp, ToAdd: toAdd} + return &AddAdministrator{ + OpBase: dag.NewOpBase(AddAdministratorOp, author, time.Now().Unix()), + ToAdd: toAdd, + } } func (aa *AddAdministrator) Id() entity.Id { - // we could memorize the Id when deserializing, but that will do - data, _ := json.Marshal(aa) - return entity.DeriveId(data) + // the Id of the operation is the hash of the serialized data. + return dag.IdOperation(aa, &aa.OpBase) } func (aa *AddAdministrator) Validate() error { @@ -138,20 +126,13 @@ func (aa *AddAdministrator) Validate() error { if len(aa.ToAdd) == 0 { return fmt.Errorf("nothing to add") } - if aa.author == nil { - return fmt.Errorf("author not set") - } - return aa.author.Validate() -} - -func (aa *AddAdministrator) Author() identity.Interface { - return aa.author + return aa.OpBase.Validate(aa, AddAdministratorOp) } // Apply is the function that makes changes on the snapshot func (aa *AddAdministrator) Apply(snapshot *Snapshot) { // check that we are allowed to change the config ... or if there is no admin yet - if !snapshot.HasAdministrator(aa.author) && len(snapshot.Administrator) != 0 { + if !snapshot.HasAdministrator(aa.Author()) && len(snapshot.Administrator) != 0 { return } for _, toAdd := range aa.ToAdd { @@ -161,25 +142,20 @@ func (aa *AddAdministrator) Apply(snapshot *Snapshot) { // RemoveAdministrator is an operation to remove an administrator from the set type RemoveAdministrator struct { - author identity.Interface - OperationType OperationType `json:"type"` - ToRemove []identity.Interface `json:"to_remove"` -} - -// removeAdministratorJson is a helper struct to deserialize identities with a concrete type. -type removeAdministratorJson struct { + dag.OpBase ToRemove []identity.Interface `json:"to_remove"` } func NewRemoveAdministratorOp(author identity.Interface, toRemove ...identity.Interface) *RemoveAdministrator { - return &RemoveAdministrator{author: author, OperationType: RemoveAdministratorOp, ToRemove: toRemove} + return &RemoveAdministrator{ + OpBase: dag.NewOpBase(RemoveAdministratorOp, author, time.Now().Unix()), + ToRemove: toRemove, + } } func (ra *RemoveAdministrator) Id() entity.Id { // the Id of the operation is the hash of the serialized data. - // we could memorize the Id when deserializing, but that will do - data, _ := json.Marshal(ra) - return entity.DeriveId(data) + return dag.IdOperation(ra, &ra.OpBase) } func (ra *RemoveAdministrator) Validate() error { @@ -188,26 +164,19 @@ func (ra *RemoveAdministrator) Validate() error { if len(ra.ToRemove) == 0 { return fmt.Errorf("nothing to remove") } - if ra.author == nil { - return fmt.Errorf("author not set") - } - return ra.author.Validate() -} - -func (ra *RemoveAdministrator) Author() identity.Interface { - return ra.author + return ra.OpBase.Validate(ra, RemoveAdministratorOp) } // Apply is the function that makes changes on the snapshot func (ra *RemoveAdministrator) Apply(snapshot *Snapshot) { // check if we are allowed to make changes - if !snapshot.HasAdministrator(ra.author) { + if !snapshot.HasAdministrator(ra.Author()) { return } // special rule: we can't end up with no administrator stillSome := false for admin, _ := range snapshot.Administrator { - if admin != ra.author { + if admin != ra.Author() { stillSome = true break } @@ -245,71 +214,52 @@ var def = dag.Definition{ // operationUnmarshaller is a function doing the de-serialization of the JSON data into our own // concrete Operations. If needed, we can use the resolver to connect to other entities. -func operationUnmarshaller(author identity.Interface, raw json.RawMessage, resolver identity.Resolver) (dag.Operation, error) { +func operationUnmarshaller(raw json.RawMessage, resolver identity.Resolver) (dag.Operation, error) { var t struct { - OperationType OperationType `json:"type"` + OperationType dag.OperationType `json:"type"` } if err := json.Unmarshal(raw, &t); err != nil { return nil, err } - var value interface{} + var op dag.Operation switch t.OperationType { case AddAdministratorOp: - value = &addAdministratorJson{} + op = &AddAdministrator{} case RemoveAdministratorOp: - value = &removeAdministratorJson{} + op = &RemoveAdministrator{} case SetSignatureRequiredOp: - value = &SetSignatureRequired{} + op = &SetSignatureRequired{} default: panic(fmt.Sprintf("unknown operation type %v", t.OperationType)) } - err := json.Unmarshal(raw, &value) + err := json.Unmarshal(raw, &op) if err != nil { return nil, err } - var op Operation - - switch value := value.(type) { - case *SetSignatureRequired: - value.author = author - op = value - case *addAdministratorJson: - // We need something less straightforward to deserialize and resolve identities - aa := &AddAdministrator{ - author: author, - OperationType: AddAdministratorOp, - ToAdd: make([]identity.Interface, len(value.ToAdd)), - } - for i, stub := range value.ToAdd { + switch op := op.(type) { + case *AddAdministrator: + // We need to resolve identities + for i, stub := range op.ToAdd { iden, err := resolver.ResolveIdentity(stub.Id()) if err != nil { return nil, err } - aa.ToAdd[i] = iden + op.ToAdd[i] = iden } - op = aa - case *removeAdministratorJson: - // We need something less straightforward to deserialize and resolve identities - ra := &RemoveAdministrator{ - author: author, - OperationType: RemoveAdministratorOp, - ToRemove: make([]identity.Interface, len(value.ToRemove)), - } - for i, stub := range value.ToRemove { + case *RemoveAdministrator: + // We need to resolve identities + for i, stub := range op.ToRemove { iden, err := resolver.ResolveIdentity(stub.Id()) if err != nil { return nil, err } - ra.ToRemove[i] = iden + op.ToRemove[i] = iden } - op = ra - default: - panic(fmt.Sprintf("unknown operation type %T", value)) } return op, nil diff --git a/entity/dag/op_noop.go b/entity/dag/op_noop.go new file mode 100644 index 00000000..2bc53578 --- /dev/null +++ b/entity/dag/op_noop.go @@ -0,0 +1,39 @@ +package dag + +import ( + "github.com/MichaelMure/git-bug/entity" + "github.com/MichaelMure/git-bug/identity" +) + +var _ Operation = &NoOpOperation[Snapshot]{} +var _ OperationDoesntChangeSnapshot = &NoOpOperation[Snapshot]{} + +// NoOpOperation is an operation that does not change the entity state. It can +// however be used to store arbitrary metadata in the entity history, for example +// to support a bridge feature. +type NoOpOperation[SnapT Snapshot] struct { + OpBase +} + +func NewNoOpOp[SnapT Snapshot](opType OperationType, author identity.Interface, unixTime int64) *NoOpOperation[SnapT] { + return &NoOpOperation[SnapT]{ + OpBase: NewOpBase(opType, author, unixTime), + } +} + +func (op *NoOpOperation[SnapT]) Id() entity.Id { + return IdOperation(op, &op.OpBase) +} + +func (op *NoOpOperation[SnapT]) Apply(snapshot SnapT) { + // Nothing to do +} + +func (op *NoOpOperation[SnapT]) Validate() error { + if err := op.OpBase.Validate(op, op.OperationType); err != nil { + return err + } + return nil +} + +func (op *NoOpOperation[SnapT]) DoesntChangeSnapshot() {} diff --git a/entity/dag/op_noop_test.go b/entity/dag/op_noop_test.go new file mode 100644 index 00000000..e2f5cde0 --- /dev/null +++ b/entity/dag/op_noop_test.go @@ -0,0 +1,13 @@ +package dag + +import ( + "testing" + + "github.com/MichaelMure/git-bug/identity" +) + +func TestNoopSerialize(t *testing.T) { + SerializeRoundTripTest(t, func(author identity.Interface, unixTime int64) *NoOpOperation[*snapshotMock] { + return NewNoOpOp[*snapshotMock](1, author, unixTime) + }) +} diff --git a/entity/dag/op_set_metadata.go b/entity/dag/op_set_metadata.go new file mode 100644 index 00000000..bf32171f --- /dev/null +++ b/entity/dag/op_set_metadata.go @@ -0,0 +1,68 @@ +package dag + +import ( + "fmt" + + "github.com/pkg/errors" + + "github.com/MichaelMure/git-bug/entity" + "github.com/MichaelMure/git-bug/identity" + "github.com/MichaelMure/git-bug/util/text" +) + +var _ Operation = &SetMetadataOperation[Snapshot]{} +var _ OperationDoesntChangeSnapshot = &SetMetadataOperation[Snapshot]{} + +type SetMetadataOperation[SnapT Snapshot] struct { + OpBase + Target entity.Id `json:"target"` + NewMetadata map[string]string `json:"new_metadata"` +} + +func NewSetMetadataOp[SnapT Snapshot](opType OperationType, author identity.Interface, unixTime int64, target entity.Id, newMetadata map[string]string) *SetMetadataOperation[SnapT] { + return &SetMetadataOperation[SnapT]{ + OpBase: NewOpBase(opType, author, unixTime), + Target: target, + NewMetadata: newMetadata, + } +} + +func (op *SetMetadataOperation[SnapT]) Id() entity.Id { + return IdOperation(op, &op.OpBase) +} + +func (op *SetMetadataOperation[SnapT]) Apply(snapshot SnapT) { + for _, target := range snapshot.AllOperations() { + if target.Id() == op.Target { + // Apply the metadata in an immutable way: if a metadata already + // exist, it's not possible to override it. + for key, value := range op.NewMetadata { + target.setExtraMetadataImmutable(key, value) + } + return + } + } +} + +func (op *SetMetadataOperation[SnapT]) Validate() error { + if err := op.OpBase.Validate(op, op.OperationType); err != nil { + return err + } + + if err := op.Target.Validate(); err != nil { + return errors.Wrap(err, "target invalid") + } + + for key, val := range op.NewMetadata { + if !text.SafeOneLine(key) { + return fmt.Errorf("metadata key is unsafe") + } + if !text.Safe(val) { + return fmt.Errorf("metadata value is not fully printable") + } + } + + return nil +} + +func (op *SetMetadataOperation[SnapT]) DoesntChangeSnapshot() {} diff --git a/entity/dag/op_set_metadata_test.go b/entity/dag/op_set_metadata_test.go new file mode 100644 index 00000000..4dab8a96 --- /dev/null +++ b/entity/dag/op_set_metadata_test.go @@ -0,0 +1,106 @@ +package dag + +import ( + "testing" + "time" + + "github.com/MichaelMure/git-bug/identity" + "github.com/MichaelMure/git-bug/repository" + + "github.com/stretchr/testify/require" +) + +type snapshotMock struct { + ops []Operation +} + +func (s *snapshotMock) AllOperations() []Operation { + return s.ops +} + +func TestSetMetadata(t *testing.T) { + snap := &snapshotMock{} + + repo := repository.NewMockRepo() + + rene, err := identity.NewIdentity(repo, "René Descartes", "rene@descartes.fr") + require.NoError(t, err) + + unix := time.Now().Unix() + + target1 := NewNoOpOp[*snapshotMock](1, rene, unix) + target1.SetMetadata("key", "value") + snap.ops = append(snap.ops, target1) + + target2 := NewNoOpOp[*snapshotMock](1, rene, unix) + target2.SetMetadata("key2", "value2") + snap.ops = append(snap.ops, target2) + + op1 := NewSetMetadataOp[*snapshotMock](2, rene, unix, target1.Id(), map[string]string{ + "key": "override", + "key2": "value", + }) + + op1.Apply(snap) + snap.ops = append(snap.ops, op1) + + target1Metadata := snap.AllOperations()[0].AllMetadata() + require.Len(t, target1Metadata, 2) + // original key is not overrided + require.Equal(t, target1Metadata["key"], "value") + // new key is set + require.Equal(t, target1Metadata["key2"], "value") + + target2Metadata := snap.AllOperations()[1].AllMetadata() + require.Len(t, target2Metadata, 1) + require.Equal(t, target2Metadata["key2"], "value2") + + op2 := NewSetMetadataOp[*snapshotMock](2, rene, unix, target2.Id(), map[string]string{ + "key2": "value", + "key3": "value3", + }) + + op2.Apply(snap) + snap.ops = append(snap.ops, op2) + + target1Metadata = snap.AllOperations()[0].AllMetadata() + require.Len(t, target1Metadata, 2) + require.Equal(t, target1Metadata["key"], "value") + require.Equal(t, target1Metadata["key2"], "value") + + target2Metadata = snap.AllOperations()[1].AllMetadata() + require.Len(t, target2Metadata, 2) + // original key is not overrided + require.Equal(t, target2Metadata["key2"], "value2") + // new key is set + require.Equal(t, target2Metadata["key3"], "value3") + + op3 := NewSetMetadataOp[*snapshotMock](2, rene, unix, target1.Id(), map[string]string{ + "key": "override", + "key2": "override", + }) + + op3.Apply(snap) + snap.ops = append(snap.ops, op3) + + target1Metadata = snap.AllOperations()[0].AllMetadata() + require.Len(t, target1Metadata, 2) + // original key is not overrided + require.Equal(t, target1Metadata["key"], "value") + // previously set key is not overrided + require.Equal(t, target1Metadata["key2"], "value") + + target2Metadata = snap.AllOperations()[1].AllMetadata() + require.Len(t, target2Metadata, 2) + require.Equal(t, target2Metadata["key2"], "value2") + require.Equal(t, target2Metadata["key3"], "value3") +} + +func TestSetMetadataSerialize(t *testing.T) { + SerializeRoundTripTest(t, func(author identity.Interface, unixTime int64) *SetMetadataOperation[*snapshotMock] { + return NewSetMetadataOp[*snapshotMock](1, author, unixTime, "message", map[string]string{ + "key1": "value1", + "key2": "value2", + }) + }) +} diff --git a/entity/dag/operation.go b/entity/dag/operation.go index a320859f..0227b3e0 100644 --- a/entity/dag/operation.go +++ b/entity/dag/operation.go @@ -1,11 +1,21 @@ package dag import ( + "crypto/rand" + "encoding/json" + "fmt" + "time" + + "github.com/pkg/errors" + "github.com/MichaelMure/git-bug/entity" "github.com/MichaelMure/git-bug/identity" "github.com/MichaelMure/git-bug/repository" ) +// OperationType is an operation type identifier +type OperationType int + // Operation is a piece of data defining a change to reflect on the state of an Entity. // What this Operation or Entity's state looks like is not of the resort of this package as it only deals with the // data structure and storage. @@ -22,23 +32,39 @@ type Operation interface { // a minimal amount of entropy and avoid collision. // // Author's note: I tried to find a clever way around that inelegance (stuffing random useless data into the stored - // structure is not exactly elegant) but I failed to find a proper way. Essentially, anything that would reuse some + // structure is not exactly elegant), but I failed to find a proper way. Essentially, anything that would reuse some // other data (parent operation's Id, lamport clock) or the graph structure (depth) impose that the Id would only // make sense in the context of the graph and yield some deep coupling between Entity and Operation. This in turn // make the whole thing even less elegant. // // A common way to derive an Id will be to use the entity.DeriveId() function on the serialized operation data. Id() entity.Id + // Type return the type of the operation + Type() OperationType // Validate check if the Operation data is valid Validate() error // Author returns the author of this operation Author() identity.Interface + // Time return the time when the operation was added + Time() time.Time + + // SetMetadata store arbitrary metadata about the operation + SetMetadata(key string, value string) + // GetMetadata retrieve arbitrary metadata about the operation + GetMetadata(key string) (string, bool) + // AllMetadata return all metadata for this operation + AllMetadata() map[string]string + + // setId allow to set the Id, used when unmarshalling only + setId(id entity.Id) + // setAuthor allow to set the author, used when unmarshalling only + setAuthor(author identity.Interface) + // setExtraMetadataImmutable add a metadata not carried by the operation itself on the operation + setExtraMetadataImmutable(key string, value string) } -// OperationWithFiles is an extended Operation that has files dependency, stored in git. +// OperationWithFiles is an optional extension for an Operation that has files dependency, stored in git. type OperationWithFiles interface { - Operation - // GetFiles return the files needed by this operation // This implies that the Operation maintain and store internally the references to those files. This is how // this information is read later, when loading from storage. @@ -46,3 +72,201 @@ type OperationWithFiles interface { // hash). GetFiles() []repository.Hash } + +// OperationDoesntChangeSnapshot is an interface signaling that the Operation implementing it doesn't change the +// snapshot, for example a metadata operation that act on other operations. +type OperationDoesntChangeSnapshot interface { + DoesntChangeSnapshot() +} + +// Snapshot is the minimal interface that a snapshot need to implement +type Snapshot interface { + // AllOperations returns all the operations that have been applied to that snapshot, in order + AllOperations() []Operation +} + +// OpBase implement the common feature that every Operation should support. +type OpBase struct { + // Not serialized. Store the op's id in memory. + id entity.Id + // Not serialized + author identity.Interface + + OperationType OperationType `json:"type"` + UnixTime int64 `json:"timestamp"` + + // mandatory random bytes to ensure a better randomness of the data used to later generate the ID + // len(Nonce) should be > 20 and < 64 bytes + // It has no functional purpose and should be ignored. + Nonce []byte `json:"nonce"` + + Metadata map[string]string `json:"metadata,omitempty"` + // Not serialized. Store the extra metadata in memory, + // compiled from SetMetadataOperation. + extraMetadata map[string]string +} + +func NewOpBase(opType OperationType, author identity.Interface, unixTime int64) OpBase { + return OpBase{ + OperationType: opType, + author: author, + UnixTime: unixTime, + Nonce: makeNonce(20), + id: entity.UnsetId, + } +} + +func makeNonce(len int) []byte { + result := make([]byte, len) + _, err := rand.Read(result) + if err != nil { + panic(err) + } + return result +} + +func IdOperation(op Operation, base *OpBase) entity.Id { + if base.id == "" { + // something went really wrong + panic("op's id not set") + } + if base.id == entity.UnsetId { + // This means we are trying to get the op's Id *before* it has been stored, for instance when + // adding multiple ops in one go in an OperationPack. + // 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(op) + if err != nil { + panic(err) + } + + base.id = entity.DeriveId(data) + } + return base.id +} + +func (base *OpBase) Type() OperationType { + return base.OperationType +} + +// Time return the time when the operation was added +func (base *OpBase) Time() time.Time { + return time.Unix(base.UnixTime, 0) +} + +// Validate check the OpBase for errors +func (base *OpBase) Validate(op Operation, opType OperationType) error { + if base.OperationType == 0 { + return fmt.Errorf("operation type unset") + } + if base.OperationType != opType { + return fmt.Errorf("incorrect operation type (expected: %v, actual: %v)", opType, base.OperationType) + } + + if op.Time().Unix() == 0 { + return fmt.Errorf("time not set") + } + + if base.author == nil { + return fmt.Errorf("author not set") + } + + if err := op.Author().Validate(); err != nil { + return errors.Wrap(err, "author") + } + + if op, ok := op.(OperationWithFiles); ok { + for _, hash := range op.GetFiles() { + if !hash.IsValid() { + return fmt.Errorf("file with invalid hash %v", hash) + } + } + } + + if len(base.Nonce) > 64 { + return fmt.Errorf("nonce is too big") + } + if len(base.Nonce) < 20 { + return fmt.Errorf("nonce is too small") + } + + return nil +} + +// IsAuthored is a sign post method for gqlgen +func (base *OpBase) IsAuthored() {} + +// Author return author identity +func (base *OpBase) Author() identity.Interface { + return base.author +} + +// IdIsSet returns true if the id has been set already +func (base *OpBase) IdIsSet() bool { + return base.id != "" && base.id != entity.UnsetId +} + +// SetMetadata store arbitrary metadata about the operation +func (base *OpBase) SetMetadata(key string, value string) { + if base.IdIsSet() { + panic("set metadata on an operation with already an Id") + } + + if base.Metadata == nil { + base.Metadata = make(map[string]string) + } + base.Metadata[key] = value +} + +// GetMetadata retrieve arbitrary metadata about the operation +func (base *OpBase) GetMetadata(key string) (string, bool) { + val, ok := base.Metadata[key] + + if ok { + return val, true + } + + // extraMetadata can't replace the original operations value if any + val, ok = base.extraMetadata[key] + + return val, ok +} + +// AllMetadata return all metadata for this operation +func (base *OpBase) AllMetadata() map[string]string { + result := make(map[string]string) + + for key, val := range base.extraMetadata { + result[key] = val + } + + // Original metadata take precedence + for key, val := range base.Metadata { + result[key] = val + } + + return result +} + +// setId allow to set the Id, used when unmarshalling only +func (base *OpBase) setId(id entity.Id) { + if base.id != "" && base.id != entity.UnsetId { + panic("trying to set id again") + } + base.id = id +} + +// setAuthor allow to set the author, used when unmarshalling only +func (base *OpBase) setAuthor(author identity.Interface) { + base.author = author +} + +func (base *OpBase) setExtraMetadataImmutable(key string, value string) { + if base.extraMetadata == nil { + base.extraMetadata = make(map[string]string) + } + if _, exist := base.extraMetadata[key]; !exist { + base.extraMetadata[key] = value + } +} diff --git a/entity/dag/operation_pack.go b/entity/dag/operation_pack.go index 9b42f9bf..b2973343 100644 --- a/entity/dag/operation_pack.go +++ b/entity/dag/operation_pack.go @@ -314,10 +314,15 @@ func unmarshallPack(def Definition, resolver identity.Resolver, data []byte) ([] for _, raw := range aux.Operations { // delegate to specialized unmarshal function - op, err := def.OperationUnmarshaler(author, raw, resolver) + op, err := def.OperationUnmarshaler(raw, resolver) if err != nil { return nil, nil, err } + // Set the id from the serialized data + op.setId(entity.DeriveId(raw)) + // Set the author, taken from the OperationPack + op.setAuthor(author) + ops = append(ops, op) } diff --git a/entity/dag/operation_pack_test.go b/entity/dag/operation_pack_test.go index 73960800..e438e4c5 100644 --- a/entity/dag/operation_pack_test.go +++ b/entity/dag/operation_pack_test.go @@ -11,13 +11,13 @@ import ( ) func TestOperationPackReadWrite(t *testing.T) { - repo, id1, _, resolver, def := makeTestContext() + repo, author, _, resolver, def := makeTestContext() opp := &operationPack{ - Author: id1, + Author: author, Operations: []Operation{ - newOp1(id1, "foo"), - newOp2(id1, "bar"), + newOp1(author, "foo"), + newOp2(author, "bar"), }, CreateTime: 123, EditTime: 456, @@ -32,34 +32,26 @@ func TestOperationPackReadWrite(t *testing.T) { opp2, err := readOperationPack(def, repo, resolver, commit) require.NoError(t, err) - require.Equal(t, opp, opp2) - - // make sure we get the same Id with the same data - opp3 := &operationPack{ - Author: id1, - Operations: []Operation{ - newOp1(id1, "foo"), - newOp2(id1, "bar"), - }, - CreateTime: 123, - EditTime: 456, + for _, op := range opp.Operations { + // force the creation of the id + op.Id() } - require.Equal(t, opp.Id(), opp3.Id()) + require.Equal(t, opp, opp2) } func TestOperationPackSignedReadWrite(t *testing.T) { - repo, id1, _, resolver, def := makeTestContext() + repo, author, _, resolver, def := makeTestContext() - err := id1.(*identity.Identity).Mutate(repo, func(orig *identity.Mutator) { + err := author.(*identity.Identity).Mutate(repo, func(orig *identity.Mutator) { orig.Keys = append(orig.Keys, identity.GenerateKey()) }) require.NoError(t, err) opp := &operationPack{ - Author: id1, + Author: author, Operations: []Operation{ - newOp1(id1, "foo"), - newOp2(id1, "bar"), + newOp1(author, "foo"), + newOp2(author, "bar"), }, CreateTime: 123, EditTime: 456, @@ -74,23 +66,15 @@ func TestOperationPackSignedReadWrite(t *testing.T) { opp2, err := readOperationPack(def, repo, resolver, commit) require.NoError(t, err) - require.Equal(t, opp, opp2) - - // make sure we get the same Id with the same data - opp3 := &operationPack{ - Author: id1, - Operations: []Operation{ - newOp1(id1, "foo"), - newOp2(id1, "bar"), - }, - CreateTime: 123, - EditTime: 456, + for _, op := range opp.Operations { + // force the creation of the id + op.Id() } - require.Equal(t, opp.Id(), opp3.Id()) + require.Equal(t, opp, opp2) } func TestOperationPackFiles(t *testing.T) { - repo, id1, _, resolver, def := makeTestContext() + repo, author, _, resolver, def := makeTestContext() blobHash1, err := repo.StoreData(randomData()) require.NoError(t, err) @@ -99,10 +83,10 @@ func TestOperationPackFiles(t *testing.T) { require.NoError(t, err) opp := &operationPack{ - Author: id1, + Author: author, Operations: []Operation{ - newOp1(id1, "foo", blobHash1, blobHash2), - newOp1(id1, "foo", blobHash2), + newOp1(author, "foo", blobHash1, blobHash2), + newOp1(author, "foo", blobHash2), }, CreateTime: 123, EditTime: 456, @@ -117,6 +101,10 @@ func TestOperationPackFiles(t *testing.T) { opp2, err := readOperationPack(def, repo, resolver, commit) require.NoError(t, err) + for _, op := range opp.Operations { + // force the creation of the id + op.Id() + } require.Equal(t, opp, opp2) require.ElementsMatch(t, opp2.Operations[0].(OperationWithFiles).GetFiles(), []repository.Hash{ diff --git a/entity/dag/operation_testing.go b/entity/dag/operation_testing.go new file mode 100644 index 00000000..29923eec --- /dev/null +++ b/entity/dag/operation_testing.go @@ -0,0 +1,57 @@ +package dag + +import ( + "encoding/json" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/MichaelMure/git-bug/entity" + "github.com/MichaelMure/git-bug/identity" + "github.com/MichaelMure/git-bug/repository" +) + +// SerializeRoundTripTest realize a marshall/unmarshall round-trip in the same condition as with OperationPack, +// and check if the recovered operation is identical. +func SerializeRoundTripTest[OpT Operation](t *testing.T, maker func(author identity.Interface, unixTime int64) OpT) { + repo := repository.NewMockRepo() + + rene, err := identity.NewIdentity(repo, "René Descartes", "rene@descartes.fr") + require.NoError(t, err) + + op := maker(rene, time.Now().Unix()) + // enforce having an id + op.Id() + + rdt := &roundTripper[OpT]{Before: op, author: rene} + + data, err := json.Marshal(rdt) + require.NoError(t, err) + + err = json.Unmarshal(data, &rdt) + require.NoError(t, err) + + require.Equal(t, op, rdt.after) +} + +type roundTripper[OpT Operation] struct { + Before OpT + author identity.Interface + after OpT +} + +func (r *roundTripper[OpT]) MarshalJSON() ([]byte, error) { + return json.Marshal(r.Before) +} + +func (r *roundTripper[OpT]) UnmarshalJSON(data []byte) error { + if err := json.Unmarshal(data, &r.after); err != nil { + return err + } + // Set the id from the serialized data + r.after.setId(entity.DeriveId(data)) + // Set the author, as OperationPack would do + r.after.setAuthor(r.author) + return nil +} |