Skip to content

Commit d17abb8

Browse files
aledbfclaude
andcommitted
fix(oci,ci): bound registry ops, fail-closed on tag-list errors, unskip OCI tests
Addresses three findings: 1. OCI operations could block indefinitely. The oras HTTP client had no ResponseHeaderTimeout, and the non-Context wrappers used a deadline-less context.Background(), so a registry that accepts the connection but stops responding hung features/templates/outdated/publish until a signal. Set ResponseHeaderTimeout on the shared transport (bounds a connected-but-silent registry without capping large blob transfers) and give each non-Context wrapper a default per-operation deadline (60s metadata, 10m blob). Publish now threads the command context via GetPublishedTagsContext. 2. publish ignored failures when listing existing tags. GetPublishedTags's error was discarded, so an auth/network/5xx failure looked like a first publish and mobile aliases (latest, 1, 1.2) were recomputed off an empty list — risking clobbering. Only a 404 (repo not created yet) is now treated as empty; any other error aborts the item. Added oci.IsNotFound to classify. 3. Critical OCI tests ran in no normal CI lane. The unanchored -skip 'TestClient|...' also excluded TestClientAuthCacheReused and TestClientOperationsHonorCanceledContext, and test:integration only runs ./internal/httpx -run TestClient — so both fell out of unit/race/coverage AND integration. Anchored the pattern to '^TestClient$|^TestE2E|^TestParityMatrix$|^TestPublishParity$' so the httpx integration test stays skipped in the unit lane while the OCI tests run. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent a9f2bee commit d17abb8

8 files changed

Lines changed: 217 additions & 11 deletions

File tree

Taskfile.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ tasks:
4242
test:unit:
4343
desc: Run hermetic unit tests
4444
cmds:
45-
- go test ./cmd/... ./internal/... -skip 'TestClient|TestE2E|TestParityMatrix|TestPublishParity' -count=1 {{.CLI_ARGS}}
45+
- go test ./cmd/... ./internal/... -skip '^TestClient$|^TestE2E|^TestParityMatrix$|^TestPublishParity$' -count=1 {{.CLI_ARGS}}
4646

4747
test:integration:
4848
desc: Run local HTTP integration tests
@@ -54,7 +54,7 @@ tasks:
5454
env:
5555
CGO_ENABLED: "1"
5656
cmds:
57-
- go test ./cmd/... ./internal/... -skip 'TestClient|TestE2E|TestParityMatrix|TestPublishParity' -race -count=1 {{.CLI_ARGS}}
57+
- go test ./cmd/... ./internal/... -skip '^TestClient$|^TestE2E|^TestParityMatrix$|^TestPublishParity$' -race -count=1 {{.CLI_ARGS}}
5858

5959
vuln:
6060
desc: Scan module and code paths for known vulnerabilities (govulncheck)
@@ -64,7 +64,7 @@ tasks:
6464
coverage:
6565
desc: Generate the hermetic unit coverage report and enforce the COVERAGE_MIN floor
6666
cmds:
67-
- go test ./cmd/... ./internal/... -skip 'TestClient|TestE2E|TestParityMatrix|TestPublishParity' -coverprofile=coverage.out -count=1
67+
- go test ./cmd/... ./internal/... -skip '^TestClient$|^TestE2E|^TestParityMatrix$|^TestPublishParity$' -coverprofile=coverage.out -count=1
6868
- go tool cover -func=coverage.out
6969
# Ratchet gate: fail if total statement coverage drops below COVERAGE_MIN.
7070
- cmd: |

