From 24ba68b387c8e2b82832aeeb49a7392af9a4a0a1 Mon Sep 17 00:00:00 2001 From: Sergio Triana Escobedo Date: Thu, 8 Jan 2026 10:37:32 -0600 Subject: [PATCH 01/15] Add multi-platform support to image handling and validation --- server.go | 60 +++++++++++++---- server_test.go | 174 +++++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 217 insertions(+), 17 deletions(-) diff --git a/server.go b/server.go index 4051b12..088be0d 100644 --- a/server.go +++ b/server.go @@ -116,18 +116,30 @@ func (s *Server) imageHandler(w http.ResponseWriter, r *http.Request) { return } - cacheFilename := s.getCacheFilename(imageName) + // Parse platform parameter (e.g., "linux/amd64", "linux/arm64") + // URL-encoded slashes (%2F) are automatically decoded by Go's URL parser + platform := r.URL.Query().Get("platform") + if platform != "" { + if err := validatePlatform(platform); err != nil { + writeJSONError(w, fmt.Sprintf("invalid platform: %v", err), http.StatusBadRequest) + return + } + } + + // Create a unique cache key combining image name and platform + cacheKey := imageName + ":" + platform + cacheFilename := s.getCacheFilename(imageName, platform) cachePath := filepath.Join(s.cacheDir, cacheFilename) if _, err := os.Stat(cachePath); err == nil { - log.Printf("Serving cached image: %s\n", imageName) - s.serveImageFile(w, r, cachePath, imageName) + log.Printf("Serving cached image: %s (platform: %s)\n", imageName, platform) + s.serveImageFile(w, r, cachePath, imageName, platform) return } - log.Printf("Downloading image: %s\n", imageName) - result, err, _ := s.downloadGroup.Do(imageName, func() (interface{}, error) { - return DownloadImage(imageName, s.cacheDir) + log.Printf("Downloading image: %s (platform: %s)\n", imageName, platform) + result, err, _ := s.downloadGroup.Do(cacheKey, func() (interface{}, error) { + return DownloadImage(imageName, s.cacheDir, platform) }) if err != nil { log.Printf("Failed to download image %s: %v\n", imageName, err) @@ -137,7 +149,31 @@ func (s *Server) imageHandler(w http.ResponseWriter, r *http.Request) { } imagePath := result.(string) - s.serveImageFile(w, r, imagePath, imageName) + s.serveImageFile(w, r, imagePath, imageName, platform) +} + +// validatePlatform validates the platform string format +func validatePlatform(platform string) error { + parts := strings.Split(platform, "/") + if len(parts) != 2 { + return fmt.Errorf("platform must be in format 'os/architecture' (e.g., 'linux/amd64')") + } + os := parts[0] + arch := parts[1] + + // Validate OS + validOS := map[string]bool{"linux": true, "windows": true, "darwin": true} + if !validOS[os] { + return fmt.Errorf("unsupported OS '%s', valid options: linux, windows, darwin", os) + } + + // Validate architecture + validArch := map[string]bool{"amd64": true, "arm64": true, "arm": true, "386": true, "ppc64le": true, "s390x": true, "riscv64": true} + if !validArch[arch] { + return fmt.Errorf("unsupported architecture '%s', valid options: amd64, arm64, arm, 386, ppc64le, s390x, riscv64", arch) + } + + return nil } var imageNamePattern = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._\-/:]*$`) @@ -166,14 +202,16 @@ func sanitizeImageName(imageName string) (string, error) { // getCacheFilename generates a safe filename for caching // This must match the filename format used by createOutputTar in image.go -func (s *Server) getCacheFilename(imageName string) string { +func (s *Server) getCacheFilename(imageName string, platform string) string { ref := ParseImageReference(imageName) + ref.Platform = ParsePlatform(platform) safeImageName := strings.ReplaceAll(ref.Repository, "/", "_") - return fmt.Sprintf("%s_%s.tar.gz", safeImageName, ref.Tag) + safePlatform := strings.ReplaceAll(ref.Platform.String(), "/", "_") + return fmt.Sprintf("%s_%s_%s.tar.gz", safeImageName, ref.Tag, safePlatform) } // serveImageFile streams an image tar file to the response with Range request support -func (s *Server) serveImageFile(w http.ResponseWriter, r *http.Request, imagePath, imageName string) { +func (s *Server) serveImageFile(w http.ResponseWriter, r *http.Request, imagePath, imageName string, platform string) { file, err := os.Open(imagePath) if err != nil { log.Printf("Failed to open image file: %v\n", err) @@ -196,7 +234,7 @@ func (s *Server) serveImageFile(w http.ResponseWriter, r *http.Request, imagePat return } - filename := s.getCacheFilename(imageName) + filename := s.getCacheFilename(imageName, platform) w.Header().Set(contentTypeHeader, "application/gzip") w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename)) diff --git a/server_test.go b/server_test.go index fde1dc1..458d246 100644 --- a/server_test.go +++ b/server_test.go @@ -106,7 +106,7 @@ func TestServeImageFile_RangeRequest(t *testing.T) { req.Header.Set("Range", "bytes=0-9") w := httptest.NewRecorder() - server.serveImageFile(w, req, testFile, "test:image") + server.serveImageFile(w, req, testFile, "test:image", "") resp := w.Result() if resp.StatusCode != http.StatusPartialContent { @@ -130,7 +130,7 @@ func TestServeImageFile_RangeRequest(t *testing.T) { req.Header.Set("Range", "bytes=10-19") w := httptest.NewRecorder() - server.serveImageFile(w, req, testFile, "test:image") + server.serveImageFile(w, req, testFile, "test:image", "") resp := w.Result() if resp.StatusCode != http.StatusPartialContent { @@ -155,13 +155,13 @@ func TestServeImageFile_RangeRequest(t *testing.T) { req1 := httptest.NewRequest(http.MethodGet, "/image", nil) req1.Header.Set("Range", "bytes=0-9") w1 := httptest.NewRecorder() - server.serveImageFile(w1, req1, testFile, "test:image") + server.serveImageFile(w1, req1, testFile, "test:image", "") combined.Write(w1.Body.Bytes()) req2 := httptest.NewRequest(http.MethodGet, "/image", nil) req2.Header.Set("Range", "bytes=10-") w2 := httptest.NewRecorder() - server.serveImageFile(w2, req2, testFile, "test:image") + server.serveImageFile(w2, req2, testFile, "test:image", "") combined.Write(w2.Body.Bytes()) if !bytes.Equal(combined.Bytes(), testContent) { @@ -174,7 +174,7 @@ func TestServeImageFile_RangeRequest(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/image", nil) w := httptest.NewRecorder() - server.serveImageFile(w, req, testFile, "test:image") + server.serveImageFile(w, req, testFile, "test:image", "") resp := w.Result() if resp.StatusCode != http.StatusOK { @@ -212,7 +212,7 @@ func TestServeImageFile_InvalidRange(t *testing.T) { req.Header.Set("Range", "bytes=100-200") w := httptest.NewRecorder() - server.serveImageFile(w, req, testFile, "test:image") + server.serveImageFile(w, req, testFile, "test:image", "") resp := w.Result() if resp.StatusCode != http.StatusRequestedRangeNotSatisfiable { @@ -220,4 +220,166 @@ func TestServeImageFile_InvalidRange(t *testing.T) { } } +func TestImageHandler_WithPlatform(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } + + server := NewServer(":8080", "") + + // Test with linux/amd64 platform + req := httptest.NewRequest(http.MethodGet, "/image?name=alpine:latest&platform=linux/amd64", nil) + w := httptest.NewRecorder() + + server.imageHandler(w, req) + + resp := w.Result() + if resp.StatusCode != http.StatusOK { + t.Errorf("expected status 200, got %d", resp.StatusCode) + } + + contentType := resp.Header.Get("Content-Type") + if contentType != "application/gzip" { + t.Errorf("expected Content-Type 'application/gzip', got '%s'", contentType) + } +} + +func TestImageHandler_WithURLEncodedPlatform(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } + + server := NewServer(":8080", "") + + // Test with URL-encoded platform (linux%2Famd64) + req := httptest.NewRequest(http.MethodGet, "/image?name=alpine:latest&platform=linux%2Famd64", nil) + w := httptest.NewRecorder() + + server.imageHandler(w, req) + + resp := w.Result() + if resp.StatusCode != http.StatusOK { + t.Errorf("expected status 200, got %d", resp.StatusCode) + } +} + +func TestImageHandler_InvalidPlatform(t *testing.T) { + server := NewServer(":8080", "") + + tests := []struct { + name string + platform string + }{ + {"invalid format", "invalid"}, + {"unsupported OS", "bsd/amd64"}, + {"unsupported arch", "linux/mips"}, + {"empty parts", "/amd64"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/image?name=alpine:latest&platform="+tt.platform, nil) + w := httptest.NewRecorder() + + server.imageHandler(w, req) + + resp := w.Result() + if resp.StatusCode != http.StatusBadRequest { + t.Errorf("expected status 400, got %d", resp.StatusCode) + } + + var body map[string]string + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + + if body["error"] == "" { + t.Error("expected error message in response") + } + }) + } +} + +func TestValidatePlatform(t *testing.T) { + tests := []struct { + name string + platform string + expectErr bool + }{ + {"linux/amd64", "linux/amd64", false}, + {"linux/arm64", "linux/arm64", false}, + {"linux/arm", "linux/arm", false}, + {"linux/386", "linux/386", false}, + {"linux/ppc64le", "linux/ppc64le", false}, + {"linux/s390x", "linux/s390x", false}, + {"linux/riscv64", "linux/riscv64", false}, + {"windows/amd64", "windows/amd64", false}, + {"darwin/amd64", "darwin/amd64", false}, + {"darwin/arm64", "darwin/arm64", false}, + {"invalid format", "invalid", true}, + {"unsupported OS", "bsd/amd64", true}, + {"unsupported arch", "linux/mips", true}, + {"too many parts", "linux/amd64/v2", true}, + {"empty OS", "/amd64", true}, + {"empty arch", "linux/", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validatePlatform(tt.platform) + if tt.expectErr && err == nil { + t.Errorf("expected error for platform '%s', got nil", tt.platform) + } + if !tt.expectErr && err != nil { + t.Errorf("unexpected error for platform '%s': %v", tt.platform, err) + } + }) + } +} + +func TestGetCacheFilename_WithPlatform(t *testing.T) { + server := NewServerWithCache(":8080", "") + + tests := []struct { + name string + imageName string + platform string + expected string + }{ + { + name: "default platform", + imageName: "alpine:latest", + platform: "", + expected: "library_alpine_latest_linux_amd64.tar.gz", + }, + { + name: "linux/amd64", + imageName: "alpine:latest", + platform: "linux/amd64", + expected: "library_alpine_latest_linux_amd64.tar.gz", + }, + { + name: "linux/arm64", + imageName: "alpine:latest", + platform: "linux/arm64", + expected: "library_alpine_latest_linux_arm64.tar.gz", + }, + { + name: "custom registry with platform", + imageName: "gcr.io/myproject/myimage:v1.0", + platform: "linux/arm64", + expected: "myproject_myimage_v1.0_linux_arm64.tar.gz", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := server.getCacheFilename(tt.imageName, tt.platform) + if result != tt.expected { + t.Errorf("expected '%s', got '%s'", tt.expected, result) + } + }) + } +} + var _ = fmt.Sprintf From 650f279d6edd7cdda2b77daa211209a2d3d64a42 Mon Sep 17 00:00:00 2001 From: Sergio Triana Escobedo Date: Thu, 8 Jan 2026 10:38:28 -0600 Subject: [PATCH 02/15] Add platform support to DownloadImage and createOutputTar functions --- image.go | 7 ++- image_test.go | 147 ++++++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 149 insertions(+), 5 deletions(-) diff --git a/image.go b/image.go index 91ceb4c..316f88f 100644 --- a/image.go +++ b/image.go @@ -162,7 +162,8 @@ func createOutputTar(ref ImageReference, tempDir, outputDir string) (string, err } safeImageName := strings.ReplaceAll(ref.Repository, "/", "_") - outputPath := filepath.Join(outputDir, fmt.Sprintf("%s_%s.tar.gz", safeImageName, ref.Tag)) + safePlatform := strings.ReplaceAll(ref.Platform.String(), "/", "_") + outputPath := filepath.Join(outputDir, fmt.Sprintf("%s_%s_%s.tar.gz", safeImageName, ref.Tag, safePlatform)) log.Println("Creating tar archive...") if err := createTar(tempDir, outputPath); err != nil { @@ -174,8 +175,10 @@ 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) { +// platform should be in format "os/architecture" (e.g., "linux/amd64", "linux/arm64") +func DownloadImage(imageRef string, outputDir string, platform string) (string, error) { ref := ParseImageReference(imageRef) + ref.Platform = ParsePlatform(platform) client, err := authenticateClient(ref) if err != nil { diff --git a/image_test.go b/image_test.go index 5a5b398..fdeb515 100644 --- a/image_test.go +++ b/image_test.go @@ -4,6 +4,7 @@ import ( "encoding/json" "os" "path/filepath" + "strings" "testing" ) @@ -219,7 +220,7 @@ func TestDownloadImage_PublicImage(t *testing.T) { } defer cleanupTempDir(t, outputDir) - imagePath, err := DownloadImage("alpine:latest", outputDir) + imagePath, err := DownloadImage("alpine:latest", outputDir, "") if err != nil { t.Fatalf("DownloadImage failed: %v", err) } @@ -248,7 +249,7 @@ func TestDownloadImage_WithAuthentication(t *testing.T) { } defer cleanupTempDir(t, outputDir) - imagePath, err := DownloadImage("busybox:latest", outputDir) + imagePath, err := DownloadImage("busybox:latest", outputDir, "") if err != nil { t.Fatalf("DownloadImage with auth failed: %v", err) } @@ -269,8 +270,148 @@ func TestDownloadImage_NonExistentImage(t *testing.T) { } defer cleanupTempDir(t, outputDir) - _, err = DownloadImage("thisimagedoesnotexist12345:nonexistenttag", outputDir) + _, err = DownloadImage("thisimagedoesnotexist12345:nonexistenttag", outputDir, "") if err == nil { t.Error("expected error for non-existent image") } } + +func TestDownloadImage_WithPlatform(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } + + tests := []struct { + name string + image string + platform string + }{ + { + name: "linux/amd64", + image: "alpine:latest", + platform: "linux/amd64", + }, + { + name: "linux/arm64", + image: "alpine:latest", + platform: "linux/arm64", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + outputDir, err := os.MkdirTemp("", "test-download-platform-*") + if err != nil { + t.Fatal(err) + } + defer cleanupTempDir(t, outputDir) + + imagePath, err := DownloadImage(tt.image, outputDir, tt.platform) + if err != nil { + t.Fatalf("DownloadImage with platform %s failed: %v", tt.platform, err) + } + + if _, err := os.Stat(imagePath); os.IsNotExist(err) { + t.Errorf("expected image file to exist at %s", imagePath) + } + + info, err := os.Stat(imagePath) + if err != nil { + t.Fatal(err) + } + if info.Size() == 0 { + t.Error("expected non-zero file size") + } + + // Verify filename includes platform + expectedSuffix := "_linux_" + tt.platform[6:] + ".tar.gz" + if !strings.HasSuffix(imagePath, expectedSuffix) { + t.Errorf("expected filename to end with '%s', got '%s'", expectedSuffix, imagePath) + } + }) + } +} + +func TestDownloadImage_UnsupportedPlatform(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } + + outputDir, err := os.MkdirTemp("", "test-download-unsupported-platform-*") + if err != nil { + t.Fatal(err) + } + defer cleanupTempDir(t, outputDir) + + // Try to download with a platform that doesn't exist for this image + _, err = DownloadImage("alpine:latest", outputDir, "windows/arm64") + if err == nil { + t.Error("expected error for unsupported platform") + } +} + +func TestCreateOutputTar_IncludesPlatformInFilename(t *testing.T) { + tempDir, err := os.MkdirTemp("", "test-output-tar-platform-*") + if err != nil { + t.Fatal(err) + } + defer cleanupTempDir(t, tempDir) + + outputDir, err := os.MkdirTemp("", "test-output-dir-platform-*") + if err != nil { + t.Fatal(err) + } + defer cleanupTempDir(t, outputDir) + + // Create a minimal file structure for tar + if err := os.WriteFile(filepath.Join(tempDir, "test.txt"), []byte("test"), 0644); err != nil { + t.Fatal(err) + } + + tests := []struct { + name string + ref ImageReference + expected string + }{ + { + name: "linux_amd64 platform", + ref: ImageReference{ + Registry: "registry-1.docker.io", + Repository: "library/alpine", + Tag: "latest", + Platform: Platform{OS: "linux", Architecture: "amd64"}, + }, + expected: "library_alpine_latest_linux_amd64.tar.gz", + }, + { + name: "linux_arm64 platform", + ref: ImageReference{ + Registry: "registry-1.docker.io", + Repository: "library/alpine", + Tag: "3.18", + Platform: Platform{OS: "linux", Architecture: "arm64"}, + }, + expected: "library_alpine_3.18_linux_arm64.tar.gz", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + testOutputDir, err := os.MkdirTemp("", "test-output-*") + if err != nil { + t.Fatal(err) + } + defer cleanupTempDir(t, testOutputDir) + + outputPath, err := createOutputTar(tt.ref, tempDir, testOutputDir) + if err != nil { + t.Fatalf("createOutputTar failed: %v", err) + } + + expectedPath := filepath.Join(testOutputDir, tt.expected) + if outputPath != expectedPath { + t.Errorf("expected path '%s', got '%s'", expectedPath, outputPath) + } + }) + } +} From 5ebe13a3ff27083baff031729a33a31b1ffaa6f7 Mon Sep 17 00:00:00 2001 From: Sergio Triana Escobedo Date: Thu, 8 Jan 2026 10:39:18 -0600 Subject: [PATCH 03/15] Add platform support and related tests for image handling --- registry.go | 48 +++++++++++++++++++-- registry_test.go | 107 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 151 insertions(+), 4 deletions(-) diff --git a/registry.go b/registry.go index 475e617..1138712 100644 --- a/registry.go +++ b/registry.go @@ -14,11 +14,40 @@ import ( const bearerPrefix = "Bearer " const responseBodyStr = "response body" +// Platform represents a target platform for docker images +type Platform struct { + OS string + Architecture string +} + +// DefaultPlatform returns the default platform (linux/amd64) +func DefaultPlatform() Platform { + return Platform{OS: "linux", Architecture: "amd64"} +} + +// String returns the platform in os/architecture format +func (p Platform) String() string { + return p.OS + "/" + p.Architecture +} + +// ParsePlatform parses a platform string like "linux/amd64" into a Platform struct +func ParsePlatform(platform string) Platform { + if platform == "" { + return DefaultPlatform() + } + parts := strings.Split(platform, "/") + if len(parts) != 2 { + return DefaultPlatform() + } + return Platform{OS: parts[0], Architecture: parts[1]} +} + // ImageReference represents a parsed Docker image reference type ImageReference struct { Registry string Repository string Tag string + Platform Platform } // RegistryClient handles communication with Docker registries @@ -80,6 +109,7 @@ func ParseImageReference(ref string) ImageReference { result := ImageReference{ Registry: "registry-1.docker.io", Tag: "latest", + Platform: DefaultPlatform(), } if idx := strings.LastIndex(ref, ":"); idx != -1 && !strings.Contains(ref[idx:], "/") { @@ -214,17 +244,27 @@ func isManifestList(contentType string) bool { return strings.Contains(contentType, "manifest.list") || strings.Contains(contentType, "image.index") } -// selectManifestDigest selects the best manifest from a manifest list, preferring linux/amd64 +// selectManifestDigest selects the best manifest from a manifest list based on the specified platform func (c *RegistryClient) selectManifestDigest(ref ImageReference, list *ManifestList) (*ManifestV2, error) { + targetOS := ref.Platform.OS + targetArch := ref.Platform.Architecture + + // First, try to find exact match for the requested platform for _, m := range list.Manifests { - if m.Platform.OS == "linux" && m.Platform.Architecture == "amd64" { + if m.Platform.OS == targetOS && m.Platform.Architecture == targetArch { return c.getManifestByDigest(ref, m.Digest) } } + + // If no exact match, return error with available platforms if len(list.Manifests) > 0 { - return c.getManifestByDigest(ref, list.Manifests[0].Digest) + available := make([]string, 0, len(list.Manifests)) + for _, m := range list.Manifests { + available = append(available, m.Platform.OS+"/"+m.Platform.Architecture) + } + return nil, fmt.Errorf("platform %s/%s not found, available platforms: %v", targetOS, targetArch, available) } - return nil, fmt.Errorf("no suitable manifest found") + return nil, fmt.Errorf("no suitable manifest found for platform %s/%s", targetOS, targetArch) } // parseManifestResponse parses the manifest response body based on content type diff --git a/registry_test.go b/registry_test.go index d5bab1d..0d1f4cf 100644 --- a/registry_test.go +++ b/registry_test.go @@ -189,3 +189,110 @@ func TestRegistryClient_GetManifest_Mock(t *testing.T) { t.Errorf("expected status 200, got %d", resp.StatusCode) } } + +func TestParsePlatform(t *testing.T) { + tests := []struct { + name string + input string + expected Platform + }{ + { + name: "linux/amd64", + input: "linux/amd64", + expected: Platform{OS: "linux", Architecture: "amd64"}, + }, + { + name: "linux/arm64", + input: "linux/arm64", + expected: Platform{OS: "linux", Architecture: "arm64"}, + }, + { + name: "windows/amd64", + input: "windows/amd64", + expected: Platform{OS: "windows", Architecture: "amd64"}, + }, + { + name: "empty string returns default", + input: "", + expected: Platform{OS: "linux", Architecture: "amd64"}, + }, + { + name: "invalid format returns default", + input: "invalid", + expected: Platform{OS: "linux", Architecture: "amd64"}, + }, + { + name: "too many parts returns default", + input: "linux/amd64/extra", + expected: Platform{OS: "linux", Architecture: "amd64"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := ParsePlatform(tt.input) + if result.OS != tt.expected.OS { + t.Errorf("expected OS '%s', got '%s'", tt.expected.OS, result.OS) + } + if result.Architecture != tt.expected.Architecture { + t.Errorf("expected Architecture '%s', got '%s'", tt.expected.Architecture, result.Architecture) + } + }) + } +} + +func TestPlatformString(t *testing.T) { + tests := []struct { + name string + platform Platform + expected string + }{ + { + name: "linux/amd64", + platform: Platform{OS: "linux", Architecture: "amd64"}, + expected: "linux/amd64", + }, + { + name: "linux/arm64", + platform: Platform{OS: "linux", Architecture: "arm64"}, + expected: "linux/arm64", + }, + { + name: "windows/amd64", + platform: Platform{OS: "windows", Architecture: "amd64"}, + expected: "windows/amd64", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := tt.platform.String() + if result != tt.expected { + t.Errorf("expected '%s', got '%s'", tt.expected, result) + } + }) + } +} + +func TestDefaultPlatform(t *testing.T) { + platform := DefaultPlatform() + + if platform.OS != "linux" { + t.Errorf("expected OS 'linux', got '%s'", platform.OS) + } + if platform.Architecture != "amd64" { + t.Errorf("expected Architecture 'amd64', got '%s'", platform.Architecture) + } +} + +func TestParseImageReference_IncludesPlatform(t *testing.T) { + ref := ParseImageReference("alpine:latest") + + // Should have default platform + if ref.Platform.OS != "linux" { + t.Errorf("expected Platform.OS 'linux', got '%s'", ref.Platform.OS) + } + if ref.Platform.Architecture != "amd64" { + t.Errorf("expected Platform.Architecture 'amd64', got '%s'", ref.Platform.Architecture) + } +} From 01db9614ba071cd86a6326049c7810f3f8b94c15 Mon Sep 17 00:00:00 2001 From: Sergio Triana Escobedo Date: Thu, 8 Jan 2026 10:40:22 -0600 Subject: [PATCH 04/15] Refactor config loading to allow optional config path and fallback to default --- main.go | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/main.go b/main.go index ff7c4b2..7f25efd 100644 --- a/main.go +++ b/main.go @@ -4,6 +4,7 @@ import ( "flag" "fmt" "log" + "os" ) func printBanner() { @@ -18,7 +19,7 @@ func printBanner() { } func main() { - configPath := flag.String("config", "config.yaml", "Path to YAML configuration file") + configPath := flag.String("config", "", "Path to YAML configuration file") flag.Parse() printBanner() @@ -26,8 +27,17 @@ func main() { var addr string var cacheDir string - if *configPath != "" { - config, err := LoadConfig(*configPath) + // Try to load config from specified path, or fall back to config.yaml if it exists + configFile := *configPath + if configFile == "" { + // Check if default config.yaml exists + if _, err := os.Stat("config.yaml"); err == nil { + configFile = "config.yaml" + } + } + + if configFile != "" { + config, err := LoadConfig(configFile) if err != nil { log.Fatalf("Failed to load config: %v", err) } @@ -36,10 +46,11 @@ func main() { cacheDir = config.CacheDir config.ApplyCredentials() - log.Printf("Loaded configuration from %s", *configPath) + log.Printf("Loaded configuration from %s", configFile) } else { addr = ":8080" cacheDir = "" + log.Println("No config file found, using defaults (port: 8080)") } server := NewServer(addr, cacheDir) From 439df9c82c3d40a909a2b385e19f6730128491bd Mon Sep 17 00:00:00 2001 From: Sergio Triana Escobedo Date: Thu, 8 Jan 2026 11:08:18 -0600 Subject: [PATCH 05/15] Normalize empty platform to default in imageHandler to prevent duplicate downloads --- server.go | 5 +++++ server_test.go | 29 +++++++++++++++++++++++++---- 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/server.go b/server.go index 088be0d..44afcd8 100644 --- a/server.go +++ b/server.go @@ -124,6 +124,11 @@ func (s *Server) imageHandler(w http.ResponseWriter, r *http.Request) { writeJSONError(w, fmt.Sprintf("invalid platform: %v", err), http.StatusBadRequest) return } + } else { + // Normalize empty platform to default to ensure consistent cache keys + // This prevents duplicate downloads when one request omits platform + // and another explicitly specifies "linux/amd64" + platform = DefaultPlatform().String() } // Create a unique cache key combining image name and platform diff --git a/server_test.go b/server_test.go index 458d246..41f63df 100644 --- a/server_test.go +++ b/server_test.go @@ -341,10 +341,10 @@ func TestGetCacheFilename_WithPlatform(t *testing.T) { server := NewServerWithCache(":8080", "") tests := []struct { - name string - imageName string - platform string - expected string + name string + imageName string + platform string + expected string }{ { name: "default platform", @@ -382,4 +382,25 @@ func TestGetCacheFilename_WithPlatform(t *testing.T) { } } +func TestImageHandler_PlatformNormalization(t *testing.T) { + // This test verifies that requests without platform and with explicit "linux/amd64" + // result in the same cache behavior (same filename) + server := NewServerWithCache(":8080", "") + + // Both should produce the same cache filename + filenameEmpty := server.getCacheFilename("alpine:latest", "") + filenameExplicit := server.getCacheFilename("alpine:latest", "linux/amd64") + + if filenameEmpty != filenameExplicit { + t.Errorf("cache filenames should match for empty and explicit linux/amd64 platform\nempty: %s\nexplicit: %s", + filenameEmpty, filenameExplicit) + } + + // Verify the normalized filename format + expected := "library_alpine_latest_linux_amd64.tar.gz" + if filenameEmpty != expected { + t.Errorf("expected filename '%s', got '%s'", expected, filenameEmpty) + } +} + var _ = fmt.Sprintf From 1dc9f7a3dc966112e305820a04ffd125ffa16cf8 Mon Sep 17 00:00:00 2001 From: Sergio Triana Escobedo Date: Thu, 8 Jan 2026 11:11:04 -0600 Subject: [PATCH 06/15] Enhance platform validation in TestDownloadImage_WithPlatform to ensure correct format for filename suffix --- image_test.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/image_test.go b/image_test.go index fdeb515..745774f 100644 --- a/image_test.go +++ b/image_test.go @@ -324,7 +324,11 @@ func TestDownloadImage_WithPlatform(t *testing.T) { } // Verify filename includes platform - expectedSuffix := "_linux_" + tt.platform[6:] + ".tar.gz" + platformParts := strings.Split(tt.platform, "/") + if len(platformParts) != 2 { + t.Fatalf("invalid platform format %q, expected /", tt.platform) + } + expectedSuffix := "_" + platformParts[0] + "_" + platformParts[1] + ".tar.gz" if !strings.HasSuffix(imagePath, expectedSuffix) { t.Errorf("expected filename to end with '%s', got '%s'", expectedSuffix, imagePath) } From 1af2318f0dc923e1b70439b4a1bc519f30659d0e Mon Sep 17 00:00:00 2001 From: Sergio Triana Escobedo Date: Thu, 8 Jan 2026 11:25:33 -0600 Subject: [PATCH 07/15] Sanitize and validate platform input in imageHandler to prevent path traversal attacks --- server.go | 28 +++++++++++++++++----------- server_test.go | 42 ++++++++++++++++++++++++------------------ 2 files changed, 41 insertions(+), 29 deletions(-) diff --git a/server.go b/server.go index 44afcd8..9e430e2 100644 --- a/server.go +++ b/server.go @@ -120,10 +120,13 @@ func (s *Server) imageHandler(w http.ResponseWriter, r *http.Request) { // URL-encoded slashes (%2F) are automatically decoded by Go's URL parser platform := r.URL.Query().Get("platform") if platform != "" { - if err := validatePlatform(platform); err != nil { + // Sanitize and validate platform - returns a safe reconstructed value + sanitized, err := sanitizePlatform(platform) + if err != nil { writeJSONError(w, fmt.Sprintf("invalid platform: %v", err), http.StatusBadRequest) return } + platform = sanitized } else { // Normalize empty platform to default to ensure consistent cache keys // This prevents duplicate downloads when one request omits platform @@ -157,28 +160,31 @@ func (s *Server) imageHandler(w http.ResponseWriter, r *http.Request) { s.serveImageFile(w, r, imagePath, imageName, platform) } -// validatePlatform validates the platform string format -func validatePlatform(platform string) error { +// validatePlatform validates the platform string format and returns a sanitized version +// This prevents path traversal attacks by reconstructing the platform from validated components +func sanitizePlatform(platform string) (string, error) { parts := strings.Split(platform, "/") if len(parts) != 2 { - return fmt.Errorf("platform must be in format 'os/architecture' (e.g., 'linux/amd64')") + return "", fmt.Errorf("platform must be in format 'os/architecture' (e.g., 'linux/amd64')") } - os := parts[0] + osName := parts[0] arch := parts[1] - // Validate OS + // Validate OS against whitelist validOS := map[string]bool{"linux": true, "windows": true, "darwin": true} - if !validOS[os] { - return fmt.Errorf("unsupported OS '%s', valid options: linux, windows, darwin", os) + if !validOS[osName] { + return "", fmt.Errorf("unsupported OS '%s', valid options: linux, windows, darwin", osName) } - // Validate architecture + // Validate architecture against whitelist validArch := map[string]bool{"amd64": true, "arm64": true, "arm": true, "386": true, "ppc64le": true, "s390x": true, "riscv64": true} if !validArch[arch] { - return fmt.Errorf("unsupported architecture '%s', valid options: amd64, arm64, arm, 386, ppc64le, s390x, riscv64", arch) + return "", fmt.Errorf("unsupported architecture '%s', valid options: amd64, arm64, arm, 386, ppc64le, s390x, riscv64", arch) } - return nil + // Return reconstructed platform from validated components (not user input) + // This ensures path safety by only using known-good values + return osName + "/" + arch, nil } var imageNamePattern = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._\-/:]*$`) diff --git a/server_test.go b/server_test.go index 41f63df..35336ab 100644 --- a/server_test.go +++ b/server_test.go @@ -300,39 +300,45 @@ func TestImageHandler_InvalidPlatform(t *testing.T) { } } -func TestValidatePlatform(t *testing.T) { +func TestSanitizePlatform(t *testing.T) { tests := []struct { name string platform string + expected string expectErr bool }{ - {"linux/amd64", "linux/amd64", false}, - {"linux/arm64", "linux/arm64", false}, - {"linux/arm", "linux/arm", false}, - {"linux/386", "linux/386", false}, - {"linux/ppc64le", "linux/ppc64le", false}, - {"linux/s390x", "linux/s390x", false}, - {"linux/riscv64", "linux/riscv64", false}, - {"windows/amd64", "windows/amd64", false}, - {"darwin/amd64", "darwin/amd64", false}, - {"darwin/arm64", "darwin/arm64", false}, - {"invalid format", "invalid", true}, - {"unsupported OS", "bsd/amd64", true}, - {"unsupported arch", "linux/mips", true}, - {"too many parts", "linux/amd64/v2", true}, - {"empty OS", "/amd64", true}, - {"empty arch", "linux/", true}, + {"linux/amd64", "linux/amd64", "linux/amd64", false}, + {"linux/arm64", "linux/arm64", "linux/arm64", false}, + {"linux/arm", "linux/arm", "linux/arm", false}, + {"linux/386", "linux/386", "linux/386", false}, + {"linux/ppc64le", "linux/ppc64le", "linux/ppc64le", false}, + {"linux/s390x", "linux/s390x", "linux/s390x", false}, + {"linux/riscv64", "linux/riscv64", "linux/riscv64", false}, + {"windows/amd64", "windows/amd64", "windows/amd64", false}, + {"darwin/amd64", "darwin/amd64", "darwin/amd64", false}, + {"darwin/arm64", "darwin/arm64", "darwin/arm64", false}, + {"invalid format", "invalid", "", true}, + {"unsupported OS", "bsd/amd64", "", true}, + {"unsupported arch", "linux/mips", "", true}, + {"too many parts", "linux/amd64/v2", "", true}, + {"empty OS", "/amd64", "", true}, + {"empty arch", "linux/", "", true}, + {"path traversal attempt", "../../../etc/passwd", "", true}, + {"path traversal in os", "../linux/amd64", "", true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - err := validatePlatform(tt.platform) + result, err := sanitizePlatform(tt.platform) if tt.expectErr && err == nil { t.Errorf("expected error for platform '%s', got nil", tt.platform) } if !tt.expectErr && err != nil { t.Errorf("unexpected error for platform '%s': %v", tt.platform, err) } + if !tt.expectErr && result != tt.expected { + t.Errorf("expected sanitized platform '%s', got '%s'", tt.expected, result) + } }) } } From 49f9ea93b847081be9d4fb70846a90782a268ad8 Mon Sep 17 00:00:00 2001 From: Sergio Triana Escobedo Date: Thu, 8 Jan 2026 11:32:51 -0600 Subject: [PATCH 08/15] Enhance output path validation and sanitization in createOutputTar to prevent path traversal vulnerabilities --- image.go | 27 ++++++++++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/image.go b/image.go index 316f88f..68e0259 100644 --- a/image.go +++ b/image.go @@ -161,9 +161,18 @@ func createOutputTar(ref ImageReference, tempDir, outputDir string) (string, err return "", err } - safeImageName := strings.ReplaceAll(ref.Repository, "/", "_") - safePlatform := strings.ReplaceAll(ref.Platform.String(), "/", "_") - outputPath := filepath.Join(outputDir, fmt.Sprintf("%s_%s_%s.tar.gz", safeImageName, ref.Tag, safePlatform)) + safeImageName := sanitizeForPath(ref.Repository) + safeTag := sanitizeForPath(ref.Tag) + safePlatform := sanitizeForPath(ref.Platform.String()) + filename := fmt.Sprintf("%s_%s_%s.tar.gz", safeImageName, safeTag, safePlatform) + outputPath := filepath.Join(outputDir, filename) + + // Validate that the output path stays within the output directory + cleanOutputDir := filepath.Clean(outputDir) + cleanOutputPath := filepath.Clean(outputPath) + if !strings.HasPrefix(cleanOutputPath, cleanOutputDir+string(filepath.Separator)) && cleanOutputPath != cleanOutputDir { + return "", fmt.Errorf("invalid path: potential path traversal detected") + } log.Println("Creating tar archive...") if err := createTar(tempDir, outputPath); err != nil { @@ -174,6 +183,18 @@ func createOutputTar(ref ImageReference, tempDir, outputDir string) (string, err return outputPath, nil } +// sanitizeForPath removes dangerous characters from a string to make it safe for use in file paths +func sanitizeForPath(s string) string { + // Replace path separators with underscores + s = strings.ReplaceAll(s, "/", "_") + s = strings.ReplaceAll(s, "\\", "_") + // Remove path traversal sequences + s = strings.ReplaceAll(s, "..", "") + // Remove leading dots + s = strings.TrimLeft(s, ".") + return s +} + // DownloadImage downloads a Docker image and saves it as a tar file // platform should be in format "os/architecture" (e.g., "linux/amd64", "linux/arm64") func DownloadImage(imageRef string, outputDir string, platform string) (string, error) { From 5da66999d076d925328ddbebf601d4fd2f8448ed Mon Sep 17 00:00:00 2001 From: Sergio Triana Escobedo Date: Thu, 8 Jan 2026 11:34:07 -0600 Subject: [PATCH 09/15] Implement path validation and sanitization to prevent path traversal vulnerabilities --- server.go | 40 ++++++++++++++++++++++++++++++++++--- server_test.go | 53 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 90 insertions(+), 3 deletions(-) diff --git a/server.go b/server.go index 9e430e2..9342000 100644 --- a/server.go +++ b/server.go @@ -139,6 +139,13 @@ func (s *Server) imageHandler(w http.ResponseWriter, r *http.Request) { cacheFilename := s.getCacheFilename(imageName, platform) cachePath := filepath.Join(s.cacheDir, cacheFilename) + // Validate that the cache path stays within the cache directory (prevent path traversal) + if err := validatePathContainment(s.cacheDir, cachePath); err != nil { + log.Printf("Security: path traversal attempt detected for image %s: %v\n", imageName, err) + writeJSONError(w, "invalid request", http.StatusBadRequest) + return + } + if _, err := os.Stat(cachePath); err == nil { log.Printf("Serving cached image: %s (platform: %s)\n", imageName, platform) s.serveImageFile(w, r, cachePath, imageName, platform) @@ -216,9 +223,36 @@ func sanitizeImageName(imageName string) (string, error) { func (s *Server) getCacheFilename(imageName string, platform string) string { ref := ParseImageReference(imageName) ref.Platform = ParsePlatform(platform) - safeImageName := strings.ReplaceAll(ref.Repository, "/", "_") - safePlatform := strings.ReplaceAll(ref.Platform.String(), "/", "_") - return fmt.Sprintf("%s_%s_%s.tar.gz", safeImageName, ref.Tag, safePlatform) + safeImageName := sanitizePathComponent(ref.Repository) + safeTag := sanitizePathComponent(ref.Tag) + safePlatform := sanitizePathComponent(ref.Platform.String()) + return fmt.Sprintf("%s_%s_%s.tar.gz", safeImageName, safeTag, safePlatform) +} + +// sanitizePathComponent removes dangerous characters from a path component +// to prevent path traversal attacks +func sanitizePathComponent(s string) string { + // Replace path separators with underscores + s = strings.ReplaceAll(s, "/", "_") + s = strings.ReplaceAll(s, "\\", "_") + // Remove path traversal sequences + s = strings.ReplaceAll(s, "..", "") + // Remove any remaining dots at the start (hidden files) + s = strings.TrimLeft(s, ".") + return s +} + +// validatePathContainment ensures the final path stays within the base directory +func validatePathContainment(basePath, fullPath string) error { + // Clean both paths for comparison + cleanBase := filepath.Clean(basePath) + cleanFull := filepath.Clean(fullPath) + + // Ensure the full path starts with the base path + if !strings.HasPrefix(cleanFull, cleanBase+string(filepath.Separator)) && cleanFull != cleanBase { + return fmt.Errorf("path traversal detected: path escapes base directory") + } + return nil } // serveImageFile streams an image tar file to the response with Range request support diff --git a/server_test.go b/server_test.go index 35336ab..8c6fc57 100644 --- a/server_test.go +++ b/server_test.go @@ -409,4 +409,57 @@ func TestImageHandler_PlatformNormalization(t *testing.T) { } } +func TestSanitizePathComponent(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + {"normal string", "alpine", "alpine"}, + {"with forward slash", "library/alpine", "library_alpine"}, + {"with backslash", "library\\alpine", "library_alpine"}, + {"path traversal", "../../../etc/passwd", "___etc_passwd"}, + {"double dots", "foo..bar", "foobar"}, + {"leading dot", ".hidden", "hidden"}, + {"multiple leading dots", "...test", "test"}, + {"complex traversal", "../../foo/../bar", "__foo__bar"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := sanitizePathComponent(tt.input) + if result != tt.expected { + t.Errorf("sanitizePathComponent(%q) = %q, want %q", tt.input, result, tt.expected) + } + }) + } +} + +func TestValidatePathContainment(t *testing.T) { + tests := []struct { + name string + basePath string + fullPath string + expectErr bool + }{ + {"valid path", "/cache", "/cache/file.tar.gz", false}, + {"valid nested path", "/cache", "/cache/subdir/file.tar.gz", false}, + {"path traversal", "/cache", "/cache/../etc/passwd", true}, + {"absolute escape", "/cache", "/etc/passwd", true}, + {"same as base", "/cache", "/cache", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validatePathContainment(tt.basePath, tt.fullPath) + if tt.expectErr && err == nil { + t.Errorf("expected error for basePath=%q, fullPath=%q", tt.basePath, tt.fullPath) + } + if !tt.expectErr && err != nil { + t.Errorf("unexpected error for basePath=%q, fullPath=%q: %v", tt.basePath, tt.fullPath, err) + } + }) + } +} + var _ = fmt.Sprintf From 225cfc4266d466bd100d0f2072aea1b8bd644eda Mon Sep 17 00:00:00 2001 From: Sergio Triana Escobedo Date: Thu, 8 Jan 2026 23:29:30 -0600 Subject: [PATCH 10/15] Update README to enhance download instructions and add platform support --- README.md | 31 +++++++++++++++++++++---------- index.html | 28 ++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index f83297d..e8b0857 100644 --- a/README.md +++ b/README.md @@ -45,18 +45,29 @@ Remember to update the domain name in the Caddyfile. ### Client side -#### Only get the file +#### Download with wget -`wget -c --tries=5 --waitretry=3 --content-disposition "https://dockerimagesave.akiel.dev/image?name=ubuntu:25.04"` +- Resumable download (keeps the file): -#### Direct pipe (simple) + ```bash + wget -c --tries=5 --waitretry=3 --content-disposition \ + "https://dockerimagesave.akiel.dev/image?name=ubuntu:25.04" + ``` -```bash -wget --tries=5 --waitretry=3 -q -O - "https://dockerimagesave.akiel.dev/image?name=ubuntu:25.04" | docker load -``` +- Stream straight into Docker (no file left on disk): -#### With resume support (for large images or if you want to keep the file) + ```bash + wget --tries=5 --waitretry=3 -q -O - \ + "https://dockerimagesave.akiel.dev/image?name=ubuntu:25.04" | docker load + ``` -```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 -``` +- Request a specific platform (matching `docker pull --platform`): + + ```bash + wget --tries=5 --waitretry=3 -q -O - \ + "https://dockerimagesave.akiel.dev/image?name=ubuntu:25.04&platform=linux/amd64" | docker load + ``` + +Notes: +- If auth is enabled on your instance, pass `X-API-Key` or `api_key` as configured. +- `--content-disposition` lets wget honor the filename suggested by the server. diff --git a/index.html b/index.html index f9a04eb..c927d1d 100644 --- a/index.html +++ b/index.html @@ -73,6 +73,8 @@ .buttons { display: flex; gap: 12px; + flex-wrap: wrap; + justify-content: center; } .btn { @@ -244,8 +246,10 @@
+
+
@@ -256,7 +260,9 @@ const form = document.getElementById('downloadForm'); const imageNameInput = document.getElementById('imageName'); const downloadBtn = document.getElementById('downloadBtn'); + const directBtn = document.getElementById('directBtn'); const status = document.getElementById('status'); + const wgetSnippet = document.getElementById('wgetSnippet'); const progressBar = document.getElementById('progressBar'); const progressFill = document.getElementById('progressFill'); @@ -435,6 +441,7 @@ progressFill.style.width = '0%'; progressFill.classList.add('indeterminate'); downloadBtn.disabled = true; + directBtn.disabled = true; try { const response = await fetch(`/image?name=${encodeURIComponent(imageName)}`); @@ -517,7 +524,28 @@ progressBar.classList.remove('visible'); } finally { downloadBtn.disabled = false; + directBtn.disabled = false; + } + }); + + directBtn.addEventListener('click', (e) => { + e.preventDefault(); + + const imageName = imageNameInput.value.trim(); + if (!imageName) { + status.textContent = 'Please enter an image name'; + status.classList.add('error'); + return; } + + status.classList.remove('error'); + status.textContent = 'Starting browser download...'; + const url = `/image?name=${encodeURIComponent(imageName)}`; + window.location.href = url; + + // Show wget snippet for convenience + const snippet = `wget --tries=5 --waitretry=3 -c --content-disposition "${window.location.origin}${url}"`; + wgetSnippet.textContent = snippet; }); From fc0abbaa4512a24525a1896250ec7b25fcfc63cf Mon Sep 17 00:00:00 2001 From: Sergio Triana Escobedo Date: Tue, 20 Jan 2026 21:43:54 -0600 Subject: [PATCH 11/15] refactor: simplify config loading to require an explicit path and update README notes. --- README.md | 4 +--- main.go | 16 +++------------- 2 files changed, 4 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index e8b0857..fd897f6 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,4 @@ Remember to update the domain name in the Caddyfile. "https://dockerimagesave.akiel.dev/image?name=ubuntu:25.04&platform=linux/amd64" | docker load ``` -Notes: -- If auth is enabled on your instance, pass `X-API-Key` or `api_key` as configured. -- `--content-disposition` lets wget honor the filename suggested by the server. +Note: `--content-disposition` lets wget honor the filename suggested by the server. diff --git a/main.go b/main.go index 7f25efd..85a0cef 100644 --- a/main.go +++ b/main.go @@ -4,7 +4,6 @@ import ( "flag" "fmt" "log" - "os" ) func printBanner() { @@ -27,17 +26,8 @@ func main() { var addr string var cacheDir string - // Try to load config from specified path, or fall back to config.yaml if it exists - configFile := *configPath - if configFile == "" { - // Check if default config.yaml exists - if _, err := os.Stat("config.yaml"); err == nil { - configFile = "config.yaml" - } - } - - if configFile != "" { - config, err := LoadConfig(configFile) + if *configPath != "" { + config, err := LoadConfig(*configPath) if err != nil { log.Fatalf("Failed to load config: %v", err) } @@ -46,7 +36,7 @@ func main() { cacheDir = config.CacheDir config.ApplyCredentials() - log.Printf("Loaded configuration from %s", configFile) + log.Printf("Loaded configuration from %s", *configPath) } else { addr = ":8080" cacheDir = "" From 5587ab961baffd585c683a76e3a53a60f0473252 Mon Sep 17 00:00:00 2001 From: Sergio Triana Escobedo Date: Tue, 20 Jan 2026 21:50:43 -0600 Subject: [PATCH 12/15] feat: Implement Docker image platform selection with UI updates and improved error parsing. --- index.html | 650 +++++++++++++++++++++++++++++------------------------ 1 file changed, 356 insertions(+), 294 deletions(-) diff --git a/index.html b/index.html index c927d1d..9a7a789 100644 --- a/index.html +++ b/index.html @@ -1,10 +1,13 @@ + - - + + Download Docker images + - -
-