diff --git a/internal/config/config.go b/internal/config/config.go index a47f347..cabf735 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1,6 +1,7 @@ package config import ( + "errors" "fmt" "os" "path/filepath" @@ -21,12 +22,16 @@ const appName = "vaultik" func expandTilde(path string) string { if path == "~" { home, _ := os.UserHomeDir() + return home } + if strings.HasPrefix(path, "~/") { home, _ := os.UserHomeDir() + return filepath.Join(home, path[2:]) } + return path } @@ -34,8 +39,10 @@ func expandTilde(path string) string { func expandTildeInURL(url string) string { if strings.HasPrefix(url, "file://~/") { home, _ := os.UserHomeDir() + return "file://" + filepath.Join(home, url[9:]) } + return url } @@ -63,6 +70,7 @@ func (c *Config) GetExcludes(snapshotName string) []string { combined := make([]string, 0, len(c.Exclude)+len(snap.Exclude)) combined = append(combined, c.Exclude...) combined = append(combined, snap.Exclude...) + return combined } @@ -74,6 +82,7 @@ func (c *Config) SnapshotNames() []string { } // Sort for deterministic order sort.Strings(names) + return names } @@ -126,7 +135,7 @@ type ConfigPath string // Returns an error if the path is empty or if loading fails. func New(path ConfigPath) (*Config, error) { if path == "" { - return nil, fmt.Errorf("config path not provided") + return nil, errors.New("config path not provided") } cfg, err := Load(string(path)) @@ -159,6 +168,7 @@ func Load(path string) (*Config, error) { // Convert smartconfig data to YAML then unmarshal configData := sc.Data() + yamlBytes, err := yaml.Marshal(configData) if err != nil { return nil, fmt.Errorf("failed to marshal config data: %w", err) @@ -177,6 +187,7 @@ func Load(path string) (*Config, error) { for i, path := range snap.Paths { snap.Paths[i] = expandTilde(path) } + cfg.Snapshots[name] = snap } @@ -196,6 +207,7 @@ func Load(path string) (*Config, error) { if err != nil { return nil, fmt.Errorf("failed to get hostname: %w", err) } + cfg.Hostname = hostname } @@ -203,6 +215,7 @@ func Load(path string) (*Config, error) { if cfg.S3.Region == "" { cfg.S3.Region = "us-east-1" } + if cfg.S3.PartSize == 0 { cfg.S3.PartSize = Size(5 * 1024 * 1024) // 5MB } @@ -236,11 +249,11 @@ func Load(path string) (*Config, error) { // Returns an error describing the first validation failure encountered. func (c *Config) Validate() error { if len(c.AgeRecipients) == 0 { - return fmt.Errorf("at least one age_recipient is required (generate with: age-keygen)") + return errors.New("at least one age_recipient is required (generate with: age-keygen)") } if len(c.Snapshots) == 0 { - return fmt.Errorf("at least one snapshot must be configured (see config.example.yml)") + return errors.New("at least one snapshot must be configured (see config.example.yml)") } for name, snap := range c.Snapshots { @@ -250,20 +263,21 @@ func (c *Config) Validate() error { } // Validate storage configuration - if err := c.validateStorage(); err != nil { + err := c.validateStorage() + if err != nil { return err } if c.ChunkSize.Int64() < 1024*1024 { // 1MB minimum - return fmt.Errorf("chunk_size must be at least 1MB") + return errors.New("chunk_size must be at least 1MB") } if c.BlobSizeLimit.Int64() < c.ChunkSize.Int64() { - return fmt.Errorf("blob_size_limit must be at least chunk_size") + return errors.New("blob_size_limit must be at least chunk_size") } if c.CompressionLevel < 1 || c.CompressionLevel > 19 { - return fmt.Errorf("compression_level must be between 1 and 19") + return errors.New("compression_level must be between 1 and 19") } return nil @@ -280,38 +294,43 @@ func (c *Config) validateStorage() error { // File storage doesn't need S3 credentials return nil } + if strings.HasPrefix(c.StorageURL, "s3://") { // S3 storage needs credentials if c.S3.AccessKeyID == "" { - return fmt.Errorf("s3.access_key_id is required for s3:// URLs") + return errors.New("s3.access_key_id is required for s3:// URLs") } + if c.S3.SecretAccessKey == "" { - return fmt.Errorf("s3.secret_access_key is required for s3:// URLs") + return errors.New("s3.secret_access_key is required for s3:// URLs") } + return nil } + if strings.HasPrefix(c.StorageURL, "rclone://") { // Rclone storage uses rclone's own config return nil } - return fmt.Errorf("storage_url must start with s3://, file://, or rclone://") + + return errors.New("storage_url must start with s3://, file://, or rclone://") } // Legacy S3 configuration if c.S3.Endpoint == "" { - return fmt.Errorf("storage not configured; set storage_url or provide s3.endpoint + s3.bucket + credentials") + return errors.New("storage not configured; set storage_url or provide s3.endpoint + s3.bucket + credentials") } if c.S3.Bucket == "" { - return fmt.Errorf("s3.bucket is required (or set storage_url)") + return errors.New("s3.bucket is required (or set storage_url)") } if c.S3.AccessKeyID == "" { - return fmt.Errorf("s3.access_key_id is required") + return errors.New("s3.access_key_id is required") } if c.S3.SecretAccessKey == "" { - return fmt.Errorf("s3.secret_access_key is required") + return errors.New("s3.secret_access_key is required") } return nil @@ -329,6 +348,7 @@ func extractAgeSecretKey(input string) string { if id, ok := identities[0].(*age.X25519Identity); ok { return id.String() } + return strings.TrimSpace(input) } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 4304af6..08d3bce 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -41,6 +41,7 @@ func TestConfigLoad(t *testing.T) { if len(cfg.AgeRecipients) != 2 { t.Errorf("Expected 2 age recipients, got %d", len(cfg.AgeRecipients)) } + if cfg.AgeRecipients[0] != TEST_SNEAK_AGE_PUBLIC_KEY { t.Errorf("Expected first age recipient to be %s, got '%s'", TEST_SNEAK_AGE_PUBLIC_KEY, cfg.AgeRecipients[0]) } diff --git a/internal/config/size.go b/internal/config/size.go index d66049d..58dbf73 100644 --- a/internal/config/size.go +++ b/internal/config/size.go @@ -1,6 +1,7 @@ package config import ( + "errors" "fmt" "github.com/dustin/go-humanize" @@ -14,18 +15,19 @@ type Size int64 // UnmarshalYAML implements yaml.Unmarshaler for Size, allowing it to be // parsed from YAML configuration files. It accepts both numeric values // (interpreted as bytes) and string values with units (e.g., "10MB"). -func (s *Size) UnmarshalYAML(unmarshal func(interface{}) error) error { +func (s *Size) UnmarshalYAML(unmarshal func(any) error) error { // Try to unmarshal as int64 first var intVal int64 if err := unmarshal(&intVal); err == nil { *s = Size(intVal) + return nil } // Try to unmarshal as string var strVal string if err := unmarshal(&strVal); err != nil { - return fmt.Errorf("size must be a number or string") + return errors.New("size must be a number or string") } // Parse the string using go-humanize @@ -35,6 +37,7 @@ func (s *Size) UnmarshalYAML(unmarshal func(interface{}) error) error { } *s = Size(bytes) + return nil } @@ -58,5 +61,6 @@ func ParseSize(s string) (Size, error) { if err != nil { return 0, fmt.Errorf("invalid size format: %w", err) } + return Size(bytes), nil }