1package config
2
3import (
4	"bytes"
5	"context"
6	"fmt"
7	"io"
8	"net/http"
9	"net/url"
10	"os"
11	"path"
12	"path/filepath"
13	"strconv"
14	"strings"
15	"time"
16
17	"github.com/hairyhenderson/gomplate/v3/internal/iohelpers"
18	"github.com/pkg/errors"
19	"gopkg.in/yaml.v3"
20)
21
22// Parse a config file
23func Parse(in io.Reader) (*Config, error) {
24	out := &Config{}
25	dec := yaml.NewDecoder(in)
26	err := dec.Decode(out)
27	if err != nil && err != io.EOF {
28		return out, err
29	}
30	return out, nil
31}
32
33// Config - configures the gomplate execution
34type Config struct {
35	Input       string   `yaml:"in,omitempty"`
36	InputFiles  []string `yaml:"inputFiles,omitempty,flow"`
37	InputDir    string   `yaml:"inputDir,omitempty"`
38	ExcludeGlob []string `yaml:"excludes,omitempty"`
39	OutputFiles []string `yaml:"outputFiles,omitempty,flow"`
40	OutputDir   string   `yaml:"outputDir,omitempty"`
41	OutputMap   string   `yaml:"outputMap,omitempty"`
42
43	Experimental bool `yaml:"experimental,omitempty"`
44
45	SuppressEmpty bool     `yaml:"suppressEmpty,omitempty"`
46	ExecPipe      bool     `yaml:"execPipe,omitempty"`
47	PostExec      []string `yaml:"postExec,omitempty,flow"`
48
49	OutMode       string                `yaml:"chmod,omitempty"`
50	LDelim        string                `yaml:"leftDelim,omitempty"`
51	RDelim        string                `yaml:"rightDelim,omitempty"`
52	DataSources   map[string]DataSource `yaml:"datasources,omitempty"`
53	Context       map[string]DataSource `yaml:"context,omitempty"`
54	Plugins       map[string]string     `yaml:"plugins,omitempty"`
55	PluginTimeout time.Duration         `yaml:"pluginTimeout,omitempty"`
56	Templates     []string              `yaml:"templates,omitempty"`
57
58	// Extra HTTP headers not attached to pre-defined datsources. Potentially
59	// used by datasources defined in the template.
60	ExtraHeaders map[string]http.Header `yaml:"-"`
61
62	// internal use only, can't be injected in YAML
63	PostExecInput io.Reader `yaml:"-"`
64
65	Stdin  io.Reader `yaml:"-"`
66	Stdout io.Writer `yaml:"-"`
67	Stderr io.Writer `yaml:"-"`
68}
69
70var cfgContextKey = struct{}{}
71
72// ContextWithConfig returns a new context with a reference to the config.
73func ContextWithConfig(ctx context.Context, cfg *Config) context.Context {
74	return context.WithValue(ctx, cfgContextKey, cfg)
75}
76
77// FromContext returns a config from the given context, if any. If no
78// config is present a new default configuration will be returned.
79func FromContext(ctx context.Context) (cfg *Config) {
80	ok := ctx != nil
81	if ok {
82		cfg, ok = ctx.Value(cfgContextKey).(*Config)
83	}
84	if !ok {
85		cfg = &Config{}
86		cfg.ApplyDefaults()
87	}
88	return cfg
89}
90
91// mergeDataSources - use d as defaults, and override with values from o
92func mergeDataSources(d, o map[string]DataSource) map[string]DataSource {
93	for k, v := range o {
94		c, ok := d[k]
95		if ok {
96			d[k] = c.mergeFrom(v)
97		} else {
98			d[k] = v
99		}
100	}
101	return d
102}
103
104// DataSource - datasource configuration
105type DataSource struct {
106	URL    *url.URL    `yaml:"-"`
107	Header http.Header `yaml:"header,omitempty,flow"`
108}
109
110// UnmarshalYAML - satisfy the yaml.Umarshaler interface - URLs aren't
111// well supported, and anyway we need to do some extra parsing
112func (d *DataSource) UnmarshalYAML(value *yaml.Node) error {
113	type raw struct {
114		URL    string
115		Header http.Header
116	}
117	r := raw{}
118	err := value.Decode(&r)
119	if err != nil {
120		return err
121	}
122	u, err := ParseSourceURL(r.URL)
123	if err != nil {
124		return fmt.Errorf("could not parse datasource URL %q: %w", r.URL, err)
125	}
126	*d = DataSource{
127		URL:    u,
128		Header: r.Header,
129	}
130	return nil
131}
132
133// MarshalYAML - satisfy the yaml.Marshaler interface - URLs aren't
134// well supported, and anyway we need to do some extra parsing
135func (d DataSource) MarshalYAML() (interface{}, error) {
136	type raw struct {
137		URL    string
138		Header http.Header
139	}
140	r := raw{
141		URL:    d.URL.String(),
142		Header: d.Header,
143	}
144	return r, nil
145}
146
147// mergeFrom - use this as default, and override with values from o
148func (d DataSource) mergeFrom(o DataSource) DataSource {
149	if o.URL != nil {
150		d.URL = o.URL
151	}
152	if d.Header == nil {
153		d.Header = o.Header
154	} else {
155		for k, v := range o.Header {
156			d.Header[k] = v
157		}
158	}
159	return d
160}
161
162// MergeFrom - use this Config as the defaults, and override it with any
163// non-zero values from the other Config
164//
165// Note that Input/InputDir/InputFiles will override each other, as well as
166// OutputDir/OutputFiles.
167func (c *Config) MergeFrom(o *Config) *Config {
168	switch {
169	case !isZero(o.Input):
170		c.Input = o.Input
171		c.InputDir = ""
172		c.InputFiles = nil
173		c.OutputDir = ""
174	case !isZero(o.InputDir):
175		c.Input = ""
176		c.InputDir = o.InputDir
177		c.InputFiles = nil
178	case !isZero(o.InputFiles):
179		if !(len(o.InputFiles) == 1 && o.InputFiles[0] == "-") {
180			c.Input = ""
181			c.InputFiles = o.InputFiles
182			c.InputDir = ""
183			c.OutputDir = ""
184		}
185	}
186
187	if !isZero(o.OutputMap) {
188		c.OutputDir = ""
189		c.OutputFiles = nil
190		c.OutputMap = o.OutputMap
191	}
192	if !isZero(o.OutputDir) {
193		c.OutputDir = o.OutputDir
194		c.OutputFiles = nil
195		c.OutputMap = ""
196	}
197	if !isZero(o.OutputFiles) {
198		c.OutputDir = ""
199		c.OutputFiles = o.OutputFiles
200		c.OutputMap = ""
201	}
202	if !isZero(o.ExecPipe) {
203		c.ExecPipe = o.ExecPipe
204		c.PostExec = o.PostExec
205		c.OutputFiles = o.OutputFiles
206	}
207	if !isZero(o.ExcludeGlob) {
208		c.ExcludeGlob = o.ExcludeGlob
209	}
210	if !isZero(o.OutMode) {
211		c.OutMode = o.OutMode
212	}
213	if !isZero(o.LDelim) {
214		c.LDelim = o.LDelim
215	}
216	if !isZero(o.RDelim) {
217		c.RDelim = o.RDelim
218	}
219	if !isZero(o.Templates) {
220		c.Templates = o.Templates
221	}
222	mergeDataSources(c.DataSources, o.DataSources)
223	mergeDataSources(c.Context, o.Context)
224	if len(o.Plugins) > 0 {
225		for k, v := range o.Plugins {
226			c.Plugins[k] = v
227		}
228	}
229
230	return c
231}
232
233// ParseDataSourceFlags - sets the DataSources and Context fields from the
234// key=value format flags as provided at the command-line
235func (c *Config) ParseDataSourceFlags(datasources, contexts, headers []string) error {
236	for _, d := range datasources {
237		k, ds, err := parseDatasourceArg(d)
238		if err != nil {
239			return err
240		}
241		if c.DataSources == nil {
242			c.DataSources = map[string]DataSource{}
243		}
244		c.DataSources[k] = ds
245	}
246	for _, d := range contexts {
247		k, ds, err := parseDatasourceArg(d)
248		if err != nil {
249			return err
250		}
251		if c.Context == nil {
252			c.Context = map[string]DataSource{}
253		}
254		c.Context[k] = ds
255	}
256
257	hdrs, err := parseHeaderArgs(headers)
258	if err != nil {
259		return err
260	}
261
262	for k, v := range hdrs {
263		if d, ok := c.Context[k]; ok {
264			d.Header = v
265			c.Context[k] = d
266			delete(hdrs, k)
267		}
268		if d, ok := c.DataSources[k]; ok {
269			d.Header = v
270			c.DataSources[k] = d
271			delete(hdrs, k)
272		}
273	}
274	if len(hdrs) > 0 {
275		c.ExtraHeaders = hdrs
276	}
277	return nil
278}
279
280// ParsePluginFlags - sets the Plugins field from the
281// key=value format flags as provided at the command-line
282func (c *Config) ParsePluginFlags(plugins []string) error {
283	for _, plugin := range plugins {
284		parts := strings.SplitN(plugin, "=", 2)
285		if len(parts) < 2 {
286			return fmt.Errorf("plugin requires both name and path")
287		}
288		if c.Plugins == nil {
289			c.Plugins = map[string]string{}
290		}
291		c.Plugins[parts[0]] = parts[1]
292	}
293	return nil
294}
295
296func parseDatasourceArg(value string) (key string, ds DataSource, err error) {
297	parts := strings.SplitN(value, "=", 2)
298	if len(parts) == 1 {
299		f := parts[0]
300		key = strings.SplitN(value, ".", 2)[0]
301		if path.Base(f) != f {
302			err = fmt.Errorf("invalid datasource (%s): must provide an alias with files not in working directory", value)
303			return key, ds, err
304		}
305		ds.URL, err = ParseSourceURL(f)
306	} else if len(parts) == 2 {
307		key = parts[0]
308		ds.URL, err = ParseSourceURL(parts[1])
309	}
310	return key, ds, err
311}
312
313func parseHeaderArgs(headerArgs []string) (map[string]http.Header, error) {
314	headers := make(map[string]http.Header)
315	for _, v := range headerArgs {
316		ds, name, value, err := splitHeaderArg(v)
317		if err != nil {
318			return nil, err
319		}
320		if _, ok := headers[ds]; !ok {
321			headers[ds] = make(http.Header)
322		}
323		headers[ds][name] = append(headers[ds][name], strings.TrimSpace(value))
324	}
325	return headers, nil
326}
327
328func splitHeaderArg(arg string) (datasourceAlias, name, value string, err error) {
329	parts := strings.SplitN(arg, "=", 2)
330	if len(parts) != 2 {
331		err = fmt.Errorf("invalid datasource-header option '%s'", arg)
332		return "", "", "", err
333	}
334	datasourceAlias = parts[0]
335	name, value, err = splitHeader(parts[1])
336	return datasourceAlias, name, value, err
337}
338
339func splitHeader(header string) (name, value string, err error) {
340	parts := strings.SplitN(header, ":", 2)
341	if len(parts) != 2 {
342		err = fmt.Errorf("invalid HTTP Header format '%s'", header)
343		return "", "", err
344	}
345	name = http.CanonicalHeaderKey(parts[0])
346	value = parts[1]
347	return name, value, nil
348}
349
350// Validate the Config
351func (c Config) Validate() (err error) {
352	err = notTogether(
353		[]string{"in", "inputFiles", "inputDir"},
354		c.Input, c.InputFiles, c.InputDir)
355	if err == nil {
356		err = notTogether(
357			[]string{"outputFiles", "outputDir", "outputMap"},
358			c.OutputFiles, c.OutputDir, c.OutputMap)
359	}
360	if err == nil {
361		err = notTogether(
362			[]string{"outputDir", "outputMap", "execPipe"},
363			c.OutputDir, c.OutputMap, c.ExecPipe)
364	}
365
366	if err == nil {
367		err = mustTogether("outputDir", "inputDir",
368			c.OutputDir, c.InputDir)
369	}
370
371	if err == nil {
372		err = mustTogether("outputMap", "inputDir",
373			c.OutputMap, c.InputDir)
374	}
375
376	if err == nil {
377		f := len(c.InputFiles)
378		if f == 0 && c.Input != "" {
379			f = 1
380		}
381		o := len(c.OutputFiles)
382		if f != o && !c.ExecPipe {
383			err = fmt.Errorf("must provide same number of 'outputFiles' (%d) as 'in' or 'inputFiles' (%d) options", o, f)
384		}
385	}
386
387	if err == nil {
388		if c.ExecPipe && len(c.PostExec) == 0 {
389			err = fmt.Errorf("execPipe may only be used with a postExec command")
390		}
391	}
392
393	if err == nil {
394		if c.ExecPipe && (len(c.OutputFiles) > 0 && c.OutputFiles[0] != "-") {
395			err = fmt.Errorf("must not set 'outputFiles' when using 'execPipe'")
396		}
397	}
398
399	return err
400}
401
402func notTogether(names []string, values ...interface{}) error {
403	found := ""
404	for i, value := range values {
405		if isZero(value) {
406			continue
407		}
408		if found != "" {
409			return fmt.Errorf("only one of these options is supported at a time: '%s', '%s'",
410				found, names[i])
411		}
412		found = names[i]
413	}
414	return nil
415}
416
417func mustTogether(left, right string, lValue, rValue interface{}) error {
418	if !isZero(lValue) && isZero(rValue) {
419		return fmt.Errorf("these options must be set together: '%s', '%s'",
420			left, right)
421	}
422
423	return nil
424}
425
426func isZero(value interface{}) bool {
427	switch v := value.(type) {
428	case string:
429		return v == ""
430	case []string:
431		return len(v) == 0
432	case bool:
433		return !v
434	default:
435		return false
436	}
437}
438
439// ApplyDefaults - any defaults changed here should be added to cmd.InitFlags as
440// well for proper help/usage display.
441func (c *Config) ApplyDefaults() {
442	if c.InputDir != "" && c.OutputDir == "" && c.OutputMap == "" {
443		c.OutputDir = "."
444	}
445	if c.Input == "" && c.InputDir == "" && len(c.InputFiles) == 0 {
446		c.InputFiles = []string{"-"}
447	}
448	if c.OutputDir == "" && c.OutputMap == "" && len(c.OutputFiles) == 0 && !c.ExecPipe {
449		c.OutputFiles = []string{"-"}
450	}
451	if c.LDelim == "" {
452		c.LDelim = "{{"
453	}
454	if c.RDelim == "" {
455		c.RDelim = "}}"
456	}
457
458	if c.ExecPipe {
459		pipe := &bytes.Buffer{}
460		c.PostExecInput = pipe
461		c.OutputFiles = []string{"-"}
462
463		// --exec-pipe redirects standard out to the out pipe
464		c.Stdout = &iohelpers.NopCloser{Writer: pipe}
465	} else {
466		c.PostExecInput = c.Stdin
467	}
468
469	if c.PluginTimeout == 0 {
470		c.PluginTimeout = 5 * time.Second
471	}
472}
473
474// GetMode - parse an os.FileMode out of the string, and let us know if it's an override or not...
475func (c *Config) GetMode() (os.FileMode, bool, error) {
476	modeOverride := c.OutMode != ""
477	m, err := strconv.ParseUint("0"+c.OutMode, 8, 32)
478	if err != nil {
479		return 0, false, err
480	}
481	mode := NormalizeFileMode(os.FileMode(m))
482	if mode == 0 && c.Input != "" {
483		mode = NormalizeFileMode(0644)
484	}
485	return mode, modeOverride, nil
486}
487
488// String -
489func (c *Config) String() string {
490	out := &strings.Builder{}
491	out.WriteString("---\n")
492	enc := yaml.NewEncoder(out)
493	enc.SetIndent(2)
494
495	// dereferenced copy so we can truncate input for display
496	c2 := *c
497	if len(c2.Input) >= 11 {
498		c2.Input = c2.Input[0:8] + "..."
499	}
500
501	err := enc.Encode(c2)
502	if err != nil {
503		return err.Error()
504	}
505	return out.String()
506}
507
508// ParseSourceURL parses a datasource URL value, which may be '-' (for stdin://),
509// or it may be a Windows path (with driver letter and back-slack separators) or
510// UNC, or it may be relative. It also might just be a regular absolute URL...
511// In all cases it returns a correct URL for the value.
512func ParseSourceURL(value string) (*url.URL, error) {
513	if value == "-" {
514		value = "stdin://"
515	}
516	value = filepath.ToSlash(value)
517	// handle absolute Windows paths
518	volName := ""
519	if volName = filepath.VolumeName(value); volName != "" {
520		// handle UNCs
521		if len(volName) > 2 {
522			value = "file:" + value
523		} else {
524			value = "file:///" + value
525		}
526	}
527	srcURL, err := url.Parse(value)
528	if err != nil {
529		return nil, err
530	}
531
532	if volName != "" && len(srcURL.Path) >= 3 {
533		if srcURL.Path[0] == '/' && srcURL.Path[2] == ':' {
534			srcURL.Path = srcURL.Path[1:]
535		}
536	}
537
538	if !srcURL.IsAbs() {
539		srcURL, err = absFileURL(value)
540		if err != nil {
541			return nil, err
542		}
543	}
544	return srcURL, nil
545}
546
547func absFileURL(value string) (*url.URL, error) {
548	wd, err := os.Getwd()
549	if err != nil {
550		return nil, errors.Wrapf(err, "can't get working directory")
551	}
552	wd = filepath.ToSlash(wd)
553	baseURL := &url.URL{
554		Scheme: "file",
555		Path:   wd + "/",
556	}
557	relURL, err := url.Parse(value)
558	if err != nil {
559		return nil, fmt.Errorf("can't parse value %s as URL: %w", value, err)
560	}
561	resolved := baseURL.ResolveReference(relURL)
562	// deal with Windows drive letters
563	if !strings.HasPrefix(wd, "/") && resolved.Path[2] == ':' {
564		resolved.Path = resolved.Path[1:]
565	}
566	return resolved, nil
567}
568