internal/cli/collection_commands.go

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -475,8 +475,15 @@ func publishCollectionWith(ctx context.Context, out Output, reg oci.Registry, ta
475475
failures++
476476
return nil, false
477477
}
478-
// Empty on first publish (repository does not exist yet).
479-
published, _ := ociClient.GetPublishedTags(ref)
478+
// The repository is empty on first publish (404). Any OTHER error — auth,
479+
// network, a 5xx — must abort: treating it as "no tags yet" would republish
480+
// mobile aliases (latest, 1, 1.2) off an empty list and could clobber them.
481+
published, tagsErr := ociClient.GetPublishedTagsContext(ctx, ref)
482+
if tagsErr != nil && !oci.IsNotFound(tagsErr) {
483+
logger.Write(fmt.Sprintf("(!) ERR: could not list existing tags for %q: %v", resource, tagsErr), log.LevelError)
484+
failures++
485+
return nil, false
486+
}
480487
tags, skip, tagErr := oci.SemanticTags(version, published)
481488
if tagErr != nil {
482489
logger.Write(fmt.Sprintf("(!) ERR: %v, skipping...", tagErr), log.LevelError)

internal/cli/collection_publish_partial_test.go

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,63 @@ import (
55
"path/filepath"
66
"strings"
77
"testing"
8+
9+
"oras.land/oras-go/v2/registry/remote/errcode"
810
)
911

12+
// TestPublishCollection_AbortsOnTagListError covers the finding that publish must
13+
// NOT treat a failed tag lookup as an empty registry: a non-404 error (auth, 5xx,
14+
// network) has to abort the item — otherwise mobile aliases would be recomputed
15+
// off an empty list and could clobber existing ones.
16+
func TestPublishCollection_AbortsOnTagListError(t *testing.T) {
17+
target := t.TempDir()
18+
src := filepath.Join(target, "src")
19+
writeSeamFeature(t, src, "good", "1.0.0")
20+
21+
reg := &fakeRegistry{
22+
published: map[string][]string{},
23+
failIDs: map[string]bool{},
24+
tagListErr: map[string]error{
25+
"ghcr.io/me/features/good": &errcode.ErrorResponse{StatusCode: 500},
26+
},
27+
}
28+
out := &captureOutput{}
29+
30+
err := publishCollectionWith(t.Context(), out, reg, target, "ghcr.io", "me/features", "feature", "info")
31+
if err == nil || !strings.Contains(err.Error(), "publish operation(s) failed") {
32+
t.Fatalf("err = %v, want a publish failure from the tag-list error", err)
33+
}
34+
// The artifact must NOT have been pushed — we aborted before computing tags.
35+
if len(reg.pushed) != 0 {
36+
t.Fatalf("artifact pushed despite a tag-list error: %v", reg.pushed)
37+
}
38+
}
39+
40+
// TestPublishCollection_TreatsNotFoundAsFirstPublish covers the complementary
41+
// case: a 404 from the tag listing means the repository does not exist yet, so
42+
// the item publishes normally as a first publish.
43+
func TestPublishCollection_TreatsNotFoundAsFirstPublish(t *testing.T) {
44+
target := t.TempDir()
45+
src := filepath.Join(target, "src")
46+
writeSeamFeature(t, src, "good", "1.0.0")
47+
48+
reg := &fakeRegistry{
49+
published: map[string][]string{},
50+
failIDs: map[string]bool{},
51+
tagListErr: map[string]error{
52+
"ghcr.io/me/features/good": &errcode.ErrorResponse{StatusCode: 404},
53+
},
54+
}
55+
out := &captureOutput{}
56+
57+
if err := publishCollectionWith(t.Context(), out, reg, target, "ghcr.io", "me/features", "feature", "info"); err != nil {
58+
t.Fatalf("404 tag listing should be a clean first publish, got: %v", err)
59+
}
60+
if len(reg.pushed) != 1 || !strings.HasSuffix(reg.pushed[0], "/good") {
61+
t.Fatalf("pushed = %v, want [.../good]", reg.pushed)
62+
}
63+
}
64+
1065
// TestPublishCollection_CollectionMetadataFailureCounts complements the
1166
// item-level partial-failure test (seams_test.go): here every ITEM publishes
1267
// fine but the SEPARATE collection-metadata push fails. That failure must be

internal/cli/seams_test.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ func (c *captureOutput) Stderr() io.Writer { return &c.err }
2929
// publishCollectionWith without contacting a real registry.
3030
type fakeRegistry struct {
3131
published map[string][]string // resource -> already-published tags
32+
tagListErr map[string]error // resource -> error to return from GetPublishedTags*
3233
failIDs map[string]bool
3334
pushed []string // resources successfully pushed (for assertions)
3435
pushedTags map[string][]string // resource -> tags requested on push
@@ -45,6 +46,15 @@ func (f *fakeRegistry) FetchBlob(ref *oci.Ref, digest string) ([]byte, error) {
4546
}
4647

4748
func (f *fakeRegistry) GetPublishedTags(ref *oci.Ref) ([]string, error) {
49+
return f.GetPublishedTagsContext(context.Background(), ref)
50+
}
51+
52+
func (f *fakeRegistry) GetPublishedTagsContext(_ context.Context, ref *oci.Ref) ([]string, error) {
53+
if f.tagListErr != nil {
54+
if err, ok := f.tagListErr[ref.Resource]; ok {
55+
return nil, err
56+
}
57+
}
4858
return f.published[ref.Resource], nil
4959
}
5060

internal/oci/client.go

Lines changed: 53 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,18 +4,39 @@ package oci
44
import (
55
"context"
66
"encoding/json"
7+
"errors"
78
"fmt"
89
"net/http"
910
"strings"
11+
"time"
1012

1113
orascontent "oras.land/oras-go/v2/content"
14+
"oras.land/oras-go/v2/errdef"
1215
"oras.land/oras-go/v2/registry/remote/auth"
16+
"oras.land/oras-go/v2/registry/remote/errcode"
1317
"oras.land/oras-go/v2/registry/remote/retry"
1418

1519
"github.com/devcontainers/cli/internal/httpx"
1620
"github.com/devcontainers/cli/internal/log"
1721
)
1822

23+
// Operation timeouts. These are vars (not consts) so tests can shrink them.
24+
//
25+
// - responseHeaderTimeout bounds a registry that accepts the TCP/TLS connection
26+
// but then stops responding: the base transport (a clone of DefaultTransport)
27+
// sets a dial and TLS-handshake timeout but NO ResponseHeaderTimeout, so
28+
// without this a hung registry blocks the command forever.
29+
// - metadataOpTimeout is the overall ceiling for small metadata operations
30+
// (manifest/tag list) when the caller supplies no context of its own.
31+
// - blobOpTimeout is a generous ceiling for blob downloads (which can be large);
32+
// it also backstops a mid-stream stall, which responseHeaderTimeout — only
33+
// covering time-to-first-header — does not catch.
34+
var (
35+
responseHeaderTimeout = 30 * time.Second
36+
metadataOpTimeout = 60 * time.Second
37+
blobOpTimeout = 10 * time.Minute
38+
)
39+
1940
// Client performs OCI registry operations (pull manifest, download blob, list
2041
// tags, push artifacts) on top of oras-go.
2142
type Client struct {
@@ -37,20 +58,44 @@ type Client struct {
3758
// NewClient creates an OCI client. Auth and retries are handled by oras-go (see
3859
// repository()); the HTTP transport is the shared proxy/CA-aware transport.
3960
func NewClient(logger log.Logger, env map[string]string) *Client {
61+
base := httpx.NewTransport()
62+
// Cut off a registry that connects but never sends response headers.
63+
base.ResponseHeaderTimeout = responseHeaderTimeout
4064
return &Client{
4165
log: logger,
4266
env: env,
4367
authCache: auth.NewCache(),
44-
retryClient: &http.Client{Transport: retry.NewTransport(httpx.NewTransport())},
68+
retryClient: &http.Client{Transport: retry.NewTransport(base)},
69+
}
70+
}
71+
72+
// IsNotFound reports whether err indicates the target repository or reference
73+
// does not exist (a 404 from the registry), as opposed to an auth, network, or
74+
// other registry failure. Publishing uses this so a not-yet-created repository
75+
// is treated as "no tags published", while a real error aborts the publish
76+
// instead of silently proceeding on an empty tag list.
77+
func IsNotFound(err error) bool {
78+
if err == nil {
79+
return false
80+
}
81+
if errors.Is(err, errdef.ErrNotFound) {
82+
return true
83+
}
84+
var respErr *errcode.ErrorResponse
85+
if errors.As(err, &respErr) {
86+
return respErr.StatusCode == http.StatusNotFound
4587
}
88+
return false
4689
}
4790

4891
// FetchManifest fetches and validates an OCI manifest using oras-go for
4992
// transport/auth (bearer-token scope negotiation, retries), keeping the
5093
// devcontainer-specific validation (config media type must be
5194
// application/vnd.devcontainers) and result shape.
5295
func (c *Client) FetchManifest(ref *Ref, expectedDigest string) (*ManifestContainer, error) {
53-
return c.FetchManifestContext(context.Background(), ref, expectedDigest)
96+
ctx, cancel := context.WithTimeout(context.Background(), metadataOpTimeout)
97+
defer cancel()
98+
return c.FetchManifestContext(ctx, ref, expectedDigest)
5499
}
55100

56101
// FetchManifestContext is FetchManifest with caller-controlled cancellation.
@@ -108,7 +153,9 @@ func (c *Client) FetchManifestContext(ctx context.Context, ref *Ref, expectedDig
108153

109154
// FetchBlob downloads a blob and verifies its digest (via oras-go).
110155
func (c *Client) FetchBlob(ref *Ref, digest string) ([]byte, error) {
111-
return c.FetchBlobContext(context.Background(), ref, digest)
156+
ctx, cancel := context.WithTimeout(context.Background(), blobOpTimeout)
157+
defer cancel()
158+
return c.FetchBlobContext(ctx, ref, digest)
112159
}
113160

114161
// FetchBlobContext is FetchBlob with caller-controlled cancellation.
@@ -128,7 +175,9 @@ func (c *Client) FetchBlobContext(ctx context.Context, ref *Ref, digest string)
128175

129176
// GetPublishedTags lists all tags for a resource (via oras-go).
130177
func (c *Client) GetPublishedTags(ref *Ref) ([]string, error) {
131-
return c.GetPublishedTagsContext(context.Background(), ref)
178+
ctx, cancel := context.WithTimeout(context.Background(), metadataOpTimeout)
179+
defer cancel()
180+
return c.GetPublishedTagsContext(ctx, ref)
132181
}
133182

134183
// GetPublishedTagsContext is GetPublishedTags with caller-controlled cancellation.
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
package oci
2+
3+
import (
4+
"errors"
5+
"fmt"
6+
"net/http"
7+
"net/http/httptest"
8+
"strings"
9+
"testing"
10+
"time"
11+
12+
"oras.land/oras-go/v2/errdef"
13+
"oras.land/oras-go/v2/registry/remote/errcode"
14+
15+
"github.com/devcontainers/cli/internal/log"
16+
)
17+
18+
func TestIsNotFound(t *testing.T) {
19+
cases := []struct {
20+
name string
21+
err error
22+
want bool
23+
}{
24+
{"nil", nil, false},
25+
{"errdef.ErrNotFound", errdef.ErrNotFound, true},
26+
{"wrapped ErrNotFound", fmt.Errorf("listing: %w", errdef.ErrNotFound), true},
27+
{"404 ErrorResponse", &errcode.ErrorResponse{StatusCode: http.StatusNotFound}, true},
28+
{"wrapped 404", fmt.Errorf("tags: %w", &errcode.ErrorResponse{StatusCode: 404}), true},
29+
{"401 ErrorResponse", &errcode.ErrorResponse{StatusCode: http.StatusUnauthorized}, false},
30+
{"500 ErrorResponse", &errcode.ErrorResponse{StatusCode: http.StatusInternalServerError}, false},
31+
{"plain error", errors.New("connection refused"), false},
32+
}
33+
for _, c := range cases {
34+
if got := IsNotFound(c.err); got != c.want {
35+
t.Errorf("%s: IsNotFound = %v, want %v", c.name, got, c.want)
36+
}
37+
}
38+
}
39+
40+
// TestGetPublishedTags_HangingRegistryTimesOut verifies the fix for the
41+
// indefinite-block finding: a registry that accepts the connection but never
42+
// sends response headers must not hang the operation forever — the default
43+
// per-operation deadline (shrunk here) cuts it off.
44+
func TestGetPublishedTags_HangingRegistryTimesOut(t *testing.T) {
45+
// A server that accepts the request and then never responds.
46+
blocked := make(chan struct{})
47+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
48+
<-blocked // hang until the test ends
49+
}))
50+
defer srv.Close()
51+
defer close(blocked)
52+
53+
// Shrink the metadata deadline so the test is fast.
54+
orig := metadataOpTimeout
55+
metadataOpTimeout = 200 * time.Millisecond
56+
defer func() { metadataOpTimeout = orig }()
57+
58+
c := NewClient(log.New(log.Options{Level: log.LevelError}), nil)
59+
ref := &Ref{Registry: strings.TrimPrefix(srv.URL, "http://"), Path: "me/feat", Resource: "me/feat"}
60+
61+
done := make(chan error, 1)
62+
start := time.Now()
63+
go func() {
64+
_, err := c.GetPublishedTags(ref)
65+
done <- err
66+
}()
67+
68+
select {
69+
case err := <-done:
70+
if err == nil {
71+
t.Fatal("expected a timeout error, got nil")
72+
}
73+
if elapsed := time.Since(start); elapsed > 5*time.Second {
74+
t.Errorf("operation took %s, expected it to time out promptly", elapsed)
75+
}
76+
case <-time.After(5 * time.Second):
77+
t.Fatal("GetPublishedTags did not honor the deadline — it blocked indefinitely")
78+
}
79+
}

internal/oci/registry.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,14 @@ import "context"
88
// tests without standing up a real registry. *Client satisfies it.
99
//
1010
// The fetch/list methods keep their non-Context convenience variants (they are
11-
// short reads); the push methods take a ctx so a long upload participates in the
12-
// CLI's signal-cancellation.
11+
// short reads, and each now applies a default per-operation deadline); a Context
12+
// variant is exposed where a caller already holds the command context (e.g.
13+
// publish) so the operation participates in the CLI's signal-cancellation.
1314
type Registry interface {
1415
FetchManifest(ref *Ref, expectedDigest string) (*ManifestContainer, error)
1516
FetchBlob(ref *Ref, digest string) ([]byte, error)
1617
GetPublishedTags(ref *Ref) ([]string, error)
18+
GetPublishedTagsContext(ctx context.Context, ref *Ref) ([]string, error)
1719
PushArtifact(ctx context.Context, ref *Ref, tgzPath string, tags []string, collectionType string, annotations map[string]string) (*PushResult, error)
1820
PushCollectionMetadata(ctx context.Context, ref *Ref, collectionJSONPath string) (*PushResult, error)
1921
}

internal/templates/apply_fetch_test.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,10 @@ func (f *fakeTemplateRegistry) GetPublishedTags(ref *oci.Ref) ([]string, error)
8787
return nil, fmt.Errorf("GetPublishedTags not used")
8888
}
8989

90+
func (f *fakeTemplateRegistry) GetPublishedTagsContext(_ context.Context, ref *oci.Ref) ([]string, error) {
91+
return nil, fmt.Errorf("GetPublishedTagsContext not used")
92+
}
93+
9094
func (f *fakeTemplateRegistry) PushArtifact(ctx context.Context, ref *oci.Ref, tgzPath string, tags []string, collectionType string, annotations map[string]string) (*oci.PushResult, error) {
9195
return nil, fmt.Errorf("PushArtifact not used")
9296
}

0 commit comments

Comments
 (0)