diff --git a/cmd/wazero/wazero.go b/cmd/wazero/wazero.go index e7eaf903..3dce1d2b 100644 --- a/cmd/wazero/wazero.go +++ b/cmd/wazero/wazero.go @@ -16,7 +16,6 @@ import ( "github.com/tetratelabs/wazero/experimental/logging" gojs "github.com/tetratelabs/wazero/imports/go" "github.com/tetratelabs/wazero/imports/wasi_snapshot_preview1" - "github.com/tetratelabs/wazero/internal/sysfs" "github.com/tetratelabs/wazero/internal/version" "github.com/tetratelabs/wazero/sys" ) @@ -161,7 +160,7 @@ func doRun(args []string, stdOut io.Writer, stdErr logging.Writer, exit func(cod env = append(env, fields[0], fields[1]) } - rootFS := validateMounts(mounts, stdErr, exit) + fsConfig := validateMounts(mounts, stdErr, exit) wasm, err := os.ReadFile(wasmPath) if err != nil { @@ -188,6 +187,7 @@ func doRun(args []string, stdOut io.Writer, stdErr logging.Writer, exit func(cod WithStderr(stdErr). WithStdin(os.Stdin). WithRandSource(rand.Reader). + WithFSConfig(fsConfig). WithSysNanosleep(). WithSysNanotime(). WithSysWalltime(). @@ -195,9 +195,6 @@ func doRun(args []string, stdOut io.Writer, stdErr logging.Writer, exit func(cod for i := 0; i < len(env); i += 2 { conf = conf.WithEnv(env[i], env[i+1]) } - if rootFS != nil { - conf = conf.WithFS(&sysfs.FSHolder{FS: rootFS}) - } code, err := rt.CompileModule(ctx, wasm) if err != nil { @@ -227,9 +224,8 @@ func doRun(args []string, stdOut io.Writer, stdErr logging.Writer, exit func(cod exit(0) } -func validateMounts(mounts sliceFlag, stdErr logging.Writer, exit func(code int)) sysfs.FS { - fs := make([]sysfs.FS, 0, len(mounts)) - guestPaths := make([]string, 0, len(mounts)) +func validateMounts(mounts sliceFlag, stdErr logging.Writer, exit func(code int)) (config wazero.FSConfig) { + config = wazero.NewFSConfig() for _, mount := range mounts { if len(mount) == 0 { fmt.Fprintln(stdErr, "invalid mount: empty string") @@ -243,48 +239,41 @@ func validateMounts(mounts sliceFlag, stdErr logging.Writer, exit func(code int) } // TODO(anuraaga): Support wasm paths with colon in them. - var host, guest string + var dir, guestPath string if clnIdx := strings.LastIndexByte(mount, ':'); clnIdx != -1 { - host, guest = mount[:clnIdx], mount[clnIdx+1:] + dir, guestPath = mount[:clnIdx], mount[clnIdx+1:] } else { - host = mount - guest = host + dir = mount + guestPath = dir } // Provide a better experience if duplicates are found later. - if guest == "" { - guest = "/" + if guestPath == "" { + guestPath = "/" } // Eagerly validate the mounts as we know they should be on the host. - if abs, err := filepath.Abs(host); err != nil { - fmt.Fprintf(stdErr, "invalid mount: path %q invalid: %v\n", host, err) + if abs, err := filepath.Abs(dir); err != nil { + fmt.Fprintf(stdErr, "invalid mount: path %q invalid: %v\n", dir, err) exit(1) } else { - host = abs + dir = abs } - if stat, err := os.Stat(host); err != nil { - fmt.Fprintf(stdErr, "invalid mount: path %q error: %v\n", host, err) + if stat, err := os.Stat(dir); err != nil { + fmt.Fprintf(stdErr, "invalid mount: path %q error: %v\n", dir, err) exit(1) } else if !stat.IsDir() { - fmt.Fprintf(stdErr, "invalid mount: path %q is not a directory\n", host) + fmt.Fprintf(stdErr, "invalid mount: path %q is not a directory\n", dir) } - next := sysfs.NewDirFS(host) if readOnly { - next = sysfs.NewReadFS(next) + config = config.WithReadOnlyDirMount(dir, guestPath) + } else { + config = config.WithDirMount(dir, guestPath) } - fs = append(fs, next) - guestPaths = append(guestPaths, guest) - } - if fs, err := sysfs.NewRootFS(fs, guestPaths); err != nil { - fmt.Fprintf(stdErr, "invalid mounts %v: %v\n", fs, err) - exit(1) - return nil - } else { - return fs } + return } func detectImports(imports []api.FunctionDefinition) (needsWASI, needsGo bool) { diff --git a/config.go b/config.go index bf0143fa..8e648848 100644 --- a/config.go +++ b/config.go @@ -15,6 +15,7 @@ import ( "github.com/tetratelabs/wazero/internal/filecache" "github.com/tetratelabs/wazero/internal/platform" internalsys "github.com/tetratelabs/wazero/internal/sys" + "github.com/tetratelabs/wazero/internal/sysfs" "github.com/tetratelabs/wazero/internal/wasm" "github.com/tetratelabs/wazero/sys" ) @@ -413,38 +414,18 @@ type ModuleConfig interface { // See https://linux.die.net/man/3/environ and https://en.wikipedia.org/wiki/Null-terminated_string WithEnv(key, value string) ModuleConfig - // WithFS assigns the file system to use for any paths beginning at "/". - // Defaults return fs.ErrNotExist. - // - // This example sets a read-only, embedded file-system: - // - // //go:embed testdata/index.html - // var testdataIndex embed.FS - // - // rooted, err := fs.Sub(testdataIndex, "testdata") - // require.NoError(t, err) - // - // // "index.html" is accessible as "/index.html". - // config := wazero.NewModuleConfig().WithFS(rooted) - // - // This example sets a mutable file-system: - // - // // Files relative to "/work/appA" are accessible as "/". - // config := wazero.NewModuleConfig().WithFS(os.DirFS("/work/appA")) - // - // Isolation - // - // os.DirFS documentation includes important notes about isolation, which - // also applies to fs.Sub. As of Go 1.19, the built-in file-systems are not - // jailed (chroot). See https://github.com/golang/go/issues/42322 - // - // Working Directory "." - // - // Relative path resolution, such as "./config.yml" to "/config.yml" or - // otherwise, is compiler-specific. See /RATIONALE.md for notes. + // WithFS is a convenience that calls WithFSConfig with an FSConfig of the + // input for the root ("/") guest path. WithFS(fs.FS) ModuleConfig - // WithName configures the module name. Defaults to what was decoded from the name section. + // WithFSConfig configures the filesystem available to each guest + // instantiated with this configuration. By default, no file access is + // allowed, so functions like `path_open` result in unsupported errors + // (e.g. syscall.ENOSYS). + WithFSConfig(FSConfig) ModuleConfig + + // WithName configures the module name. Defaults to what was decoded from + // the name section. WithName(string) ModuleConfig // WithStartFunctions configures the functions to call after the module is @@ -593,8 +574,8 @@ type moduleConfig struct { environ [][]byte // environKeys allow overwriting of existing values. environKeys map[string]int - // fs is the file system to open files with - fs fs.FS + // fsConfig is the file system configuration for ABI like WASI. + fsConfig FSConfig } // NewModuleConfig returns a ModuleConfig that can be used for configuring module instantiation. @@ -648,8 +629,13 @@ func (c *moduleConfig) WithEnv(key, value string) ModuleConfig { // WithFS implements ModuleConfig.WithFS func (c *moduleConfig) WithFS(fs fs.FS) ModuleConfig { + return c.WithFSConfig(NewFSConfig().WithFSMount(fs, "")) +} + +// WithFSConfig implements ModuleConfig.WithFSConfig +func (c *moduleConfig) WithFSConfig(config FSConfig) ModuleConfig { ret := c.clone() - ret.fs = fs + ret.fsConfig = config return ret } @@ -764,6 +750,13 @@ func (c *moduleConfig) toSysContext() (sysCtx *internalsys.Context, err error) { environ = append(environ, result) } + var fs sysfs.FS + if f, ok := c.fsConfig.(*fsConfig); ok { + if fs, err = f.toFS(); err != nil { + return + } + } + return internalsys.NewContext( math.MaxUint32, c.args, @@ -775,6 +768,6 @@ func (c *moduleConfig) toSysContext() (sysCtx *internalsys.Context, err error) { c.walltime, c.walltimeResolution, c.nanotime, c.nanotimeResolution, c.nanosleep, - c.fs, + fs, ) } diff --git a/config_test.go b/config_test.go index b5fa17df..1f216ab4 100644 --- a/config_test.go +++ b/config_test.go @@ -4,7 +4,6 @@ import ( "context" "crypto/rand" "io" - "io/fs" "math" "testing" @@ -12,6 +11,7 @@ import ( "github.com/tetratelabs/wazero/internal/fstest" "github.com/tetratelabs/wazero/internal/platform" internalsys "github.com/tetratelabs/wazero/internal/sys" + "github.com/tetratelabs/wazero/internal/sysfs" testfs "github.com/tetratelabs/wazero/internal/testing/fs" "github.com/tetratelabs/wazero/internal/testing/require" "github.com/tetratelabs/wazero/internal/wasm" @@ -323,7 +323,7 @@ func TestModuleConfig_toSysContext(t *testing.T) { &wt, 1, // walltime, walltimeResolution &nt, 1, // nanotime, nanotimeResolution nil, // nanosleep - testFS, + sysfs.Adapt(testFS), ), }, { @@ -339,8 +339,8 @@ func TestModuleConfig_toSysContext(t *testing.T) { nil, // randSource &wt, 1, // walltime, walltimeResolution &nt, 1, // nanotime, nanotimeResolution - nil, // nanosleep - testFS2, // fs + nil, // nanosleep + sysfs.Adapt(testFS2), // fs ), }, { @@ -563,7 +563,7 @@ func TestModuleConfig_clone(t *testing.T) { cloned := mc.clone() // Make post-clone changes - mc.fs = fstest.FS + mc.fsConfig = NewFSConfig().WithFSMount(fstest.FS, "/") mc.environKeys["2"] = 2 cloned.environKeys["1"] = 1 @@ -573,7 +573,7 @@ func TestModuleConfig_clone(t *testing.T) { require.Equal(t, map[string]int{"1": 1}, cloned.environKeys) // Ensure the fs is not shared - require.Nil(t, cloned.fs) + require.Nil(t, cloned.fsConfig) } func Test_compiledModule_Name(t *testing.T) { @@ -697,7 +697,7 @@ func requireSysContext( walltime *sys.Walltime, walltimeResolution sys.ClockResolution, nanotime *sys.Nanotime, nanotimeResolution sys.ClockResolution, nanosleep *sys.Nanosleep, - fs fs.FS, + fs sysfs.FS, ) *internalsys.Context { sysCtx, err := internalsys.NewContext( max, diff --git a/experimental/writefs/writefs.go b/experimental/writefs/writefs.go deleted file mode 100644 index 289ce14d..00000000 --- a/experimental/writefs/writefs.go +++ /dev/null @@ -1,40 +0,0 @@ -// Package writefs includes wazero-specific fs.FS implementations that allow -// creation and deletion of files and directories. -// -// This is a work-in-progress and a workaround needed because write support is -// not yet supported in fs.FS. See https://github.com/golang/go/issues/45757 -// -// Tracking issue: https://github.com/tetratelabs/wazero/issues/390 -package writefs - -import ( - "io/fs" - - "github.com/tetratelabs/wazero/internal/sysfs" -) - -// NewDirFS creates a writeable filesystem at the given path on the host -// filesystem. -// -// This is like os.DirFS, but allows creation and deletion of files and -// directories, as well as timestamp modifications. None of which are supported -// in fs.FS. -// -// The following errors are expected: -// - syscall.EINVAL: `dir` is invalid. -// - syscall.ENOENT: `dir` doesn't exist. -// - syscall.ENOTDIR: `dir` exists, but is not a directory. -// -// # Isolation -// -// Symbolic links can escape the root path as files are opened via os.OpenFile -// which cannot restrict following them. -// -// # This is wazero-only -// -// Do not attempt to use the result as a fs.FS, as it will panic. This is a -// bridge to a future filesystem abstraction made for wazero. -func NewDirFS(dir string) fs.FS { - // sysfs.DirFS is intentionally internal as it is still evolving - return &sysfs.FSHolder{FS: sysfs.NewDirFS(dir)} -} diff --git a/experimental/writefs/writefs_example_test.go b/experimental/writefs/writefs_example_test.go deleted file mode 100644 index 2d73d2df..00000000 --- a/experimental/writefs/writefs_example_test.go +++ /dev/null @@ -1,17 +0,0 @@ -package writefs_test - -import ( - _ "embed" - - "github.com/tetratelabs/wazero" - "github.com/tetratelabs/wazero/experimental/writefs" -) - -var config wazero.ModuleConfig //nolint - -// This shows how to use writefs.NewDirFS to map paths relative to "/work/appA", -// as "/". Unlike os.DirFS, these paths will be writable. -func Example_dirFS() { - fs := writefs.NewDirFS("/work/appA") - config = wazero.NewModuleConfig().WithFS(fs) -} diff --git a/fsconfig.go b/fsconfig.go new file mode 100644 index 00000000..d2eedcb8 --- /dev/null +++ b/fsconfig.go @@ -0,0 +1,165 @@ +package wazero + +import ( + "io/fs" + + "github.com/tetratelabs/wazero/internal/sysfs" +) + +// FSConfig configures filesystem paths the embedding host allows the wasm +// guest to access. Unconfigured paths are not allowed, so functions like +// `path_open` result in unsupported errors (e.g. syscall.ENOSYS). +// +// # Guest Path +// +// `guestPath` is the name of the path the guest should use a filesystem for, or +// empty for any files. +// +// All `guestPath` paths are normalized, specifically removing any leading or +// trailing slashes. This means "/", "./" or "." all coerce to empty "". +// +// Multiple `guestPath` values can be configured, but the last longest match +// wins. For example, if "tmp", then "" were added, a request to open +// "tmp/foo.txt" use the filesystem associated with "tmp" even though a wider +// path, "" (all files), was added later. +// +// A `guestPath` of "." coerces to the empty string "" because the current +// directory is handled by the guest. In other words, the guest resolves ites +// current directory prior to requesting files. +// +// More notes on `guestPath` +// - Go compiled with runtime.GOOS=js do not pay attention to this value. +// Hence, you need to normalize the filesystem with NewRootFS to ensure +// paths requested resolve as expected. +// - Working directories are typically tracked in wasm, though possible some +// relative paths are requested. For example, TinyGo may attempt to resolve +// a path "../.." in unit tests. +// - Zig uses the first path name it sees as the initial working directory of +// the process. +// +// # Scope +// +// Configuration here is module instance scoped. This means you can use the +// same configuration for multiple calls to Runtime.InstantiateModule. Each +// module will have a different file descriptor table. Any errors accessing +// resources allowed here are deferred to instantiation time of each module. +// +// Any host resources present at the time of configuration, but deleted before +// Runtime.InstantiateModule will trap/panic when the guest wasm initializes or +// calls functions like `fd_read`. +// +// # Windows +// +// While wazero supports Windows as a platform, all known compilers use POSIX +// conventions at runtime. For example, even when running on Windows, paths +// used by wasm are separated by forward slash (/), not backslash (\). +// +// # Notes +// +// - FSConfig is immutable. Each WithXXX function returns a new instance +// including the corresponding change. +// - RATIONALE.md includes design background and relationship to WebAssembly +// System Interfaces (WASI). +type FSConfig interface { + // WithDirMount assigns a directory at `dir` to any paths beginning at + // `guestPath`. + // + // If the same `guestPath` was assigned before, this overrides its value, + // retaining the original precedence. See the documentation of FSConfig for + // more details on `guestPath`. + // + // # Isolation + // + // The guest will have full access to this directory including escaping it + // via relative path lookups like "../../". Full access includes operations + // such as creating or deleting files, limited to any host level access + // controls. + WithDirMount(dir, guestPath string) FSConfig + + // WithReadOnlyDirMount assigns a directory at `dir` to any paths + // beginning at `guestPath`. + // + // This is the same as WithDirMount except only read operations are + // permitted. However, escaping the directory via relative path lookups + // like "../../" is still allowed. + WithReadOnlyDirMount(dir, guestPath string) FSConfig + + // WithFSMount assigns a fs.FS file system for any paths beginning at + // `guestPath`. + // + // If the same `guestPath` was assigned before, this overrides its value, + // retaining the original precedence. See the documentation of FSConfig for + // more details on `guestPath`. + // + // # Isolation + // + // fs.FS does not restrict the ability to overwrite returned files via + // io.Writer. Moreover, os.DirFS documentation includes important notes + // about isolation, which also applies to fs.Sub. As of Go 1.19, the + // built-in file-systems are not jailed (chroot). See + // https://github.com/golang/go/issues/42322 + WithFSMount(fs fs.FS, guestPath string) FSConfig +} + +type fsConfig struct { + // fs are the currently configured filesystems. + fs []sysfs.FS + // guestPaths are the user-supplied names of the filesystems, retained for + // error messages and fmt.Stringer. + guestPaths []string + // guestPathToFS are the normalized paths to the currently configured + // filesystems, used for de-duplicating. + guestPathToFS map[string]int +} + +// NewFSConfig returns a FSConfig that can be used for configuring module instantiation. +func NewFSConfig() FSConfig { + return &fsConfig{guestPathToFS: map[string]int{}} +} + +// clone makes a deep copy of this module config. +func (c *fsConfig) clone() *fsConfig { + ret := *c // copy except slice and maps which share a ref + ret.fs = make([]sysfs.FS, 0, len(c.fs)) + ret.fs = append(ret.fs, c.fs...) + ret.guestPaths = make([]string, 0, len(c.guestPaths)) + ret.guestPaths = append(ret.guestPaths, c.guestPaths...) + ret.guestPathToFS = make(map[string]int, len(c.guestPathToFS)) + for key, value := range c.guestPathToFS { + ret.guestPathToFS[key] = value + } + return &ret +} + +// WithDirMount implements FSConfig.WithDirMount +func (c *fsConfig) WithDirMount(dir, guestPath string) FSConfig { + return c.withMount(sysfs.NewDirFS(dir), guestPath) +} + +// WithReadOnlyDirMount implements FSConfig.WithReadOnlyDirMount +func (c *fsConfig) WithReadOnlyDirMount(dir, guestPath string) FSConfig { + return c.withMount(sysfs.NewReadFS(sysfs.NewDirFS(dir)), guestPath) +} + +// WithFSMount implements FSConfig.WithFSMount +func (c *fsConfig) WithFSMount(fs fs.FS, guestPath string) FSConfig { + return c.withMount(sysfs.Adapt(fs), guestPath) +} + +func (c *fsConfig) withMount(fs sysfs.FS, guestPath string) FSConfig { + cleaned := sysfs.StripPrefixesAndTrailingSlash(guestPath) + ret := c.clone() + if i, ok := ret.guestPathToFS[cleaned]; ok { + ret.fs[i] = fs + ret.guestPaths[i] = guestPath + } else { + ret.guestPathToFS[cleaned] = len(ret.fs) + ret.fs = append(ret.fs, fs) + ret.guestPaths = append(ret.guestPaths, guestPath) + } + return ret +} + +func (c *fsConfig) toFS() (sysfs.FS, error) { + return sysfs.NewRootFS(c.fs, c.guestPaths) +} diff --git a/fsconfig_example_test.go b/fsconfig_example_test.go new file mode 100644 index 00000000..1993421e --- /dev/null +++ b/fsconfig_example_test.go @@ -0,0 +1,27 @@ +package wazero_test + +import ( + "embed" + "io/fs" + "log" + + "github.com/tetratelabs/wazero" +) + +//go:embed testdata/index.html +var testdataIndex embed.FS + +var moduleConfig wazero.ModuleConfig + +// This example shows how to configure an embed.FS. +func Example_withFSConfig_embedFS() { + // Strip the embedded path testdata/ + rooted, err := fs.Sub(testdataIndex, "testdata") + if err != nil { + log.Panicln(err) + } + + moduleConfig = wazero.NewModuleConfig(). + // Make "index.html" accessible to the guest as "/index.html". + WithFSConfig(wazero.NewFSConfig().WithFSMount(rooted, "/")) +} diff --git a/fsconfig_test.go b/fsconfig_test.go new file mode 100644 index 00000000..144b1f87 --- /dev/null +++ b/fsconfig_test.go @@ -0,0 +1,106 @@ +package wazero + +import ( + "testing" + + "github.com/tetratelabs/wazero/internal/sysfs" + testfs "github.com/tetratelabs/wazero/internal/testing/fs" + "github.com/tetratelabs/wazero/internal/testing/require" +) + +// TestFSConfig only tests the cases that change the inputs to sysfs.NewRootFS. +func TestFSConfig(t *testing.T) { + base := NewFSConfig() + + testFS := testfs.FS{} + testFS2 := testfs.FS{"/": &testfs.File{}} + + tests := []struct { + name string + input FSConfig + expected sysfs.FS + }{ + { + name: "empty", + input: base, + expected: sysfs.UnimplementedFS{}, + }, + { + name: "WithFSMount", + input: base.WithFSMount(testFS, "/"), + expected: sysfs.Adapt(testFS), + }, + { + name: "WithFSMount overwrites", + input: base.WithFSMount(testFS, "/").WithFSMount(testFS2, "/"), + expected: sysfs.Adapt(testFS2), + }, + { + name: "WithDirMount overwrites", + input: base.WithFSMount(testFS, "/").WithDirMount(".", "/"), + expected: sysfs.NewDirFS("."), + }, + { + name: "Composition", + input: base.WithReadOnlyDirMount(".", "/").WithDirMount("/tmp", "/tmp"), + expected: func() sysfs.FS { + f, err := sysfs.NewRootFS( + []sysfs.FS{sysfs.NewReadFS(sysfs.NewDirFS(".")), sysfs.NewDirFS("/tmp")}, + []string{"/", "/tmp"}, + ) + require.NoError(t, err) + return f + }(), + }, + } + + for _, tt := range tests { + tc := tt + + t.Run(tc.name, func(t *testing.T) { + sysCtx, err := tc.input.(*fsConfig).toFS() + require.NoError(t, err) + require.Equal(t, tc.expected, sysCtx) + }) + } +} + +func TestFSConfig_Errors(t *testing.T) { + tests := []struct { + name string + input FSConfig + expectedErr string + }{ + { + name: "multi-level path not yet supported", + input: NewFSConfig().WithDirMount(".", "/usr/bin"), + expectedErr: "only single-level guest paths allowed: [.:/usr/bin]", + }, + } + for _, tt := range tests { + tc := tt + + t.Run(tc.name, func(t *testing.T) { + _, err := tc.input.(*fsConfig).toFS() + require.EqualError(t, err, tc.expectedErr) + }) + } +} + +func TestFSConfig_clone(t *testing.T) { + fc := NewFSConfig().(*fsConfig) + fc.guestPathToFS["/"] = 0 + + cloned := fc.clone() + + // Make post-clone changes + fc.guestPaths = []string{"/"} + fc.guestPathToFS["/"] = 1 + + // Ensure the guestPathToFS map is not shared + require.Equal(t, map[string]int{"/": 1}, fc.guestPathToFS) + require.Equal(t, map[string]int{"/": 0}, cloned.guestPathToFS) + + // Ensure the guestPaths slice is not shared + require.Zero(t, len(cloned.guestPaths)) +} diff --git a/imports/wasi_snapshot_preview1/fs_test.go b/imports/wasi_snapshot_preview1/fs_test.go index 93f68c30..c8d673ba 100644 --- a/imports/wasi_snapshot_preview1/fs_test.go +++ b/imports/wasi_snapshot_preview1/fs_test.go @@ -14,7 +14,6 @@ import ( "github.com/tetratelabs/wazero" "github.com/tetratelabs/wazero/api" - "github.com/tetratelabs/wazero/experimental/writefs" "github.com/tetratelabs/wazero/internal/fstest" "github.com/tetratelabs/wazero/internal/leb128" "github.com/tetratelabs/wazero/internal/sys" @@ -720,9 +719,8 @@ func Test_fdPread_Errors(t *testing.T) { } func Test_fdPrestatGet(t *testing.T) { - testFS := writefs.NewDirFS(t.TempDir()) - - mod, r, log := requireProxyModule(t, wazero.NewModuleConfig().WithFS(testFS)) + fsConfig := wazero.NewFSConfig().WithDirMount(t.TempDir(), "/") + mod, r, log := requireProxyModule(t, wazero.NewModuleConfig().WithFSConfig(fsConfig)) defer r.Close(testCtx) dirFD := sys.FdPreopen @@ -806,9 +804,8 @@ func Test_fdPrestatGet_Errors(t *testing.T) { } func Test_fdPrestatDirName(t *testing.T) { - testFS := writefs.NewDirFS(t.TempDir()) - - mod, r, log := requireProxyModule(t, wazero.NewModuleConfig().WithFS(testFS)) + fsConfig := wazero.NewFSConfig().WithDirMount(t.TempDir(), "/") + mod, r, log := requireProxyModule(t, wazero.NewModuleConfig().WithFSConfig(fsConfig)) defer r.Close(testCtx) dirFD := sys.FdPreopen @@ -2282,9 +2279,8 @@ func Test_fdWrite_Errors(t *testing.T) { func Test_pathCreateDirectory(t *testing.T) { tmpDir := t.TempDir() // open before loop to ensure no locking problems. - testFS := writefs.NewDirFS(tmpDir) - - mod, r, log := requireProxyModule(t, wazero.NewModuleConfig().WithFS(testFS)) + fsConfig := wazero.NewFSConfig().WithDirMount(tmpDir, "/") + mod, r, log := requireProxyModule(t, wazero.NewModuleConfig().WithFSConfig(fsConfig)) defer r.Close(testCtx) // set up the initial memory to include the path name starting at an offset. @@ -2312,9 +2308,8 @@ func Test_pathCreateDirectory(t *testing.T) { func Test_pathCreateDirectory_Errors(t *testing.T) { tmpDir := t.TempDir() // open before loop to ensure no locking problems. - testFS := writefs.NewDirFS(tmpDir) - - mod, r, log := requireProxyModule(t, wazero.NewModuleConfig().WithFS(testFS)) + fsConfig := wazero.NewFSConfig().WithDirMount(tmpDir, "/") + mod, r, log := requireProxyModule(t, wazero.NewModuleConfig().WithFSConfig(fsConfig)) defer r.Close(testCtx) file := "file" @@ -2820,7 +2815,7 @@ func Test_pathOpen(t *testing.T) { t.Run(tc.name, func(t *testing.T) { mod, r, log := requireProxyModule(t, wazero.NewModuleConfig(). - WithFS(&sysfs.FSHolder{FS: tc.fs})) + WithFS(tc.fs.(fs.FS))) // built-in impls reverse-implement fs.FS defer r.Close(testCtx) pathName := tc.path(t) mod.Memory().Write(0, []byte(pathName)) @@ -2890,9 +2885,8 @@ func writeFile(t *testing.T, tmpDir, file string, contents []byte) { func Test_pathOpen_Errors(t *testing.T) { tmpDir := t.TempDir() // open before loop to ensure no locking problems. - testFS := writefs.NewDirFS(tmpDir) - - mod, r, log := requireProxyModule(t, wazero.NewModuleConfig().WithFS(testFS)) + fsConfig := wazero.NewFSConfig().WithDirMount(tmpDir, "/") + mod, r, log := requireProxyModule(t, wazero.NewModuleConfig().WithFSConfig(fsConfig)) defer r.Close(testCtx) preopenedFD := sys.FdPreopen @@ -3033,9 +3027,8 @@ func Test_pathReadlink(t *testing.T) { func Test_pathRemoveDirectory(t *testing.T) { tmpDir := t.TempDir() // open before loop to ensure no locking problems. - testFS := writefs.NewDirFS(tmpDir) - - mod, r, log := requireProxyModule(t, wazero.NewModuleConfig().WithFS(testFS)) + fsConfig := wazero.NewFSConfig().WithDirMount(tmpDir, "/") + mod, r, log := requireProxyModule(t, wazero.NewModuleConfig().WithFSConfig(fsConfig)) defer r.Close(testCtx) // set up the initial memory to include the path name starting at an offset. @@ -3065,9 +3058,8 @@ func Test_pathRemoveDirectory(t *testing.T) { func Test_pathRemoveDirectory_Errors(t *testing.T) { tmpDir := t.TempDir() // open before loop to ensure no locking problems. - testFS := writefs.NewDirFS(tmpDir) - - mod, r, log := requireProxyModule(t, wazero.NewModuleConfig().WithFS(testFS)) + fsConfig := wazero.NewFSConfig().WithDirMount(tmpDir, "/") + mod, r, log := requireProxyModule(t, wazero.NewModuleConfig().WithFSConfig(fsConfig)) defer r.Close(testCtx) file := "file" @@ -3202,9 +3194,8 @@ func Test_pathSymlink(t *testing.T) { func Test_pathRename(t *testing.T) { tmpDir := t.TempDir() // open before loop to ensure no locking problems. - testFS := writefs.NewDirFS(tmpDir) - - mod, r, log := requireProxyModule(t, wazero.NewModuleConfig().WithFS(testFS)) + fsConfig := wazero.NewFSConfig().WithDirMount(tmpDir, "/") + mod, r, log := requireProxyModule(t, wazero.NewModuleConfig().WithFSConfig(fsConfig)) defer r.Close(testCtx) // set up the initial memory to include the old path name starting at an offset. @@ -3245,9 +3236,8 @@ func Test_pathRename(t *testing.T) { func Test_pathRename_Errors(t *testing.T) { tmpDir := t.TempDir() // open before loop to ensure no locking problems. - testFS := writefs.NewDirFS(tmpDir) - - mod, r, log := requireProxyModule(t, wazero.NewModuleConfig().WithFS(testFS)) + fsConfig := wazero.NewFSConfig().WithDirMount(tmpDir, "/") + mod, r, log := requireProxyModule(t, wazero.NewModuleConfig().WithFSConfig(fsConfig)) defer r.Close(testCtx) file := "file" @@ -3420,9 +3410,8 @@ func Test_pathRename_Errors(t *testing.T) { func Test_pathUnlinkFile(t *testing.T) { tmpDir := t.TempDir() // open before loop to ensure no locking problems. - testFS := writefs.NewDirFS(tmpDir) - - mod, r, log := requireProxyModule(t, wazero.NewModuleConfig().WithFS(testFS)) + fsConfig := wazero.NewFSConfig().WithDirMount(tmpDir, "/") + mod, r, log := requireProxyModule(t, wazero.NewModuleConfig().WithFSConfig(fsConfig)) defer r.Close(testCtx) // set up the initial memory to include the path name starting at an offset. @@ -3452,9 +3441,8 @@ func Test_pathUnlinkFile(t *testing.T) { func Test_pathUnlinkFile_Errors(t *testing.T) { tmpDir := t.TempDir() // open before loop to ensure no locking problems. - testFS := writefs.NewDirFS(tmpDir) - - mod, r, log := requireProxyModule(t, wazero.NewModuleConfig().WithFS(testFS)) + fsConfig := wazero.NewFSConfig().WithDirMount(tmpDir, "/") + mod, r, log := requireProxyModule(t, wazero.NewModuleConfig().WithFSConfig(fsConfig)) defer r.Close(testCtx) file := "file" @@ -3562,15 +3550,16 @@ func requireOpenFile(t *testing.T, tmpDir string, pathName string, data []byte, require.NoError(t, os.WriteFile(realPath, data, 0o600)) } - testFS := sysfs.NewDirFS(tmpDir) + fsConfig := wazero.NewFSConfig() if readOnly { oflags = os.O_RDONLY - testFS = sysfs.NewReadFS(testFS) + fsConfig = fsConfig.WithReadOnlyDirMount(tmpDir, "/") + } else { + fsConfig = fsConfig.WithDirMount(tmpDir, "/") } - mod, r, log := requireProxyModule(t, wazero.NewModuleConfig(). - WithFS(&sysfs.FSHolder{FS: testFS})) + mod, r, log := requireProxyModule(t, wazero.NewModuleConfig().WithFSConfig(fsConfig)) fsc := mod.(*wasm.CallContext).Sys.FS() fd, err := fsc.OpenFile(pathName, oflags, 0) diff --git a/internal/gojs/fs_test.go b/internal/gojs/fs_test.go index 61ef81a2..6ab65733 100644 --- a/internal/gojs/fs_test.go +++ b/internal/gojs/fs_test.go @@ -6,7 +6,6 @@ import ( "testing" "github.com/tetratelabs/wazero" - "github.com/tetratelabs/wazero/experimental/writefs" "github.com/tetratelabs/wazero/internal/fstest" "github.com/tetratelabs/wazero/internal/platform" "github.com/tetratelabs/wazero/internal/testing/require" @@ -44,9 +43,8 @@ func Test_testfs(t *testing.T) { require.NoError(t, os.Mkdir(testfsDir, 0o700)) require.NoError(t, fstest.WriteTestFiles(testfsDir)) - testFS := writefs.NewDirFS(tmpDir) - - stdout, stderr, err := compileAndRun(testCtx, "testfs", wazero.NewModuleConfig().WithFS(testFS)) + fsConfig := wazero.NewFSConfig().WithDirMount(tmpDir, "/") + stdout, stderr, err := compileAndRun(testCtx, "testfs", wazero.NewModuleConfig().WithFSConfig(fsConfig)) require.Zero(t, stderr) require.EqualError(t, err, `module "" closed with exit_code(0)`) @@ -56,12 +54,12 @@ func Test_testfs(t *testing.T) { func Test_writefs(t *testing.T) { t.Parallel() tmpDir := t.TempDir() - testFS := writefs.NewDirFS(tmpDir) + fsConfig := wazero.NewFSConfig().WithDirMount(tmpDir, "/") // test expects to write under /tmp require.NoError(t, os.Mkdir(path.Join(tmpDir, "tmp"), 0o700)) - stdout, stderr, err := compileAndRun(testCtx, "writefs", wazero.NewModuleConfig().WithFS(testFS)) + stdout, stderr, err := compileAndRun(testCtx, "writefs", wazero.NewModuleConfig().WithFSConfig(fsConfig)) require.Zero(t, stderr) require.EqualError(t, err, `module "" closed with exit_code(0)`) diff --git a/internal/sys/sys.go b/internal/sys/sys.go index 052deb0d..0399c25f 100644 --- a/internal/sys/sys.go +++ b/internal/sys/sys.go @@ -4,7 +4,6 @@ import ( "errors" "fmt" "io" - "io/fs" "time" "github.com/tetratelabs/wazero/internal/platform" @@ -109,7 +108,7 @@ func (eofReader) Read([]byte) (int, error) { } // DefaultContext returns Context with no values set except a possible nil fs.FS -func DefaultContext(fs fs.FS) *Context { +func DefaultContext(fs sysfs.FS) *Context { if sysCtx, err := NewContext(0, nil, nil, nil, nil, nil, nil, nil, 0, nil, 0, nil, fs); err != nil { panic(fmt.Errorf("BUG: DefaultContext should never error: %w", err)) } else { @@ -135,7 +134,7 @@ func NewContext( nanotime *sys.Nanotime, nanotimeResolution sys.ClockResolution, nanosleep *sys.Nanosleep, - fs fs.FS, + fs sysfs.FS, ) (sysCtx *Context, err error) { sysCtx = &Context{args: args, environ: environ} @@ -182,7 +181,7 @@ func NewContext( } if fs != nil { - sysCtx.fsc, err = NewFSContext(stdin, stdout, stderr, sysfs.Adapt(fs)) + sysCtx.fsc, err = NewFSContext(stdin, stdout, stderr, fs) } else { sysCtx.fsc, err = NewFSContext(stdin, stdout, stderr, sysfs.UnimplementedFS{}) } diff --git a/internal/sys/sys_test.go b/internal/sys/sys_test.go index d26b9d96..fa2fac0a 100644 --- a/internal/sys/sys_test.go +++ b/internal/sys/sys_test.go @@ -22,6 +22,8 @@ func TestContext_FS(t *testing.T) { } func TestDefaultSysContext(t *testing.T) { + testFS := sysfs.Adapt(testfs.FS{}) + sysCtx, err := NewContext( 0, // max nil, // args @@ -32,8 +34,8 @@ func TestDefaultSysContext(t *testing.T) { nil, // randSource nil, 0, // walltime, walltimeResolution nil, 0, // nanotime, nanotimeResolution - nil, // nanosleep - testfs.FS{}, // fs + nil, // nanosleep + testFS, // fs ) require.NoError(t, err) @@ -51,7 +53,6 @@ func TestDefaultSysContext(t *testing.T) { require.Equal(t, &ns, sysCtx.nanosleep) require.Equal(t, platform.NewFakeRandSource(), sysCtx.RandSource()) - testFS := sysfs.Adapt(testfs.FS{}) expectedFS, _ := NewFSContext(nil, nil, nil, testFS) expectedOpenedFiles := FileTable{} diff --git a/internal/sysfs/adapter.go b/internal/sysfs/adapter.go index 18630d0a..28f52466 100644 --- a/internal/sysfs/adapter.go +++ b/internal/sysfs/adapter.go @@ -5,6 +5,8 @@ import ( "io/fs" "os" pathutil "path" + "runtime" + "strings" ) // Adapt adapts the input to FS unless it is already one. NewDirFS should be @@ -15,8 +17,8 @@ import ( // documentation does not require the file to be present. In summary, we can't // enforce flag behavior. func Adapt(fs fs.FS) FS { - if sys, ok := fs.(*FSHolder); ok { - return sys.FS + if sys, ok := fs.(FS); ok { + return sys } return &adapter{fs: fs} } @@ -31,6 +33,11 @@ func (a *adapter) String() string { return fmt.Sprintf("%v", a.fs) } +// Open implements the same method as documented on fs.FS +func (a *adapter) Open(name string) (fs.File, error) { + return a.fs.Open(name) +} + // OpenFile implements FS.OpenFile func (a *adapter) OpenFile(path string, flag int, perm fs.FileMode) (fs.File, error) { path = cleanPath(path) @@ -57,3 +64,26 @@ func cleanPath(name string) string { cleaned = pathutil.Clean(cleaned) // e.g. "sub/." -> "sub" return cleaned } + +// fsOpen implements the Open method as documented on fs.FS +func fsOpen(f FS, name string) (fs.File, error) { + if !fs.ValidPath(name) { // FS.OpenFile has fewer constraints than fs.FS + return nil, &fs.PathError{Op: "open", Path: name, Err: fs.ErrInvalid} + } + + // This isn't a production-grade fs.FS implementation. The only special + // cases we address here are to pass testfs.TestFS. + + if runtime.GOOS == "windows" { + switch { + case strings.Contains(name, "\\"): + return nil, &fs.PathError{Op: "open", Path: name, Err: fs.ErrInvalid} + } + } + + if f, err := f.OpenFile(name, os.O_RDONLY, 0); err != nil { + return nil, &fs.PathError{Op: "open", Path: name, Err: err} + } else { + return f, nil + } +} diff --git a/internal/sysfs/adapter_test.go b/internal/sysfs/adapter_test.go index 8971caed..46a19caf 100644 --- a/internal/sysfs/adapter_test.go +++ b/internal/sysfs/adapter_test.go @@ -159,7 +159,7 @@ func TestAdapt_TestFS(t *testing.T) { testFS := Adapt(tc.fs) // Adapt it back to fs.FS and run the tests - require.NoError(t, fstest.TestFS(&testFSAdapter{testFS})) + require.NoError(t, fstest.TestFS(testFS.(fs.FS))) }) } } diff --git a/internal/sysfs/dirfs.go b/internal/sysfs/dirfs.go index 76175b44..ccb3fe8e 100644 --- a/internal/sysfs/dirfs.go +++ b/internal/sysfs/dirfs.go @@ -33,6 +33,11 @@ func (d *dirFS) String() string { return d.dir } +// Open implements the same method as documented on fs.FS +func (d *dirFS) Open(name string) (fs.File, error) { + return fsOpen(d, name) +} + // OpenFile implements FS.OpenFile func (d *dirFS) OpenFile(name string, flag int, perm fs.FileMode) (fs.File, error) { f, err := os.OpenFile(d.join(name), flag, perm) diff --git a/internal/sysfs/dirfs_test.go b/internal/sysfs/dirfs_test.go index f27b808e..0387f1ef 100644 --- a/internal/sysfs/dirfs_test.go +++ b/internal/sysfs/dirfs_test.go @@ -380,5 +380,5 @@ func TestDirFS_TestFS(t *testing.T) { testFS := NewDirFS(tmpDir) // Run TestFS via the adapter - require.NoError(t, fstest.TestFS(&testFSAdapter{testFS})) + require.NoError(t, fstest.TestFS(testFS.(fs.FS))) } diff --git a/internal/sysfs/readfs.go b/internal/sysfs/readfs.go index 7dbc1f39..001e2ca4 100644 --- a/internal/sysfs/readfs.go +++ b/internal/sysfs/readfs.go @@ -32,6 +32,11 @@ func (r *readFS) String() string { return r.fs.String() } +// Open implements the same method as documented on fs.FS +func (r *readFS) Open(name string) (fs.File, error) { + return fsOpen(r, name) +} + // OpenFile implements FS.OpenFile func (r *readFS) OpenFile(path string, flag int, perm fs.FileMode) (fs.File, error) { // TODO: Once the real implementation is complete, move the below to diff --git a/internal/sysfs/readfs_test.go b/internal/sysfs/readfs_test.go index 6beeb90d..9a6a165b 100644 --- a/internal/sysfs/readfs_test.go +++ b/internal/sysfs/readfs_test.go @@ -123,5 +123,5 @@ func TestReadFS_TestFS(t *testing.T) { testFS = NewReadFS(testFS) // Run TestFS via the adapter - require.NoError(t, fstest.TestFS(&testFSAdapter{testFS})) + require.NoError(t, fstest.TestFS(testFS.(fs.FS))) } diff --git a/internal/sysfs/rootfs.go b/internal/sysfs/rootfs.go index 8d67dba3..232eb95c 100644 --- a/internal/sysfs/rootfs.go +++ b/internal/sysfs/rootfs.go @@ -56,7 +56,7 @@ func NewRootFS(fs []FS, guestPaths []string) (FS, error) { if ret.rootIndex == -1 { ret.rootIndex = len(fs) ret.guestPaths = append(ret.guestPaths, "") - ret.fs = append(ret.fs, fakeRootFS{}) + ret.fs = append(ret.fs, &fakeRootFS{}) } return ret, nil } @@ -106,13 +106,19 @@ func writeMount(ret *strings.Builder, f FS, guestPath string) { func (c *CompositeFS) Unwrap() []FS { result := make([]FS, 0, len(c.fs)) for i := len(c.fs) - 1; i >= 0; i-- { - if fs := c.fs[i]; fs != (fakeRootFS{}) { + fs := c.fs[i] + if _, ok := fs.(*fakeRootFS); !ok { result = append(result, fs) } } return result } +// Open implements the same method as documented on fs.FS +func (c *CompositeFS) Open(name string) (fs.File, error) { + return fsOpen(c, name) +} + // OpenFile implements FS.OpenFile func (c *CompositeFS) OpenFile(path string, flag int, perm fs.FileMode) (f fs.File, err error) { matchIndex, relativePath := c.chooseFS(path) @@ -401,7 +407,7 @@ loop: type fakeRootFS struct{ UnimplementedFS } // OpenFile implements FS.OpenFile -func (fakeRootFS) OpenFile(path string, flag int, perm fs.FileMode) (fs.File, error) { +func (*fakeRootFS) OpenFile(path string, flag int, perm fs.FileMode) (fs.File, error) { switch path { case ".", "/", "": return fakeRootDir{}, nil diff --git a/internal/sysfs/rootfs_test.go b/internal/sysfs/rootfs_test.go index aa721c97..76eaf60f 100644 --- a/internal/sysfs/rootfs_test.go +++ b/internal/sysfs/rootfs_test.go @@ -173,7 +173,7 @@ func TestRootFS_TestFS(t *testing.T) { require.NoError(t, err) // Run TestFS via the adapter - require.NoError(t, fstest.TestFS(&testFSAdapter{testFS})) + require.NoError(t, fstest.TestFS(testFS.(fs.FS))) } func TestRootFS_examples(t *testing.T) { diff --git a/internal/sysfs/sysfs.go b/internal/sysfs/sysfs.go index cc4eaa90..3395d2d0 100644 --- a/internal/sysfs/sysfs.go +++ b/internal/sysfs/sysfs.go @@ -6,24 +6,12 @@ package sysfs import ( - "fmt" "io" "io/fs" "os" "syscall" ) -// FSHolder implements fs.FS in order to pass an FS until configuration -// supports it natively. -type FSHolder struct { - FS FS -} - -// Open implements the same method as documented on fs.FS -func (*FSHolder) Open(name string) (fs.File, error) { - panic(fmt.Errorf("unexpected to call fs.FS.Open(%s)", name)) -} - // FS is a writeable fs.FS bridge backed by syscall functions needed for ABI // including WASI and runtime.GOOS=js. // diff --git a/internal/sysfs/sysfs_test.go b/internal/sysfs/sysfs_test.go index fe4017d3..5c9f7155 100644 --- a/internal/sysfs/sysfs_test.go +++ b/internal/sysfs/sysfs_test.go @@ -10,7 +10,6 @@ import ( "path" "runtime" "sort" - "strings" "syscall" "testing" "testing/fstest" @@ -231,30 +230,6 @@ func testUtimes(t *testing.T, tmpDir string, testFS FS) { } } -// testFSAdapter implements fs.FS only to use fstest.TestFS -type testFSAdapter struct { - fs FS -} - -// Open implements the same method as documented on fs.FS -func (f *testFSAdapter) Open(name string) (fs.File, error) { - if !fs.ValidPath(name) { // FS.OpenFile has fewer constraints than fs.FS - return nil, os.ErrInvalid - } - - // This isn't a production-grade fs.FS implementation. The only special - // cases we address here are to pass testfs.TestFS. - - if runtime.GOOS == "windows" { - switch { - case strings.Contains(name, "\\"): - return nil, os.ErrInvalid - } - } - - return f.fs.OpenFile(name, os.O_RDONLY, 0) -} - // requireErrno should only be used for functions that wrap the underlying // syscall.Errno. func requireErrno(t *testing.T, expected syscall.Errno, actual error) { diff --git a/internal/sysfs/unsupported.go b/internal/sysfs/unsupported.go index 42cad374..fb95e073 100644 --- a/internal/sysfs/unsupported.go +++ b/internal/sysfs/unsupported.go @@ -14,6 +14,11 @@ func (UnimplementedFS) String() string { return "Unimplemented:/" } +// Open implements the same method as documented on fs.FS +func (UnimplementedFS) Open(name string) (fs.File, error) { + return nil, &fs.PathError{Op: "open", Path: name, Err: syscall.ENOSYS} +} + // OpenFile implements FS.OpenFile func (UnimplementedFS) OpenFile(path string, flag int, perm fs.FileMode) (fs.File, error) { return nil, syscall.ENOSYS diff --git a/internal/wasm/call_context_test.go b/internal/wasm/call_context_test.go index bbb9ee8d..b25b2dc0 100644 --- a/internal/wasm/call_context_test.go +++ b/internal/wasm/call_context_test.go @@ -8,6 +8,7 @@ import ( "testing" "github.com/tetratelabs/wazero/internal/sys" + "github.com/tetratelabs/wazero/internal/sysfs" testfs "github.com/tetratelabs/wazero/internal/testing/fs" "github.com/tetratelabs/wazero/internal/testing/require" ) @@ -144,7 +145,7 @@ func TestCallContext_Close(t *testing.T) { } t.Run("calls Context.Close()", func(t *testing.T) { - sysCtx := sys.DefaultContext(testfs.FS{"foo": &testfs.File{}}) + sysCtx := sys.DefaultContext(sysfs.Adapt(testfs.FS{"foo": &testfs.File{}})) fsCtx := sysCtx.FS() _, err := fsCtx.OpenFile("/foo", os.O_RDONLY, 0) @@ -172,7 +173,7 @@ func TestCallContext_Close(t *testing.T) { t.Run("error closing", func(t *testing.T) { // Right now, the only way to err closing the sys context is if a File.Close erred. testFS := testfs.FS{"foo": &testfs.File{CloseErr: errors.New("error closing")}} - sysCtx := sys.DefaultContext(testFS) + sysCtx := sys.DefaultContext(sysfs.Adapt(testFS)) fsCtx := sysCtx.FS() _, err := fsCtx.OpenFile("/foo", os.O_RDONLY, 0) @@ -240,7 +241,7 @@ func TestCallContext_CallDynamic(t *testing.T) { } t.Run("calls Context.Close()", func(t *testing.T) { - sysCtx := sys.DefaultContext(testfs.FS{"foo": &testfs.File{}}) + sysCtx := sys.DefaultContext(sysfs.Adapt(testfs.FS{"foo": &testfs.File{}})) fsCtx := sysCtx.FS() _, err := fsCtx.OpenFile("/foo", os.O_RDONLY, 0) @@ -268,7 +269,7 @@ func TestCallContext_CallDynamic(t *testing.T) { t.Run("error closing", func(t *testing.T) { // Right now, the only way to err closing the sys context is if a File.Close erred. testFS := testfs.FS{"foo": &testfs.File{CloseErr: errors.New("error closing")}} - sysCtx := sys.DefaultContext(testFS) + sysCtx := sys.DefaultContext(sysfs.Adapt(testFS)) fsCtx := sysCtx.FS() path := "/foo" diff --git a/testdata/index.html b/testdata/index.html new file mode 100644 index 00000000..69e9da41 --- /dev/null +++ b/testdata/index.html @@ -0,0 +1,2 @@ + +