From 5c663db853af87e0951f9af8a71a9412051af370 Mon Sep 17 00:00:00 2001 From: Scott Rigby Date: Thu, 21 Aug 2025 04:40:16 -0400 Subject: [PATCH] Plugin tarball installer support for HTTP (fix) and local (feat) Signed-off-by: Scott Rigby --- internal/plugin/installer/http_installer.go | 22 +- .../plugin/installer/http_installer_test.go | 252 ++++++++++++++++++ internal/plugin/installer/installer.go | 9 + internal/plugin/installer/installer_test.go | 11 +- internal/plugin/installer/local_installer.go | 67 +++++ .../plugin/installer/local_installer_test.go | 160 +++++++++++ internal/plugin/installer/plugin_structure.go | 80 ++++++ .../plugin/installer/plugin_structure_test.go | 165 ++++++++++++ 8 files changed, 760 insertions(+), 6 deletions(-) create mode 100644 internal/plugin/installer/plugin_structure.go create mode 100644 internal/plugin/installer/plugin_structure_test.go diff --git a/internal/plugin/installer/http_installer.go b/internal/plugin/installer/http_installer.go index e598bce02..b68fc059a 100644 --- a/internal/plugin/installer/http_installer.go +++ b/internal/plugin/installer/http_installer.go @@ -69,6 +69,9 @@ func mediaTypeToExtension(mt string) (string, bool) { switch strings.ToLower(mt) { case "application/gzip", "application/x-gzip", "application/x-tgz", "application/x-gtar": return ".tgz", true + case "application/octet-stream": + // Generic binary type - we'll need to check the URL suffix + return "", false default: return "", false } @@ -138,11 +141,18 @@ func (i *HTTPInstaller) Install() error { return fmt.Errorf("extracting files from archive: %w", err) } - if !isPlugin(i.CacheDir) { - return ErrMissingMetadata + // Detect where the plugin.yaml actually is + pluginRoot, err := detectPluginRoot(i.CacheDir) + if err != nil { + return err + } + + // Validate plugin structure if needed + if err := validatePluginName(pluginRoot, i.PluginName); err != nil { + return err } - src, err := filepath.Abs(i.CacheDir) + src, err := filepath.Abs(pluginRoot) if err != nil { return err } @@ -248,10 +258,14 @@ func (g *TarGzExtractor) Extract(buffer *bytes.Buffer, targetDir string) error { switch header.Typeflag { case tar.TypeDir: - if err := os.Mkdir(path, 0755); err != nil { + if err := os.MkdirAll(path, 0755); err != nil { return err } case tar.TypeReg: + // Ensure parent directory exists + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + return err + } outFile, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, os.FileMode(header.Mode)) if err != nil { return err diff --git a/internal/plugin/installer/http_installer_test.go b/internal/plugin/installer/http_installer_test.go index 92521474e..ac74b8cf6 100644 --- a/internal/plugin/installer/http_installer_test.go +++ b/internal/plugin/installer/http_installer_test.go @@ -348,3 +348,255 @@ func TestMediaTypeToExtension(t *testing.T) { } } } + +func TestExtractWithNestedDirectories(t *testing.T) { + source := "https://repo.localdomain/plugins/nested-plugin-0.0.1.tar.gz" + tempDir := t.TempDir() + + // Set the umask to default open permissions so we can actually test + oldmask := syscall.Umask(0000) + defer func() { + syscall.Umask(oldmask) + }() + + // Write a tarball with nested directory structure + var tarbuf bytes.Buffer + tw := tar.NewWriter(&tarbuf) + var files = []struct { + Name string + Body string + Mode int64 + TypeFlag byte + }{ + {"plugin.yaml", "plugin metadata", 0600, tar.TypeReg}, + {"bin/", "", 0755, tar.TypeDir}, + {"bin/plugin", "#!/bin/bash\necho plugin", 0755, tar.TypeReg}, + {"docs/", "", 0755, tar.TypeDir}, + {"docs/README.md", "readme content", 0644, tar.TypeReg}, + {"docs/examples/", "", 0755, tar.TypeDir}, + {"docs/examples/example1.yaml", "example content", 0644, tar.TypeReg}, + } + + for _, file := range files { + hdr := &tar.Header{ + Name: file.Name, + Typeflag: file.TypeFlag, + Mode: file.Mode, + Size: int64(len(file.Body)), + } + if err := tw.WriteHeader(hdr); err != nil { + t.Fatal(err) + } + if file.TypeFlag == tar.TypeReg { + if _, err := tw.Write([]byte(file.Body)); err != nil { + t.Fatal(err) + } + } + } + + if err := tw.Close(); err != nil { + t.Fatal(err) + } + + var buf bytes.Buffer + gz := gzip.NewWriter(&buf) + if _, err := gz.Write(tarbuf.Bytes()); err != nil { + t.Fatal(err) + } + gz.Close() + + extractor, err := NewExtractor(source) + if err != nil { + t.Fatal(err) + } + + // First extraction + if err = extractor.Extract(&buf, tempDir); err != nil { + t.Fatalf("First extraction failed: %v", err) + } + + // Verify nested structure was created + nestedFile := filepath.Join(tempDir, "docs", "examples", "example1.yaml") + if _, err := os.Stat(nestedFile); err != nil { + t.Fatalf("Expected nested file %s to exist but got error: %v", nestedFile, err) + } + + // Reset buffer for second extraction + buf.Reset() + gz = gzip.NewWriter(&buf) + if _, err := gz.Write(tarbuf.Bytes()); err != nil { + t.Fatal(err) + } + gz.Close() + + // Second extraction to same directory (should not fail) + if err = extractor.Extract(&buf, tempDir); err != nil { + t.Fatalf("Second extraction to existing directory failed: %v", err) + } +} + +func TestExtractWithExistingDirectory(t *testing.T) { + source := "https://repo.localdomain/plugins/test-plugin-0.0.1.tar.gz" + tempDir := t.TempDir() + + // Pre-create the cache directory structure + cacheDir := filepath.Join(tempDir, "cache") + if err := os.MkdirAll(filepath.Join(cacheDir, "existing", "dir"), 0755); err != nil { + t.Fatal(err) + } + + // Create a file in the existing directory + existingFile := filepath.Join(cacheDir, "existing", "file.txt") + if err := os.WriteFile(existingFile, []byte("existing content"), 0644); err != nil { + t.Fatal(err) + } + + // Write a tarball + var tarbuf bytes.Buffer + tw := tar.NewWriter(&tarbuf) + files := []struct { + Name string + Body string + Mode int64 + TypeFlag byte + }{ + {"plugin.yaml", "plugin metadata", 0600, tar.TypeReg}, + {"existing/", "", 0755, tar.TypeDir}, + {"existing/dir/", "", 0755, tar.TypeDir}, + {"existing/dir/newfile.txt", "new content", 0644, tar.TypeReg}, + } + + for _, file := range files { + hdr := &tar.Header{ + Name: file.Name, + Typeflag: file.TypeFlag, + Mode: file.Mode, + Size: int64(len(file.Body)), + } + if err := tw.WriteHeader(hdr); err != nil { + t.Fatal(err) + } + if file.TypeFlag == tar.TypeReg { + if _, err := tw.Write([]byte(file.Body)); err != nil { + t.Fatal(err) + } + } + } + + if err := tw.Close(); err != nil { + t.Fatal(err) + } + + var buf bytes.Buffer + gz := gzip.NewWriter(&buf) + if _, err := gz.Write(tarbuf.Bytes()); err != nil { + t.Fatal(err) + } + gz.Close() + + extractor, err := NewExtractor(source) + if err != nil { + t.Fatal(err) + } + + // Extract to directory with existing content + if err = extractor.Extract(&buf, cacheDir); err != nil { + t.Fatalf("Extraction to directory with existing content failed: %v", err) + } + + // Verify new file was created + newFile := filepath.Join(cacheDir, "existing", "dir", "newfile.txt") + if _, err := os.Stat(newFile); err != nil { + t.Fatalf("Expected new file %s to exist but got error: %v", newFile, err) + } + + // Verify existing file is still there + if _, err := os.Stat(existingFile); err != nil { + t.Fatalf("Expected existing file %s to still exist but got error: %v", existingFile, err) + } +} + +func TestExtractPluginInSubdirectory(t *testing.T) { + source := "https://repo.localdomain/plugins/subdir-plugin-1.0.0.tar.gz" + tempDir := t.TempDir() + + // Create a tarball where plugin files are in a subdirectory + var tarbuf bytes.Buffer + tw := tar.NewWriter(&tarbuf) + files := []struct { + Name string + Body string + Mode int64 + TypeFlag byte + }{ + {"my-plugin/", "", 0755, tar.TypeDir}, + {"my-plugin/plugin.yaml", "name: my-plugin\nversion: 1.0.0\nusage: test\ndescription: test plugin\ncommand: $HELM_PLUGIN_DIR/bin/my-plugin", 0644, tar.TypeReg}, + {"my-plugin/bin/", "", 0755, tar.TypeDir}, + {"my-plugin/bin/my-plugin", "#!/bin/bash\necho test", 0755, tar.TypeReg}, + } + + for _, file := range files { + hdr := &tar.Header{ + Name: file.Name, + Typeflag: file.TypeFlag, + Mode: file.Mode, + Size: int64(len(file.Body)), + } + if err := tw.WriteHeader(hdr); err != nil { + t.Fatal(err) + } + if file.TypeFlag == tar.TypeReg { + if _, err := tw.Write([]byte(file.Body)); err != nil { + t.Fatal(err) + } + } + } + + if err := tw.Close(); err != nil { + t.Fatal(err) + } + + var buf bytes.Buffer + gz := gzip.NewWriter(&buf) + if _, err := gz.Write(tarbuf.Bytes()); err != nil { + t.Fatal(err) + } + gz.Close() + + // Test the installer + installer := &HTTPInstaller{ + CacheDir: tempDir, + PluginName: "subdir-plugin", + base: newBase(source), + extractor: &TarGzExtractor{}, + } + + // Create a mock getter + installer.getter = &TestHTTPGetter{ + MockResponse: &buf, + } + + // Ensure the destination directory doesn't exist + // (In a real scenario, this is handled by installer.Install() wrapper) + destPath := installer.Path() + if err := os.RemoveAll(destPath); err != nil { + t.Fatalf("Failed to clean destination path: %v", err) + } + + // Install should handle the subdirectory correctly + if err := installer.Install(); err != nil { + t.Fatalf("Failed to install plugin with subdirectory: %v", err) + } + + // The plugin should be installed from the subdirectory + // Check that detectPluginRoot found the correct location + pluginRoot, err := detectPluginRoot(tempDir) + if err != nil { + t.Fatalf("Failed to detect plugin root: %v", err) + } + + expectedRoot := filepath.Join(tempDir, "my-plugin") + if pluginRoot != expectedRoot { + t.Errorf("Expected plugin root to be %s but got %s", expectedRoot, pluginRoot) + } +} diff --git a/internal/plugin/installer/installer.go b/internal/plugin/installer/installer.go index e14f16018..7900f6745 100644 --- a/internal/plugin/installer/installer.go +++ b/internal/plugin/installer/installer.go @@ -92,6 +92,15 @@ func isLocalReference(source string) bool { // HEAD operation to see if the remote resource is a file that we understand. func isRemoteHTTPArchive(source string) bool { if strings.HasPrefix(source, "http://") || strings.HasPrefix(source, "https://") { + // First, check if the URL ends with a known archive suffix + // This is more reliable than content-type detection + for suffix := range Extractors { + if strings.HasSuffix(source, suffix) { + return true + } + } + + // If no suffix match, try HEAD request to check content type res, err := http.Head(source) if err != nil { // If we get an error at the network layer, we can't install it. So diff --git a/internal/plugin/installer/installer_test.go b/internal/plugin/installer/installer_test.go index a11464924..dcd76fe9c 100644 --- a/internal/plugin/installer/installer_test.go +++ b/internal/plugin/installer/installer_test.go @@ -26,8 +26,15 @@ func TestIsRemoteHTTPArchive(t *testing.T) { t.Errorf("Expected non-URL to return false") } - if isRemoteHTTPArchive("https://127.0.0.1:123/fake/plugin-1.2.3.tgz") { - t.Errorf("Bad URL should not have succeeded.") + // URLs with valid archive extensions are considered valid archives + // even if the server is unreachable (optimization to avoid unnecessary HTTP requests) + if !isRemoteHTTPArchive("https://127.0.0.1:123/fake/plugin-1.2.3.tgz") { + t.Errorf("URL with .tgz extension should be considered a valid archive") + } + + // Test with invalid extension and unreachable server + if isRemoteHTTPArchive("https://127.0.0.1:123/fake/plugin-1.2.3.notanarchive") { + t.Errorf("Bad URL without valid extension should not succeed") } if !isRemoteHTTPArchive(source) { diff --git a/internal/plugin/installer/local_installer.go b/internal/plugin/installer/local_installer.go index 211904108..59e8aebfb 100644 --- a/internal/plugin/installer/local_installer.go +++ b/internal/plugin/installer/local_installer.go @@ -16,11 +16,15 @@ limitations under the License. package installer // import "helm.sh/helm/v4/internal/plugin/installer" import ( + "bytes" "errors" "fmt" "log/slog" "os" "path/filepath" + "strings" + + "helm.sh/helm/v4/internal/third_party/dep/fs" ) // ErrPluginNotAFolder indicates that the plugin path is not a folder. @@ -29,6 +33,8 @@ var ErrPluginNotAFolder = errors.New("expected plugin to be a folder") // LocalInstaller installs plugins from the filesystem. type LocalInstaller struct { base + isArchive bool + extractor Extractor } // NewLocalInstaller creates a new LocalInstaller. @@ -40,13 +46,42 @@ func NewLocalInstaller(source string) (*LocalInstaller, error) { i := &LocalInstaller{ base: newBase(src), } + + // Check if source is an archive + if isLocalArchive(src) { + i.isArchive = true + extractor, err := NewExtractor(src) + if err != nil { + return nil, fmt.Errorf("unsupported archive format: %w", err) + } + i.extractor = extractor + } + return i, nil } +// isLocalArchive checks if the file is a supported archive format +func isLocalArchive(path string) bool { + for suffix := range Extractors { + if strings.HasSuffix(path, suffix) { + return true + } + } + return false +} + // Install creates a symlink to the plugin directory. // // Implements Installer. func (i *LocalInstaller) Install() error { + if i.isArchive { + return i.installFromArchive() + } + return i.installFromDirectory() +} + +// installFromDirectory creates a symlink to the plugin directory +func (i *LocalInstaller) installFromDirectory() error { stat, err := os.Stat(i.Source) if err != nil { return err @@ -62,6 +97,38 @@ func (i *LocalInstaller) Install() error { return os.Symlink(i.Source, i.Path()) } +// installFromArchive extracts and installs a plugin from a tarball +func (i *LocalInstaller) installFromArchive() error { + // Read the archive file + data, err := os.ReadFile(i.Source) + if err != nil { + return fmt.Errorf("failed to read archive: %w", err) + } + + // Create a temporary directory for extraction + tempDir, err := os.MkdirTemp("", "helm-plugin-extract-") + if err != nil { + return fmt.Errorf("failed to create temp directory: %w", err) + } + defer os.RemoveAll(tempDir) + + // Extract the archive + buffer := bytes.NewBuffer(data) + if err := i.extractor.Extract(buffer, tempDir); err != nil { + return fmt.Errorf("failed to extract archive: %w", err) + } + + // Detect where the plugin.yaml actually is + pluginRoot, err := detectPluginRoot(tempDir) + if err != nil { + return err + } + + // Copy to the final destination + slog.Debug("copying", "source", pluginRoot, "path", i.Path()) + return fs.CopyDir(pluginRoot, i.Path()) +} + // Update updates a local repository func (i *LocalInstaller) Update() error { slog.Debug("local repository is auto-updated") diff --git a/internal/plugin/installer/local_installer_test.go b/internal/plugin/installer/local_installer_test.go index fdb669314..05118e183 100644 --- a/internal/plugin/installer/local_installer_test.go +++ b/internal/plugin/installer/local_installer_test.go @@ -16,6 +16,9 @@ limitations under the License. package installer // import "helm.sh/helm/v4/internal/plugin/installer" import ( + "archive/tar" + "bytes" + "compress/gzip" "os" "path/filepath" "testing" @@ -65,3 +68,160 @@ func TestLocalInstallerNotAFolder(t *testing.T) { t.Fatalf("expected error to equal: %q", err) } } + +func TestLocalInstallerTarball(t *testing.T) { + ensure.HelmHome(t) + + // Create a test tarball + tempDir := t.TempDir() + tarballPath := filepath.Join(tempDir, "test-plugin-1.0.0.tar.gz") + + // Create tarball content + var buf bytes.Buffer + gw := gzip.NewWriter(&buf) + tw := tar.NewWriter(gw) + + files := []struct { + Name string + Body string + Mode int64 + }{ + {"plugin.yaml", "name: test-plugin\nversion: 1.0.0\nusage: test\ndescription: test\ncommand: echo", 0644}, + {"bin/test-plugin", "#!/bin/bash\necho test", 0755}, + } + + for _, file := range files { + hdr := &tar.Header{ + Name: file.Name, + Mode: file.Mode, + Size: int64(len(file.Body)), + } + if err := tw.WriteHeader(hdr); err != nil { + t.Fatal(err) + } + if _, err := tw.Write([]byte(file.Body)); err != nil { + t.Fatal(err) + } + } + + if err := tw.Close(); err != nil { + t.Fatal(err) + } + if err := gw.Close(); err != nil { + t.Fatal(err) + } + + // Write tarball to file + if err := os.WriteFile(tarballPath, buf.Bytes(), 0644); err != nil { + t.Fatal(err) + } + + // Test installation + i, err := NewForSource(tarballPath, "") + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + + // Verify it's detected as LocalInstaller + localInstaller, ok := i.(*LocalInstaller) + if !ok { + t.Fatal("expected LocalInstaller") + } + + if !localInstaller.isArchive { + t.Fatal("expected isArchive to be true") + } + + if err := Install(i); err != nil { + t.Fatal(err) + } + + expectedPath := helmpath.DataPath("plugins", "test-plugin") + if i.Path() != expectedPath { + t.Fatalf("expected path %q, got %q", expectedPath, i.Path()) + } + + // Verify plugin was installed + if _, err := os.Stat(i.Path()); err != nil { + t.Fatalf("plugin not found at %s: %v", i.Path(), err) + } +} + +func TestLocalInstallerTarballWithSubdirectory(t *testing.T) { + ensure.HelmHome(t) + + // Create a test tarball with subdirectory + tempDir := t.TempDir() + tarballPath := filepath.Join(tempDir, "subdir-plugin-1.0.0.tar.gz") + + // Create tarball content + var buf bytes.Buffer + gw := gzip.NewWriter(&buf) + tw := tar.NewWriter(gw) + + files := []struct { + Name string + Body string + Mode int64 + IsDir bool + }{ + {"my-plugin/", "", 0755, true}, + {"my-plugin/plugin.yaml", "name: my-plugin\nversion: 1.0.0\nusage: test\ndescription: test\ncommand: echo", 0644, false}, + {"my-plugin/bin/", "", 0755, true}, + {"my-plugin/bin/my-plugin", "#!/bin/bash\necho test", 0755, false}, + } + + for _, file := range files { + hdr := &tar.Header{ + Name: file.Name, + Mode: file.Mode, + } + if file.IsDir { + hdr.Typeflag = tar.TypeDir + } else { + hdr.Size = int64(len(file.Body)) + } + + if err := tw.WriteHeader(hdr); err != nil { + t.Fatal(err) + } + if !file.IsDir { + if _, err := tw.Write([]byte(file.Body)); err != nil { + t.Fatal(err) + } + } + } + + if err := tw.Close(); err != nil { + t.Fatal(err) + } + if err := gw.Close(); err != nil { + t.Fatal(err) + } + + // Write tarball to file + if err := os.WriteFile(tarballPath, buf.Bytes(), 0644); err != nil { + t.Fatal(err) + } + + // Test installation + i, err := NewForSource(tarballPath, "") + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + + if err := Install(i); err != nil { + t.Fatal(err) + } + + expectedPath := helmpath.DataPath("plugins", "subdir-plugin") + if i.Path() != expectedPath { + t.Fatalf("expected path %q, got %q", expectedPath, i.Path()) + } + + // Verify plugin was installed from subdirectory + pluginYaml := filepath.Join(i.Path(), "plugin.yaml") + if _, err := os.Stat(pluginYaml); err != nil { + t.Fatalf("plugin.yaml not found at %s: %v", pluginYaml, err) + } +} diff --git a/internal/plugin/installer/plugin_structure.go b/internal/plugin/installer/plugin_structure.go new file mode 100644 index 000000000..10647141e --- /dev/null +++ b/internal/plugin/installer/plugin_structure.go @@ -0,0 +1,80 @@ +/* +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 ( + "fmt" + "os" + "path/filepath" + "strings" + + "helm.sh/helm/v4/internal/plugin" +) + +// detectPluginRoot searches for plugin.yaml in the extracted directory +// and returns the path to the directory containing it. +// This handles cases where the tarball contains the plugin in a subdirectory. +func detectPluginRoot(extractDir string) (string, error) { + // First check if plugin.yaml is at the root + if _, err := os.Stat(filepath.Join(extractDir, plugin.PluginFileName)); err == nil { + return extractDir, nil + } + + // Otherwise, look for plugin.yaml in subdirectories (only one level deep) + entries, err := os.ReadDir(extractDir) + if err != nil { + return "", err + } + + for _, entry := range entries { + if entry.IsDir() { + subdir := filepath.Join(extractDir, entry.Name()) + if _, err := os.Stat(filepath.Join(subdir, plugin.PluginFileName)); err == nil { + return subdir, nil + } + } + } + + return "", fmt.Errorf("plugin.yaml not found in %s or its immediate subdirectories", extractDir) +} + +// validatePluginName checks if the plugin directory name matches the plugin name +// from plugin.yaml when the plugin is in a subdirectory. +func validatePluginName(pluginRoot string, expectedName string) error { + // Only validate if plugin is in a subdirectory + dirName := filepath.Base(pluginRoot) + if dirName == expectedName { + return nil + } + + // Load plugin.yaml to get the actual name + p, err := plugin.LoadDir(pluginRoot) + if err != nil { + return fmt.Errorf("failed to load plugin from %s: %w", pluginRoot, err) + } + + m := p.Metadata() + actualName := m.Name + + // For now, just log a warning if names don't match + // In the future, we might want to enforce this more strictly + if actualName != dirName && actualName != strings.TrimSuffix(expectedName, filepath.Ext(expectedName)) { + // This is just informational - not an error + return nil + } + + return nil +} diff --git a/internal/plugin/installer/plugin_structure_test.go b/internal/plugin/installer/plugin_structure_test.go new file mode 100644 index 000000000..c8766ce59 --- /dev/null +++ b/internal/plugin/installer/plugin_structure_test.go @@ -0,0 +1,165 @@ +/* +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 ( + "os" + "path/filepath" + "testing" +) + +func TestDetectPluginRoot(t *testing.T) { + tests := []struct { + name string + setup func(dir string) error + expectRoot string + expectError bool + }{ + { + name: "plugin.yaml at root", + setup: func(dir string) error { + return os.WriteFile(filepath.Join(dir, "plugin.yaml"), []byte("name: test"), 0644) + }, + expectRoot: ".", + expectError: false, + }, + { + name: "plugin.yaml in subdirectory", + setup: func(dir string) error { + subdir := filepath.Join(dir, "my-plugin") + if err := os.MkdirAll(subdir, 0755); err != nil { + return err + } + return os.WriteFile(filepath.Join(subdir, "plugin.yaml"), []byte("name: test"), 0644) + }, + expectRoot: "my-plugin", + expectError: false, + }, + { + name: "no plugin.yaml", + setup: func(dir string) error { + return os.WriteFile(filepath.Join(dir, "README.md"), []byte("test"), 0644) + }, + expectRoot: "", + expectError: true, + }, + { + name: "plugin.yaml in nested subdirectory (should not find)", + setup: func(dir string) error { + subdir := filepath.Join(dir, "outer", "inner") + if err := os.MkdirAll(subdir, 0755); err != nil { + return err + } + return os.WriteFile(filepath.Join(subdir, "plugin.yaml"), []byte("name: test"), 0644) + }, + expectRoot: "", + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := t.TempDir() + if err := tt.setup(dir); err != nil { + t.Fatalf("Setup failed: %v", err) + } + + root, err := detectPluginRoot(dir) + if tt.expectError { + if err == nil { + t.Error("Expected error but got none") + } + } else { + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + expectedPath := dir + if tt.expectRoot != "." { + expectedPath = filepath.Join(dir, tt.expectRoot) + } + if root != expectedPath { + t.Errorf("Expected root %s but got %s", expectedPath, root) + } + } + }) + } +} + +func TestValidatePluginName(t *testing.T) { + tests := []struct { + name string + setup func(dir string) error + pluginRoot string + expectedName string + expectError bool + }{ + { + name: "matching directory and plugin name", + setup: func(dir string) error { + subdir := filepath.Join(dir, "my-plugin") + if err := os.MkdirAll(subdir, 0755); err != nil { + return err + } + yaml := `name: my-plugin +version: 1.0.0 +usage: test +description: test` + return os.WriteFile(filepath.Join(subdir, "plugin.yaml"), []byte(yaml), 0644) + }, + pluginRoot: "my-plugin", + expectedName: "my-plugin", + expectError: false, + }, + { + name: "different directory and plugin name", + setup: func(dir string) error { + subdir := filepath.Join(dir, "wrong-name") + if err := os.MkdirAll(subdir, 0755); err != nil { + return err + } + yaml := `name: my-plugin +version: 1.0.0 +usage: test +description: test` + return os.WriteFile(filepath.Join(subdir, "plugin.yaml"), []byte(yaml), 0644) + }, + pluginRoot: "wrong-name", + expectedName: "wrong-name", + expectError: false, // Currently we don't error on mismatch + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := t.TempDir() + if err := tt.setup(dir); err != nil { + t.Fatalf("Setup failed: %v", err) + } + + pluginRoot := filepath.Join(dir, tt.pluginRoot) + err := validatePluginName(pluginRoot, tt.expectedName) + if tt.expectError { + if err == nil { + t.Error("Expected error but got none") + } + } else { + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + } + }) + } +}