detached 658f480

fix(security): clean up temp tarballs after registry push

Add WithImageCleanup option and wire it into Push() via defer.
defaultImageSource now produces a cleanup callback that removes the temp
tar file. Previously, temp tarballs containing full image contents were
left in /tmp indefinitely. The public ImageSource type signature is
unchanged for backward compatibility.
commit
658f480e01cfacfb9c7b0ee986a920100a0aabe1
author
brice · · 2026-03-14 22:15 UTC
parent
92dab38
signature
unsigned
2 files · +65 -16
M pkg/registrypush/push.go +40 -16
@@ -36,6 +36,7 @@ type Pusher struct {
3636 transport http.RoundTripper
3737 insecureTLS bool
3838 imageSource ImageSource
39+ cleanupFn func()
3940 progress io.Writer
4041 }
4142
@@ -63,6 +64,12 @@ func WithImageSource(src ImageSource) Option {
6364 return func(p *Pusher) { p.imageSource = src }
6465 }
6566
67+// WithImageCleanup sets a function called after Push completes to clean up
68+// resources created by the image source (e.g., temp tar files).
69+func WithImageCleanup(fn func()) Option {
70+ return func(p *Pusher) { p.cleanupFn = fn }
71+}
72+
6673 // WithProgress sets a writer for progress messages.
6774 func WithProgress(w io.Writer) Option {
6875 return func(p *Pusher) { p.progress = w }
@@ -82,7 +89,14 @@ func New(opts ...Option) *Pusher {
8289 }
8390 }
8491 if p.imageSource == nil {
85- p.imageSource = defaultImageSource
92+ p.imageSource = func(ctx context.Context, ref string) (v1.Image, error) {
93+ img, cleanup, err := defaultImageSourceWithCleanup(ctx, ref)
94+ if err != nil {
95+ return nil, err
96+ }
97+ p.cleanupFn = cleanup
98+ return img, nil
99+ }
86100 }
87101 return p
88102 }
@@ -102,6 +116,9 @@ func (p *Pusher) Push(ctx context.Context, ref string) error {
102116 return fmt.Errorf("image ref %s must include a tag", ref)
103117 }
104118
119+ cleanup := p.cleanupFn
120+ p.cleanupFn = nil
121+
105122 authHeader, err := resolveAuthHeader(parsedRef)
106123 if err != nil {
107124 return err
@@ -111,6 +128,12 @@ func (p *Pusher) Push(ctx context.Context, ref string) error {
111128 if err != nil {
112129 return err
113130 }
131+ if p.cleanupFn != nil {
132+ cleanup = p.cleanupFn
133+ }
134+ if cleanup != nil {
135+ defer cleanup()
136+ }
114137
115138 baseURL := registryBaseURL(parsedRef.Context().RegistryStr())
116139 repo := parsedRef.Context().RepositoryStr()
@@ -155,30 +178,30 @@ func (p *Pusher) UploadBlob(ctx context.Context, baseURL, repo, digest string, s
155178 return p.finalizeBlobUpload(ctx, baseURL, uploadURL, digest, auth)
156179 }
157180
158-// defaultImageSource reads an image from the local Docker/Podman daemon.
159-func defaultImageSource(ctx context.Context, ref string) (v1.Image, error) {
181+// defaultImageSourceWithCleanup reads an image from the local Docker/Podman daemon.
182+func defaultImageSourceWithCleanup(ctx context.Context, ref string) (v1.Image, func(), error) {
160183 cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
161184 if err != nil {
162- return nil, fmt.Errorf("failed to create Docker client: %w", err)
185+ return nil, nil, fmt.Errorf("failed to create Docker client: %w", err)
163186 }
164187 defer cli.Close()
165188
166189 tag, err := name.NewTag(ref)
167190 if err != nil {
168- return nil, fmt.Errorf("failed to parse image ref %s: %w", ref, err)
191+ return nil, nil, fmt.Errorf("failed to parse image ref %s: %w", ref, err)
169192 }
170193
171194 rc, err := cli.ImageSave(ctx, []string{ref})
172195 if err != nil {
173- return nil, fmt.Errorf("failed to export image %s: %w", ref, err)
196+ return nil, nil, fmt.Errorf("failed to export image %s: %w", ref, err)
174197 }
175198
176199 tmpFile, err := os.CreateTemp("", "gordon-push-*.tar")
177200 if err != nil {
178201 if closeErr := rc.Close(); closeErr != nil {
179- return nil, fmt.Errorf("failed to create temp file for image export: %w (also failed to close image export stream: %v)", err, closeErr)
202+ return nil, nil, fmt.Errorf("failed to create temp file for image export: %w (also failed to close image export stream: %v)", err, closeErr)
180203 }
181- return nil, fmt.Errorf("failed to create temp file for image export: %w", err)
204+ return nil, nil, fmt.Errorf("failed to create temp file for image export: %w", err)
182205 }
183206 tmpPath := tmpFile.Name()
184207
@@ -186,33 +209,34 @@ func defaultImageSource(ctx context.Context, ref string) (v1.Image, error) {
186209 if closeErr := rc.Close(); closeErr != nil {
187210 _ = tmpFile.Close()
188211 _ = os.Remove(tmpPath)
189- return nil, fmt.Errorf("failed to write image tar to temp file: %w (also failed to close image export stream: %v)", err, closeErr)
212+ return nil, nil, fmt.Errorf("failed to write image tar to temp file: %w (also failed to close image export stream: %v)", err, closeErr)
190213 }
191214 if closeErr := tmpFile.Close(); closeErr != nil {
192215 _ = os.Remove(tmpPath)
193- return nil, fmt.Errorf("failed to write image tar to temp file: %w (also failed to close temp file: %v)", err, closeErr)
216+ return nil, nil, fmt.Errorf("failed to write image tar to temp file: %w (also failed to close temp file: %v)", err, closeErr)
194217 }
195218 if removeErr := os.Remove(tmpPath); removeErr != nil {
196- return nil, fmt.Errorf("failed to write image tar to temp file: %w (also failed to remove temp file %s: %v)", err, tmpPath, removeErr)
219+ return nil, nil, fmt.Errorf("failed to write image tar to temp file: %w (also failed to remove temp file %s: %v)", err, tmpPath, removeErr)
197220 }
198- return nil, fmt.Errorf("failed to write image tar to temp file: %w", err)
221+ return nil, nil, fmt.Errorf("failed to write image tar to temp file: %w", err)
199222 }
200223 if err := rc.Close(); err != nil {
201224 _ = tmpFile.Close()
202225 _ = os.Remove(tmpPath)
203- return nil, fmt.Errorf("failed to close image export stream: %w", err)
226+ return nil, nil, fmt.Errorf("failed to close image export stream: %w", err)
204227 }
205228 if err := tmpFile.Close(); err != nil {
206229 _ = os.Remove(tmpPath)
207- return nil, fmt.Errorf("failed to close temp file %s: %w", tmpPath, err)
230+ return nil, nil, fmt.Errorf("failed to close temp file %s: %w", tmpPath, err)
208231 }
209232
210233 img, err := tarball.ImageFromPath(tmpPath, &tag)
211234 if err != nil {
212235 _ = os.Remove(tmpPath)
213- return nil, fmt.Errorf("failed to parse image tar for %s: %w", ref, err)
236+ return nil, nil, fmt.Errorf("failed to parse image tar for %s: %w", ref, err)
214237 }
215- return img, nil
238+ cleanup := func() { _ = os.Remove(tmpPath) }
239+ return img, cleanup, nil
216240 }
217241
218242 func (p *Pusher) uploadImageLayers(ctx context.Context, img v1.Image, baseURL, repo, auth string) error {
@@ -36,6 +36,7 @@ type Pusher struct {
36 transport http.RoundTripper36 transport http.RoundTripper
37 insecureTLS bool37 insecureTLS bool
38 imageSource ImageSource38 imageSource ImageSource
39+ cleanupFn func()
39 progress io.Writer40 progress io.Writer
40 }41 }
41 42
@@ -63,6 +64,12 @@ func WithImageSource(src ImageSource) Option {
63 return func(p *Pusher) { p.imageSource = src }64 return func(p *Pusher) { p.imageSource = src }
64 }65 }
65 66
67+// WithImageCleanup sets a function called after Push completes to clean up
68+// resources created by the image source (e.g., temp tar files).
69+func WithImageCleanup(fn func()) Option {
70+ return func(p *Pusher) { p.cleanupFn = fn }
71+}
72+
66 // WithProgress sets a writer for progress messages.73 // WithProgress sets a writer for progress messages.
67 func WithProgress(w io.Writer) Option {74 func WithProgress(w io.Writer) Option {
68 return func(p *Pusher) { p.progress = w }75 return func(p *Pusher) { p.progress = w }
@@ -82,7 +89,14 @@ func New(opts ...Option) *Pusher {
82 }89 }
83 }90 }
84 if p.imageSource == nil {91 if p.imageSource == nil {
85- p.imageSource = defaultImageSource92+ p.imageSource = func(ctx context.Context, ref string) (v1.Image, error) {
93+ img, cleanup, err := defaultImageSourceWithCleanup(ctx, ref)
94+ if err != nil {
95+ return nil, err
96+ }
97+ p.cleanupFn = cleanup
98+ return img, nil
99+ }
86 }100 }
87 return p101 return p
88 }102 }
@@ -102,6 +116,9 @@ func (p *Pusher) Push(ctx context.Context, ref string) error {
102 return fmt.Errorf("image ref %s must include a tag", ref)116 return fmt.Errorf("image ref %s must include a tag", ref)
103 }117 }
104 118
119+ cleanup := p.cleanupFn
120+ p.cleanupFn = nil
121+
105 authHeader, err := resolveAuthHeader(parsedRef)122 authHeader, err := resolveAuthHeader(parsedRef)
106 if err != nil {123 if err != nil {
107 return err124 return err
@@ -111,6 +128,12 @@ func (p *Pusher) Push(ctx context.Context, ref string) error {
111 if err != nil {128 if err != nil {
112 return err129 return err
113 }130 }
131+ if p.cleanupFn != nil {
132+ cleanup = p.cleanupFn
133+ }
134+ if cleanup != nil {
135+ defer cleanup()
136+ }
114 137
115 baseURL := registryBaseURL(parsedRef.Context().RegistryStr())138 baseURL := registryBaseURL(parsedRef.Context().RegistryStr())
116 repo := parsedRef.Context().RepositoryStr()139 repo := parsedRef.Context().RepositoryStr()
@@ -155,30 +178,30 @@ func (p *Pusher) UploadBlob(ctx context.Context, baseURL, repo, digest string, s
155 return p.finalizeBlobUpload(ctx, baseURL, uploadURL, digest, auth)178 return p.finalizeBlobUpload(ctx, baseURL, uploadURL, digest, auth)
156 }179 }
157 180
158-// defaultImageSource reads an image from the local Docker/Podman daemon.181+// defaultImageSourceWithCleanup reads an image from the local Docker/Podman daemon.
159-func defaultImageSource(ctx context.Context, ref string) (v1.Image, error) {182+func defaultImageSourceWithCleanup(ctx context.Context, ref string) (v1.Image, func(), error) {
160 cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())183 cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
161 if err != nil {184 if err != nil {
162- return nil, fmt.Errorf("failed to create Docker client: %w", err)185+ return nil, nil, fmt.Errorf("failed to create Docker client: %w", err)
163 }186 }
164 defer cli.Close()187 defer cli.Close()
165 188
166 tag, err := name.NewTag(ref)189 tag, err := name.NewTag(ref)
167 if err != nil {190 if err != nil {
168- return nil, fmt.Errorf("failed to parse image ref %s: %w", ref, err)191+ return nil, nil, fmt.Errorf("failed to parse image ref %s: %w", ref, err)
169 }192 }
170 193
171 rc, err := cli.ImageSave(ctx, []string{ref})194 rc, err := cli.ImageSave(ctx, []string{ref})
172 if err != nil {195 if err != nil {
173- return nil, fmt.Errorf("failed to export image %s: %w", ref, err)196+ return nil, nil, fmt.Errorf("failed to export image %s: %w", ref, err)
174 }197 }
175 198
176 tmpFile, err := os.CreateTemp("", "gordon-push-*.tar")199 tmpFile, err := os.CreateTemp("", "gordon-push-*.tar")
177 if err != nil {200 if err != nil {
178 if closeErr := rc.Close(); closeErr != nil {201 if closeErr := rc.Close(); closeErr != nil {
179- return nil, fmt.Errorf("failed to create temp file for image export: %w (also failed to close image export stream: %v)", err, closeErr)202+ return nil, nil, fmt.Errorf("failed to create temp file for image export: %w (also failed to close image export stream: %v)", err, closeErr)
180 }203 }
181- return nil, fmt.Errorf("failed to create temp file for image export: %w", err)204+ return nil, nil, fmt.Errorf("failed to create temp file for image export: %w", err)
182 }205 }
183 tmpPath := tmpFile.Name()206 tmpPath := tmpFile.Name()
184 207
@@ -186,33 +209,34 @@ func defaultImageSource(ctx context.Context, ref string) (v1.Image, error) {
186 if closeErr := rc.Close(); closeErr != nil {209 if closeErr := rc.Close(); closeErr != nil {
187 _ = tmpFile.Close()210 _ = tmpFile.Close()
188 _ = os.Remove(tmpPath)211 _ = os.Remove(tmpPath)
189- return nil, fmt.Errorf("failed to write image tar to temp file: %w (also failed to close image export stream: %v)", err, closeErr)212+ return nil, nil, fmt.Errorf("failed to write image tar to temp file: %w (also failed to close image export stream: %v)", err, closeErr)
190 }213 }
191 if closeErr := tmpFile.Close(); closeErr != nil {214 if closeErr := tmpFile.Close(); closeErr != nil {
192 _ = os.Remove(tmpPath)215 _ = os.Remove(tmpPath)
193- return nil, fmt.Errorf("failed to write image tar to temp file: %w (also failed to close temp file: %v)", err, closeErr)216+ return nil, nil, fmt.Errorf("failed to write image tar to temp file: %w (also failed to close temp file: %v)", err, closeErr)
194 }217 }
195 if removeErr := os.Remove(tmpPath); removeErr != nil {218 if removeErr := os.Remove(tmpPath); removeErr != nil {
196- return nil, fmt.Errorf("failed to write image tar to temp file: %w (also failed to remove temp file %s: %v)", err, tmpPath, removeErr)219+ return nil, nil, fmt.Errorf("failed to write image tar to temp file: %w (also failed to remove temp file %s: %v)", err, tmpPath, removeErr)
197 }220 }
198- return nil, fmt.Errorf("failed to write image tar to temp file: %w", err)221+ return nil, nil, fmt.Errorf("failed to write image tar to temp file: %w", err)
199 }222 }
200 if err := rc.Close(); err != nil {223 if err := rc.Close(); err != nil {
201 _ = tmpFile.Close()224 _ = tmpFile.Close()
202 _ = os.Remove(tmpPath)225 _ = os.Remove(tmpPath)
203- return nil, fmt.Errorf("failed to close image export stream: %w", err)226+ return nil, nil, fmt.Errorf("failed to close image export stream: %w", err)
204 }227 }
205 if err := tmpFile.Close(); err != nil {228 if err := tmpFile.Close(); err != nil {
206 _ = os.Remove(tmpPath)229 _ = os.Remove(tmpPath)
207- return nil, fmt.Errorf("failed to close temp file %s: %w", tmpPath, err)230+ return nil, nil, fmt.Errorf("failed to close temp file %s: %w", tmpPath, err)
208 }231 }
209 232
210 img, err := tarball.ImageFromPath(tmpPath, &tag)233 img, err := tarball.ImageFromPath(tmpPath, &tag)
211 if err != nil {234 if err != nil {
212 _ = os.Remove(tmpPath)235 _ = os.Remove(tmpPath)
213- return nil, fmt.Errorf("failed to parse image tar for %s: %w", ref, err)236+ return nil, nil, fmt.Errorf("failed to parse image tar for %s: %w", ref, err)
214 }237 }
215- return img, nil238+ cleanup := func() { _ = os.Remove(tmpPath) }
239+ return img, cleanup, nil
216 }240 }
217 241
218 func (p *Pusher) uploadImageLayers(ctx context.Context, img v1.Image, baseURL, repo, auth string) error {242 func (p *Pusher) uploadImageLayers(ctx context.Context, img v1.Image, baseURL, repo, auth string) error {
M pkg/registrypush/push_test.go +25 -0
@@ -217,6 +217,31 @@ func TestPusher_Push(t *testing.T) {
217217 }
218218 }
219219
220+func TestPusher_Push_InvokesCleanup(t *testing.T) {
221+ var cleanupCalled bool
222+ img, err := random.Image(1024, 1)
223+ require.NoError(t, err)
224+
225+ registry := httptest.NewServer(newFakePushRegistry(t, img, map[string]bool{}).handler())
226+ defer registry.Close()
227+
228+ p := registrypush.New(
229+ registrypush.WithChunkSize(1024),
230+ registrypush.WithImageSource(func(ctx context.Context, ref string) (v1.Image, error) {
231+ return img, nil
232+ }),
233+ registrypush.WithImageCleanup(func() {
234+ cleanupCalled = true
235+ }),
236+ )
237+
238+ registryHost := strings.TrimPrefix(registry.URL, "http://")
239+ err = p.Push(context.Background(), registryHost+"/demo/app:v1")
240+
241+ require.NoError(t, err)
242+ assert.True(t, cleanupCalled, "cleanup callback should have been invoked after push")
243+}
244+
220245 func TestPusher_WithInsecureTLS_AllowsSelfSignedCertificate(t *testing.T) {
221246 t.Parallel()
222247
@@ -217,6 +217,31 @@ func TestPusher_Push(t *testing.T) {
217 }217 }
218 }218 }
219 219
220+func TestPusher_Push_InvokesCleanup(t *testing.T) {
221+ var cleanupCalled bool
222+ img, err := random.Image(1024, 1)
223+ require.NoError(t, err)
224+
225+ registry := httptest.NewServer(newFakePushRegistry(t, img, map[string]bool{}).handler())
226+ defer registry.Close()
227+
228+ p := registrypush.New(
229+ registrypush.WithChunkSize(1024),
230+ registrypush.WithImageSource(func(ctx context.Context, ref string) (v1.Image, error) {
231+ return img, nil
232+ }),
233+ registrypush.WithImageCleanup(func() {
234+ cleanupCalled = true
235+ }),
236+ )
237+
238+ registryHost := strings.TrimPrefix(registry.URL, "http://")
239+ err = p.Push(context.Background(), registryHost+"/demo/app:v1")
240+
241+ require.NoError(t, err)
242+ assert.True(t, cleanupCalled, "cleanup callback should have been invoked after push")
243+}
244+
220 func TestPusher_WithInsecureTLS_AllowsSelfSignedCertificate(t *testing.T) {245 func TestPusher_WithInsecureTLS_AllowsSelfSignedCertificate(t *testing.T) {
221 t.Parallel()246 t.Parallel()
222 247

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