diff --git a/content/file/file.go b/content/file/file.go index 573cfe4b..57d583e8 100644 --- a/content/file/file.go +++ b/content/file/file.go @@ -78,7 +78,7 @@ type Store struct { // TarReproducible controls if the tarballs generated // for the added directories are reproducible. // When specified, some metadata such as change time - // will be stripped from the files in the tarballs. Default value: false. + // will be removed from the files in the tarballs. Default value: false. TarReproducible bool // AllowPathTraversalOnWrite controls if path traversal is allowed // when writing files. When specified, writing files diff --git a/content/file/utils.go b/content/file/utils.go index 35d30ed2..c42013d8 100644 --- a/content/file/utils.go +++ b/content/file/utils.go @@ -31,7 +31,7 @@ import ( // tarDirectory walks the directory specified by path, and tar those files with a new // path prefix. -func tarDirectory(root, prefix string, w io.Writer, stripTimes bool, buf []byte) (err error) { +func tarDirectory(root, prefix string, w io.Writer, removeTimes bool, buf []byte) (err error) { tw := tar.NewWriter(w) defer func() { closeErr := tw.Close() @@ -71,7 +71,7 @@ func tarDirectory(root, prefix string, w io.Writer, stripTimes bool, buf []byte) header.Uname = "" header.Gname = "" - if stripTimes { + if removeTimes { header.ModTime = time.Time{} header.AccessTime = time.Time{} header.ChangeTime = time.Time{} diff --git a/content/oci/oci.go b/content/oci/oci.go index 3443ca40..17f1515d 100644 --- a/content/oci/oci.go +++ b/content/oci/oci.go @@ -27,10 +27,12 @@ import ( "path/filepath" "sync" + "github.com/opencontainers/go-digest" specs "github.com/opencontainers/image-spec/specs-go" ocispec "github.com/opencontainers/image-spec/specs-go/v1" "oras.land/oras-go/v2/content" "oras.land/oras-go/v2/errdef" + "oras.land/oras-go/v2/internal/container/set" "oras.land/oras-go/v2/internal/descriptor" "oras.land/oras-go/v2/internal/graph" "oras.land/oras-go/v2/internal/resolver" @@ -142,10 +144,6 @@ func (s *Store) Tag(ctx context.Context, desc ocispec.Descriptor, reference stri return fmt.Errorf("%s: %s: %w", desc.Digest, desc.MediaType, errdef.ErrNotFound) } - if desc.Annotations == nil { - desc.Annotations = map[string]string{} - } - desc.Annotations[ocispec.AnnotationRefName] = reference return s.tag(ctx, desc, reference) } @@ -153,7 +151,7 @@ func (s *Store) Tag(ctx context.Context, desc ocispec.Descriptor, reference stri func (s *Store) tag(ctx context.Context, desc ocispec.Descriptor, reference string) error { dgst := desc.Digest.String() if reference != dgst { - // mark desc for deduplication in SaveIndex() + // also tag desc by its digest if err := s.tagResolver.Tag(ctx, desc, dgst); err != nil { return err } @@ -269,14 +267,32 @@ func (s *Store) SaveIndex() error { defer s.indexLock.Unlock() var manifests []ocispec.Descriptor + tagged := set.New[digest.Digest]() refMap := s.tagResolver.Map() + + // 1. Add descriptors that are associated with tags + // Note: One descriptor can be associated with multiple tags. + for ref, desc := range refMap { + if ref != desc.Digest.String() { + annotations := make(map[string]string, len(desc.Annotations)+1) + for k, v := range desc.Annotations { + annotations[k] = v + } + annotations[ocispec.AnnotationRefName] = ref + desc.Annotations = annotations + manifests = append(manifests, desc) + // mark the digest as tagged for deduplication in step 2 + tagged.Add(desc.Digest) + } + } + // 2. Add descriptors that are not associated with any tag for ref, desc := range refMap { - if ref == desc.Digest.String() && desc.Annotations[ocispec.AnnotationRefName] != "" { - // skip saving desc if ref is a digest and desc is tagged - continue + if ref == desc.Digest.String() && !tagged.Contains(desc.Digest) { + // skip tagged ones since they have been added in step 1 + manifests = append(manifests, deleteAnnotationRefName(desc)) } - manifests = append(manifests, desc) } + s.index.Manifests = manifests return s.writeIndexFile() } diff --git a/content/oci/oci_test.go b/content/oci/oci_test.go index 255ee22c..79198eaa 100644 --- a/content/oci/oci_test.go +++ b/content/oci/oci_test.go @@ -598,7 +598,10 @@ func TestStore_RepeatTag(t *testing.T) { t.Fatal("Store.Push() error =", err) } if got, want := len(internalResolver.Map()), 1; got != want { - t.Errorf("resolver.Map() = %v, want %v", got, want) + t.Errorf("len(resolver.Map()) = %v, want %v", got, want) + } + if got, want := len(s.index.Manifests), 1; got != want { + t.Errorf("len(index.Manifests) = %v, want %v", got, want) } err = s.Tag(ctx, desc, ref) @@ -608,6 +611,9 @@ func TestStore_RepeatTag(t *testing.T) { if got, want := len(internalResolver.Map()), 2; got != want { t.Errorf("resolver.Map() = %v, want %v", got, want) } + if got, want := len(s.index.Manifests), 1; got != want { + t.Errorf("len(index.Manifests) = %v, want %v", got, want) + } gotDesc, err := s.Resolve(ctx, desc.Digest.String()) if err != nil { @@ -635,6 +641,9 @@ func TestStore_RepeatTag(t *testing.T) { if got, want := len(internalResolver.Map()), 3; got != want { t.Errorf("resolver.Map() = %v, want %v", got, want) } + if got, want := len(s.index.Manifests), 2; got != want { + t.Errorf("len(index.Manifests) = %v, want %v", got, want) + } err = s.Tag(ctx, desc, ref) if err != nil { @@ -643,6 +652,9 @@ func TestStore_RepeatTag(t *testing.T) { if got, want := len(internalResolver.Map()), 3; got != want { t.Errorf("resolver.Map() = %v, want %v", got, want) } + if got, want := len(s.index.Manifests), 2; got != want { + t.Errorf("len(index.Manifests) = %v, want %v", got, want) + } gotDesc, err = s.Resolve(ctx, desc.Digest.String()) if err != nil { @@ -670,6 +682,9 @@ func TestStore_RepeatTag(t *testing.T) { if got, want := len(internalResolver.Map()), 3; got != want { t.Errorf("resolver.Map() = %v, want %v", got, want) } + if got, want := len(s.index.Manifests), 2; got != want { + t.Errorf("len(index.Manifests) = %v, want %v", got, want) + } err = s.Tag(ctx, desc, ref) if err != nil { @@ -678,6 +693,50 @@ func TestStore_RepeatTag(t *testing.T) { if got, want := len(internalResolver.Map()), 4; got != want { t.Errorf("resolver.Map() = %v, want %v", got, want) } + if got, want := len(s.index.Manifests), 3; got != want { + t.Errorf("len(index.Manifests) = %v, want %v", got, want) + } + + gotDesc, err = s.Resolve(ctx, desc.Digest.String()) + if err != nil { + t.Fatal("Store.Resolve() error =", err) + } + if !reflect.DeepEqual(gotDesc, desc) { + t.Errorf("Store.Resolve() = %v, want %v", gotDesc, desc) + } + + gotDesc, err = s.Resolve(ctx, ref) + if err != nil { + t.Fatal("Store.Resolve() error =", err) + } + if !reflect.DeepEqual(gotDesc, desc) { + t.Errorf("Store.Resolve() = %v, want %v", gotDesc, desc) + } + + // tag another blob + blob = []byte("barfoo") + desc = content.NewDescriptorFromBytes("test", blob) + err = s.Push(ctx, desc, bytes.NewReader(blob)) + if err != nil { + t.Fatal("Store.Push() error =", err) + } + if got, want := len(internalResolver.Map()), 4; got != want { + t.Errorf("resolver.Map() = %v, want %v", got, want) + } + if got, want := len(s.index.Manifests), 3; got != want { + t.Errorf("len(index.Manifests) = %v, want %v", got, want) + } + + err = s.Tag(ctx, desc, ref) + if err != nil { + t.Fatal("Store.Tag() error =", err) + } + if got, want := len(internalResolver.Map()), 5; got != want { + t.Errorf("resolver.Map() = %v, want %v", got, want) + } + if got, want := len(s.index.Manifests), 4; got != want { + t.Errorf("len(index.Manifests) = %v, want %v", got, want) + } gotDesc, err = s.Resolve(ctx, desc.Digest.String()) if err != nil { @@ -696,6 +755,100 @@ func TestStore_RepeatTag(t *testing.T) { } } +// Related bug: https://github.com/oras-project/oras-go/issues/461 +func TestStore_TagByDigest(t *testing.T) { + tempDir := t.TempDir() + s, err := New(tempDir) + if err != nil { + t.Fatal("New() error =", err) + } + ctx := context.Background() + + // get internal resolver + internalResolver := s.tagResolver + + manifest := []byte(`{"layers":[]}`) + manifestDesc := content.NewDescriptorFromBytes(ocispec.MediaTypeImageManifest, manifest) + + // push a manifest + err = s.Push(ctx, manifestDesc, bytes.NewReader(manifest)) + if err != nil { + t.Fatal("Store.Push() error =", err) + } + if got, want := len(internalResolver.Map()), 1; got != want { + t.Errorf("len(resolver.Map()) = %v, want %v", got, want) + } + if got, want := len(s.index.Manifests), 1; got != want { + t.Errorf("len(index.Manifests) = %v, want %v", got, want) + } + gotDesc, err := s.Resolve(ctx, manifestDesc.Digest.String()) + if err != nil { + t.Fatal("Store.Resolve() error =", err) + } + if !reflect.DeepEqual(gotDesc, manifestDesc) { + t.Errorf("Store.Resolve() = %v, want %v", gotDesc, manifestDesc) + } + + // tag manifest by digest + err = s.Tag(ctx, manifestDesc, manifestDesc.Digest.String()) + if err != nil { + t.Fatal("Store.Tag() error =", err) + } + if got, want := len(internalResolver.Map()), 1; got != want { + t.Errorf("len(resolver.Map()) = %v, want %v", got, want) + } + if got, want := len(s.index.Manifests), 1; got != want { + t.Errorf("len(index.Manifests) = %v, want %v", got, want) + } + gotDesc, err = s.Resolve(ctx, manifestDesc.Digest.String()) + if err != nil { + t.Fatal("Store.Resolve() error =", err) + } + if !reflect.DeepEqual(gotDesc, manifestDesc) { + t.Errorf("Store.Resolve() = %v, want %v", gotDesc, manifestDesc) + } + + // push a blob + blob := []byte("foobar") + blobDesc := content.NewDescriptorFromBytes("test", blob) + err = s.Push(ctx, blobDesc, bytes.NewReader(blob)) + if err != nil { + t.Fatal("Store.Push() error =", err) + } + if got, want := len(internalResolver.Map()), 1; got != want { + t.Errorf("resolver.Map() = %v, want %v", got, want) + } + if got, want := len(s.index.Manifests), 1; got != want { + t.Errorf("len(index.Manifests) = %v, want %v", got, want) + } + gotDesc, err = s.Resolve(ctx, blobDesc.Digest.String()) + if err != nil { + t.Fatal("Store.Resolve() error =", err) + } + if gotDesc.Digest != blobDesc.Digest || gotDesc.Size != blobDesc.Size { + t.Errorf("Store.Resolve() = %v, want %v", gotDesc, blobDesc) + } + + // tag blob by digest + err = s.Tag(ctx, blobDesc, blobDesc.Digest.String()) + if err != nil { + t.Fatal("Store.Tag() error =", err) + } + if got, want := len(internalResolver.Map()), 2; got != want { + t.Errorf("resolver.Map() = %v, want %v", got, want) + } + if got, want := len(s.index.Manifests), 2; got != want { + t.Errorf("len(index.Manifests) = %v, want %v", got, want) + } + gotDesc, err = s.Resolve(ctx, blobDesc.Digest.String()) + if err != nil { + t.Fatal("Store.Resolve() error =", err) + } + if !reflect.DeepEqual(gotDesc, blobDesc) { + t.Errorf("Store.Resolve() = %v, want %v", gotDesc, blobDesc) + } +} + func TestStore_BadIndex(t *testing.T) { tempDir := t.TempDir() content := []byte("whatever") @@ -922,6 +1075,11 @@ func TestStore_ExistingStore(t *testing.T) { if err := s.Tag(ctx, indexRoot, tag); err != nil { t.Fatal("Tag() error =", err) } + // tag index root by digest + // related bug: https://github.com/oras-project/oras-go/issues/461 + if err := s.Tag(ctx, indexRoot, indexRoot.Digest.String()); err != nil { + t.Fatal("Tag() error =", err) + } // test with another OCI store instance to mock loading from an existing store anotherS, err := New(tempDir) @@ -960,7 +1118,7 @@ func TestStore_ExistingStore(t *testing.T) { // test resolving blob by digest gotDesc, err = anotherS.Resolve(ctx, descs[0].Digest.String()) if err != nil { - t.Fatal("Store: Resolve() error =", err) + t.Fatal("Store.Resolve() error =", err) } if want := descs[0]; gotDesc.Size != want.Size || gotDesc.Digest != want.Digest { t.Errorf("Store.Resolve() = %v, want %v", gotDesc, want) @@ -1029,7 +1187,94 @@ func TestStore_ExistingStore(t *testing.T) { t.Errorf("Store.Predecessors(%d) = %v, want %v", i, predecessors, want) } } +} + +func Test_ExistingStore_Retag(t *testing.T) { + tempDir := t.TempDir() + s, err := New(tempDir) + if err != nil { + t.Fatal("New() error =", err) + } + ctx := context.Background() + + manifest_1 := []byte(`{"layers":[]}`) + manifestDesc_1 := content.NewDescriptorFromBytes(ocispec.MediaTypeImageManifest, manifest_1) + manifestDesc_1.Annotations = map[string]string{"key1": "val1"} + + // push a manifest + err = s.Push(ctx, manifestDesc_1, bytes.NewReader(manifest_1)) + if err != nil { + t.Fatal("Store.Push() error =", err) + } + // tag manifest by digest + err = s.Tag(ctx, manifestDesc_1, manifestDesc_1.Digest.String()) + if err != nil { + t.Fatal("Store.Tag() error =", err) + } + // tag manifest by tag + ref := "foobar" + err = s.Tag(ctx, manifestDesc_1, ref) + if err != nil { + t.Fatal("Store.Tag() error =", err) + } + + // verify index + want := []ocispec.Descriptor{ + { + MediaType: manifestDesc_1.MediaType, + Digest: manifestDesc_1.Digest, + Size: manifestDesc_1.Size, + Annotations: map[string]string{ + "key1": "val1", + ocispec.AnnotationRefName: ref, + }, + }, + } + if got := s.index.Manifests; !equalDescriptorSet(got, want) { + t.Errorf("index.Manifests = %v, want %v", got, want) + } + + // test with another OCI store instance to mock loading from an existing store + anotherS, err := New(tempDir) + if err != nil { + t.Fatal("New() error =", err) + } + manifest_2 := []byte(`{"layers":[], "annotations":{}}`) + manifestDesc_2 := content.NewDescriptorFromBytes(ocispec.MediaTypeImageManifest, manifest_2) + manifestDesc_2.Annotations = map[string]string{"key2": "val2"} + + err = anotherS.Push(ctx, manifestDesc_2, bytes.NewReader(manifest_2)) + if err != nil { + t.Fatal("Store.Push() error =", err) + } + err = anotherS.Tag(ctx, manifestDesc_2, ref) + if err != nil { + t.Fatal("Store.Tag() error =", err) + } + // verify index + want = []ocispec.Descriptor{ + { + MediaType: manifestDesc_1.MediaType, + Digest: manifestDesc_1.Digest, + Size: manifestDesc_1.Size, + Annotations: map[string]string{ + "key1": "val1", + }, + }, + { + MediaType: manifestDesc_2.MediaType, + Digest: manifestDesc_2.Digest, + Size: manifestDesc_2.Size, + Annotations: map[string]string{ + "key2": "val2", + ocispec.AnnotationRefName: ref, + }, + }, + } + if got := anotherS.index.Manifests; !equalDescriptorSet(got, want) { + t.Errorf("index.Manifests = %v, want %v", got, want) + } } func TestCopy_MemoryToOCI_FullCopy(t *testing.T) { diff --git a/content/oci/readonlyoci.go b/content/oci/readonlyoci.go index 59e7bc2e..cf36e6bd 100644 --- a/content/oci/readonlyoci.go +++ b/content/oci/readonlyoci.go @@ -161,7 +161,7 @@ func (s *ReadOnlyStore) loadIndexFile(ctx context.Context) error { // loadIndex loads index into memory. func loadIndex(ctx context.Context, index *ocispec.Index, fetcher content.Fetcher, tagger content.Tagger, graph *graph.Memory) error { for _, desc := range index.Manifests { - if err := tagger.Tag(ctx, desc, desc.Digest.String()); err != nil { + if err := tagger.Tag(ctx, deleteAnnotationRefName(desc), desc.Digest.String()); err != nil { return err } if ref := desc.Annotations[ocispec.AnnotationRefName]; ref != "" { @@ -224,3 +224,27 @@ func listTags(ctx context.Context, tagResolver *resolver.Memory, last string, fn return fn(tags) } + +// deleteAnnotationRefName deletes the AnnotationRefName from the annotation map +// of desc. +func deleteAnnotationRefName(desc ocispec.Descriptor) ocispec.Descriptor { + if _, ok := desc.Annotations[ocispec.AnnotationRefName]; !ok { + // no ops + return desc + } + + size := len(desc.Annotations) - 1 + if size == 0 { + desc.Annotations = nil + return desc + } + + annotations := make(map[string]string, size) + for k, v := range desc.Annotations { + if k != ocispec.AnnotationRefName { + annotations[k] = v + } + } + desc.Annotations = annotations + return desc +} diff --git a/content/oci/readonlyoci_test.go b/content/oci/readonlyoci_test.go index d2069d48..5c4ab344 100644 --- a/content/oci/readonlyoci_test.go +++ b/content/oci/readonlyoci_test.go @@ -762,3 +762,68 @@ func TestReadOnlyStore_Tags(t *testing.T) { t.Errorf("ReadOnlyStore.Tags() error = %v, wantErr %v", err, wantErr) } } + +func Test_deleteAnnotationRefName(t *testing.T) { + tests := []struct { + name string + desc ocispec.Descriptor + want ocispec.Descriptor + }{ + { + name: "No annotation", + desc: ocispec.Descriptor{}, + want: ocispec.Descriptor{}, + }, + { + name: "Nil annotation", + desc: ocispec.Descriptor{Annotations: nil}, + want: ocispec.Descriptor{}, + }, + { + name: "Empty annotation", + desc: ocispec.Descriptor{Annotations: map[string]string{}}, + want: ocispec.Descriptor{Annotations: map[string]string{}}, + }, + { + name: "No RefName", + desc: ocispec.Descriptor{Annotations: map[string]string{"foo": "bar"}}, + want: ocispec.Descriptor{Annotations: map[string]string{"foo": "bar"}}, + }, + { + name: "Empty RefName", + desc: ocispec.Descriptor{Annotations: map[string]string{ + "foo": "bar", + ocispec.AnnotationRefName: "", + }}, + want: ocispec.Descriptor{Annotations: map[string]string{"foo": "bar"}}, + }, + { + name: "RefName only", + desc: ocispec.Descriptor{Annotations: map[string]string{ocispec.AnnotationRefName: "foobar"}}, + want: ocispec.Descriptor{}, + }, + { + name: "Multiple annotations with RefName", + desc: ocispec.Descriptor{Annotations: map[string]string{ + "foo": "bar", + ocispec.AnnotationRefName: "foobar", + }}, + want: ocispec.Descriptor{Annotations: map[string]string{"foo": "bar"}}, + }, + { + name: "Multiple annotations with empty RefName", + desc: ocispec.Descriptor{Annotations: map[string]string{ + "foo": "bar", + ocispec.AnnotationRefName: "", + }}, + want: ocispec.Descriptor{Annotations: map[string]string{"foo": "bar"}}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := deleteAnnotationRefName(tt.desc); !reflect.DeepEqual(got, tt.want) { + t.Errorf("deleteAnnotationRefName() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/extendedcopy.go b/extendedcopy.go index 543a2c9d..93b46c4b 100644 --- a/extendedcopy.go +++ b/extendedcopy.go @@ -25,6 +25,7 @@ import ( "golang.org/x/sync/semaphore" "oras.land/oras-go/v2/content" "oras.land/oras-go/v2/internal/cas" + "oras.land/oras-go/v2/internal/container/set" "oras.land/oras-go/v2/internal/copyutil" "oras.land/oras-go/v2/internal/descriptor" "oras.land/oras-go/v2/internal/docker" @@ -131,7 +132,7 @@ func ExtendedCopyGraph(ctx context.Context, src content.ReadOnlyGraphStorage, ds // findRoots finds the root nodes reachable from the given node through a // depth-first search. func findRoots(ctx context.Context, storage content.ReadOnlyGraphStorage, node ocispec.Descriptor, opts ExtendedCopyGraphOptions) ([]ocispec.Descriptor, error) { - visited := make(map[descriptor.Descriptor]bool) + visited := set.New[descriptor.Descriptor]() rootMap := make(map[descriptor.Descriptor]ocispec.Descriptor) addRoot := func(key descriptor.Descriptor, val ocispec.Descriptor) { if _, exists := rootMap[key]; !exists { @@ -158,11 +159,11 @@ func findRoots(ctx context.Context, storage content.ReadOnlyGraphStorage, node o currentNode := current.Node currentKey := descriptor.FromOCI(currentNode) - if visited[currentKey] { + if visited.Contains(currentKey) { // skip the current node if it has been visited continue } - visited[currentKey] = true + visited.Add(currentKey) // stop finding predecessors if the target depth is reached if opts.Depth > 0 && current.Depth == opts.Depth { @@ -186,7 +187,7 @@ func findRoots(ctx context.Context, storage content.ReadOnlyGraphStorage, node o // Push the predecessor nodes to the stack and keep finding from there. for _, predecessor := range predecessors { predecessorKey := descriptor.FromOCI(predecessor) - if !visited[predecessorKey] { + if !visited.Contains(predecessorKey) { // push the predecessor node with increased depth stack.Push(copyutil.NodeInfo{Node: predecessor, Depth: current.Depth + 1}) } diff --git a/internal/container/set/set.go b/internal/container/set/set.go new file mode 100644 index 00000000..a084e288 --- /dev/null +++ b/internal/container/set/set.go @@ -0,0 +1,35 @@ +/* +Copyright The ORAS Authors. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package set + +// Set represents a set data structure. +type Set[T comparable] map[T]struct{} + +// New returns an initialized set. +func New[T comparable]() Set[T] { + return make(Set[T]) +} + +// Add adds item into the set s. +func (s Set[T]) Add(item T) { + s[item] = struct{}{} +} + +// Contains returns true if the set s contains item. +func (s Set[T]) Contains(item T) bool { + _, ok := s[item] + return ok +} diff --git a/internal/container/set/set_test.go b/internal/container/set/set_test.go new file mode 100644 index 00000000..94f87c7a --- /dev/null +++ b/internal/container/set/set_test.go @@ -0,0 +1,55 @@ +/* +Copyright The ORAS Authors. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package set + +import "testing" + +func TestSet(t *testing.T) { + set := New[string]() + // test checking a non-existing key + key1 := "foo" + if got, want := set.Contains(key1), false; got != want { + t.Errorf("Set.Contains(%s) = %v, want %v", key1, got, want) + } + if got, want := len(set), 0; got != want { + t.Errorf("len(Set) = %v, want %v", got, want) + } + // test adding a new key + set.Add(key1) + if got, want := set.Contains(key1), true; got != want { + t.Errorf("Set.Contains(%s) = %v, want %v", key1, got, want) + } + if got, want := len(set), 1; got != want { + t.Errorf("len(Set) = %v, want %v", got, want) + } + // test adding an existing key + set.Add(key1) + if got, want := set.Contains(key1), true; got != want { + t.Errorf("Set.Contains(%s) = %v, want %v", key1, got, want) + } + if got, want := len(set), 1; got != want { + t.Errorf("len(Set) = %v, want %v", got, want) + } + // test adding another key + key2 := "bar" + set.Add(key2) + if got, want := set.Contains(key2), true; got != want { + t.Errorf("Set.Contains(%s) = %v, want %v", key2, got, want) + } + if got, want := len(set), 2; got != want { + t.Errorf("len(Set) = %v, want %v", got, want) + } +}