Fix ext install with relative path source
- commit
- 10fde8fd0eec86bf3d91d601b37f3408c6c13a6d
- author
- patriceckhart · · 2026-06-07 08:18 UTC
- parent
- 84fd98e
- signature
- unsigned
2 files · +85 -2
M packages/agent/extcmd.go +14 -2
| @@ -269,12 +269,24 @@ func extInstall(args []string) error { | |||
| 269 | 269 | if _, err := os.Stat(filepath.Join(src, "extension.json")); err != nil { | |
| 270 | 270 | return fmt.Errorf("source lacks extension.json") | |
| 271 | 271 | } | |
| 272 | - | name := filepath.Base(src) | |
| 272 | + | // Resolve to an absolute, cleaned path before deriving the install | |
| 273 | + | // name. Otherwise relative sources like "." or "./" collapse to a | |
| 274 | + | // basename of ".", and the destination wrongly resolves to the | |
| 275 | + | // extensions/ parent directory (which zot creates on first run), | |
| 276 | + | // triggering a false "already exists" failure. | |
| 277 | + | absSrc, err := filepath.Abs(src) | |
| 278 | + | if err != nil { | |
| 279 | + | return err | |
| 280 | + | } | |
| 281 | + | name := filepath.Base(absSrc) | |
| 282 | + | if name == "." || name == ".." || name == string(filepath.Separator) || name == "" { | |
| 283 | + | return fmt.Errorf("cannot derive extension name from %q", src) | |
| 284 | + | } | |
| 273 | 285 | out := filepath.Join(dest, name) | |
| 274 | 286 | if _, err := os.Stat(out); err == nil { | |
| 275 | 287 | return fmt.Errorf("destination %s already exists; remove it first", out) | |
| 276 | 288 | } | |
| 277 | - | if err := copyDir(src, out); err != nil { | |
| 289 | + | if err := copyDir(absSrc, out); err != nil { | |
| 278 | 290 | return err | |
| 279 | 291 | } | |
| 280 | 292 | fmt.Fprintf(os.Stderr, "installed %s\n", out) | |
| @@ -269,12 +269,24 @@ func extInstall(args []string) error { | |||||
| 269 | if _, err := os.Stat(filepath.Join(src, "extension.json")); err != nil { | 269 | if _, err := os.Stat(filepath.Join(src, "extension.json")); err != nil { | ||
| 270 | return fmt.Errorf("source lacks extension.json") | 270 | return fmt.Errorf("source lacks extension.json") | ||
| 271 | } | 271 | } | ||
| 272 | - | name := filepath.Base(src) | 272 | + | // Resolve to an absolute, cleaned path before deriving the install |
| 273 | + | // name. Otherwise relative sources like "." or "./" collapse to a | |||
| 274 | + | // basename of ".", and the destination wrongly resolves to the | |||
| 275 | + | // extensions/ parent directory (which zot creates on first run), | |||
| 276 | + | // triggering a false "already exists" failure. | |||
| 277 | + | absSrc, err := filepath.Abs(src) | |||
| 278 | + | if err != nil { | |||
| 279 | + | return err | |||
| 280 | + | } | |||
| 281 | + | name := filepath.Base(absSrc) | |||
| 282 | + | if name == "." || name == ".." || name == string(filepath.Separator) || name == "" { | |||
| 283 | + | return fmt.Errorf("cannot derive extension name from %q", src) | |||
| 284 | + | } | |||
| 273 | out := filepath.Join(dest, name) | 285 | out := filepath.Join(dest, name) | ||
| 274 | if _, err := os.Stat(out); err == nil { | 286 | if _, err := os.Stat(out); err == nil { | ||
| 275 | return fmt.Errorf("destination %s already exists; remove it first", out) | 287 | return fmt.Errorf("destination %s already exists; remove it first", out) | ||
| 276 | } | 288 | } | ||
| 277 | - | if err := copyDir(src, out); err != nil { | 289 | + | if err := copyDir(absSrc, out); err != nil { |
| 278 | return err | 290 | return err | ||
| 279 | } | 291 | } | ||
| 280 | fmt.Fprintf(os.Stderr, "installed %s\n", out) | 292 | fmt.Fprintf(os.Stderr, "installed %s\n", out) | ||
A packages/agent/extcmd_test.go +71 -0
| @@ -0,0 +1,71 @@ | |||
| 1 | + | package agent | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "os" | |
| 5 | + | "path/filepath" | |
| 6 | + | "testing" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | // TestExtInstallDotSource verifies that `zot ext install .` derives the | |
| 10 | + | // extension name from the resolved directory name rather than collapsing | |
| 11 | + | // to the extensions/ parent directory (the false "already exists" bug). | |
| 12 | + | func TestExtInstallDotSource(t *testing.T) { | |
| 13 | + | home := t.TempDir() | |
| 14 | + | t.Setenv("ZOT_HOME", home) | |
| 15 | + | ||
| 16 | + | // Pre-create extensions/ to mimic a normal first run. | |
| 17 | + | if err := os.MkdirAll(filepath.Join(home, "extensions"), 0o755); err != nil { | |
| 18 | + | t.Fatal(err) | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | srcParent := t.TempDir() | |
| 22 | + | src := filepath.Join(srcParent, "kagi") | |
| 23 | + | if err := os.MkdirAll(src, 0o755); err != nil { | |
| 24 | + | t.Fatal(err) | |
| 25 | + | } | |
| 26 | + | if err := os.WriteFile(filepath.Join(src, "extension.json"), []byte(`{"name":"kagi"}`), 0o644); err != nil { | |
| 27 | + | t.Fatal(err) | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | cwd, err := os.Getwd() | |
| 31 | + | if err != nil { | |
| 32 | + | t.Fatal(err) | |
| 33 | + | } | |
| 34 | + | defer os.Chdir(cwd) | |
| 35 | + | if err := os.Chdir(src); err != nil { | |
| 36 | + | t.Fatal(err) | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | if err := extInstall([]string{"."}); err != nil { | |
| 40 | + | t.Fatalf("install with '.' failed: %v", err) | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | out := filepath.Join(home, "extensions", "kagi") | |
| 44 | + | if _, err := os.Stat(filepath.Join(out, "extension.json")); err != nil { | |
| 45 | + | t.Fatalf("expected installed extension at %s: %v", out, err) | |
| 46 | + | } | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | // TestExtInstallRejectsParentName guards against deriving a name of ".." | |
| 50 | + | // from a source that resolves to a filesystem root edge case. A normal | |
| 51 | + | // directory always yields a real basename, so this just ensures the | |
| 52 | + | // guard logic does not crash for well-formed input. | |
| 53 | + | func TestExtInstallNamedDir(t *testing.T) { | |
| 54 | + | home := t.TempDir() | |
| 55 | + | t.Setenv("ZOT_HOME", home) | |
| 56 | + | ||
| 57 | + | src := filepath.Join(t.TempDir(), "myext") | |
| 58 | + | if err := os.MkdirAll(src, 0o755); err != nil { | |
| 59 | + | t.Fatal(err) | |
| 60 | + | } | |
| 61 | + | if err := os.WriteFile(filepath.Join(src, "extension.json"), []byte(`{"name":"myext"}`), 0o644); err != nil { | |
| 62 | + | t.Fatal(err) | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | if err := extInstall([]string{src}); err != nil { | |
| 66 | + | t.Fatalf("install failed: %v", err) | |
| 67 | + | } | |
| 68 | + | if _, err := os.Stat(filepath.Join(home, "extensions", "myext", "extension.json")); err != nil { | |
| 69 | + | t.Fatalf("expected installed extension: %v", err) | |
| 70 | + | } | |
| 71 | + | } | |
| @@ -0,0 +1,71 @@ | |||||
| 1 | + | package agent | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "os" | |||
| 5 | + | "path/filepath" | |||
| 6 | + | "testing" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | // TestExtInstallDotSource verifies that `zot ext install .` derives the | |||
| 10 | + | // extension name from the resolved directory name rather than collapsing | |||
| 11 | + | // to the extensions/ parent directory (the false "already exists" bug). | |||
| 12 | + | func TestExtInstallDotSource(t *testing.T) { | |||
| 13 | + | home := t.TempDir() | |||
| 14 | + | t.Setenv("ZOT_HOME", home) | |||
| 15 | + | ||||
| 16 | + | // Pre-create extensions/ to mimic a normal first run. | |||
| 17 | + | if err := os.MkdirAll(filepath.Join(home, "extensions"), 0o755); err != nil { | |||
| 18 | + | t.Fatal(err) | |||
| 19 | + | } | |||
| 20 | + | ||||
| 21 | + | srcParent := t.TempDir() | |||
| 22 | + | src := filepath.Join(srcParent, "kagi") | |||
| 23 | + | if err := os.MkdirAll(src, 0o755); err != nil { | |||
| 24 | + | t.Fatal(err) | |||
| 25 | + | } | |||
| 26 | + | if err := os.WriteFile(filepath.Join(src, "extension.json"), []byte(`{"name":"kagi"}`), 0o644); err != nil { | |||
| 27 | + | t.Fatal(err) | |||
| 28 | + | } | |||
| 29 | + | ||||
| 30 | + | cwd, err := os.Getwd() | |||
| 31 | + | if err != nil { | |||
| 32 | + | t.Fatal(err) | |||
| 33 | + | } | |||
| 34 | + | defer os.Chdir(cwd) | |||
| 35 | + | if err := os.Chdir(src); err != nil { | |||
| 36 | + | t.Fatal(err) | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | if err := extInstall([]string{"."}); err != nil { | |||
| 40 | + | t.Fatalf("install with '.' failed: %v", err) | |||
| 41 | + | } | |||
| 42 | + | ||||
| 43 | + | out := filepath.Join(home, "extensions", "kagi") | |||
| 44 | + | if _, err := os.Stat(filepath.Join(out, "extension.json")); err != nil { | |||
| 45 | + | t.Fatalf("expected installed extension at %s: %v", out, err) | |||
| 46 | + | } | |||
| 47 | + | } | |||
| 48 | + | ||||
| 49 | + | // TestExtInstallRejectsParentName guards against deriving a name of ".." | |||
| 50 | + | // from a source that resolves to a filesystem root edge case. A normal | |||
| 51 | + | // directory always yields a real basename, so this just ensures the | |||
| 52 | + | // guard logic does not crash for well-formed input. | |||
| 53 | + | func TestExtInstallNamedDir(t *testing.T) { | |||
| 54 | + | home := t.TempDir() | |||
| 55 | + | t.Setenv("ZOT_HOME", home) | |||
| 56 | + | ||||
| 57 | + | src := filepath.Join(t.TempDir(), "myext") | |||
| 58 | + | if err := os.MkdirAll(src, 0o755); err != nil { | |||
| 59 | + | t.Fatal(err) | |||
| 60 | + | } | |||
| 61 | + | if err := os.WriteFile(filepath.Join(src, "extension.json"), []byte(`{"name":"myext"}`), 0o644); err != nil { | |||
| 62 | + | t.Fatal(err) | |||
| 63 | + | } | |||
| 64 | + | ||||
| 65 | + | if err := extInstall([]string{src}); err != nil { | |||
| 66 | + | t.Fatalf("install failed: %v", err) | |||
| 67 | + | } | |||
| 68 | + | if _, err := os.Stat(filepath.Join(home, "extensions", "myext", "extension.json")); err != nil { | |||
| 69 | + | t.Fatalf("expected installed extension: %v", err) | |||
| 70 | + | } | |||
| 71 | + | } | |||