Skip to content

Commit e9f58ff

Browse files
authored
Add multiplatform support (#212)
* Add multiplatform support * Add platform variant support to cache and output filenames and safely populate dropdowns * Add tests for multi-arch platform support * Refactor registry validation to extract blocked host and IP obfuscation checks * Add error handling for JSON encoding in registry test * Add integration test for downloading images with unsupported platforms * Refactor platform formatting and filename generation, and show download size - Add `String()` method to `Platform` for cleaner logging - Extract shared `imageFilename` function for cache and output paths - Show final downloaded size in MB on the frontend upon completion - Add unit tests for `GetImagePlatforms` - Remove redundant image reference validation in `GetPlatforms` * Refactor server handlers and add JSON helper functions * Add output path traversal protection and list available platforms in missing manifest error * Disallow slashes in platform parameter validation
1 parent 48333f8 commit e9f58ff

File tree

12 files changed

+979
-145
lines changed

12 files changed

+979
-145
lines changed

README.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,3 +60,35 @@ wget --tries=5 --waitretry=3 -q -O - "https://dockerimagesave.akiel.dev/image?na
6060
```bash
6161
wget -c --tries=5 --waitretry=3 --content-disposition "https://dockerimagesave.akiel.dev/image?name=ubuntu:25.04" && docker load -i ubuntu_25_04.tar
6262
```
63+
64+
#### Selecting a specific architecture
65+
66+
By default the service downloads `linux/amd64`. Use the `os` and `arch` query parameters to select a different platform:
67+
68+
```bash
69+
# Download linux/arm64
70+
wget -c --tries=5 --waitretry=3 --content-disposition \
71+
"https://dockerimagesave.akiel.dev/image?name=ubuntu:25.04&os=linux&arch=arm64"
72+
73+
# Download linux/arm/v7 (32-bit ARM)
74+
wget -c --tries=5 --waitretry=3 --content-disposition \
75+
"https://dockerimagesave.akiel.dev/image?name=ubuntu:25.04&os=linux&arch=arm&variant=v7"
76+
```
77+
78+
#### Listing available platforms for an image
79+
80+
```bash
81+
curl "https://dockerimagesave.akiel.dev/platforms?name=ubuntu:25.04"
82+
```
83+
84+
Returns JSON like:
85+
86+
```json
87+
{
88+
"platforms": [
89+
{"os": "linux", "architecture": "amd64"},
90+
{"os": "linux", "architecture": "arm64"},
91+
{"os": "linux", "architecture": "arm", "variant": "v7"}
92+
]
93+
}
94+
```

cache.go

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"log"
77
"os"
88
"path/filepath"
9+
"strings"
910
"time"
1011
)
1112

@@ -86,16 +87,27 @@ func (c *CacheManager) PerformCleanup() {
8687
}
8788

