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 }