taler-merchant-demos

Python-based Frontends for the Demonstration Web site
Log | Files | Refs | README | LICENSE

config.go (9978B)


      1 // Package config reads the small subset of GNUnet-style configuration used by
      2 // the merchant demos.  It deliberately has no dependency on libgnunet or a
      3 // third-party INI parser.
      4 package config
      5 
      6 import (
      7 	"bufio"
      8 	"errors"
      9 	"fmt"
     10 	"io"
     11 	"os"
     12 	"path/filepath"
     13 	"sort"
     14 	"strconv"
     15 	"strings"
     16 )
     17 
     18 // Config stores case-insensitive section and option names.
     19 type Config struct {
     20 	sections    map[string]map[string]string
     21 	sources     map[string]map[string]source
     22 	loadedFiles []string
     23 }
     24 
     25 type source struct {
     26 	filename string
     27 	line     int
     28 }
     29 
     30 const (
     31 	projectName    = "taler-merchant-demos"
     32 	configFilename = projectName + ".conf"
     33 	baseConfigEnv  = "TALER_MERCHANT_DEMOS_BASE_CONFIG"
     34 	prefixEnv      = "TALER_MERCHANT_DEMOS_PREFIX"
     35 )
     36 
     37 // Load reads defaults and then filename, matching the precedence used by the
     38 // other Taler components. An empty filename selects the first existing
     39 // component-specific user or system configuration file.
     40 func Load(filename string) (*Config, error) {
     41 	c := &Config{
     42 		sections: make(map[string]map[string]string),
     43 		sources:  make(map[string]map[string]source),
     44 	}
     45 	defaults := os.Getenv(baseConfigEnv)
     46 	if defaults == "" {
     47 		defaults = filepath.Join(installationPrefix(), "share", projectName, "config.d")
     48 	}
     49 	if err := c.loadDir(defaults); err != nil {
     50 		return nil, err
     51 	}
     52 	if filename == "" {
     53 		var err error
     54 		filename, err = defaultConfigFile()
     55 		if err != nil {
     56 			return nil, err
     57 		}
     58 		if filename == "" {
     59 			return c, nil
     60 		}
     61 	}
     62 	if err := c.loadFile(filename, make(map[string]bool)); err != nil {
     63 		return nil, err
     64 	}
     65 	return c, nil
     66 }
     67 
     68 func installationPrefix() string {
     69 	if prefix := os.Getenv(prefixEnv); prefix != "" {
     70 		return filepath.Clean(prefix)
     71 	}
     72 	executable, err := os.Executable()
     73 	if err == nil {
     74 		if resolved, resolveErr := filepath.EvalSymlinks(executable); resolveErr == nil {
     75 			executable = resolved
     76 		}
     77 		binDir := filepath.Dir(executable)
     78 		if filepath.Base(binDir) == "bin" {
     79 			return filepath.Dir(binDir)
     80 		}
     81 	}
     82 	return "/usr"
     83 }
     84 
     85 func defaultConfigFile() (string, error) {
     86 	var candidates []string
     87 	if xdg := os.Getenv("XDG_CONFIG_HOME"); xdg != "" {
     88 		candidates = append(candidates, filepath.Join(xdg, configFilename))
     89 	} else {
     90 		home, err := os.UserHomeDir()
     91 		if err != nil {
     92 			return "", fmt.Errorf("locate home directory: %w", err)
     93 		}
     94 		candidates = append(candidates, filepath.Join(home, ".config", configFilename))
     95 	}
     96 	candidates = append(candidates,
     97 		filepath.Join("/etc", configFilename),
     98 		filepath.Join("/etc", projectName, configFilename),
     99 	)
    100 	for _, filename := range candidates {
    101 		if _, err := os.Stat(filename); err == nil {
    102 			return filename, nil
    103 		} else if !errors.Is(err, os.ErrNotExist) {
    104 			return "", fmt.Errorf("inspect configuration %q: %w", filename, err)
    105 		}
    106 	}
    107 	return "", nil
    108 }
    109 
    110 func (c *Config) loadDir(dirname string) error {
    111 	entries, err := os.ReadDir(dirname)
    112 	if errors.Is(err, os.ErrNotExist) {
    113 		return nil
    114 	}
    115 	if err != nil {
    116 		return fmt.Errorf("read defaults directory %q: %w", dirname, err)
    117 	}
    118 	for _, entry := range entries {
    119 		if entry.IsDir() || filepath.Ext(entry.Name()) != ".conf" {
    120 			continue
    121 		}
    122 		if err := c.loadFile(filepath.Join(dirname, entry.Name()), make(map[string]bool)); err != nil {
    123 			return err
    124 		}
    125 	}
    126 	return nil
    127 }
    128 
    129 func (c *Config) loadFile(filename string, loading map[string]bool) error {
    130 	abs, err := filepath.Abs(filename)
    131 	if err != nil {
    132 		return err
    133 	}
    134 	if loading[abs] {
    135 		return fmt.Errorf("configuration include cycle at %q", filename)
    136 	}
    137 	loading[abs] = true
    138 	defer delete(loading, abs)
    139 
    140 	f, err := os.Open(abs)
    141 	if err != nil {
    142 		return fmt.Errorf("open configuration %q: %w", abs, err)
    143 	}
    144 	defer f.Close()
    145 	c.loadedFiles = append(c.loadedFiles, abs)
    146 
    147 	var section string
    148 	scanner := bufio.NewScanner(f)
    149 	for lineNo := 1; scanner.Scan(); lineNo++ {
    150 		line := strings.TrimSpace(scanner.Text())
    151 		if line == "" || strings.HasPrefix(line, "#") {
    152 			continue
    153 		}
    154 		if strings.HasPrefix(line, "@INLINE@") {
    155 			fields := strings.Fields(line)
    156 			if len(fields) != 2 {
    157 				return fmt.Errorf("%s:%d: malformed @INLINE@ directive", abs, lineNo)
    158 			}
    159 			included := fields[1]
    160 			if !filepath.IsAbs(included) {
    161 				included = filepath.Join(filepath.Dir(abs), included)
    162 			}
    163 			if err := c.loadFile(included, loading); err != nil {
    164 				return err
    165 			}
    166 			continue
    167 		}
    168 		if strings.HasPrefix(line, "[") {
    169 			if !strings.HasSuffix(line, "]") {
    170 				return fmt.Errorf("%s:%d: malformed section header", abs, lineNo)
    171 			}
    172 			section = normalize(strings.Trim(strings.TrimSpace(line[1:len(line)-1]), `"`))
    173 			if section == "" {
    174 				return fmt.Errorf("%s:%d: empty section name", abs, lineNo)
    175 			}
    176 			if c.sections[section] == nil {
    177 				c.sections[section] = make(map[string]string)
    178 				c.sources[section] = make(map[string]source)
    179 			}
    180 			continue
    181 		}
    182 		if section == "" {
    183 			return fmt.Errorf("%s:%d: option outside a section", abs, lineNo)
    184 		}
    185 		key, value, ok := strings.Cut(line, "=")
    186 		if !ok {
    187 			return fmt.Errorf("%s:%d: malformed option", abs, lineNo)
    188 		}
    189 		key = normalize(strings.TrimSpace(key))
    190 		value = strings.TrimSpace(value)
    191 		if strings.HasPrefix(value, `"`) {
    192 			if len(value) < 2 || !strings.HasSuffix(value, `"`) {
    193 				return fmt.Errorf("%s:%d: mismatched quotes", abs, lineNo)
    194 			}
    195 			value = value[1 : len(value)-1]
    196 		}
    197 		c.sections[section][key] = value
    198 		c.sources[section][key] = source{filename: abs, line: lineNo}
    199 	}
    200 	if err := scanner.Err(); err != nil {
    201 		return fmt.Errorf("read configuration %q: %w", abs, err)
    202 	}
    203 	return nil
    204 }
    205 
    206 func normalize(s string) string { return strings.ToLower(s) }
    207 
    208 // Dump writes the effective configuration with the source of every option.
    209 func (c *Config) Dump(w io.Writer) error {
    210 	sections := make([]string, 0, len(c.sections))
    211 	for section := range c.sections {
    212 		sections = append(sections, section)
    213 	}
    214 	sort.Strings(sections)
    215 
    216 	var output strings.Builder
    217 	output.WriteString("#\n# Configuration file load order:\n")
    218 	for _, filename := range c.loadedFiles {
    219 		fmt.Fprintf(&output, "# %s\n", filename)
    220 	}
    221 	output.WriteString("#\n\n")
    222 	for sectionIndex, section := range sections {
    223 		if sectionIndex > 0 {
    224 			output.WriteByte('\n')
    225 		}
    226 		fmt.Fprintf(&output, "[%s]\n\n", section)
    227 
    228 		options := make([]string, 0, len(c.sections[section]))
    229 		for option := range c.sections[section] {
    230 			options = append(options, option)
    231 		}
    232 		sort.Strings(options)
    233 		for _, option := range options {
    234 			source := c.sources[section][option]
    235 			fmt.Fprintf(&output, "# %s:%d\n%s = %s\n\n", source.filename, source.line, option, c.sections[section][option])
    236 		}
    237 	}
    238 	_, err := io.WriteString(w, output.String())
    239 	return err
    240 }
    241 
    242 // Get returns an option or an empty string when it is absent.
    243 func (c *Config) Get(section, option string) string {
    244 	return c.sections[normalize(section)][normalize(option)]
    245 }
    246 
    247 // Require returns a non-empty option or an actionable configuration error.
    248 func (c *Config) Require(section, option string) (string, error) {
    249 	value := c.Get(section, option)
    250 	if value == "" {
    251 		return "", fmt.Errorf("missing required option %s in section %s", strings.ToUpper(option), strings.ToUpper(section))
    252 	}
    253 	return value, nil
    254 }
    255 
    256 // Int parses a decimal integer option.  Missing options return fallback.
    257 func (c *Config) Int(section, option string, fallback int) (int, error) {
    258 	value := c.Get(section, option)
    259 	if value == "" {
    260 		return fallback, nil
    261 	}
    262 	n, err := strconv.Atoi(value)
    263 	if err != nil {
    264 		return 0, fmt.Errorf("option %s in section %s must be an integer: %w", strings.ToUpper(option), strings.ToUpper(section), err)
    265 	}
    266 	return n, nil
    267 }
    268 
    269 const maxExpansionDepth = 128
    270 
    271 // Filename expands the variable syntax used by Taler filenames. Values in
    272 // [PATHS] take precedence over environment variables, defaults and variable
    273 // values are expanded recursively, and unknown variables are left unchanged.
    274 func (c *Config) Filename(section, option string) string {
    275 	return c.expandFilename(c.Get(section, option), 0)
    276 }
    277 
    278 func (c *Config) expandFilename(value string, depth int) string {
    279 	if depth >= maxExpansionDepth || !strings.Contains(value, "$") {
    280 		return value
    281 	}
    282 	var expanded strings.Builder
    283 	for offset := 0; offset < len(value); {
    284 		if value[offset] != '$' {
    285 			expanded.WriteByte(value[offset])
    286 			offset++
    287 			continue
    288 		}
    289 		name, fallback, end, ok := filenameVariable(value, offset)
    290 		if !ok {
    291 			expanded.WriteByte(value[offset])
    292 			offset++
    293 			continue
    294 		}
    295 		replacement, found := c.pathVariable(name)
    296 		if !found && fallback != nil {
    297 			replacement, found = *fallback, true
    298 		}
    299 		if !found {
    300 			expanded.WriteString(value[offset:end])
    301 		} else {
    302 			expanded.WriteString(c.expandFilename(replacement, depth+1))
    303 		}
    304 		offset = end
    305 	}
    306 	return expanded.String()
    307 }
    308 
    309 func (c *Config) pathVariable(name string) (string, bool) {
    310 	if paths := c.sections[normalize("paths")]; paths != nil {
    311 		if value, ok := paths[normalize(name)]; ok {
    312 			return value, true
    313 		}
    314 	}
    315 	return os.LookupEnv(name)
    316 }
    317 
    318 func filenameVariable(value string, offset int) (name string, fallback *string, end int, ok bool) {
    319 	if offset+1 >= len(value) {
    320 		return "", nil, offset, false
    321 	}
    322 	if value[offset+1] != '{' {
    323 		end = offset + 1
    324 		for end < len(value) && isVariableCharacter(value[end]) {
    325 			end++
    326 		}
    327 		if end == offset+1 {
    328 			return "", nil, offset, false
    329 		}
    330 		return value[offset+1 : end], nil, end, true
    331 	}
    332 
    333 	level := 1
    334 	end = offset + 2
    335 	for end < len(value) && level > 0 {
    336 		switch value[end] {
    337 		case '{':
    338 			level++
    339 		case '}':
    340 			level--
    341 		}
    342 		end++
    343 	}
    344 	if level != 0 {
    345 		return "", nil, offset, false
    346 	}
    347 	contents := value[offset+2 : end-1]
    348 	if separator := strings.Index(contents, ":-"); separator >= 0 {
    349 		name = contents[:separator]
    350 		defaultValue := contents[separator+2:]
    351 		fallback = &defaultValue
    352 	} else {
    353 		name = contents
    354 	}
    355 	if name == "" {
    356 		return "", nil, offset, false
    357 	}
    358 	return name, fallback, end, true
    359 }
    360 
    361 func isVariableCharacter(value byte) bool {
    362 	return value >= 'a' && value <= 'z' || value >= 'A' && value <= 'Z' || value >= '0' && value <= '9' || value == '_'
    363 }