Skip to content

Commit 3728192

Browse files
authored
Refactor file utilities into dedicated package and improve plugin validation (#211)
* Refactor file utilities into dedicated package * Improve plugin config validation on-load
1 parent 86b6ba9 commit 3728192

File tree

10 files changed

+859
-794
lines changed

10 files changed

+859
-794
lines changed

internal/cache/cache.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import (
1111

1212
"github.com/hashicorp/go-hclog"
1313

14-
"github.com/mozilla-ai/mcpd/v2/internal/context"
14+
"github.com/mozilla-ai/mcpd/v2/internal/files"
1515
)
1616

1717
// Cache manages cached registry manifests.
@@ -42,7 +42,7 @@ func NewCache(logger hclog.Logger, opts ...Option) (*Cache, error) {
4242

4343
// Only create cache directory if caching is enabled.
4444
if options.enabled {
45-
if err := context.EnsureAtLeastRegularDir(options.dir); err != nil {
45+
if err := files.EnsureAtLeastRegularDir(options.dir); err != nil {
4646
return nil, fmt.Errorf("failed to create cache directory: %w", err)
4747
}
4848
}

internal/config/plugin_config.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"strings"
77

88
"github.com/mozilla-ai/mcpd/v2/internal/context"
9+
"github.com/mozilla-ai/mcpd/v2/internal/files"
910
)
1011

1112
const (
@@ -247,9 +248,45 @@ func (p *PluginConfig) Validate() error {
247248
}
248249
}
249250

251+
// Validate directory and plugins if Dir is configured.
252+
if err := p.validatePluginDirectory(); err != nil {
253+
validationErrors = append(validationErrors, err)
254+
}
255+
250256
return errors.Join(validationErrors...)
251257
}
252258

259+
// validatePluginDirectory validates that the plugin directory exists and contains all configured plugins.
260+
// Returns nil if Dir is empty (plugins disabled).
261+
func (p *PluginConfig) validatePluginDirectory() error {
262+
if strings.TrimSpace(p.Dir) == "" {
263+
return nil
264+
}
265+
266+
available, err := files.DiscoverExecutables(p.Dir)
267+
if err != nil {
268+
return fmt.Errorf("plugin directory %s: %w", p.Dir, err)
269+
}
270+
271+
return p.validateConfiguredPluginsExist(available)
272+
}
273+
274+
// validateConfiguredPluginsExist checks that all configured plugins exist in the available set.
275+
func (p *PluginConfig) validateConfiguredPluginsExist(available map[string]struct{}) error {
276+
var missingPlugins []error
277+
278+
for name := range p.PluginNamesDistinct() {
279+
if _, exists := available[name]; !exists {
280+
missingPlugins = append(
281+
missingPlugins,
282+
fmt.Errorf("plugin %s not found in directory %s", name, p.Dir),
283+
)
284+
}
285+
}
286+
287+
return errors.Join(missingPlugins...)
288+
}
289+
253290
// categorySlice returns a pointer to the category slice for the given category name.
254291
func (p *PluginConfig) categorySlice(category Category) (*[]PluginEntry, error) {
255292
switch category {

internal/context/context.go

Lines changed: 3 additions & 107 deletions
Original file line numberDiff line numberDiff line change
@@ -12,17 +12,10 @@ import (
1212

1313
"github.com/BurntSushi/toml"
1414

15+
"github.com/mozilla-ai/mcpd/v2/internal/files"
1516
"github.com/mozilla-ai/mcpd/v2/internal/perms"
1617
)
1718

18-
const (
19-
// EnvVarXDGConfigHome is the XDG Base Directory env var name for config files.
20-
EnvVarXDGConfigHome = "XDG_CONFIG_HOME"
21-
22-
// EnvVarXDGCacheHome is the XDG Base Directory env var name for cache file.
23-
EnvVarXDGCacheHome = "XDG_CACHE_HOME"
24-
)
25-
2619
// DefaultLoader loads execution context configurations.
2720
type DefaultLoader struct{}
2821

@@ -126,13 +119,13 @@ func (c *ExecutionContextConfig) List() []ServerExecutionContext {
126119
// SaveConfig saves the execution context configuration to a file with secure permissions.
127120
// Used for runtime execution contexts that may contain sensitive data.
128121
func (c *ExecutionContextConfig) SaveConfig() error {
129-
return c.saveConfig(EnsureAtLeastSecureDir, perms.SecureFile)
122+
return c.saveConfig(files.EnsureAtLeastSecureDir, perms.SecureFile)
130123
}
131124

132125
// SaveExportedConfig saves the execution context configuration to a file with regular permissions.
133126
// Used for exported configurations that are sanitized and suitable for sharing.
134127
func (c *ExecutionContextConfig) SaveExportedConfig() error {
135-
return c.saveConfig(EnsureAtLeastRegularDir, perms.RegularFile)
128+
return c.saveConfig(files.EnsureAtLeastRegularDir, perms.RegularFile)
136129
}
137130

138131
// Upsert updates the execution context for the given server name.
@@ -216,27 +209,6 @@ func (s *ServerExecutionContext) IsEmpty() bool {
216209
return len(s.Args) == 0 && len(s.Env) == 0 && len(s.Volumes) == 0
217210
}
218211

219-
// AppDirName returns the name of the application directory for use in user-specific operations where data is being written.
220-
func AppDirName() string {
221-
return "mcpd"
222-
}
223-
224-
// EnsureAtLeastRegularDir creates a directory with standard permissions if it doesn't exist,
225-
// and verifies that it has at least the required regular permissions if it already exists.
226-
// It does not attempt to repair ownership or permissions: if they are wrong, it returns an error.
227-
// Used for cache directories, data directories, and documentation.
228-
func EnsureAtLeastRegularDir(path string) error {
229-
return ensureAtLeastDir(path, perms.RegularDir)
230-
}
231-
232-
// EnsureAtLeastSecureDir creates a directory with secure permissions if it doesn't exist,
233-
// and verifies that it has at least the required secure permissions if it already exists.
234-
// It does not attempt to repair ownership or permissions: if they are wrong,
235-
// it returns an error.
236-
func EnsureAtLeastSecureDir(path string) error {
237-
return ensureAtLeastDir(path, perms.SecureDir)
238-
}
239-
240212
// NewExecutionContextConfig returns a newly initialized ExecutionContextConfig.
241213
func NewExecutionContextConfig(path string) *ExecutionContextConfig {
242214
return &ExecutionContextConfig{
@@ -245,46 +217,6 @@ func NewExecutionContextConfig(path string) *ExecutionContextConfig {
245217
}
246218
}
247219

248-
// UserSpecificCacheDir returns the directory that should be used to store any user-specific cache files.
249-
// It adheres to the XDG Base Directory Specification, respecting the XDG_CACHE_HOME environment variable.
250-
// When XDG_CACHE_HOME is not set, it defaults to ~/.cache/mcpd/
251-
// See: https://specifications.freedesktop.org/basedir-spec/latest/
252-
func UserSpecificCacheDir() (string, error) {
253-
return userSpecificDir(EnvVarXDGCacheHome, ".cache")
254-
}
255-
256-
// UserSpecificConfigDir returns the directory that should be used to store any user-specific configuration.
257-
// It adheres to the XDG Base Directory Specification, respecting the XDG_CONFIG_HOME environment variable.
258-
// When XDG_CONFIG_HOME is not set, it defaults to ~/.config/mcpd/
259-
// See: https://specifications.freedesktop.org/basedir-spec/latest/
260-
func UserSpecificConfigDir() (string, error) {
261-
return userSpecificDir(EnvVarXDGConfigHome, ".config")
262-
}
263-
264-
// ensureAtLeastDir creates a directory with the specified permissions if it doesn't exist,
265-
// and verifies that it has at least the required permissions if it already exists.
266-
// It does not attempt to repair ownership or permissions: if they are wrong, it returns an error.
267-
func ensureAtLeastDir(path string, perm os.FileMode) error {
268-
if err := os.MkdirAll(path, perm); err != nil {
269-
return fmt.Errorf("could not ensure directory exists for '%s': %w", path, err)
270-
}
271-
272-
info, err := os.Stat(path)
273-
if err != nil {
274-
return fmt.Errorf("could not stat directory '%s': %w", path, err)
275-
}
276-
277-
if !isPermissionAcceptable(info.Mode().Perm(), perm) {
278-
return fmt.Errorf(
279-
"incorrect permissions for directory '%s' (%#o, want %#o or more restrictive)",
280-
path, info.Mode().Perm(),
281-
perm,
282-
)
283-
}
284-
285-
return nil
286-
}
287-
288220
// equalSlices compares two string slices for equality, ignoring order.
289221
func equalSlices(a []string, b []string) bool {
290222
if len(a) != len(b) {
@@ -300,14 +232,6 @@ func equalSlices(a []string, b []string) bool {
300232
return slices.Equal(sortedA, sortedB)
301233
}
302234

303-
// isPermissionAcceptable checks if the actual permissions are acceptable for the required permissions.
304-
// It returns true if the actual permissions are equal to or more restrictive than required.
305-
// "More restrictive" means: no permission bit set in actual that isn't also set in required.
306-
func isPermissionAcceptable(actual, required os.FileMode) bool {
307-
// Check that actual doesn't grant any permissions that required doesn't grant
308-
return (actual & ^required) == 0
309-
}
310-
311235
// loadExecutionContextConfig loads a runtime execution context file from disk and expands environment variables.
312236
//
313237
// The function parses the TOML file at the specified path and automatically expands all ${VAR} references
@@ -396,31 +320,3 @@ func (c *ExecutionContextConfig) saveConfig(ensureDirFunc func(string) error, fi
396320

397321
return nil
398322
}
399-
400-
// userSpecificDir returns a user-specific directory following XDG Base Directory Specification.
401-
// It respects the given environment variable, falling back to homeDir/dir/AppDirName() if not set.
402-
// The envVar must have XDG_ prefix to follow the specification.
403-
func userSpecificDir(envVar string, dir string) (string, error) {
404-
envVar = strings.TrimSpace(envVar)
405-
// Validate that the environment variable follows XDG naming convention.
406-
if !strings.HasPrefix(envVar, "XDG_") {
407-
return "", fmt.Errorf(
408-
"environment variable '%s' does not follow XDG Base Directory Specification",
409-
envVar,
410-
)
411-
}
412-
413-
// If the relevant environment variable is present and configured, then use it.
414-
if ch, ok := os.LookupEnv(envVar); ok && strings.TrimSpace(ch) != "" {
415-
home := strings.TrimSpace(ch)
416-
return filepath.Join(home, AppDirName()), nil
417-
}
418-
419-
// Attempt to locate the home directory for the current user and return the path that follows the spec.
420-
homeDir, err := os.UserHomeDir()
421-
if err != nil {
422-
return "", fmt.Errorf("failed to get user home directory: %w", err)
423-
}
424-
425-
return filepath.Join(homeDir, dir, AppDirName()), nil
426-
}

0 commit comments

Comments
 (0)