Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,3 +60,35 @@ wget --tries=5 --waitretry=3 -q -O - "https://dockerimagesave.akiel.dev/image?na
```bash
wget -c --tries=5 --waitretry=3 --content-disposition "https://dockerimagesave.akiel.dev/image?name=ubuntu:25.04" && docker load -i ubuntu_25_04.tar
```

#### Selecting a specific architecture

By default the service downloads `linux/amd64`. Use the `os` and `arch` query parameters to select a different platform:

```bash
# Download linux/arm64
wget -c --tries=5 --waitretry=3 --content-disposition \
"https://dockerimagesave.akiel.dev/image?name=ubuntu:25.04&os=linux&arch=arm64"

# Download linux/arm/v7 (32-bit ARM)
wget -c --tries=5 --waitretry=3 --content-disposition \
"https://dockerimagesave.akiel.dev/image?name=ubuntu:25.04&os=linux&arch=arm&variant=v7"
```

#### Listing available platforms for an image

```bash
curl "https://dockerimagesave.akiel.dev/platforms?name=ubuntu:25.04"
```

Returns JSON like:

```json
{
"platforms": [
{"os": "linux", "architecture": "amd64"},
{"os": "linux", "architecture": "arm64"},
{"os": "linux", "architecture": "arm", "variant": "v7"}
]
}
```
26 changes: 19 additions & 7 deletions cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"log"
"os"
"path/filepath"
"strings"
"time"
)

Expand Down Expand Up @@ -86,16 +87,27 @@ func (c *CacheManager) PerformCleanup() {
}

