Repository: hairyhenderson/gomplate
Pull request #1642 (2023-02-25 17:09:13)

replace afero module
There's an impedance mismatch between `github.com/spf13/afero` and `io/fs`. This replaces uses of `afero` with an `io/fs`-compatible filesystem. For write operations, `github.com/hack-pad/hackpadfs` is used - it's more closely aligned with what a writeable `io/fs` might look like in future.

This introduces a new working-directory-aware filesystem `datafs.WdFS` which behaves a little differently from a regular local filesystem. It supports absolute paths (which begin with `/`) - this strays from the `io/fs` definition of a valid path, as leading slashes are not permitted (as paths must be relative to the filesystem root). When given a relative path, that will be resolved relative to the current working directory.

All interactions with user-provided paths are done with `WdFS`, so that the existing behaviour can be preserved.


Patch:
diff --git a/Makefile b/Makefile
index f491f8b1..be23be3c 100644
--- a/Makefile
+++ b/Makefile
@@ -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_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_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/env/env.go b/env/env.go
index b8bbbfa5..e9ab3632 100644
--- a/env/env.go
+++ b/env/env.go
@@ -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/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/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/go.mod b/go.mod
index 136967ca..dcbdaab6 100644
--- a/go.mod
+++ b/go.mod
@@ -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
diff --git a/go.sum b/go.sum
index 1fc61deb..fc897deb 100644
--- a/go.sum
+++ b/go.sum
@@ -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/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/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/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/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/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


Test patch:
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_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_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")
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_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_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))
+}
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/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/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_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_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_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)