8889
// GetCachePath returns the full path for a cached image
89-
func (c *CacheManager) GetCachePath(imageName string) string {
90-
return filepath.Join(c.dir, c.GetCacheFilename(imageName))
90+
func (c *CacheManager) GetCachePath(imageName string, platform Platform) string {
91+
return filepath.Join(c.dir, c.GetCacheFilename(imageName, platform))
9192
}
9293

9394
// GetCacheFilename generates a safe filename for caching
94-
func (c *CacheManager) GetCacheFilename(imageName string) string {
95-
ref := ParseImageReference(imageName)
96-
safeImageName := sanitizeFilenameComponent(ref.Repository)
97-
safeTag := sanitizeFilenameComponent(ref.Tag)
98-
return fmt.Sprintf("%s_%s.tar.gz", safeImageName, safeTag)
95+
func (c *CacheManager) GetCacheFilename(imageName string, platform Platform) string {
96+
return imageFilename(ParseImageReference(imageName), platform)
97+
}
98+
99+
// imageFilename builds the platform-qualified tar filename for an image reference.
100+
func imageFilename(ref ImageReference, platform Platform) string {
101+
parts := []string{
102+
sanitizeFilenameComponent(ref.Repository),
103+
sanitizeFilenameComponent(ref.Tag),
104+
sanitizeFilenameComponent(platform.OS),
105+
sanitizeFilenameComponent(platform.Architecture),
106+
}
107+
if platform.Variant != "" {
108+
parts = append(parts, sanitizeFilenameComponent(platform.Variant))
109+
}
110+
return strings.Join(parts, "_") + ".tar.gz"
99111
}
100112

101113
// Dir returns the cache directory path

cache_test.go

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -59,25 +59,34 @@ func TestGetCacheFilename(t *testing.T) {
5959

6060
tests := []struct {
6161
imageName string
62+
platform Platform
6263
expected string
6364
}{
6465
{
6566
imageName: "alpine:latest",
66-
expected: "library_alpine_latest.tar.gz",
67+
platform: Platform{OS: "linux", Architecture: "amd64"},
68+
expected: "library_alpine_latest_linux_amd64.tar.gz",
6769
},
6870
{
6971
imageName: "library/ubuntu:20.04",
70-
expected: "library_ubuntu_20.04.tar.gz",
72+
platform: Platform{OS: "linux", Architecture: "arm64"},
73+
expected: "library_ubuntu_20.04_linux_arm64.tar.gz",
7174
},
7275
{
7376
imageName: "ghcr.io/username/repo:v1.2.3",
74-
expected: "username_repo_v1.2.3.tar.gz",
77+
platform: Platform{OS: "linux", Architecture: "amd64"},
78+
expected: "username_repo_v1.2.3_linux_amd64.tar.gz",
79+
},
80+
{
81+
imageName: "alpine:latest",
82+
platform: Platform{OS: "linux", Architecture: "arm", Variant: "v7"},
83+
expected: "library_alpine_latest_linux_arm_v7.tar.gz",
7584
},
7685
}
7786

7887
for _, tt := range tests {
7988
t.Run(tt.imageName, func(t *testing.T) {
80-
got := cache.GetCacheFilename(tt.imageName)
89+
got := cache.GetCacheFilename(tt.imageName, tt.platform)
8190
if got != tt.expected {
8291
t.Errorf("GetCacheFilename(%q) = %q, want %q", tt.imageName, got, tt.expected)
8392
}

image.go

Lines changed: 45 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,10 @@ func authenticateClient(ref ImageReference) (*RegistryClient, error) {
2424
return client, nil
2525
}
2626

27-
// fetchManifest retrieves the manifest for the image
28-
func fetchManifest(client *RegistryClient, ref ImageReference) (*ManifestV2, error) {
29-
log.Printf("Fetching manifest for %s:%s...\n", ref.Repository, ref.Tag)
30-
manifest, err := client.getManifest(ref)
27+
// fetchManifest retrieves the manifest for the image for the given platform
28+
func fetchManifest(client *RegistryClient, ref ImageReference, platform Platform) (*ManifestV2, error) {
29+
log.Printf("Fetching manifest for %s:%s (platform: %s)...\n", ref.Repository, ref.Tag, platform)
30+
manifest, err := client.getManifest(ref, platform)
3131
if err != nil {
3232
return nil, fmt.Errorf("failed to get manifest: %w", err)
3333
}
@@ -99,11 +99,7 @@ func createLayerMetadata(layerDir, diffID string, index int, imageConfig *ImageC
9999
layerJSON["parent"] = prevDiffID
100100
}
101101

102-
layerJSONBytes, err := json.Marshal(layerJSON)
103-
if err != nil {
104-
return fmt.Errorf("failed to marshal layer JSON: %w", err)
105-
}
106-
return os.WriteFile(filepath.Join(layerDir, "json"), layerJSONBytes, 0644)
102+
return marshalJSONToFile(layerJSON, layerDir, "json")
107103
}
108104

109105
// downloadAllLayers downloads all layers and returns their diff IDs
@@ -141,11 +137,7 @@ func createDockerManifest(ref ImageReference, configDigest string, layerPaths []
141137
},
142138
}
143139

144-
manifestJSONBytes, err := json.Marshal(manifestJSON)
145-
if err != nil {
146-
return fmt.Errorf("failed to marshal manifest JSON: %w", err)
147-
}
148-
return os.WriteFile(filepath.Join(tempDir, "manifest.json"), manifestJSONBytes, 0644)
140+
return marshalJSONToFile(manifestJSON, tempDir, "manifest.json")
149141
}
150142

151143
// createRepositoriesFile creates the repositories file for docker load
@@ -157,22 +149,23 @@ func createRepositoriesFile(ref ImageReference, layerPaths []string, tempDir str
157149
imageName: {ref.Tag: topLayer},
158150
}
159151

160-
reposBytes, err := json.Marshal(repositories)
161-
if err != nil {
162-
return fmt.Errorf("failed to marshal repositories JSON: %w", err)
163-
}
164-
return os.WriteFile(filepath.Join(tempDir, "repositories"), reposBytes, 0644)
152+
return marshalJSONToFile(repositories, tempDir, "repositories")
165153
}
166154

167155
// createOutputTar creates the final tar archive
168-
func createOutputTar(ref ImageReference, tempDir, outputDir string) (string, error) {
156+
func createOutputTar(ref ImageReference, tempDir, outputDir string, platform Platform) (string, error) {
169157
if err := os.MkdirAll(outputDir, 0755); err != nil {
170158
return "", err
171159
}
172160

173-
safeImageName := sanitizeFilenameComponent(ref.Repository)
174-
safeTag := sanitizeFilenameComponent(ref.Tag)
175-
outputPath := filepath.Join(outputDir, fmt.Sprintf("%s_%s.tar.gz", safeImageName, safeTag))
161+
outputPath := filepath.Join(outputDir, imageFilename(ref, platform))
162+
163+
// Defense-in-depth: confirm the assembled path stays within the output directory.
164+
cleanOut := filepath.Clean(outputDir)
165+
cleanPath := filepath.Clean(outputPath)
166+
if !strings.HasPrefix(cleanPath, cleanOut+string(filepath.Separator)) {
167+
return "", fmt.Errorf("output path escapes cache directory: %s", cleanPath)
168+
}
176169

177170
log.Println("Creating tar archive...")
178171
if err := createTar(tempDir, outputPath); err != nil {
@@ -184,7 +177,7 @@ func createOutputTar(ref ImageReference, tempDir, outputDir string) (string, err
184177
}
185178

186179
// DownloadImage downloads a Docker image and saves it as a tar file
187-
func DownloadImage(imageRef string, outputDir string) (string, error) {
180+
func DownloadImage(imageRef string, outputDir string, platform Platform) (string, error) {
188181
ref := ParseImageReference(imageRef)
189182

190183
// Validate the image reference to prevent SSRF and other attacks
@@ -197,7 +190,7 @@ func DownloadImage(imageRef string, outputDir string) (string, error) {
197190
return "", err
198191
}
199192

200-
manifest, err := fetchManifest(client, ref)
193+
manifest, err := fetchManifest(client, ref, platform)
201194
if err != nil {
202195
return "", err
203196
}
@@ -230,5 +223,31 @@ func DownloadImage(imageRef string, outputDir string) (string, error) {
230223
return "", err
231224
}
232225

233-
return createOutputTar(ref, tempDir, outputDir)
226+
return createOutputTar(ref, tempDir, outputDir, platform)
227+
}
228+
229+
// GetImagePlatforms returns the available platforms for a multi-arch image.
230+
// Returns nil, nil if the image is single-arch.
231+
func GetImagePlatforms(imageRef string) ([]Platform, error) {
232+
ref := ParseImageReference(imageRef)
233+
234+
if err := ValidateImageReference(ref); err != nil {
235+
return nil, fmt.Errorf("invalid image reference: %w", err)
236+
}
237+
238+
client, err := authenticateClient(ref)
239+
if err != nil {
240+
return nil, err
241+
}
242+
243+
return client.GetPlatforms(ref)
244+
}
245+
246+
// marshalJSONToFile marshals v to JSON and writes it to dir/filename.
247+
func marshalJSONToFile(v interface{}, dir, filename string) error {
248+
data, err := json.Marshal(v)
249+
if err != nil {
250+
return fmt.Errorf("failed to marshal %s: %w", filename, err)
251+
}
252+
return os.WriteFile(filepath.Join(dir, filename), data, 0644)
234253
}

image_test.go

Lines changed: 88 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -219,7 +219,7 @@ func TestDownloadImage_PublicImage(t *testing.T) {
219219
}
220220
defer cleanupTempDir(t, outputDir)
221221

222-
imagePath, err := DownloadImage("alpine:latest", outputDir)
222+
imagePath, err := DownloadImage("alpine:latest", outputDir, DefaultPlatform())
223223
if err != nil {
224224
t.Fatalf("DownloadImage failed: %v", err)
225225
}
@@ -248,7 +248,7 @@ func TestDownloadImage_WithAuthentication(t *testing.T) {
248248
}
249249
defer cleanupTempDir(t, outputDir)
250250

251-
imagePath, err := DownloadImage("busybox:latest", outputDir)
251+
imagePath, err := DownloadImage("busybox:latest", outputDir, DefaultPlatform())
252252
if err != nil {
253253
t.Fatalf("DownloadImage with auth failed: %v", err)
254254
}
@@ -269,7 +269,92 @@ func TestDownloadImage_NonExistentImage(t *testing.T) {
269269
}
270270
defer cleanupTempDir(t, outputDir)
271271

272-
_, err = DownloadImage("thisimagedoesnotexist12345:nonexistenttag", outputDir)
272+
_, err = DownloadImage("thisimagedoesnotexist12345:nonexistenttag", outputDir, DefaultPlatform())
273+
if err == nil {
274+
t.Error("expected error for non-existent image")
275+
}
276+
}
277+
278+
func TestDownloadImage_NonExistentPlatform(t *testing.T) {
279+
if testing.Short() {
280+
t.Skip("skipping integration test")
281+
}
282+
283+
tests := []struct {
284+
name string
285+
image string
286+
platform Platform
287+
}{
288+
{
289+
name: "windows image on unsupported arch",
290+
image: "alpine:latest",
291+
platform: Platform{OS: "windows", Architecture: "amd64"},
292+
},
293+
{
294+
name: "nonexistent OS",
295+
image: "alpine:latest",
296+
platform: Platform{OS: "solaris", Architecture: "amd64"},
297+
},
298+
{
299+
name: "nonexistent arch",
300+
image: "alpine:latest",
301+
platform: Platform{OS: "linux", Architecture: "mips64"},
302+
},
303+
{
304+
name: "nonexistent variant",
305+
image: "alpine:latest",
306+
platform: Platform{OS: "linux", Architecture: "arm", Variant: "v5"},
307+
},
308+
}
309+
310+
for _, tt := range tests {
311+
t.Run(tt.name, func(t *testing.T) {
312+
outputDir, err := os.MkdirTemp("", "test-download-badplatform-*")
313+
if err != nil {
314+
t.Fatal(err)
315+
}
316+
defer cleanupTempDir(t, outputDir)
317+
318+
_, err = DownloadImage(tt.image, outputDir, tt.platform)
319+
if err == nil {
320+
t.Errorf("expected error for unsupported platform %s/%s/%s", tt.platform.OS, tt.platform.Architecture, tt.platform.Variant)
321+
}
322+
})
323+
}
324+
}
325+
326+
func TestGetImagePlatforms_MultiArch(t *testing.T) {
327+
if testing.Short() {
328+
t.Skip("skipping integration test")
329+
}
330+
331+
platforms, err := GetImagePlatforms("ubuntu:latest")
332+
if err != nil {
333+
t.Fatalf("GetImagePlatforms failed: %v", err)
334+
}
335+
if len(platforms) == 0 {
336+
t.Fatal("expected at least one platform for ubuntu:latest, got none")
337+
}
338+
339+
// ubuntu:latest is a well-known multi-arch image; linux/amd64 must be present
340+
found := false
341+
for _, p := range platforms {
342+
if p.OS == "linux" && p.Architecture == "amd64" {
343+
found = true
344+
break
345+
}
346+
}
347+
if !found {
348+
t.Errorf("expected linux/amd64 in platforms, got: %+v", platforms)
349+
}
350+
}
351+
352+
func TestGetImagePlatforms_InvalidImage(t *testing.T) {
353+
if testing.Short() {
354+
t.Skip("skipping integration test")
355+
}
356+
357+
_, err := GetImagePlatforms("thisimagedoesnotexist12345:nonexistenttag")
273358
if err == nil {
274359
t.Error("expected error for non-existent image")
275360
}

0 commit comments

Comments
 (0)