// GetCachePath returns the full path for a cached image
func (c *CacheManager) GetCachePath(imageName string) string {
return filepath.Join(c.dir, c.GetCacheFilename(imageName))
func (c *CacheManager) GetCachePath(imageName string, platform Platform) string {
return filepath.Join(c.dir, c.GetCacheFilename(imageName, platform))
}

// GetCacheFilename generates a safe filename for caching
func (c *CacheManager) GetCacheFilename(imageName string) string {
ref := ParseImageReference(imageName)
safeImageName := sanitizeFilenameComponent(ref.Repository)
safeTag := sanitizeFilenameComponent(ref.Tag)
return fmt.Sprintf("%s_%s.tar.gz", safeImageName, safeTag)
func (c *CacheManager) GetCacheFilename(imageName string, platform Platform) string {
return imageFilename(ParseImageReference(imageName), platform)
}

// imageFilename builds the platform-qualified tar filename for an image reference.
func imageFilename(ref ImageReference, platform Platform) string {
parts := []string{
sanitizeFilenameComponent(ref.Repository),
sanitizeFilenameComponent(ref.Tag),
sanitizeFilenameComponent(platform.OS),
sanitizeFilenameComponent(platform.Architecture),
}
if platform.Variant != "" {
parts = append(parts, sanitizeFilenameComponent(platform.Variant))
}
return strings.Join(parts, "_") + ".tar.gz"
}

// Dir returns the cache directory path
Expand Down
17 changes: 13 additions & 4 deletions cache_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,25 +59,34 @@ func TestGetCacheFilename(t *testing.T) {

tests := []struct {
imageName string
platform Platform
expected string
}{
{
imageName: "alpine:latest",
expected: "library_alpine_latest.tar.gz",
platform: Platform{OS: "linux", Architecture: "amd64"},
expected: "library_alpine_latest_linux_amd64.tar.gz",
},
{
imageName: "library/ubuntu:20.04",
expected: "library_ubuntu_20.04.tar.gz",
platform: Platform{OS: "linux", Architecture: "arm64"},
expected: "library_ubuntu_20.04_linux_arm64.tar.gz",
},
{
imageName: "ghcr.io/username/repo:v1.2.3",
expected: "username_repo_v1.2.3.tar.gz",
platform: Platform{OS: "linux", Architecture: "amd64"},
expected: "username_repo_v1.2.3_linux_amd64.tar.gz",
},
{
imageName: "alpine:latest",
platform: Platform{OS: "linux", Architecture: "arm", Variant: "v7"},
expected: "library_alpine_latest_linux_arm_v7.tar.gz",
},
}

for _, tt := range tests {
t.Run(tt.imageName, func(t *testing.T) {
got := cache.GetCacheFilename(tt.imageName)
got := cache.GetCacheFilename(tt.imageName, tt.platform)
if got != tt.expected {
t.Errorf("GetCacheFilename(%q) = %q, want %q", tt.imageName, got, tt.expected)
}
Expand Down
71 changes: 45 additions & 26 deletions image.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,10 @@ func authenticateClient(ref ImageReference) (*RegistryClient, error) {
return client, nil
}

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

layerJSONBytes, err := json.Marshal(layerJSON)
if err != nil {
return fmt.Errorf("failed to marshal layer JSON: %w", err)
}
return os.WriteFile(filepath.Join(layerDir, "json"), layerJSONBytes, 0644)
return marshalJSONToFile(layerJSON, layerDir, "json")
}

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

manifestJSONBytes, err := json.Marshal(manifestJSON)
if err != nil {
return fmt.Errorf("failed to marshal manifest JSON: %w", err)
}
return os.WriteFile(filepath.Join(tempDir, "manifest.json"), manifestJSONBytes, 0644)
return marshalJSONToFile(manifestJSON, tempDir, "manifest.json")
}

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

reposBytes, err := json.Marshal(repositories)
if err != nil {
return fmt.Errorf("failed to marshal repositories JSON: %w", err)
}
return os.WriteFile(filepath.Join(tempDir, "repositories"), reposBytes, 0644)
return marshalJSONToFile(repositories, tempDir, "repositories")
}

// createOutputTar creates the final tar archive
func createOutputTar(ref ImageReference, tempDir, outputDir string) (string, error) {
func createOutputTar(ref ImageReference, tempDir, outputDir string, platform Platform) (string, error) {
if err := os.MkdirAll(outputDir, 0755); err != nil {
return "", err
}

safeImageName := sanitizeFilenameComponent(ref.Repository)
safeTag := sanitizeFilenameComponent(ref.Tag)
outputPath := filepath.Join(outputDir, fmt.Sprintf("%s_%s.tar.gz", safeImageName, safeTag))
outputPath := filepath.Join(outputDir, imageFilename(ref, platform))

// Defense-in-depth: confirm the assembled path stays within the output directory.
cleanOut := filepath.Clean(outputDir)
cleanPath := filepath.Clean(outputPath)
if !strings.HasPrefix(cleanPath, cleanOut+string(filepath.Separator)) {
return "", fmt.Errorf("output path escapes cache directory: %s", cleanPath)
}

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

// DownloadImage downloads a Docker image and saves it as a tar file
func DownloadImage(imageRef string, outputDir string) (string, error) {
func DownloadImage(imageRef string, outputDir string, platform Platform) (string, error) {
ref := ParseImageReference(imageRef)

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

manifest, err := fetchManifest(client, ref)
manifest, err := fetchManifest(client, ref, platform)
if err != nil {
return "", err
}
Expand Down Expand Up @@ -230,5 +223,31 @@ func DownloadImage(imageRef string, outputDir string) (string, error) {
return "", err
}

return createOutputTar(ref, tempDir, outputDir)
return createOutputTar(ref, tempDir, outputDir, platform)
}

// GetImagePlatforms returns the available platforms for a multi-arch image.
// Returns nil, nil if the image is single-arch.
func GetImagePlatforms(imageRef string) ([]Platform, error) {
ref := ParseImageReference(imageRef)

if err := ValidateImageReference(ref); err != nil {
return nil, fmt.Errorf("invalid image reference: %w", err)
}

client, err := authenticateClient(ref)
if err != nil {
return nil, err
}

return client.GetPlatforms(ref)
}

// marshalJSONToFile marshals v to JSON and writes it to dir/filename.
func marshalJSONToFile(v interface{}, dir, filename string) error {
data, err := json.Marshal(v)
if err != nil {
return fmt.Errorf("failed to marshal %s: %w", filename, err)
}
return os.WriteFile(filepath.Join(dir, filename), data, 0644)
}
91 changes: 88 additions & 3 deletions image_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -219,7 +219,7 @@ func TestDownloadImage_PublicImage(t *testing.T) {
}
defer cleanupTempDir(t, outputDir)

imagePath, err := DownloadImage("alpine:latest", outputDir)
imagePath, err := DownloadImage("alpine:latest", outputDir, DefaultPlatform())
if err != nil {
t.Fatalf("DownloadImage failed: %v", err)
}
Expand Down Expand Up @@ -248,7 +248,7 @@ func TestDownloadImage_WithAuthentication(t *testing.T) {
}
defer cleanupTempDir(t, outputDir)

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

_, err = DownloadImage("thisimagedoesnotexist12345:nonexistenttag", outputDir)
_, err = DownloadImage("thisimagedoesnotexist12345:nonexistenttag", outputDir, DefaultPlatform())
if err == nil {
t.Error("expected error for non-existent image")
}
}

func TestDownloadImage_NonExistentPlatform(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test")
}

tests := []struct {
name string
image string
platform Platform
}{
{
name: "windows image on unsupported arch",
image: "alpine:latest",
platform: Platform{OS: "windows", Architecture: "amd64"},
},
{
name: "nonexistent OS",
image: "alpine:latest",
platform: Platform{OS: "solaris", Architecture: "amd64"},
},
{
name: "nonexistent arch",
image: "alpine:latest",
platform: Platform{OS: "linux", Architecture: "mips64"},
},
{
name: "nonexistent variant",
image: "alpine:latest",
platform: Platform{OS: "linux", Architecture: "arm", Variant: "v5"},
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
outputDir, err := os.MkdirTemp("", "test-download-badplatform-*")
if err != nil {
t.Fatal(err)
}
defer cleanupTempDir(t, outputDir)

_, err = DownloadImage(tt.image, outputDir, tt.platform)
if err == nil {
t.Errorf("expected error for unsupported platform %s/%s/%s", tt.platform.OS, tt.platform.Architecture, tt.platform.Variant)
}
})
}
}

func TestGetImagePlatforms_MultiArch(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test")
}

platforms, err := GetImagePlatforms("ubuntu:latest")
if err != nil {
t.Fatalf("GetImagePlatforms failed: %v", err)
}
if len(platforms) == 0 {
t.Fatal("expected at least one platform for ubuntu:latest, got none")
}

// ubuntu:latest is a well-known multi-arch image; linux/amd64 must be present
found := false
for _, p := range platforms {
if p.OS == "linux" && p.Architecture == "amd64" {
found = true
break
}
}
if !found {
t.Errorf("expected linux/amd64 in platforms, got: %+v", platforms)
}
}

func TestGetImagePlatforms_InvalidImage(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test")
}

_, err := GetImagePlatforms("thisimagedoesnotexist12345:nonexistenttag")
if err == nil {
t.Error("expected error for non-existent image")
}
Expand Down
Loading
Loading