diff --git a/internal/plugin/installer/extractor.go b/internal/plugin/installer/extractor.go index b753dfbca..b07dc1aba 100644 --- a/internal/plugin/installer/extractor.go +++ b/internal/plugin/installer/extractor.go @@ -68,6 +68,17 @@ func NewExtractor(source string) (Extractor, error) { return extractor, nil } } + if strings.HasPrefix(source, "http") { + isGzip, err := isGzipArchiveFromURL(source) + if err != nil { + return nil, err + } + + if isGzip { + return &TarGzExtractor{}, nil + } + } + return nil, fmt.Errorf("no extractor implemented yet for %s", source) } diff --git a/internal/plugin/installer/gzip.go b/internal/plugin/installer/gzip.go new file mode 100644 index 000000000..0b69d277b --- /dev/null +++ b/internal/plugin/installer/gzip.go @@ -0,0 +1,66 @@ +/* +Copyright The Helm Authors. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package installer + +import ( + "context" + "fmt" + "io" + "net/http" + "time" +) + +// isGzipArchive checks if data represents a gzip archive by checking the magic bytes +func isGzipArchive(data []byte) bool { + return len(data) >= 2 && data[0] == 0x1f && data[1] == 0x8b +} + +// isGzipArchiveFromURL checks if a URL points to a gzip archive by reading the magic bytes +func isGzipArchiveFromURL(url string) (bool, error) { + // Use a short timeout context to avoid hanging on slow servers + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + // Make a GET request to read the first few bytes + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return false, err + } + + // Request only the first 512 bytes to check magic bytes + req.Header.Set("Range", "bytes=0-511") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return false, err + } + defer resp.Body.Close() + + // Check for valid status codes early + // 206 = Partial Content (range supported) + // 200 = OK (range not supported, full content returned) + if resp.StatusCode != http.StatusPartialContent && resp.StatusCode != http.StatusOK { + return false, fmt.Errorf("unexpected status code %d when checking gzip archive at %s", resp.StatusCode, url) + } + + // Read exactly 2 bytes for gzip magic number check + buf := make([]byte, 2) + if _, err := io.ReadAtLeast(resp.Body, buf, len(buf)); err != nil { + return false, fmt.Errorf("failed to read magic bytes from %s: %w", url, err) + } + + return isGzipArchive(buf), nil +} diff --git a/internal/plugin/installer/http_installer_test.go b/internal/plugin/installer/http_installer_test.go index 7f7e6cef6..c78239cc2 100644 --- a/internal/plugin/installer/http_installer_test.go +++ b/internal/plugin/installer/http_installer_test.go @@ -27,6 +27,7 @@ import ( "net/http/httptest" "os" "path/filepath" + "sort" "strings" "syscall" "testing" @@ -66,22 +67,37 @@ func TestStripName(t *testing.T) { } } -func mockArchiveServer() *httptest.Server { +func mockArchiveServer(extensionToContentType map[string]string) *httptest.Server { + // Extract and sort keys by length in descending order + extensions := make([]string, 0, len(extensionToContentType)) + for ext := range extensionToContentType { + extensions = append(extensions, ext) + } + sort.Slice(extensions, func(i, j int) bool { + return len(extensions[i]) > len(extensions[j]) + }) + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if !strings.HasSuffix(r.URL.Path, ".tar.gz") { - w.Header().Add("Content-Type", "text/html") - fmt.Fprintln(w, "broken") - return + for _, ext := range extensions { + contentType := extensionToContentType[ext] + if strings.HasSuffix(r.URL.Path, ext) { + w.Header().Add("Content-Type", contentType) + fmt.Fprintln(w, "test") + return + } } - w.Header().Add("Content-Type", "application/gzip") - fmt.Fprintln(w, "test") + + w.Header().Add("Content-Type", "text/html") + fmt.Fprintln(w, "broken") })) } func TestHTTPInstaller(t *testing.T) { ensure.HelmHome(t) - srv := mockArchiveServer() + srv := mockArchiveServer(map[string]string{ + ".tar.gz": "application/gzip", + }) defer srv.Close() source := srv.URL + "/plugins/fake-plugin-0.0.1.tar.gz" @@ -129,7 +145,9 @@ func TestHTTPInstaller(t *testing.T) { func TestHTTPInstallerNonExistentVersion(t *testing.T) { ensure.HelmHome(t) - srv := mockArchiveServer() + srv := mockArchiveServer(map[string]string{ + ".tar.gz": "application/gzip", + }) defer srv.Close() source := srv.URL + "/plugins/fake-plugin-0.0.1.tar.gz" @@ -161,7 +179,9 @@ func TestHTTPInstallerNonExistentVersion(t *testing.T) { } func TestHTTPInstallerUpdate(t *testing.T) { - srv := mockArchiveServer() + srv := mockArchiveServer(map[string]string{ + ".tar.gz": "application/gzip", + }) defer srv.Close() source := srv.URL + "/plugins/fake-plugin-0.0.1.tar.gz" ensure.HelmHome(t) diff --git a/internal/plugin/installer/installer.go b/internal/plugin/installer/installer.go index e3975c2d7..43c404b7a 100644 --- a/internal/plugin/installer/installer.go +++ b/internal/plugin/installer/installer.go @@ -203,7 +203,20 @@ func isRemoteHTTPArchive(source string) bool { contentType := res.Header.Get("content-type") foundSuffix, ok := mediaTypeToExtension(contentType) if !ok { - // Media type not recognized + if contentType == "application/octet-stream" { + isGzip, err := isGzipArchiveFromURL(source) + if err != nil { + slog.Debug("isGzipArchiveFromURL", slog.Any("error", err)) + return false + } + + if isGzip { + // For generic binary content, try to detect the actual file type + // by reading the first few bytes (magic bytes) + return true + } + } + return false } diff --git a/internal/plugin/installer/installer_test.go b/internal/plugin/installer/installer_test.go index dcd76fe9c..9714e72ce 100644 --- a/internal/plugin/installer/installer_test.go +++ b/internal/plugin/installer/installer_test.go @@ -18,9 +18,13 @@ package installer import "testing" func TestIsRemoteHTTPArchive(t *testing.T) { - srv := mockArchiveServer() + srv := mockArchiveServer(map[string]string{ + ".tar.gz": "application/gzip", + ".binary.tar.gz": "application/octet-stream", + }) defer srv.Close() source := srv.URL + "/plugins/fake-plugin-0.0.1.tar.gz" + binarySource := srv.URL + "/plugins/fake-plugin-0.0.1.binary.tar.gz" if isRemoteHTTPArchive("/not/a/URL") { t.Errorf("Expected non-URL to return false") @@ -44,4 +48,8 @@ func TestIsRemoteHTTPArchive(t *testing.T) { if isRemoteHTTPArchive(source + "-not-an-extension") { t.Error("Expected media type match to fail") } + + if !isRemoteHTTPArchive(binarySource) { + t.Errorf("Expected %q to be a valid archive URL", binarySource) + } } diff --git a/internal/plugin/installer/oci_installer.go b/internal/plugin/installer/oci_installer.go index 67f99b6f8..8eea3f7ad 100644 --- a/internal/plugin/installer/oci_installer.go +++ b/internal/plugin/installer/oci_installer.go @@ -129,7 +129,7 @@ func (i *OCIInstaller) Install() error { } // Check if this is a gzip compressed file - if len(i.pluginData) < 2 || i.pluginData[0] != 0x1f || i.pluginData[1] != 0x8b { + if !isGzipArchive(i.pluginData) { return fmt.Errorf("plugin data is not a gzip compressed archive") } diff --git a/internal/plugin/installer/oci_installer_test.go b/internal/plugin/installer/oci_installer_test.go index 1280cf97d..aa58ef2d3 100644 --- a/internal/plugin/installer/oci_installer_test.go +++ b/internal/plugin/installer/oci_installer_test.go @@ -792,7 +792,7 @@ func TestOCIInstaller_Install_ValidationErrors(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // Test the gzip validation logic that's used in the Install method - if len(tt.layerData) < 2 || tt.layerData[0] != 0x1f || tt.layerData[1] != 0x8b { + if !isGzipArchive(tt.layerData) { // This matches the validation in the Install method if !tt.expectError { t.Error("expected valid gzip data")