fix: resolve extensions by manifest name
- commit
- 035a5cdd5f8f9946cdaab90b915e64ea7488f508
- author
- patriceckhart · · 2026-09-15 16:09 UTC
- parent
- 0f485af
- signature
- unsigned
3 files · +153 -4
M docs/extensions.md +9 -0
| @@ -813,6 +813,15 @@ zot ext disable <name> disable without removing | |||
| 813 | 813 | zot ext logs <name> [-f] cat / tail the extension's stderr | |
| 814 | 814 | ``` | |
| 815 | 815 | ||
| 816 | + | `remove`, `enable`, and `disable` accept the manifest name shown by | |
| 817 | + | `zot ext list` or the installed directory name. For example, an extension | |
| 818 | + | named `todo` installed in `zot-todo/` can be removed with either name. | |
| 819 | + | Both global and project-local installations are searched. If a name matches | |
| 820 | + | multiple installations (including a manifest name matching another directory | |
| 821 | + | name), the command reports the matching paths and makes no changes. Use a | |
| 822 | + | unique directory name to disambiguate where possible. `logs` uses the | |
| 823 | + | manifest name. | |
| 824 | + | ||
| 816 | 825 | `zot ext doctor` runs the same discovery path as zot startup, but reports | |
| 817 | 826 | what happened instead of changing the fail-soft runtime behavior. It shows | |
| 818 | 827 | manifest errors, disabled or shadowed extensions, subprocess load errors, | |
| @@ -813,6 +813,15 @@ zot ext disable <name> disable without removing | |||||
| 813 | zot ext logs <name> [-f] cat / tail the extension's stderr | 813 | zot ext logs <name> [-f] cat / tail the extension's stderr | ||
| 814 | ``` | 814 | ``` | ||
| 815 | 815 | ||||
| 816 | + | `remove`, `enable`, and `disable` accept the manifest name shown by | |||
| 817 | + | `zot ext list` or the installed directory name. For example, an extension | |||
| 818 | + | named `todo` installed in `zot-todo/` can be removed with either name. | |||
| 819 | + | Both global and project-local installations are searched. If a name matches | |||
| 820 | + | multiple installations (including a manifest name matching another directory | |||
| 821 | + | name), the command reports the matching paths and makes no changes. Use a | |||
| 822 | + | unique directory name to disambiguate where possible. `logs` uses the | |||
| 823 | + | manifest name. | |||
| 824 | + | ||||
| 816 | `zot ext doctor` runs the same discovery path as zot startup, but reports | 825 | `zot ext doctor` runs the same discovery path as zot startup, but reports | ||
| 817 | what happened instead of changing the fail-soft runtime behavior. It shows | 826 | what happened instead of changing the fail-soft runtime behavior. It shows | ||
| 818 | manifest errors, disabled or shadowed extensions, subprocess load errors, | 827 | manifest errors, disabled or shadowed extensions, subprocess load errors, | ||
M packages/agent/extcmd.go +43 -4
| @@ -547,13 +547,52 @@ func extensionDirs() map[string]string { | |||
| 547 | 547 | } | |
| 548 | 548 | ||
| 549 | 549 | func findExtensionDir(name string) (string, error) { | |
| 550 | + | // Scan installed entries rather than joining user input onto a root. | |
| 551 | + | // Manifest names and directory names are both accepted, but neither | |
| 552 | + | // takes precedence when they identify different installations. | |
| 553 | + | matches := map[string]bool{} | |
| 550 | 554 | for _, dir := range extensionDirs() { | |
| 551 | - | candidate := filepath.Join(dir, name) | |
| 552 | - | if _, err := os.Stat(filepath.Join(candidate, "extension.json")); err == nil { | |
| 553 | - | return candidate, nil | |
| 555 | + | entries, err := os.ReadDir(dir) | |
| 556 | + | if os.IsNotExist(err) { | |
| 557 | + | continue | |
| 558 | + | } | |
| 559 | + | if err != nil { | |
| 560 | + | return "", fmt.Errorf("read extensions directory %s: %w", dir, err) | |
| 561 | + | } | |
| 562 | + | for _, entry := range entries { | |
| 563 | + | if !entry.IsDir() && entry.Type()&os.ModeSymlink == 0 { | |
| 564 | + | continue | |
| 565 | + | } | |
| 566 | + | candidate := filepath.Join(dir, entry.Name()) | |
| 567 | + | raw, err := os.ReadFile(filepath.Join(candidate, "extension.json")) | |
| 568 | + | if os.IsNotExist(err) { | |
| 569 | + | continue | |
| 570 | + | } | |
| 571 | + | if err != nil { | |
| 572 | + | return "", fmt.Errorf("read extension manifest in %s: %w", candidate, err) | |
| 573 | + | } | |
| 574 | + | var manifest struct { | |
| 575 | + | Name string `json:"name"` | |
| 576 | + | } | |
| 577 | + | manifestMatch := json.Unmarshal(raw, &manifest) == nil && manifest.Name != "" && manifest.Name == name | |
| 578 | + | if entry.Name() == name || manifestMatch { | |
| 579 | + | matches[candidate] = true | |
| 580 | + | } | |
| 554 | 581 | } | |
| 555 | 582 | } | |
| 556 | - | return "", fmt.Errorf("extension %q not found", name) | |
| 583 | + | paths := make([]string, 0, len(matches)) | |
| 584 | + | for path := range matches { | |
| 585 | + | paths = append(paths, path) | |
| 586 | + | } | |
| 587 | + | sort.Strings(paths) | |
| 588 | + | switch len(paths) { | |
| 589 | + | case 0: | |
| 590 | + | return "", fmt.Errorf("extension %q not found", name) | |
| 591 | + | case 1: | |
| 592 | + | return paths[0], nil | |
| 593 | + | default: | |
| 594 | + | return "", fmt.Errorf("extension %q is ambiguous, matches: %s", name, strings.Join(paths, ", ")) | |
| 595 | + | } | |
| 557 | 596 | } | |
| 558 | 597 | ||
| 559 | 598 | func dashIfEmpty(s string) string { | |
| @@ -547,13 +547,52 @@ func extensionDirs() map[string]string { | |||||
| 547 | } | 547 | } | ||
| 548 | 548 | ||||
| 549 | func findExtensionDir(name string) (string, error) { | 549 | func findExtensionDir(name string) (string, error) { | ||
| 550 | + | // Scan installed entries rather than joining user input onto a root. | |||
| 551 | + | // Manifest names and directory names are both accepted, but neither | |||
| 552 | + | // takes precedence when they identify different installations. | |||
| 553 | + | matches := map[string]bool{} | |||
| 550 | for _, dir := range extensionDirs() { | 554 | for _, dir := range extensionDirs() { | ||
| 551 | - | candidate := filepath.Join(dir, name) | 555 | + | entries, err := os.ReadDir(dir) |
| 552 | - | if _, err := os.Stat(filepath.Join(candidate, "extension.json")); err == nil { | 556 | + | if os.IsNotExist(err) { |
| 553 | - | return candidate, nil | 557 | + | continue |
| 558 | + | } | |||
| 559 | + | if err != nil { | |||
| 560 | + | return "", fmt.Errorf("read extensions directory %s: %w", dir, err) | |||
| 561 | + | } | |||
| 562 | + | for _, entry := range entries { | |||
| 563 | + | if !entry.IsDir() && entry.Type()&os.ModeSymlink == 0 { | |||
| 564 | + | continue | |||
| 565 | + | } | |||
| 566 | + | candidate := filepath.Join(dir, entry.Name()) | |||
| 567 | + | raw, err := os.ReadFile(filepath.Join(candidate, "extension.json")) | |||
| 568 | + | if os.IsNotExist(err) { | |||
| 569 | + | continue | |||
| 570 | + | } | |||
| 571 | + | if err != nil { | |||
| 572 | + | return "", fmt.Errorf("read extension manifest in %s: %w", candidate, err) | |||
| 573 | + | } | |||
| 574 | + | var manifest struct { | |||
| 575 | + | Name string `json:"name"` | |||
| 576 | + | } | |||
| 577 | + | manifestMatch := json.Unmarshal(raw, &manifest) == nil && manifest.Name != "" && manifest.Name == name | |||
| 578 | + | if entry.Name() == name || manifestMatch { | |||
| 579 | + | matches[candidate] = true | |||
| 580 | + | } | |||
| 554 | } | 581 | } | ||
| 555 | } | 582 | } | ||
| 556 | - | return "", fmt.Errorf("extension %q not found", name) | 583 | + | paths := make([]string, 0, len(matches)) |
| 584 | + | for path := range matches { | |||
| 585 | + | paths = append(paths, path) | |||
| 586 | + | } | |||
| 587 | + | sort.Strings(paths) | |||
| 588 | + | switch len(paths) { | |||
| 589 | + | case 0: | |||
| 590 | + | return "", fmt.Errorf("extension %q not found", name) | |||
| 591 | + | case 1: | |||
| 592 | + | return paths[0], nil | |||
| 593 | + | default: | |||
| 594 | + | return "", fmt.Errorf("extension %q is ambiguous, matches: %s", name, strings.Join(paths, ", ")) | |||
| 595 | + | } | |||
| 557 | } | 596 | } | ||
| 558 | 597 | ||||
| 559 | func dashIfEmpty(s string) string { | 598 | func dashIfEmpty(s string) string { | ||
A packages/agent/extresolve_test.go +101 -0
| @@ -0,0 +1,101 @@ | |||
| 1 | + | package agent | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "os" | |
| 5 | + | "path/filepath" | |
| 6 | + | "strings" | |
| 7 | + | "testing" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func TestFindExtensionDir(t *testing.T) { | |
| 11 | + | for _, scope := range []string{"global", "project"} { | |
| 12 | + | t.Run(scope, func(t *testing.T) { | |
| 13 | + | home := t.TempDir() | |
| 14 | + | project := t.TempDir() | |
| 15 | + | t.Setenv("ZOT_HOME", home) | |
| 16 | + | t.Chdir(project) | |
| 17 | + | root := filepath.Join(home, "extensions") | |
| 18 | + | if scope == "project" { | |
| 19 | + | root = filepath.Join(project, ".zot", "extensions") | |
| 20 | + | } | |
| 21 | + | dir := writeResolverExtension(t, root, "zot-todo", `{"name":"todo"}`) | |
| 22 | + | for _, name := range []string{"todo", "zot-todo"} { | |
| 23 | + | got, err := findExtensionDir(name) | |
| 24 | + | if err != nil || got != dir { | |
| 25 | + | t.Fatalf("findExtensionDir(%q) = %q, %v, want %q", name, got, err, dir) | |
| 26 | + | } | |
| 27 | + | } | |
| 28 | + | if _, err := findExtensionDir("missing"); err == nil { | |
| 29 | + | t.Fatal("expected missing extension error") | |
| 30 | + | } | |
| 31 | + | if err := extToggle([]string{"todo"}, false); err != nil { | |
| 32 | + | t.Fatal(err) | |
| 33 | + | } | |
| 34 | + | raw, err := os.ReadFile(filepath.Join(dir, "extension.json")) | |
| 35 | + | if err != nil || !strings.Contains(string(raw), `"enabled": false`) { | |
| 36 | + | t.Fatalf("disable did not update manifest: %s, %v", raw, err) | |
| 37 | + | } | |
| 38 | + | if err := extToggle([]string{"todo"}, true); err != nil { | |
| 39 | + | t.Fatal(err) | |
| 40 | + | } | |
| 41 | + | if err := extRemove([]string{"todo", "--yes"}); err != nil { | |
| 42 | + | t.Fatal(err) | |
| 43 | + | } | |
| 44 | + | if _, err := os.Stat(dir); !os.IsNotExist(err) { | |
| 45 | + | t.Fatalf("extension was not removed: %v", err) | |
| 46 | + | } | |
| 47 | + | }) | |
| 48 | + | } | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | func TestFindExtensionDirAmbiguous(t *testing.T) { | |
| 52 | + | for _, collision := range []string{"manifest", "directory", "cross-scope"} { | |
| 53 | + | t.Run(collision, func(t *testing.T) { | |
| 54 | + | home := t.TempDir() | |
| 55 | + | project := t.TempDir() | |
| 56 | + | t.Setenv("ZOT_HOME", home) | |
| 57 | + | t.Chdir(project) | |
| 58 | + | root := filepath.Join(home, "extensions") | |
| 59 | + | first := writeResolverExtension(t, root, "zot-todo", `{"name":"todo"}`) | |
| 60 | + | secondName := "other-todo" | |
| 61 | + | if collision == "directory" { | |
| 62 | + | secondName = "todo" | |
| 63 | + | } | |
| 64 | + | if collision == "cross-scope" { | |
| 65 | + | root = filepath.Join(project, ".zot", "extensions") | |
| 66 | + | secondName = "zot-todo" | |
| 67 | + | } | |
| 68 | + | second := writeResolverExtension(t, root, secondName, `{"name":"todo"}`) | |
| 69 | + | if err := extRemove([]string{"todo", "--yes"}); err == nil || !strings.Contains(err.Error(), "ambiguous") { | |
| 70 | + | t.Fatalf("expected ambiguity error, got %v", err) | |
| 71 | + | } | |
| 72 | + | for _, dir := range []string{first, second} { | |
| 73 | + | if _, err := os.Stat(dir); err != nil { | |
| 74 | + | t.Fatalf("ambiguous removal modified %s: %v", dir, err) | |
| 75 | + | } | |
| 76 | + | } | |
| 77 | + | }) | |
| 78 | + | } | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | func TestFindExtensionDirRejectsTraversal(t *testing.T) { | |
| 82 | + | home := t.TempDir() | |
| 83 | + | t.Setenv("ZOT_HOME", home) | |
| 84 | + | t.Chdir(t.TempDir()) | |
| 85 | + | writeResolverExtension(t, home, "outside", `{"name":"outside"}`) | |
| 86 | + | if _, err := findExtensionDir("../outside"); err == nil { | |
| 87 | + | t.Fatal("resolved directory outside extension roots") | |
| 88 | + | } | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | func writeResolverExtension(t *testing.T, root, dir, manifest string) string { | |
| 92 | + | t.Helper() | |
| 93 | + | path := filepath.Join(root, dir) | |
| 94 | + | if err := os.MkdirAll(path, 0o755); err != nil { | |
| 95 | + | t.Fatal(err) | |
| 96 | + | } | |
| 97 | + | if err := os.WriteFile(filepath.Join(path, "extension.json"), []byte(manifest), 0o644); err != nil { | |
| 98 | + | t.Fatal(err) | |
| 99 | + | } | |
| 100 | + | return path | |
| 101 | + | } | |
| @@ -0,0 +1,101 @@ | |||||
| 1 | + | package agent | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "os" | |||
| 5 | + | "path/filepath" | |||
| 6 | + | "strings" | |||
| 7 | + | "testing" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func TestFindExtensionDir(t *testing.T) { | |||
| 11 | + | for _, scope := range []string{"global", "project"} { | |||
| 12 | + | t.Run(scope, func(t *testing.T) { | |||
| 13 | + | home := t.TempDir() | |||
| 14 | + | project := t.TempDir() | |||
| 15 | + | t.Setenv("ZOT_HOME", home) | |||
| 16 | + | t.Chdir(project) | |||
| 17 | + | root := filepath.Join(home, "extensions") | |||
| 18 | + | if scope == "project" { | |||
| 19 | + | root = filepath.Join(project, ".zot", "extensions") | |||
| 20 | + | } | |||
| 21 | + | dir := writeResolverExtension(t, root, "zot-todo", `{"name":"todo"}`) | |||
| 22 | + | for _, name := range []string{"todo", "zot-todo"} { | |||
| 23 | + | got, err := findExtensionDir(name) | |||
| 24 | + | if err != nil || got != dir { | |||
| 25 | + | t.Fatalf("findExtensionDir(%q) = %q, %v, want %q", name, got, err, dir) | |||
| 26 | + | } | |||
| 27 | + | } | |||
| 28 | + | if _, err := findExtensionDir("missing"); err == nil { | |||
| 29 | + | t.Fatal("expected missing extension error") | |||
| 30 | + | } | |||
| 31 | + | if err := extToggle([]string{"todo"}, false); err != nil { | |||
| 32 | + | t.Fatal(err) | |||
| 33 | + | } | |||
| 34 | + | raw, err := os.ReadFile(filepath.Join(dir, "extension.json")) | |||
| 35 | + | if err != nil || !strings.Contains(string(raw), `"enabled": false`) { | |||
| 36 | + | t.Fatalf("disable did not update manifest: %s, %v", raw, err) | |||
| 37 | + | } | |||
| 38 | + | if err := extToggle([]string{"todo"}, true); err != nil { | |||
| 39 | + | t.Fatal(err) | |||
| 40 | + | } | |||
| 41 | + | if err := extRemove([]string{"todo", "--yes"}); err != nil { | |||
| 42 | + | t.Fatal(err) | |||
| 43 | + | } | |||
| 44 | + | if _, err := os.Stat(dir); !os.IsNotExist(err) { | |||
| 45 | + | t.Fatalf("extension was not removed: %v", err) | |||
| 46 | + | } | |||
| 47 | + | }) | |||
| 48 | + | } | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | func TestFindExtensionDirAmbiguous(t *testing.T) { | |||
| 52 | + | for _, collision := range []string{"manifest", "directory", "cross-scope"} { | |||
| 53 | + | t.Run(collision, func(t *testing.T) { | |||
| 54 | + | home := t.TempDir() | |||
| 55 | + | project := t.TempDir() | |||
| 56 | + | t.Setenv("ZOT_HOME", home) | |||
| 57 | + | t.Chdir(project) | |||
| 58 | + | root := filepath.Join(home, "extensions") | |||
| 59 | + | first := writeResolverExtension(t, root, "zot-todo", `{"name":"todo"}`) | |||
| 60 | + | secondName := "other-todo" | |||
| 61 | + | if collision == "directory" { | |||
| 62 | + | secondName = "todo" | |||
| 63 | + | } | |||
| 64 | + | if collision == "cross-scope" { | |||
| 65 | + | root = filepath.Join(project, ".zot", "extensions") | |||
| 66 | + | secondName = "zot-todo" | |||
| 67 | + | } | |||
| 68 | + | second := writeResolverExtension(t, root, secondName, `{"name":"todo"}`) | |||
| 69 | + | if err := extRemove([]string{"todo", "--yes"}); err == nil || !strings.Contains(err.Error(), "ambiguous") { | |||
| 70 | + | t.Fatalf("expected ambiguity error, got %v", err) | |||
| 71 | + | } | |||
| 72 | + | for _, dir := range []string{first, second} { | |||
| 73 | + | if _, err := os.Stat(dir); err != nil { | |||
| 74 | + | t.Fatalf("ambiguous removal modified %s: %v", dir, err) | |||
| 75 | + | } | |||
| 76 | + | } | |||
| 77 | + | }) | |||
| 78 | + | } | |||
| 79 | + | } | |||
| 80 | + | ||||
| 81 | + | func TestFindExtensionDirRejectsTraversal(t *testing.T) { | |||
| 82 | + | home := t.TempDir() | |||
| 83 | + | t.Setenv("ZOT_HOME", home) | |||
| 84 | + | t.Chdir(t.TempDir()) | |||
| 85 | + | writeResolverExtension(t, home, "outside", `{"name":"outside"}`) | |||
| 86 | + | if _, err := findExtensionDir("../outside"); err == nil { | |||
| 87 | + | t.Fatal("resolved directory outside extension roots") | |||
| 88 | + | } | |||
| 89 | + | } | |||
| 90 | + | ||||
| 91 | + | func writeResolverExtension(t *testing.T, root, dir, manifest string) string { | |||
| 92 | + | t.Helper() | |||
| 93 | + | path := filepath.Join(root, dir) | |||
| 94 | + | if err := os.MkdirAll(path, 0o755); err != nil { | |||
| 95 | + | t.Fatal(err) | |||
| 96 | + | } | |||
| 97 | + | if err := os.WriteFile(filepath.Join(path, "extension.json"), []byte(manifest), 0o644); err != nil { | |||
| 98 | + | t.Fatal(err) | |||
| 99 | + | } | |||
| 100 | + | return path | |||
| 101 | + | } | |||