package plugins import ( "context" "errors" "os" "path/filepath" "strings" "testing" ) func writePlugin(t *testing.T, base, name string, files map[string]string) string { t.Helper() dir := filepath.Join(base, name) if err := os.MkdirAll(dir, 0o755); err != nil { t.Fatal(err) } for path, content := range files { full := filepath.Join(dir, path) if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(full, []byte(content), 0o644); err != nil { t.Fatal(err) } } return dir } func TestLocalResolver_Scheme(t *testing.T) { if (&LocalResolver{}).Scheme() != "local" { t.Error("scheme must be 'local'") } } func TestLocalResolver_CopiesPluginTree(t *testing.T) { base := t.TempDir() writePlugin(t, base, "demo", map[string]string{ "plugin.yaml": "name: demo\n", "rules/one.md": "- rule", "skills/hello/SKILL.md": "---\nname: hello\ndescription: d\n---\nbody", "skills/hello/scripts/t.py": "# tool", }) dst := t.TempDir() r := NewLocalResolver(base) name, err := r.Fetch(context.Background(), "demo", dst) if err != nil { t.Fatalf("unexpected err: %v", err) } if name != "demo" { t.Errorf("got name %q", name) } // Verify every file made it across. for _, want := range []string{ "plugin.yaml", "rules/one.md", "skills/hello/SKILL.md", "skills/hello/scripts/t.py", } { if _, err := os.Stat(filepath.Join(dst, want)); err != nil { t.Errorf("missing %q in dst: %v", want, err) } } } func TestLocalResolver_RejectsPathTraversal(t *testing.T) { r := NewLocalResolver(t.TempDir()) cases := []string{ "", " ", "../evil", "foo/bar", "..", "./hidden", "foo\\bar", } for _, name := range cases { t.Run(name, func(t *testing.T) { _, err := r.Fetch(context.Background(), name, t.TempDir()) if err == nil { t.Errorf("Fetch(%q) should have failed", name) } }) } } func TestLocalResolver_MissingPluginReturnsError(t *testing.T) { r := NewLocalResolver(t.TempDir()) _, err := r.Fetch(context.Background(), "not-here", t.TempDir()) if err == nil { t.Fatal("expected error") } if !strings.Contains(err.Error(), "not found") { t.Errorf("error should mention 'not found': %v", err) } } func TestLocalResolver_RejectsNonDirectoryTarget(t *testing.T) { base := t.TempDir() // "demo" exists but is a file, not a dir. if err := os.WriteFile(filepath.Join(base, "demo"), []byte("hi"), 0o644); err != nil { t.Fatal(err) } r := NewLocalResolver(base) _, err := r.Fetch(context.Background(), "demo", t.TempDir()) if err == nil { t.Fatal("expected error") } if !strings.Contains(err.Error(), "not a directory") { t.Errorf("error should mention 'not a directory': %v", err) } } func TestLocalResolver_HonoursContextCancellation(t *testing.T) { // Make a plugin with enough files to take a moment. base := t.TempDir() files := map[string]string{} for i := 0; i < 20; i++ { files[filepath.Join("data", "f"+string(rune('a'+i))+".txt")] = strings.Repeat("x", 1024) } writePlugin(t, base, "big", files) ctx, cancel := context.WithCancel(context.Background()) cancel() // cancel before starting — walk should abort fast r := NewLocalResolver(base) _, err := r.Fetch(ctx, "big", t.TempDir()) if err == nil { t.Error("expected cancellation error") } } func TestLocalResolver_BubblesUpCopyFailure(t *testing.T) { // Source file the copyTree walk would read; make dst unwritable so // the copyFile step fails. base := t.TempDir() writePlugin(t, base, "demo", map[string]string{ "plugin.yaml": "name: demo\n", }) dst := t.TempDir() // Make dst read-only so creating files inside it fails. if err := os.Chmod(dst, 0o555); err != nil { t.Fatal(err) } t.Cleanup(func() { _ = os.Chmod(dst, 0o755) }) r := NewLocalResolver(base) _, err := r.Fetch(context.Background(), "demo", dst) if err == nil { t.Error("expected copy failure when dst is read-only") } } func TestLocalResolver_CopyFileSourceUnreadable(t *testing.T) { base := t.TempDir() pluginDir := writePlugin(t, base, "demo", map[string]string{ "plugin.yaml": "name: demo\n", }) // Make the source file unreadable — copyFile should error. if err := os.Chmod(filepath.Join(pluginDir, "plugin.yaml"), 0o000); err != nil { t.Fatal(err) } t.Cleanup(func() { _ = os.Chmod(filepath.Join(pluginDir, "plugin.yaml"), 0o644) }) r := NewLocalResolver(base) _, err := r.Fetch(context.Background(), "demo", t.TempDir()) // Root can read any file, so this test only asserts on non-root hosts. if os.Getuid() == 0 { t.Skip("running as root — cannot exercise unreadable-file branch") } if err == nil { t.Error("expected error when source file is unreadable") } } func TestLocalResolver_WalkErrorPropagates(t *testing.T) { // Put a plugin dir in place, then replace its subdirectory with an // unreadable one so filepath.Walk surfaces walkErr to our callback. if os.Getuid() == 0 { t.Skip("running as root — cannot exercise Walk error branch") } base := t.TempDir() pluginDir := writePlugin(t, base, "demo", map[string]string{ "sub/file.txt": "x", }) // Make the subdir unreadable so Walk's readdir fails. if err := os.Chmod(filepath.Join(pluginDir, "sub"), 0o000); err != nil { t.Fatal(err) } t.Cleanup(func() { _ = os.Chmod(filepath.Join(pluginDir, "sub"), 0o755) }) r := NewLocalResolver(base) _, err := r.Fetch(context.Background(), "demo", t.TempDir()) if err == nil { t.Error("expected Walk error to propagate") } } func TestLocalResolver_MissingReturnsErrPluginNotFound(t *testing.T) { r := NewLocalResolver(t.TempDir()) _, err := r.Fetch(context.Background(), "absent", t.TempDir()) if !errors.Is(err, ErrPluginNotFound) { t.Errorf("expected ErrPluginNotFound, got %v", err) } } func TestLocalResolver_NonNotExistStatErrorIsNotErrPluginNotFound(t *testing.T) { // stat failing for reasons other than "doesn't exist" should NOT // surface as ErrPluginNotFound. Hard to reliably trigger cross- // platform, but we can at least prove the wrapping on a read-only // permission-denied BaseDir. if os.Getuid() == 0 { t.Skip("running as root — cannot exercise permission branch") } base := t.TempDir() locked := base + "/locked" if err := os.Mkdir(locked, 0o000); err != nil { t.Fatal(err) } t.Cleanup(func() { _ = os.Chmod(locked, 0o755) }) r := NewLocalResolver(locked) _, err := r.Fetch(context.Background(), "anything", t.TempDir()) if err == nil { t.Fatal("expected error") } if errors.Is(err, ErrPluginNotFound) { t.Errorf("permission-denied stat must not be surfaced as ErrPluginNotFound: %v", err) } } func TestLocalResolver_RejectsOverlongName(t *testing.T) { // 129-char name (1 + 128 tail); max is 1 + 127 = 128. long := "a" + strings.Repeat("b", 128) r := NewLocalResolver(t.TempDir()) _, err := r.Fetch(context.Background(), long, t.TempDir()) if err == nil { t.Error("overlong name should be rejected") } // Exactly at limit should still work (just the regex check; will // still fail at the stat step since the dir doesn't exist, but with // ErrPluginNotFound, not a format error). atLimit := "a" + strings.Repeat("b", 127) _, err = r.Fetch(context.Background(), atLimit, t.TempDir()) if err == nil { t.Error("at-limit name with missing dir should still err") } else if !errors.Is(err, ErrPluginNotFound) { t.Errorf("at-limit name should err with ErrPluginNotFound (format is fine), got %v", err) } }