Skip to content

Commit b159c21

Browse files
JAORMXclaude
andauthored
Extract common validation helpers in config package (#2481)
Centralizes duplicated validation logic across config operations into reusable helper functions with consistent error messaging. Changes: - Add pkg/config/validation.go with reusable validators: * validateFilePath() - file existence and path cleaning * validateFileExists() - file existence check without cleaning * readFile() - consistent file reading with error handling * validateJSONFile() - JSON extension and content validation * validateURLScheme() - URL scheme validation (http/https) * makeAbsolutePath() - relative to absolute path conversion - Add error message constants for consistent formatting - Add comprehensive unit tests in validation_test.go - Refactor cacert.go to use validation helpers - Refactor registry.go to use validation helpers - Remove duplicated validation logic and unused imports Benefits: - Eliminates code duplication in file and URL validation - Consistent error messages across all config operations - Easier maintenance with centralized validation logic - Better testability with isolated validator tests All existing tests pass with no breaking changes. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude <noreply@anthropic.com>
1 parent cdf8a49 commit b159c21

File tree

4 files changed

+539
-47
lines changed

4 files changed

+539
-47
lines changed

pkg/config/cacert.go

Lines changed: 8 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ package config
33
import (
44
"fmt"
55
"os"
6-
"path/filepath"
76

87
"github.com/stacklok/toolhive/pkg/certs"
98
)
@@ -17,18 +16,16 @@ import (
1716
//
1817
// The function returns an error if any validation fails or if updating the configuration fails.
1918
func setCACert(provider Provider, certPath string) error {
20-
// Clean the path
21-
certPath = filepath.Clean(certPath)
22-
23-
// Validate that the file exists and is readable
24-
if _, err := os.Stat(certPath); err != nil {
25-
return fmt.Errorf("CA certificate file not found or not accessible: %w", err)
19+
// Validate and clean the file path
20+
cleanPath, err := validateFilePath(certPath)
21+
if err != nil {
22+
return fmt.Errorf("CA certificate %w", err)
2623
}
2724

28-
// Read and validate the certificate
29-
certContent, err := os.ReadFile(certPath)
25+
// Read the certificate
26+
certContent, err := readFile(cleanPath)
3027
if err != nil {
31-
return fmt.Errorf("failed to read CA certificate file: %w", err)
28+
return fmt.Errorf("CA certificate %w", err)
3229
}
3330

3431
// Validate the certificate format
@@ -38,7 +35,7 @@ func setCACert(provider Provider, certPath string) error {
3835

3936
// Update the configuration
4037
err = provider.UpdateConfig(func(c *Config) {
41-
c.CACertificatePath = certPath
38+
c.CACertificatePath = cleanPath
4239
})
4340
if err != nil {
4441
return fmt.Errorf("failed to update configuration: %w", err)

pkg/config/registry.go

Lines changed: 11 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,7 @@
11
package config
22

33
import (
4-
"encoding/json"
54
"fmt"
6-
neturl "net/url"
7-
"os"
85
"path/filepath"
96
"strings"
107

@@ -36,23 +33,13 @@ func DetectRegistryType(input string) (registryType string, cleanPath string) {
3633

3734
// setRegistryURL validates and sets a registry URL using the provided provider
3835
func setRegistryURL(provider Provider, registryURL string, allowPrivateRegistryIp bool) error {
39-
parsedURL, err := neturl.Parse(registryURL)
36+
// Validate URL scheme
37+
_, err := validateURLScheme(registryURL, allowPrivateRegistryIp)
4038
if err != nil {
4139
return fmt.Errorf("invalid registry URL: %w", err)
4240
}
4341

44-
if allowPrivateRegistryIp {
45-
// we validate either https or http URLs
46-
if parsedURL.Scheme != networking.HttpScheme && parsedURL.Scheme != networking.HttpsScheme {
47-
return fmt.Errorf("registry URL must start with http:// or https:// when allowing private IPs")
48-
}
49-
} else {
50-
// we just allow https
51-
if parsedURL.Scheme != networking.HttpsScheme {
52-
return fmt.Errorf("registry URL must start with https:// when not allowing private IPs")
53-
}
54-
}
55-
42+
// Check for private IP addresses if not allowed
5643
if !allowPrivateRegistryIp {
5744
registryClient, err := networking.NewHttpClientBuilder().Build()
5845
if err != nil {
@@ -79,33 +66,21 @@ func setRegistryURL(provider Provider, registryURL string, allowPrivateRegistryI
7966

8067
// setRegistryFile validates and sets a local registry file using the provided provider
8168
func setRegistryFile(provider Provider, registryPath string) error {
82-
// Validate that the file exists and is readable
83-
if _, err := os.Stat(registryPath); err != nil {
84-
return fmt.Errorf("local registry file not found or not accessible: %w", err)
85-
}
86-
87-
// Basic validation - check if it's a JSON file
88-
if !strings.HasSuffix(strings.ToLower(registryPath), ".json") {
89-
return fmt.Errorf("registry file must be a JSON file (*.json)")
90-
}
91-
92-
// Try to read and parse the file to validate it's a valid registry
93-
// #nosec G304: File path is user-provided but validated above
94-
registryContent, err := os.ReadFile(registryPath)
69+
// Validate file path exists
70+
cleanPath, err := validateFilePath(registryPath)
9571
if err != nil {
96-
return fmt.Errorf("failed to read registry file: %w", err)
72+
return fmt.Errorf("local registry %w", err)
9773
}
9874

99-
// Basic JSON validation
100-
var registry map[string]interface{}
101-
if err := json.Unmarshal(registryContent, &registry); err != nil {
102-
return fmt.Errorf("invalid JSON format in registry file: %w", err)
75+
// Validate JSON file
76+
if err := validateJSONFile(cleanPath); err != nil {
77+
return fmt.Errorf("registry file: %w", err)
10378
}
10479

10580
// Make the path absolute
106-
absPath, err := filepath.Abs(registryPath)
81+
absPath, err := makeAbsolutePath(cleanPath)
10782
if err != nil {
108-
return fmt.Errorf("failed to resolve absolute path: %w", err)
83+
return fmt.Errorf("registry file: %w", err)
10984
}
11085

11186
// Update the configuration

pkg/config/validation.go

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
package config
2+
3+
import (
4+
"encoding/json"
5+
"errors"
6+
"fmt"
7+
neturl "net/url"
8+
"os"
9+
"path/filepath"
10+
"strings"
11+
12+
"github.com/stacklok/toolhive/pkg/networking"
13+
)
14+
15+
// Error message templates for consistent error formatting
16+
const (
17+
errFileNotFound = "file not found or not accessible: %w"
18+
errFileRead = "failed to read file: %w"
19+
errInvalidJSON = "invalid JSON format: %w"
20+
errInvalidURL = "invalid URL format: %w"
21+
errInvalidURLScheme = "URL must start with %s://"
22+
errJSONExtensionOnly = "file must be a JSON file (*.json)"
23+
errAbsolutePathResolve = "failed to resolve absolute path: %w"
24+
)
25+
26+
// validateFilePath validates that a file path exists and is accessible.
27+
// It also cleans the file path using filepath.Clean.
28+
// Returns the cleaned path and an error if the file doesn't exist or isn't accessible.
29+
func validateFilePath(path string) (string, error) {
30+
cleanPath := filepath.Clean(path)
31+
32+
if _, err := os.Stat(cleanPath); err != nil {
33+
return "", fmt.Errorf(errFileNotFound, err)
34+
}
35+
36+
return cleanPath, nil
37+
}
38+
39+
// validateFileExists checks if a file exists and is accessible without cleaning the path.
40+
// This is useful when the path has already been cleaned.
41+
func validateFileExists(path string) error {
42+
if _, err := os.Stat(path); err != nil {
43+
return fmt.Errorf(errFileNotFound, err)
44+
}
45+
return nil
46+
}
47+
48+
// readFile reads the contents of a file and returns the data.
49+
// This is a wrapper around os.ReadFile with consistent error messaging.
50+
func readFile(path string) ([]byte, error) {
51+
// #nosec G304: File path is user-provided but should be validated by caller
52+
data, err := os.ReadFile(path)
53+
if err != nil {
54+
return nil, fmt.Errorf(errFileRead, err)
55+
}
56+
return data, nil
57+
}
58+
59+
// validateJSONFile validates that a file contains valid JSON.
60+
// It checks the file extension and attempts to parse the content.
61+
func validateJSONFile(path string) error {
62+
// Check file extension
63+
if !strings.HasSuffix(strings.ToLower(path), ".json") {
64+
return errors.New(errJSONExtensionOnly)
65+
}
66+
67+
// Read and validate JSON content
68+
data, err := readFile(path)
69+
if err != nil {
70+
return err
71+
}
72+
73+
// Basic JSON validation - unmarshal into generic map
74+
var jsonData map[string]interface{}
75+
if err := json.Unmarshal(data, &jsonData); err != nil {
76+
return fmt.Errorf(errInvalidJSON, err)
77+
}
78+
79+
return nil
80+
}
81+
82+
// validateURLScheme validates that a URL has the correct scheme (http or https).
83+
// If allowInsecure is false, only https is allowed.
84+
// If allowInsecure is true, both http and https are allowed.
85+
func validateURLScheme(rawURL string, allowInsecure bool) (*neturl.URL, error) {
86+
parsedURL, err := neturl.Parse(rawURL)
87+
if err != nil {
88+
return nil, fmt.Errorf(errInvalidURL, err)
89+
}
90+
91+
if allowInsecure {
92+
// Allow both http and https
93+
if parsedURL.Scheme != networking.HttpScheme && parsedURL.Scheme != networking.HttpsScheme {
94+
return nil, fmt.Errorf("URL must start with http:// or https://")
95+
}
96+
} else {
97+
// Only allow https
98+
if parsedURL.Scheme != networking.HttpsScheme {
99+
return nil, fmt.Errorf(errInvalidURLScheme, networking.HttpsScheme)
100+
}
101+
}
102+
103+
return parsedURL, nil
104+
}
105+
106+
// makeAbsolutePath converts a relative path to an absolute path.
107+
func makeAbsolutePath(path string) (string, error) {
108+
absPath, err := filepath.Abs(path)
109+
if err != nil {
110+
return "", fmt.Errorf(errAbsolutePathResolve, err)
111+
}
112+
return absPath, nil
113+
}

0 commit comments

Comments
 (0)