v2.31.0-dev18 7b9923f

push_test.go RawHistoryFolder

1package registrypush_test
2
3import (
4	"bytes"
5	"context"
6	"crypto/sha256"
7	"fmt"
8	"io"
9	"net/http"
10	"net/http/httptest"
11	"net/url"
12	"strconv"
13	"strings"
14	"testing"
15	"time"
16
17	"github.com/google/go-containerregistry/pkg/v1"
18	"github.com/google/go-containerregistry/pkg/v1/random"
19	"github.com/stretchr/testify/assert"
20	"github.com/stretchr/testify/require"
21
22	"github.com/bnema/gordon/pkg/registrypush"
23)
24
25func TestNew_defaults(t *testing.T) {
26	p := registrypush.New()
27	assert.NotNil(t, p)
28}
29
30func TestNew_withChunkSize(t *testing.T) {
31	p := registrypush.New(registrypush.WithChunkSize(10 * 1024 * 1024))
32	assert.NotNil(t, p)
33}
34
35func TestPusher_UploadBlob(t *testing.T) {
36	testCases := []struct {
37		name               string
38		blobSize           int
39		chunkSize          int64
40		headStatus         int
41		expectedPatchSizes []int
42		expectedHeadCalls  int
43		expectedPostCalls  int
44		expectedPutCalls   int
45	}{
46		{
47			name:               "small blob under chunk size",
48			blobSize:           100,
49			chunkSize:          1024,
50			headStatus:         http.StatusNotFound,
51			expectedPatchSizes: []int{100},
52			expectedHeadCalls:  1,
53			expectedPostCalls:  1,
54			expectedPutCalls:   1,
55		},
56		{
57			name:               "exact chunk size",
58			blobSize:           1024,
59			chunkSize:          1024,
60			headStatus:         http.StatusNotFound,
61			expectedPatchSizes: []int{1024},
62			expectedHeadCalls:  1,
63			expectedPostCalls:  1,
64			expectedPutCalls:   1,
65		},
66		{
67			name:               "large blob across multiple chunks",
68			blobSize:           3072,
69			chunkSize:          1024,
70			headStatus:         http.StatusNotFound,
71			expectedPatchSizes: []int{1024, 1024, 1024},
72			expectedHeadCalls:  1,
73			expectedPostCalls:  1,
74			expectedPutCalls:   1,
75		},
76		{
77			name:               "blob already exists",
78			blobSize:           512,
79			chunkSize:          1024,
80			headStatus:         http.StatusOK,
81			expectedPatchSizes: nil,
82			expectedHeadCalls:  1,
83			expectedPostCalls:  0,
84			expectedPutCalls:   0,
85		},
86		{
87			name:               "auth header present on every request",
88			blobSize:           1536,
89			chunkSize:          1024,
90			headStatus:         http.StatusNotFound,
91			expectedPatchSizes: []int{1024, 512},
92			expectedHeadCalls:  1,
93			expectedPostCalls:  1,
94			expectedPutCalls:   1,
95		},
96	}
97
98	for _, tc := range testCases {
99		t.Run(tc.name, func(t *testing.T) {
100			const authHeader = "Bearer test-token"
101			const repo = "demo/app"
102			const digest = "sha256:testdigest"
103
104			blob := bytes.Repeat([]byte("a"), tc.blobSize)
105			serverState := newFakeBlobUploadRegistry(t, authHeader, digest, tc.headStatus)
106			server := httptest.NewServer(serverState.handler())
107			defer server.Close()
108
109			p := registrypush.New(
110				registrypush.WithChunkSize(tc.chunkSize),
111				registrypush.WithTransport(server.Client().Transport),
112			)
113
114			err := p.UploadBlob(context.Background(), server.URL, repo, digest, int64(len(blob)), bytes.NewReader(blob), authHeader)
115			require.NoError(t, err)
116
117			assert.Equal(t, tc.expectedHeadCalls, serverState.headCalls)
118			assert.Equal(t, tc.expectedPostCalls, serverState.postCalls)
119			assert.Equal(t, tc.expectedPutCalls, serverState.putCalls)
120			assert.Equal(t, tc.expectedPatchSizes, serverState.patchSizes)
121			assert.Equal(t, tc.expectedPatchSizes, serverState.rangeSizes)
122			assert.Equal(t, serverState.requestCount, serverState.authCount)
123		})
124	}
125}
126
127func TestPusher_Push(t *testing.T) {
128	testCases := []struct {
129		name                       string
130		imageSize                  int64
131		layerCount                 int64
132		existingBlobDigests        func(t *testing.T, img v1.Image) map[string]bool
133		expectedUploadedBlobCount  int
134		expectedManifestMediaTypes []string
135	}{
136		{
137			name:                      "single-layer image uploads config layer and manifest",
138			imageSize:                 1024,
139			layerCount:                1,
140			existingBlobDigests:       func(t *testing.T, img v1.Image) map[string]bool { return map[string]bool{} },
141			expectedUploadedBlobCount: 2,
142			expectedManifestMediaTypes: []string{
143				"application/vnd.docker.distribution.manifest.v2+json",
144				typesOCIManifestSchema1(),
145			},
146		},
147		{
148			name:                      "multi-layer image uploads all blobs and manifest",
149			imageSize:                 1024,
150			layerCount:                2,
151			existingBlobDigests:       func(t *testing.T, img v1.Image) map[string]bool { return map[string]bool{} },
152			expectedUploadedBlobCount: 3,
153			expectedManifestMediaTypes: []string{
154				"application/vnd.docker.distribution.manifest.v2+json",
155				typesOCIManifestSchema1(),
156			},
157		},
158		{
159			name:       "existing layer is skipped while others upload",
160			imageSize:  1024,
161			layerCount: 2,
162			existingBlobDigests: func(t *testing.T, img v1.Image) map[string]bool {
163				t.Helper()
164				layers, err := img.Layers()
165				require.NoError(t, err)
166				digest, err := layers[0].Digest()
167				require.NoError(t, err)
168				return map[string]bool{digest.String(): true}
169			},
170			expectedUploadedBlobCount: 2,
171			expectedManifestMediaTypes: []string{
172				"application/vnd.docker.distribution.manifest.v2+json",
173				typesOCIManifestSchema1(),
174			},
175		},
176	}
177
178	for _, tc := range testCases {
179		t.Run(tc.name, func(t *testing.T) {
180			img, err := random.Image(tc.imageSize, tc.layerCount)
181			require.NoError(t, err)
182
183			serverState := newFakePushRegistry(t, img, tc.existingBlobDigests(t, img))
184			server := httptest.NewServer(serverState.handler())
185			defer server.Close()
186
187			serverURL, err := url.Parse(server.URL)
188			require.NoError(t, err)
189			ref := serverURL.Host + "/demo/app:v1"
190
191			p := registrypush.New(
192				registrypush.WithTransport(server.Client().Transport),
193				registrypush.WithImageSource(func(context.Context, string) (v1.Image, error) {
194					return img, nil
195				}),
196			)
197
198			err = p.Push(context.Background(), ref)
199			require.NoError(t, err)
200
201			expectedDigests := make([]string, 0, len(serverState.expectedBlobs))
202			for digest := range serverState.expectedBlobs {
203				expectedDigests = append(expectedDigests, digest)
204				assert.Contains(t, serverState.headChecks, digest)
205			}
206			assert.Len(t, serverState.headChecks, len(expectedDigests))
207
208			assert.Len(t, serverState.uploadedBlobs, tc.expectedUploadedBlobCount)
209			for digest, content := range serverState.uploadedBlobs {
210				assert.Equal(t, serverState.expectedBlobs[digest], content)
211			}
212
213			manifestPut, ok := serverState.manifestPuts[serverState.expectedRepo+":"+serverState.expectedTag]
214			require.True(t, ok)
215			assert.Contains(t, tc.expectedManifestMediaTypes, manifestPut.contentType)
216			assert.Equal(t, serverState.expectedManifest, manifestPut.body)
217		})
218	}
219}
220
221func TestPusher_Push_InvokesCleanup(t *testing.T) {
222	var cleanupCount int
223	img, err := random.Image(1024, 1)
224	require.NoError(t, err)
225
226	registry := httptest.NewServer(newFakePushRegistry(t, img, map[string]bool{}).handler())
227	defer registry.Close()
228
229	p := registrypush.New(
230		registrypush.WithChunkSize(1024),
231		registrypush.WithImageSource(func(ctx context.Context, ref string) (v1.Image, error) {
232			return img, nil
233		}),
234		registrypush.WithImageCleanup(func() {
235			cleanupCount++
236		}),
237	)
238
239	registryHost := strings.TrimPrefix(registry.URL, "http://")
240	err = p.Push(context.Background(), registryHost+"/demo/app:v1")
241	require.NoError(t, err)
242	assert.Equal(t, 1, cleanupCount, "cleanup callback should have been invoked after first push")
243
244	err = p.Push(context.Background(), registryHost+"/demo/app:v1")
245	require.NoError(t, err)
246	assert.Equal(t, 2, cleanupCount, "cleanup callback should have been invoked after second push")
247}
248
249func TestPusher_WithInsecureTLS_AllowsSelfSignedCertificate(t *testing.T) {
250	t.Parallel()
251
252	srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
253		if r.Method == http.MethodHead {
254			w.WriteHeader(http.StatusNotFound)
255			return
256		}
257		if r.Method == http.MethodPost {
258			w.Header().Set("Location", "/v2/repo/blobs/uploads/test-uuid")
259			w.WriteHeader(http.StatusAccepted)
260			return
261		}
262		if r.Method == http.MethodPatch {
263			w.Header().Set("Location", "/v2/repo/blobs/uploads/test-uuid")
264			w.WriteHeader(http.StatusAccepted)
265			return
266		}
267		if r.Method == http.MethodPut {
268			w.WriteHeader(http.StatusCreated)
269			return
270		}
271	}))
272	defer srv.Close()
273
274	ctx := context.Background()
275	content := []byte("test-blob-data")
276	digest := fmt.Sprintf("sha256:%x", sha256.Sum256(content))
277
278	pusherSecure := registrypush.New(registrypush.WithChunkSize(1024))
279	err := pusherSecure.UploadBlob(ctx, srv.URL, "repo", digest, int64(len(content)), bytes.NewReader(content), "")
280	require.Error(t, err)
281	assert.Contains(t, err.Error(), "certificate")
282
283	pusherInsecure := registrypush.New(registrypush.WithChunkSize(1024), registrypush.WithInsecureTLS(true))
284	err = pusherInsecure.UploadBlob(ctx, srv.URL, "repo", digest, int64(len(content)), bytes.NewReader(content), "")
285	require.NoError(t, err)
286}
287
288func TestPusher_WithTimeout_RespectsDeadline(t *testing.T) {
289	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
290		<-r.Context().Done()
291	}))
292	defer srv.Close()
293
294	p := registrypush.New(
295		registrypush.WithChunkSize(1024),
296		registrypush.WithTimeout(100*time.Millisecond),
297	)
298
299	ctx := context.Background()
300	err := p.UploadBlob(ctx, srv.URL, "test/repo", "sha256:abc123", 10, strings.NewReader("0123456789"), "")
301
302	require.Error(t, err)
303}
304
305func TestUploadBlob_RejectsCrossOriginRedirect(t *testing.T) {
306	attacker := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
307		t.Errorf("request leaked to attacker: %s %s (Authorization: %s)", r.Method, r.URL.Path, r.Header.Get("Authorization"))
308		w.WriteHeader(http.StatusOK)
309	}))
310	defer attacker.Close()
311
312	registry := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
313		switch r.Method {
314		case http.MethodHead:
315			w.WriteHeader(http.StatusNotFound)
316		case http.MethodPost:
317			w.Header().Set("Location", attacker.URL+"/v2/repo/blobs/uploads/evil")
318			w.WriteHeader(http.StatusAccepted)
319		default:
320			w.WriteHeader(http.StatusInternalServerError)
321		}
322	}))
323	defer registry.Close()
324
325	p := registrypush.New(registrypush.WithChunkSize(1024))
326	ctx := context.Background()
327	err := p.UploadBlob(ctx, registry.URL, "test/repo", "sha256:abc", 5, strings.NewReader("hello"), "Basic secret")
328
329	require.Error(t, err)
330	assert.Contains(t, err.Error(), "cross-origin")
331}
332
333// authRecordingTransport records Authorization headers per request URL.
334type authRecordingTransport struct {
335	inner   http.RoundTripper
336	records []authRecord
337}
338
339type authRecord struct {
340	URL  string
341	Auth string
342}
343
344func (t *authRecordingTransport) RoundTrip(req *http.Request) (*http.Response, error) {
345	t.records = append(t.records, authRecord{
346		URL:  req.URL.String(),
347		Auth: req.Header.Get("Authorization"),
348	})
349	return t.inner.RoundTrip(req)
350}
351
352func TestUploadBlob_AuthHeaderOnlySentToSameOrigin(t *testing.T) {
353	registry := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
354		switch r.Method {
355		case http.MethodHead:
356			w.WriteHeader(http.StatusNotFound)
357		case http.MethodPost:
358			w.Header().Set("Location", "/v2/repo/blobs/uploads/abc?chunk=0")
359			w.WriteHeader(http.StatusAccepted)
360		case http.MethodPatch:
361			w.Header().Set("Location", "/v2/repo/blobs/uploads/abc?chunk=1")
362			w.WriteHeader(http.StatusAccepted)
363		case http.MethodPut:
364			w.WriteHeader(http.StatusCreated)
365		default:
366			w.WriteHeader(http.StatusInternalServerError)
367		}
368	}))
369	defer registry.Close()
370
371	recorder := &authRecordingTransport{inner: http.DefaultTransport}
372	p := registrypush.New(
373		registrypush.WithChunkSize(1024),
374		registrypush.WithTransport(recorder),
375	)
376	ctx := context.Background()
377	secret := "Basic dXNlcjpwYXNz"
378	err := p.UploadBlob(ctx, registry.URL, "test/repo", "sha256:abc", 5, strings.NewReader("hello"), secret)
379
380	require.NoError(t, err)
381	require.NotEmpty(t, recorder.records)
382
383	registryURL, _ := url.Parse(registry.URL)
384	for _, rec := range recorder.records {
385		reqURL, _ := url.Parse(rec.URL)
386		if reqURL.Host == registryURL.Host {
387			assert.Equal(t, secret, rec.Auth, "same-origin request to %s should have auth", rec.URL)
388		} else {
389			assert.Empty(t, rec.Auth, "cross-origin request to %s should NOT have auth", rec.URL)
390		}
391	}
392}
393
394type fakeBlobUploadRegistry struct {
395	t              *testing.T
396	expectedAuth   string
397	expectedDigest string
398	headStatus     int
399	patchSizes     []int
400	rangeSizes     []int
401	headCalls      int
402	postCalls      int
403	putCalls       int
404	requestCount   int
405	authCount      int
406	nextOffset     int
407	location       string
408	uuid           string
409}
410
411func newFakeBlobUploadRegistry(t *testing.T, expectedAuth, expectedDigest string, headStatus int) *fakeBlobUploadRegistry {
412	t.Helper()
413	return &fakeBlobUploadRegistry{
414		t:              t,
415		expectedAuth:   expectedAuth,
416		expectedDigest: expectedDigest,
417		headStatus:     headStatus,
418		location:       "/upload/session-1",
419		uuid:           "session-1",
420	}
421}
422
423func (f *fakeBlobUploadRegistry) handler() http.Handler {
424	f.t.Helper()
425	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
426		f.requestCount++
427		if r.Header.Get("Authorization") == f.expectedAuth {
428			f.authCount++
429		}
430
431		switch {
432		case r.Method == http.MethodHead && strings.Contains(r.URL.Path, "/blobs/"):
433			f.headCalls++
434			w.WriteHeader(f.headStatus)
435		case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/blobs/uploads/"):
436			f.postCalls++
437			w.Header().Set("Location", f.location)
438			w.Header().Set("Docker-Upload-UUID", f.uuid)
439			w.Header().Set("Range", rangeHeaderValue(0, f.nextOffset-1))
440			w.WriteHeader(http.StatusAccepted)
441		case r.Method == http.MethodPatch && r.URL.Path == f.location:
442			f.handlePatch(w, r)
443		case r.Method == http.MethodPut && r.URL.Path == f.location:
444			f.putCalls++
445			assert.Equal(f.t, f.expectedDigest, r.URL.Query().Get("digest"))
446			body, err := io.ReadAll(r.Body)
447			require.NoError(f.t, err)
448			assert.Len(f.t, body, 0)
449			w.Header().Set("Location", f.location)
450			w.Header().Set("Docker-Content-Digest", f.expectedDigest)
451			w.WriteHeader(http.StatusCreated)
452		default:
453			f.t.Fatalf("unexpected request: %s %s", r.Method, r.URL.String())
454		}
455	})
456}
457
458func (f *fakeBlobUploadRegistry) handlePatch(w http.ResponseWriter, r *http.Request) {
459	body, err := io.ReadAll(r.Body)
460	require.NoError(f.t, err)
461
462	start, end := parseContentRange(f.t, r.Header.Get("Content-Range"))
463	assert.Equal(f.t, f.nextOffset, start)
464	assert.Equal(f.t, len(body), end-start+1)
465
466	contentLength, err := strconv.Atoi(r.Header.Get("Content-Length"))
467	require.NoError(f.t, err)
468	assert.Equal(f.t, len(body), contentLength)
469	assert.Equal(f.t, "application/octet-stream", r.Header.Get("Content-Type"))
470
471	f.patchSizes = append(f.patchSizes, len(body))
472	f.rangeSizes = append(f.rangeSizes, end-start+1)
473	f.nextOffset = end + 1
474
475	w.Header().Set("Location", f.location)
476	w.Header().Set("Docker-Upload-UUID", f.uuid)
477	w.Header().Set("Range", rangeHeaderValue(0, end))
478	w.WriteHeader(http.StatusAccepted)
479}
480
481func parseContentRange(t *testing.T, value string) (int, int) {
482	t.Helper()
483	parts := strings.Split(value, "-")
484	require.Len(t, parts, 2)
485
486	start, err := strconv.Atoi(parts[0])
487	require.NoError(t, err)
488
489	end, err := strconv.Atoi(parts[1])
490	require.NoError(t, err)
491
492	return start, end
493}
494
495func rangeHeaderValue(start, end int) string {
496	if end < start {
497		return ""
498	}
499	return fmt.Sprintf("%d-%d", start, end)
500}
501
502type fakePushRegistry struct {
503	t                *testing.T
504	existingBlobs    map[string]bool
505	uploadedBlobs    map[string][]byte
506	manifestPuts     map[string]fakeManifestPut
507	uploads          map[string]*fakeUploadSession
508	nextUploadID     int
509	headChecks       []string
510	expectedBlobs    map[string][]byte
511	expectedRepo     string
512	expectedTag      string
513	expectedMT       string
514	expectedManifest []byte
515}
516
517type fakeUploadSession struct {
518	repo string
519	data []byte
520}
521
522type fakeManifestPut struct {
523	contentType string
524	body        []byte
525}
526
527func newFakePushRegistry(t *testing.T, img v1.Image, existingBlobs map[string]bool) *fakePushRegistry {
528	t.Helper()
529
530	layers, err := img.Layers()
531	require.NoError(t, err)
532
533	expectedBlobs := make(map[string][]byte, len(layers)+1)
534	for _, layer := range layers {
535		digest, err := layer.Digest()
536		require.NoError(t, err)
537		rc, err := layer.Compressed()
538		require.NoError(t, err)
539		content, err := io.ReadAll(rc)
540		require.NoError(t, err)
541		require.NoError(t, rc.Close())
542		expectedBlobs[digest.String()] = content
543	}
544
545	config, err := img.RawConfigFile()
546	require.NoError(t, err)
547	configDigest := fmt.Sprintf("sha256:%x", sha256.Sum256(config))
548	expectedBlobs[configDigest] = config
549
550	manifest, err := img.RawManifest()
551	require.NoError(t, err)
552	mediaType, err := img.MediaType()
553	require.NoError(t, err)
554
555	return &fakePushRegistry{
556		t:                t,
557		existingBlobs:    existingBlobs,
558		uploadedBlobs:    make(map[string][]byte),
559		manifestPuts:     make(map[string]fakeManifestPut),
560		uploads:          make(map[string]*fakeUploadSession),
561		expectedBlobs:    expectedBlobs,
562		expectedRepo:     "demo/app",
563		expectedTag:      "v1",
564		expectedMT:       string(mediaType),
565		expectedManifest: manifest,
566	}
567}
568
569func (f *fakePushRegistry) handler() http.Handler {
570	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
571		switch {
572		case r.Method == http.MethodHead && strings.HasPrefix(r.URL.Path, "/v2/") && strings.Contains(r.URL.Path, "/blobs/"):
573			f.handleBlobHead(w, r)
574		case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/blobs/uploads/"):
575			f.handleBlobStart(w, r)
576		case r.Method == http.MethodPatch && strings.Contains(r.URL.Path, "/blobs/uploads/"):
577			f.handleBlobPatch(w, r)
578		case r.Method == http.MethodPut && strings.Contains(r.URL.Path, "/blobs/uploads/"):
579			f.handleBlobFinalize(w, r)
580		case r.Method == http.MethodPut && strings.Contains(r.URL.Path, "/manifests/"):
581			f.handleManifestPut(w, r)
582		default:
583			f.t.Fatalf("unexpected request: %s %s", r.Method, r.URL.String())
584		}
585	})
586}
587
588func (f *fakePushRegistry) handleBlobHead(w http.ResponseWriter, r *http.Request) {
589	digest := pathTail(r.URL.Path)
590	f.headChecks = append(f.headChecks, digest)
591	if f.existingBlobs[digest] {
592		w.WriteHeader(http.StatusOK)
593		return
594	}
595	w.WriteHeader(http.StatusNotFound)
596}
597
598func (f *fakePushRegistry) handleBlobStart(w http.ResponseWriter, r *http.Request) {
599	repo := pathSegmentBetween(r.URL.Path, "/v2/", "/blobs/uploads/")
600	assert.Equal(f.t, f.expectedRepo, repo)
601	f.nextUploadID++
602	uploadPath := fmt.Sprintf("/v2/%s/blobs/uploads/upload-%d", repo, f.nextUploadID)
603	f.uploads[uploadPath] = &fakeUploadSession{repo: repo}
604	w.Header().Set("Location", uploadPath)
605	w.WriteHeader(http.StatusAccepted)
606}
607
608func (f *fakePushRegistry) handleBlobPatch(w http.ResponseWriter, r *http.Request) {
609	session := f.uploads[r.URL.Path]
610	if session == nil {
611		f.t.Fatalf("missing upload session for %s", r.URL.Path)
612	}
613	body, err := io.ReadAll(r.Body)
614	require.NoError(f.t, err)
615	session.data = append(session.data, body...)
616	w.Header().Set("Location", r.URL.Path)
617	w.WriteHeader(http.StatusAccepted)
618}
619
620func (f *fakePushRegistry) handleBlobFinalize(w http.ResponseWriter, r *http.Request) {
621	session := f.uploads[r.URL.Path]
622	if session == nil {
623		f.t.Fatalf("missing upload session for %s", r.URL.Path)
624	}
625	digest := r.URL.Query().Get("digest")
626	f.uploadedBlobs[digest] = append([]byte(nil), session.data...)
627	delete(f.uploads, r.URL.Path)
628	w.WriteHeader(http.StatusCreated)
629}
630
631func (f *fakePushRegistry) handleManifestPut(w http.ResponseWriter, r *http.Request) {
632	repo := pathSegmentBetween(r.URL.Path, "/v2/", "/manifests/")
633	tag := pathTail(r.URL.Path)
634	assert.Equal(f.t, f.expectedRepo, repo)
635	assert.Equal(f.t, f.expectedTag, tag)
636	body, err := io.ReadAll(r.Body)
637	require.NoError(f.t, err)
638	f.manifestPuts[repo+":"+tag] = fakeManifestPut{contentType: r.Header.Get("Content-Type"), body: body}
639	w.WriteHeader(http.StatusCreated)
640}
641
642func pathTail(path string) string {
643	parts := strings.Split(path, "/")
644	return parts[len(parts)-1]
645}
646
647func pathSegmentBetween(value, start, end string) string {
648	startIdx := strings.Index(value, start)
649	if startIdx == -1 {
650		return ""
651	}
652	trimmed := value[startIdx+len(start):]
653	endIdx := strings.Index(trimmed, end)
654	if endIdx == -1 {
655		return ""
656	}
657	return trimmed[:endIdx]
658}
659
660func typesOCIManifestSchema1() string {
661	return "application/vnd.oci.image.manifest.v1+json"
662}
663
664// TestPusher_WithAuth verifies that when WithAuth is provided, its header is
665// used for all registry requests instead of the Docker keychain, and that
666// omitting WithAuth leaves existing behavior unchanged.
667func TestPusher_WithAuth(t *testing.T) {
668	t.Run("WithAuth header is sent on every registry request", func(t *testing.T) {
669		img, err := random.Image(1024, 1)
670		require.NoError(t, err)
671
672		const explicitToken = "Bearer my-token"
673
674		// authAssertingRegistry fails the test if any request lacks the expected
675		// Authorization header.
676		var requestCount int
677		registry := httptest.NewServer(newFakePushRegistry(t, img, map[string]bool{}).handlerWithAuthCheck(t, explicitToken, &requestCount))
678		defer registry.Close()
679
680		serverURL, err := url.Parse(registry.URL)
681		require.NoError(t, err)
682		ref := serverURL.Host + "/demo/app:v1"
683
684		p := registrypush.New(
685			registrypush.WithTransport(registry.Client().Transport),
686			registrypush.WithImageSource(func(context.Context, string) (v1.Image, error) {
687				return img, nil
688			}),
689			registrypush.WithAuth(explicitToken),
690		)
691
692		err = p.Push(context.Background(), ref)
693		require.NoError(t, err)
694		assert.Positive(t, requestCount, "at least one request should have been made")
695	})
696
697	t.Run("without WithAuth, existing push behavior is unchanged", func(t *testing.T) {
698		img, err := random.Image(1024, 1)
699		require.NoError(t, err)
700
701		serverState := newFakePushRegistry(t, img, map[string]bool{})
702		registry := httptest.NewServer(serverState.handler())
703		defer registry.Close()
704
705		serverURL, err := url.Parse(registry.URL)
706		require.NoError(t, err)
707		ref := serverURL.Host + "/demo/app:v1"
708
709		// No WithAuth — relies on Docker keychain (returns empty string for localhost).
710		p := registrypush.New(
711			registrypush.WithTransport(registry.Client().Transport),
712			registrypush.WithImageSource(func(context.Context, string) (v1.Image, error) {
713				return img, nil
714			}),
715		)
716
717		err = p.Push(context.Background(), ref)
718		require.NoError(t, err)
719
720		// Manifest should have been pushed — verifies the push completed normally.
721		_, ok := serverState.manifestPuts[serverState.expectedRepo+":"+serverState.expectedTag]
722		assert.True(t, ok, "manifest should have been uploaded")
723	})
724}
725
726// handlerWithAuthCheck wraps the fakePushRegistry handler and asserts that
727// every request carries the expected Authorization header.
728func (f *fakePushRegistry) handlerWithAuthCheck(t *testing.T, expectedAuth string, requestCount *int) http.Handler {
729	t.Helper()
730	inner := f.handler()
731	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
732		*requestCount++
733		assert.Equal(t, expectedAuth, r.Header.Get("Authorization"),
734			"expected Authorization header on %s %s", r.Method, r.URL.Path)
735		inner.ServeHTTP(w, r)
736	})
737}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close