diff options
| author | Dave Henderson <dhenderson@gmail.com> | 2023-02-04 15:03:03 -0500 |
|---|---|---|
| committer | Dave Henderson <dhenderson@gmail.com> | 2023-04-29 19:54:52 -0400 |
| commit | 5e05dc9fb9ad3ada91466da64d20ffbf063ca93d (patch) | |
| tree | 15edfc95169365b7c540bb68f978f56ed1e1da73 | |
| parent | 496bac6da308507760a70ccbce8da6fddee4c3ba (diff) | |
replace afero module
Signed-off-by: Dave Henderson <dhenderson@gmail.com>
38 files changed, 1706 insertions, 910 deletions
@@ -166,12 +166,14 @@ $(shell go list -f '{{ if not (eq "" (join .TestGoFiles "")) }}testbin/{{.Import # available. Git must also be configured with a username and email address. See # the GitHub workflow config in .github/workflows/build.yml for hints. # A recent PowerShell is also required, such as version 7.3 or later. +# +# An F: drive is expected to be available, with a tmp directory. .SECONDEXPANSION: $(shell go list -f '{{ if not (eq "" (join .TestGoFiles "")) }}testbin/{{.ImportPath}}.test.exe.remote{{end}}' ./...): $$(shell go list -f '{{.Dir}}' $$(subst testbin/,,$$(subst .test.exe.remote,,$$@))) @echo $< @GOOS=windows GOARCH=amd64 $(GO) test -tags timetzdata -c -o $(PREFIX)/testbin/remote-test.exe $< @scp -q $(PREFIX)/testbin/remote-test.exe $(GO_REMOTE_WINDOWS):/$(shell ssh $(GO_REMOTE_WINDOWS) 'echo %TEMP%' | cut -f2 -d= | sed -e 's#\\#/#g')/ - @ssh $(GO_REMOTE_WINDOWS) '%TEMP%\remote-test.exe' + @ssh -o 'SetEnv TMP=F:\tmp' $(GO_REMOTE_WINDOWS) '%TEMP%\remote-test.exe' # test-remote-windows runs the above target for all packages that have tests test-remote-windows: $(shell go list -f '{{ if not (eq "" (join .TestGoFiles "")) }}testbin/{{.ImportPath}}.test.exe.remote{{end}}' ./...) diff --git a/data/datasource.go b/data/datasource.go index bebef803..d16fe09a 100644 --- a/data/datasource.go +++ b/data/datasource.go @@ -3,6 +3,7 @@ package data import ( "context" "fmt" + "io/fs" "mime" "net/http" "net/url" @@ -10,9 +11,8 @@ import ( "sort" "strings" - "github.com/spf13/afero" - "github.com/hairyhenderson/gomplate/v4/internal/config" + "github.com/hairyhenderson/gomplate/v4/internal/datafs" "github.com/hairyhenderson/gomplate/v4/libkv" "github.com/hairyhenderson/gomplate/v4/vault" ) @@ -61,11 +61,16 @@ func (d *Data) registerReaders() { d.sourceReaders["git+ssh"] = readGit } -// lookupReader - return the reader function for the given scheme +// lookupReader - return the reader function for the given scheme. Empty scheme +// will return the file reader. func (d *Data) lookupReader(scheme string) (func(context.Context, *Source, ...string) ([]byte, error), error) { if d.sourceReaders == nil { d.registerReaders() } + if scheme == "" { + scheme = "file" + } + r, ok := d.sourceReaders[scheme] if !ok { return nil, fmt.Errorf("scheme %s not registered", scheme) @@ -144,7 +149,7 @@ type Source struct { Alias string URL *url.URL Header http.Header // used for http[s]: URLs, nil otherwise - fs afero.Fs // used for file: URLs, nil otherwise + fs fs.FS // used for file: URLs, nil otherwise hc *http.Client // used for http[s]: URLs, nil otherwise vc *vault.Vault // used for vault: URLs, nil otherwise kv *libkv.LibKV // used for consul:, etcd:, zookeeper: URLs, nil otherwise @@ -240,7 +245,7 @@ func (d *Data) DefineDatasource(alias, value string) (string, error) { if d.DatasourceExists(alias) { return "", nil } - srcURL, err := config.ParseSourceURL(value) + srcURL, err := datafs.ParseSourceURL(value) if err != nil { return "", err } diff --git a/data/datasource_file.go b/data/datasource_file.go index 156ab276..f5c764fe 100644 --- a/data/datasource_file.go +++ b/data/datasource_file.go @@ -5,18 +5,22 @@ import ( "context" "encoding/json" "fmt" - "io" + "io/fs" "net/url" - "os" "path/filepath" "strings" - "github.com/spf13/afero" + "github.com/hairyhenderson/gomplate/v4/internal/datafs" ) -func readFile(_ context.Context, source *Source, args ...string) ([]byte, error) { +func readFile(ctx context.Context, source *Source, args ...string) ([]byte, error) { if source.fs == nil { - source.fs = afero.NewOsFs() + fsp := datafs.FSProviderFromContext(ctx) + fsys, err := fsp.New(source.URL) + if err != nil { + return nil, fmt.Errorf("filesystem provider for %q unavailable: %w", source.URL, err) + } + source.fs = fsys } p := filepath.FromSlash(source.URL.Path) @@ -35,13 +39,18 @@ func readFile(_ context.Context, source *Source, args ...string) ([]byte, error) source.mediaType = "" } + isDir := strings.HasSuffix(p, string(filepath.Separator)) + if strings.HasSuffix(p, string(filepath.Separator)) { + p = p[:len(p)-1] + } + // make sure we can access the file - i, err := source.fs.Stat(p) + i, err := fs.Stat(source.fs, p) if err != nil { return nil, fmt.Errorf("stat %s: %w", p, err) } - if strings.HasSuffix(p, string(filepath.Separator)) { + if isDir { source.mediaType = jsonArrayMimetype if i.IsDir() { return readFileDir(source, p) @@ -49,25 +58,19 @@ func readFile(_ context.Context, source *Source, args ...string) ([]byte, error) return nil, fmt.Errorf("%s is not a directory", p) } - f, err := source.fs.OpenFile(p, os.O_RDONLY, 0) + b, err := fs.ReadFile(source.fs, p) if err != nil { - return nil, fmt.Errorf("openFile %s: %w", p, err) - } - - defer f.Close() - - b, err := io.ReadAll(f) - if err != nil { - return nil, fmt.Errorf("readAll %s: %w", p, err) + return nil, fmt.Errorf("readFile %s: %w", p, err) } return b, nil } func readFileDir(source *Source, p string) ([]byte, error) { - names, err := afero.ReadDir(source.fs, p) + names, err := fs.ReadDir(source.fs, p) if err != nil { return nil, err } + files := make([]string, len(names)) for i, v := range names { files[i] = v.Name() diff --git a/data/datasource_file_test.go b/data/datasource_file_test.go index 420ca380..7ad1c2a0 100644 --- a/data/datasource_file_test.go +++ b/data/datasource_file_test.go @@ -2,10 +2,11 @@ package data import ( "context" + "io/fs" "testing" + "testing/fstest" - "github.com/spf13/afero" - + "github.com/hairyhenderson/gomplate/v4/internal/datafs" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -14,45 +15,42 @@ func TestReadFile(t *testing.T) { ctx := context.Background() content := []byte(`hello world`) - fs := afero.NewMemMapFs() - - _ = fs.Mkdir("/tmp", 0777) - f, _ := fs.Create("/tmp/foo") - _, _ = f.Write(content) - _ = fs.Mkdir("/tmp/partial", 0777) - f, _ = fs.Create("/tmp/partial/foo.txt") - _, _ = f.Write(content) - _, _ = fs.Create("/tmp/partial/bar.txt") - _, _ = fs.Create("/tmp/partial/baz.txt") - _ = f.Close() + fsys := datafs.WrapWdFS(fstest.MapFS{ + "tmp": {Mode: fs.ModeDir | 0o777}, + "tmp/foo": {Data: content}, + "tmp/partial": {Mode: fs.ModeDir | 0o777}, + "tmp/partial/foo.txt": {Data: content}, + "tmp/partial/bar.txt": {}, + "tmp/partial/baz.txt": {}, + }) source := &Source{Alias: "foo", URL: mustParseURL("file:///tmp/foo")} - source.fs = fs + source.fs = fsys actual, err := readFile(ctx, source) require.NoError(t, err) assert.Equal(t, content, actual) source = &Source{Alias: "bogus", URL: mustParseURL("file:///bogus")} - source.fs = fs + source.fs = fsys _, err = readFile(ctx, source) assert.Error(t, err) source = &Source{Alias: "partial", URL: mustParseURL("file:///tmp/partial")} - source.fs = fs + source.fs = fsys actual, err = readFile(ctx, source, "foo.txt") require.NoError(t, err) assert.Equal(t, content, actual) source = &Source{Alias: "dir", URL: mustParseURL("file:///tmp/partial/")} - source.fs = fs + source.fs = fsys actual, err = readFile(ctx, source) require.NoError(t, err) assert.Equal(t, []byte(`["bar.txt","baz.txt","foo.txt"]`), actual) source = &Source{Alias: "dir", URL: mustParseURL("file:///tmp/partial/?type=application/json")} - source.fs = fs + source.fs = fsys actual, err = readFile(ctx, source) require.NoError(t, err) assert.Equal(t, []byte(`["bar.txt","baz.txt","foo.txt"]`), actual) @@ -61,7 +59,7 @@ func TestReadFile(t *testing.T) { assert.Equal(t, "application/json", mime) source = &Source{Alias: "dir", URL: mustParseURL("file:///tmp/partial/?type=application/json")} - source.fs = fs + source.fs = fsys actual, err = readFile(ctx, source, "foo.txt") require.NoError(t, err) assert.Equal(t, content, actual) diff --git a/data/datasource_git.go b/data/datasource_git.go index c5e5adf8..5c12da4c 100644 --- a/data/datasource_git.go +++ b/data/datasource_git.go @@ -248,17 +248,17 @@ func (g gitsource) clone(ctx context.Context, repoURL *url.URL, depth int) (bill } // read - reads the provided path out of a git repo -func (g gitsource) read(fs billy.Filesystem, path string) (string, []byte, error) { - fi, err := fs.Stat(path) +func (g gitsource) read(fsys billy.Filesystem, path string) (string, []byte, error) { + fi, err := fsys.Stat(path) if err != nil { return "", nil, fmt.Errorf("can't stat %s: %w", path, err) } if fi.IsDir() || strings.HasSuffix(path, string(filepath.Separator)) { - out, rerr := g.readDir(fs, path) + out, rerr := g.readDir(fsys, path) return jsonArrayMimetype, out, rerr } - f, err := fs.OpenFile(path, os.O_RDONLY, 0) + f, err := fsys.OpenFile(path, os.O_RDONLY, 0) if err != nil { return "", nil, fmt.Errorf("can't open %s: %w", path, err) } diff --git a/data/datasource_git_test.go b/data/datasource_git_test.go index 157e14a8..a5de9bc4 100644 --- a/data/datasource_git_test.go +++ b/data/datasource_git_test.go @@ -331,10 +331,10 @@ func TestOpenFileRepo(t *testing.T) { overrideFSLoader(repoFS) defer overrideFSLoader(osfs.New("")) - fs, _, err := g.clone(ctx, mustParseURL("git+file:///repo"), 0) + fsys, _, err := g.clone(ctx, mustParseURL("git+file:///repo"), 0) assert.NilError(t, err) - f, err := fs.Open("/foo/bar/hi.txt") + f, err := fsys.Open("/foo/bar/hi.txt") assert.NilError(t, err) b, _ := io.ReadAll(f) assert.Equal(t, "hello world", string(b)) @@ -370,10 +370,10 @@ func TestOpenBareFileRepo(t *testing.T) { overrideFSLoader(repoFS) defer overrideFSLoader(osfs.New("")) - fs, _, err := g.clone(ctx, mustParseURL("git+file:///bare.git"), 0) + fsys, _, err := g.clone(ctx, mustParseURL("git+file:///bare.git"), 0) assert.NilError(t, err) - f, err := fs.Open("/hello.txt") + f, err := fsys.Open("/hello.txt") assert.NilError(t, err) b, _ := io.ReadAll(f) assert.Equal(t, "hello world", string(b)) diff --git a/data/datasource_merge.go b/data/datasource_merge.go index f2c28399..b0d8b6bd 100644 --- a/data/datasource_merge.go +++ b/data/datasource_merge.go @@ -6,7 +6,7 @@ import ( "strings" "github.com/hairyhenderson/gomplate/v4/coll" - "github.com/hairyhenderson/gomplate/v4/internal/config" + "github.com/hairyhenderson/gomplate/v4/internal/datafs" ) // readMerge demultiplexes a `merge:` datasource. The 'args' parameter currently @@ -31,7 +31,7 @@ func (d *Data) readMerge(ctx context.Context, source *Source, _ ...string) ([]by subSource, err := d.lookupSource(part) if err != nil { // maybe it's a relative filename? - u, uerr := config.ParseSourceURL(part) + u, uerr := datafs.ParseSourceURL(part) if uerr != nil { return nil, uerr } diff --git a/data/datasource_merge_test.go b/data/datasource_merge_test.go index 66365f36..48d1f85e 100644 --- a/data/datasource_merge_test.go +++ b/data/datasource_merge_test.go @@ -2,12 +2,15 @@ package data import ( "context" + "io/fs" "net/url" "os" + "path" "path/filepath" "testing" + "testing/fstest" - "github.com/spf13/afero" + "github.com/hairyhenderson/gomplate/v4/internal/datafs" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -22,31 +25,32 @@ func TestReadMerge(t *testing.T) { mergedContent := "goodnight: moon\nhello: world\n" - fs := afero.NewMemMapFs() - - _ = fs.Mkdir("/tmp", 0777) - f, _ := fs.Create("/tmp/jsonfile.json") - _, _ = f.WriteString(jsonContent) - f, _ = fs.Create("/tmp/array.json") - _, _ = f.WriteString(arrayContent) - f, _ = fs.Create("/tmp/yamlfile.yaml") - _, _ = f.WriteString(yamlContent) - f, _ = fs.Create("/tmp/textfile.txt") - _, _ = f.WriteString(`plain text...`) - wd, _ := os.Getwd() - _ = fs.Mkdir(wd, 0777) - f, _ = fs.Create(filepath.Join(wd, "jsonfile.json")) - _, _ = f.WriteString(jsonContent) - f, _ = fs.Create(filepath.Join(wd, "array.json")) - _, _ = f.WriteString(arrayContent) - f, _ = fs.Create(filepath.Join(wd, "yamlfile.yaml")) - _, _ = f.WriteString(yamlContent) - f, _ = fs.Create(filepath.Join(wd, "textfile.txt")) - _, _ = f.WriteString(`plain text...`) + + // MapFS doesn't support windows path separators, so we use / exclusively + // in this test + vol := filepath.VolumeName(wd) + if vol != "" && wd != vol { + wd = wd[len(vol)+1:] + } else if wd[0] == '/' { + wd = wd[1:] + } + wd = filepath.ToSlash(wd) + + fsys := datafs.WrapWdFS(fstest.MapFS{ + "tmp": {Mode: fs.ModeDir | 0o777}, + "tmp/jsonfile.json": {Data: []byte(jsonContent)}, + "tmp/array.json": {Data: []byte(arrayContent)}, + "tmp/yamlfile.yaml": {Data: []byte(yamlContent)}, + "tmp/textfile.txt": {Data: []byte(`plain text...`)}, + path.Join(wd, "jsonfile.json"): {Data: []byte(jsonContent)}, + path.Join(wd, "array.json"): {Data: []byte(arrayContent)}, + path.Join(wd, "yamlfile.yaml"): {Data: []byte(yamlContent)}, + path.Join(wd, "textfile.txt"): {Data: []byte(`plain text...`)}, + }) source := &Source{Alias: "foo", URL: mustParseURL("merge:file:///tmp/jsonfile.json|file:///tmp/yamlfile.yaml")} - source.fs = fs + source.fs = fsys d := &Data{ Sources: map[string]*Source{ "foo": source, @@ -68,6 +72,11 @@ func TestReadMerge(t *testing.T) { require.NoError(t, err) assert.Equal(t, mergedContent, string(actual)) + source.URL = mustParseURL("merge:jsonfile.json|baz") + actual, err = d.readMerge(ctx, source) + require.NoError(t, err) + assert.Equal(t, mergedContent, string(actual)) + source.URL = mustParseURL("merge:./jsonfile.json|baz") actual, err = d.readMerge(ctx, source) require.NoError(t, err) diff --git a/data/datasource_test.go b/data/datasource_test.go index c0d0c70a..43d59c85 100644 --- a/data/datasource_test.go +++ b/data/datasource_test.go @@ -7,9 +7,10 @@ import ( "net/url" "runtime" "testing" + "testing/fstest" "github.com/hairyhenderson/gomplate/v4/internal/config" - "github.com/spf13/afero" + "github.com/hairyhenderson/gomplate/v4/internal/datafs" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -45,26 +46,23 @@ func TestNewData(t *testing.T) { func TestDatasource(t *testing.T) { setup := func(ext, mime string, contents []byte) *Data { fname := "foo." + ext - fs := afero.NewMemMapFs() var uPath string - var f afero.File if runtime.GOOS == osWindows { - _ = fs.Mkdir("C:\\tmp", 0777) - f, _ = fs.Create("C:\\tmp\\" + fname) uPath = "C:/tmp/" + fname } else { - _ = fs.Mkdir("/tmp", 0777) - f, _ = fs.Create("/tmp/" + fname) uPath = "/tmp/" + fname } - _, _ = f.Write(contents) + + fsys := datafs.WrapWdFS(fstest.MapFS{ + "tmp/" + fname: &fstest.MapFile{Data: contents}, + }) sources := map[string]*Source{ "foo": { Alias: "foo", URL: &url.URL{Scheme: "file", Path: uPath}, mediaType: mime, - fs: fs, + fs: fsys, }, } return &Data{Sources: sources} @@ -102,31 +100,28 @@ func TestDatasource(t *testing.T) { func TestDatasourceReachable(t *testing.T) { fname := "foo.json" - fs := afero.NewMemMapFs() var uPath string - var f afero.File if runtime.GOOS == osWindows { - _ = fs.Mkdir("C:\\tmp", 0777) - f, _ = fs.Create("C:\\tmp\\" + fname) uPath = "C:/tmp/" + fname } else { - _ = fs.Mkdir("/tmp", 0777) - f, _ = fs.Create("/tmp/" + fname) uPath = "/tmp/" + fname } - _, _ = f.Write([]byte("{}")) + + fsys := datafs.WrapWdFS(fstest.MapFS{ + "tmp/" + fname: &fstest.MapFile{Data: []byte("{}")}, + }) sources := map[string]*Source{ "foo": { Alias: "foo", URL: &url.URL{Scheme: "file", Path: uPath}, mediaType: jsonMimetype, - fs: fs, + fs: fsys, }, "bar": { Alias: "bar", URL: &url.URL{Scheme: "file", Path: "/bogus"}, - fs: fs, + fs: fsys, }, } data := &Data{Sources: sources} @@ -148,27 +143,24 @@ func TestInclude(t *testing.T) { ext := "txt" contents := "hello world" fname := "foo." + ext - fs := afero.NewMemMapFs() var uPath string - var f afero.File if runtime.GOOS == osWindows { - _ = fs.Mkdir("C:\\tmp", 0777) - f, _ = fs.Create("C:\\tmp\\" + fname) uPath = "C:/tmp/" + fname } else { - _ = fs.Mkdir("/tmp", 0777) - f, _ = fs.Create("/tmp/" + fname) uPath = "/tmp/" + fname } - _, _ = f.Write([]byte(contents)) + + fsys := datafs.WrapWdFS(fstest.MapFS{ + "tmp/" + fname: &fstest.MapFile{Data: []byte(contents)}, + }) sources := map[string]*Source{ "foo": { Alias: "foo", URL: &url.URL{Scheme: "file", Path: uPath}, mediaType: textMimetype, - fs: fs, + fs: fsys, }, } data := &Data{ @@ -185,7 +177,6 @@ func (e errorReader) Read(_ []byte) (n int, err error) { return 0, fmt.Errorf("error") } -// nolint: megacheck func TestDefineDatasource(t *testing.T) { d := &Data{} _, err := d.DefineDatasource("", "foo.json") @@ -204,8 +195,7 @@ func TestDefineDatasource(t *testing.T) { s := d.Sources["data"] require.NoError(t, err) assert.Equal(t, "data", s.Alias) - assert.Equal(t, "file", s.URL.Scheme) - assert.True(t, s.URL.IsAbs()) + assert.EqualValues(t, &url.URL{Path: "foo.json"}, s.URL) d = &Data{} _, err = d.DefineDatasource("data", "/otherdir/foo.json") @@ -2,11 +2,12 @@ package env import ( - "io" + "io/fs" "os" "strings" - "github.com/spf13/afero" + osfs "github.com/hack-pad/hackpadfs/os" + "github.com/hairyhenderson/gomplate/v4/internal/datafs" ) // Getenv - retrieves the value of the environment variable named by the key. @@ -14,24 +15,26 @@ import ( // referenced file will be read into the value. // Otherwise the provided default (or an emptry string) is returned. func Getenv(key string, def ...string) string { - return getenvVFS(afero.NewOsFs(), key, def...) + fsys := datafs.WrapWdFS(osfs.NewFS()) + return getenvVFS(fsys, key, def...) } // ExpandEnv - like os.ExpandEnv, except supports `_FILE` vars as well func ExpandEnv(s string) string { - return expandEnvVFS(afero.NewOsFs(), s) + fsys := datafs.WrapWdFS(osfs.NewFS()) + return expandEnvVFS(fsys, s) } // expandEnvVFS - -func expandEnvVFS(fs afero.Fs, s string) string { +func expandEnvVFS(fsys fs.FS, s string) string { return os.Expand(s, func(s string) string { - return getenvVFS(fs, s) + return getenvVFS(fsys, s) }) } // getenvVFS - a convenience function intended for internal use only! -func getenvVFS(fs afero.Fs, key string, def ...string) string { - val := getenvFile(fs, key) +func getenvVFS(fsys fs.FS, key string, def ...string) string { + val := getenvFile(fsys, key) if val == "" && len(def) > 0 { return def[0] } @@ -39,7 +42,7 @@ func getenvVFS(fs afero.Fs, key string, def ...string) string { return val } -func getenvFile(fs afero.Fs, key string) string { +func getenvFile(fsys fs.FS, key string) string { val := os.Getenv(key) if val != "" { return val @@ -47,7 +50,7 @@ func getenvFile(fs afero.Fs, key string) string { p := os.Getenv(key + "_FILE") if p != "" { - val, err := readFile(fs, p) + val, err := readFile(fsys, p) if err != nil { return "" } @@ -57,14 +60,11 @@ func getenvFile(fs afero.Fs, key string) string { return "" } -func readFile(fs afero.Fs, p string) (string, error) { - f, err := fs.OpenFile(p, os.O_RDONLY, 0) - if err != nil { - return "", err - } - b, err := io.ReadAll(f) +func readFile(fsys fs.FS, p string) (string, error) { + b, err := fs.ReadFile(fsys, p) if err != nil { return "", err } + return string(b), nil } diff --git a/env/env_test.go b/env/env_test.go index 59bf4048..faaaa603 100644 --- a/env/env_test.go +++ b/env/env_test.go @@ -2,10 +2,13 @@ package env import ( "errors" + "io/fs" "os" "testing" + "testing/fstest" - "github.com/spf13/afero" + "github.com/hack-pad/hackpadfs" + "github.com/hairyhenderson/gomplate/v4/internal/datafs" "github.com/stretchr/testify/assert" ) @@ -17,22 +20,22 @@ func TestGetenv(t *testing.T) { } func TestGetenvFile(t *testing.T) { - fs := afero.NewMemMapFs() - _ = fs.Mkdir("/tmp", 0777) - f, _ := fs.Create("/tmp/foo") - _, _ = f.Write([]byte("foo")) - - defer os.Unsetenv("FOO_FILE") - os.Setenv("FOO_FILE", "/tmp/foo") - assert.Equal(t, "foo", getenvVFS(fs, "FOO", "bar")) - - os.Setenv("FOO_FILE", "/tmp/missing") - assert.Equal(t, "bar", getenvVFS(fs, "FOO", "bar")) - - _, _ = fs.Create("/tmp/unreadable") - fs = writeOnly(fs) - os.Setenv("FOO_FILE", "/tmp/unreadable") - assert.Equal(t, "bar", getenvVFS(fs, "FOO", "bar")) + fsys := fs.FS(fstest.MapFS{ + "tmp": &fstest.MapFile{Mode: fs.ModeDir | 0o777}, + "tmp/foo": &fstest.MapFile{Data: []byte("foo")}, + "tmp/unreadable": &fstest.MapFile{Data: []byte("foo"), Mode: 0o000}, + }) + fsys = datafs.WrapWdFS(fsys) + + t.Setenv("FOO_FILE", "/tmp/foo") + assert.Equal(t, "foo", getenvVFS(fsys, "FOO", "bar")) + + t.Setenv("FOO_FILE", "/tmp/missing") + assert.Equal(t, "bar", getenvVFS(fsys, "FOO", "bar")) + + fsys = writeOnly(fsys) + t.Setenv("FOO_FILE", "/tmp/unreadable") + assert.Equal(t, "bar", getenvVFS(fsys, "FOO", "bar")) } func TestExpandEnv(t *testing.T) { @@ -44,73 +47,62 @@ func TestExpandEnv(t *testing.T) { } func TestExpandEnvFile(t *testing.T) { - fs := afero.NewMemMapFs() - _ = fs.Mkdir("/tmp", 0777) - f, _ := fs.Create("/tmp/foo") - _, _ = f.Write([]byte("foo")) - - defer os.Unsetenv("FOO_FILE") - os.Setenv("FOO_FILE", "/tmp/foo") - assert.Equal(t, "foo is foo", expandEnvVFS(fs, "foo is $FOO")) - - os.Setenv("FOO_FILE", "/tmp/missing") - assert.Equal(t, "empty", expandEnvVFS(fs, "${FOO}empty")) - - _, _ = fs.Create("/tmp/unreadable") - fs = writeOnly(fs) - os.Setenv("FOO_FILE", "/tmp/unreadable") - assert.Equal(t, "", expandEnvVFS(fs, "${FOO}")) -} + fsys := fs.FS(fstest.MapFS{ + "tmp": &fstest.MapFile{Mode: fs.ModeDir | 0o777}, + "tmp/foo": &fstest.MapFile{Data: []byte("foo")}, + "tmp/unreadable": &fstest.MapFile{Data: []byte("foo"), Mode: 0o000}, + }) + fsys = datafs.WrapWdFS(fsys) -// Maybe extract this into a separate package sometime... -// writeOnly - represents a filesystem that's writeable, but read operations fail -func writeOnly(fs afero.Fs) afero.Fs { - return &woFS{fs} -} + t.Setenv("FOO_FILE", "/tmp/foo") + assert.Equal(t, "foo is foo", expandEnvVFS(fsys, "foo is $FOO")) -type woFS struct { - afero.Fs -} + t.Setenv("FOO_FILE", "/tmp/missing") + assert.Equal(t, "empty", expandEnvVFS(fsys, "${FOO}empty")) -func (fs woFS) Remove(name string) error { - return fs.Fs.Remove(name) + fsys = writeOnly(fsys) + t.Setenv("FOO_FILE", "/tmp/unreadable") + assert.Equal(t, "", expandEnvVFS(fsys, "${FOO}")) } -func (fs woFS) Rename(oldpath, newpath string) error { - return fs.Fs.Rename(oldpath, newpath) +// Maybe extract this into a separate package sometime... +// writeOnly - represents a filesystem that's writeable, but read operations fail +func writeOnly(fsys fs.FS) fs.FS { + return &woFS{fsys} } -func (fs woFS) Mkdir(name string, perm os.FileMode) error { - return fs.Fs.Mkdir(name, perm) +type woFS struct { + fsys fs.FS } -func (fs woFS) OpenFile(name string, flag int, perm os.FileMode) (afero.File, error) { - f, err := fs.Fs.OpenFile(name, flag, perm) - if err != nil { - return writeOnlyFile(f), err - } - return writeOnlyFile(f), nil +func (fsys woFS) Open(name string) (fs.File, error) { + f, err := fsys.fsys.Open(name) + return writeOnlyFile(f), err } -func (fs woFS) ReadDir(_ string) ([]os.FileInfo, error) { +func (fsys woFS) ReadDir(_ string) ([]fs.DirEntry, error) { return nil, ErrWriteOnly } -func (fs woFS) Stat(_ string) (os.FileInfo, error) { +func (fsys woFS) Stat(_ string) (fs.FileInfo, error) { return nil, ErrWriteOnly } -func writeOnlyFile(f afero.File) afero.File { +func writeOnlyFile(f fs.File) fs.File { + if f == nil { + return nil + } + return &woFile{f} } type woFile struct { - afero.File + fs.File } -// Write is disabled and returns ErrWriteOnly +// Write - func (f woFile) Write(p []byte) (n int, err error) { - return f.File.Write(p) + return hackpadfs.WriteFile(f.File, p) } // Read is disabled and returns ErrWriteOnly diff --git a/file/file.go b/file/file.go index 11857fff..6b7a8e8a 100644 --- a/file/file.go +++ b/file/file.go @@ -3,28 +3,22 @@ package file import ( "fmt" - "io" - "os" - "path/filepath" - "strings" + "io/fs" + "github.com/hairyhenderson/gomplate/v4/internal/datafs" "github.com/hairyhenderson/gomplate/v4/internal/iohelpers" - "github.com/spf13/afero" + osfs "github.com/hack-pad/hackpadfs/os" ) -// for overriding in tests -var fs = afero.NewOsFs() +// fsys for legacy functions - see deprecation notices +var fsys = datafs.WrapWdFS(osfs.NewFS()) // Read the contents of the referenced file, as a string. +// +// Deprecated: (as of 4.0.0) use [io/fs#ReadFile] instead func Read(filename string) (string, error) { - inFile, err := fs.OpenFile(filename, os.O_RDONLY, 0) - if err != nil { - return "", fmt.Errorf("failed to open %s: %w", filename, err) - } - // nolint: errcheck - defer inFile.Close() - bytes, err := io.ReadAll(inFile) + bytes, err := fs.ReadFile(fsys, filename) if err != nil { err = fmt.Errorf("read failed for %s: %w", filename, err) return "", err @@ -33,73 +27,26 @@ func Read(filename string) (string, error) { } // ReadDir gets a directory listing. +// +// Deprecated: (as of 4.0.0) use [io/fs#ReadDir] instead func ReadDir(path string) ([]string, error) { - f, err := fs.Open(path) - if err != nil { - return nil, err - } - i, err := f.Stat() + des, err := fs.ReadDir(fsys, path) if err != nil { return nil, err } - if i.IsDir() { - return f.Readdirnames(0) + + names := make([]string, len(des)) + for i, de := range des { + names[i] = de.Name() } - return nil, fmt.Errorf("file is not a directory") + + return names, nil } // Write the given content to the file, truncating any existing file, and // creating the directory structure leading up to it if necessary. +// +// Deprecated: (as of 4.0.0) use [os#WriteFile] instead func Write(filename string, content []byte) error { - err := assertPathInWD(filename) - if err != nil { - return fmt.Errorf("failed to open %s: %w", filename, err) - } - - fi, err := os.Stat(filename) - if err != nil && !os.IsNotExist(err) { - return fmt.Errorf("failed to stat %s: %w", filename, err) - } - mode := iohelpers.NormalizeFileMode(0o644) - if fi != nil { - mode = fi.Mode() - } - err = fs.MkdirAll(filepath.Dir(filename), 0o755) - if err != nil { - return fmt.Errorf("failed to make dirs for %s: %w", filename, err) - } - inFile, err := fs.OpenFile(filename, os.O_RDWR|os.O_CREATE|os.O_TRUNC, mode) - if err != nil { - return fmt.Errorf("failed to open %s: %w", filename, err) - } - - defer inFile.Close() - - n, err := inFile.Write(content) - if err != nil { - return fmt.Errorf("failed to write %s: %w", filename, err) - } - if n != len(content) { - return fmt.Errorf("short write on %s (%d bytes): %w", filename, n, err) - } - return nil -} - -func assertPathInWD(filename string) error { - wd, err := os.Getwd() - if err != nil { - return err - } - f, err := filepath.Abs(filename) - if err != nil { - return err - } - r, err := filepath.Rel(wd, f) - if err != nil { - return err - } - if strings.HasPrefix(r, "..") { - return fmt.Errorf("path %s not contained by working directory %s (rel: %s)", filename, wd, r) - } - return nil + return iohelpers.WriteFile(fsys, filename, content) } diff --git a/file/file_test.go b/file/file_test.go index 73821f50..1c185951 100644 --- a/file/file_test.go +++ b/file/file_test.go @@ -1,23 +1,22 @@ package file import ( - "os" - "path/filepath" + "io/fs" "testing" + "testing/fstest" - "github.com/spf13/afero" + "github.com/hairyhenderson/gomplate/v4/internal/datafs" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - tfs "gotest.tools/v3/fs" ) func TestRead(t *testing.T) { - origfs := fs - defer func() { fs = origfs }() - fs = afero.NewMemMapFs() - _ = fs.Mkdir("/tmp", 0777) - f, _ := fs.Create("/tmp/foo") - _, _ = f.Write([]byte("foo")) + oldFS := fsys + defer func() { fsys = oldFS }() + fsys = datafs.WrapWdFS(fstest.MapFS{ + "tmp": &fstest.MapFile{Mode: fs.ModeDir | 0o777}, + "tmp/foo": &fstest.MapFile{Data: []byte("foo")}, + }) actual, err := Read("/tmp/foo") require.NoError(t, err) @@ -28,15 +27,16 @@ func TestRead(t *testing.T) { } func TestReadDir(t *testing.T) { - origfs := fs - defer func() { fs = origfs }() - fs = afero.NewMemMapFs() - fs.Mkdir("/tmp", 0777) - fs.Create("/tmp/foo") - fs.Create("/tmp/bar") - fs.Create("/tmp/baz") - fs.Mkdir("/tmp/qux", 0777) - fs.Create("/tmp/qux/quux") + oldFS := fsys + defer func() { fsys = oldFS }() + fsys = datafs.WrapWdFS(fstest.MapFS{ + "tmp": &fstest.MapFile{Mode: fs.ModeDir | 0o777}, + "tmp/foo": &fstest.MapFile{Data: []byte("foo")}, + "tmp/bar": &fstest.MapFile{Data: []byte("bar")}, + "tmp/baz": &fstest.MapFile{Data: []byte("baz")}, + "tmp/qux": &fstest.MapFile{Mode: fs.ModeDir | 0o777}, + "tmp/qux/quux": &fstest.MapFile{Data: []byte("quux")}, + }) actual, err := ReadDir("/tmp") require.NoError(t, err) @@ -45,79 +45,3 @@ func TestReadDir(t *testing.T) { _, err = ReadDir("/tmp/foo") assert.Error(t, err) } - -func TestWrite(t *testing.T) { - oldwd, _ := os.Getwd() - defer os.Chdir(oldwd) - - rootDir := tfs.NewDir(t, "gomplate-test") - defer rootDir.Remove() - - newwd := rootDir.Join("the", "path", "we", "want") - badwd := rootDir.Join("some", "other", "dir") - fs.MkdirAll(newwd, 0755) - fs.MkdirAll(badwd, 0755) - newwd, _ = filepath.EvalSymlinks(newwd) - badwd, _ = filepath.EvalSymlinks(badwd) - - err := os.Chdir(newwd) - require.NoError(t, err) - - err = Write("/foo", []byte("Hello world")) - assert.Error(t, err) - - rel, err := filepath.Rel(newwd, badwd) - require.NoError(t, err) - err = Write(rel, []byte("Hello world")) - assert.Error(t, err) - - foopath := filepath.Join(newwd, "foo") - err = Write(foopath, []byte("Hello world")) - require.NoError(t, err) - - out, err := os.ReadFile(foopath) - require.NoError(t, err) - assert.Equal(t, "Hello world", string(out)) - - err = Write(foopath, []byte("truncate")) - require.NoError(t, err) - - out, err = os.ReadFile(foopath) - require.NoError(t, err) - assert.Equal(t, "truncate", string(out)) - - foopath = filepath.Join(newwd, "nonexistant", "subdir", "foo") - err = Write(foopath, []byte("Hello subdirranean world!")) - require.NoError(t, err) - - out, err = os.ReadFile(foopath) - require.NoError(t, err) - assert.Equal(t, "Hello subdirranean world!", string(out)) -} - -func TestAssertPathInWD(t *testing.T) { - oldwd, _ := os.Getwd() - defer os.Chdir(oldwd) - - err := assertPathInWD("/tmp") - assert.Error(t, err) - - err = assertPathInWD(filepath.Join(oldwd, "subpath")) - require.NoError(t, err) - - err = assertPathInWD("subpath") - require.NoError(t, err) - - err = assertPathInWD("./subpath") - require.NoError(t, err) - - err = assertPathInWD(filepath.Join("..", "bogus")) - assert.Error(t, err) - - err = assertPathInWD(filepath.Join("..", "..", "bogus")) - assert.Error(t, err) - - base := filepath.Base(oldwd) - err = assertPathInWD(filepath.Join("..", base)) - require.NoError(t, err) -} diff --git a/funcs/file.go b/funcs/file.go index 05fecf77..32af47cf 100644 --- a/funcs/file.go +++ b/funcs/file.go @@ -2,11 +2,13 @@ package funcs import ( "context" - "os" + "io/fs" + "path/filepath" + osfs "github.com/hack-pad/hackpadfs/os" "github.com/hairyhenderson/gomplate/v4/conv" - "github.com/hairyhenderson/gomplate/v4/file" - "github.com/spf13/afero" + "github.com/hairyhenderson/gomplate/v4/internal/datafs" + "github.com/hairyhenderson/gomplate/v4/internal/iohelpers" ) // FileNS - the File namespace @@ -27,10 +29,16 @@ func AddFileFuncs(f map[string]interface{}) { // CreateFileFuncs - func CreateFileFuncs(ctx context.Context) map[string]interface{} { + fsys, err := datafs.FSysForPath(ctx, "/") + if err != nil { + fsys = datafs.WrapWdFS(osfs.NewFS()) + } + ns := &FileFuncs{ ctx: ctx, - fs: afero.NewOsFs(), + fs: fsys, } + return map[string]interface{}{ "file": func() interface{} { return ns }, } @@ -39,17 +47,18 @@ func CreateFileFuncs(ctx context.Context) map[string]interface{} { // FileFuncs - type FileFuncs struct { ctx context.Context - fs afero.Fs + fs fs.FS } // Read - func (f *FileFuncs) Read(path interface{}) (string, error) { - return file.Read(conv.ToString(path)) + b, err := fs.ReadFile(f.fs, conv.ToString(path)) + return string(b), err } // Stat - -func (f *FileFuncs) Stat(path interface{}) (os.FileInfo, error) { - return f.fs.Stat(conv.ToString(path)) +func (f *FileFuncs) Stat(path interface{}) (fs.FileInfo, error) { + return fs.Stat(f.fs, conv.ToString(path)) } // Exists - @@ -66,16 +75,32 @@ func (f *FileFuncs) IsDir(path interface{}) bool { // ReadDir - func (f *FileFuncs) ReadDir(path interface{}) ([]string, error) { - return file.ReadDir(conv.ToString(path)) + des, err := fs.ReadDir(f.fs, conv.ToString(path)) + if err != nil { + return nil, err + } + + names := make([]string, len(des)) + for i, de := range des { + names[i] = de.Name() + } + + return names, nil } // Walk - func (f *FileFuncs) Walk(path interface{}) ([]string, error) { files := make([]string, 0) - err := afero.Walk(f.fs, conv.ToString(path), func(subpath string, finfo os.FileInfo, err error) error { + err := fs.WalkDir(f.fs, conv.ToString(path), func(subpath string, d fs.DirEntry, err error) error { if err != nil { return err } + + // fs.WalkDir always uses slash-separated paths, even on Windows. We + // need to convert them to the OS-specific separator as that was the + // previous behavior. + subpath = filepath.FromSlash(subpath) + files = append(files, subpath) return nil }) @@ -84,10 +109,20 @@ func (f *FileFuncs) Walk(path interface{}) ([]string, error) { // Write - func (f *FileFuncs) Write(path interface{}, data interface{}) (s string, err error) { + type byteser interface{ Bytes() []byte } + + var content []byte + fname := conv.ToString(path) + if b, ok := data.([]byte); ok { - err = file.Write(conv.ToString(path), b) + content = b + } else if b, ok := data.(byteser); ok { + content = b.Bytes() } else { - err = file.Write(conv.ToString(path), []byte(conv.ToString(data))) + content = []byte(conv.ToString(data)) } + + err = iohelpers.WriteFile(f.fs, fname, content) + return "", err } diff --git a/funcs/file_test.go b/funcs/file_test.go index d36f7f65..e70c178a 100644 --- a/funcs/file_test.go +++ b/funcs/file_test.go @@ -1,14 +1,21 @@ package funcs import ( + "bytes" "context" + "io/fs" + "os" "path/filepath" "strconv" "testing" + "testing/fstest" - "github.com/spf13/afero" + "github.com/hack-pad/hackpadfs" + osfs "github.com/hack-pad/hackpadfs/os" + "github.com/hairyhenderson/gomplate/v4/internal/datafs" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + tfs "gotest.tools/v3/fs" ) func TestCreateFileFuncs(t *testing.T) { @@ -31,12 +38,11 @@ func TestCreateFileFuncs(t *testing.T) { func TestFileExists(t *testing.T) { t.Parallel() - fs := afero.NewMemMapFs() - ff := &FileFuncs{fs: fs} - - _ = fs.Mkdir("/tmp", 0777) - f, _ := fs.Create("/tmp/foo") - _, _ = f.Write([]byte("foo")) + fsys := fstest.MapFS{ + "tmp": &fstest.MapFile{Mode: fs.ModeDir | 0o777}, + "tmp/foo": &fstest.MapFile{Data: []byte("foo")}, + } + ff := &FileFuncs{fs: datafs.WrapWdFS(fsys)} assert.True(t, ff.Exists("/tmp/foo")) assert.False(t, ff.Exists("/tmp/bar")) @@ -45,12 +51,12 @@ func TestFileExists(t *testing.T) { func TestFileIsDir(t *testing.T) { t.Parallel() - fs := afero.NewMemMapFs() - ff := &FileFuncs{fs: fs} + fsys := fstest.MapFS{ + "tmp": &fstest.MapFile{Mode: fs.ModeDir | 0o777}, + "tmp/foo": &fstest.MapFile{Data: []byte("foo")}, + } - _ = fs.Mkdir("/tmp", 0777) - f, _ := fs.Create("/tmp/foo") - _, _ = f.Write([]byte("foo")) + ff := &FileFuncs{fs: datafs.WrapWdFS(fsys)} assert.True(t, ff.IsDir("/tmp")) assert.False(t, ff.IsDir("/tmp/foo")) @@ -59,14 +65,14 @@ func TestFileIsDir(t *testing.T) { func TestFileWalk(t *testing.T) { t.Parallel() - fs := afero.NewMemMapFs() - ff := &FileFuncs{fs: fs} + fsys := fstest.MapFS{ + "tmp": &fstest.MapFile{Mode: fs.ModeDir | 0o777}, + "tmp/bar": &fstest.MapFile{Mode: fs.ModeDir | 0o777}, + "tmp/bar/baz": &fstest.MapFile{Mode: fs.ModeDir | 0o777}, + "tmp/bar/baz/foo": &fstest.MapFile{Data: []byte("foo")}, + } - _ = fs.Mkdir("/tmp", 0777) - _ = fs.Mkdir("/tmp/bar", 0777) - _ = fs.Mkdir("/tmp/bar/baz", 0777) - f, _ := fs.Create("/tmp/bar/baz/foo") - _, _ = f.Write([]byte("foo")) + ff := &FileFuncs{fs: datafs.WrapWdFS(fsys)} expectedLists := [][]string{{"tmp"}, {"tmp", "bar"}, {"tmp", "bar", "baz"}, {"tmp", "bar", "baz", "foo"}} expectedPaths := make([]string, 0) @@ -79,3 +85,93 @@ func TestFileWalk(t *testing.T) { require.NoError(t, err) assert.Equal(t, expectedPaths, actualPaths) } + +func TestReadDir(t *testing.T) { + fsys := fs.FS(fstest.MapFS{ + "tmp": &fstest.MapFile{Mode: fs.ModeDir | 0o777}, + "tmp/foo": &fstest.MapFile{Data: []byte("foo")}, + "tmp/bar": &fstest.MapFile{Data: []byte("bar")}, + "tmp/baz": &fstest.MapFile{Data: []byte("baz")}, + "tmp/qux": &fstest.MapFile{Mode: fs.ModeDir | 0o777}, + "tmp/qux/quux": &fstest.MapFile{Data: []byte("quux")}, + }) + + fsys = datafs.WrapWdFS(fsys) + + ff := &FileFuncs{ + ctx: context.Background(), + fs: fsys, + } + + actual, err := ff.ReadDir("/tmp") + require.NoError(t, err) + assert.Equal(t, []string{"bar", "baz", "foo", "qux"}, actual) + + _, err = ff.ReadDir("/tmp/foo") + assert.Error(t, err) +} + +func TestWrite(t *testing.T) { + oldwd, _ := os.Getwd() + defer os.Chdir(oldwd) + + rootDir := tfs.NewDir(t, "gomplate-test") + t.Cleanup(rootDir.Remove) + + // we want to use a real filesystem here, so we can test interactions with + // the current working directory + fsys := datafs.WrapWdFS(osfs.NewFS()) + + f := &FileFuncs{ + ctx: context.Background(), + fs: fsys, + } + + newwd := rootDir.Join("the", "path", "we", "want") + badwd := rootDir.Join("some", "other", "dir") + hackpadfs.MkdirAll(fsys, newwd, 0755) + hackpadfs.MkdirAll(fsys, badwd, 0755) + newwd, _ = filepath.EvalSymlinks(newwd) + badwd, _ = filepath.EvalSymlinks(badwd) + + err := os.Chdir(newwd) + require.NoError(t, err) + + _, err = f.Write("/foo", []byte("Hello world")) + assert.Error(t, err) + + rel, err := filepath.Rel(newwd, badwd) + require.NoError(t, err) + _, err = f.Write(rel, []byte("Hello world")) + assert.Error(t, err) + + foopath := filepath.Join(newwd, "foo") + _, err = f.Write(foopath, []byte("Hello world")) + require.NoError(t, err) + + out, err := fs.ReadFile(fsys, foopath) + require.NoError(t, err) + assert.Equal(t, "Hello world", string(out)) + + _, err = f.Write(foopath, []byte("truncate")) + require.NoError(t, err) + + out, err = fs.ReadFile(fsys, foopath) + require.NoError(t, err) + assert.Equal(t, "truncate", string(out)) + + foopath = filepath.Join(newwd, "nonexistant", "subdir", "foo") + _, err = f.Write(foopath, "Hello subdirranean world!") + require.NoError(t, err) + + out, err = fs.ReadFile(fsys, foopath) + require.NoError(t, err) + assert.Equal(t, "Hello subdirranean world!", string(out)) + + _, err = f.Write(foopath, bytes.NewBufferString("Hello from a byte buffer!")) + require.NoError(t, err) + + out, err = fs.ReadFile(fsys, foopath) + require.NoError(t, err) + assert.Equal(t, "Hello from a byte buffer!", string(out)) +} @@ -13,8 +13,10 @@ require ( github.com/go-git/go-git/v5 v5.6.1 github.com/google/uuid v1.3.0 github.com/gosimple/slug v1.13.1 + github.com/hack-pad/hackpadfs v0.2.0 github.com/hairyhenderson/go-fsimpl v0.0.0-20230121155226-8aa24800449d github.com/hairyhenderson/toml v0.4.2-0.20210923231440-40456b8e66cf + github.com/hairyhenderson/xignore v0.3.3-0.20230403012150-95fe86932830 // iofs-port branch github.com/hashicorp/consul/api v1.20.0 github.com/hashicorp/go-sockaddr v1.0.2 github.com/hashicorp/vault/api v1.9.0 @@ -22,11 +24,9 @@ require ( github.com/johannesboyne/gofakes3 v0.0.0-20220627085814-c3ac35da23b2 github.com/joho/godotenv v1.5.1 github.com/rs/zerolog v1.29.1 - github.com/spf13/afero v1.9.5 github.com/spf13/cobra v1.7.0 github.com/stretchr/testify v1.8.2 github.com/ugorji/go/codec v1.2.11 - github.com/zealic/xignore v0.3.3 go4.org/netipx v0.0.0-20230125063823-8449b0a6169f gocloud.dev v0.29.0 golang.org/x/crypto v0.8.0 @@ -1212,10 +1212,14 @@ github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFb github.com/grpc-ecosystem/grpc-gateway/v2 v2.7.0/go.mod h1:hgWBS7lorOAVIJEQMi4ZsPv9hVvWI6+ch50m39Pf2Ks= github.com/grpc-ecosystem/grpc-gateway/v2 v2.11.1/go.mod h1:G+WkljZi4mflcqVxYSgvt8MNctRQHjEH8ubKtt1Ka3w= github.com/grpc-ecosystem/grpc-gateway/v2 v2.11.3/go.mod h1:o//XUCC/F+yRGJoPO/VU0GSB0f8Nhgmxx0VIRUvaC0w= +github.com/hack-pad/hackpadfs v0.2.0 h1:biRa6fvmuwwdbmODi2lnA+WlNkCStvmj3jr6DndMqKY= +github.com/hack-pad/hackpadfs v0.2.0/go.mod h1:8Pz+ynD4SBpYltFauQHxSvCL35CCaqfTJBAs9Zbs38k= github.com/hairyhenderson/go-fsimpl v0.0.0-20230121155226-8aa24800449d h1:RqyRRWUi3ftiPrEuO9ZbaILf6C8TDRw90fLM8pqbzGs= github.com/hairyhenderson/go-fsimpl v0.0.0-20230121155226-8aa24800449d/go.mod h1:2k9HLXToBp8dOXrbgZUTaZfm03JZlkCGkrgvp4ip/JQ= github.com/hairyhenderson/toml v0.4.2-0.20210923231440-40456b8e66cf h1:I1sbT4ZbIt9i+hB1zfKw2mE8C12TuGxPiW7YmtLbPa4= github.com/hairyhenderson/toml v0.4.2-0.20210923231440-40456b8e66cf/go.mod h1:jDHmWDKZY6MIIYltYYfW4Rs7hQ50oS4qf/6spSiZAxY= +github.com/hairyhenderson/xignore v0.3.3-0.20230403012150-95fe86932830 h1:f+VnmDFJqYgkq1PRraUsYEzJ7bFr36CmzOb/xfV5Q9s= +github.com/hairyhenderson/xignore v0.3.3-0.20230403012150-95fe86932830/go.mod h1:UqUZ8CHnVcV2/rb26Ydn+PQO7bAI8kFONU/vaK1Q/WU= github.com/hairyhenderson/yaml v0.0.0-20220618171115-2d35fca545ce h1:cVkYhlWAxwuS2/Yp6qPtcl0fGpcWxuZNonywHZ6/I+s= github.com/hairyhenderson/yaml v0.0.0-20220618171115-2d35fca545ce/go.mod h1:7TyiGlHI+IO+iJbqRZ82QbFtvgj/AIcFm5qc9DLn7Kc= github.com/hanwen/go-fuse/v2 v2.2.0/go.mod h1:B1nGE/6RBFyBRC1RRnf23UpwCdyJ31eukw34oAKukAc= @@ -1795,15 +1799,12 @@ github.com/soheilhy/cmux v0.1.5/go.mod h1:T7TcVDs9LWfQgPlPsdngu6I6QIoyIFZDDC6sNE github.com/sony/gobreaker v0.4.1/go.mod h1:ZKptC7FHNvhBz7dN2LGjPVBz2sZJmc0/PkyDJOjmxWY= github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ= -github.com/spf13/afero v1.2.0/go.mod h1:9ZxEEn6pIJ8Rxe320qSDBk6AsU0r9pR7Q4OcevTdifk= github.com/spf13/afero v1.2.1/go.mod h1:9ZxEEn6pIJ8Rxe320qSDBk6AsU0r9pR7Q4OcevTdifk= github.com/spf13/afero v1.2.2/go.mod h1:9ZxEEn6pIJ8Rxe320qSDBk6AsU0r9pR7Q4OcevTdifk= github.com/spf13/afero v1.3.3/go.mod h1:5KUK8ByomD5Ti5Artl0RtHeI5pTF7MIDuXL3yY520V4= github.com/spf13/afero v1.6.0/go.mod h1:Ai8FlHk4v/PARR026UzYexafAt9roJ7LcLMAmO6Z93I= github.com/spf13/afero v1.8.2/go.mod h1:CtAatgMJh6bJEIs48Ay/FOnkljP3WeGUG0MC1RfAqwo= github.com/spf13/afero v1.9.2/go.mod h1:iUV7ddyEEZPO5gA3zD4fJt6iStLlL+Lg4m2cihcDf8Y= -github.com/spf13/afero v1.9.5 h1:stMpOSZFs//0Lv29HduCmli3GUfpFoF3Y1Q/aXj/wVM= -github.com/spf13/afero v1.9.5/go.mod h1:UBogFpq8E9Hx+xc5CNTTEpTnuHVmXDwZcZcE1eb/UhQ= github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= github.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= github.com/spf13/cast v1.5.0/go.mod h1:SpXXQ5YoyJw6s3/6cMTQuxvgRl3PCJiyaX9p6b155UU= @@ -1906,8 +1907,6 @@ github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5t github.com/yvasiyarov/go-metrics v0.0.0-20140926110328-57bccd1ccd43/go.mod h1:aX5oPXxHm3bOH+xeAttToC8pqch2ScQN/JoXYupl6xs= github.com/yvasiyarov/gorelic v0.0.0-20141212073537-a9bba5b9ab50/go.mod h1:NUSPSUX/bi6SeDMUh6brw0nXpxHnc96TguQh0+r/ssA= github.com/yvasiyarov/newrelic_platform_go v0.0.0-20140908184405-b21fdbd4370f/go.mod h1:GlGEuHIJweS1mbCqG+7vt2nvWLzLLnRHbXz5JKd/Qbg= -github.com/zealic/xignore v0.3.3 h1:EpLXUgZY/JEzFkTc+Y/VYypzXtNz+MSOMVCGW5Q4CKQ= -github.com/zealic/xignore v0.3.3/go.mod h1:lhS8V7fuSOtJOKsvKI7WfsZE276/7AYEqokv3UiqEAU= github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q= go.etcd.io/bbolt v1.3.2/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU= go.etcd.io/bbolt v1.3.3/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU= diff --git a/internal/cmd/config.go b/internal/cmd/config.go index cfba90f7..409bfa28 100644 --- a/internal/cmd/config.go +++ b/internal/cmd/config.go @@ -8,10 +8,10 @@ import ( "github.com/hairyhenderson/gomplate/v4/conv" "github.com/hairyhenderson/gomplate/v4/env" "github.com/hairyhenderson/gomplate/v4/internal/config" + "github.com/hairyhenderson/gomplate/v4/internal/datafs" "github.com/rs/zerolog" - "github.com/spf13/afero" "github.com/spf13/cobra" ) @@ -19,20 +19,17 @@ const ( defaultConfigFile = ".gomplate.yaml" ) -var fs = afero.NewOsFs() - // loadConfig is intended to be called before command execution. It: // - creates a config.Config from the cobra flags // - creates a config.Config from the config file (if present) // - merges the two (flags take precedence) -func loadConfig(cmd *cobra.Command, args []string) (*config.Config, error) { - ctx := cmd.Context() +func loadConfig(ctx context.Context, cmd *cobra.Command, args []string) (*config.Config, error) { flagConfig, err := cobraConfig(cmd, args) if err != nil { return nil, err } - cfg, err := readConfigFile(cmd) + cfg, err := readConfigFile(ctx, cmd) if err != nil { return nil, err } @@ -68,16 +65,18 @@ func pickConfigFile(cmd *cobra.Command) (cfgFile string, required bool) { return cfgFile, required } -func readConfigFile(cmd *cobra.Command) (cfg *config.Config, err error) { - ctx := cmd.Context() - if ctx == nil { - ctx = context.Background() - } +func readConfigFile(ctx context.Context, cmd *cobra.Command) (cfg *config.Config, err error) { log := zerolog.Ctx(ctx) cfgFile, configRequired := pickConfigFile(cmd) - f, err := fs.Open(cfgFile) + // we only support loading configs from the local filesystem for now + fsys, err := datafs.FSysForPath(ctx, cfgFile) + if err != nil { + return nil, err + } + + f, err := fsys.Open(cfgFile) if err != nil { if configRequired { return cfg, fmt.Errorf("config file requested, but couldn't be opened: %w", err) diff --git a/internal/cmd/config_test.go b/internal/cmd/config_test.go index 5203952e..f4c032c8 100644 --- a/internal/cmd/config_test.go +++ b/internal/cmd/config_test.go @@ -4,66 +4,72 @@ import ( "bytes" "context" "fmt" + "io/fs" + "net/url" "os" "testing" + "testing/fstest" "time" + "github.com/hairyhenderson/go-fsimpl" "github.com/hairyhenderson/gomplate/v4/internal/config" + "github.com/hairyhenderson/gomplate/v4/internal/datafs" - "github.com/spf13/afero" "github.com/spf13/cobra" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestReadConfigFile(t *testing.T) { - fs = afero.NewMemMapFs() - defer func() { fs = afero.NewOsFs() }() + ctx := context.Background() + fsys := fstest.MapFS{} + ctx = datafs.ContextWithFSProvider(ctx, fsimpl.FSProviderFunc(func(_ *url.URL) (fs.FS, error) { + return fsys, nil + })) cmd := &cobra.Command{} - _, err := readConfigFile(cmd) + _, err := readConfigFile(ctx, cmd) require.NoError(t, err) cmd.Flags().String("config", defaultConfigFile, "foo") - _, err = readConfigFile(cmd) + _, err = readConfigFile(ctx, cmd) require.NoError(t, err) cmd.ParseFlags([]string{"--config", "config.file"}) - _, err = readConfigFile(cmd) + _, err = readConfigFile(ctx, cmd) assert.Error(t, err) cmd = &cobra.Command{} cmd.Flags().String("config", defaultConfigFile, "foo") - f, err := fs.Create(defaultConfigFile) - require.NoError(t, err) - f.WriteString("") + fsys[defaultConfigFile] = &fstest.MapFile{} - cfg, err := readConfigFile(cmd) + cfg, err := readConfigFile(ctx, cmd) require.NoError(t, err) assert.EqualValues(t, &config.Config{}, cfg) cmd.ParseFlags([]string{"--config", "config.yaml"}) - f, err = fs.Create("config.yaml") - require.NoError(t, err) - f.WriteString("in: hello world\n") + fsys["config.yaml"] = &fstest.MapFile{Data: []byte("in: hello world\n")} - cfg, err = readConfigFile(cmd) + cfg, err = readConfigFile(ctx, cmd) require.NoError(t, err) assert.EqualValues(t, &config.Config{Input: "hello world"}, cfg) - f.WriteString("in: ") + fsys["config.yaml"] = &fstest.MapFile{Data: []byte("in: hello world\nin: \n")} - _, err = readConfigFile(cmd) + _, err = readConfigFile(ctx, cmd) assert.Error(t, err) } func TestLoadConfig(t *testing.T) { - fs = afero.NewMemMapFs() - defer func() { fs = afero.NewOsFs() }() + ctx := context.Background() + fsys := fstest.MapFS{} + ctx = datafs.ContextWithFSProvider(ctx, fsimpl.FSProviderFunc(func(_ *url.URL) (fs.FS, error) { + return fsys, nil + })) stdin, stdout, stderr := &bytes.Buffer{}, &bytes.Buffer{}, &bytes.Buffer{} cmd := &cobra.Command{} @@ -81,7 +87,7 @@ func TestLoadConfig(t *testing.T) { cmd.Flags().Bool("exec-pipe", false, "...") cmd.ParseFlags(nil) - out, err := loadConfig(cmd, cmd.Flags().Args()) + out, err := loadConfig(ctx, cmd, cmd.Flags().Args()) expected := &config.Config{ Stdin: stdin, Stdout: stdout, @@ -91,7 +97,7 @@ func TestLoadConfig(t *testing.T) { assert.EqualValues(t, expected, out) cmd.ParseFlags([]string{"--in", "foo"}) - out, err = loadConfig(cmd, cmd.Flags().Args()) + out, err = loadConfig(ctx, cmd, cmd.Flags().Args()) expected = &config.Config{ Input: "foo", Stdin: stdin, @@ -102,7 +108,7 @@ func TestLoadConfig(t *testing.T) { assert.EqualValues(t, expected, out) cmd.ParseFlags([]string{"--in", "foo", "--exec-pipe", "--", "tr", "[a-z]", "[A-Z]"}) - out, err = loadConfig(cmd, cmd.Flags().Args()) + out, err = loadConfig(ctx, cmd, cmd.Flags().Args()) expected = &config.Config{ Input: "foo", ExecPipe: true, diff --git a/internal/cmd/main.go b/internal/cmd/main.go index 1a1d9b3a..e09ee863 100644 --- a/internal/cmd/main.go +++ b/internal/cmd/main.go @@ -7,9 +7,10 @@ import ( "os/exec" "os/signal" - "github.com/hairyhenderson/go-fsimpl/filefs" + "github.com/hairyhenderson/go-fsimpl" "github.com/hairyhenderson/gomplate/v4" "github.com/hairyhenderson/gomplate/v4/env" + "github.com/hairyhenderson/gomplate/v4/internal/datafs" "github.com/hairyhenderson/gomplate/v4/version" "github.com/rs/zerolog" @@ -82,7 +83,7 @@ func NewGomplateCmd() *cobra.Command { ctx := cmd.Context() log := zerolog.Ctx(ctx) - cfg, err := loadConfig(cmd, args) + cfg, err := loadConfig(ctx, cmd, args) if err != nil { return err } @@ -168,10 +169,12 @@ func Main(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io ctx = initLogger(ctx, stderr) // inject a default filesystem provider for file:// URLs - // TODO: expand this to support other schemes! - if gomplate.FSProviderFromContext(ctx) == nil { - // allow this to be overridden by tests - ctx = gomplate.ContextWithFSProvider(ctx, filefs.FS) + if datafs.FSProviderFromContext(ctx) == nil { + // TODO: expand this to support other schemes! + mux := fsimpl.NewMux() + mux.Add(datafs.WdFS) + + ctx = datafs.ContextWithFSProvider(ctx, mux) } command := NewGomplateCmd() diff --git a/internal/config/configfile.go b/internal/config/configfile.go index e9b17671..763520de 100644 --- a/internal/config/configfile.go +++ b/internal/config/configfile.go @@ -9,11 +9,11 @@ import ( "net/url" "os" "path" - "path/filepath" "strconv" "strings" "time" + "github.com/hairyhenderson/gomplate/v4/internal/datafs" "github.com/hairyhenderson/gomplate/v4/internal/iohelpers" "github.com/hairyhenderson/yaml" ) @@ -69,14 +69,14 @@ type Config struct { Experimental bool `yaml:"experimental,omitempty"` } -var experimentalCtxKey = struct{}{} +type experimentalCtxKey struct{} func SetExperimental(ctx context.Context) context.Context { - return context.WithValue(ctx, experimentalCtxKey, true) + return context.WithValue(ctx, experimentalCtxKey{}, true) } func ExperimentalEnabled(ctx context.Context) bool { - v, ok := ctx.Value(experimentalCtxKey).(bool) + v, ok := ctx.Value(experimentalCtxKey{}).(bool) return ok && v } @@ -111,7 +111,7 @@ func (d *DataSource) UnmarshalYAML(value *yaml.Node) error { if err != nil { return err } - u, err := ParseSourceURL(r.URL) + u, err := datafs.ParseSourceURL(r.URL) if err != nil { return fmt.Errorf("could not parse datasource URL %q: %w", r.URL, err) } @@ -374,7 +374,7 @@ func parseDatasourceArg(value string) (alias string, ds DataSource, err error) { } } - ds.URL, err = ParseSourceURL(u) + ds.URL, err = datafs.ParseSourceURL(u) return alias, ds, err } @@ -583,64 +583,3 @@ func (c *Config) String() string { } return out.String() } - -// ParseSourceURL parses a datasource URL value, which may be '-' (for stdin://), -// or it may be a Windows path (with driver letter and back-slack separators) or -// UNC, or it may be relative. It also might just be a regular absolute URL... -// In all cases it returns a correct URL for the value. -func ParseSourceURL(value string) (*url.URL, error) { - if value == "-" { - value = "stdin://" - } - value = filepath.ToSlash(value) - // handle absolute Windows paths - volName := "" - if volName = filepath.VolumeName(value); volName != "" { - // handle UNCs - if len(volName) > 2 { - value = "file:" + value - } else { - value = "file:///" + value - } - } - srcURL, err := url.Parse(value) - if err != nil { - return nil, err - } - - if volName != "" && len(srcURL.Path) >= 3 { - if srcURL.Path[0] == '/' && srcURL.Path[2] == ':' { - srcURL.Path = srcURL.Path[1:] - } - } - - if !srcURL.IsAbs() { - srcURL, err = absFileURL(value) - if err != nil { - return nil, err - } - } - return srcURL, nil -} - -func absFileURL(value string) (*url.URL, error) { - wd, err := os.Getwd() - if err != nil { - return nil, fmt.Errorf("can't get working directory: %w", err) - } - wd = filepath.ToSlash(wd) - baseURL := &url.URL{ - Scheme: "file", - Path: wd + "/", - } - relURL, err := url.Parse(value) - if err != nil { - return nil, fmt.Errorf("can't parse value %s as URL: %w", value, err) - } - resolved := baseURL.ResolveReference(relURL) - // deal with Windows drive letters - if !strings.HasPrefix(wd, "/") && resolved.Path[2] == ':' { - resolved.Path = resolved.Path[1:] - } - return resolved, nil -} diff --git a/internal/config/configfile_test.go b/internal/config/configfile_test.go index 46aaf89c..8fe17693 100644 --- a/internal/config/configfile_test.go +++ b/internal/config/configfile_test.go @@ -3,9 +3,6 @@ package config import ( "net/http" "net/url" - "os" - "path" - "path/filepath" "runtime" "strings" "testing" @@ -91,13 +88,7 @@ func mustURL(s string) *url.URL { if err != nil { panic(err) } - // handle the case where it's a relative URL - just like in parseSourceURL. - if !u.IsAbs() { - u, err = absFileURL(s) - if err != nil { - panic(err) - } - } + return u } @@ -232,6 +223,7 @@ func TestMergeFrom(t *testing.T) { "bar": {URL: mustURL("stdin:///")}, }, } + expected := &Config{ Input: "hello world", OutputFiles: []string{"out.txt"}, @@ -534,30 +526,33 @@ func TestParsePluginFlags(t *testing.T) { assert.EqualValues(t, &Config{Plugins: map[string]PluginConfig{"foo": {Cmd: "bar"}}}, cfg) } -func TestConfigString(t *testing.T) { - c := &Config{} - c.ApplyDefaults() +func TestConfig_String(t *testing.T) { + t.Run("defaults", func(t *testing.T) { + c := &Config{} + c.ApplyDefaults() - expected := `--- + expected := `--- inputFiles: ['-'] outputFiles: ['-'] leftDelim: '{{' rightDelim: '}}' pluginTimeout: 5s ` - assert.Equal(t, expected, c.String()) - - c = &Config{ - LDelim: "L", - RDelim: "R", - Input: "foo", - OutputFiles: []string{"-"}, - Templates: Templates{ - "foo": {URL: mustURL("https://www.example.com/foo.tmpl")}, - "bar": {URL: mustURL("/tmp/bar.t")}, - }, - } - expected = `--- + assert.Equal(t, expected, c.String()) + }) + + t.Run("overridden values", func(t *testing.T) { + c := &Config{ + LDelim: "L", + RDelim: "R", + Input: "foo", + OutputFiles: []string{"-"}, + Templates: Templates{ + "foo": {URL: mustURL("https://www.example.com/foo.tmpl")}, + "bar": {URL: mustURL("file:///tmp/bar.t")}, + }, + } + expected := `--- in: foo outputFiles: ['-'] leftDelim: L @@ -568,19 +563,21 @@ templates: bar: url: file:///tmp/bar.t ` - assert.YAMLEq(t, expected, c.String()) - - c = &Config{ - LDelim: "L", - RDelim: "R", - Input: "long input that should be truncated", - OutputFiles: []string{"-"}, - Templates: Templates{ - "foo": {URL: mustURL("https://www.example.com/foo.tmpl")}, - "bar": {URL: mustURL("/tmp/bar.t")}, - }, - } - expected = `--- + assert.YAMLEq(t, expected, c.String()) + }) + + t.Run("long input", func(t *testing.T) { + c := &Config{ + LDelim: "L", + RDelim: "R", + Input: "long input that should be truncated", + OutputFiles: []string{"-"}, + Templates: Templates{ + "foo": {URL: mustURL("https://www.example.com/foo.tmpl")}, + "bar": {URL: mustURL("file:///tmp/bar.t")}, + }, + } + expected := `--- in: long inp... outputFiles: ['-'] leftDelim: L @@ -591,49 +588,56 @@ templates: bar: url: file:///tmp/bar.t ` - assert.YAMLEq(t, expected, c.String()) + assert.YAMLEq(t, expected, c.String()) + }) - c = &Config{ - InputDir: "in/", - OutputDir: "out/", - } - expected = `--- + t.Run("relative dirs", func(t *testing.T) { + c := &Config{ + InputDir: "in/", + OutputDir: "out/", + } + expected := `--- inputDir: in/ outputDir: out/ ` + assert.YAMLEq(t, expected, c.String()) + }) - assert.Equal(t, expected, c.String()) - - c = &Config{ - InputDir: "in/", - OutputMap: "{{ .in }}", - } - expected = `--- + t.Run("outputmap", func(t *testing.T) { + c := &Config{ + InputDir: "in/", + OutputMap: "{{ .in }}", + } + expected := `--- inputDir: in/ outputMap: '{{ .in }}' ` - assert.Equal(t, expected, c.String()) + assert.YAMLEq(t, expected, c.String()) + }) - c = &Config{ - PluginTimeout: 500 * time.Millisecond, - } - expected = `--- + t.Run("pluginTimeout", func(t *testing.T) { + c := &Config{ + PluginTimeout: 500 * time.Millisecond, + } + expected := `--- pluginTimeout: 500ms ` - assert.Equal(t, expected, c.String()) + assert.YAMLEq(t, expected, c.String()) + }) - c = &Config{ - Plugins: map[string]PluginConfig{ - "foo": { - Cmd: "bar", - Timeout: 1 * time.Second, - Pipe: true, + t.Run("plugins", func(t *testing.T) { + c := &Config{ + Plugins: map[string]PluginConfig{ + "foo": { + Cmd: "bar", + Timeout: 1 * time.Second, + Pipe: true, + }, }, - }, - } - expected = `--- + } + expected := `--- plugins: foo: cmd: bar @@ -641,7 +645,8 @@ plugins: pipe: true ` - assert.Equal(t, expected, c.String()) + assert.YAMLEq(t, expected, c.String()) + }) } func TestApplyDefaults(t *testing.T) { @@ -772,71 +777,11 @@ func TestParseHeaderArgs(t *testing.T) { assert.Equal(t, expected, parsed) } -func TestParseSourceURL(t *testing.T) { - expected := &url.URL{ - Scheme: "http", - Host: "example.com", - Path: "/foo.json", - RawQuery: "bar", - } - u, err := ParseSourceURL("http://example.com/foo.json?bar") - require.NoError(t, err) - assert.EqualValues(t, expected, u) - - expected = &url.URL{Scheme: "stdin"} - u, err = ParseSourceURL("-") - require.NoError(t, err) - assert.EqualValues(t, expected, u) - - wd, err := os.Getwd() - require.NoError(t, err) - expected = &url.URL{ - Scheme: "file", - Path: path.Join(filepath.ToSlash(wd), "foo/bar.json"), - } - u, err = ParseSourceURL("./foo/bar.json") - require.NoError(t, err) - assert.EqualValues(t, expected, u) -} - -func TestAbsFileURL(t *testing.T) { - cwd, _ := os.Getwd() - // make this pass on Windows - cwd = filepath.ToSlash(cwd) - expected := &url.URL{ - Scheme: "file", - Host: "", - Path: "/tmp/foo", - } - u, err := absFileURL("/tmp/foo") - require.NoError(t, err) - assert.EqualValues(t, expected, u) - - expected = &url.URL{ - Scheme: "file", - Host: "", - Path: cwd + "/tmp/foo", - } - u, err = absFileURL("tmp/foo") - require.NoError(t, err) - assert.EqualValues(t, expected, u) - - expected = &url.URL{ - Scheme: "file", - Host: "", - Path: cwd + "/tmp/foo", - RawQuery: "q=p", - } - u, err = absFileURL("tmp/foo?q=p") - require.NoError(t, err) - assert.EqualValues(t, expected, u) -} - func TestParseDatasourceArgNoAlias(t *testing.T) { alias, ds, err := parseDatasourceArg("foo.json") require.NoError(t, err) assert.Equal(t, "foo", alias) - assert.Equal(t, "file", ds.URL.Scheme) + assert.Empty(t, ds.URL.Scheme) _, _, err = parseDatasourceArg("../foo.json") assert.Error(t, err) @@ -849,8 +794,7 @@ func TestParseDatasourceArgWithAlias(t *testing.T) { alias, ds, err := parseDatasourceArg("data=foo.json") require.NoError(t, err) assert.Equal(t, "data", alias) - assert.Equal(t, "file", ds.URL.Scheme) - assert.True(t, ds.URL.IsAbs()) + assert.EqualValues(t, &url.URL{Path: "foo.json"}, ds.URL) alias, ds, err = parseDatasourceArg("data=/otherdir/foo.json") require.NoError(t, err) @@ -863,45 +807,33 @@ func TestParseDatasourceArgWithAlias(t *testing.T) { alias, ds, err = parseDatasourceArg("data=foo.json") require.NoError(t, err) assert.Equal(t, "data", alias) - assert.Equal(t, "file", ds.URL.Scheme) - assert.True(t, ds.URL.IsAbs()) - assert.Equalf(t, byte(':'), ds.URL.Path[1], "Path was %s", ds.URL.Path) + assert.EqualValues(t, &url.URL{Path: "foo.json"}, ds.URL) alias, ds, err = parseDatasourceArg(`data=\otherdir\foo.json`) require.NoError(t, err) assert.Equal(t, "data", alias) - assert.Equal(t, "file", ds.URL.Scheme) - assert.True(t, ds.URL.IsAbs()) - assert.Equal(t, `/otherdir/foo.json`, ds.URL.Path) + assert.EqualValues(t, &url.URL{Scheme: "file", Path: "/otherdir/foo.json"}, ds.URL) alias, ds, err = parseDatasourceArg("data=C:\\windowsdir\\foo.json") require.NoError(t, err) assert.Equal(t, "data", alias) - assert.Equal(t, "file", ds.URL.Scheme) - assert.True(t, ds.URL.IsAbs()) - assert.Equal(t, "C:/windowsdir/foo.json", ds.URL.Path) + assert.EqualValues(t, &url.URL{Scheme: "file", Path: "C:/windowsdir/foo.json"}, ds.URL) alias, ds, err = parseDatasourceArg("data=\\\\somehost\\share\\foo.json") require.NoError(t, err) assert.Equal(t, "data", alias) - assert.Equal(t, "file", ds.URL.Scheme) - assert.Equal(t, "somehost", ds.URL.Host) - assert.True(t, ds.URL.IsAbs()) - assert.Equal(t, "/share/foo.json", ds.URL.Path) + assert.EqualValues(t, &url.URL{Scheme: "file", Host: "somehost", Path: "/share/foo.json"}, ds.URL) } alias, ds, err = parseDatasourceArg("data=sftp://example.com/blahblah/foo.json") require.NoError(t, err) assert.Equal(t, "data", alias) - assert.Equal(t, "sftp", ds.URL.Scheme) - assert.True(t, ds.URL.IsAbs()) - assert.Equal(t, "/blahblah/foo.json", ds.URL.Path) + assert.EqualValues(t, &url.URL{Scheme: "sftp", Host: "example.com", Path: "/blahblah/foo.json"}, ds.URL) alias, ds, err = parseDatasourceArg("merged=merge:./foo.yaml|http://example.com/bar.json%3Ffoo=bar") require.NoError(t, err) assert.Equal(t, "merged", alias) - assert.Equal(t, "merge", ds.URL.Scheme) - assert.Equal(t, "./foo.yaml|http://example.com/bar.json%3Ffoo=bar", ds.URL.Opaque) + assert.EqualValues(t, &url.URL{Scheme: "merge", Opaque: "./foo.yaml|http://example.com/bar.json%3Ffoo=bar"}, ds.URL) } func TestPluginConfig_UnmarshalYAML(t *testing.T) { diff --git a/internal/config/types.go b/internal/config/types.go index d058d5a5..57901f0d 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -5,6 +5,7 @@ import ( "net/http" "strings" + "github.com/hairyhenderson/gomplate/v4/internal/datafs" "github.com/hairyhenderson/yaml" ) @@ -52,7 +53,7 @@ func (t *Templates) unmarshalYAMLArray(value *yaml.Node) error { pth = alias } - u, err := ParseSourceURL(pth) + u, err := datafs.ParseSourceURL(pth) if err != nil { return fmt.Errorf("could not parse template URL %q: %w", pth, err) } @@ -89,7 +90,7 @@ func parseTemplateArg(value string) (alias string, ds DataSource, err error) { u = alias } - ds.URL, err = ParseSourceURL(u) + ds.URL, err = datafs.ParseSourceURL(u) return alias, ds, err } diff --git a/internal/datafs/fsys.go b/internal/datafs/fsys.go new file mode 100644 index 00000000..ac4c22c5 --- /dev/null +++ b/internal/datafs/fsys.go @@ -0,0 +1,111 @@ +package datafs + +import ( + "context" + "fmt" + "io/fs" + "net/url" + "path" + "path/filepath" + + "github.com/hairyhenderson/go-fsimpl" +) + +type fsProviderCtxKey struct{} + +// ContextWithFSProvider returns a context with the given FSProvider. Should +// only be used in tests. +func ContextWithFSProvider(ctx context.Context, fsp fsimpl.FSProvider) context.Context { + return context.WithValue(ctx, fsProviderCtxKey{}, fsp) +} + +// FSProviderFromContext returns the FSProvider from the context, if any +func FSProviderFromContext(ctx context.Context) fsimpl.FSProvider { + if fsp, ok := ctx.Value(fsProviderCtxKey{}).(fsimpl.FSProvider); ok { + return fsp + } + + return nil +} + +// ParseSourceURL parses a datasource URL value, which may be '-' (for stdin://), +// or it may be a Windows path (with driver letter and back-slash separators) or +// UNC, or it may be relative. It also might just be a regular absolute URL... +// In all cases it returns a correct URL for the value. It may be a relative URL +// in which case the scheme should be assumed to be 'file' +func ParseSourceURL(value string) (*url.URL, error) { + if value == "-" { + value = "stdin://" + } + value = filepath.ToSlash(value) + // handle absolute Windows paths + volName := "" + if volName = filepath.VolumeName(value); volName != "" { + // handle UNCs + if len(volName) > 2 { + value = "file:" + value + } else { + value = "file:///" + value + } + } + srcURL, err := url.Parse(value) + if err != nil { + return nil, err + } + + if volName != "" && len(srcURL.Path) >= 3 { + if srcURL.Path[0] == '/' && srcURL.Path[2] == ':' { + srcURL.Path = srcURL.Path[1:] + } + } + + // if it's an absolute path with no scheme, assume it's a file + if srcURL.Scheme == "" && path.IsAbs(srcURL.Path) { + srcURL.Scheme = "file" + } + + return srcURL, nil +} + +// FSysForPath returns an [io/fs.FS] for the given path (which may be an URL), +// rooted at /. A [fsimpl.FSProvider] is required to be present in ctx, +// otherwise an error is returned. +func FSysForPath(ctx context.Context, path string) (fs.FS, error) { + u, err := ParseSourceURL(path) + if err != nil { + return nil, err + } + + fsp := FSProviderFromContext(ctx) + if fsp == nil { + return nil, fmt.Errorf("no filesystem provider in context") + } + + fsys, err := fsp.New(&url.URL{Scheme: u.Scheme, Path: "/"}) + if err != nil { + return nil, fmt.Errorf("filesystem provider for %q unavailable: %w", path, err) + } + + return fsys, nil +} + +type fsp struct { + newFunc func(*url.URL) (fs.FS, error) + schemes []string +} + +func (p fsp) Schemes() []string { + return p.schemes +} + +func (p fsp) New(u *url.URL) (fs.FS, error) { + return p.newFunc(u) +} + +// WrappedFSProvider is an FSProvider that returns the given fs.FS +func WrappedFSProvider(fsys fs.FS, schemes ...string) fsimpl.FSProvider { + return fsp{ + newFunc: func(u *url.URL) (fs.FS, error) { return fsys, nil }, + schemes: schemes, + } +} diff --git a/internal/datafs/fsys_test.go b/internal/datafs/fsys_test.go new file mode 100644 index 00000000..80ff5ca1 --- /dev/null +++ b/internal/datafs/fsys_test.go @@ -0,0 +1,42 @@ +package datafs + +import ( + "net/url" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseSourceURL(t *testing.T) { + expected := &url.URL{ + Scheme: "http", + Host: "example.com", + Path: "/foo.json", + RawQuery: "bar", + } + u, err := ParseSourceURL("http://example.com/foo.json?bar") + require.NoError(t, err) + assert.EqualValues(t, expected, u) + + expected = &url.URL{Scheme: "", Path: ""} + u, err = ParseSourceURL("") + require.NoError(t, err) + assert.EqualValues(t, expected, u) + + expected = &url.URL{Scheme: "stdin"} + u, err = ParseSourceURL("-") + require.NoError(t, err) + assert.EqualValues(t, expected, u) + + // behviour change in v4 - return relative if it's relative + expected = &url.URL{Path: "./foo/bar.json"} + u, err = ParseSourceURL("./foo/bar.json") + require.NoError(t, err) + assert.EqualValues(t, expected, u) + + expected = &url.URL{Scheme: "file", Path: "/absolute/bar.json"} + u, err = ParseSourceURL("/absolute/bar.json") + require.NoError(t, err) + assert.EqualValues(t, expected, u) +} diff --git a/internal/datafs/wdfs.go b/internal/datafs/wdfs.go new file mode 100644 index 00000000..79c2b0ef --- /dev/null +++ b/internal/datafs/wdfs.go @@ -0,0 +1,275 @@ +package datafs + +import ( + "fmt" + "io/fs" + "net/url" + "os" + "path/filepath" + + "github.com/hack-pad/hackpadfs" + osfs "github.com/hack-pad/hackpadfs/os" + "github.com/hairyhenderson/go-fsimpl" +) + +// ResolveLocalPath resolves a path on the local filesystem, relative to the +// current working directory, and returns both the root (/ or a volume name on +// Windows) and the resolved path. If the path is absolute (e.g. starts with a `/` or +// volume name on Windows), it is split and returned as-is. +// The output is suitable for use with [io/fs] functions. +// +// TODO: maybe take fsys as an argument, and if it's a wdFS, use its vol instead +// of calling os.Getwd? +func ResolveLocalPath(name string) (root, resolved string) { + // ignore empty names + if len(name) == 0 { + return "", "" + } + + wd, err := os.Getwd() + if err != nil { + panic(err) + } + + vol := filepath.VolumeName(wd) + if vol == "" { + vol = "/" + } + + f := &wdFS{vol: vol} + return f.resolveLocalPath(name) +} + +func (w *wdFS) resolveLocalPath(name string) (root, resolved string) { + // ignore empty names + if len(name) == 0 { + return "", "" + } + + // we want to assume a / separator regardless of the OS + name = filepath.ToSlash(name) + + // special-case for (Windows) paths that start with '/' but have no volume + // name (e.g. "/foo/bar"). UNC paths (beginning with "//") are ignored. + if name[0] == '/' && (len(name) == 1 || name[1] != '/') { + name = filepath.Join(w.vol, name) + } else if name[0] != '/' && !filepath.IsAbs(name) { + wd, err := os.Getwd() + if err != nil { + panic(err) + } + name = filepath.Join(wd, name) + } + + name = filepath.ToSlash(name) + + vol := filepath.VolumeName(name) + if vol != "" && name != vol { + root = vol + name = name[len(vol)+1:] + } else if name[0] == '/' { + root = "/" + name = name[1:] + } + + // there may still be backslashes in the root + root = filepath.ToSlash(root) + + // we might've emptied name, so return "." instead + if name == "" { + name = "." + } + + return root, name +} + +// WdFS is a filesystem provider that creates local filesystems which support +// absolute paths beginning with '/', and interpret relative paths as relative +// to the current working directory (as reported by [os.Getwd]) +var WdFS = fsimpl.FSProviderFunc( + func(u *url.URL) (fs.FS, error) { + vol, _ := ResolveLocalPath(u.Path) + + var fsys fs.FS + if vol == "" || vol == "/" { + fsys = osfs.NewFS() + } else { + var err error + fsys, err = osfs.NewFS().SubVolume(vol) + if err != nil { + return nil, err + } + } + + return &wdFS{vol: vol, fsys: fsys}, nil + }, + // register for both file and empty scheme (empty when path is relative) + "file", "", +) + +// WrapWdFS is a filesystem wrapper that assumes non-absolute paths are relative +// to the current working directory (as reported by [os.Getwd]). It only works +// in a meaningful way when used with a local filesystem (e.g. [os.DirFS] or +// [hackpadfs/os.FS]). +func WrapWdFS(fsys fs.FS) fs.FS { + // if fsys is a wdFS, just return it + if _, ok := fsys.(*wdFS); ok { + return fsys + } + + return &wdFS{fsys: fsys} +} + +// wdFS is a filesystem wrapper that assumes non-absolute paths are relative to +// the current working directory (as reported by [os.Getwd]). +// It only works in a meaningful way when used with a local filesystem (e.g. +// [os.DirFS] or [hackpadfs/os.FS]). +type wdFS struct { + fsys fs.FS + vol string +} + +var ( + _ fs.FS = &wdFS{} + _ fs.StatFS = &wdFS{} + _ fs.ReadFileFS = &wdFS{} + _ fs.ReadDirFS = &wdFS{} + _ fs.SubFS = &wdFS{} + _ fs.GlobFS = &wdFS{} + _ hackpadfs.CreateFS = &wdFS{} + _ hackpadfs.OpenFileFS = &wdFS{} + _ hackpadfs.MkdirFS = &wdFS{} + _ hackpadfs.MkdirAllFS = &wdFS{} + _ hackpadfs.RemoveFS = &wdFS{} + _ hackpadfs.ChmodFS = &wdFS{} +) + +func (w *wdFS) fsysFor(vol string) (fs.FS, error) { + if vol == "" || vol == "/" || vol == w.vol { + return w.fsys, nil + } + + // create a new osfs.FS here, because we can't modify the original if + // SubVolume was already called on it. + if _, ok := w.fsys.(*osfs.FS); ok { + fsys, err := osfs.NewFS().SubVolume(vol) + if err != nil { + return nil, fmt.Errorf("fsysFor %q: %w", vol, err) + } + + return fsys, nil + } + + // just return the original filesystem if we're not wrapping an osfs.FS + return w.fsys, nil +} + +func (w *wdFS) Open(name string) (fs.File, error) { + root, resolved := w.resolveLocalPath(name) + fsys, err := w.fsysFor(root) + if err != nil { + return nil, err + } + return fsys.Open(resolved) +} + +func (w *wdFS) Stat(name string) (fs.FileInfo, error) { + root, resolved := w.resolveLocalPath(name) + fsys, err := w.fsysFor(root) + if err != nil { + return nil, err + } + return fs.Stat(fsys, resolved) +} + +func (w *wdFS) ReadFile(name string) ([]byte, error) { + root, resolved := w.resolveLocalPath(name) + fsys, err := w.fsysFor(root) + if err != nil { + return nil, err + } + return fs.ReadFile(fsys, resolved) +} + +func (w *wdFS) ReadDir(name string) ([]fs.DirEntry, error) { + root, resolved := w.resolveLocalPath(name) + fsys, err := w.fsysFor(root) + if err != nil { + return nil, err + } + return fs.ReadDir(fsys, resolved) +} + +func (w *wdFS) Sub(name string) (fs.FS, error) { + // we don't resolve the name here, because this name must necessarily be + // a path relative to the wrapped filesystem's root + if fsys, ok := w.fsys.(fs.SubFS); ok { + return fsys.Sub(name) + } + + return fs.Sub(w.fsys, name) +} + +func (w *wdFS) Glob(_ string) ([]string, error) { + // I'm not sure how to handle this, so I'm just going to error for now - + // I have no need of Glob anyway. + return nil, fmt.Errorf("glob not supported by wdFS: %w", fs.ErrInvalid) +} + +func (w *wdFS) Create(name string) (fs.File, error) { + root, resolved := w.resolveLocalPath(name) + fsys, err := w.fsysFor(root) + if err != nil { + return nil, err + } + return hackpadfs.Create(fsys, resolved) +} + +func (w *wdFS) OpenFile(name string, flag int, perm fs.FileMode) (fs.File, error) { + root, resolved := w.resolveLocalPath(name) + fsys, err := w.fsysFor(root) + if err != nil { + return nil, err + } + return hackpadfs.OpenFile(fsys, resolved, flag, perm) +} + +func (w *wdFS) Mkdir(name string, perm fs.FileMode) error { + root, resolved := w.resolveLocalPath(name) + fsys, err := w.fsysFor(root) + if err != nil { + return err + } + err = hackpadfs.Mkdir(fsys, resolved, perm) + if err != nil { + return fmt.Errorf("mkdir %q (resolved as %q): %w", name, resolved, err) + } + return nil +} + +func (w *wdFS) MkdirAll(name string, perm fs.FileMode) error { + root, resolved := w.resolveLocalPath(name) + fsys, err := w.fsysFor(root) + if err != nil { + return err + } + return hackpadfs.MkdirAll(fsys, resolved, perm) +} + +func (w *wdFS) Remove(name string) error { + root, resolved := w.resolveLocalPath(name) + fsys, err := w.fsysFor(root) + if err != nil { + return err + } + return hackpadfs.Remove(fsys, resolved) +} + +func (w *wdFS) Chmod(name string, mode fs.FileMode) error { + root, resolved := w.resolveLocalPath(name) + fsys, err := w.fsysFor(root) + if err != nil { + return err + } + return hackpadfs.Chmod(fsys, resolved, mode) +} diff --git a/internal/datafs/wdfs_test.go b/internal/datafs/wdfs_test.go new file mode 100644 index 00000000..610920af --- /dev/null +++ b/internal/datafs/wdfs_test.go @@ -0,0 +1,273 @@ +package datafs + +import ( + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/hack-pad/hackpadfs" + "github.com/hack-pad/hackpadfs/mem" + osfs "github.com/hack-pad/hackpadfs/os" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + tfs "gotest.tools/v3/fs" +) + +func TestWDFS_ReadOps(t *testing.T) { + wd, _ := os.Getwd() + t.Cleanup(func() { + _ = os.Chdir(wd) + }) + _ = os.Chdir("/") + + memfs, _ := mem.NewFS() + + _ = memfs.Mkdir("tmp", 0o777) + _ = hackpadfs.WriteFullFile(memfs, "tmp/foo", []byte("hello world"), 0o777) + _ = hackpadfs.WriteFullFile(memfs, "tmp/one.txt", []byte("one"), 0o644) + _ = hackpadfs.WriteFullFile(memfs, "tmp/two.txt", []byte("two"), 0o644) + _ = hackpadfs.WriteFullFile(memfs, "tmp/three.txt", []byte("three"), 0o644) + _ = memfs.Mkdir("tmp/sub", 0o777) + _ = hackpadfs.WriteFullFile(memfs, "tmp/sub/bar", []byte("goodnight moon"), 0o777) + + fsys := WrapWdFS(memfs).(*wdFS) + + f, err := fsys.Open("/tmp/foo") + require.NoError(t, err) + + b, err := io.ReadAll(f) + require.NoError(t, err) + assert.Equal(t, "hello world", string(b)) + + fi, err := fs.Stat(fsys, "/tmp/sub/bar") + require.NoError(t, err) + assert.True(t, fi.Mode().IsRegular()) + + b, err = fs.ReadFile(fsys, "/tmp/sub/bar") + require.NoError(t, err) + assert.Equal(t, "goodnight moon", string(b)) + + des, err := fs.ReadDir(fsys, "/tmp") + require.NoError(t, err) + assert.Len(t, des, 5) + + // note the relative path here, a requirement of fsys.Sub + subfs, err := fs.Sub(fsys, "tmp/sub") + require.NoError(t, err) + + b, err = fs.ReadFile(subfs, "bar") + require.NoError(t, err) + assert.Equal(t, "goodnight moon", string(b)) +} + +func TestWDFS_WriteOps(t *testing.T) { + // this test is backed by the real filesystem so we can test permissions + // and have some confidence it'll run on Windows + tmpDir := tfs.NewDir(t, "gomplate-wdfs-test") + tmpPath := tmpDir.Path() + vol := filepath.VolumeName(tmpPath) + if vol != "" { + tmpPath = tmpPath[len(vol):] + } else if tmpPath[0] == '/' { + vol = "/" + tmpPath = tmpPath[1:] + } + + var osfsys fs.FS + var err error + if vol != "/" { + osfsys, err = osfs.NewFS().SubVolume(vol) + require.NoError(t, err) + } else { + osfsys = osfs.NewFS() + } + + osfsys, err = hackpadfs.Sub(osfsys, tmpPath) + require.NoError(t, err) + + fsys := &wdFS{ + vol: vol, + fsys: osfsys, + } + + err = fsys.Mkdir("/tmp", 0o700) + require.NoError(t, err, "failed to create /tmp: %q", tmpDir.Path()) + + // use os.Stat to make sure the directory was created in the right place + fi, err := os.Stat(filepath.Join(vol, tmpPath, "tmp")) + require.NoError(t, err) + assert.True(t, fi.Mode().IsDir()) + + err = hackpadfs.WriteFullFile(fsys, "/tmp/foo", []byte("hello world"), 0o600) + require.NoError(t, err) + err = hackpadfs.WriteFullFile(fsys, "/tmp/one.txt", []byte("one"), 0o644) + require.NoError(t, err) + err = hackpadfs.WriteFullFile(fsys, "/tmp/two.txt", []byte("two"), 0o644) + require.NoError(t, err) + err = hackpadfs.WriteFullFile(fsys, "/tmp/three.txt", []byte("three"), 0o644) + require.NoError(t, err) + + err = fsys.MkdirAll("/tmp/sub", 0o777) + require.NoError(t, err) + err = hackpadfs.WriteFullFile(fsys, "/tmp/sub/bar", []byte("goodnight moon"), 0o777) + require.NoError(t, err) + + b, err := fs.ReadFile(fsys, "/tmp/foo") + require.NoError(t, err) + assert.Equal(t, "hello world", string(b)) + + b, err = fs.ReadFile(fsys, "/tmp/sub/bar") + require.NoError(t, err) + assert.Equal(t, "goodnight moon", string(b)) + + err = fsys.Chmod("/tmp/foo", 0o444) + require.NoError(t, err) + + // check permissions + fi, err = fsys.Stat("/tmp/foo") + require.NoError(t, err) + assert.True(t, fi.Mode().IsRegular()) + assert.Equal(t, "0444", fmt.Sprintf("%#o", fi.Mode().Perm())) + + // now delete it + err = fsys.Remove("/tmp/foo") + require.NoError(t, err) + + // and check that it's gone + _, err = fsys.Stat("/tmp/foo") + assert.ErrorIs(t, err, fs.ErrNotExist) +} + +func skipWindows(t *testing.T) { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("skipping non-Windows test") + } +} + +func skipNonWindows(t *testing.T) { + t.Helper() + if runtime.GOOS != "windows" { + t.Skip("skipping Windows test") + } +} + +func TestResolveLocalPath_NonWindows(t *testing.T) { + skipWindows(t) + + wd, _ := os.Getwd() + wd = wd[1:] + + testdata := []struct { + path string + expected string + }{ + {"/tmp/foo", "tmp/foo"}, + {"tmp/foo", wd + "/tmp/foo"}, + {"./tmp/foo", wd + "/tmp/foo"}, + {"tmp/../foo", wd + "/foo"}, + } + + for _, td := range testdata { + root, path := ResolveLocalPath(td.path) + assert.Equal(t, "/", root) + assert.Equal(t, td.expected, path) + } +} + +func TestResolveLocalPath_Windows(t *testing.T) { + skipNonWindows(t) + + wd, _ := os.Getwd() + volname := filepath.VolumeName(wd) + wd = wd[len(volname)+1:] + wd = filepath.ToSlash(wd) + + testdata := []struct { + path string + expRoot string + expected string + }{ + {"C:/tmp/foo", "C:", "tmp/foo"}, + {"D:\\tmp\\foo", "D:", "tmp/foo"}, + {"/tmp/foo", volname, "tmp/foo"}, + {"tmp/foo", volname, wd + "/tmp/foo"}, + {"./tmp/foo", volname, wd + "/tmp/foo"}, + {"tmp/../foo", volname, wd + "/foo"}, + } + + for _, td := range testdata { + td := td + t.Run(td.path, func(t *testing.T) { + root, path := ResolveLocalPath(td.path) + assert.Equal(t, td.expRoot, root) + assert.Equal(t, td.expected, path) + }) + } +} + +func TestWdFS_ResolveLocalPath_NonWindows(t *testing.T) { + skipWindows(t) + + wd, _ := os.Getwd() + wd = wd[1:] + + testdata := []struct { + path string + expected string + }{ + {"/tmp/foo", "tmp/foo"}, + {"tmp/foo", wd + "/tmp/foo"}, + {"./tmp/foo", wd + "/tmp/foo"}, + {"tmp/../foo", wd + "/foo"}, + } + + fsys := &wdFS{} + + for _, td := range testdata { + root, path := fsys.resolveLocalPath(td.path) + assert.Equal(t, "/", root) + assert.Equal(t, td.expected, path) + } +} + +func TestWdFS_ResolveLocalPath_Windows(t *testing.T) { + skipNonWindows(t) + + wd, _ := os.Getwd() + volname := filepath.VolumeName(wd) + wd = wd[len(volname)+1:] + wd = filepath.ToSlash(wd) + + testdata := []struct { + path string + expRoot string + expected string + }{ + {"C:/tmp/foo", "C:", "tmp/foo"}, + {"D:\\tmp\\foo", "D:", "tmp/foo"}, + {"/tmp/foo", volname, "tmp/foo"}, + {"tmp/foo", volname, wd + "/tmp/foo"}, + {"./tmp/foo", volname, wd + "/tmp/foo"}, + {"tmp/../foo", volname, wd + "/foo"}, + {`\\?\C:\tmp\foo`, "//?/C:", "tmp/foo"}, + {`\\somehost\share\foo\bar`, "//somehost/share", "foo/bar"}, + {`//?/C:/tmp/foo`, "//?/C:", "tmp/foo"}, + {`//somehost/share/foo/bar`, "//somehost/share", "foo/bar"}, + } + + fsys := &wdFS{vol: volname} + + for _, td := range testdata { + td := td + t.Run(td.path, func(t *testing.T) { + root, path := fsys.resolveLocalPath(td.path) + assert.Equal(t, td.expRoot, root) + assert.Equal(t, td.expected, path) + }) + } +} diff --git a/internal/iohelpers/writers.go b/internal/iohelpers/writers.go index 5030d275..870fa4cb 100644 --- a/internal/iohelpers/writers.go +++ b/internal/iohelpers/writers.go @@ -6,7 +6,13 @@ import ( "errors" "fmt" "io" + "io/fs" + "os" + "path/filepath" + "strings" "sync" + + "github.com/hack-pad/hackpadfs" ) type emptySkipper struct { @@ -212,3 +218,52 @@ func (l *lazyWriteCloser) Write(p []byte) (n int, err error) { } return w.Write(p) } + +// WriteFile writes the given content to the file, truncating any existing file, +// and creating the directory structure leading up to it if necessary. +func WriteFile(fsys fs.FS, filename string, content []byte) error { + err := assertPathInWD(filename) + if err != nil { + return fmt.Errorf("failed to open %s: %w", filename, err) + } + + fi, err := fs.Stat(fsys, filename) + if err != nil && !errors.Is(err, fs.ErrNotExist) { + return fmt.Errorf("failed to stat %s: %w", filename, err) + } + mode := NormalizeFileMode(0o644) + if fi != nil { + mode = fi.Mode() + } + + err = hackpadfs.MkdirAll(fsys, filepath.Dir(filename), 0o755) + if err != nil { + return fmt.Errorf("failed to make dirs for %s: %w", filename, err) + } + + err = hackpadfs.WriteFullFile(fsys, filename, content, mode) + if err != nil { + return fmt.Errorf("failed to write %s: %w", filename, err) + } + + return nil +} + +func assertPathInWD(filename string) error { + wd, err := os.Getwd() + if err != nil { + return err + } + f, err := filepath.Abs(filename) + if err != nil { + return err + } + r, err := filepath.Rel(wd, f) + if err != nil { + return err + } + if strings.HasPrefix(r, "..") { + return fmt.Errorf("path %s not contained by working directory %s (rel: %s)", filename, wd, r) + } + return nil +} diff --git a/internal/iohelpers/writers_test.go b/internal/iohelpers/writers_test.go index f2451737..415634d5 100644 --- a/internal/iohelpers/writers_test.go +++ b/internal/iohelpers/writers_test.go @@ -4,11 +4,17 @@ import ( "bytes" "fmt" "io" + "io/fs" "os" + "path/filepath" "testing" + "github.com/hack-pad/hackpadfs" + osfs "github.com/hack-pad/hackpadfs/os" + "github.com/hairyhenderson/gomplate/v4/internal/datafs" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + tfs "gotest.tools/v3/fs" ) func TestAllWhitespace(t *testing.T) { @@ -159,3 +165,83 @@ func TestLazyWriteCloser(t *testing.T) { err = l.Close() assert.Error(t, err) } + +func TestWrite(t *testing.T) { + oldwd, _ := os.Getwd() + defer os.Chdir(oldwd) + + rootDir := tfs.NewDir(t, "gomplate-test") + t.Cleanup(rootDir.Remove) + + // we want to use a real filesystem here, so we can test interactions with + // the current working directory + fsys := datafs.WrapWdFS(osfs.NewFS()) + + newwd := rootDir.Join("the", "path", "we", "want") + badwd := rootDir.Join("some", "other", "dir") + hackpadfs.MkdirAll(fsys, newwd, 0755) + hackpadfs.MkdirAll(fsys, badwd, 0755) + newwd, _ = filepath.EvalSymlinks(newwd) + badwd, _ = filepath.EvalSymlinks(badwd) + + err := os.Chdir(newwd) + require.NoError(t, err) + + err = WriteFile(fsys, "/foo", []byte("Hello world")) + assert.Error(t, err) + + rel, err := filepath.Rel(newwd, badwd) + require.NoError(t, err) + err = WriteFile(fsys, rel, []byte("Hello world")) + assert.Error(t, err) + + foopath := filepath.Join(newwd, "foo") + err = WriteFile(fsys, foopath, []byte("Hello world")) + require.NoError(t, err) + + out, err := fs.ReadFile(fsys, foopath) + require.NoError(t, err) + assert.Equal(t, "Hello world", string(out)) + + err = WriteFile(fsys, foopath, []byte("truncate")) + require.NoError(t, err) + + out, err = fs.ReadFile(fsys, foopath) + require.NoError(t, err) + assert.Equal(t, "truncate", string(out)) + + foopath = filepath.Join(newwd, "nonexistant", "subdir", "foo") + err = WriteFile(fsys, foopath, []byte("Hello subdirranean world!")) + require.NoError(t, err) + + out, err = fs.ReadFile(fsys, foopath) + require.NoError(t, err) + assert.Equal(t, "Hello subdirranean world!", string(out)) +} + +func TestAssertPathInWD(t *testing.T) { + oldwd, _ := os.Getwd() + defer os.Chdir(oldwd) + + err := assertPathInWD("/tmp") + assert.Error(t, err) + + err = assertPathInWD(filepath.Join(oldwd, "subpath")) + require.NoError(t, err) + + err = assertPathInWD("subpath") + require.NoError(t, err) + + err = assertPathInWD("./subpath") + require.NoError(t, err) + + err = assertPathInWD(filepath.Join("..", "bogus")) + assert.Error(t, err) + + err = assertPathInWD(filepath.Join("..", "..", "bogus")) + assert.Error(t, err) + + base := filepath.Base(oldwd) + err = assertPathInWD(filepath.Join("..", base)) + require.NoError(t, err) +} diff --git a/internal/tests/integration/basic_test.go b/internal/tests/integration/basic_test.go index d3a1d369..e4cb046e 100644 --- a/internal/tests/integration/basic_test.go +++ b/internal/tests/integration/basic_test.go @@ -8,17 +8,19 @@ import ( "github.com/hairyhenderson/gomplate/v4/internal/iohelpers" "gotest.tools/v3/assert" "gotest.tools/v3/assert/cmp" - testfs "gotest.tools/v3/fs" + tfs "gotest.tools/v3/fs" ) -func setupBasicTest(t *testing.T) *testfs.Dir { - tmpDir := testfs.NewDir(t, "gomplate-inttests", - testfs.WithFile("one", "hi\n", testfs.WithMode(0640)), - testfs.WithFile("two", "hello\n"), - testfs.WithFile("broken", "", testfs.WithMode(0000)), - testfs.WithDir("subdir", - testfs.WithFile("f1", "first\n", testfs.WithMode(0640)), - testfs.WithFile("f2", "second\n"), +func setupBasicTest(t *testing.T) *tfs.Dir { + t.Helper() + + tmpDir := tfs.NewDir(t, "gomplate-inttests", + tfs.WithFile("one", "hi\n", tfs.WithMode(0o640)), + tfs.WithFile("two", "hello\n"), + tfs.WithFile("broken", "", tfs.WithMode(0o000)), + tfs.WithDir("subdir", + tfs.WithFile("f1", "first\n", tfs.WithMode(0o640)), + tfs.WithFile("f2", "second\n"), ), ) t.Cleanup(tmpDir.Remove) @@ -180,7 +182,7 @@ func TestBasic_EmptyOutputSuppression(t *testing.T) { assertSuccess(t, o, e, err, "") _, err = os.Stat(out) - assert.Equal(t, true, os.IsNotExist(err)) + assert.ErrorIs(t, err, fs.ErrNotExist) } func TestBasic_RoutesInputsToProperOutputsWithChmod(t *testing.T) { diff --git a/internal/tests/integration/config_test.go b/internal/tests/integration/config_test.go index 7b05048b..cccc0d97 100644 --- a/internal/tests/integration/config_test.go +++ b/internal/tests/integration/config_test.go @@ -1,25 +1,30 @@ package integration import ( + "io/fs" "os" "testing" "gotest.tools/v3/assert" - "gotest.tools/v3/fs" + tfs "gotest.tools/v3/fs" ) -func setupConfigTest(t *testing.T) *fs.Dir { - tmpDir := fs.NewDir(t, "gomplate-inttests", - fs.WithDir("indir"), - fs.WithDir("outdir"), - fs.WithFile(".gomplate.yaml", "in: hello world\n"), - fs.WithFile("sleep.sh", "#!/bin/sh\n\nexec sleep $1\n", fs.WithMode(0755)), +func setupConfigTest(t *testing.T) *tfs.Dir { + t.Helper() + + tmpDir := tfs.NewDir(t, "gomplate-inttests", + tfs.WithDir("indir"), + tfs.WithDir("outdir"), + tfs.WithFile(".gomplate.yaml", "in: hello world\n"), + tfs.WithFile("sleep.sh", "#!/bin/sh\n\nexec sleep $1\n", tfs.WithMode(0755)), ) t.Cleanup(tmpDir.Remove) return tmpDir } -func writeFile(t *testing.T, dir *fs.Dir, f, content string) { +func writeFile(t *testing.T, dir *tfs.Dir, f, content string) { + t.Helper() + f = dir.Join(f) err := os.WriteFile(f, []byte(content), 0600) if err != nil { @@ -27,7 +32,7 @@ func writeFile(t *testing.T, dir *fs.Dir, f, content string) { } } -func writeConfig(t *testing.T, dir *fs.Dir, content string) { +func writeConfig(t *testing.T, dir *tfs.Dir, content string) { t.Helper() writeFile(t, dir, ".gomplate.yaml", content) @@ -230,7 +235,7 @@ suppressEmpty: true assert.NilError(t, err) _, err = os.Stat(tmpDir.Join("missing")) - assert.Equal(t, true, os.IsNotExist(err)) + assert.ErrorIs(t, err, fs.ErrNotExist) } func TestConfig_ConfigTemplatesSupportsMap(t *testing.T) { diff --git a/internal/tests/integration/gomplateignore_test.go b/internal/tests/integration/gomplateignore_test.go index 17a6c817..c3220d97 100644 --- a/internal/tests/integration/gomplateignore_test.go +++ b/internal/tests/integration/gomplateignore_test.go @@ -1,24 +1,24 @@ package integration import ( + "io/fs" "os" "path/filepath" "sort" "testing" - "github.com/spf13/afero" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "gotest.tools/v3/fs" + tfs "gotest.tools/v3/fs" ) -func setupGomplateignoreTest(t *testing.T) func(inFileOps ...fs.PathOp) *fs.Dir { +func setupGomplateignoreTest(t *testing.T) func(inFileOps ...tfs.PathOp) *tfs.Dir { basedir := "gomplate-gomplateignore-tests" - inBuilder := func(inFileOps ...fs.PathOp) *fs.Dir { - tmpDir := fs.NewDir(t, basedir, - fs.WithDir("in", inFileOps...), - fs.WithDir("out"), + inBuilder := func(inFileOps ...tfs.PathOp) *tfs.Dir { + tmpDir := tfs.NewDir(t, basedir, + tfs.WithDir("in", inFileOps...), + tfs.WithDir("out"), ) t.Cleanup(tmpDir.Remove) return tmpDir @@ -27,14 +27,14 @@ func setupGomplateignoreTest(t *testing.T) func(inFileOps ...fs.PathOp) *fs.Dir return inBuilder } -func execute(t *testing.T, ignoreContent string, inFileOps ...fs.PathOp) ([]string, error) { +func execute(t *testing.T, ignoreContent string, inFileOps ...tfs.PathOp) ([]string, error) { return executeOpts(t, ignoreContent, []string{}, inFileOps...) } -func executeOpts(t *testing.T, ignoreContent string, opts []string, inFileOps ...fs.PathOp) ([]string, error) { +func executeOpts(t *testing.T, ignoreContent string, opts []string, inFileOps ...tfs.PathOp) ([]string, error) { inBuilder := setupGomplateignoreTest(t) - inFileOps = append(inFileOps, fs.WithFile(".gomplateignore", ignoreContent)) + inFileOps = append(inFileOps, tfs.WithFile(".gomplateignore", ignoreContent)) tmpDir := inBuilder(inFileOps...) argv := []string{} @@ -48,13 +48,14 @@ func executeOpts(t *testing.T, ignoreContent string, opts []string, inFileOps .. files := []string{} - fs := afero.NewBasePathFs(afero.NewOsFs(), tmpDir.Join("out")) - afero.Walk(fs, "", func(path string, info os.FileInfo, werr error) error { - if werr != nil { - err = werr - return nil + fsys := os.DirFS(tmpDir.Join("out") + "/") + err = fs.WalkDir(fsys, ".", func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err } - if path != "" && !info.IsDir() { + + if path != "" && !d.IsDir() { + path = filepath.FromSlash(path) files = append(files, path) } return nil @@ -69,8 +70,8 @@ func TestGomplateignore_Simple(t *testing.T) { files, err := execute(t, `# all dot files .* *.log`, - fs.WithFile("empty.log", ""), - fs.WithFile("rain.txt", "")) + tfs.WithFile("empty.log", ""), + tfs.WithFile("rain.txt", "")) require.NoError(t, err) assert.Equal(t, []string{"rain.txt"}, files) @@ -87,15 +88,15 @@ func TestGomplateignore_Folder(t *testing.T) { files, err := execute(t, `.gomplateignore f[o]o/bar !foo/bar/tool`, - fs.WithDir("foo", - fs.WithDir("bar", - fs.WithDir("tool", - fs.WithFile("lex.txt", ""), + tfs.WithDir("foo", + tfs.WithDir("bar", + tfs.WithDir("tool", + tfs.WithFile("lex.txt", ""), ), - fs.WithFile("1.txt", ""), + tfs.WithFile("1.txt", ""), ), - fs.WithDir("tar", - fs.WithFile("2.txt", ""), + tfs.WithDir("tar", + tfs.WithFile("2.txt", ""), ), ), ) @@ -108,11 +109,11 @@ f[o]o/bar func TestGomplateignore_Root(t *testing.T) { files, err := execute(t, `.gomplateignore /1.txt`, - fs.WithDir("sub", - fs.WithFile("1.txt", ""), - fs.WithFile("2.txt", ""), + tfs.WithDir("sub", + tfs.WithFile("1.txt", ""), + tfs.WithFile("2.txt", ""), ), - fs.WithFile("1.txt", ""), + tfs.WithFile("1.txt", ""), ) require.NoError(t, err) @@ -126,14 +127,14 @@ func TestGomplateignore_Exclusion(t *testing.T) { !/e2.txt en/e3.txt !`, - fs.WithFile("!", ""), - fs.WithFile("e1.txt", ""), - fs.WithFile("e2.txt", ""), - fs.WithFile("e3.txt", ""), - fs.WithDir("en", - fs.WithFile("e1.txt", ""), - fs.WithFile("e2.txt", ""), - fs.WithFile("e3.txt", ""), + tfs.WithFile("!", ""), + tfs.WithFile("e1.txt", ""), + tfs.WithFile("e2.txt", ""), + tfs.WithFile("e3.txt", ""), + tfs.WithDir("en", + tfs.WithFile("e1.txt", ""), + tfs.WithFile("e2.txt", ""), + tfs.WithFile("e3.txt", ""), ), ) @@ -144,16 +145,16 @@ en/e3.txt func TestGomplateignore_Nested(t *testing.T) { files, err := execute(t, `inner/foo.md`, - fs.WithDir("inner", - fs.WithDir("inner2", - fs.WithFile(".gomplateignore", "moss.ini\n!/jess.ini"), - fs.WithFile("jess.ini", ""), - fs.WithFile("moss.ini", "")), - fs.WithFile(".gomplateignore", "*.lst\njess.ini"), - fs.WithFile("2.lst", ""), - fs.WithFile("foo.md", ""), + tfs.WithDir("inner", + tfs.WithDir("inner2", + tfs.WithFile(".gomplateignore", "moss.ini\n!/jess.ini"), + tfs.WithFile("jess.ini", ""), + tfs.WithFile("moss.ini", "")), + tfs.WithFile(".gomplateignore", "*.lst\njess.ini"), + tfs.WithFile("2.lst", ""), + tfs.WithFile("foo.md", ""), ), - fs.WithFile("1.txt", ""), + tfs.WithFile("1.txt", ""), ) require.NoError(t, err) @@ -166,20 +167,20 @@ func TestGomplateignore_Nested(t *testing.T) { func TestGomplateignore_ByName(t *testing.T) { files, err := execute(t, `.gomplateignore world.txt`, - fs.WithDir("aa", - fs.WithDir("a1", - fs.WithDir("a2", - fs.WithFile("hello.txt", ""), - fs.WithFile("world.txt", "")), - fs.WithFile("hello.txt", ""), - fs.WithFile("world.txt", "")), - fs.WithFile("hello.txt", ""), - fs.WithFile("world.txt", "")), - fs.WithDir("bb", - fs.WithFile("hello.txt", ""), - fs.WithFile("world.txt", "")), - fs.WithFile("hello.txt", ""), - fs.WithFile("world.txt", ""), + tfs.WithDir("aa", + tfs.WithDir("a1", + tfs.WithDir("a2", + tfs.WithFile("hello.txt", ""), + tfs.WithFile("world.txt", "")), + tfs.WithFile("hello.txt", ""), + tfs.WithFile("world.txt", "")), + tfs.WithFile("hello.txt", ""), + tfs.WithFile("world.txt", "")), + tfs.WithDir("bb", + tfs.WithFile("hello.txt", ""), + tfs.WithFile("world.txt", "")), + tfs.WithFile("hello.txt", ""), + tfs.WithFile("world.txt", ""), ) require.NoError(t, err) @@ -193,12 +194,12 @@ func TestGomplateignore_BothName(t *testing.T) { loss.txt !2.log `, - fs.WithDir("loss.txt", - fs.WithFile("1.log", ""), - fs.WithFile("2.log", "")), - fs.WithDir("foo", - fs.WithFile("loss.txt", ""), - fs.WithFile("bare.txt", "")), + tfs.WithDir("loss.txt", + tfs.WithFile("1.log", ""), + tfs.WithFile("2.log", "")), + tfs.WithDir("foo", + tfs.WithFile("loss.txt", ""), + tfs.WithFile("bare.txt", "")), ) require.NoError(t, err) @@ -213,12 +214,12 @@ func TestGomplateignore_LeadingSpace(t *testing.T) { *.log ! dart.log `, - fs.WithDir("inner", - fs.WithFile(" what.txt", ""), - fs.WithFile(" dart.log", "")), - fs.WithDir("inner2", - fs.WithFile(" what.txt", "")), - fs.WithFile(" what.txt", ""), + tfs.WithDir("inner", + tfs.WithFile(" what.txt", ""), + tfs.WithFile(" dart.log", "")), + tfs.WithDir("inner2", + tfs.WithFile(" what.txt", "")), + tfs.WithFile(" what.txt", ""), ) require.NoError(t, err) @@ -234,20 +235,20 @@ func TestGomplateignore_WithExcludes(t *testing.T) { "--exclude", "rules/*.txt", "--exclude", "sprites/*.ini", }, - fs.WithDir("logs", - fs.WithFile("archive.zip", ""), - fs.WithFile("engine.log", ""), - fs.WithFile("skills.log", "")), - fs.WithDir("rules", - fs.WithFile("index.csv", ""), - fs.WithFile("fire.txt", ""), - fs.WithFile("earth.txt", "")), - fs.WithDir("sprites", - fs.WithFile("human.csv", ""), - fs.WithFile("demon.xml", ""), - fs.WithFile("alien.ini", "")), - fs.WithFile("manifest.json", ""), - fs.WithFile("crash.bin", ""), + tfs.WithDir("logs", + tfs.WithFile("archive.zip", ""), + tfs.WithFile("engine.log", ""), + tfs.WithFile("skills.log", "")), + tfs.WithDir("rules", + tfs.WithFile("index.csv", ""), + tfs.WithFile("fire.txt", ""), + tfs.WithFile("earth.txt", "")), + tfs.WithDir("sprites", + tfs.WithFile("human.csv", ""), + tfs.WithFile("demon.xml", ""), + tfs.WithFile("alien.ini", "")), + tfs.WithFile("manifest.json", ""), + tfs.WithFile("crash.bin", ""), ) require.NoError(t, err) @@ -263,16 +264,16 @@ func TestGomplateignore_WithIncludes(t *testing.T) { "--include", "rules/*", "--exclude", "rules/*.txt", }, - fs.WithDir("logs", - fs.WithFile("archive.zip", ""), - fs.WithFile("engine.log", ""), - fs.WithFile("skills.log", "")), - fs.WithDir("rules", - fs.WithFile("index.csv", ""), - fs.WithFile("fire.txt", ""), - fs.WithFile("earth.txt", "")), - fs.WithFile("manifest.json", ""), - fs.WithFile("crash.bin", ""), + tfs.WithDir("logs", + tfs.WithFile("archive.zip", ""), + tfs.WithFile("engine.log", ""), + tfs.WithFile("skills.log", "")), + tfs.WithDir("rules", + tfs.WithFile("index.csv", ""), + tfs.WithFile("fire.txt", ""), + tfs.WithFile("earth.txt", "")), + tfs.WithFile("manifest.json", ""), + tfs.WithFile("crash.bin", ""), ) require.NoError(t, err) diff --git a/internal/tests/integration/integration_test.go b/internal/tests/integration/integration_test.go index 95479407..1e56e009 100644 --- a/internal/tests/integration/integration_test.go +++ b/internal/tests/integration/integration_test.go @@ -190,6 +190,8 @@ func (c *command) withEnv(k, v string) *command { var GomplateBinPath = "" func (c *command) run() (o, e string, err error) { + c.t.Helper() + if GomplateBinPath != "" { return c.runCompiled(GomplateBinPath) } @@ -197,6 +199,8 @@ func (c *command) run() (o, e string, err error) { } func (c *command) runInProcess() (o, e string, err error) { + c.t.Helper() + // iterate env vars by order of insertion for _, k := range c.envK { k := k @@ -221,6 +225,8 @@ func (c *command) runInProcess() (o, e string, err error) { if err != nil { c.t.Fatal(err) } + + c.t.Logf("running in dir %q", c.dir) } stdin := strings.NewReader(c.stdin) diff --git a/render_test.go b/render_test.go index e7f03ac0..261a2591 100644 --- a/render_test.go +++ b/render_test.go @@ -10,16 +10,21 @@ import ( "testing" "testing/fstest" - "github.com/hairyhenderson/go-fsimpl" "github.com/hairyhenderson/gomplate/v4/data" + "github.com/hairyhenderson/gomplate/v4/internal/datafs" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestRenderTemplate(t *testing.T) { + wd, _ := os.Getwd() + t.Cleanup(func() { + _ = os.Chdir(wd) + }) + _ = os.Chdir("/") + fsys := fstest.MapFS{} - ctx := ContextWithFSProvider(context.Background(), - fsimpl.WrappedFSProvider(fsys, "mem")) + ctx := datafs.ContextWithFSProvider(context.Background(), datafs.WrappedFSProvider(fsys, "mem")) // no options - built-in function tr := NewRenderer(Options{}) diff --git a/template.go b/template.go index f9679b47..54095fdd 100644 --- a/template.go +++ b/template.go @@ -2,6 +2,7 @@ package gomplate import ( "context" + "errors" "fmt" "io" "io/fs" @@ -11,21 +12,20 @@ import ( "strings" "text/template" + "github.com/hack-pad/hackpadfs" "github.com/hairyhenderson/go-fsimpl" "github.com/hairyhenderson/gomplate/v4/internal/config" + "github.com/hairyhenderson/gomplate/v4/internal/datafs" "github.com/hairyhenderson/gomplate/v4/internal/iohelpers" "github.com/hairyhenderson/gomplate/v4/tmpl" - "github.com/spf13/afero" - "github.com/zealic/xignore" + // TODO: switch back if/when fs.FS support gets merged upstream + "github.com/hairyhenderson/xignore" ) // ignorefile name, like .gitignore const gomplateignore = ".gomplateignore" -// for overriding in tests -var aferoFS = afero.NewOsFs() - func addTmplFuncs(f template.FuncMap, root *template.Template, tctx interface{}, path string) { t := tmpl.New(root, tctx, path) tns := func() *tmpl.Template { return t } @@ -47,23 +47,6 @@ func copyFuncMap(funcMap template.FuncMap) template.FuncMap { return newFuncMap } -var fsProviderCtxKey = struct{}{} - -// ContextWithFSProvider returns a context with the given FSProvider. Should -// only be used in tests. -func ContextWithFSProvider(ctx context.Context, fsp fsimpl.FSProvider) context.Context { - return context.WithValue(ctx, fsProviderCtxKey, fsp) -} - -// FSProviderFromContext returns the FSProvider from the context, if any -func FSProviderFromContext(ctx context.Context) fsimpl.FSProvider { - if fsp, ok := ctx.Value(fsProviderCtxKey).(fsimpl.FSProvider); ok { - return fsp - } - - return nil -} - // parseTemplate - parses text as a Go template with the given name and options func parseTemplate(ctx context.Context, name, text string, funcs template.FuncMap, tmplctx interface{}, nested config.Templates, leftDelim, rightDelim string) (tmpl *template.Template, err error) { tmpl = template.New(name) @@ -89,7 +72,7 @@ func parseTemplate(ctx context.Context, name, text string, funcs template.FuncMa } func parseNestedTemplates(ctx context.Context, nested config.Templates, tmpl *template.Template) error { - fsp := FSProviderFromContext(ctx) + fsp := datafs.FSProviderFromContext(ctx) for alias, n := range nested { u := *n.URL @@ -106,6 +89,14 @@ func parseNestedTemplates(ctx context.Context, nested config.Templates, tmpl *te return fmt.Errorf("filesystem provider for %q unavailable: %w", &u, err) } + // TODO: maybe need to do something with root here? + if _, reldir := datafs.ResolveLocalPath(u.Path); reldir != "" && reldir != "." { + fsys, err = fs.Sub(fsys, reldir) + if err != nil { + return fmt.Errorf("sub filesystem for %q unavailable: %w", &u, err) + } + } + // inject context & header in case they're useful... fsys = fsimpl.WithContextFS(ctx, fsys) fsys = fsimpl.WithHeaderFS(n.Header, fsys) @@ -172,20 +163,22 @@ func parseNestedTemplate(_ context.Context, fsys fs.FS, alias, fname string, tmp // gatherTemplates - gather and prepare templates for rendering // nolint: gocyclo -func gatherTemplates(ctx context.Context, cfg *config.Config, outFileNamer func(context.Context, string) (string, error)) (templates []Template, err error) { +func gatherTemplates(ctx context.Context, cfg *config.Config, outFileNamer func(context.Context, string) (string, error)) ([]Template, error) { mode, modeOverride, err := cfg.GetMode() if err != nil { return nil, err } + var templates []Template + switch { // the arg-provided input string gets a special name case cfg.Input != "": // open the output file - no need to close it, as it will be closed by the // caller later - target, oerr := openOutFile(cfg.OutputFiles[0], 0755, mode, modeOverride, cfg.Stdout, cfg.SuppressEmpty) + target, oerr := openOutFile(ctx, cfg.OutputFiles[0], 0o755, mode, modeOverride, cfg.Stdout, cfg.SuppressEmpty) if oerr != nil { - return nil, oerr + return nil, fmt.Errorf("openOutFile: %w", oerr) } templates = []Template{{ @@ -197,14 +190,14 @@ func gatherTemplates(ctx context.Context, cfg *config.Config, outFileNamer func( // input dirs presume output dirs are set too templates, err = walkDir(ctx, cfg, cfg.InputDir, outFileNamer, cfg.ExcludeGlob, mode, modeOverride) if err != nil { - return nil, err + return nil, fmt.Errorf("walkDir: %w", err) } case cfg.Input == "": templates = make([]Template, len(cfg.InputFiles)) - for i := range cfg.InputFiles { - templates[i], err = fileToTemplate(cfg, cfg.InputFiles[i], cfg.OutputFiles[i], mode, modeOverride) + for i, f := range cfg.InputFiles { + templates[i], err = fileToTemplate(ctx, cfg, f, cfg.OutputFiles[i], mode, modeOverride) if err != nil { - return nil, err + return nil, fmt.Errorf("fileToTemplate: %w", err) } } } @@ -216,48 +209,66 @@ func gatherTemplates(ctx context.Context, cfg *config.Config, outFileNamer func( // of .gomplateignore and exclude globs (if any), walk the input directory and create a list of // tplate objects, and an error, if any. func walkDir(ctx context.Context, cfg *config.Config, dir string, outFileNamer func(context.Context, string) (string, error), excludeGlob []string, mode os.FileMode, modeOverride bool) ([]Template, error) { - dir = filepath.Clean(dir) + dir = filepath.ToSlash(filepath.Clean(dir)) + + // we want a filesystem rooted at dir, for relative matching + fsys, err := datafs.FSysForPath(ctx, dir) + if err != nil { + return nil, fmt.Errorf("filesystem provider for %q unavailable: %w", dir, err) + } + + // we need dir to be relative to the root of fsys + // TODO: maybe need to do something with root here? + _, reldir := datafs.ResolveLocalPath(dir) - dirStat, err := aferoFS.Stat(dir) + subfsys, err := fs.Sub(fsys, reldir) if err != nil { - return nil, fmt.Errorf("couldn't stat %s: %w", dir, err) + return nil, fmt.Errorf("sub: %w", err) + } + + // just check . because fsys is subbed to dir already + dirStat, err := fs.Stat(subfsys, ".") + if err != nil { + return nil, fmt.Errorf("stat %q (%q): %w", dir, reldir, err) } dirMode := dirStat.Mode() templates := make([]Template, 0) - matcher := xignore.NewMatcher(aferoFS) + matcher := xignore.NewMatcher(subfsys) - // work around bug in xignore - a basedir of '.' doesn't work - basedir := dir - if basedir == "." { - basedir, _ = os.Getwd() - } - matches, err := matcher.Matches(basedir, &xignore.MatchesOptions{ + matches, err := matcher.Matches(".", &xignore.MatchesOptions{ Ignorefile: gomplateignore, Nested: true, // allow nested ignorefile AfterPatterns: excludeGlob, }) if err != nil { - return nil, fmt.Errorf("ignore matching failed for %s: %w", basedir, err) + return nil, fmt.Errorf("ignore matching failed for %s: %w", dir, err) } // Unmatched ignorefile rules's files - files := matches.UnmatchedFiles - for _, file := range files { - inFile := filepath.Join(dir, file) + for _, file := range matches.UnmatchedFiles { + // we want to pass an absolute (as much as possible) path to fileToTemplate + inPath := filepath.Join(dir, file) + inPath = filepath.ToSlash(inPath) + + // but outFileNamer expects only the filename itself outFile, err := outFileNamer(ctx, file) if err != nil { - return nil, err + return nil, fmt.Errorf("outFileNamer: %w", err) } - tpl, err := fileToTemplate(cfg, inFile, outFile, mode, modeOverride) + tpl, err := fileToTemplate(ctx, cfg, inPath, outFile, mode, modeOverride) if err != nil { - return nil, err + return nil, fmt.Errorf("fileToTemplate: %w", err) } - // Ensure file parent dirs - if err = aferoFS.MkdirAll(filepath.Dir(outFile), dirMode); err != nil { - return nil, err + // Ensure file parent dirs - use separate fsys for output file + outfsys, err := datafs.FSysForPath(ctx, outFile) + if err != nil { + return nil, fmt.Errorf("fsysForPath: %w", err) + } + if err = hackpadfs.MkdirAll(outfsys, filepath.Dir(outFile), dirMode); err != nil { + return nil, fmt.Errorf("mkdirAll %q: %w", outFile, err) } templates = append(templates, tpl) @@ -266,21 +277,26 @@ func walkDir(ctx context.Context, cfg *config.Config, dir string, outFileNamer f return templates, nil } -func fileToTemplate(cfg *config.Config, inFile, outFile string, mode os.FileMode, modeOverride bool) (Template, error) { +func fileToTemplate(ctx context.Context, cfg *config.Config, inFile, outFile string, mode os.FileMode, modeOverride bool) (Template, error) { source := "" //nolint:nestif if inFile == "-" { b, err := io.ReadAll(cfg.Stdin) if err != nil { - return Template{}, fmt.Errorf("failed to read from stdin: %w", err) + return Template{}, fmt.Errorf("read from stdin: %w", err) } source = string(b) } else { - si, err := aferoFS.Stat(inFile) + fsys, err := datafs.FSysForPath(ctx, inFile) + if err != nil { + return Template{}, fmt.Errorf("fsysForPath: %w", err) + } + + si, err := fs.Stat(fsys, inFile) if err != nil { - return Template{}, err + return Template{}, fmt.Errorf("stat %q: %w", inFile, err) } if mode == 0 { mode = si.Mode() @@ -288,17 +304,9 @@ func fileToTemplate(cfg *config.Config, inFile, outFile string, mode os.FileMode // we read the file and store in memory immediately, to prevent leaking // file descriptors. - f, err := aferoFS.OpenFile(inFile, os.O_RDONLY, 0) + b, err := fs.ReadFile(fsys, inFile) if err != nil { - return Template{}, fmt.Errorf("failed to open %s: %w", inFile, err) - } - - //nolint: errcheck - defer f.Close() - - b, err := io.ReadAll(f) - if err != nil { - return Template{}, fmt.Errorf("failed to read %s: %w", inFile, err) + return Template{}, fmt.Errorf("readAll %q: %w", inFile, err) } source = string(b) @@ -306,9 +314,9 @@ func fileToTemplate(cfg *config.Config, inFile, outFile string, mode os.FileMode // open the output file - no need to close it, as it will be closed by the // caller later - target, err := openOutFile(outFile, 0755, mode, modeOverride, cfg.Stdout, cfg.SuppressEmpty) + target, err := openOutFile(ctx, outFile, 0755, mode, modeOverride, cfg.Stdout, cfg.SuppressEmpty) if err != nil { - return Template{}, err + return Template{}, fmt.Errorf("openOutFile: %w", err) } tmpl := Template{ @@ -328,13 +336,13 @@ func fileToTemplate(cfg *config.Config, inFile, outFile string, mode os.FileMode // // TODO: the 'suppressEmpty' behaviour should be always enabled, in the next // major release (v4.x). -func openOutFile(filename string, dirMode, mode os.FileMode, modeOverride bool, stdout io.Writer, suppressEmpty bool) (out io.Writer, err error) { +func openOutFile(ctx context.Context, filename string, dirMode, mode os.FileMode, modeOverride bool, stdout io.Writer, suppressEmpty bool) (out io.Writer, err error) { if suppressEmpty { out = iohelpers.NewEmptySkipper(func() (io.Writer, error) { if filename == "-" { return stdout, nil } - return createOutFile(filename, dirMode, mode, modeOverride) + return createOutFile(ctx, filename, dirMode, mode, modeOverride) }) return out, nil } @@ -342,34 +350,41 @@ func openOutFile(filename string, dirMode, mode os.FileMode, modeOverride bool, if filename == "-" { return stdout, nil } - return createOutFile(filename, dirMode, mode, modeOverride) + return createOutFile(ctx, filename, dirMode, mode, modeOverride) } -func createOutFile(filename string, dirMode, mode os.FileMode, modeOverride bool) (out io.WriteCloser, err error) { +func createOutFile(ctx context.Context, filename string, dirMode, mode os.FileMode, modeOverride bool) (out io.WriteCloser, err error) { + // we only support writing out to local files for now + fsys, err := datafs.FSysForPath(ctx, filename) + if err != nil { + return nil, fmt.Errorf("fsysForPath: %w", err) + } + mode = iohelpers.NormalizeFileMode(mode.Perm()) if modeOverride { - err = aferoFS.Chmod(filename, mode) - if err != nil && !os.IsNotExist(err) { - return nil, fmt.Errorf("failed to chmod output file '%s' with mode %q: %w", filename, mode, err) + err = hackpadfs.Chmod(fsys, filename, mode) + if err != nil && !errors.Is(err, fs.ErrNotExist) { + return nil, fmt.Errorf("failed to chmod output file %q with mode %q: %w", filename, mode, err) } } open := func() (out io.WriteCloser, err error) { // Ensure file parent dirs - if err = aferoFS.MkdirAll(filepath.Dir(filename), dirMode); err != nil { - return nil, err + if err = hackpadfs.MkdirAll(fsys, filepath.Dir(filename), dirMode); err != nil { + return nil, fmt.Errorf("mkdirAll %q: %w", filename, err) } - out, err = aferoFS.OpenFile(filename, os.O_RDWR|os.O_CREATE|os.O_TRUNC, mode) + f, err := hackpadfs.OpenFile(fsys, filename, os.O_RDWR|os.O_CREATE|os.O_TRUNC, mode) if err != nil { return out, fmt.Errorf("failed to open output file '%s' for writing: %w", filename, err) } + out = f.(io.WriteCloser) return out, err } // if the output file already exists, we'll use a SameSkipper - fi, err := aferoFS.Stat(filename) + fi, err := hackpadfs.Stat(fsys, filename) if err != nil { // likely means the file just doesn't exist - further errors will be more useful return iohelpers.LazyWriteCloser(open), nil @@ -380,7 +395,7 @@ func createOutFile(filename string, dirMode, mode os.FileMode, modeOverride bool } out = iohelpers.SameSkipper(iohelpers.LazyReadCloser(func() (io.ReadCloser, error) { - return aferoFS.OpenFile(filename, os.O_RDONLY, mode) + return hackpadfs.OpenFile(fsys, filename, os.O_RDONLY, mode) }), open) return out, err diff --git a/template_test.go b/template_test.go index 85a8ae90..fd6a387a 100644 --- a/template_test.go +++ b/template_test.go @@ -4,29 +4,33 @@ import ( "bytes" "context" "io" + "io/fs" "net/url" "os" "testing" "testing/fstest" "text/template" - "github.com/hairyhenderson/go-fsimpl" + "github.com/hack-pad/hackpadfs" + "github.com/hack-pad/hackpadfs/mem" "github.com/hairyhenderson/gomplate/v4/internal/config" + "github.com/hairyhenderson/gomplate/v4/internal/datafs" "github.com/hairyhenderson/gomplate/v4/internal/iohelpers" - "github.com/spf13/afero" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestOpenOutFile(t *testing.T) { - origfs := aferoFS - defer func() { aferoFS = origfs }() - aferoFS = afero.NewMemMapFs() - _ = aferoFS.Mkdir("/tmp", 0777) + memfs, _ := mem.NewFS() + fsys := datafs.WrapWdFS(memfs) + + _ = hackpadfs.Mkdir(fsys, "/tmp", 0o777) + + ctx := datafs.ContextWithFSProvider(context.Background(), datafs.WrappedFSProvider(fsys, "file")) cfg := &config.Config{Stdout: &bytes.Buffer{}} - f, err := openOutFile("/tmp/foo", 0755, 0644, false, nil, false) + f, err := openOutFile(ctx, "/tmp/foo", 0o755, 0o644, false, nil, false) require.NoError(t, err) wc, ok := f.(io.WriteCloser) @@ -34,28 +38,34 @@ func TestOpenOutFile(t *testing.T) { err = wc.Close() require.NoError(t, err) - i, err := aferoFS.Stat("/tmp/foo") + i, err := hackpadfs.Stat(fsys, "/tmp/foo") require.NoError(t, err) assert.Equal(t, iohelpers.NormalizeFileMode(0644), i.Mode()) out := &bytes.Buffer{} - f, err = openOutFile("-", 0755, 0644, false, out, false) + f, err = openOutFile(ctx, "-", 0755, 0644, false, out, false) require.NoError(t, err) assert.Equal(t, cfg.Stdout, f) } func TestGatherTemplates(t *testing.T) { - ctx := context.Background() + // chdir to root so we can use relative paths + wd, _ := os.Getwd() + t.Cleanup(func() { + _ = os.Chdir(wd) + }) + _ = os.Chdir("/") + + fsys, _ := mem.NewFS() - origfs := aferoFS - defer func() { aferoFS = origfs }() - aferoFS = afero.NewMemMapFs() - afero.WriteFile(aferoFS, "foo", []byte("bar"), 0600) + _ = hackpadfs.WriteFullFile(fsys, "foo", []byte("bar"), 0o600) + _ = hackpadfs.Mkdir(fsys, "in", 0o777) + _ = hackpadfs.WriteFullFile(fsys, "in/1", []byte("foo"), 0o644) + _ = hackpadfs.WriteFullFile(fsys, "in/2", []byte("bar"), 0o644) + _ = hackpadfs.WriteFullFile(fsys, "in/3", []byte("baz"), 0o644) - afero.WriteFile(aferoFS, "in/1", []byte("foo"), 0644) - afero.WriteFile(aferoFS, "in/2", []byte("bar"), 0644) - afero.WriteFile(aferoFS, "in/3", []byte("baz"), 0644) + ctx := datafs.ContextWithFSProvider(context.Background(), datafs.WrappedFSProvider(fsys, "file")) cfg := &config.Config{ Stdin: &bytes.Buffer{}, @@ -83,20 +93,18 @@ func TestGatherTemplates(t *testing.T) { }, nil) require.NoError(t, err) assert.Len(t, templates, 1) - // assert.Equal(t, iohelpers.NormalizeFileMode(0644), templates[0].mode) // out file is created only on demand - _, err = aferoFS.Stat("out") - assert.Error(t, err) - assert.True(t, os.IsNotExist(err)) + _, err = hackpadfs.Stat(fsys, "out") + assert.ErrorIs(t, err, fs.ErrNotExist) _, err = templates[0].Writer.Write([]byte("hello world")) require.NoError(t, err) - info, err := aferoFS.Stat("out") + info, err := hackpadfs.Stat(fsys, "out") require.NoError(t, err) - assert.Equal(t, iohelpers.NormalizeFileMode(0644), info.Mode()) - aferoFS.Remove("out") + assert.Equal(t, iohelpers.NormalizeFileMode(0o644), info.Mode()) + _ = hackpadfs.Remove(fsys, "out") cfg = &config.Config{ InputFiles: []string{"foo"}, @@ -113,10 +121,10 @@ func TestGatherTemplates(t *testing.T) { _, err = templates[0].Writer.Write([]byte("hello world")) require.NoError(t, err) - info, err = aferoFS.Stat("out") + info, err = hackpadfs.Stat(fsys, "out") require.NoError(t, err) assert.Equal(t, iohelpers.NormalizeFileMode(0600), info.Mode()) - aferoFS.Remove("out") + hackpadfs.Remove(fsys, "out") cfg = &config.Config{ InputFiles: []string{"foo"}, @@ -134,44 +142,48 @@ func TestGatherTemplates(t *testing.T) { _, err = templates[0].Writer.Write([]byte("hello world")) require.NoError(t, err) - info, err = aferoFS.Stat("out") + info, err = hackpadfs.Stat(fsys, "out") require.NoError(t, err) assert.Equal(t, iohelpers.NormalizeFileMode(0755), info.Mode()) - aferoFS.Remove("out") + hackpadfs.Remove(fsys, "out") templates, err = gatherTemplates(ctx, &config.Config{ InputDir: "in", OutputDir: "out", }, simpleNamer("out")) require.NoError(t, err) - assert.Len(t, templates, 3) + require.Len(t, templates, 3) assert.Equal(t, "foo", templates[0].Text) - aferoFS.Remove("out") + hackpadfs.Remove(fsys, "out") } func TestCreateOutFile(t *testing.T) { - origfs := aferoFS - defer func() { aferoFS = origfs }() - aferoFS = afero.NewMemMapFs() - _ = aferoFS.Mkdir("in", 0755) + fsys, _ := mem.NewFS() + _ = hackpadfs.Mkdir(fsys, "in", 0755) + + ctx := datafs.ContextWithFSProvider(context.Background(), datafs.WrappedFSProvider(fsys, "file")) - _, err := createOutFile("in", 0755, 0644, false) + _, err := createOutFile(ctx, "in", 0755, 0644, false) assert.Error(t, err) - assert.IsType(t, &os.PathError{}, err) + assert.IsType(t, &fs.PathError{}, err) } func TestParseNestedTemplates(t *testing.T) { - ctx := context.Background() + wd, _ := os.Getwd() + t.Cleanup(func() { + _ = os.Chdir(wd) + }) + _ = os.Chdir("/") // in-memory test filesystem fsys := fstest.MapFS{ "foo.t": {Data: []byte("hello world"), Mode: 0o600}, } - fsp := fsimpl.WrappedFSProvider(fsys, "file") - ctx = ContextWithFSProvider(ctx, fsp) + + ctx := datafs.ContextWithFSProvider(context.Background(), datafs.WrappedFSProvider(fsys, "file")) // simple test with single template - u, _ := url.Parse("file:///foo.t") + u, _ := url.Parse("foo.t") nested := config.Templates{"foo": {URL: u}} tmpl, _ := template.New("root").Parse(`{{ template "foo" }}`) @@ -185,10 +197,11 @@ func TestParseNestedTemplates(t *testing.T) { assert.Equal(t, "hello world", out.String()) // test with directory of templates + fsys["dir/"] = &fstest.MapFile{Mode: 0o777 | os.ModeDir} fsys["dir/foo.t"] = &fstest.MapFile{Data: []byte("foo"), Mode: 0o600} fsys["dir/bar.t"] = &fstest.MapFile{Data: []byte("bar"), Mode: 0o600} - u, _ = url.Parse("file:///dir/") + u, _ = url.Parse("dir/") nested["dir"] = config.DataSource{URL: u} tmpl, _ = template.New("root").Parse(`{{ template "dir/foo.t" }} {{ template "dir/bar.t" }}`) diff --git a/template_unix_test.go b/template_unix_test.go index fb7f6fef..9cf8729b 100644 --- a/template_unix_test.go +++ b/template_unix_test.go @@ -6,33 +6,40 @@ import ( "context" "testing" + "github.com/hack-pad/hackpadfs" + "github.com/hack-pad/hackpadfs/mem" "github.com/hairyhenderson/gomplate/v4/internal/config" - "github.com/spf13/afero" + "github.com/hairyhenderson/gomplate/v4/internal/datafs" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func TestWalkDir(t *testing.T) { - ctx := context.Background() - origfs := aferoFS - defer func() { aferoFS = origfs }() - aferoFS = afero.NewMemMapFs() +func TestWalkDir_UNIX(t *testing.T) { + memfs, _ := mem.NewFS() + fsys := datafs.WrapWdFS(memfs) + + ctx := datafs.ContextWithFSProvider(context.Background(), datafs.WrappedFSProvider(fsys, "file")) cfg := &config.Config{} _, err := walkDir(ctx, cfg, "/indir", simpleNamer("/outdir"), nil, 0, false) assert.Error(t, err) - _ = aferoFS.MkdirAll("/indir/one", 0777) - _ = aferoFS.MkdirAll("/indir/two", 0777) - afero.WriteFile(aferoFS, "/indir/one/foo", []byte("foo"), 0644) - afero.WriteFile(aferoFS, "/indir/one/bar", []byte("bar"), 0664) - afero.WriteFile(aferoFS, "/indir/two/baz", []byte("baz"), 0644) + err = hackpadfs.MkdirAll(fsys, "/indir/one", 0o777) + require.NoError(t, err) + err = hackpadfs.MkdirAll(fsys, "/indir/two", 0o777) + require.NoError(t, err) + err = hackpadfs.WriteFullFile(fsys, "/indir/one/foo", []byte("foo"), 0o644) + require.NoError(t, err) + err = hackpadfs.WriteFullFile(fsys, "/indir/one/bar", []byte("bar"), 0o644) + require.NoError(t, err) + err = hackpadfs.WriteFullFile(fsys, "/indir/two/baz", []byte("baz"), 0o644) + require.NoError(t, err) templates, err := walkDir(ctx, cfg, "/indir", simpleNamer("/outdir"), []string{"*/two"}, 0, false) - require.NoError(t, err) + expected := []Template{ { Name: "/indir/one/bar", diff --git a/template_windows_test.go b/template_windows_test.go index 587b8b1c..c5c977aa 100644 --- a/template_windows_test.go +++ b/template_windows_test.go @@ -1,49 +1,69 @@ //go:build windows +// +build windows package gomplate import ( "context" + "io/fs" + "os" "testing" + "github.com/hack-pad/hackpadfs" + "github.com/hack-pad/hackpadfs/mem" "github.com/hairyhenderson/gomplate/v4/internal/config" - "github.com/spf13/afero" + "github.com/hairyhenderson/gomplate/v4/internal/datafs" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func TestWalkDir(t *testing.T) { - ctx := context.Background() - origfs := aferoFS - defer func() { aferoFS = origfs }() - aferoFS = afero.NewMemMapFs() +func TestWalkDir_Windows(t *testing.T) { + wd, _ := os.Getwd() + t.Cleanup(func() { + _ = os.Chdir(wd) + }) + _ = os.Chdir("C:/") + + memfs, _ := mem.NewFS() + fsys := datafs.WrapWdFS(memfs) + + ctx := datafs.ContextWithFSProvider(context.Background(), datafs.WrappedFSProvider(fsys, "file")) cfg := &config.Config{} - _, err := walkDir(ctx, cfg, `C:\indir`, simpleNamer(`C:\outdir`), nil, 0, false) + _, err := walkDir(ctx, cfg, `C:\indir`, simpleNamer(`C:/outdir`), nil, 0, false) assert.Error(t, err) - _ = aferoFS.MkdirAll(`C:\indir\one`, 0777) - _ = aferoFS.MkdirAll(`C:\indir\two`, 0777) - afero.WriteFile(aferoFS, `C:\indir\one\foo`, []byte("foo"), 0644) - afero.WriteFile(aferoFS, `C:\indir\one\bar`, []byte("bar"), 0644) - afero.WriteFile(aferoFS, `C:\indir\two\baz`, []byte("baz"), 0644) + err = hackpadfs.MkdirAll(fsys, `C:\indir\one`, 0o777) + require.NoError(t, err) + err = hackpadfs.MkdirAll(fsys, `C:\indir\two`, 0o777) + require.NoError(t, err) + err = hackpadfs.WriteFullFile(fsys, `C:\indir\one\foo`, []byte("foo"), 0o644) + require.NoError(t, err) + err = hackpadfs.WriteFullFile(fsys, `C:\indir\one\bar`, []byte("bar"), 0o644) + require.NoError(t, err) + err = hackpadfs.WriteFullFile(fsys, `C:\indir\two\baz`, []byte("baz"), 0o644) + require.NoError(t, err) - templates, err := walkDir(ctx, cfg, `C:\indir`, simpleNamer(`C:\outdir`), []string{`*\two`}, 0, false) + fi, err := fs.Stat(fsys, `C:\indir\two\baz`) + require.NoError(t, err) + assert.Equal(t, "baz", fi.Name()) + templates, err := walkDir(ctx, cfg, `C:\indir`, simpleNamer(`C:/outdir`), []string{`*\two`}, 0, false) require.NoError(t, err) + expected := []Template{ { - Name: `C:\indir\one\bar`, + Name: `C:/indir/one/bar`, Text: "bar", }, { - Name: `C:\indir\one\foo`, + Name: `C:/indir/one/foo`, Text: "foo", }, } - assert.Len(t, templates, 2) + require.Len(t, templates, 2) for i, tmpl := range templates { assert.Equal(t, expected[i].Name, tmpl.Name) assert.Equal(t, expected[i].Text, tmpl.Text) |
