[infra] Add dependency injection support to more fileutils API

Adds the ability to pass in a oswrapper.* to IsDir(), IsExe(),
IsFile(), CopyFile(), and CopyDir() to allow better testing of code
that uses these functions.

Testing is added for these functions.

CopyFile & CopyDir were significantly rewritten due to issues found
when testing.

Updates all of the callsites to use the new API. Some places where I
was making these changes I also changed calls to os.* to be to
oswrapper.*.

Bug: 344014313
Change-Id: I418672fdbcb1f5492d7fd796ec70e9dd2f3a7cc9
Reviewed-on: https://dawn-review.googlesource.com/c/dawn/+/255514
Commit-Queue: Ryan Harrison <rharrison@chromium.org>
Reviewed-by: Brian Sheedy <bsheedy@google.com>
diff --git a/tools/src/cmd/cts/roll/roll.go b/tools/src/cmd/cts/roll/roll.go
index d319908..b5d54d9 100644
--- a/tools/src/cmd/cts/roll/roll.go
+++ b/tools/src/cmd/cts/roll/roll.go
@@ -810,7 +810,7 @@
 	ctx context.Context, fsReader oswrapper.FilesystemReader) (string, error) {
 
 	tscPath := filepath.Join(r.ctsDir, "node_modules/.bin/tsc")
-	if !fileutils.IsExe(tscPath) {
+	if !fileutils.IsExe(tscPath, fsReader) {
 		return "", fmt.Errorf("tsc not found at '%v'", tscPath)
 	}
 
diff --git a/tools/src/cmd/fuzz/main.go b/tools/src/cmd/fuzz/main.go
index a96cca7..f7b64b5 100644
--- a/tools/src/cmd/fuzz/main.go
+++ b/tools/src/cmd/fuzz/main.go
@@ -164,24 +164,24 @@
 	dictionary string   // path to dictionary to use for tint_wgsl_fuzzer
 }
 
-func run(c *cmdConfig, osWrapper oswrapper.OSWrapper) error {
-	if !fileutils.IsDir(c.build) {
+func run(c *cmdConfig, fsReaderWriter oswrapper.FilesystemReaderWriter) error {
+	if !fileutils.IsDir(c.build, fsReaderWriter) {
 		return fmt.Errorf("build directory '%v' does not exist", c.build)
 	}
 
 	// Verify / create the output directory
 	if c.out == "" || c.out == "<tmp>" {
-		if tmp, err := osWrapper.MkdirTemp("", "tint_fuzz"); err == nil {
-			defer func(osWrapper oswrapper.OSWrapper, path string) {
-				_ = osWrapper.RemoveAll(path)
-			}(osWrapper, tmp)
+		if tmp, err := fsReaderWriter.MkdirTemp("", "tint_fuzz"); err == nil {
+			defer func(fsWriter oswrapper.FilesystemWriter, path string) {
+				_ = fsWriter.RemoveAll(path)
+			}(fsReaderWriter, tmp)
 			c.out = tmp
 		} else {
 			return err
 		}
 	}
 
-	if !fileutils.IsDir(c.out) {
+	if !fileutils.IsDir(c.out, fsReaderWriter) {
 		return fmt.Errorf("output directory '%v' does not exist", c.out)
 	}
 
@@ -190,18 +190,18 @@
 	if c.fuzzMode == FuzzModeIr && (c.cmdMode == TaskModeRun || c.cmdMode == TaskModeCheck) {
 		// The default input files are .wgsl files and tint_ir_fuzzer runs on .tirb files, so need
 		// to convert them before running/checking
-		if c.inputs == defaultWgslCorpusDir(osWrapper) {
+		if c.inputs == defaultWgslCorpusDir(fsReaderWriter) {
 			origOut := c.out
-			tmp, err := osWrapper.MkdirTemp("", "ir_corpus")
+			tmp, err := fsReaderWriter.MkdirTemp("", "ir_corpus")
 			if err != nil {
 				return err
 			}
-			defer func(osWrapper oswrapper.OSWrapper, path string) {
-				_ = osWrapper.RemoveAll(path)
-			}(osWrapper, tmp)
+			defer func(fsWriter oswrapper.FilesystemWriter, path string) {
+				_ = fsWriter.RemoveAll(path)
+			}(fsReaderWriter, tmp)
 
 			c.out = tmp
-			t, err := generateTaskConfig(TaskModeGenerate, c, osWrapper)
+			t, err := generateTaskConfig(TaskModeGenerate, c, fsReaderWriter)
 			if err != nil {
 				return err
 			}
@@ -212,7 +212,7 @@
 		}
 	}
 
-	t, err := generateTaskConfig(c.cmdMode, c, osWrapper)
+	t, err := generateTaskConfig(c.cmdMode, c, fsReaderWriter)
 	if err != nil {
 		return err
 	}
@@ -222,11 +222,11 @@
 		var err error
 		switch t.taskMode {
 		case TaskModeRun:
-			err = runFuzzer(t, osWrapper)
+			err = runFuzzer(t)
 		case TaskModeCheck:
-			err = checkFuzzer(t, osWrapper)
+			err = checkFuzzer(t, fsReaderWriter)
 		case TaskModeGenerate:
-			err = runCorpusGenerator(t, osWrapper)
+			err = runCorpusGenerator(t, fsReaderWriter)
 		default:
 			err = fmt.Errorf("unknown task mode %d", t.taskMode)
 		}
@@ -238,7 +238,7 @@
 }
 
 // generateTaskConfig produces a taskConfig based off the supplied cmdConfig and specified TaskMode.
-func generateTaskConfig(tm TaskMode, c *cmdConfig, osWrapper oswrapper.OSWrapper) (*taskConfig, error) {
+func generateTaskConfig(tm TaskMode, c *cmdConfig, fsReader oswrapper.FilesystemReader) (*taskConfig, error) {
 	t := taskConfig{
 		cmdConfig: *c,
 		taskMode:  tm,
@@ -272,18 +272,18 @@
 	for _, config := range dependencies {
 		switch {
 		case filepath.Ext(config.name) == ".py":
-			*config.path = filepath.Join(filepath.Join(fileutils.DawnRoot(osWrapper), "src", "tint", "cmd", "fuzz"), config.name)
-			if !fileutils.IsFile(*config.path) {
+			*config.path = filepath.Join(filepath.Join(fileutils.DawnRoot(fsReader), "src", "tint", "cmd", "fuzz"), config.name)
+			if !fileutils.IsFile(*config.path, fsReader) {
 				return nil, fmt.Errorf("script '%v' not found at '%v'", config.name, *config.path)
 			}
 		case filepath.Ext(config.name) == ".txt":
-			*config.path = filepath.Join(filepath.Join(fileutils.DawnRoot(osWrapper), "src", "tint", "cmd", "fuzz", "wgsl"), config.name)
-			if !fileutils.IsFile(*config.path) {
+			*config.path = filepath.Join(filepath.Join(fileutils.DawnRoot(fsReader), "src", "tint", "cmd", "fuzz", "wgsl"), config.name)
+			if !fileutils.IsFile(*config.path, fsReader) {
 				return nil, fmt.Errorf("resource '%v' not found at '%v'", config.name, *config.path)
 			}
 		default:
 			*config.path = filepath.Join(t.build, config.name+fileutils.ExeExt)
-			if !fileutils.IsExe(*config.path) {
+			if !fileutils.IsExe(*config.path, fsReader) {
 				return nil, fmt.Errorf("binary '%v' not found at '%v'", config.name, *config.path)
 			}
 		}
@@ -294,14 +294,14 @@
 
 // checkFuzzer runs the fuzzer against all the test files the inputs directory,
 // ensuring that the fuzzers do not error for the given file.
-func checkFuzzer(t *taskConfig, osWrapper oswrapper.OSWrapper) error {
+func checkFuzzer(t *taskConfig, fsReader oswrapper.FilesystemReader) error {
 	var files []string
 	var err error
 	switch t.fuzzMode {
 	case FuzzModeIr:
-		files, err = glob.Glob(filepath.Join(t.inputs, "**.tirb"), osWrapper)
+		files, err = glob.Glob(filepath.Join(t.inputs, "**.tirb"), fsReader)
 	case FuzzModeWgsl:
-		files, err = glob.Glob(filepath.Join(t.inputs, "**.wgsl"), osWrapper)
+		files, err = glob.Glob(filepath.Join(t.inputs, "**.wgsl"), fsReader)
 	default:
 		err = fmt.Errorf("unknown fuzzer mode %d", t.fuzzMode)
 	}
@@ -355,7 +355,7 @@
 // The fuzzer will use t.inputs as the seed directory.
 // New cases are written to t.out.
 // Blocks until a fuzzer errors, or the process is interrupted.
-func runFuzzer(t *taskConfig, fsReader oswrapper.FilesystemReader) error {
+func runFuzzer(t *taskConfig) error {
 	ctx := utils.CancelOnInterruptContext(context.Background())
 	ctx, cancel := context.WithCancel(ctx)
 	defer cancel()
@@ -423,35 +423,35 @@
 // runCorpusGenerator converts a set of input test files into a fuzzer corpus
 // The generator will use t.inputs as the source directory.
 // The corpus will be written to t.out.
-func runCorpusGenerator(t *taskConfig, osWrapper oswrapper.OSWrapper) error {
+func runCorpusGenerator(t *taskConfig, fsReaderWriter oswrapper.FilesystemReaderWriter) error {
 	switch t.fuzzMode {
 	case FuzzModeWgsl:
-		return runCorpusGeneratorWgsl(t, osWrapper)
+		return runCorpusGeneratorWgsl(t, fsReaderWriter)
 	case FuzzModeIr:
-		return runCorpusGeneratorIr(t, osWrapper)
+		return runCorpusGeneratorIr(t, fsReaderWriter)
 	default:
 		return fmt.Errorf("unknown fuzzer mode %d", t.fuzzMode)
 	}
 }
 
 // runCorpusGeneratorWgsl converts a set of input test .wgsl files into a WGSL fuzzer corpus.
-func runCorpusGeneratorWgsl(t *taskConfig, osWrapper oswrapper.OSWrapper) error {
-	return gatherWgslFiles(t.inputs, t.out, osWrapper)
+func runCorpusGeneratorWgsl(t *taskConfig, fsReaderWriter oswrapper.FilesystemReaderWriter) error {
+	return gatherWgslFiles(t.inputs, t.out, fsReaderWriter)
 }
 
 // runCorpusGeneratorWgsl converts a set of input test .wgsl files into an IR fuzzer corpus
 // Forks out to an external binary, t.assembler, to perform the operation.
-func runCorpusGeneratorIr(t *taskConfig, osWrapper oswrapper.OSWrapper) error {
-	tmp, err := osWrapper.MkdirTemp("", "wgsl_corpus")
+func runCorpusGeneratorIr(t *taskConfig, fsReaderWriter oswrapper.FilesystemReaderWriter) error {
+	tmp, err := fsReaderWriter.MkdirTemp("", "wgsl_corpus")
 	if err == nil {
-		defer func(osWrapper oswrapper.OSWrapper, path string) {
-			_ = osWrapper.RemoveAll(path)
-		}(osWrapper, tmp)
+		defer func(fsWriter oswrapper.FilesystemWriter, path string) {
+			_ = fsWriter.RemoveAll(path)
+		}(fsReaderWriter, tmp)
 	} else {
 		return err
 	}
 
-	err = gatherWgslFiles(t.inputs, tmp, osWrapper)
+	err = gatherWgslFiles(t.inputs, tmp, fsReaderWriter)
 	if err != nil {
 		return err
 	}
@@ -486,9 +486,9 @@
 // gatherWgslFiles copies all the .wgsl files in a directory structure over to a flat directory
 // structure, via replacing the path separators for the origins with underscores in the destination
 // file names. It also filters out any '*.expected.*' files
-func gatherWgslFiles(inputs string, out string, osWrapper oswrapper.OSWrapper) error {
+func gatherWgslFiles(inputs string, out string, fsReaderWriter oswrapper.FilesystemReaderWriter) error {
 	fmt.Println("gathering and filtering .wgsl files")
-	files, err := glob.Glob(filepath.Join(inputs, "**.wgsl"), osWrapper)
+	files, err := glob.Glob(filepath.Join(inputs, "**.wgsl"), fsReaderWriter)
 	if err != nil {
 		return err
 	}
@@ -506,7 +506,7 @@
 	}
 
 	for src, dest := range mapping {
-		if err := fileutils.CopyFile(filepath.Join(out, dest), src); err != nil {
+		if err := fileutils.CopyFile(filepath.Join(out, dest), src, fsReaderWriter); err != nil {
 			return err
 		}
 	}
diff --git a/tools/src/cmd/gen/common/clang_format.go b/tools/src/cmd/gen/common/clang_format.go
index f21f9bc..ba717e8 100644
--- a/tools/src/cmd/gen/common/clang_format.go
+++ b/tools/src/cmd/gen/common/clang_format.go
@@ -75,7 +75,7 @@
 	case "windows":
 		path = filepath.Join(dawnRoot, "buildtools/win/clang-format.exe")
 	}
-	if fileutils.IsExe(path) {
+	if fileutils.IsExe(path, fsReader) {
 		return path, nil
 	}
 	var err error
diff --git a/tools/src/cmd/node/main.go b/tools/src/cmd/node/main.go
index 6ebe65c..5443b34 100644
--- a/tools/src/cmd/node/main.go
+++ b/tools/src/cmd/node/main.go
@@ -79,14 +79,14 @@
 		debugger = "lldb"
 	}
 
-	if err := run(opts.BinDir, nodePath, nodeFlags, flag.Args(), debugger); err != nil {
+	if err := run(opts.BinDir, nodePath, nodeFlags, flag.Args(), debugger, wrapper); err != nil {
 		fmt.Fprintln(os.Stderr, err)
 		os.Exit(1)
 	}
 }
 
 // run starts the
-func run(binPath, nodePath string, flags node.Flags, args []string, debugger string) error {
+func run(binPath, nodePath string, flags node.Flags, args []string, debugger string, fsReader oswrapper.FilesystemReader) error {
 	if len(args) == 0 {
 		return fmt.Errorf("missing path to .js file")
 	}
@@ -102,7 +102,7 @@
 	}
 
 	for _, file := range []string{"cts.js", "dawn.node"} {
-		if !fileutils.IsFile(filepath.Join(binPath, file)) {
+		if !fileutils.IsFile(filepath.Join(binPath, file), fsReader) {
 			return fmt.Errorf("'%v' does not contain '%v'", binPath, file)
 		}
 	}
diff --git a/tools/src/cmd/run-cts/chrome/cmd.go b/tools/src/cmd/run-cts/chrome/cmd.go
index 5a6bd53..ee34531 100644
--- a/tools/src/cmd/run-cts/chrome/cmd.go
+++ b/tools/src/cmd/run-cts/chrome/cmd.go
@@ -80,7 +80,7 @@
 // TODO(crbug.com/416755658): Add unittest coverage once there is a way to fake
 // the Chrome instance.
 func (c *cmd) Run(ctx context.Context, cfg common.Config) error {
-	state, err := c.flags.Process()
+	state, err := c.flags.Process(cfg.OsWrapper)
 	if err != nil {
 		return err
 	}
@@ -96,10 +96,10 @@
 		return fmt.Errorf("only a single query can be provided")
 	}
 
-	if err := c.state.CTS.Node.BuildIfRequired(c.flags.Verbose); err != nil {
+	if err := c.state.CTS.Node.BuildIfRequired(c.flags.Verbose, cfg.OsWrapper); err != nil {
 		return err
 	}
-	if err := c.state.CTS.Standalone.BuildIfRequired(c.flags.Verbose); err != nil {
+	if err := c.state.CTS.Standalone.BuildIfRequired(c.flags.Verbose, cfg.OsWrapper); err != nil {
 		return err
 	}
 
@@ -206,9 +206,9 @@
 	handler.HandleFunc("/test_page.html", serveFile("webgpu-cts/test_page.html", fsReader))
 	handler.HandleFunc("/test_runner.js", serveFile("webgpu-cts/test_runner.js", fsReader))
 	handler.HandleFunc("/third_party/webgpu-cts/resources/",
-		serveDir("/third_party/webgpu-cts/resources/", c.flags.CTS+"/out/resources/"))
+		serveDir("/third_party/webgpu-cts/resources/", c.flags.CTS+"/out/resources/", fsReader))
 	handler.HandleFunc("/third_party/webgpu-cts/src/",
-		serveDir("/third_party/webgpu-cts/src/", c.flags.CTS+"/out/"))
+		serveDir("/third_party/webgpu-cts/src/", c.flags.CTS+"/out/", fsReader))
 	handler.HandleFunc("/", websocket.Handler(func(ws *websocket.Conn) {
 		go func() {
 			d := json.NewDecoder(ws)
@@ -337,17 +337,17 @@
 	dawnRoot := fileutils.DawnRoot(fsReader)
 	return func(w http.ResponseWriter, r *http.Request) {
 		fullPath := filepath.Join(dawnRoot, relPath)
-		if !fileutils.IsFile(fullPath) {
+		if !fileutils.IsFile(fullPath, fsReader) {
 			log.Printf("'%v' file does not exist", fullPath)
 		}
 		http.ServeFile(w, r, filepath.Join(dawnRoot, relPath))
 	}
 }
 
-func serveDir(remote, local string) func(http.ResponseWriter, *http.Request) {
+func serveDir(remote, local string, fsReader oswrapper.FilesystemReader) func(http.ResponseWriter, *http.Request) {
 	return func(w http.ResponseWriter, r *http.Request) {
 		fullPath := filepath.Join(local, strings.TrimPrefix(r.URL.Path, remote))
-		if !fileutils.IsFile(fullPath) {
+		if !fileutils.IsFile(fullPath, fsReader) {
 			log.Printf("'%v' file does not exist", fullPath)
 		}
 		http.ServeFile(w, r, fullPath)
diff --git a/tools/src/cmd/run-cts/common/builder.go b/tools/src/cmd/run-cts/common/builder.go
index 14bc5b9..b58b8ee 100644
--- a/tools/src/cmd/run-cts/common/builder.go
+++ b/tools/src/cmd/run-cts/common/builder.go
@@ -28,9 +28,9 @@
 package common
 
 import (
+	"dawn.googlesource.com/dawn/tools/src/oswrapper"
 	"encoding/json"
 	"fmt"
-	"os"
 	"os/exec"
 	"path/filepath"
 	"time"
@@ -46,7 +46,7 @@
 }
 
 // BuildIfRequired calls Build() if the CTS sources have been modified since the last build.
-func (b *Builder) BuildIfRequired(verbose bool) error {
+func (b *Builder) BuildIfRequired(verbose bool, osWrapper oswrapper.OSWrapper) error {
 	name := fmt.Sprintf("cts %v", b.Name)
 
 	// Scan the CTS source to determine the most recent change to the CTS source
@@ -61,17 +61,17 @@
 	cache := Cache{BuildTimestamp: map[string]time.Time{}}
 
 	cachePath := ""
-	if home, err := os.UserHomeDir(); err == nil {
+	if home, err := osWrapper.UserHomeDir(); err == nil {
 		cacheDir := filepath.Join(home, ".cache/webgpu")
 		cachePath = filepath.Join(cacheDir, "run-cts.json")
-		os.MkdirAll(cacheDir, 0777)
+		osWrapper.MkdirAll(cacheDir, 0777)
 	}
 
 	needsRebuild := true
 	if cachePath != "" { // consult the cache to see if we need to rebuild
-		if cacheFile, err := os.Open(cachePath); err == nil {
+		if cacheFile, err := osWrapper.Open(cachePath); err == nil {
 			if err := json.NewDecoder(cacheFile).Decode(&cache); err == nil {
-				if fileutils.IsDir(b.Out) {
+				if fileutils.IsDir(b.Out, osWrapper) {
 					needsRebuild = mostRecentSourceChange.After(cache.BuildTimestamp[b.Name])
 				}
 			}
@@ -84,14 +84,14 @@
 	}
 
 	if needsRebuild {
-		if err := b.Build(verbose); err != nil {
+		if err := b.Build(verbose, osWrapper); err != nil {
 			return fmt.Errorf("failed to build %v: %w", name, err)
 		}
 	}
 
 	if cachePath != "" {
 		// Update the cache timestamp
-		if cacheFile, err := os.Create(cachePath); err == nil {
+		if cacheFile, err := osWrapper.Create(cachePath); err == nil {
 			cache.BuildTimestamp[b.Name] = mostRecentSourceChange
 			json.NewEncoder(cacheFile).Encode(&cache)
 			cacheFile.Close()
@@ -104,7 +104,7 @@
 // Build executes the necessary build commands to build the CTS, including
 // copying the cache files from gen to the out directory and compiling the
 // TypeScript files down to JavaScript.
-func (b *Builder) Build(verbose bool) error {
+func (b *Builder) Build(verbose bool, fsReaderWrapper oswrapper.FilesystemReaderWriter) error {
 	if verbose {
 		start := time.Now()
 		fmt.Printf("Building CTS %v...\n", b.Name)
@@ -113,11 +113,11 @@
 		}()
 	}
 
-	if err := os.MkdirAll(b.Out, 0777); err != nil {
+	if err := fsReaderWrapper.MkdirAll(b.Out, 0777); err != nil {
 		return err
 	}
 
-	if !fileutils.IsExe(b.npx) {
+	if !fileutils.IsExe(b.npx, fsReaderWrapper) {
 		return fmt.Errorf("cannot find npx at '%v'", b.npx)
 	}
 
diff --git a/tools/src/cmd/run-cts/common/flags.go b/tools/src/cmd/run-cts/common/flags.go
index caa5561..f0a9e81 100644
--- a/tools/src/cmd/run-cts/common/flags.go
+++ b/tools/src/cmd/run-cts/common/flags.go
@@ -31,7 +31,6 @@
 	"flag"
 	"fmt"
 	"io"
-	"os"
 	"os/exec"
 	"path/filepath"
 	"runtime"
@@ -69,7 +68,7 @@
 
 // Process processes the flags, returning a State.
 // Note: Ensure you call Close() on the returned State
-func (f *Flags) Process() (*State, error) {
+func (f *Flags) Process(fsReaderWriter oswrapper.FilesystemReaderWriter) (*State, error) {
 	s := &State{
 		resultsPath: f.ResultsPath,
 	}
@@ -81,7 +80,7 @@
 	if f.CTS == "" {
 		return nil, subcmd.InvalidCLA()
 	}
-	if !fileutils.IsDir(f.CTS) {
+	if !fileutils.IsDir(f.CTS, fsReaderWriter) {
 		return nil, fmt.Errorf("'%v' is not a directory", f.CTS)
 	}
 	absCTS, err := filepath.Abs(f.CTS)
@@ -92,7 +91,7 @@
 
 	// Build the logger, if needed
 	if f.Log != "" {
-		writer, err := os.Create(f.Log)
+		writer, err := fsReaderWriter.Create(f.Log)
 		if err != nil {
 			return nil, fmt.Errorf("failed to open log '%v': %w", f.Log, err)
 		}
diff --git a/tools/src/cmd/run-cts/node/cmd.go b/tools/src/cmd/run-cts/node/cmd.go
index d9b5757..4de3457 100644
--- a/tools/src/cmd/run-cts/node/cmd.go
+++ b/tools/src/cmd/run-cts/node/cmd.go
@@ -126,12 +126,12 @@
 		return err
 	}
 
-	if err := c.maybeInitCoverage(); err != nil {
+	if err := c.maybeInitCoverage(cfg.OsWrapper); err != nil {
 		return err
 	}
 
 	if c.flags.build {
-		if err := c.state.CTS.Node.BuildIfRequired(c.flags.Verbose); err != nil {
+		if err := c.state.CTS.Node.BuildIfRequired(c.flags.Verbose, cfg.OsWrapper); err != nil {
 			return err
 		}
 	}
@@ -178,17 +178,17 @@
 }
 
 // TODO(crbug.com/344014313): Add unittest coverage.
-func (c *cmd) processFlags(fsReader oswrapper.FilesystemReader) error {
+func (c *cmd) processFlags(fsReaderWriter oswrapper.FilesystemReaderWriter) error {
 	// Check mandatory arguments
 	if c.flags.bin == "" {
 		return fmt.Errorf("-bin is not set. It defaults to <dawn>/out/active (%v) which does not exist",
-			filepath.Join(fileutils.DawnRoot(fsReader), "out/active"))
+			filepath.Join(fileutils.DawnRoot(fsReaderWriter), "out/active"))
 	}
-	if !fileutils.IsDir(c.flags.bin) {
+	if !fileutils.IsDir(c.flags.bin, fsReaderWriter) {
 		return fmt.Errorf("'%v' is not a directory", c.flags.bin)
 	}
 	for _, file := range []string{"cts.js", "dawn.node"} {
-		if !fileutils.IsFile(filepath.Join(c.flags.bin, file)) {
+		if !fileutils.IsFile(filepath.Join(c.flags.bin, file), fsReaderWriter) {
 			return fmt.Errorf("'%v' does not contain '%v'", c.flags.bin, file)
 		}
 	}
@@ -228,7 +228,7 @@
 		c.flags.Verbose = true
 	}
 
-	state, err := c.flags.Process()
+	state, err := c.flags.Process(fsReaderWriter)
 	if err != nil {
 		return err
 	}
@@ -239,7 +239,7 @@
 
 // TODO(crbug.com/416755658): Add unittest coverage when exec is handled via
 // dependency injection.
-func (c *cmd) maybeInitCoverage() error {
+func (c *cmd) maybeInitCoverage(fsReader oswrapper.FilesystemReader) error {
 	if !c.flags.genCoverage && c.flags.coverageFile == "" {
 		return nil
 	}
@@ -249,7 +249,7 @@
 		profdata = ""
 		if runtime.GOOS == "darwin" {
 			profdata = "/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/llvm-profdata"
-			if !fileutils.IsExe(profdata) {
+			if !fileutils.IsExe(profdata, fsReader) {
 				profdata = ""
 			}
 		}
@@ -260,7 +260,7 @@
 
 	llvmCov := ""
 	turboCov := filepath.Join(c.flags.bin, "turbo-cov"+fileutils.ExeExt)
-	if !fileutils.IsExe(turboCov) {
+	if !fileutils.IsExe(turboCov, fsReader) {
 		turboCov = ""
 		if path, err := exec.LookPath("llvm-cov"); err == nil {
 			llvmCov = path
diff --git a/tools/src/cmd/tests/main.go b/tools/src/cmd/tests/main.go
index b2aa927..4328861 100644
--- a/tools/src/cmd/tests/main.go
+++ b/tools/src/cmd/tests/main.go
@@ -159,7 +159,7 @@
 	flag.Parse()
 
 	// Check the executable can be found and actually is executable
-	if !fileutils.IsExe(tintPath) {
+	if !fileutils.IsExe(tintPath, fsReaderWriter) {
 		fmt.Fprintln(os.Stderr, "tint executable not found, please specify with --tint")
 		showUsage()
 	}
@@ -192,12 +192,12 @@
 		}
 
 		switch {
-		case fileutils.IsDir(arg):
+		case fileutils.IsDir(arg, fsReaderWriter):
 			// Argument is to a directory, expand out to N globs
 			for _, glob := range directoryGlobs {
 				globs = append(globs, path.Join(arg, glob))
 			}
-		case fileutils.IsFile(arg):
+		case fileutils.IsFile(arg, fsReaderWriter):
 			// Argument is a file, append to absFiles
 			absFiles = append(absFiles, arg)
 		default:
@@ -270,16 +270,16 @@
 		if *tool.path == "" {
 			// Look first in the directory of the tint executable
 			p, err := exec.LookPath(filepath.Join(filepath.Dir(tintPath), tool.name))
-			if err == nil && fileutils.IsExe(p) {
+			if err == nil && fileutils.IsExe(p, fsReaderWriter) {
 				*tool.path = p
 			} else {
 				// Look in PATH
 				p, err := exec.LookPath(tool.name)
-				if err == nil && fileutils.IsExe(p) {
+				if err == nil && fileutils.IsExe(p, fsReaderWriter) {
 					*tool.path = p
 				}
 			}
-		} else if !fileutils.IsExe(*tool.path) {
+		} else if !fileutils.IsExe(*tool.path, fsReaderWriter) {
 			return fmt.Errorf("%v not found at '%v'", tool.name, *tool.path)
 		}
 
diff --git a/tools/src/cmd/tintd/install/install.go b/tools/src/cmd/tintd/install/install.go
index 541d566..e55d78f 100644
--- a/tools/src/cmd/tintd/install/install.go
+++ b/tools/src/cmd/tintd/install/install.go
@@ -31,6 +31,7 @@
 import (
 	"bytes"
 	"context"
+	"dawn.googlesource.com/dawn/tools/src/oswrapper"
 	"encoding/json"
 	"flag"
 	"fmt"
@@ -74,12 +75,12 @@
 // TODO(crbug.com/416755658): Add unittest coverage once exec is handled via
 // dependency injection.
 func (c Cmd) Run(ctx context.Context, cfg *common.Config) error {
-	pkgDir := c.findPackage()
+	pkgDir := c.findPackage(cfg.OsWrapper)
 	if pkgDir == "" {
 		return fmt.Errorf("could not find extension package directory at '%v'", c.flags.buildDir)
 	}
 
-	if !fileutils.IsExe(c.flags.npmPath) {
+	if !fileutils.IsExe(c.flags.npmPath, cfg.OsWrapper) {
 		return fmt.Errorf("could not find npm")
 	}
 
@@ -109,7 +110,7 @@
 		return fmt.Errorf("failed to obtain home directory: %w", err)
 	}
 	vscodeBaseExtsDir := filepath.Join(home, ".vscode", "extensions")
-	if !fileutils.IsDir(vscodeBaseExtsDir) {
+	if !fileutils.IsDir(vscodeBaseExtsDir, cfg.OsWrapper) {
 		return fmt.Errorf("vscode extensions directory not found at '%v'", vscodeBaseExtsDir)
 	}
 
@@ -123,7 +124,7 @@
 		}
 	} else {
 		// Copy the build directory to vscode extensions directory
-		if err := fileutils.CopyDir(vscodeTintdDir, pkgDir); err != nil {
+		if err := fileutils.CopyDir(vscodeTintdDir, pkgDir, cfg.OsWrapper); err != nil {
 			return fmt.Errorf("failed to copy '%v' to '%v': %w", pkgDir, vscodeTintdDir, err)
 		}
 	}
@@ -133,7 +134,7 @@
 
 // TODO(crbug.com/344014313): Add unittest coverage.
 // findPackage looks for and returns the tintd package directory. Returns an empty string if not found.
-func (c Cmd) findPackage() string {
+func (c Cmd) findPackage(fsReader oswrapper.FilesystemReader) string {
 	searchPaths := []string{
 		filepath.Join(c.flags.buildDir, "gen/vscode"),
 		c.flags.buildDir,
@@ -143,7 +144,7 @@
 nextDir:
 	for _, dir := range searchPaths {
 		for _, file := range files {
-			if !fileutils.IsFile(filepath.Join(dir, file)) {
+			if !fileutils.IsFile(filepath.Join(dir, file), fsReader) {
 				continue nextDir
 			}
 		}
diff --git a/tools/src/fileutils/copy.go b/tools/src/fileutils/copy.go
index 0170c4f..d314e16 100644
--- a/tools/src/fileutils/copy.go
+++ b/tools/src/fileutils/copy.go
@@ -32,75 +32,102 @@
 	"io"
 	"os"
 	"path/filepath"
+	"strings"
+
+	"dawn.googlesource.com/dawn/tools/src/oswrapper"
 )
 
-// CopyFile copies the file from 'src' to 'dst' replacing the existing file at 'dst' if it already
-// exists.
-func CopyFile(dst, src string) error {
-	if !IsFile(src) {
-		return fmt.Errorf("'%v' is not a file", src)
-	}
-
-	dstDir := filepath.Dir(dst)
-	if !IsDir(dstDir) {
-		if err := os.MkdirAll(dstDir, 0777); err != nil {
-			return err
-		}
-	}
-
-	s, err := os.Open(src)
+// CopyFile copies the file from 'src' to 'dst', creating the destination directory
+// if needed and overwriting the destination file if it already exists.
+// It preserves the file mode from the source.
+func CopyFile(dst, src string, fsReaderWriter oswrapper.FilesystemReaderWriter) error {
+	srcInfo, err := fsReaderWriter.Stat(src)
 	if err != nil {
-		return err
+		return fmt.Errorf("cannot stat source '%v': %w", src, err)
+	}
+	if srcInfo.IsDir() {
+		return fmt.Errorf("source '%v' is a directory, not a file", src)
+	}
+
+	s, err := fsReaderWriter.Open(src)
+	if err != nil {
+		return fmt.Errorf("failed to open source file '%v': %w", src, err)
 	}
 	defer s.Close()
 
-	info, err := s.Stat()
-	if err != nil {
-		return err
+	if err := fsReaderWriter.MkdirAll(filepath.Dir(dst), 0755); err != nil {
+		return fmt.Errorf("failed to create destination directory '%v': %w", filepath.Dir(dst), err)
 	}
 
-	d, err := os.OpenFile(dst, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0666|info.Mode()&0777)
+	d, err := fsReaderWriter.OpenFile(dst, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, srcInfo.Mode())
 	if err != nil {
-		return err
+		return fmt.Errorf("failed to open destination file '%v': %w", dst, err)
 	}
 	defer d.Close()
 
-	_, err = io.Copy(d, s)
-	return err
+	if _, err = io.Copy(d, s); err != nil {
+		return fmt.Errorf("failed to copy data from '%v' to '%v': %w", src, dst, err)
+	}
+
+	return nil
 }
 
-// CopyDir copies the directory and all its content from 'src' to 'dst' replacing the existing
-// directory at 'dst' if it already exists.
-func CopyDir(dst, src string) error {
-	if !IsDir(src) {
-		return fmt.Errorf("'%v' is not a directory", src)
+// CopyDir recursively copies the content of the 'src' directory to 'dst'.
+// If 'dst' exists, it will be completely overwritten with the content of 'src'.
+// If 'dst' does not exist, it will be created.
+func CopyDir(dst, src string, fsReaderWriter oswrapper.FilesystemReaderWriter) error {
+	srcInfo, err := fsReaderWriter.Stat(src)
+	if err != nil {
+		return fmt.Errorf("cannot stat source '%v': %w", src, err)
 	}
-	if IsFile(dst) {
-		return fmt.Errorf("'%v' is a file", dst)
+	if !srcInfo.IsDir() {
+		return fmt.Errorf("source '%v' is not a directory", src)
 	}
 
-	if IsDir(dst) {
-		if err := os.RemoveAll(dst); err != nil {
-			return err
+	var dstMode = srcInfo.Mode()
+	if dstInfo, err := fsReaderWriter.Stat(dst); err == nil {
+		if !dstInfo.IsDir() {
+			return fmt.Errorf("destination '%v' is a file, not a directory", dst)
 		}
+		dstMode = dstInfo.Mode() // Preserve original dst permissions
+	} else if !os.IsNotExist(err) { // Some other error
+		return fmt.Errorf("cannot stat destination '%v': %w", dst, err)
 	}
 
-	return filepath.Walk(src, func(path string, info os.FileInfo, err error) error {
+	// To prevent recursion, ensure dst is not a subdirectory of src.
+	cleanSrc := filepath.Clean(src)
+	cleanDst := filepath.Clean(dst)
+	if strings.HasPrefix(cleanDst, cleanSrc) && (cleanDst == cleanSrc || cleanDst[len(cleanSrc)] == filepath.Separator) {
+		return fmt.Errorf("cannot copy directory '%s' into itself '%s'", src, dst)
+	}
+
+	if err := fsReaderWriter.RemoveAll(dst); err != nil && !os.IsNotExist(err) {
+		return fmt.Errorf("failed to remove destination directory '%v': %w", dst, err)
+	}
+	if err := fsReaderWriter.MkdirAll(dst, dstMode); err != nil {
+		return fmt.Errorf("failed to create destination directory '%v': %w", dst, err)
+	}
+
+	return fsReaderWriter.Walk(src, func(path string, info os.FileInfo, err error) error {
 		if err != nil {
 			return err
 		}
 
-		rel, err := filepath.Rel(src, path)
+		relPath, err := filepath.Rel(src, path)
 		if err != nil {
-			return err
+			return fmt.Errorf("failed to calculate relative path for '%s': %w", path, err)
 		}
 
-		if !info.IsDir() {
-			if err := CopyFile(filepath.Join(dst, rel), path); err != nil {
-				return err
-			}
+		if relPath == "." { // Skip the root directory itself.
+			return nil
 		}
 
-		return nil
+		dstPath := filepath.Join(dst, relPath)
+
+		if info.IsDir() {
+			return fsReaderWriter.MkdirAll(dstPath, info.Mode())
+		}
+
+		return CopyFile(dstPath, path, fsReaderWriter)
 	})
 }
diff --git a/tools/src/fileutils/copy_test.go b/tools/src/fileutils/copy_test.go
new file mode 100644
index 0000000..f05f644
--- /dev/null
+++ b/tools/src/fileutils/copy_test.go
@@ -0,0 +1,302 @@
+// Copyright 2025 The Dawn & Tint Authors
+//
+// Redistribution and use in source and binary forms, with or without
+// modification, are permitted provided that the following conditions are met:
+//
+// 1. Redistributions of source code must retain the above copyright notice, this
+//    list of conditions and the following disclaimer.
+//
+// 2. Redistributions in binary form must reproduce the above copyright notice,
+//    this list of conditions and the following disclaimer in the documentation
+//    and/or other materials provided with the distribution.
+//
+// 3. Neither the name of the copyright holder nor the names of its
+//    contributors may be used to endorse or promote products derived from
+//    this software without specific prior written permission.
+//
+// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+// FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+// DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+// SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+// CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+// OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+package fileutils_test
+
+import (
+	"os"
+	"path/filepath"
+	"testing"
+
+	"dawn.googlesource.com/dawn/tools/src/fileutils"
+	"dawn.googlesource.com/dawn/tools/src/oswrapper"
+	"github.com/stretchr/testify/require"
+)
+
+func TestCopyFile(t *testing.T) {
+	tests := []struct {
+		name    string
+		srcPath string
+		dstPath string
+		setupFS func(t *testing.T, fs oswrapper.MemMapOSWrapper)
+		wantErr bool
+		verify  func(t *testing.T, fs oswrapper.MemMapOSWrapper)
+	}{
+		{
+			name:    "Simple copy",
+			srcPath: "/src/file.txt",
+			dstPath: "/dst/file.txt",
+			setupFS: func(t *testing.T, fs oswrapper.MemMapOSWrapper) {
+				require.NoError(t, fs.MkdirAll("/src", 0777))
+				require.NoError(t, fs.WriteFile("/src/file.txt", []byte("hello world"), 0666))
+				require.NoError(t, fs.MkdirAll("/dst", 0777))
+			},
+			wantErr: false,
+			verify: func(t *testing.T, fs oswrapper.MemMapOSWrapper) {
+				content, err := fs.ReadFile("/dst/file.txt")
+				require.NoError(t, err)
+				require.Equal(t, "hello world", string(content))
+			},
+		},
+		{
+			name:    "Overwrite existing file",
+			srcPath: "/src/file.txt",
+			dstPath: "/dst/file.txt",
+			setupFS: func(t *testing.T, fs oswrapper.MemMapOSWrapper) {
+				require.NoError(t, fs.MkdirAll("/src", 0777))
+				require.NoError(t, fs.WriteFile("/src/file.txt", []byte("new content"), 0666))
+				require.NoError(t, fs.MkdirAll("/dst", 0777))
+				require.NoError(t, fs.WriteFile("/dst/file.txt", []byte("old content"), 0666))
+			},
+			wantErr: false,
+			verify: func(t *testing.T, fs oswrapper.MemMapOSWrapper) {
+				content, err := fs.ReadFile("/dst/file.txt")
+				require.NoError(t, err)
+				require.Equal(t, "new content", string(content))
+			},
+		},
+		{
+			name:    "Source does not exist",
+			srcPath: "/src/nonexistent.txt",
+			dstPath: "/dst/file.txt",
+			setupFS: func(t *testing.T, fs oswrapper.MemMapOSWrapper) {
+				require.NoError(t, fs.MkdirAll("/dst", 0777))
+			},
+			wantErr: true,
+		},
+		{
+			name:    "Source is a directory",
+			srcPath: "/src/dir",
+			dstPath: "/dst/file.txt",
+			setupFS: func(t *testing.T, fs oswrapper.MemMapOSWrapper) {
+				require.NoError(t, fs.MkdirAll("/src/dir", 0777))
+				require.NoError(t, fs.MkdirAll("/dst", 0777))
+			},
+			wantErr: true,
+		},
+		{
+			name:    "Destination directory does not exist",
+			srcPath: "/src/file.txt",
+			dstPath: "/nonexistent/dst/file.txt",
+			setupFS: func(t *testing.T, fs oswrapper.MemMapOSWrapper) {
+				require.NoError(t, fs.MkdirAll("/src", 0777))
+				require.NoError(t, fs.WriteFile("/src/file.txt", []byte("hello"), 0666))
+			},
+			wantErr: false, // CopyFile creates the destination directory
+			verify: func(t *testing.T, fs oswrapper.MemMapOSWrapper) {
+				content, err := fs.ReadFile("/nonexistent/dst/file.txt")
+				require.NoError(t, err)
+				require.Equal(t, "hello", string(content))
+			},
+		},
+	}
+
+	for _, tc := range tests {
+		t.Run(tc.name, func(t *testing.T) {
+			wrapper := oswrapper.CreateMemMapOSWrapper()
+			if tc.setupFS != nil {
+				tc.setupFS(t, wrapper)
+			}
+
+			err := fileutils.CopyFile(tc.dstPath, tc.srcPath, wrapper)
+
+			if tc.wantErr {
+				require.Error(t, err)
+			} else {
+				require.NoError(t, err)
+			}
+
+			if tc.verify != nil {
+				tc.verify(t, wrapper)
+			}
+		})
+	}
+}
+
+func TestCopyDir(t *testing.T) {
+	tests := []struct {
+		name    string
+		srcPath string
+		dstPath string
+		setupFS func(t *testing.T, fs oswrapper.MemMapOSWrapper)
+		wantErr bool
+		verify  func(t *testing.T, fs oswrapper.MemMapOSWrapper)
+	}{
+		{
+			name:    "Copy to non-existent destination",
+			srcPath: "/src/data",
+			dstPath: "/dst",
+			setupFS: func(t *testing.T, fs oswrapper.MemMapOSWrapper) {
+				require.NoError(t, fs.MkdirAll("/src/data/subdir", 0777))
+				require.NoError(t, fs.WriteFile("/src/data/file1.txt", []byte("file1"), 0666))
+			},
+			wantErr: false,
+			verify: func(t *testing.T, fs oswrapper.MemMapOSWrapper) {
+				require.True(t, fileutils.IsDir("/dst", fs))
+				content, err := fs.ReadFile(filepath.Join("/dst", "file1.txt"))
+				require.NoError(t, err)
+				require.Equal(t, "file1", string(content))
+				require.True(t, fileutils.IsDir(filepath.Join("/dst", "subdir"), fs))
+			},
+		},
+		{
+			name:    "Overwrite existing destination",
+			srcPath: "/src/new_data",
+			dstPath: "/dst",
+			setupFS: func(t *testing.T, fs oswrapper.MemMapOSWrapper) {
+				// Source
+				require.NoError(t, fs.MkdirAll("/src/new_data", 0777))
+				require.NoError(t, fs.WriteFile("/src/new_data/new_file.txt", []byte("new"), 0666))
+				// Destination with old content
+				require.NoError(t, fs.MkdirAll("/dst/old_subdir", 0777))
+				require.NoError(t, fs.WriteFile("/dst/old_file.txt", []byte("old"), 0666))
+			},
+			wantErr: false,
+			verify: func(t *testing.T, fs oswrapper.MemMapOSWrapper) {
+				// Check new file exists
+				content, err := fs.ReadFile(filepath.Join("/dst", "new_file.txt"))
+				require.NoError(t, err)
+				require.Equal(t, "new", string(content))
+
+				// Check old files are gone
+				require.False(t, fileutils.IsFile(filepath.Join("/dst", "old_file.txt"), fs))
+				require.False(t, fileutils.IsDir(filepath.Join("/dst", "old_subdir"), fs))
+			},
+		},
+		{
+			name:    "Copy complex directory structure",
+			srcPath: "/src/complex",
+			dstPath: "/dst",
+			setupFS: func(t *testing.T, fs oswrapper.MemMapOSWrapper) {
+				// Source with complex structure
+				require.NoError(t, fs.MkdirAll("/src/complex/a", 0777))
+				require.NoError(t, fs.MkdirAll("/src/complex/b/c", 0777))
+				require.NoError(t, fs.WriteFile("/src/complex/root.txt", []byte("root"), 0666))
+				require.NoError(t, fs.WriteFile("/src/complex/a/a.txt", []byte("a"), 0666))
+				require.NoError(t, fs.WriteFile("/src/complex/b/b.txt", []byte("b"), 0666))
+				require.NoError(t, fs.WriteFile("/src/complex/b/c/c.txt", []byte("c"), 0666))
+				// Empty destination
+				require.NoError(t, fs.MkdirAll("/dst", 0777))
+			},
+			wantErr: false,
+			verify: func(t *testing.T, fs oswrapper.MemMapOSWrapper) {
+				content, err := fs.ReadFile(filepath.Join("/dst", "root.txt"))
+				require.NoError(t, err)
+				require.Equal(t, "root", string(content))
+				content, err = fs.ReadFile(filepath.Join("/dst", "a", "a.txt"))
+				require.NoError(t, err)
+				require.Equal(t, "a", string(content))
+				content, err = fs.ReadFile(filepath.Join("/dst", "b", "b.txt"))
+				require.NoError(t, err)
+				require.Equal(t, "b", string(content))
+				content, err = fs.ReadFile(filepath.Join("/dst", "b", "c", "c.txt"))
+				require.NoError(t, err)
+				require.Equal(t, "c", string(content))
+			},
+		},
+		{
+			name:    "Copy empty directory to existing destination",
+			srcPath: "/src/empty",
+			dstPath: "/dst",
+			setupFS: func(t *testing.T, fs oswrapper.MemMapOSWrapper) {
+				require.NoError(t, fs.MkdirAll("/src/empty", 0777))
+				require.NoError(t, fs.MkdirAll("/dst", 0777))
+				require.NoError(t, fs.WriteFile("/dst/old_file.txt", []byte("old"), 0666))
+			},
+			wantErr: false,
+			verify: func(t *testing.T, fs oswrapper.MemMapOSWrapper) {
+				require.True(t, fileutils.IsDir("/dst", fs))
+				// Check it's empty by walking the directory.
+				var fileCount int
+				err := fs.Walk("/dst", func(path string, info os.FileInfo, err error) error {
+					require.NoError(t, err)
+					if path != "/dst" {
+						fileCount++
+					}
+					return nil
+				})
+				require.NoError(t, err)
+				require.Zero(t, fileCount, "directory should be empty")
+			},
+		},
+		{
+			name:    "Source does not exist",
+			srcPath: "/src/nonexistent",
+			dstPath: "/dst",
+			setupFS: func(t *testing.T, fs oswrapper.MemMapOSWrapper) {
+				require.NoError(t, fs.MkdirAll("/dst", 0777))
+			},
+			wantErr: true,
+		},
+		{
+			name:    "Source is a file",
+			srcPath: "/src/file.txt",
+			dstPath: "/dst",
+			setupFS: func(t *testing.T, fs oswrapper.MemMapOSWrapper) {
+				require.NoError(t, fs.WriteFile("/src/file.txt", []byte("i am a file"), 0666))
+				require.NoError(t, fs.MkdirAll("/dst", 0777))
+			},
+			wantErr: true,
+		},
+		{
+			name:    "Destination is a file",
+			srcPath: "/src/data",
+			dstPath: "/dst_is_a_file",
+			setupFS: func(t *testing.T, fs oswrapper.MemMapOSWrapper) {
+				require.NoError(t, fs.MkdirAll("/src/data", 0777))
+				require.NoError(t, fs.WriteFile("/dst_is_a_file", []byte("i am a file"), 0666))
+			},
+			wantErr: true,
+		},
+		{
+			name:    "Destination is a subdirectory of source",
+			srcPath: "/src",
+			dstPath: "/src/sub",
+			setupFS: func(t *testing.T, fs oswrapper.MemMapOSWrapper) {
+				require.NoError(t, fs.MkdirAll("/src/sub", 0777))
+			},
+			wantErr: true,
+		},
+	}
+
+	for _, tc := range tests {
+		t.Run(tc.name, func(t *testing.T) {
+			wrapper := oswrapper.CreateMemMapOSWrapper()
+			if tc.setupFS != nil {
+				tc.setupFS(t, wrapper)
+			}
+
+			err := fileutils.CopyDir(tc.dstPath, tc.srcPath, wrapper)
+
+			require.Equal(t, tc.wantErr, err != nil, "CopyDir() error = %v, wantErr %v", err, tc.wantErr)
+
+			if tc.verify != nil {
+				tc.verify(t, wrapper)
+			}
+		})
+	}
+}
diff --git a/tools/src/fileutils/fileutils_other.go b/tools/src/fileutils/fileutils_other.go
index 2742b74..13bb23f 100644
--- a/tools/src/fileutils/fileutils_other.go
+++ b/tools/src/fileutils/fileutils_other.go
@@ -32,14 +32,14 @@
 package fileutils
 
 import (
-	"os"
+	"dawn.googlesource.com/dawn/tools/src/oswrapper"
 )
 
 const ExeExt = ""
 
 // IsExe returns true if the file at path is an executable
-func IsExe(path string) bool {
-	s, err := os.Stat(path)
+func IsExe(path string, fsReader oswrapper.FilesystemReader) bool {
+	s, err := fsReader.Stat(path)
 	if err != nil {
 		return false
 	}
diff --git a/tools/src/fileutils/fileutils_windows.go b/tools/src/fileutils/fileutils_windows.go
index 7e897e9..5a6e2cd 100644
--- a/tools/src/fileutils/fileutils_windows.go
+++ b/tools/src/fileutils/fileutils_windows.go
@@ -28,13 +28,15 @@
 // Package fileutils contains utility functions for files
 package fileutils
 
-import "os"
+import (
+	"dawn.googlesource.com/dawn/tools/src/oswrapper"
+)
 
 const ExeExt = ".exe"
 
 // IsExe returns true if the file at path is an executable
-func IsExe(path string) bool {
-	if _, err := os.Stat(path); err != nil {
+func IsExe(path string, fsReader oswrapper.FilesystemReader) bool {
+	if _, err := fsReader.Stat(path); err != nil {
 		return false
 	}
 	return true
diff --git a/tools/src/fileutils/paths.go b/tools/src/fileutils/paths.go
index a3513a7..e0abd1d 100644
--- a/tools/src/fileutils/paths.go
+++ b/tools/src/fileutils/paths.go
@@ -29,7 +29,6 @@
 
 import (
 	"fmt"
-	"os"
 	"os/exec"
 	"path/filepath"
 	"runtime"
@@ -137,8 +136,8 @@
 }
 
 // IsDir returns true if the path resolves to a directory
-func IsDir(path string) bool {
-	s, err := os.Stat(path)
+func IsDir(path string, fsReader oswrapper.FilesystemReader) bool {
+	s, err := fsReader.Stat(path)
 	if err != nil {
 		return false
 	}
@@ -146,8 +145,8 @@
 }
 
 // IsFile returns true if the path resolves to a file
-func IsFile(path string) bool {
-	s, err := os.Stat(path)
+func IsFile(path string, fsReader oswrapper.FilesystemReader) bool {
+	s, err := fsReader.Stat(path)
 	if err != nil {
 		return false
 	}
diff --git a/tools/src/fileutils/paths_test.go b/tools/src/fileutils/paths_test.go
index c8bd072..f3df804 100644
--- a/tools/src/fileutils/paths_test.go
+++ b/tools/src/fileutils/paths_test.go
@@ -180,3 +180,105 @@
 		}
 	}
 }
+
+func TestIsDir(t *testing.T) {
+	tests := []struct {
+		name    string
+		path    string
+		setupFS func(fs oswrapper.MemMapOSWrapper) // Sets up the filesystem
+		want    bool
+	}{
+		{
+			name: "Is a directory",
+			path: "/a/b/c",
+			setupFS: func(fs oswrapper.MemMapOSWrapper) {
+				require.NoError(t, fs.MkdirAll("/a/b/c", 0777))
+			},
+			want: true,
+		},
+		{
+			name: "Is a file",
+			path: "/a/b/file.txt",
+			setupFS: func(fs oswrapper.MemMapOSWrapper) {
+				require.NoError(t, fs.MkdirAll("/a/b", 0777))
+				require.NoError(t, fs.WriteFile("/a/b/file.txt", []byte("hello"), 0666))
+			},
+			want: false,
+		},
+		{
+			name:    "Does not exist",
+			path:    "/a/b/c",
+			setupFS: nil,
+			want:    false,
+		},
+		{
+			name:    "Empty path",
+			path:    "",
+			setupFS: nil,
+			want:    false,
+		},
+	}
+
+	for _, tc := range tests {
+		t.Run(tc.name, func(t *testing.T) {
+			wrapper := oswrapper.CreateMemMapOSWrapper()
+			if tc.setupFS != nil {
+				tc.setupFS(wrapper)
+			}
+
+			got := fileutils.IsDir(tc.path, wrapper)
+			require.Equal(t, tc.want, got)
+		})
+	}
+}
+
+func TestIsFile(t *testing.T) {
+	tests := []struct {
+		name    string
+		path    string
+		setupFS func(fs oswrapper.MemMapOSWrapper) // Sets up the filesystem
+		want    bool
+	}{
+		{
+			name: "Is a file",
+			path: "/a/b/file.txt",
+			setupFS: func(fs oswrapper.MemMapOSWrapper) {
+				require.NoError(t, fs.MkdirAll("/a/b", 0777))
+				require.NoError(t, fs.WriteFile("/a/b/file.txt", []byte("hello"), 0666))
+			},
+			want: true,
+		},
+		{
+			name: "Is a directory",
+			path: "/a/b/c",
+			setupFS: func(fs oswrapper.MemMapOSWrapper) {
+				require.NoError(t, fs.MkdirAll("/a/b/c", 0777))
+			},
+			want: false,
+		},
+		{
+			name:    "Does not exist",
+			path:    "/a/b/c",
+			setupFS: nil,
+			want:    false,
+		},
+		{
+			name:    "Empty path",
+			path:    "",
+			setupFS: nil,
+			want:    false,
+		},
+	}
+
+	for _, tc := range tests {
+		t.Run(tc.name, func(t *testing.T) {
+			wrapper := oswrapper.CreateMemMapOSWrapper()
+			if tc.setupFS != nil {
+				tc.setupFS(wrapper)
+			}
+
+			got := fileutils.IsFile(tc.path, wrapper)
+			require.Equal(t, tc.want, got)
+		})
+	}
+}
diff --git a/tools/src/oswrapper/memmaposwrapper.go b/tools/src/oswrapper/memmaposwrapper.go
index dc01f4e..69c7593 100644
--- a/tools/src/oswrapper/memmaposwrapper.go
+++ b/tools/src/oswrapper/memmaposwrapper.go
@@ -123,6 +123,11 @@
 }
 
 func (m MemMapFilesystemReader) Stat(name string) (os.FileInfo, error) {
+	// KI with afero, https://github.com/spf13/afero/issues/522
+	if name == "" {
+		return nil, fmt.Errorf("no such file or directory")
+	}
+
 	return m.fs.Stat(name)
 }
 
diff --git a/tools/src/oswrapper/memmaposwrapper_test.go b/tools/src/oswrapper/memmaposwrapper_test.go
index 936a96f..a97baa5 100644
--- a/tools/src/oswrapper/memmaposwrapper_test.go
+++ b/tools/src/oswrapper/memmaposwrapper_test.go
@@ -146,6 +146,12 @@
 	require.ErrorContains(t, err, "open /foo.txt: file does not exist")
 }
 
+func TestStat_EmptyString(t *testing.T) {
+	wrapper := CreateMemMapOSWrapper()
+	_, err := wrapper.Stat("")
+	require.ErrorContains(t, err, "no such file or directory")
+}
+
 func TestWalk_Nonexistent(t *testing.T) {
 	wrapper := CreateMemMapOSWrapper()