From 7698e6ba857bcfedd0906bcf272dc4d6064cfa5b Mon Sep 17 00:00:00 2001 From: CMGS Date: Sat, 4 Jul 2026 03:10:18 +0800 Subject: [PATCH 1/3] fix(image): guard parallel Range fetch; test the chunk split fetchRange checked only status 206, then blindly copied the body at the chunk offset. A misbehaving edge (short 206, or a 206 whose actual span differs from the request) would land bytes at the wrong offset or leave a zero hole, surfaced only after verifyDigest re-hashes the whole multi-GB file three times. Add the Content-Range prefix check and the n==want short-body guard (cocoon parity) so a bad range fails fast and precisely. Extract splitRanges and unit-test the contiguous / non-overlapping / covers-[0,size) invariant. --- cmd/image/oci.go | 39 ++++++++++++++++++++++++++++----------- cmd/image/oci_test.go | 28 ++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 11 deletions(-) diff --git a/cmd/image/oci.go b/cmd/image/oci.go index 488c6a3..ff9b702 100644 --- a/cmd/image/oci.go +++ b/cmd/image/oci.go @@ -100,20 +100,26 @@ func rangeSupported(ctx context.Context, client *auth.Client, url string) bool { // fetchParallel downloads [0,size) in pullConns chunks concurrently, each writing at its offset. func fetchParallel(ctx context.Context, client *auth.Client, url string, size int64, f *os.File) error { - chunk := (size + pullConns - 1) / pullConns g, gctx := errgroup.WithContext(ctx) - for i := range int64(pullConns) { - start := i * chunk - if start >= size { - break - } + for _, r := range splitRanges(size, pullConns) { + start, end := r[0], r[1] + g.Go(func() error { return fetchRange(gctx, client, url, start, end, f) }) + } + return g.Wait() +} + +// splitRanges divides [0,size) into up to n contiguous, inclusive-ended byte ranges. +func splitRanges(size int64, n int) [][2]int64 { + chunk := (size + int64(n) - 1) / int64(n) + var ranges [][2]int64 + for start := int64(0); start < size; start += chunk { end := start + chunk - 1 if end >= size { end = size - 1 } - g.Go(func() error { return fetchRange(gctx, client, url, start, end, f) }) + ranges = append(ranges, [2]int64{start, end}) } - return g.Wait() + return ranges } func fetchRange(ctx context.Context, client *auth.Client, url string, start, end int64, f *os.File) error { @@ -130,9 +136,20 @@ func fetchRange(ctx context.Context, client *auth.Client, url string, start, end if resp.StatusCode != http.StatusPartialContent { return fmt.Errorf("range %d-%d: unexpected status %s", start, end, resp.Status) } - // each chunk owns a disjoint region, so concurrent WriteAt via the offset writer never overlaps - _, err = io.Copy(io.NewOffsetWriter(f, start), resp.Body) - return err + // A mismatched span lands bytes at the wrong offset; a short body leaves a zero + // hole — both would only surface after verifyDigest re-hashes the whole file. + if cr := resp.Header.Get("Content-Range"); !strings.HasPrefix(cr, fmt.Sprintf("bytes %d-%d/", start, end)) { + return fmt.Errorf("range %d-%d: mismatched content-range %q", start, end, cr) + } + want := end - start + 1 + n, err := io.Copy(io.NewOffsetWriter(f, start), io.LimitReader(resp.Body, want)) + if err != nil { + return err + } + if n != want { + return fmt.Errorf("range %d-%d: short body %d of %d bytes", start, end, n, want) + } + return nil } // fetchSingle streams the blob in one connection (fallback when Range isn't supported). diff --git a/cmd/image/oci_test.go b/cmd/image/oci_test.go index 20ca59c..3b9eac6 100644 --- a/cmd/image/oci_test.go +++ b/cmd/image/oci_test.go @@ -69,3 +69,31 @@ func TestPickQcow2Layer(t *testing.T) { }) } } + +func TestSplitRanges(t *testing.T) { + sizes := []int64{1, 7, 8, 9, 100, 257 << 10, (257 << 10) + 7} + for _, size := range sizes { + for _, n := range []int{1, 3, 8, 100} { + ranges := splitRanges(size, n) + if len(ranges) == 0 { + t.Fatalf("size=%d n=%d: no ranges", size, n) + } + if ranges[0][0] != 0 { + t.Errorf("size=%d n=%d: first start %d, want 0", size, n, ranges[0][0]) + } + if last := ranges[len(ranges)-1][1]; last != size-1 { + t.Errorf("size=%d n=%d: last end %d, want %d", size, n, last, size-1) + } + // contiguous + non-overlapping: each start is the previous end + 1 + for i := 1; i < len(ranges); i++ { + if ranges[i][0] != ranges[i-1][1]+1 { + t.Errorf("size=%d n=%d: gap/overlap at %d: %v after %v", size, n, i, ranges[i], ranges[i-1]) + } + } + // every range is non-empty and never exceeds n chunks + if len(ranges) > n { + t.Errorf("size=%d n=%d: %d chunks exceeds n", size, n, len(ranges)) + } + } + } +} From a9a8e86471901322ff6a41706abce93317c5287f Mon Sep 17 00:00:00 2001 From: CMGS Date: Sat, 4 Jul 2026 10:33:00 +0800 Subject: [PATCH 2/3] refactor(image): use cocoon utils.SplitRanges/CopyRangeBody MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps cocoon to master (pseudo-version) which exports the shared range helpers, and drops the local splitRanges + inline response guards; the split-contract test moved to cocoon utils with the implementation. cloudimg.New grew a pullConns parameter — pass 0 for its default. --- cmd/image/oci.go | 36 ++++-------------------------------- cmd/image/oci_test.go | 28 ---------------------------- go.mod | 2 +- go.sum | 4 ++-- home/home.go | 2 +- 5 files changed, 8 insertions(+), 64 deletions(-) diff --git a/cmd/image/oci.go b/cmd/image/oci.go index ff9b702..2cd6141 100644 --- a/cmd/image/oci.go +++ b/cmd/image/oci.go @@ -17,6 +17,8 @@ import ( "oras.land/oras-go/v2/registry/remote" "oras.land/oras-go/v2/registry/remote/auth" "oras.land/oras-go/v2/registry/remote/credentials" + + "github.com/cocoonstack/cocoon/utils" ) // pullConns is the number of parallel HTTP Range connections used to fetch a blob; ghcr throttles a @@ -101,27 +103,13 @@ func rangeSupported(ctx context.Context, client *auth.Client, url string) bool { // fetchParallel downloads [0,size) in pullConns chunks concurrently, each writing at its offset. func fetchParallel(ctx context.Context, client *auth.Client, url string, size int64, f *os.File) error { g, gctx := errgroup.WithContext(ctx) - for _, r := range splitRanges(size, pullConns) { + for _, r := range utils.SplitRanges(size, pullConns) { start, end := r[0], r[1] g.Go(func() error { return fetchRange(gctx, client, url, start, end, f) }) } return g.Wait() } -// splitRanges divides [0,size) into up to n contiguous, inclusive-ended byte ranges. -func splitRanges(size int64, n int) [][2]int64 { - chunk := (size + int64(n) - 1) / int64(n) - var ranges [][2]int64 - for start := int64(0); start < size; start += chunk { - end := start + chunk - 1 - if end >= size { - end = size - 1 - } - ranges = append(ranges, [2]int64{start, end}) - } - return ranges -} - func fetchRange(ctx context.Context, client *auth.Client, url string, start, end int64, f *os.File) error { req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { @@ -133,23 +121,7 @@ func fetchRange(ctx context.Context, client *auth.Client, url string, start, end return err } defer func() { _ = resp.Body.Close() }() - if resp.StatusCode != http.StatusPartialContent { - return fmt.Errorf("range %d-%d: unexpected status %s", start, end, resp.Status) - } - // A mismatched span lands bytes at the wrong offset; a short body leaves a zero - // hole — both would only surface after verifyDigest re-hashes the whole file. - if cr := resp.Header.Get("Content-Range"); !strings.HasPrefix(cr, fmt.Sprintf("bytes %d-%d/", start, end)) { - return fmt.Errorf("range %d-%d: mismatched content-range %q", start, end, cr) - } - want := end - start + 1 - n, err := io.Copy(io.NewOffsetWriter(f, start), io.LimitReader(resp.Body, want)) - if err != nil { - return err - } - if n != want { - return fmt.Errorf("range %d-%d: short body %d of %d bytes", start, end, n, want) - } - return nil + return utils.CopyRangeBody(resp, io.NewOffsetWriter(f, start), start, end) } // fetchSingle streams the blob in one connection (fallback when Range isn't supported). diff --git a/cmd/image/oci_test.go b/cmd/image/oci_test.go index 3b9eac6..20ca59c 100644 --- a/cmd/image/oci_test.go +++ b/cmd/image/oci_test.go @@ -69,31 +69,3 @@ func TestPickQcow2Layer(t *testing.T) { }) } } - -func TestSplitRanges(t *testing.T) { - sizes := []int64{1, 7, 8, 9, 100, 257 << 10, (257 << 10) + 7} - for _, size := range sizes { - for _, n := range []int{1, 3, 8, 100} { - ranges := splitRanges(size, n) - if len(ranges) == 0 { - t.Fatalf("size=%d n=%d: no ranges", size, n) - } - if ranges[0][0] != 0 { - t.Errorf("size=%d n=%d: first start %d, want 0", size, n, ranges[0][0]) - } - if last := ranges[len(ranges)-1][1]; last != size-1 { - t.Errorf("size=%d n=%d: last end %d, want %d", size, n, last, size-1) - } - // contiguous + non-overlapping: each start is the previous end + 1 - for i := 1; i < len(ranges); i++ { - if ranges[i][0] != ranges[i-1][1]+1 { - t.Errorf("size=%d n=%d: gap/overlap at %d: %v after %v", size, n, i, ranges[i], ranges[i-1]) - } - } - // every range is non-empty and never exceeds n chunks - if len(ranges) > n { - t.Errorf("size=%d n=%d: %d chunks exceeds n", size, n, len(ranges)) - } - } - } -} diff --git a/go.mod b/go.mod index c4b4b63..5c4eda6 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/cocoonstack/cocoon-macos go 1.26.4 require ( - github.com/cocoonstack/cocoon v0.4.5 + github.com/cocoonstack/cocoon v0.4.6-0.20260704023024-7e4406fbe5f5 github.com/docker/go-units v0.5.0 github.com/opencontainers/image-spec v1.1.1 github.com/projecteru2/core v0.0.0-20241016125006-ff909eefe04c diff --git a/go.sum b/go.sum index 679c7af..8dadd44 100644 --- a/go.sum +++ b/go.sum @@ -8,8 +8,8 @@ github.com/cockroachdb/logtags v0.0.0-20230118201751-21c54148d20b h1:r6VH0faHjZe github.com/cockroachdb/logtags v0.0.0-20230118201751-21c54148d20b/go.mod h1:Vz9DsVWQQhf3vs21MhPMZpMGSht7O/2vFW2xusFUVOs= github.com/cockroachdb/redact v1.1.5 h1:u1PMllDkdFfPWaNGMyLD1+so+aq3uUItthCFqzwPJ30= github.com/cockroachdb/redact v1.1.5/go.mod h1:BVNblN9mBWFyMyqK1k3AAiSxhvhfK2oOZZ2lK+dpvRg= -github.com/cocoonstack/cocoon v0.4.5 h1:FoLgZhXdfKFvTJcF1ijb6A3C4n22NDOtwT2FDsoJbgM= -github.com/cocoonstack/cocoon v0.4.5/go.mod h1:LOFTBWiRXYsZJtNIF6UctDO0gThKCaG5+NOQ+ahGgv8= +github.com/cocoonstack/cocoon v0.4.6-0.20260704023024-7e4406fbe5f5 h1:ZcxPePItTkp1mnpehnLQdmTX4zq+wRYrC+rqLNEHq/U= +github.com/cocoonstack/cocoon v0.4.6-0.20260704023024-7e4406fbe5f5/go.mod h1:Rl/SAzj1RbyL8XJaIyWiHdT96yK2D1e0xCr8flVqIcA= github.com/containernetworking/cni v1.3.0 h1:v6EpN8RznAZj9765HhXQrtXgX+ECGebEYEmnuFjskwo= github.com/containernetworking/cni v1.3.0/go.mod h1:Bs8glZjjFfGPHMw6hQu82RUgEPNGEaBb9KS5KtNMnJ4= github.com/containernetworking/plugins v1.9.0 h1:Mg3SXBdRGkdXyFC4lcwr6u2ZB2SDeL6LC3U+QrEANuQ= diff --git a/home/home.go b/home/home.go index 161acb4..3641ea0 100644 --- a/home/home.go +++ b/home/home.go @@ -43,7 +43,7 @@ func VMDir(cmd *cobra.Command, name string) string { // command context alongside it. func OpenStore(cmd *cobra.Command) (context.Context, *cloudimg.CloudImg, error) { ctx := cliutil.CommandContext(cmd) - s, err := cloudimg.New(ctx, Dir(cmd)) + s, err := cloudimg.New(ctx, Dir(cmd), 0) // 0 = cloudimg's default pull connections if err != nil { return ctx, nil, fmt.Errorf("init cloudimg store: %w", err) } From 202a73b88c98d0adbb86ee842767583351b85ba2 Mon Sep 17 00:00:00 2001 From: CMGS Date: Sat, 4 Jul 2026 10:58:24 +0800 Subject: [PATCH 3/3] chore: bump cocoon to master (d9fa4df) for the range helpers --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 5c4eda6..87601fe 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/cocoonstack/cocoon-macos go 1.26.4 require ( - github.com/cocoonstack/cocoon v0.4.6-0.20260704023024-7e4406fbe5f5 + github.com/cocoonstack/cocoon v0.4.6-0.20260704025747-d9fa4dfb5105 github.com/docker/go-units v0.5.0 github.com/opencontainers/image-spec v1.1.1 github.com/projecteru2/core v0.0.0-20241016125006-ff909eefe04c diff --git a/go.sum b/go.sum index 8dadd44..9557d2d 100644 --- a/go.sum +++ b/go.sum @@ -8,8 +8,8 @@ github.com/cockroachdb/logtags v0.0.0-20230118201751-21c54148d20b h1:r6VH0faHjZe github.com/cockroachdb/logtags v0.0.0-20230118201751-21c54148d20b/go.mod h1:Vz9DsVWQQhf3vs21MhPMZpMGSht7O/2vFW2xusFUVOs= github.com/cockroachdb/redact v1.1.5 h1:u1PMllDkdFfPWaNGMyLD1+so+aq3uUItthCFqzwPJ30= github.com/cockroachdb/redact v1.1.5/go.mod h1:BVNblN9mBWFyMyqK1k3AAiSxhvhfK2oOZZ2lK+dpvRg= -github.com/cocoonstack/cocoon v0.4.6-0.20260704023024-7e4406fbe5f5 h1:ZcxPePItTkp1mnpehnLQdmTX4zq+wRYrC+rqLNEHq/U= -github.com/cocoonstack/cocoon v0.4.6-0.20260704023024-7e4406fbe5f5/go.mod h1:Rl/SAzj1RbyL8XJaIyWiHdT96yK2D1e0xCr8flVqIcA= +github.com/cocoonstack/cocoon v0.4.6-0.20260704025747-d9fa4dfb5105 h1:kY2QEEK+RiJflgTGRfvRZJvN4X87d36dkrneyOZiAMI= +github.com/cocoonstack/cocoon v0.4.6-0.20260704025747-d9fa4dfb5105/go.mod h1:Rl/SAzj1RbyL8XJaIyWiHdT96yK2D1e0xCr8flVqIcA= github.com/containernetworking/cni v1.3.0 h1:v6EpN8RznAZj9765HhXQrtXgX+ECGebEYEmnuFjskwo= github.com/containernetworking/cni v1.3.0/go.mod h1:Bs8glZjjFfGPHMw6hQu82RUgEPNGEaBb9KS5KtNMnJ4= github.com/containernetworking/plugins v1.9.0 h1:Mg3SXBdRGkdXyFC4lcwr6u2ZB2SDeL6LC3U+QrEANuQ=