5 files changed,
699 insertions(+),
38 deletions(-)
Author:
Oleksandr Smirnov
olexsmir@gmail.com
Committed at:
2026-02-04 23:34:07 +0200
Change ID:
llzpqltrnktzpynqmvzxpkkvzrtysopo
Parent:
7711375
M
go.mod
@@ -12,6 +12,7 @@ github.com/yuin/goldmark v1.7.16
golang.org/x/crypto v0.47.0 golang.org/x/sync v0.19.0 gopkg.in/yaml.v2 v2.4.0 + olexsmir.xyz/x v0.1.2 ) require (
M
go.sum
@@ -109,3 +109,5 @@ gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +olexsmir.xyz/x v0.1.2 h1:etNRQijfG4Oduip6XClBDIaP/gqugAd9Viu5MNyLcoA= +olexsmir.xyz/x v0.1.2/go.mod h1:mScl7AVsDCUUxvhzh6X2sLGwjEBg2K79AlkE9dgHXUA=
M
internal/config/config.go
@@ -5,46 +5,57 @@ "errors"
"fmt" "os" "path/filepath" + "time" "gopkg.in/yaml.v2" ) var ErrConfigNotFound = errors.New("no config file found") +type ServerConfig struct { + Host string `yaml:"host"` + Port int `yaml:"port"` +} + +type MetaConfig struct { + Title string `yaml:"title"` + Description string `yaml:"description"` + Host string `yaml:"host"` +} + +type RepoConfig struct { + Dir string `yaml:"dir"` + Readmes []string `yaml:"readmes"` + Masters []string `yaml:"masters"` +} + +type SSHConfig struct { + Enable bool `yaml:"enable"` + Port int `yaml:"port"` + HostKey string `yaml:"host_key"` + Keys []string `yaml:"keys"` +} + +type MirrorConfig struct { + Enable bool `yaml:"enable"` + Interval string `yaml:"interval"` + GithubToken string `yaml:"github_token"` +} + type Config struct { - Server struct { - Host string `yaml:"host"` - Port int `yaml:"port"` - } `yaml:"server"` - Meta struct { - Title string `yaml:"title"` - Description string `yaml:"description"` - Host string `yaml:"host"` - } `yaml:"meta"` - Repo struct { - Dir string `yaml:"dir"` - Readmes []string `yaml:"readmes"` - Masters []string `yaml:"masters"` - } `yaml:"repo"` - SSH struct { - Enable bool `yaml:"enable"` - Port int `yaml:"port"` - HostKey string `yaml:"host_key"` - Keys []string `yaml:"keys"` - } `yaml:"ssh"` - Mirror struct { - Enable bool `yaml:"enable"` - Interval string `yaml:"interval"` - GithubToken string `yaml:"github_token"` - } `yaml:"mirror"` + Server ServerConfig `yaml:"server"` + Meta MetaConfig `yaml:"meta"` + Repo RepoConfig `yaml:"repo"` + SSH SSHConfig `yaml:"ssh"` + Mirror MirrorConfig `yaml:"mirror"` } // Load loads configuration with the following priority: // 1. User provided fpath (if provided and exists) -// 2. $XDG_CONFIG_HOME/mugit/config.yaml -// 3. $HOME/.config/mugit/config.yaml (fallback if XDG_CONFIG_HOME not set) -// 4. /etc/mugit/config.yaml +// 2. /var/lib/mugit/config.yaml +// 3. $XDG_CONFIG_HOME/mugit/config.yaml or $HOME/.config/mugit/config.yaml func Load(fpath string) (*Config, error) { + // 4. /etc/mugit/config.yaml configPath, err := findConfigFile(fpath) if err != nil { return nil, err@@ -72,9 +83,55 @@ return &config, nil
} func (c Config) validate() error { - // var errs []error - // return errors.Join(errs...) - return nil + var errs []error + + // server + if err := validatePort(c.Server.Port, "server.port"); err != nil { + errs = append(errs, err) + } + + // meta + if c.Meta.Host == "" { + errs = append(errs, errors.New("meta.host is required")) + } + + // repo + if err := validateDirExists(c.Repo.Dir, "repo.dir"); err != nil { + errs = append(errs, err) + } + if len(c.Repo.Readmes) == 0 { + errs = append(errs, errors.New("repo.readmes must have at least one value")) + } + if len(c.Repo.Masters) == 0 { + errs = append(errs, errors.New("repo.masters must have at least one value")) + } + + // ssh + if c.SSH.Enable { + if err := validatePort(c.SSH.Port, "ssh.port"); err != nil { + errs = append(errs, err) + } + if c.SSH.Port == c.Server.Port { + errs = append(errs, fmt.Errorf("ssh.port must differ from server.port (both are %d)", c.Server.Port)) + } + if err := validateFileExists(c.SSH.HostKey, "ssh.host_key"); err != nil { + errs = append(errs, err) + } + if len(c.SSH.Keys) == 0 { + errs = append(errs, errors.New("ssh.keys must have at least one value when ssh is enabled")) + } + } + + // mirror + if c.Mirror.Enable { + if c.Mirror.Interval == "" { + errs = append(errs, errors.New("mirror.interval is required when mirror is enabled")) + } else if _, err := time.ParseDuration(c.Mirror.Interval); err != nil { + errs = append(errs, fmt.Errorf("mirror.interval: invalid duration format: %w", err)) + } + } + + return errors.Join(errs...) } func findConfigFile(userPath string) (string, error) {@@ -84,19 +141,63 @@ return userPath, nil
} } - paths := []string{} - if xdg := os.Getenv("XDG_CONFIG_HOME"); xdg != "" { - paths = append(paths, filepath.Join(xdg, "mugit", "config.yaml")) - } else if home := os.Getenv("HOME"); home != "" { - paths = append(paths, filepath.Join(home, ".config", "mugit", "config.yaml")) + path := "/var/lib/mugit/config.yaml" + if _, err := os.Stat(path); err == nil { + return path, nil } - paths = append(paths, "/etc/mugit/config.yaml") - for _, p := range paths { + if configDir, err := os.UserConfigDir(); err == nil { + p := filepath.Join(configDir, "mugit", "config.yaml") if _, err := os.Stat(p); err == nil { return p, nil } } + path = "/etc/mugit/config.yaml" + if _, err := os.Stat(path); err == nil { + return path, nil + } + return "", ErrConfigNotFound } + +func validatePort(port int, fieldName string) error { + if port < 1 || port > 65535 { + return fmt.Errorf("%s must be between 1 and 65535, got %d", fieldName, port) + } + return nil +} + +func validateDirExists(path string, fieldName string) error { + if path == "" { + return fmt.Errorf("%s is required", fieldName) + } + info, err := os.Stat(path) + if err != nil { + if os.IsNotExist(err) { + return fmt.Errorf("%s: directory does not exist: %s", fieldName, path) + } + return fmt.Errorf("%s: cannot access directory: %w", fieldName, err) + } + if !info.IsDir() { + return fmt.Errorf("%s: path exists but is not a directory: %s", fieldName, path) + } + return nil +} + +func validateFileExists(path string, fieldName string) error { + if path == "" { + return fmt.Errorf("%s is required", fieldName) + } + info, err := os.Stat(path) + if err != nil { + if os.IsNotExist(err) { + return fmt.Errorf("%s: file does not exist: %s", fieldName, path) + } + return fmt.Errorf("%s: cannot access file: %w", fieldName, err) + } + if info.IsDir() { + return fmt.Errorf("%s: path is a directory, not a file: %s", fieldName, path) + } + return nil +}
A
internal/config/config_test.go
@@ -0,0 +1,556 @@
+package config + +import ( + "os" + "path/filepath" + "testing" + + "olexsmir.xyz/x/is" +) + +func TestFindConfigFile(t *testing.T) { + t.Run("returns user provided path when it exists", func(t *testing.T) { + path, err := findConfigFile("testdata/hostkey") + is.Err(t, err, nil) + is.Equal(t, path, "testdata/hostkey") + }) + + t.Run("falls back when user path doesn't exist", func(t *testing.T) { + path, err := findConfigFile("/nonexistent/user/config.yaml") + if err != nil { + is.Err(t, err, ErrConfigNotFound) + } else { + _, statErr := os.Stat(path) + is.Err(t, statErr, nil) + } + }) + + t.Run("finds config in user config directory", func(t *testing.T) { + tmpDir := t.TempDir() + configDir := filepath.Join(tmpDir, "mugit") + if err := os.MkdirAll(configDir, 0o755); err != nil { + t.Fatal(err) + } + configFile := filepath.Join(configDir, "config.yaml") + if err := os.WriteFile(configFile, []byte("test"), 0o644); err != nil { + t.Fatal(err) + } + + t.Setenv("XDG_CONFIG_HOME", tmpDir) + + path, err := findConfigFile("") + is.Err(t, err, nil) + is.Equal(t, path, configFile) + }) + + t.Run("returns error when no config found anywhere", func(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", "/nonexistent") + t.Setenv("HOME", "/nonexistent") + + path, err := findConfigFile("/nonexistent/config.yaml") + is.Err(t, err, ErrConfigNotFound) + is.Equal(t, path, "") + }) + + t.Run("prefers data directory over user config", func(t *testing.T) { + tmpDir := t.TempDir() + configDir := filepath.Join(tmpDir, "mugit") + if err := os.MkdirAll(configDir, 0o755); err != nil { + t.Fatal(err) + } + userConfigFile := filepath.Join(configDir, "config.yaml") + if err := os.WriteFile(userConfigFile, []byte("user config"), 0o644); err != nil { + t.Fatal(err) + } + + t.Setenv("XDG_CONFIG_HOME", tmpDir) + + path, err := findConfigFile("") + is.Err(t, err, nil) + + if path == "/var/lib/mugit/config.yaml" { + _, statErr := os.Stat(path) + is.Err(t, statErr, nil) + } else { + is.Equal(t, path, userConfigFile) + } + }) +} + +func TestValidatePort(t *testing.T) { + t.Run("accepts standard port numbers", func(t *testing.T) { + is.Err(t, validatePort(1, "test"), nil) + is.Err(t, validatePort(80, "test"), nil) + is.Err(t, validatePort(8080, "test"), nil) + is.Err(t, validatePort(65535, "test"), nil) + }) + + t.Run("rejects out of range ports", func(t *testing.T) { + is.Err(t, validatePort(0, "test"), "must be between") + is.Err(t, validatePort(-1, "test"), "must be between") + is.Err(t, validatePort(65536, "test"), "must be between") + is.Err(t, validatePort(100000, "test"), "must be between") + }) +} + +func TestValidateDirExists(t *testing.T) { + t.Run("accepts existing directories", func(t *testing.T) { + tmpDir := t.TempDir() + is.Err(t, validateDirExists(tmpDir, "test"), nil) + }) + + t.Run("rejects nonexistent paths", func(t *testing.T) { + is.Err(t, validateDirExists("/nonexistent/path/to/dir", "test"), "does not exist") + }) + + t.Run("rejects empty paths", func(t *testing.T) { + is.Err(t, validateDirExists("", "test"), "is required") + }) + + t.Run("rejects files when directory expected", func(t *testing.T) { + tmpDir := t.TempDir() + tmpFile := filepath.Join(tmpDir, "file.txt") + if err := os.WriteFile(tmpFile, []byte("test"), 0o644); err != nil { + t.Fatal(err) + } + is.Err(t, validateDirExists(tmpFile, "test"), "not a directory") + }) +} + +func TestValidateFileExists(t *testing.T) { + t.Run("accepts existing files", func(t *testing.T) { + tmpDir := t.TempDir() + tmpFile := filepath.Join(tmpDir, "file.txt") + if err := os.WriteFile(tmpFile, []byte("test"), 0o644); err != nil { + t.Fatal(err) + } + is.Err(t, validateFileExists(tmpFile, "test"), nil) + }) + + t.Run("rejects nonexistent files", func(t *testing.T) { + is.Err(t, validateFileExists("/nonexistent/file.txt", "test"), "does not exist") + }) + + t.Run("rejects empty paths", func(t *testing.T) { + is.Err(t, validateFileExists("", "test"), "is required") + }) + + t.Run("rejects directories when file expected", func(t *testing.T) { + tmpDir := t.TempDir() + is.Err(t, validateFileExists(tmpDir, "test"), "is a directory") + }) +} + +func TestConfigValidate(t *testing.T) { + tmpDir := t.TempDir() + hostKeyPath := "testdata/hostkey" + + t.Run("accepts minimal valid configuration", func(t *testing.T) { + cfg := Config{ + Server: ServerConfig{ + Host: "localhost", + Port: 8080, + }, + Meta: MetaConfig{ + Title: "Test", + Description: "Test description", + Host: "example.com", + }, + Repo: RepoConfig{ + Dir: tmpDir, + Readmes: []string{"README.md"}, + Masters: []string{"main"}, + }, + SSH: SSHConfig{ + Enable: false, + }, + Mirror: MirrorConfig{ + Enable: false, + }, + } + is.Err(t, cfg.validate(), nil) + }) + + t.Run("accepts configuration with SSH enabled", func(t *testing.T) { + cfg := Config{ + Server: ServerConfig{ + Host: "localhost", + Port: 8080, + }, + Meta: MetaConfig{ + Title: "Test", + Description: "Test description", + Host: "example.com", + }, + Repo: RepoConfig{ + Dir: tmpDir, + Readmes: []string{"README.md"}, + Masters: []string{"main"}, + }, + SSH: SSHConfig{ + Enable: true, + Port: 2222, + HostKey: hostKeyPath, + Keys: []string{"ssh-rsa AAAAB3..."}, + }, + Mirror: MirrorConfig{ + Enable: false, + }, + } + is.Err(t, cfg.validate(), nil) + }) + + t.Run("accepts configuration with mirroring enabled", func(t *testing.T) { + cfg := Config{ + Server: ServerConfig{ + Host: "localhost", + Port: 8080, + }, + Meta: MetaConfig{ + Title: "Test", + Description: "Test description", + Host: "example.com", + }, + Repo: RepoConfig{ + Dir: tmpDir, + Readmes: []string{"README.md"}, + Masters: []string{"main"}, + }, + SSH: SSHConfig{ + Enable: false, + }, + Mirror: MirrorConfig{ + Enable: true, + Interval: "1h", + GithubToken: "ghp_token", + }, + } + is.Err(t, cfg.validate(), nil) + }) + + t.Run("rejects invalid server port", func(t *testing.T) { + cfg := Config{ + Server: ServerConfig{ + Port: 0, + }, + Meta: MetaConfig{ + Host: "example.com", + }, + Repo: RepoConfig{ + Dir: tmpDir, + Readmes: []string{"README.md"}, + Masters: []string{"main"}, + }, + } + is.Err(t, cfg.validate(), "server.port") + }) + + t.Run("rejects missing meta host", func(t *testing.T) { + cfg := Config{ + Server: ServerConfig{ + Port: 8080, + }, + Meta: MetaConfig{ + Host: "", + }, + Repo: RepoConfig{ + Dir: tmpDir, + Readmes: []string{"README.md"}, + Masters: []string{"main"}, + }, + } + is.Err(t, cfg.validate(), "meta.host") + }) + + t.Run("rejects nonexistent repository directory", func(t *testing.T) { + cfg := Config{ + Server: ServerConfig{ + Port: 8080, + }, + Meta: MetaConfig{ + Host: "example.com", + }, + Repo: RepoConfig{ + Dir: "/nonexistent/path", + Readmes: []string{"README.md"}, + Masters: []string{"main"}, + }, + } + is.Err(t, cfg.validate(), "repo.dir") + }) + + t.Run("rejects empty readme list", func(t *testing.T) { + cfg := Config{ + Server: ServerConfig{ + Port: 8080, + }, + Meta: MetaConfig{ + Host: "example.com", + }, + Repo: RepoConfig{ + Dir: tmpDir, + Readmes: []string{}, + Masters: []string{"main"}, + }, + } + is.Err(t, cfg.validate(), "repo.readmes") + }) + + t.Run("rejects empty master branches list", func(t *testing.T) { + cfg := Config{ + Server: ServerConfig{ + Port: 8080, + }, + Meta: MetaConfig{ + Host: "example.com", + }, + Repo: RepoConfig{ + Dir: tmpDir, + Readmes: []string{"README.md"}, + Masters: []string{}, + }, + } + is.Err(t, cfg.validate(), "repo.masters") + }) + + t.Run("rejects invalid SSH port when SSH enabled", func(t *testing.T) { + cfg := Config{ + Server: ServerConfig{ + Port: 8080, + }, + Meta: MetaConfig{ + Host: "example.com", + }, + Repo: RepoConfig{ + Dir: tmpDir, + Readmes: []string{"README.md"}, + Masters: []string{"main"}, + }, + SSH: SSHConfig{ + Enable: true, + Port: 0, + HostKey: hostKeyPath, + Keys: []string{"ssh-rsa AAAAB3..."}, + }, + } + is.Err(t, cfg.validate(), "ssh.port") + }) + + t.Run("rejects SSH port same as HTTP port", func(t *testing.T) { + cfg := Config{ + Server: ServerConfig{ + Port: 8080, + }, + Meta: MetaConfig{ + Host: "example.com", + }, + Repo: RepoConfig{ + Dir: tmpDir, + Readmes: []string{"README.md"}, + Masters: []string{"main"}, + }, + SSH: SSHConfig{ + Enable: true, + Port: 8080, + HostKey: hostKeyPath, + Keys: []string{"ssh-rsa AAAAB3..."}, + }, + } + is.Err(t, cfg.validate(), "must differ") + }) + + t.Run("rejects nonexistent SSH host key file", func(t *testing.T) { + cfg := Config{ + Server: ServerConfig{ + Port: 8080, + }, + Meta: MetaConfig{ + Host: "example.com", + }, + Repo: RepoConfig{ + Dir: tmpDir, + Readmes: []string{"README.md"}, + Masters: []string{"main"}, + }, + SSH: SSHConfig{ + Enable: true, + Port: 2222, + HostKey: "/nonexistent/key", + Keys: []string{"ssh-rsa AAAAB3..."}, + }, + } + is.Err(t, cfg.validate(), "ssh.host_key") + }) + + t.Run("rejects empty SSH keys list when SSH enabled", func(t *testing.T) { + cfg := Config{ + Server: ServerConfig{ + Port: 8080, + }, + Meta: MetaConfig{ + Host: "example.com", + }, + Repo: RepoConfig{ + Dir: tmpDir, + Readmes: []string{"README.md"}, + Masters: []string{"main"}, + }, + SSH: SSHConfig{ + Enable: true, + Port: 2222, + HostKey: hostKeyPath, + Keys: []string{}, + }, + } + is.Err(t, cfg.validate(), "ssh.keys") + }) + + t.Run("rejects empty mirror interval", func(t *testing.T) { + cfg := Config{ + Server: ServerConfig{ + Port: 8080, + }, + Meta: MetaConfig{ + Host: "example.com", + }, + Repo: RepoConfig{ + Dir: tmpDir, + Readmes: []string{"README.md"}, + Masters: []string{"main"}, + }, + Mirror: MirrorConfig{ + Enable: true, + Interval: "", + }, + } + is.Err(t, cfg.validate(), "mirror.interval") + }) + + t.Run("rejects invalid mirror interval format", func(t *testing.T) { + cfg := Config{ + Server: ServerConfig{ + Port: 8080, + }, + Meta: MetaConfig{ + Host: "example.com", + }, + Repo: RepoConfig{ + Dir: tmpDir, + Readmes: []string{"README.md"}, + Masters: []string{"main"}, + }, + Mirror: MirrorConfig{ + Enable: true, + Interval: "1hour", + }, + } + is.Err(t, cfg.validate(), "invalid duration") + }) + + t.Run("collects and reports multiple validation errors", func(t *testing.T) { + cfg := Config{ + Server: ServerConfig{ + Port: 0, + }, + Meta: MetaConfig{ + Host: "", + }, + Repo: RepoConfig{ + Dir: "/nonexistent", + Readmes: []string{}, + Masters: []string{}, + }, + } + err := cfg.validate() + is.Err(t, err, "server.port") + is.Err(t, err, "meta.host") + is.Err(t, err, "repo.dir") + is.Err(t, err, "repo.readmes") + is.Err(t, err, "repo.masters") + }) + + t.Run("accepts multiple readme and master branch names", func(t *testing.T) { + cfg := Config{ + Server: ServerConfig{ + Port: 8080, + }, + Meta: MetaConfig{ + Host: "example.com", + }, + Repo: RepoConfig{ + Dir: tmpDir, + Readmes: []string{"README.md", "readme.txt", "README"}, + Masters: []string{"main", "master", "trunk"}, + }, + } + is.Err(t, cfg.validate(), nil) + }) + + t.Run("ignores invalid SSH fields when SSH disabled", func(t *testing.T) { + cfg := Config{ + Server: ServerConfig{ + Port: 8080, + }, + Meta: MetaConfig{ + Host: "example.com", + }, + Repo: RepoConfig{ + Dir: tmpDir, + Readmes: []string{"README.md"}, + Masters: []string{"main"}, + }, + SSH: SSHConfig{ + Enable: false, + Port: 0, + HostKey: "/nonexistent", + Keys: []string{}, + }, + } + is.Err(t, cfg.validate(), nil) + }) + + t.Run("ignores invalid mirror fields when mirroring disabled", func(t *testing.T) { + cfg := Config{ + Server: ServerConfig{ + Port: 8080, + }, + Meta: MetaConfig{ + Host: "example.com", + }, + Repo: RepoConfig{ + Dir: tmpDir, + Readmes: []string{"README.md"}, + Masters: []string{"main"}, + }, + Mirror: MirrorConfig{ + Enable: false, + Interval: "invalid", + }, + } + is.Err(t, cfg.validate(), nil) + }) + + t.Run("accepts various time duration formats", func(t *testing.T) { + durations := []string{"1h", "30m", "1h30m", "1h30m45s", "24h"} + for _, duration := range durations { + cfg := Config{ + Server: ServerConfig{ + Port: 8080, + }, + Meta: MetaConfig{ + Host: "example.com", + }, + Repo: RepoConfig{ + Dir: tmpDir, + Readmes: []string{"README.md"}, + Masters: []string{"main"}, + }, + Mirror: MirrorConfig{ + Enable: true, + Interval: duration, + }, + } + is.Err(t, cfg.validate(), nil) + } + }) +}