diff --git a/packages/agent/extcmd.go b/packages/agent/extcmd.go index 0a7d3cb..8e5503e 100644 --- a/packages/agent/extcmd.go +++ b/packages/agent/extcmd.go @@ -269,12 +269,24 @@ func extInstall(args []string) error { if _, err := os.Stat(filepath.Join(src, "extension.json")); err != nil { return fmt.Errorf("source lacks extension.json") } - name := filepath.Base(src) + // Resolve to an absolute, cleaned path before deriving the install + // name. Otherwise relative sources like "." or "./" collapse to a + // basename of ".", and the destination wrongly resolves to the + // extensions/ parent directory (which zot creates on first run), + // triggering a false "already exists" failure. + absSrc, err := filepath.Abs(src) + if err != nil { + return err + } + name := filepath.Base(absSrc) + if name == "." || name == ".." || name == string(filepath.Separator) || name == "" { + return fmt.Errorf("cannot derive extension name from %q", src) + } out := filepath.Join(dest, name) if _, err := os.Stat(out); err == nil { return fmt.Errorf("destination %s already exists; remove it first", out) } - if err := copyDir(src, out); err != nil { + if err := copyDir(absSrc, out); err != nil { return err } fmt.Fprintf(os.Stderr, "installed %s\n", out) diff --git a/packages/agent/extcmd_test.go b/packages/agent/extcmd_test.go new file mode 100644 index 0000000..e0e3e5f --- /dev/null +++ b/packages/agent/extcmd_test.go @@ -0,0 +1,71 @@ +package agent + +import ( + "os" + "path/filepath" + "testing" +) + +// TestExtInstallDotSource verifies that `zot ext install .` derives the +// extension name from the resolved directory name rather than collapsing +// to the extensions/ parent directory (the false "already exists" bug). +func TestExtInstallDotSource(t *testing.T) { + home := t.TempDir() + t.Setenv("ZOT_HOME", home) + + // Pre-create extensions/ to mimic a normal first run. + if err := os.MkdirAll(filepath.Join(home, "extensions"), 0o755); err != nil { + t.Fatal(err) + } + + srcParent := t.TempDir() + src := filepath.Join(srcParent, "kagi") + if err := os.MkdirAll(src, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(src, "extension.json"), []byte(`{"name":"kagi"}`), 0o644); err != nil { + t.Fatal(err) + } + + cwd, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + defer os.Chdir(cwd) + if err := os.Chdir(src); err != nil { + t.Fatal(err) + } + + if err := extInstall([]string{"."}); err != nil { + t.Fatalf("install with '.' failed: %v", err) + } + + out := filepath.Join(home, "extensions", "kagi") + if _, err := os.Stat(filepath.Join(out, "extension.json")); err != nil { + t.Fatalf("expected installed extension at %s: %v", out, err) + } +} + +// TestExtInstallRejectsParentName guards against deriving a name of ".." +// from a source that resolves to a filesystem root edge case. A normal +// directory always yields a real basename, so this just ensures the +// guard logic does not crash for well-formed input. +func TestExtInstallNamedDir(t *testing.T) { + home := t.TempDir() + t.Setenv("ZOT_HOME", home) + + src := filepath.Join(t.TempDir(), "myext") + if err := os.MkdirAll(src, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(src, "extension.json"), []byte(`{"name":"myext"}`), 0o644); err != nil { + t.Fatal(err) + } + + if err := extInstall([]string{src}); err != nil { + t.Fatalf("install failed: %v", err) + } + if _, err := os.Stat(filepath.Join(home, "extensions", "myext", "extension.json")); err != nil { + t.Fatalf("expected installed extension: %v", err) + } +}