Repository: kopia/kopia
Pull request #2942 (2023-04-14 08:47:52)

test(general): added tests for parallelwork
This PR adds unit tests for parallelwork (100% coverage).

codecov[bot]: ## [Codecov](https://codecov.io/gh/kopia/kopia/pull/2942?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kopia) Report
Patch coverage has no change and project coverage change: **`-0.06`** :warning:
> Comparison is base [(`a8b3e87`)](https://codecov.io/gh/kopia/kopia/commit/a8b3e870f12fb46e9bd6ee19d592f5284071a3bb?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kopia) 75.33% compared to head [(`6b06e51`)](https://codecov.io/gh/kopia/kopia/pull/2942?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kopia) 75.28%.

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##           master    #2942      +/-   ##
==========================================
- Coverage   75.33%   75.28%   -0.06%     
==========================================
  Files         456      456              
  Lines       36021    36021              
==========================================
- Hits        27138    27119      -19     
- Misses       6988     7001      +13     
- Partials     1895     1901       +6     
```


| [Impacted Files](https://codecov.io/gh/kopia/kopia/pull/2942?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kopia) | Coverage Δ | |
|---|---|---|
| [internal/parallelwork/parallel\_work\_queue.go](https://codecov.io/gh/kopia/kopia/pull/2942?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kopia#diff-aW50ZXJuYWwvcGFyYWxsZWx3b3JrL3BhcmFsbGVsX3dvcmtfcXVldWUuZ28=) | `100.00% <ø> (ø)` | |

... and [8 files with indirect coverage changes](https://codecov.io/gh/kopia/kopia/pull/2942/indirect-changes?src=pr&el=tree-more&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kopia)

Help us with your feedback. Take ten seconds to tell us [how you rate us](https://about.codecov.io/nps?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kopia). Have a feature suggestion? [Share it here.](https://app.codecov.io/gh/feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kopia)

</details>

[:umbrella: View full report in Codecov by Sentry](https://codecov.io/gh/kopia/kopia/pull/2942?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kopia).   
:loudspeaker: Do you have feedback about the report comment? [Let us know in this issue](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kopia).


Patch:
diff --git a/.github/workflows/make.yml b/.github/workflows/make.yml
index f33467a9..1ff3ff03 100644
--- a/.github/workflows/make.yml
+++ b/.github/workflows/make.yml
@@ -189,7 +189,9 @@ jobs:
         DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
     - name: Bump Homebrew formula
       uses: dawidd6/action-homebrew-bump-formula@v3
-      if: github.ref_type == 'tag'
+      # only bump formula for tags which don't contain '-'
+      # this excludes vx.y.z-rc1
+      if: github.ref_type == 'tag' && !contains(github.ref_name, '-')
       with:
         token: ${{ secrets.HOMEBREW_PUSH_TOKEN }}
         formula: kopia
diff --git a/go.mod b/go.mod
index fa1c9cc6..ed31bb54 100644
--- a/go.mod
+++ b/go.mod
@@ -8,7 +8,7 @@ require (
 	github.com/Azure/azure-storage-blob-go v0.15.0
 	github.com/alecthomas/kingpin/v2 v2.3.2
 	github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137
-	github.com/aws/aws-sdk-go v1.44.244
+	github.com/aws/aws-sdk-go v1.44.247
 	github.com/chmduquesne/rollinghash v4.0.0+incompatible
 	github.com/chromedp/cdproto v0.0.0-20230220211738-2b1ec77315c9
 	github.com/chromedp/chromedp v0.9.1
@@ -24,7 +24,7 @@ require (
 	github.com/google/uuid v1.3.0
 	github.com/gorilla/mux v1.8.0
 	github.com/hanwen/go-fuse/v2 v2.2.0
-	github.com/klauspost/compress v1.16.4
+	github.com/klauspost/compress v1.16.5
 	github.com/klauspost/pgzip v1.2.5
 	github.com/klauspost/reedsolomon v1.11.7
 	github.com/kopia/htmluibuild v0.0.0-20230326183719-f482ef17e2c9
@@ -61,7 +61,7 @@ require (
 	golang.org/x/sys v0.7.0
 	golang.org/x/term v0.7.0
 	golang.org/x/text v0.9.0
-	google.golang.org/api v0.118.0
+	google.golang.org/api v0.119.0
 	google.golang.org/grpc v1.54.0
 	google.golang.org/protobuf v1.30.0
 	gopkg.in/kothar/go-backblaze.v0 v0.0.0-20210124194846-35409b867216
@@ -96,7 +96,7 @@ require (
 	github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
 	github.com/google/pprof v0.0.0-20211214055906-6f57359322fd // indirect
 	github.com/google/readahead v0.0.0-20161222183148-eaceba169032 // indirect
-	github.com/google/s2a-go v0.1.0 // indirect
+	github.com/google/s2a-go v0.1.2 // indirect
 	github.com/googleapis/enterprise-certificate-proxy v0.2.3 // indirect
 	github.com/googleapis/gax-go/v2 v2.8.0 // indirect
 	github.com/jmespath/go-jmespath v0.4.0 // indirect
@@ -122,7 +122,7 @@ require (
 	go.uber.org/atomic v1.9.0 // indirect
 	golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect
 	google.golang.org/appengine v1.6.7 // indirect
-	google.golang.org/genproto v0.0.0-20230403163135-c38d8f061ccd // indirect
+	google.golang.org/genproto v0.0.0-20230410155749-daa745c078e1 // indirect
 	gopkg.in/ini.v1 v1.67.0 // indirect
 	gopkg.in/yaml.v3 v3.0.1 // indirect
 )
diff --git a/go.sum b/go.sum
index a2993014..5e314604 100644
--- a/go.sum
+++ b/go.sum
@@ -44,8 +44,8 @@ github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137/go.mod h1:OMCwj8V
 github.com/alessio/shellescape v1.4.1 h1:V7yhSDDn8LP4lc4jS8pFkt0zCnzVJlG5JXy9BVKJUX0=
 github.com/alessio/shellescape v1.4.1/go.mod h1:PZAiSCk0LJaZkiCSkPv8qIobYglO3FPpyFjDCtHLS30=
 github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY=
-github.com/aws/aws-sdk-go v1.44.244 h1:QzBWLD5HjZHdRZyTMTOWtD9Pobzf1n8/CeTJB4giXi0=
-github.com/aws/aws-sdk-go v1.44.244/go.mod h1:aVsgQcEevwlmQ7qHE9I3h+dtQgpqhFB+i8Phjh7fkwI=
+github.com/aws/aws-sdk-go v1.44.247 h1:oUJZr62HT8RIo9nRCwam8iXzXnSIwwndSVGH0/gym+E=
+github.com/aws/aws-sdk-go v1.44.247/go.mod h1:aVsgQcEevwlmQ7qHE9I3h+dtQgpqhFB+i8Phjh7fkwI=
 github.com/benbjohnson/clock v1.1.0 h1:Q92kusRqC1XV2MjkWETPvjJVqKetz1OzxZB7mHJLju8=
 github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
 github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
@@ -163,8 +163,8 @@ github.com/google/pprof v0.0.0-20211214055906-6f57359322fd h1:1FjCyPC+syAzJ5/2S8
 github.com/google/pprof v0.0.0-20211214055906-6f57359322fd/go.mod h1:KgnwoLYCZ8IQu3XUZ8Nc/bM9CCZFOyjUNOSygVozoDg=
 github.com/google/readahead v0.0.0-20161222183148-eaceba169032 h1:6Be3nkuJFyRfCgr6qTIzmRp8y9QwDIbqy/nYr9WDPos=
 github.com/google/readahead v0.0.0-20161222183148-eaceba169032/go.mod h1:qYysrqQXuV4tzsizt4oOQ6mrBZQ0xnQXP3ylXX8Jk5Y=
-github.com/google/s2a-go v0.1.0 h1:3Qm0liEiCErViKERO2Su5wp+9PfMRiuS6XB5FvpKnYQ=
-github.com/google/s2a-go v0.1.0/go.mod h1:OJpEgntRZo8ugHpF9hkoLJbS5dSI20XZeXJ9JVywLlM=
+github.com/google/s2a-go v0.1.2 h1:WVtYAYuYxKeYajAmThMRYWP6K3wXkcqbGHeUgeubUHY=
+github.com/google/s2a-go v0.1.2/go.mod h1:OJpEgntRZo8ugHpF9hkoLJbS5dSI20XZeXJ9JVywLlM=
 github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
 github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
 github.com/google/uuid v1.2.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
@@ -188,8 +188,8 @@ github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8Hm
 github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
 github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
 github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
-github.com/klauspost/compress v1.16.4 h1:91KN02FnsOYhuunwU4ssRe8lc2JosWmizWa91B5v1PU=
-github.com/klauspost/compress v1.16.4/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE=
+github.com/klauspost/compress v1.16.5 h1:IFV2oUNUzZaz+XyusxpLzpzS8Pt5rh0Z16For/djlyI=
+github.com/klauspost/compress v1.16.5/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE=
 github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
 github.com/klauspost/cpuid/v2 v2.0.4/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
 github.com/klauspost/cpuid/v2 v2.0.12/go.mod h1:g2LTdtYhdyuGPqyWyv7qRAmj1WBqxuObKfj5c0PQa7c=
@@ -423,8 +423,8 @@ golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8T
 golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 h1:H2TDz8ibqkAF6YGhCdN3jS9O0/s90v0rJh3X/OLHEUk=
 golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8=
-google.golang.org/api v0.118.0 h1:FNfHq9Z2GKULxu7cEhCaB0wWQHg43UpomrrN+24ZRdE=
-google.golang.org/api v0.118.0/go.mod h1:76TtD3vkgmZ66zZzp72bUUklpmQmKlhh6sYtIjYK+5E=
+google.golang.org/api v0.119.0 h1:Dzq+ARD6+8jmd5wknJE1crpuzu1JiovEU6gCp9PkoKA=
+google.golang.org/api v0.119.0/go.mod h1:CrSvlNEFCFLae9ZUtL1z+61+rEBD7J/aCYwVYKZoWFU=
 google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
 google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
 google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c=
@@ -433,8 +433,8 @@ google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoA
 google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
 google.golang.org/genproto v0.0.0-20200513103714-09dca8ec2884/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
 google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
-google.golang.org/genproto v0.0.0-20230403163135-c38d8f061ccd h1:sLpv7bNL1AsX3fdnWh9WVh7ejIzXdOc1RRHGeAmeStU=
-google.golang.org/genproto v0.0.0-20230403163135-c38d8f061ccd/go.mod h1:UUQDJDOlWu4KYeJZffbWgBkS1YFobzKbLVfK69pe0Ak=
+google.golang.org/genproto v0.0.0-20230410155749-daa745c078e1 h1:KpwkzHKEF7B9Zxg18WzOa7djJ+Ha5DzthMyZYQfEn2A=
+google.golang.org/genproto v0.0.0-20230410155749-daa745c078e1/go.mod h1:nKE/iIaLqn2bQwXBg8f1g2Ylh6r5MN5CmZvuzZCgsCU=
 google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
 google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
 google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
diff --git a/internal/parallelwork/parallel_work_queue.go b/internal/parallelwork/parallel_work_queue.go
index 855e539b..144c385b 100644
--- a/internal/parallelwork/parallel_work_queue.go
+++ b/internal/parallelwork/parallel_work_queue.go
@@ -1,4 +1,4 @@
-// Package parallelwork implements pallel work queue with fixed number of workers that concurrently process and add work items to the queue.
+// Package parallelwork implements parallel work queue with fixed number of workers that concurrently process and add work items to the queue.
 package parallelwork
 
 import (
diff --git a/repo/manifest/committed_manifest_manager.go b/repo/manifest/committed_manifest_manager.go
index 048d2862..32638a3a 100644
--- a/repo/manifest/committed_manifest_manager.go
+++ b/repo/manifest/committed_manifest_manager.go
@@ -346,11 +346,13 @@ func loadManifestContent(ctx context.Context, b contentManager, contentID conten
 		return man, errors.Wrapf(err, "unable to unpack manifest data %q", contentID)
 	}
 
-	if err := json.NewDecoder(gz).Decode(&man); err != nil {
-		return man, errors.Wrapf(err, "unable to parse manifest %q", contentID)
-	}
+	// Will be GC-ed even if we don't close it?
+	//nolint:errcheck
+	defer gz.Close()
+
+	man, err = decodeManifestArray(gz)
 
-	return man, nil
+	return man, errors.Wrapf(err, "unable to parse manifest %q", contentID)
 }
 
 func newCommittedManager(b contentManager) *committedManifestManager {
diff --git a/repo/manifest/serialized.go b/repo/manifest/serialized.go
index 05afb177..a99f2ff9 100644
--- a/repo/manifest/serialized.go
+++ b/repo/manifest/serialized.go
@@ -2,7 +2,11 @@
 
 import (
 	"encoding/json"
+	"io"
+	"strings"
 	"time"
+
+	"github.com/pkg/errors"
 )
 
 type manifest struct {
@@ -16,3 +20,122 @@ type manifestEntry struct {
 	Deleted bool              `json:"deleted,omitempty"`
 	Content json.RawMessage   `json:"data"`
 }
+
+const (
+	objectOpen  = "{"
+	objectClose = "}"
+	arrayOpen   = "["
+	arrayClose  = "]"
+)
+
+var errEOF = errors.New("unexpected end of input")
+
+func expectDelimToken(dec *json.Decoder, expectedToken string) error {
+	t, err := dec.Token()
+	if errors.Is(err, io.EOF) {
+		return errors.WithStack(errEOF)
+	} else if err != nil {
+		return errors.Wrap(err, "reading JSON token")
+	}
+
+	d, ok := t.(json.Delim)
+	if !ok {
+		return errors.Errorf("unexpected token: (%T) %v", t, t)
+	} else if d.String() != expectedToken {
+		return errors.Errorf("unexpected token; wanted %s, got %s", expectedToken, d)
+	}
+
+	return nil
+}
+
+func stringToken(dec *json.Decoder) (string, error) {
+	t, err := dec.Token()
+	if errors.Is(err, io.EOF) {
+		return "", errors.WithStack(errEOF)
+	} else if err != nil {
+		return "", errors.Wrap(err, "reading JSON token")
+	}
+
+	l, ok := t.(string)
+	if !ok {
+		return "", errors.Errorf("unexpected token (%T) %v; wanted field name", t, t)
+	}
+
+	return l, nil
+}
+
+func decodeManifestArray(r io.Reader) (manifest, error) {
+	var (
+		dec = json.NewDecoder(r)
+		res = manifest{}
+	)
+
+	if err := expectDelimToken(dec, objectOpen); err != nil {
+		return res, err
+	}
+
+	// Need to manually decode fields here since we can't reuse the stdlib
+	// decoder due to memory issues.
+	if err := parseFields(dec, &res); err != nil {
+		return res, err
+	}
+
+	// Consumes closing object curly brace after we're done. Don't need to check
+	// for EOF because json.Decode only guarantees decoding the next JSON item in
+	// the stream so this follows that.
+	return res, expectDelimToken(dec, objectClose)
+}
+
+func parseFields(dec *json.Decoder, res *manifest) error {
+	var seen bool
+
+	for dec.More() {
+		l, err := stringToken(dec)
+		if err != nil {
+			return err
+		}
+
+		// Only have `entries` field right now. Skip other fields.
+		if !strings.EqualFold("entries", l) {
+			continue
+		}
+
+		if seen {
+			return errors.New("repeated Entries field")
+		}
+
+		seen = true
+
+		if err := decodeArray(dec, &res.Entries); err != nil {
+			return err
+		}
+	}
+
+	return nil
+}
+
+// decodeArray decodes an array of *manifestEntry and returns them in output. If
+// an error occurs output may contain intermediate state.
+//
+// This can be made into a generic function pretty easily if it's needed in
+// other places.
+func decodeArray(dec *json.Decoder, output *[]*manifestEntry) error {
+	// Consume starting bracket.
+	if err := expectDelimToken(dec, arrayOpen); err != nil {
+		return err
+	}
+
+	// Read elements.
+	for dec.More() {
+		var tmp *manifestEntry
+
+		if err := dec.Decode(&tmp); err != nil {
+			return errors.Wrap(err, "decoding array element")
+		}
+
+		*output = append(*output, tmp)
+	}
+
+	// Consume ending bracket.
+	return expectDelimToken(dec, arrayClose)
+}
diff --git a/site/content/docs/Installation/_index.md b/site/content/docs/Installation/_index.md
index edb323c4..a96266aa 100644
--- a/site/content/docs/Installation/_index.md
+++ b/site/content/docs/Installation/_index.md
@@ -124,13 +124,13 @@ Kopia offers APT repository compatible with Debian, Ubuntu and other similar dis
 To begin, install the GPG signing key to verify authenticity of the releases.
 
 ```shell
-curl -s https://kopia.io/signing-key | sudo gpg --dearmor -o /usr/share/keyrings/kopia-keyring.gpg
+curl -s https://kopia.io/signing-key | sudo gpg --dearmor -o /etc/apt/keyrings/kopia-keyring.gpg
 ```
 
 Register APT source:
 
 ```shell
-echo "deb [signed-by=/usr/share/keyrings/kopia-keyring.gpg] http://packages.kopia.io/apt/ stable main" | sudo tee /etc/apt/sources.list.d/kopia.list
+echo "deb [signed-by=/etc/apt/keyrings/kopia-keyring.gpg] http://packages.kopia.io/apt/ stable main" | sudo tee /etc/apt/sources.list.d/kopia.list
 sudo apt update
 ```
 
diff --git a/site/content/signing-key b/site/content/signing-key
index b547ab96..7cd94fd3 100644
--- a/site/content/signing-key
+++ b/site/content/signing-key
@@ -11,19 +11,19 @@ CNSKN3pj72MzQGFE53T2q7IJ/H7ICZcvuZUhfkbmKTjNZOJlealfmlrftcbiDZbY
 RguMmEvDQUMCbzjRPyXs/2q28uNqnwDYGzOh5wSTyUks1cGR9JhkAO/n7EHsJDyW
 dRQmXfAl/f/9Tbt/D31N+T7JmWsBVvhxJQoKUGWnKuelpUr8zegTy29z2Xii68tW
 s6jMGCbmKn6JvVHjunBemEAWlT0ZI/+ETER+krHZQ9Z9TFkcl9m2Yq63gwARAQAB
-tCBLb3BpYSBCdWlsZGVyIDxidWlsZGVyQGtvcGlhLmlvPokCVAQTAQgAPhYhBH+5
-nf1HgJ8NUznX2SJzaZr9VqVWBQJgeOwGAhsDBQkDwmcABQsJCAcCBhUKCQgLAgQW
-AgMBAh4BAheAAAoJECJzaZr9VqVW2A4QALsCaXZCWUlr6sg5RgM5TScOYNxt+qqc
-ViIY/EkEhQ9tb77d6BW9JqduXEEgfUtBZ96fQpXEqcf49Cyiqezf9Bq6OKLNS15x
-mBae78kZVMER2pGgvFM5ZrNURZO3mTjcdMx941GdR1rdXIKspkNapkGXhIBArYHt
-2OQkM4XAblU/ai2EXFHaRiN4H7Id536iqpt8HBH/kpXMbOgxuFrhVn8Ze89UpI+W
-WcoXZ4VaYzs5rBop5aM4YncshBodH0UlUK9/mhu0kioPiJA75DYg1MK/TeKL6yMr
-T9MvU7aFZkm0G/4O68xfWWqbB4xlnUBU9PwqF0Pkg3fpVKQifvhaeJz+KrxAyt6V
-ShHnnw2wh9S3wEr6SuaA2ivGIfjDEd9dVSVbxnQD0p+/NKqcSFr7/RB7+1n4l8j0
-UFa0mJTSB4xJvDhWflmYqRox/x/4LjpwRE1U5PX7gwJ3yELwy6ybJN4826nN5a6Y
-XU+OVMR7pL4UuC+8MACKRnVq7Tw92E0ttYDhYAZvjGHmOjBtdQcj9eqJ8K/vf/DU
-+MD+vCFNUkhq3V4LoNE6K5Uz6ESwDbCNSiyxO3Xd+c0yElDozjXioMLVAuKE/STX
-6Do6WJUVDbP0ygbR6a1AGJU2/mVICfob6ai3FvjazWRxPjTtZlpHuOBu4JP2e0iT
-iJYG/llgQmKv
-=VNmX
+tCBLb3BpYSBCdWlsZGVyIDxidWlsZGVyQGtvcGlhLmlvPokCTgQTAQgAOAIbAwUL
+CQgHAgYVCgkICwIEFgIDAQIeAQIXgBYhBH+5nf1HgJ8NUznX2SJzaZr9VqVWBQJk
+PNfMAAoJECJzaZr9VqVWNPgQALRGo3wQQULN6CGxjLCiGjtX7nRHS8h9/qDOBCai
+BQ9mCn291ecPVYbHpnufMpsBpEBnGEaMHKEFurX590A8r3VcHp5khj7APP4bG3RF
+iJiPlxqBxbkDaG3QhoG3Wzd9HQgFoPT9uEtcs1Kgmn9H522mV9g2+3S8dBAH+VBw
+eFWxQTNnUp8ZsHndlxRSXdVH43HEKHN5P/xCVFdIo7x1EIBEfnaFEb0RbYuwFtv5
+wvDuIPxKSDABhyuxVzOLFPE66yyjahx+dJ9yjbZOvEZQ7IElPvgQDrM1edr1swEC
+1xSJraUN0TC9gqqgFtYRM8D0Tp1BB5Mxr+Q1S1vMYIvlROEZmggHKalYHSordW43
+mxN1pkIPy0dWyXGLyPsXuAPRAzLm5l2IJY/QEEBEHLjpCD6nreCvIjmeKtB7ZJzG
+PyVluT8CAhDUYirGGOz3AT6/KHDCb7neZePGuuAQ7SXHrB8W2FtVT4dGDlvAzcDK
+sY/7jgQmSmZhmC4TthtI09Q9YPBl8kfJBr4cEEvB8RpduVl0NASoiuO1cPvrT2O7
+LKNunPX03gC2vezHvpRZNeA2ytMIAnlvAnZHugA3N8rH8IPFwAw9r84gxrwgRqzf
+fMV1mFfOxrurGv2YA+fNiWIg3Is12VrVZ52QMYRC6OfFfUO1HivU2kL2hKkv4OwT
+Hk0g
+=GzQn
 -----END PGP PUBLIC KEY BLOCK-----


Test patch:
diff --git a/internal/parallelwork/parallel_work_queue_test.go b/internal/parallelwork/parallel_work_queue_test.go
new file mode 100644
index 00000000..9b98f307
--- /dev/null
+++ b/internal/parallelwork/parallel_work_queue_test.go
@@ -0,0 +1,241 @@
+package parallelwork_test
+
+import (
+	"context"
+	"errors"
+	"sync"
+	"sync/atomic"
+	"testing"
+	"time"
+
+	"github.com/stretchr/testify/require"
+
+	"github.com/kopia/kopia/internal/parallelwork"
+)
+
+func TestEnqueueFrontAndProcess(t *testing.T) {
+	queue := parallelwork.NewQueue()
+
+	results := make(chan int, 3)
+
+	// Enqueue work items to the front of the queue
+	queue.EnqueueFront(context.Background(), func() error {
+		results <- 3
+		return nil
+	})
+	queue.EnqueueFront(context.Background(), func() error {
+		results <- 2
+		return nil
+	})
+	queue.EnqueueFront(context.Background(), func() error {
+		results <- 1
+		return nil
+	})
+
+	err := queue.Process(context.Background(), 2) // Use two workers
+	require.NoError(t, err)
+
+	close(results)
+
+	var sum int
+	for res := range results {
+		sum += res
+	}
+
+	require.Equal(t, 6, sum)
+}
+
+func TestEnqueueBackAndProcess(t *testing.T) {
+	queue := parallelwork.NewQueue()
+
+	results := make(chan int, 3)
+
+	// Enqueue work items to the back of the queue
+	queue.EnqueueBack(context.Background(), func() error {
+		results <- 1
+		return nil
+	})
+	queue.EnqueueBack(context.Background(), func() error {
+		results <- 2
+		return nil
+	})
+	queue.EnqueueBack(context.Background(), func() error {
+		results <- 3
+		return nil
+	})
+
+	err := queue.Process(context.Background(), 2) // Use two workers
+	require.NoError(t, err)
+
+	close(results)
+
+	var sum int
+	for res := range results {
+		sum += res
+	}
+
+	require.Equal(t, 6, sum)
+}
+
+func TestProcessWithError(t *testing.T) {
+	queue := parallelwork.NewQueue()
+
+	testError := errors.New("test error") //nolint:goerr113
+
+	// Enqueue work items, one of them returns an error
+	queue.EnqueueBack(context.Background(), func() error {
+		time.Sleep(100 * time.Millisecond)
+		return nil
+	})
+	queue.EnqueueBack(context.Background(), func() error {
+		return testError
+	})
+	queue.EnqueueBack(context.Background(), func() error {
+		time.Sleep(100 * time.Millisecond)
+		return nil
+	})
+
+	err := queue.Process(context.Background(), 2) // Use two workers
+	require.Equal(t, testError, err)
+}
+
+func TestWaitForActiveWorkers(t *testing.T) {
+	queue := parallelwork.NewQueue()
+
+	results := make(chan int, 3)
+
+	queue.EnqueueBack(context.Background(), func() error {
+		time.Sleep(100 * time.Millisecond)
+		queue.EnqueueBack(context.Background(), func() error {
+			results <- 2
+			return nil
+		})
+		results <- 1
+		return nil
+	})
+
+	err := queue.Process(context.Background(), 1) // Use only one worker
+	require.NoError(t, err)
+
+	close(results)
+
+	var sum int
+	for res := range results {
+		sum += res
+	}
+
+	require.Equal(t, 3, sum)
+}
+
+func TestProgressCallback(t *testing.T) {
+	queue := parallelwork.NewQueue()
+
+	progressUpdates := make(chan struct {
+		enqueued, active, completed int64
+	}, 3)
+
+	queue.ProgressCallback = func(ctx context.Context, enqueued, active, completed int64) {
+		progressUpdates <- struct {
+			enqueued, active, completed int64
+		}{enqueued, active, completed}
+	}
+
+	queue.EnqueueBack(context.Background(), func() error {
+		time.Sleep(100 * time.Millisecond)
+		return nil
+	})
+	queue.EnqueueBack(context.Background(), func() error {
+		time.Sleep(100 * time.Millisecond)
+		return nil
+	})
+
+	err := queue.Process(context.Background(), 2) // Use two workers
+	require.NoError(t, err)
+
+	close(progressUpdates)
+
+	for update := range progressUpdates {
+		require.True(t, update.enqueued >= 0)
+		require.True(t, update.active >= 0)
+		require.True(t, update.completed >= 0)
+	}
+}
+
+func TestOnNthCompletion(t *testing.T) {
+	t.Run("callback is only called on n-th invocation", func(t *testing.T) {
+		var (
+			n               = 5                    // expect invocation on 5th attempt
+			errCalled       = errors.New("called") //nolint:goerr113
+			callbackInvoked int
+			callback        = func() error {
+				callbackInvoked++
+				return errCalled
+			}
+		)
+
+		onNthCompletion := parallelwork.OnNthCompletion(n, callback)
+
+		// before n-th invocation
+		for i := 0; i < n-1; i++ {
+			err := onNthCompletion()
+			require.NoError(t, err)
+			require.Equal(t, callbackInvoked, 0)
+		}
+
+		// on n-th invocation
+		err := onNthCompletion()
+		require.Error(t, err)
+		require.ErrorIs(t, err, errCalled)
+		require.Equal(t, callbackInvoked, 1)
+
+		// call once again (after n-th invocation)
+		err = onNthCompletion()
+		require.NoError(t, err)
+		require.Equal(t, callbackInvoked, 1)
+	})
+
+	t.Run("concurrency-safe", func(t *testing.T) {
+		var (
+			n               = 5                     // expect invocation on 5th attempt
+			results         = make(chan error, n+1) // we will have n+1, i.e. 6 attempts in total
+			errCalled       = errors.New("called")  //nolint:goerr113
+			callbackInvoked atomic.Int32
+			wg              sync.WaitGroup
+			callback        = func() error {
+				callbackInvoked.Add(1)
+				return errCalled
+			}
+		)
+
+		onNthCompletion := parallelwork.OnNthCompletion(n, callback)
+
+		wg.Add(n + 1)
+
+		for i := 0; i < n+1; i++ {
+			go func() {
+				results <- onNthCompletion()
+				wg.Done()
+			}()
+		}
+
+		wg.Wait()
+		close(results)
+
+		// callback must be called exactly 1 time
+		require.Equal(t, callbackInvoked.Load(), int32(1))
+
+		var cnt int
+		for result := range results {
+			cnt++
+			switch cnt {
+			// n-th invocation must run and return an expected error
+			case n:
+				require.Error(t, result)
+				require.ErrorIs(t, result, errCalled)
+			// other invocations must not run and return any error
+			default:
+				require.NoError(t, result)
+			}
+		}
+	})
+}
diff --git a/repo/manifest/serialized_test.go b/repo/manifest/serialized_test.go
new file mode 100644
index 00000000..1529adcc
--- /dev/null
+++ b/repo/manifest/serialized_test.go
@@ -0,0 +1,67 @@
+package manifest
+
+import (
+	"bytes"
+	"encoding/json"
+	"reflect"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+
+	"github.com/kopia/kopia/repo/manifest/testdata"
+)
+
+func TestManifestDecode_GoodInput(t *testing.T) {
+	table := []struct {
+		name  string
+		input []byte
+	}{
+		{
+			name:  "MultipleManifests",
+			input: []byte(testdata.GoodManifests),
+		},
+		{
+			name:  "IgnoredField",
+			input: []byte(testdata.IgnoredField),
+		},
+		{
+			name:  "StopsAtStructEnd",
+			input: []byte(testdata.ExtraInputAtEnd),
+		},
+		{
+			name:  "CaseInsensitive",
+			input: []byte(testdata.CaseInsensitive),
+		},
+	}
+
+	for _, test := range table {
+		t.Run(test.name, func(t *testing.T) {
+			stdlibDec := manifest{}
+
+			stdReader := bytes.NewReader(test.input)
+			require.NoError(t, json.NewDecoder(stdReader).Decode(&stdlibDec))
+
+			arrReader := bytes.NewReader(test.input)
+			arrDec, err := decodeManifestArray(arrReader)
+			require.NoError(t, err)
+
+			assert.Equal(t, stdlibDec, arrDec)
+
+			assert.True(t, reflect.DeepEqual(stdlibDec, arrDec))
+		})
+	}
+}
+
+func TestManifestDecode_BadInput(t *testing.T) {
+	for _, test := range testdata.BadInputs {
+		t.Run(test.Name, func(t *testing.T) {
+			r := bytes.NewReader([]byte(test.Input))
+			_, err := decodeManifestArray(r)
+
+			t.Logf("%v", err)
+
+			assert.Error(t, err)
+		})
+	}
+}
diff --git a/repo/manifest/testdata/manifests.go b/repo/manifest/testdata/manifests.go
new file mode 100644
index 00000000..07274c24
--- /dev/null
+++ b/repo/manifest/testdata/manifests.go
@@ -0,0 +1,879 @@
+package testdata
+
+type testInput struct {
+	Name  string
+	Input string
+}
+
+var (
+	BadInputs = []testInput{
+		{
+			Name: "RepeatedEntriesField",
+			Input: `
+{
+	"entries": [
+	{
+		"id": "25905b6f222a153561543baea0a67043",
+		"labels": {
+			"hostname": "host-name",
+			"path": "/root/tmp/test",
+			"type": "snapshot",
+			"username": "user-name"
+		},
+		"modified": "2023-03-16T20:46:59.70714Z",
+		"data": {
+			"id": "",
+			"source": {
+				"host": "host-name",
+				"userName": "user-name",
+				"path": "/root/tmp/test"
+			},
+			"description": "",
+			"startTime": "2023-03-16T20:46:55.76843Z",
+			"endTime": "2023-03-16T20:46:59.707064Z",
+			"stats": {
+				"totalSize": 536927459,
+				"excludedTotalSize": 0,
+				"fileCount": 18,
+				"cachedFiles": 0,
+				"nonCachedFiles": 18,
+				"dirCount": 14,
+				"excludedFileCount": 0,
+				"excludedDirCount": 0,
+				"ignoredErrorCount": 0,
+				"errorCount": 0
+			},
+			"rootEntry": {
+				"name": "test",
+				"type": "d",
+				"mode": "0777",
+				"mtime": "1754-08-30T22:43:41.128654848Z",
+				"obj": "k74647859396c88127696f426b4c79088",
+				"summ": {
+					"size": 536927459,
+					"files": 18,
+					"symlinks": 0,
+					"dirs": 14,
+					"maxTime": "2023-03-16T20:46:56.187394Z",
+					"numFailed": 0
+				}
+			},
+		}
+	},
+	],
+	entries: []
+}`,
+		},
+		{
+			Name: "MissingObjectStart",
+			Input: `
+	"entries": [
+	{
+		"id": "25905b6f222a153561543baea0a67043",
+		"labels": {
+			"hostname": "host-name",
+			"path": "/root/tmp/test",
+			"type": "snapshot",
+			"username": "user-name"
+		},
+		"modified": "2023-03-16T20:46:59.70714Z",
+		"data": {
+			"id": "",
+			"source": {
+				"host": "host-name",
+				"userName": "user-name",
+				"path": "/root/tmp/test"
+			},
+			"description": "",
+			"startTime": "2023-03-16T20:46:55.76843Z",
+			"endTime": "2023-03-16T20:46:59.707064Z",
+			"stats": {
+				"totalSize": 536927459,
+				"excludedTotalSize": 0,
+				"fileCount": 18,
+				"cachedFiles": 0,
+				"nonCachedFiles": 18,
+				"dirCount": 14,
+				"excludedFileCount": 0,
+				"excludedDirCount": 0,
+				"ignoredErrorCount": 0,
+				"errorCount": 0
+			},
+			"rootEntry": {
+				"name": "test",
+				"type": "d",
+				"mode": "0777",
+				"mtime": "1754-08-30T22:43:41.128654848Z",
+				"obj": "k74647859396c88127696f426b4c79088",
+				"summ": {
+					"size": 536927459,
+					"files": 18,
+					"symlinks": 0,
+					"dirs": 14,
+					"maxTime": "2023-03-16T20:46:56.187394Z",
+					"numFailed": 0
+				}
+			}
+		}
+	}
+	]
+}`,
+		},
+		{
+			Name: "MissingObjectEnd",
+			Input: `
+{
+	"entries": [
+	{
+		"id": "25905b6f222a153561543baea0a67043",
+		"labels": {
+			"hostname": "host-name",
+			"path": "/root/tmp/test",
+			"type": "snapshot",
+			"username": "user-name"
+		},
+		"modified": "2023-03-16T20:46:59.70714Z",
+		"data": {
+			"id": "",
+			"source": {
+				"host": "host-name",
+				"userName": "user-name",
+				"path": "/root/tmp/test"
+			},
+			"description": "",
+			"startTime": "2023-03-16T20:46:55.76843Z",
+			"endTime": "2023-03-16T20:46:59.707064Z",
+			"stats": {
+				"totalSize": 536927459,
+				"excludedTotalSize": 0,
+				"fileCount": 18,
+				"cachedFiles": 0,
+				"nonCachedFiles": 18,
+				"dirCount": 14,
+				"excludedFileCount": 0,
+				"excludedDirCount": 0,
+				"ignoredErrorCount": 0,
+				"errorCount": 0
+			},
+			"rootEntry": {
+				"name": "test",
+				"type": "d",
+				"mode": "0777",
+				"mtime": "1754-08-30T22:43:41.128654848Z",
+				"obj": "k74647859396c88127696f426b4c79088",
+				"summ": {
+					"size": 536927459,
+					"files": 18,
+					"symlinks": 0,
+					"dirs": 14,
+					"maxTime": "2023-03-16T20:46:56.187394Z",
+					"numFailed": 0
+				}
+			}
+		}
+	}
+	]`,
+		},
+		{
+			Name: "MissingArrayStart",
+			Input: `
+{
+	"entries":
+	{
+		"id": "25905b6f222a153561543baea0a67043",
+		"labels": {
+			"hostname": "host-name",
+			"path": "/root/tmp/test",
+			"type": "snapshot",
+			"username": "user-name"
+		},
+		"modified": "2023-03-16T20:46:59.70714Z",
+		"data": {
+			"id": "",
+			"source": {
+				"host": "host-name",
+				"userName": "user-name",
+				"path": "/root/tmp/test"
+			},
+			"description": "",
+			"startTime": "2023-03-16T20:46:55.76843Z",
+			"endTime": "2023-03-16T20:46:59.707064Z",
+			"stats": {
+				"totalSize": 536927459,
+				"excludedTotalSize": 0,
+				"fileCount": 18,
+				"cachedFiles": 0,
+				"nonCachedFiles": 18,
+				"dirCount": 14,
+				"excludedFileCount": 0,
+				"excludedDirCount": 0,
+				"ignoredErrorCount": 0,
+				"errorCount": 0
+			},
+			"rootEntry": {
+				"name": "test",
+				"type": "d",
+				"mode": "0777",
+				"mtime": "1754-08-30T22:43:41.128654848Z",
+				"obj": "k74647859396c88127696f426b4c79088",
+				"summ": {
+					"size": 536927459,
+					"files": 18,
+					"symlinks": 0,
+					"dirs": 14,
+					"maxTime": "2023-03-16T20:46:56.187394Z",
+					"numFailed": 0
+				}
+			}
+		}
+	}
+	]
+}`,
+		},
+		{
+			Name: "MissingArrayEnd",
+			Input: `
+{
+	"entries": [
+	{
+		"id": "25905b6f222a153561543baea0a67043",
+		"labels": {
+			"hostname": "host-name",
+			"path": "/root/tmp/test",
+			"type": "snapshot",
+			"username": "user-name"
+		},
+		"modified": "2023-03-16T20:46:59.70714Z",
+		"data": {
+			"id": "",
+			"source": {
+				"host": "host-name",
+				"userName": "user-name",
+				"path": "/root/tmp/test"
+			},
+			"description": "",
+			"startTime": "2023-03-16T20:46:55.76843Z",
+			"endTime": "2023-03-16T20:46:59.707064Z",
+			"stats": {
+				"totalSize": 536927459,
+				"excludedTotalSize": 0,
+				"fileCount": 18,
+				"cachedFiles": 0,
+				"nonCachedFiles": 18,
+				"dirCount": 14,
+				"excludedFileCount": 0,
+				"excludedDirCount": 0,
+				"ignoredErrorCount": 0,
+				"errorCount": 0
+			},
+			"rootEntry": {
+				"name": "test",
+				"type": "d",
+				"mode": "0777",
+				"mtime": "1754-08-30T22:43:41.128654848Z",
+				"obj": "k74647859396c88127696f426b4c79088",
+				"summ": {
+					"size": 536927459,
+					"files": 18,
+					"symlinks": 0,
+					"dirs": 14,
+					"maxTime": "2023-03-16T20:46:56.187394Z",
+					"numFailed": 0
+				}
+			}
+		}
+	}
+}`,
+		},
+		{
+			Name: "MissingInnerObjectStart",
+			Input: `
+{
+	"entries": [
+		"id": "25905b6f222a153561543baea0a67043",
+		"labels": {
+			"hostname": "host-name",
+			"path": "/root/tmp/test",
+			"type": "snapshot",
+			"username": "user-name"
+		},
+		"modified": "2023-03-16T20:46:59.70714Z",
+		"data": {
+			"id": "",
+			"source": {
+				"host": "host-name",
+				"userName": "user-name",
+				"path": "/root/tmp/test"
+			},
+			"description": "",
+			"startTime": "2023-03-16T20:46:55.76843Z",
+			"endTime": "2023-03-16T20:46:59.707064Z",
+			"stats": {
+				"totalSize": 536927459,
+				"excludedTotalSize": 0,
+				"fileCount": 18,
+				"cachedFiles": 0,
+				"nonCachedFiles": 18,
+				"dirCount": 14,
+				"excludedFileCount": 0,
+				"excludedDirCount": 0,
+				"ignoredErrorCount": 0,
+				"errorCount": 0
+			},
+			"rootEntry": {
+				"name": "test",
+				"type": "d",
+				"mode": "0777",
+				"mtime": "1754-08-30T22:43:41.128654848Z",
+				"obj": "k74647859396c88127696f426b4c79088",
+				"summ": {
+					"size": 536927459,
+					"files": 18,
+					"symlinks": 0,
+					"dirs": 14,
+					"maxTime": "2023-03-16T20:46:56.187394Z",
+					"numFailed": 0
+				}
+			}
+		}
+	},
+	]
+}`,
+		},
+		{
+			Name: "BadInnerObject",
+			Input: `
+{
+	"entries": [
+	{
+		"id": "25905b6f222a153561543baea0a67043",
+		"labels": {
+			"hostname": "host-name",
+			"path": "/root/tmp/test",
+			"type": "snapshot",
+			"username": "user-name"
+		},
+		"modified": "2023-03-16T20:46:59.70714Z",
+		"data": {
+			"id": "",
+			"source": {
+				"host": "host-name",
+				"userName": "user-name",
+				"path": "/root/tmp/test"
+			},
+			"description": "",
+			"startTime": "2023-03-16T20:46:55.76843Z",
+			"endTime": "2023-03-16T20:46:59.707064Z",
+			"stats": {
+				"totalSize": 536927459,
+				"excludedTotalSize": 0,
+				"fileCount": 18,
+				"cachedFiles": 0,
+				"nonCachedFiles": 18,
+				"dirCount": 14,
+				"excludedFileCount": 0,
+				"excludedDirCount": 0,
+				"ignoredErrorCount": 0,
+				"errorCount": 0
+			},
+			"rootEntry": {
+				"name": "test",
+				"type": "d",
+				"mode": "0777",
+				"mtime": "1754-08-30T22:43:41.128654848Z",
+				"obj": "k74647859396c88127696f426b4c79088",
+				"summ": {
+					"size": 536927459,
+					"files": 18,
+					"symlinks": 0,
+					"dirs": 14,
+					"maxTime": "2023-03-16T20:46:56.187394Z",
+					"numFailed": 0
+				}
+		}
+	}
+	]
+}`,
+		},
+		{
+			Name: "MissingInnerObjectEnd",
+			Input: `
+{
+	"entries": [
+	{
+		"id": "25905b6f222a153561543baea0a67043",
+		"labels": {
+			"hostname": "host-name",
+			"path": "/root/tmp/test",
+			"type": "snapshot",
+			"username": "user-name"
+		},
+		"modified": "2023-03-16T20:46:59.70714Z",
+		"data": {
+			"id": "",
+			"source": {
+				"host": "host-name",
+				"userName": "user-name",
+				"path": "/root/tmp/test"
+			},
+			"description": "",
+			"startTime": "2023-03-16T20:46:55.76843Z",
+			"endTime": "2023-03-16T20:46:59.707064Z",
+			"stats": {
+				"totalSize": 536927459,
+				"excludedTotalSize": 0,
+				"fileCount": 18,
+				"cachedFiles": 0,
+				"nonCachedFiles": 18,
+				"dirCount": 14,
+				"excludedFileCount": 0,
+				"excludedDirCount": 0,
+				"ignoredErrorCount": 0,
+				"errorCount": 0
+			},
+			"rootEntry": {
+				"name": "test",
+				"type": "d",
+				"mode": "0777",
+				"mtime": "1754-08-30T22:43:41.128654848Z",
+				"obj": "k74647859396c88127696f426b4c79088",
+				"summ": {
+					"size": 536927459,
+					"files": 18,
+					"symlinks": 0,
+					"dirs": 14,
+					"maxTime": "2023-03-16T20:46:56.187394Z",
+					"numFailed": 0
+				}
+			}
+		}
+	]
+}`,
+		},
+	}
+)
+
+const (
+	GoodManifests = `
+{
+	"entries": [
+	{
+		"id": "2e14cba9427c57223dd768bd1ddf694c",
+		"labels": {
+			"hostname": "host-name",
+			"path": "/root/tmp/test",
+			"tag": "value",
+			"type": "snapshot",
+			"username": "user-name"
+		},
+		"modified": "2023-03-17T01:08:32.962808Z",
+		"data": {
+			"id": "",
+			"source": {
+				"host": "host-name",
+				"userName": "user-name",
+				"path": "/root/tmp/test"
+			},
+			"description": "",
+			"startTime": "2023-03-17T01:08:29.674573Z",
+			"endTime": "2023-03-17T01:08:32.962614Z",
+			"stats": {
+				"totalSize": 427221,
+				"excludedTotalSize": 0,
+				"fileCount": 143,
+				"cachedFiles": 0,
+				"nonCachedFiles": 143,
+				"dirCount": 10,
+				"excludedFileCount": 0,
+				"excludedDirCount": 0,
+				"ignoredErrorCount": 0,
+				"errorCount": 0
+			},
+			"rootEntry": {
+				"name": "test",
+				"type": "d",
+				"mode": "0777",
+				"mtime": "1754-08-30T22:43:41.128654848Z",
+				"obj": "kfe00a91781912fc352edca26571a5f83",
+				"summ": {
+					"size": 427221,
+					"files": 143,
+					"symlinks": 0,
+					"dirs": 10,
+					"maxTime": "2023-03-17T01:08:29.677079Z",
+					"numFailed": 0
+				}
+			},
+			"tags": {
+				"tag": "value"
+			}
+		}
+	},
+	{
+		"id": "2c54893efd80bcda7102f622da5c63ee",
+		"labels": {
+			"hostname": "host-name",
+			"path": "/root/tmp/test",
+			"type": "snapshot",
+			"username": "user-name"
+		},
+		"modified": "2023-03-17T01:11:34.506121Z",
+		"data": {
+			"id": "",
+			"source": {
+				"host": "host-name",
+				"userName": "user-name",
+				"path": "/root/tmp/test"
+			},
+			"description": "",
+			"startTime": "2023-03-17T01:11:22.34148Z",
+			"endTime": "2023-03-17T01:11:34.505952Z",
+			"stats": {
+				"totalSize": 427221,
+				"excludedTotalSize": 0,
+				"fileCount": 2,
+				"cachedFiles": 141,
+				"nonCachedFiles": 2,
+				"dirCount": 10,
+				"excludedFileCount": 0,
+				"excludedDirCount": 0,
+				"ignoredErrorCount": 0,
+				"errorCount": 0
+			},
+			"rootEntry": {
+				"name": "test",
+				"type": "d",
+				"mode": "0777",
+				"mtime": "1754-08-30T22:43:41.128654848Z",
+				"obj": "k4f1a9e8049091615cbe4ad93507680f3",
+				"summ": {
+					"size": 427221,
+					"files": 143,
+					"symlinks": 0,
+					"dirs": 10,
+					"maxTime": "2023-03-17T01:11:22.725375Z",
+					"numFailed": 0
+				}
+			}
+		}
+	}
+	]
+}
+`
+	IgnoredField = `
+{
+	"entries": [
+	{
+		"id": "2e14cba9427c57223dd768bd1ddf694c",
+		"labels": {
+			"hostname": "host-name",
+			"path": "/root/tmp/test",
+			"tag": "value",
+			"type": "snapshot",
+			"username": "user-name"
+		},
+		"modified": "2023-03-17T01:08:32.962808Z",
+		"data": {
+			"id": "",
+			"source": {
+				"host": "host-name",
+				"userName": "user-name",
+				"path": "/root/tmp/test"
+			},
+			"description": "",
+			"startTime": "2023-03-17T01:08:29.674573Z",
+			"endTime": "2023-03-17T01:08:32.962614Z",
+			"stats": {
+				"totalSize": 427221,
+				"excludedTotalSize": 0,
+				"fileCount": 143,
+				"cachedFiles": 0,
+				"nonCachedFiles": 143,
+				"dirCount": 10,
+				"excludedFileCount": 0,
+				"excludedDirCount": 0,
+				"ignoredErrorCount": 0,
+				"errorCount": 0
+			},
+			"rootEntry": {
+				"name": "test",
+				"type": "d",
+				"mode": "0777",
+				"mtime": "1754-08-30T22:43:41.128654848Z",
+				"obj": "kfe00a91781912fc352edca26571a5f83",
+				"summ": {
+					"size": 427221,
+					"files": 143,
+					"symlinks": 0,
+					"dirs": 10,
+					"maxTime": "2023-03-17T01:08:29.677079Z",
+					"numFailed": 0
+				}
+			},
+			"tags": {
+				"tag": "value"
+			}
+		}
+	},
+	{
+		"id": "2c54893efd80bcda7102f622da5c63ee",
+		"labels": {
+			"hostname": "host-name",
+			"path": "/root/tmp/test",
+			"type": "snapshot",
+			"username": "user-name"
+		},
+		"modified": "2023-03-17T01:11:34.506121Z",
+		"data": {
+			"id": "",
+			"source": {
+				"host": "host-name",
+				"userName": "user-name",
+				"path": "/root/tmp/test"
+			},
+			"description": "",
+			"startTime": "2023-03-17T01:11:22.34148Z",
+			"endTime": "2023-03-17T01:11:34.505952Z",
+			"stats": {
+				"totalSize": 427221,
+				"excludedTotalSize": 0,
+				"fileCount": 2,
+				"cachedFiles": 141,
+				"nonCachedFiles": 2,
+				"dirCount": 10,
+				"excludedFileCount": 0,
+				"excludedDirCount": 0,
+				"ignoredErrorCount": 0,
+				"errorCount": 0
+			},
+			"rootEntry": {
+				"name": "test",
+				"type": "d",
+				"mode": "0777",
+				"mtime": "1754-08-30T22:43:41.128654848Z",
+				"obj": "k4f1a9e8049091615cbe4ad93507680f3",
+				"summ": {
+					"size": 427221,
+					"files": 143,
+					"symlinks": 0,
+					"dirs": 10,
+					"maxTime": "2023-03-17T01:11:22.725375Z",
+					"numFailed": 0
+				}
+			}
+		}
+	}
+	],
+	"ignored": "hello world"
+}`
+	ExtraInputAtEnd = `
+{
+	"entries": [
+	{
+		"id": "2e14cba9427c57223dd768bd1ddf694c",
+		"labels": {
+			"hostname": "host-name",
+			"path": "/root/tmp/test",
+			"tag": "value",
+			"type": "snapshot",
+			"username": "user-name"
+		},
+		"modified": "2023-03-17T01:08:32.962808Z",
+		"data": {
+			"id": "",
+			"source": {
+				"host": "host-name",
+				"userName": "user-name",
+				"path": "/root/tmp/test"
+			},
+			"description": "",
+			"startTime": "2023-03-17T01:08:29.674573Z",
+			"endTime": "2023-03-17T01:08:32.962614Z",
+			"stats": {
+				"totalSize": 427221,
+				"excludedTotalSize": 0,
+				"fileCount": 143,
+				"cachedFiles": 0,
+				"nonCachedFiles": 143,
+				"dirCount": 10,
+				"excludedFileCount": 0,
+				"excludedDirCount": 0,
+				"ignoredErrorCount": 0,
+				"errorCount": 0
+			},
+			"rootEntry": {
+				"name": "test",
+				"type": "d",
+				"mode": "0777",
+				"mtime": "1754-08-30T22:43:41.128654848Z",
+				"obj": "kfe00a91781912fc352edca26571a5f83",
+				"summ": {
+					"size": 427221,
+					"files": 143,
+					"symlinks": 0,
+					"dirs": 10,
+					"maxTime": "2023-03-17T01:08:29.677079Z",
+					"numFailed": 0
+				}
+			},
+			"tags": {
+				"tag": "value"
+			}
+		}
+	},
+	{
+		"id": "2c54893efd80bcda7102f622da5c63ee",
+		"labels": {
+			"hostname": "host-name",
+			"path": "/root/tmp/test",
+			"type": "snapshot",
+			"username": "user-name"
+		},
+		"modified": "2023-03-17T01:11:34.506121Z",
+		"data": {
+			"id": "",
+			"source": {
+				"host": "host-name",
+				"userName": "user-name",
+				"path": "/root/tmp/test"
+			},
+			"description": "",
+			"startTime": "2023-03-17T01:11:22.34148Z",
+			"endTime": "2023-03-17T01:11:34.505952Z",
+			"stats": {
+				"totalSize": 427221,
+				"excludedTotalSize": 0,
+				"fileCount": 2,
+				"cachedFiles": 141,
+				"nonCachedFiles": 2,
+				"dirCount": 10,
+				"excludedFileCount": 0,
+				"excludedDirCount": 0,
+				"ignoredErrorCount": 0,
+				"errorCount": 0
+			},
+			"rootEntry": {
+				"name": "test",
+				"type": "d",
+				"mode": "0777",
+				"mtime": "1754-08-30T22:43:41.128654848Z",
+				"obj": "k4f1a9e8049091615cbe4ad93507680f3",
+				"summ": {
+					"size": 427221,
+					"files": 143,
+					"symlinks": 0,
+					"dirs": 10,
+					"maxTime": "2023-03-17T01:11:22.725375Z",
+					"numFailed": 0
+				}
+			}
+		}
+	}
+	]
+}abcdefg`
+	CaseInsensitive = `
+{
+	"Entries": [
+	{
+		"id": "2e14cba9427c57223dd768bd1ddf694c",
+		"labels": {
+			"hostname": "host-name",
+			"path": "/root/tmp/test",
+			"tag": "value",
+			"type": "snapshot",
+			"username": "user-name"
+		},
+		"modified": "2023-03-17T01:08:32.962808Z",
+		"data": {
+			"id": "",
+			"source": {
+				"host": "host-name",
+				"userName": "user-name",
+				"path": "/root/tmp/test"
+			},
+			"description": "",
+			"startTime": "2023-03-17T01:08:29.674573Z",
+			"endTime": "2023-03-17T01:08:32.962614Z",
+			"stats": {
+				"totalSize": 427221,
+				"excludedTotalSize": 0,
+				"fileCount": 143,
+				"cachedFiles": 0,
+				"nonCachedFiles": 143,
+				"dirCount": 10,
+				"excludedFileCount": 0,
+				"excludedDirCount": 0,
+				"ignoredErrorCount": 0,
+				"errorCount": 0
+			},
+			"rootEntry": {
+				"name": "test",
+				"type": "d",
+				"mode": "0777",
+				"mtime": "1754-08-30T22:43:41.128654848Z",
+				"obj": "kfe00a91781912fc352edca26571a5f83",
+				"summ": {
+					"size": 427221,
+					"files": 143,
+					"symlinks": 0,
+					"dirs": 10,
+					"maxTime": "2023-03-17T01:08:29.677079Z",
+					"numFailed": 0
+				}
+			},
+			"tags": {
+				"tag": "value"
+			}
+		}
+	},
+	{
+		"id": "2c54893efd80bcda7102f622da5c63ee",
+		"labels": {
+			"hostname": "host-name",
+			"path": "/root/tmp/test",
+			"type": "snapshot",
+			"username": "user-name"
+		},
+		"modified": "2023-03-17T01:11:34.506121Z",
+		"data": {
+			"id": "",
+			"source": {
+				"host": "host-name",
+				"userName": "user-name",
+				"path": "/root/tmp/test"
+			},
+			"description": "",
+			"startTime": "2023-03-17T01:11:22.34148Z",
+			"endTime": "2023-03-17T01:11:34.505952Z",
+			"stats": {
+				"totalSize": 427221,
+				"excludedTotalSize": 0,
+				"fileCount": 2,
+				"cachedFiles": 141,
+				"nonCachedFiles": 2,
+				"dirCount": 10,
+				"excludedFileCount": 0,
+				"excludedDirCount": 0,
+				"ignoredErrorCount": 0,
+				"errorCount": 0
+			},
+			"rootEntry": {
+				"name": "test",
+				"type": "d",
+				"mode": "0777",
+				"mtime": "1754-08-30T22:43:41.128654848Z",
+				"obj": "k4f1a9e8049091615cbe4ad93507680f3",
+				"summ": {
+					"size": 427221,
+					"files": 143,
+					"symlinks": 0,
+					"dirs": 10,
+					"maxTime": "2023-03-17T01:11:22.725375Z",
+					"numFailed": 0
+				}
+			}
+		}
+	}
+	]
+}`
+)