cloud/storage/b2cs: implement the storage.Lister interface.

Change-Id: I4dd2fbcf01d89094c4638907dcc009dafa48600a
Reviewed-on: https://upspin-review.googlesource.com/17760
Reviewed-by: Andrew Gerrand <adg@golang.org>
diff --git a/cloud/storage/b2cs/b2cs.go b/cloud/storage/b2cs/b2cs.go
index 4a79118..9699c03 100644
--- a/cloud/storage/b2cs/b2cs.go
+++ b/cloud/storage/b2cs/b2cs.go
@@ -8,11 +8,13 @@
 import (
 	"bytes"
 	"context"
+	"crypto/rand"
 	"fmt"
 	"io"
 
 	b2api "github.com/kurin/blazer/b2"
 
+	"upspin.io/cache"
 	"upspin.io/cloud/storage"
 	"upspin.io/errors"
 	"upspin.io/upspin"
@@ -31,10 +33,18 @@
 	bucket *b2api.Bucket
 	access b2api.BucketType
 
+	cursors *cache.LRU
+
 	ctx    context.Context
 	cancel context.CancelFunc
 }
 
+func randomToken() string {
+	b := make([]byte, 16)
+	rand.Read(b)
+	return fmt.Sprintf("%x", b)
+}
+
 // New initializes a Storage implementation that stores data to B2 Cloud Storage.
 func New(opts *storage.Opts) (storage.Storage, error) {
 	const op errors.Op = "cloud/storage/b2cs.New"
@@ -66,10 +76,11 @@
 	}
 
 	return &b2csImpl{
-		client: client,
-		bucket: bucket,
-		ctx:    ctx,
-		cancel: cancel,
+		client:  client,
+		bucket:  bucket,
+		ctx:     ctx,
+		cancel:  cancel,
+		cursors: cache.NewLRU(100),
 	}, nil
 }
 
@@ -147,6 +158,50 @@
 	return nil
 }
 
+// maxResults specifies the number of references to return from each call to
+// List. It is a variable here so that it may be overridden in tests.
+var maxResults = 1000
+
+// List implements storage.Lister.
+func (b2 *b2csImpl) List(token string) (refs []upspin.ListRefsItem, nextToken string, err error) {
+	const op = "cloud/storage/b2cs.List"
+
+	var cur *b2api.Cursor
+	if token != "" {
+		if cursor, ok := b2.cursors.Get(token); ok {
+			cur = cursor.(*b2api.Cursor)
+		} else {
+			return refs, "", errors.E(op, errors.IO, errors.Errorf("unknown token: %q", token))
+		}
+	}
+
+	objs, c, err := b2.bucket.ListCurrentObjects(b2.ctx, maxResults, cur)
+	if err != nil && err != io.EOF {
+		return refs, "", errors.E(op, errors.IO, errors.Errorf("unable to list objects: %v", err))
+	}
+
+	for _, obj := range objs {
+		attrs, err2 := obj.Attrs(b2.ctx)
+		if err2 != nil {
+			return refs, "", errors.E(op, errors.IO, errors.Errorf("unable to get object attributes %q: %v", obj.Name(), err))
+		}
+
+		refs = append(refs, upspin.ListRefsItem{
+			Ref:  upspin.Reference(obj.Name()),
+			Size: attrs.Size,
+		})
+	}
+
+	if err == io.EOF {
+		return refs, "", nil
+	}
+
+	nextToken = randomToken()
+	b2.cursors.Add(nextToken, c)
+
+	return refs, nextToken, nil
+}
+
 // Close implements Storage.
 func (b2 *b2csImpl) Close() {
 	b2.cancel()
diff --git a/cloud/storage/b2cs/b2cs_test.go b/cloud/storage/b2cs/b2cs_test.go
index 67a60a8..14ff183 100644
--- a/cloud/storage/b2cs/b2cs_test.go
+++ b/cloud/storage/b2cs/b2cs_test.go
@@ -13,6 +13,7 @@
 
 	"upspin.io/cloud/storage"
 	"upspin.io/log"
+	"upspin.io/upspin"
 )
 
 const defaultTestBucketName = "upspin-test-scratch"
@@ -26,8 +27,69 @@
 	testAccountID = flag.String("account", "", "B2 Cloud Storage account ID")
 	testAppKey    = flag.String("appkey", "", "B2 Cloud Storage application key")
 	useB2CS       = flag.Bool("use_b2cs", false, "enable to run b2cs tests; requires Backblaze credentials")
+
+	objectContents = []byte(fmt.Sprintf("This is test at %v", time.Now()))
 )
 
+func TestListingEmptyContainer(t *testing.T) {
+	l := client.(*b2csImpl)
+	refs, nextToken, err := l.List("")
+	if err != nil {
+		t.Fatal(err)
+	}
+	if len(refs) != 0 {
+		t.Errorf("List returned %d refs, want 0", len(refs))
+	}
+	if nextToken != "" {
+		t.Errorf("List returned token %q, want empty string", nextToken)
+	}
+}
+
+func TestListingWithPagination(t *testing.T) {
+	putRefs := make([]string, 10)
+	for i := 0; i < 10; i++ {
+		ref := fmt.Sprintf("ref%d", i)
+		putRefs[i] = ref
+		if err := client.Put(ref, objectContents); err != nil {
+			t.Fatal(err)
+		}
+	}
+
+	refs, callCount, err := getAllRefs(3, len(putRefs))
+	if err != nil {
+		t.Fatal(err)
+	}
+	if len(refs) != len(putRefs) {
+		t.Errorf("Listed %d refs, want %d", len(refs), len(putRefs))
+	}
+	if want := 4; callCount != want {
+		t.Errorf("List split into %d pages, want %d", callCount, want)
+	}
+}
+
+func getAllRefs(perPage int, maxCalls int) (allRefs []upspin.ListRefsItem, callCount int, err error) {
+	l := client.(*b2csImpl)
+	var token string
+
+	oldMax := maxResults
+	maxResults = perPage
+	defer func() { maxResults = oldMax }()
+
+	for callCount < maxCalls {
+		var refs []upspin.ListRefsItem
+		refs, token, err = l.List(token)
+		callCount++
+		if err != nil {
+			break
+		}
+		allRefs = append(allRefs, refs...)
+		if token == "" {
+			break
+		}
+	}
+	return
+}
+
 // The tests run against the live B2 Cloud Storage, not against a mocked B2
 // service. Because of that, credentials for an existing B2 account need to be
 // supplied with command-line flags to "go test". The test bucket is deleted
diff --git a/cmd/upspinserver-b2cs/upspinserver-b2cs b/cmd/upspinserver-b2cs/upspinserver-b2cs
new file mode 100755
index 0000000..54c2135
--- /dev/null
+++ b/cmd/upspinserver-b2cs/upspinserver-b2cs
Binary files differ