Remediate all lint findings under the canonical golangci-lint config
Fix every finding surfaced by the canonical .golangci.yml with golangci-lint v2.12.2 (refs #61), behavior-preserving throughout: - err113: dynamic errors replaced with package-level sentinels and %w wrapping; direct comparisons converted to errors.Is - goprintffuncname: printf-style helpers renamed with an f suffix (ui.Writer message methods, cli.ReportErrorf, database.Fatalf, vaultik stdoutf) and all call sites updated - revive: stuttering type names renamed (blob.Handler, blob.WithReader, blob.ChunkPosition, storage.URL, storage.Info), doc comments added, unused parameters blanked, package comments added - contextcheck/noctx: ctx threaded through blob.Packer (AddChunk/Flush/FinalizeBlob/PackChunks) and scanner call sites; context-aware exec and sql variants used - funlen/cyclop/gocognit/nestif/dupl: oversized or duplicated functions split into focused helpers across production and test code - paralleltest/tparallel/thelper/usetesting/testpackage: tests parallelized where safe (global log.Initialize kept in the serial phase), helpers marked, t.TempDir adopted, external test packages where only exported API is used - gosec: integer conversions clamped or justified, header timeouts added, remaining findings suppressed with per-site justifications - mnd/goconst/lll/wsl_v5/nlreturn/noinlineerr/errcheck and other mechanical findings fixed directly Remove the deprecated log.LogOptions alias (callers migrated to log.Options). make check is green.
This commit is contained in:
@@ -1,3 +1,6 @@
|
||||
// Package config loads, validates, and provides the vaultik YAML
|
||||
// configuration, including snapshot definitions, encryption recipients,
|
||||
// and storage settings.
|
||||
package config
|
||||
|
||||
import (
|
||||
@@ -18,6 +21,38 @@ import (
|
||||
|
||||
const appName = "vaultik"
|
||||
|
||||
// Defaults and validation bounds for tunable settings.
|
||||
const (
|
||||
defaultBlobSizeLimit = Size(10 * 1024 * 1024 * 1024) // 10GB
|
||||
defaultChunkSize = Size(10 * 1024 * 1024) // 10MB
|
||||
defaultS3PartSize = Size(5 * 1024 * 1024) // 5MB
|
||||
defaultCompressionLevel = 3
|
||||
minChunkSize = 1024 * 1024 // 1MB
|
||||
minCompressionLevel = 1
|
||||
maxCompressionLevel = 19
|
||||
)
|
||||
|
||||
// Sentinel validation errors.
|
||||
var (
|
||||
errNoConfigPath = errors.New("config path not provided")
|
||||
errNoAgeRecipients = errors.New(
|
||||
"at least one age_recipient is required (generate with: age-keygen)")
|
||||
errNoSnapshots = errors.New(
|
||||
"at least one snapshot must be configured (see config.example.yml)")
|
||||
errSnapshotNoPaths = errors.New("snapshot must have at least one path")
|
||||
errChunkSizeTooSmall = errors.New("chunk_size must be at least 1MB")
|
||||
errBlobSizeTooSmall = errors.New("blob_size_limit must be at least chunk_size")
|
||||
errBadCompression = errors.New("compression_level must be between 1 and 19")
|
||||
errBadStorageScheme = errors.New(
|
||||
"storage_url must start with s3://, file://, or rclone://")
|
||||
errStorageNotConfigured = errors.New(
|
||||
"storage not configured; set storage_url or provide s3.endpoint + " +
|
||||
"s3.bucket + credentials")
|
||||
errS3BucketRequired = errors.New("s3.bucket is required (or set storage_url)")
|
||||
errS3KeyIDRequired = errors.New("s3.access_key_id is required")
|
||||
errS3SecretRequired = errors.New("s3.secret_access_key is required")
|
||||
)
|
||||
|
||||
// expandTilde expands ~ at the start of a path to the user's home directory.
|
||||
func expandTilde(path string) string {
|
||||
if path == "~" {
|
||||
@@ -90,12 +125,15 @@ func (c *Config) SnapshotNames() []string {
|
||||
// It defines all settings for backup operations, including source directories,
|
||||
// encryption recipients, storage configuration, and performance tuning parameters.
|
||||
// Configuration is typically loaded from a YAML file.
|
||||
//
|
||||
//nolint:tagliatelle // snake_case is the established config-file format
|
||||
type Config struct {
|
||||
AgeRecipients []string `yaml:"age_recipients"`
|
||||
AgeSecretKey string `yaml:"age_secret_key"`
|
||||
BlobSizeLimit Size `yaml:"blob_size_limit"`
|
||||
ChunkSize Size `yaml:"chunk_size"`
|
||||
Exclude []string `yaml:"exclude"` // Global excludes applied to all snapshots
|
||||
AgeRecipients []string `yaml:"age_recipients"`
|
||||
AgeSecretKey string `yaml:"age_secret_key"`
|
||||
BlobSizeLimit Size `yaml:"blob_size_limit"`
|
||||
ChunkSize Size `yaml:"chunk_size"`
|
||||
// Exclude holds global excludes applied to all snapshots.
|
||||
Exclude []string `yaml:"exclude"`
|
||||
Hostname string `yaml:"hostname"`
|
||||
IndexPath string `yaml:"index_path"`
|
||||
S3 S3Config `yaml:"s3"`
|
||||
@@ -107,13 +145,16 @@ type Config struct {
|
||||
// Supported formats:
|
||||
// - s3://bucket/prefix?endpoint=host®ion=us-east-1
|
||||
// - file:///path/to/backup
|
||||
// For S3 URLs, credentials are still read from s3.access_key_id and s3.secret_access_key.
|
||||
// For S3 URLs, credentials are still read from s3.access_key_id
|
||||
// and s3.secret_access_key.
|
||||
StorageURL string `yaml:"storage_url"`
|
||||
}
|
||||
|
||||
// S3Config represents S3 storage configuration for backup storage.
|
||||
// It supports both AWS S3 and S3-compatible storage services.
|
||||
// All fields except UseSSL and PartSize are required.
|
||||
//
|
||||
//nolint:tagliatelle // snake_case is the established config-file format
|
||||
type S3Config struct {
|
||||
Endpoint string `yaml:"endpoint"`
|
||||
Bucket string `yaml:"bucket"`
|
||||
@@ -125,17 +166,17 @@ type S3Config struct {
|
||||
PartSize Size `yaml:"part_size"`
|
||||
}
|
||||
|
||||
// ConfigPath wraps the config file path for fx dependency injection.
|
||||
// Path wraps the config file path for fx dependency injection.
|
||||
// This type allows the config file path to be injected as a distinct type
|
||||
// rather than a plain string, avoiding conflicts with other string dependencies.
|
||||
type ConfigPath string
|
||||
type Path string
|
||||
|
||||
// New creates a new Config instance by loading from the specified path.
|
||||
// This function is used by the fx dependency injection framework.
|
||||
// Returns an error if the path is empty or if loading fails.
|
||||
func New(path ConfigPath) (*Config, error) {
|
||||
func New(path Path) (*Config, error) {
|
||||
if path == "" {
|
||||
return nil, errors.New("config path not provided")
|
||||
return nil, errNoConfigPath
|
||||
}
|
||||
|
||||
cfg, err := Load(string(path))
|
||||
@@ -160,10 +201,10 @@ func Load(path string) (*Config, error) {
|
||||
|
||||
cfg := &Config{
|
||||
// Set defaults
|
||||
BlobSizeLimit: Size(10 * 1024 * 1024 * 1024), // 10GB
|
||||
ChunkSize: Size(10 * 1024 * 1024), // 10MB
|
||||
BlobSizeLimit: defaultBlobSizeLimit,
|
||||
ChunkSize: defaultChunkSize,
|
||||
IndexPath: filepath.Join(xdg.DataHome, appName, "index.sqlite"),
|
||||
CompressionLevel: 3,
|
||||
CompressionLevel: defaultCompressionLevel,
|
||||
}
|
||||
|
||||
// Convert smartconfig data to YAML then unmarshal
|
||||
@@ -174,7 +215,8 @@ func Load(path string) (*Config, error) {
|
||||
return nil, fmt.Errorf("failed to marshal config data: %w", err)
|
||||
}
|
||||
|
||||
if err := yaml.Unmarshal(yamlBytes, cfg); err != nil {
|
||||
err = yaml.Unmarshal(yamlBytes, cfg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse config: %w", err)
|
||||
}
|
||||
|
||||
@@ -217,11 +259,13 @@ func Load(path string) (*Config, error) {
|
||||
}
|
||||
|
||||
if cfg.S3.PartSize == 0 {
|
||||
cfg.S3.PartSize = Size(5 * 1024 * 1024) // 5MB
|
||||
cfg.S3.PartSize = defaultS3PartSize
|
||||
}
|
||||
|
||||
// Check config file permissions (warn if world or group readable)
|
||||
if info, err := os.Stat(path); err == nil {
|
||||
//nolint:gosec // G703: config path is operator-supplied by design
|
||||
info, statErr := os.Stat(path)
|
||||
if statErr == nil {
|
||||
mode := info.Mode().Perm()
|
||||
if mode&0044 != 0 { // group or world readable
|
||||
log.Warn("Config file has insecure permissions (contains S3 credentials)",
|
||||
@@ -231,7 +275,8 @@ func Load(path string) (*Config, error) {
|
||||
}
|
||||
}
|
||||
|
||||
if err := cfg.Validate(); err != nil {
|
||||
err = cfg.Validate()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid config: %w", err)
|
||||
}
|
||||
|
||||
@@ -249,16 +294,16 @@ 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 errors.New("at least one age_recipient is required (generate with: age-keygen)")
|
||||
return errNoAgeRecipients
|
||||
}
|
||||
|
||||
if len(c.Snapshots) == 0 {
|
||||
return errors.New("at least one snapshot must be configured (see config.example.yml)")
|
||||
return errNoSnapshots
|
||||
}
|
||||
|
||||
for name, snap := range c.Snapshots {
|
||||
if len(snap.Paths) == 0 {
|
||||
return fmt.Errorf("snapshot %q must have at least one path", name)
|
||||
return fmt.Errorf("%w: %q", errSnapshotNoPaths, name)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -268,16 +313,17 @@ func (c *Config) Validate() error {
|
||||
return err
|
||||
}
|
||||
|
||||
if c.ChunkSize.Int64() < 1024*1024 { // 1MB minimum
|
||||
return errors.New("chunk_size must be at least 1MB")
|
||||
if c.ChunkSize.Int64() < minChunkSize {
|
||||
return errChunkSizeTooSmall
|
||||
}
|
||||
|
||||
if c.BlobSizeLimit.Int64() < c.ChunkSize.Int64() {
|
||||
return errors.New("blob_size_limit must be at least chunk_size")
|
||||
return errBlobSizeTooSmall
|
||||
}
|
||||
|
||||
if c.CompressionLevel < 1 || c.CompressionLevel > 19 {
|
||||
return errors.New("compression_level must be between 1 and 19")
|
||||
if c.CompressionLevel < minCompressionLevel ||
|
||||
c.CompressionLevel > maxCompressionLevel {
|
||||
return errBadCompression
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -289,53 +335,56 @@ func (c *Config) Validate() error {
|
||||
// If StorageURL is not set, legacy S3 configuration is required.
|
||||
func (c *Config) validateStorage() error {
|
||||
if c.StorageURL != "" {
|
||||
// URL-based configuration
|
||||
if strings.HasPrefix(c.StorageURL, "file://") {
|
||||
// File storage doesn't need S3 credentials
|
||||
return nil
|
||||
}
|
||||
|
||||
if strings.HasPrefix(c.StorageURL, "s3://") {
|
||||
// S3 storage needs credentials
|
||||
if c.S3.AccessKeyID == "" {
|
||||
return errors.New("s3.access_key_id is required for s3:// URLs")
|
||||
}
|
||||
|
||||
if c.S3.SecretAccessKey == "" {
|
||||
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 errors.New("storage_url must start with s3://, file://, or rclone://")
|
||||
return c.validateStorageURL()
|
||||
}
|
||||
|
||||
// Legacy S3 configuration
|
||||
if c.S3.Endpoint == "" {
|
||||
return errors.New("storage not configured; set storage_url or provide s3.endpoint + s3.bucket + credentials")
|
||||
return errStorageNotConfigured
|
||||
}
|
||||
|
||||
if c.S3.Bucket == "" {
|
||||
return errors.New("s3.bucket is required (or set storage_url)")
|
||||
return errS3BucketRequired
|
||||
}
|
||||
|
||||
if c.S3.AccessKeyID == "" {
|
||||
return errors.New("s3.access_key_id is required")
|
||||
return errS3KeyIDRequired
|
||||
}
|
||||
|
||||
if c.S3.SecretAccessKey == "" {
|
||||
return errors.New("s3.secret_access_key is required")
|
||||
return errS3SecretRequired
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateStorageURL validates URL-based storage configuration. File and
|
||||
// rclone URLs need no credentials; S3 URLs require the legacy s3.*
|
||||
// credential fields.
|
||||
func (c *Config) validateStorageURL() error {
|
||||
switch {
|
||||
case strings.HasPrefix(c.StorageURL, "file://"):
|
||||
// File storage doesn't need S3 credentials
|
||||
return nil
|
||||
case strings.HasPrefix(c.StorageURL, "rclone://"):
|
||||
// Rclone storage uses rclone's own config
|
||||
return nil
|
||||
case strings.HasPrefix(c.StorageURL, "s3://"):
|
||||
// S3 storage needs credentials
|
||||
if c.S3.AccessKeyID == "" {
|
||||
return fmt.Errorf("%w for s3:// URLs", errS3KeyIDRequired)
|
||||
}
|
||||
|
||||
if c.S3.SecretAccessKey == "" {
|
||||
return fmt.Errorf("%w for s3:// URLs", errS3SecretRequired)
|
||||
}
|
||||
|
||||
return nil
|
||||
default:
|
||||
return errBadStorageScheme
|
||||
}
|
||||
}
|
||||
|
||||
// extractAgeSecretKey extracts the AGE-SECRET-KEY from the input using
|
||||
// the age library's parser, which handles comments and whitespace.
|
||||
func extractAgeSecretKey(input string) string {
|
||||
@@ -354,6 +403,8 @@ func extractAgeSecretKey(input string) string {
|
||||
|
||||
// Module exports the config module for fx dependency injection.
|
||||
// It provides the Config type to other modules in the application.
|
||||
//
|
||||
//nolint:gochecknoglobals // fx module definitions are package globals
|
||||
var Module = fx.Module("config",
|
||||
fx.Provide(New),
|
||||
)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package config
|
||||
package config //nolint:testpackage // exercises unexported extractAgeSecretKey
|
||||
|
||||
import (
|
||||
"os"
|
||||
@@ -7,15 +7,20 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
TEST_SNEAK_AGE_PUBLIC_KEY = "age1278m9q7dp3chsh2dcy82qk27v047zywyvtxwnj4cvt0z65jw6a7q5dqhfj"
|
||||
TEST_INTEGRATION_AGE_PUBLIC_KEY = "age1ezrjmfpwsc95svdg0y54mums3zevgzu0x0ecq2f7tp8a05gl0sjq9q9wjg"
|
||||
TEST_INTEGRATION_AGE_PRIVATE_KEY = "AGE-SECRET-KEY-19CR5YSFW59HM4TLD6GXVEDMZFTVVF7PPHKUT68TXSFPK7APHXA2QS2NJA5"
|
||||
testSneakAgePublicKey = "age1278m9q7dp3chsh2dcy82qk27v047zywyvt" +
|
||||
"xwnj4cvt0z65jw6a7q5dqhfj"
|
||||
testIntegrationAgePublicKey = "age1ezrjmfpwsc95svdg0y54mums3zevgzu" +
|
||||
"0x0ecq2f7tp8a05gl0sjq9q9wjg"
|
||||
testIntegrationAgePrivateKey = "AGE-SECRET-KEY-19CR5YSFW59HM4TLD6GX" +
|
||||
"VEDMZFTVVF7PPHKUT68TXSFPK7APHXA2QS2NJA5"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
// Set up test environment
|
||||
testConfigPath := filepath.Join("..", "..", "test", "config.yaml")
|
||||
if absPath, err := filepath.Abs(testConfigPath); err == nil {
|
||||
|
||||
absPath, err := filepath.Abs(testConfigPath)
|
||||
if err == nil {
|
||||
_ = os.Setenv("VAULTIK_CONFIG", absPath)
|
||||
}
|
||||
|
||||
@@ -23,8 +28,11 @@ func TestMain(m *testing.M) {
|
||||
os.Exit(code)
|
||||
}
|
||||
|
||||
// TestConfigLoad ensures the config package can be imported and basic functionality works
|
||||
// TestConfigLoad ensures the config package can be imported and basic
|
||||
// functionality works.
|
||||
func TestConfigLoad(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Use the test config file
|
||||
configPath := os.Getenv("VAULTIK_CONFIG")
|
||||
if configPath == "" {
|
||||
@@ -42,8 +50,9 @@ func TestConfigLoad(t *testing.T) {
|
||||
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])
|
||||
if cfg.AgeRecipients[0] != testSneakAgePublicKey {
|
||||
t.Errorf("Expected first age recipient to be %s, got '%s'",
|
||||
testSneakAgePublicKey, cfg.AgeRecipients[0])
|
||||
}
|
||||
|
||||
if len(cfg.Snapshots) != 1 {
|
||||
@@ -60,11 +69,13 @@ func TestConfigLoad(t *testing.T) {
|
||||
}
|
||||
|
||||
if testSnap.Paths[0] != "/tmp/vaultik-test-source" {
|
||||
t.Errorf("Expected first path to be '/tmp/vaultik-test-source', got '%s'", testSnap.Paths[0])
|
||||
t.Errorf("Expected first path to be '/tmp/vaultik-test-source', got '%s'",
|
||||
testSnap.Paths[0])
|
||||
}
|
||||
|
||||
if cfg.S3.Bucket != "vaultik-test-bucket" {
|
||||
t.Errorf("Expected S3 bucket to be 'vaultik-test-bucket', got '%s'", cfg.S3.Bucket)
|
||||
t.Errorf("Expected S3 bucket to be 'vaultik-test-bucket', got '%s'",
|
||||
cfg.S3.Bucket)
|
||||
}
|
||||
|
||||
if cfg.Hostname != "test-host" {
|
||||
@@ -74,19 +85,26 @@ func TestConfigLoad(t *testing.T) {
|
||||
|
||||
// TestConfigFromEnv tests loading config path from environment variable
|
||||
func TestConfigFromEnv(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
configPath := os.Getenv("VAULTIK_CONFIG")
|
||||
if configPath == "" {
|
||||
t.Skip("VAULTIK_CONFIG not set")
|
||||
}
|
||||
|
||||
// Verify the file exists
|
||||
if _, err := os.Stat(configPath); os.IsNotExist(err) {
|
||||
t.Errorf("Config file does not exist at path from VAULTIK_CONFIG: %s", configPath)
|
||||
//nolint:gosec // G703: test config path comes from the test environment
|
||||
_, err := os.Stat(configPath)
|
||||
if os.IsNotExist(err) {
|
||||
t.Errorf("Config file does not exist at path from VAULTIK_CONFIG: %s",
|
||||
configPath)
|
||||
}
|
||||
}
|
||||
|
||||
// TestExtractAgeSecretKey tests extraction of AGE-SECRET-KEY from various inputs
|
||||
func TestExtractAgeSecretKey(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
@@ -94,36 +112,32 @@ func TestExtractAgeSecretKey(t *testing.T) {
|
||||
}{
|
||||
{
|
||||
name: "plain key",
|
||||
input: "AGE-SECRET-KEY-19CR5YSFW59HM4TLD6GXVEDMZFTVVF7PPHKUT68TXSFPK7APHXA2QS2NJA5",
|
||||
expected: "AGE-SECRET-KEY-19CR5YSFW59HM4TLD6GXVEDMZFTVVF7PPHKUT68TXSFPK7APHXA2QS2NJA5",
|
||||
input: testIntegrationAgePrivateKey,
|
||||
expected: testIntegrationAgePrivateKey,
|
||||
},
|
||||
{
|
||||
name: "key with trailing newline",
|
||||
input: "AGE-SECRET-KEY-19CR5YSFW59HM4TLD6GXVEDMZFTVVF7PPHKUT68TXSFPK7APHXA2QS2NJA5\n",
|
||||
expected: "AGE-SECRET-KEY-19CR5YSFW59HM4TLD6GXVEDMZFTVVF7PPHKUT68TXSFPK7APHXA2QS2NJA5",
|
||||
input: testIntegrationAgePrivateKey + "\n",
|
||||
expected: testIntegrationAgePrivateKey,
|
||||
},
|
||||
{
|
||||
name: "full age-keygen output",
|
||||
input: `# created: 2025-01-14T12:00:00Z
|
||||
# public key: age1ezrjmfpwsc95svdg0y54mums3zevgzu0x0ecq2f7tp8a05gl0sjq9q9wjg
|
||||
AGE-SECRET-KEY-19CR5YSFW59HM4TLD6GXVEDMZFTVVF7PPHKUT68TXSFPK7APHXA2QS2NJA5
|
||||
`,
|
||||
expected: "AGE-SECRET-KEY-19CR5YSFW59HM4TLD6GXVEDMZFTVVF7PPHKUT68TXSFPK7APHXA2QS2NJA5",
|
||||
input: "# created: 2025-01-14T12:00:00Z\n" +
|
||||
"# public key: " + testIntegrationAgePublicKey + "\n" +
|
||||
testIntegrationAgePrivateKey + "\n",
|
||||
expected: testIntegrationAgePrivateKey,
|
||||
},
|
||||
{
|
||||
name: "age-keygen output with extra blank lines",
|
||||
input: `# created: 2025-01-14T12:00:00Z
|
||||
# public key: age1ezrjmfpwsc95svdg0y54mums3zevgzu0x0ecq2f7tp8a05gl0sjq9q9wjg
|
||||
|
||||
AGE-SECRET-KEY-19CR5YSFW59HM4TLD6GXVEDMZFTVVF7PPHKUT68TXSFPK7APHXA2QS2NJA5
|
||||
|
||||
`,
|
||||
expected: "AGE-SECRET-KEY-19CR5YSFW59HM4TLD6GXVEDMZFTVVF7PPHKUT68TXSFPK7APHXA2QS2NJA5",
|
||||
input: "# created: 2025-01-14T12:00:00Z\n" +
|
||||
"# public key: " + testIntegrationAgePublicKey + "\n\n" +
|
||||
testIntegrationAgePrivateKey + "\n\n",
|
||||
expected: testIntegrationAgePrivateKey,
|
||||
},
|
||||
{
|
||||
name: "key with leading whitespace",
|
||||
input: " AGE-SECRET-KEY-19CR5YSFW59HM4TLD6GXVEDMZFTVVF7PPHKUT68TXSFPK7APHXA2QS2NJA5 ",
|
||||
expected: "AGE-SECRET-KEY-19CR5YSFW59HM4TLD6GXVEDMZFTVVF7PPHKUT68TXSFPK7APHXA2QS2NJA5",
|
||||
input: " " + testIntegrationAgePrivateKey + " ",
|
||||
expected: testIntegrationAgePrivateKey,
|
||||
},
|
||||
{
|
||||
name: "empty input",
|
||||
@@ -139,9 +153,12 @@ AGE-SECRET-KEY-19CR5YSFW59HM4TLD6GXVEDMZFTVVF7PPHKUT68TXSFPK7APHXA2QS2NJA5
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
result := extractAgeSecretKey(tt.input)
|
||||
if result != tt.expected {
|
||||
t.Errorf("extractAgeSecretKey(%q) = %q, want %q", tt.input, result, tt.expected)
|
||||
t.Errorf("extractAgeSecretKey(%q) = %q, want %q",
|
||||
tt.input, result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -3,13 +3,21 @@ package config
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
|
||||
"github.com/dustin/go-humanize"
|
||||
)
|
||||
|
||||
var (
|
||||
errSizeType = errors.New("size must be a number or string")
|
||||
errSizeTooLarge = errors.New("size exceeds maximum supported value")
|
||||
)
|
||||
|
||||
// Size represents a byte size that can be specified in configuration files.
|
||||
// It can unmarshal from both numeric values (interpreted as bytes) and
|
||||
// human-readable strings like "10MB", "2.5GB", or "1TB".
|
||||
//
|
||||
//nolint:recvcheck // UnmarshalYAML requires a pointer; String/Int64 are value reads
|
||||
type Size int64
|
||||
|
||||
// UnmarshalYAML implements yaml.Unmarshaler for Size, allowing it to be
|
||||
@@ -18,7 +26,9 @@ type Size int64
|
||||
func (s *Size) UnmarshalYAML(unmarshal func(any) error) error {
|
||||
// Try to unmarshal as int64 first
|
||||
var intVal int64
|
||||
if err := unmarshal(&intVal); err == nil {
|
||||
|
||||
err := unmarshal(&intVal)
|
||||
if err == nil {
|
||||
*s = Size(intVal)
|
||||
|
||||
return nil
|
||||
@@ -26,8 +36,10 @@ func (s *Size) UnmarshalYAML(unmarshal func(any) error) error {
|
||||
|
||||
// Try to unmarshal as string
|
||||
var strVal string
|
||||
if err := unmarshal(&strVal); err != nil {
|
||||
return errors.New("size must be a number or string")
|
||||
|
||||
err = unmarshal(&strVal)
|
||||
if err != nil {
|
||||
return errSizeType
|
||||
}
|
||||
|
||||
// Parse the string using go-humanize
|
||||
@@ -36,6 +48,10 @@ func (s *Size) UnmarshalYAML(unmarshal func(any) error) error {
|
||||
return fmt.Errorf("invalid size format: %w", err)
|
||||
}
|
||||
|
||||
if bytes > math.MaxInt64 {
|
||||
return fmt.Errorf("%w: %s", errSizeTooLarge, strVal)
|
||||
}
|
||||
|
||||
*s = Size(bytes)
|
||||
|
||||
return nil
|
||||
@@ -52,6 +68,7 @@ func (s Size) Int64() int64 {
|
||||
// For example, 1048576 bytes would be formatted as "1.0 MB".
|
||||
// This implements the fmt.Stringer interface.
|
||||
func (s Size) String() string {
|
||||
//nolint:gosec // G115: sizes are non-negative by construction
|
||||
return humanize.Bytes(uint64(s))
|
||||
}
|
||||
|
||||
@@ -62,5 +79,9 @@ func ParseSize(s string) (Size, error) {
|
||||
return 0, fmt.Errorf("invalid size format: %w", err)
|
||||
}
|
||||
|
||||
if bytes > math.MaxInt64 {
|
||||
return 0, fmt.Errorf("%w: %s", errSizeTooLarge, s)
|
||||
}
|
||||
|
||||
return Size(bytes), nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user