From b893e99864496e98f90fb8b8a70aa6a437910bdb Mon Sep 17 00:00:00 2001 From: Kyle Isom Date: Sun, 20 Feb 2022 17:40:38 -0800 Subject: [PATCH] config: add default path, customized configs. A customised config is an ini file with a [default] section and some other name sections; a config file is loaded from the default section with any keys in the named section being added in, overriding keys in the host. This allows for, e.g. setting different paths based on the host name or operating system. --- config/config.go | 31 +++++ config/iniconf/iniconf.go | 223 +++++++++++++++++++++++++++++++++ config/iniconf/iniconf_test.go | 142 +++++++++++++++++++++ config/path.go | 19 +++ config/path_linux.go | 43 +++++++ config/path_test.go | 7 ++ 6 files changed, 465 insertions(+) create mode 100644 config/iniconf/iniconf.go create mode 100644 config/iniconf/iniconf_test.go create mode 100644 config/path.go create mode 100644 config/path_linux.go create mode 100644 config/path_test.go diff --git a/config/config.go b/config/config.go index 235db3e..1552c69 100644 --- a/config/config.go +++ b/config/config.go @@ -10,9 +10,12 @@ package config import ( "bufio" + "fmt" "log" "os" "strings" + + "git.sr.ht/~kisom/goutils/config/iniconf" ) // NB: Rather than define a singleton type, everything is defined at @@ -67,6 +70,34 @@ func LoadFile(path string) error { return nil } +// LoadFileFor scans the ini file at path, loading the default section +// and overriding any keys found under section. If strict is true, the +// named section must exist (i.e. to catch typos in the section name). +func LoadFileFor(path, section string, strict bool) error { + cmap, err := iniconf.ParseFile(path) + if err != nil { + return err + } + + for key, value := range cmap[iniconf.DefaultSection] { + vars[key] = value + } + + smap, ok := cmap[section] + if !ok { + if strict { + return fmt.Errorf("config: section '%s' wasn't found in the config file", section) + } + return nil + } + + for key, value := range smap { + vars[key] = value + } + + return nil +} + // Get retrieves a value from either a configuration file or the // environment. Note that values from a file will override environment // variables. diff --git a/config/iniconf/iniconf.go b/config/iniconf/iniconf.go new file mode 100644 index 0000000..2c28cf2 --- /dev/null +++ b/config/iniconf/iniconf.go @@ -0,0 +1,223 @@ +package iniconf + +import ( + "bufio" + "fmt" + "io" + "os" + "regexp" +) + +// ConfigMap is shorthand for the type used as a config struct. +type ConfigMap map[string]map[string]string + +var ( + configSection = regexp.MustCompile(`^\s*\[\s*(\w+)\s*\]\s*$`) + quotedConfigLine = regexp.MustCompile(`^\s*(\w+)\s*=\s*["'](.*)["']\s*$`) + configLine = regexp.MustCompile(`^\s*(\w+)\s*=\s*(.*)\s*$`) + commentLine = regexp.MustCompile(`^#.*$`) + blankLine = regexp.MustCompile(`^\s*$`) +) + +// DefaultSection is the label for the default ini file section. +var DefaultSection = "default" + +// ParseFile attempts to load the named config file. +func ParseFile(fileName string) (cfg ConfigMap, err error) { + var file *os.File + file, err = os.Open(fileName) + if err != nil { + return + } + defer file.Close() + return ParseReader(file) +} + +// ParseReader reads a configuration from an io.Reader. +func ParseReader(r io.Reader) (cfg ConfigMap, err error) { + cfg = ConfigMap{} + buf := bufio.NewReader(r) + + var ( + line string + longLine bool + currentSection string + lineBytes []byte + isPrefix bool + ) + + for { + err = nil + lineBytes, isPrefix, err = buf.ReadLine() + if io.EOF == err { + err = nil + break + } else if err != nil { + break + } else if isPrefix { + line += string(lineBytes) + + longLine = true + continue + } else if longLine { + line += string(lineBytes) + longLine = false + } else { + line = string(lineBytes) + } + + if commentLine.MatchString(line) { + continue + } else if blankLine.MatchString(line) { + continue + } else if configSection.MatchString(line) { + section := configSection.ReplaceAllString(line, + "$1") + if section == "" { + err = fmt.Errorf("invalid structure in file") + break + } else if !cfg.SectionInConfig(section) { + cfg[section] = make(map[string]string, 0) + } + currentSection = section + } else if configLine.MatchString(line) { + regex := configLine + if quotedConfigLine.MatchString(line) { + regex = quotedConfigLine + } + if currentSection == "" { + currentSection = DefaultSection + if !cfg.SectionInConfig(currentSection) { + cfg[currentSection] = map[string]string{} + } + } + key := regex.ReplaceAllString(line, "$1") + val := regex.ReplaceAllString(line, "$2") + if key == "" { + continue + } + cfg[currentSection][key] = val + } else { + err = fmt.Errorf("invalid config file") + break + } + } + return +} + +// SectionInConfig determines whether a section is in the configuration. +func (c ConfigMap) SectionInConfig(section string) bool { + _, ok := c[section] + return ok +} + +// ListSections returns the list of sections in the config map. +func (c ConfigMap) ListSections() (sections []string) { + for section := range c { + sections = append(sections, section) + } + return +} + +// WriteFile writes out the configuration to a file. +func (c ConfigMap) WriteFile(filename string) (err error) { + file, err := os.Create(filename) + if err != nil { + return + } + defer file.Close() + + for _, section := range c.ListSections() { + sName := fmt.Sprintf("[ %s ]\n", section) + _, err = file.Write([]byte(sName)) + if err != nil { + return + } + + for k, v := range c[section] { + line := fmt.Sprintf("%s = %s\n", k, v) + _, err = file.Write([]byte(line)) + if err != nil { + return + } + } + _, err = file.Write([]byte{0x0a}) + if err != nil { + return + } + } + return +} + +// AddSection creates a new section in the config map. +func (c ConfigMap) AddSection(section string) { + if nil != c[section] { + c[section] = map[string]string{} + } +} + +// AddKeyVal adds a key value pair to a config map. +func (c ConfigMap) AddKeyVal(section, key, val string) { + if section == "" { + section = DefaultSection + } + + if nil == c[section] { + c.AddSection(section) + } + + c[section][key] = val +} + +// GetValue retrieves the value from a key map. +func (c ConfigMap) GetValue(section, key string) (val string, present bool) { + if c == nil { + return + } + + if section == "" { + section = DefaultSection + } + + _, ok := c[section] + if !ok { + return + } + + val, present = c[section][key] + return +} + +// GetValueDefault retrieves the value from a key map if present, +// otherwise the default value. +func (c ConfigMap) GetValueDefault(section, key, value string) (val string) { + kval, ok := c.GetValue(section, key) + if !ok { + return value + } + return kval +} + +// SectionKeys returns the sections in the config map. +func (c ConfigMap) SectionKeys(section string) (keys []string, present bool) { + if c == nil { + return nil, false + } + + if section == "" { + section = DefaultSection + } + + cm := c + s, ok := cm[section] + if !ok { + return nil, false + } + + keys = make([]string, 0, len(s)) + for key := range s { + keys = append(keys, key) + } + + return keys, true +} diff --git a/config/iniconf/iniconf_test.go b/config/iniconf/iniconf_test.go new file mode 100644 index 0000000..26fa570 --- /dev/null +++ b/config/iniconf/iniconf_test.go @@ -0,0 +1,142 @@ +package iniconf + +import ( + "errors" + "fmt" + "os" + "sort" + "testing" +) + +// FailWithError is a utility for dumping errors and failing the test. +func FailWithError(t *testing.T, err error) { + fmt.Println("failed") + if err != nil { + fmt.Println("[!] ", err.Error()) + } + t.FailNow() +} + +// UnlinkIfExists removes a file if it exists. +func UnlinkIfExists(file string) { + _, err := os.Stat(file) + if err != nil && os.IsNotExist(err) { + panic("failed to remove " + file) + } + os.Remove(file) +} + +// stringSlicesEqual compares two string lists, checking that they +// contain the same elements. +func stringSlicesEqual(slice1, slice2 []string) bool { + if len(slice1) != len(slice2) { + return false + } + + for i := range slice1 { + if slice1[i] != slice2[i] { + return false + } + } + + for i := range slice2 { + if slice1[i] != slice2[i] { + return false + } + } + return true +} + +func TestGoodConfig(t *testing.T) { + testFile := "testdata/test.conf" + fmt.Printf("[+] validating known-good config... ") + cmap, err := ParseFile(testFile) + if err != nil { + FailWithError(t, err) + } else if len(cmap) != 2 { + FailWithError(t, err) + } + fmt.Println("ok") +} + +func TestGoodConfig2(t *testing.T) { + testFile := "testdata/test2.conf" + fmt.Printf("[+] validating second known-good config... ") + cmap, err := ParseFile(testFile) + if err != nil { + FailWithError(t, err) + } else if len(cmap) != 1 { + FailWithError(t, err) + } else if len(cmap["default"]) != 3 { + FailWithError(t, err) + } + fmt.Println("ok") +} + +func TestBadConfig(t *testing.T) { + testFile := "testdata/bad.conf" + fmt.Printf("[+] ensure invalid config file fails... ") + _, err := ParseFile(testFile) + if err == nil { + err = fmt.Errorf("invalid config file should fail") + FailWithError(t, err) + } + fmt.Println("ok") +} + +func TestWriteConfigFile(t *testing.T) { + fmt.Printf("[+] ensure config file is written properly... ") + const testFile = "testdata/test.conf" + const testOut = "testdata/test.out" + + cmap, err := ParseFile(testFile) + if err != nil { + FailWithError(t, err) + } + + defer UnlinkIfExists(testOut) + err = cmap.WriteFile(testOut) + if err != nil { + FailWithError(t, err) + } + + cmap2, err := ParseFile(testOut) + if err != nil { + FailWithError(t, err) + } + + sectionList1 := cmap.ListSections() + sectionList2 := cmap2.ListSections() + sort.Strings(sectionList1) + sort.Strings(sectionList2) + if !stringSlicesEqual(sectionList1, sectionList2) { + err = fmt.Errorf("section lists don't match") + FailWithError(t, err) + } + + for _, section := range sectionList1 { + for _, k := range cmap[section] { + if cmap[section][k] != cmap2[section][k] { + err = fmt.Errorf("config key doesn't match") + FailWithError(t, err) + } + } + } + fmt.Println("ok") +} + +func TestQuotedValue(t *testing.T) { + testFile := "testdata/test.conf" + fmt.Printf("[+] validating quoted value... ") + cmap, _ := ParseFile(testFile) + val := cmap["sectionName"]["key4"] + if val != " space at beginning and end " { + FailWithError(t, errors.New("Wrong value in double quotes ["+val+"]")) + } + + val = cmap["sectionName"]["key5"] + if val != " is quoted with single quotes " { + FailWithError(t, errors.New("Wrong value in single quotes ["+val+"]")) + } + fmt.Println("ok") +} diff --git a/config/path.go b/config/path.go new file mode 100644 index 0000000..2da08bc --- /dev/null +++ b/config/path.go @@ -0,0 +1,19 @@ +//go:build ignore +// +build ignore + +package config + +import ( + "os/user" + "path/filepath" +) + +// DefaultConfigPath returns a sensible default configuration file path. +func DefaultConfigPath(dir, base string) string { + user, err := user.Current() + if err != nil || user.HomeDir == "" { + return filepath.Join(dir, base) + } + + return filepath.Join(user.HomeDir, dir, base) +} diff --git a/config/path_linux.go b/config/path_linux.go new file mode 100644 index 0000000..584de56 --- /dev/null +++ b/config/path_linux.go @@ -0,0 +1,43 @@ +package config + +import ( + "os" + "path/filepath" +) + +// canUseXDGConfigDir checks whether the XDG config directory exists +// and is accessible by the current user. If it is present, it will +// be returned. Note that if the directory does not exist, it is +// presumed unusable. +func canUseXDGConfigDir() (string, bool) { + xdgDir := os.Getenv("XDG_CONFIG_DIR") + if xdgDir == "" { + userDir := os.Getenv("HOME") + if userDir == "" { + return "", false + } + + xdgDir = filepath.Join(userDir, ".config") + } + + fi, err := os.Stat(xdgDir) + if err != nil { + return "", false + } + + if !fi.IsDir() { + return "", false + } + + return xdgDir, true +} + +// DefaultConfigPath returns a sensible default configuration file path. +func DefaultConfigPath(dir, base string) string { + dirPath, ok := canUseXDGConfigDir() + if !ok { + dirPath = "/etc" + } + + return filepath.Join(dirPath, dir, base) +} diff --git a/config/path_test.go b/config/path_test.go new file mode 100644 index 0000000..95f20d4 --- /dev/null +++ b/config/path_test.go @@ -0,0 +1,7 @@ +package config + +import "testing" + +func TestDefaultPath(t *testing.T) { + t.Log(DefaultConfigPath("demoapp", "app.conf")) +}