diff --git a/go.mod b/go.mod index 737d83d..7b2910d 100644 --- a/go.mod +++ b/go.mod @@ -18,7 +18,6 @@ require ( github.com/hashicorp/go-getter v1.8.6 github.com/moby/moby/api v1.54.1 github.com/moby/moby/client v0.4.0 - github.com/otiai10/copy v1.14.1 github.com/pkg/errors v0.9.1 github.com/samber/lo v1.53.0 github.com/shirou/gopsutil/v3 v3.24.5 @@ -94,7 +93,6 @@ require ( github.com/moby/docker-image-spec v1.3.1 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect - github.com/otiai10/mint v1.6.3 // indirect github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect diff --git a/go.sum b/go.sum index 3759dfb..8ede932 100644 --- a/go.sum +++ b/go.sum @@ -177,10 +177,6 @@ github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8 github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= -github.com/otiai10/copy v1.14.1 h1:5/7E6qsUMBaH5AnQ0sSLzzTg1oTECmcCmT6lvF45Na8= -github.com/otiai10/copy v1.14.1/go.mod h1:oQwrEDDOci3IM8dJF0d8+jnbfPDllW6vUjNc3DoZm9I= -github.com/otiai10/mint v1.6.3 h1:87qsV/aw1F5as1eH1zS/yqHY85ANKVMgkDrf9rcxbQs= -github.com/otiai10/mint v1.6.3/go.mod h1:MJm72SBthJjz8qhefc4z1PYEieWmy8Bku7CjcAqyUSM= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo= diff --git a/internal/app/fsutil/copy.go b/internal/app/fsutil/copy.go new file mode 100644 index 0000000..2380b0c --- /dev/null +++ b/internal/app/fsutil/copy.go @@ -0,0 +1,251 @@ +package fsutil + +import ( + "io" + "io/fs" + "os" + "path" + "path/filepath" + + "github.com/pkg/errors" +) + +// SymlinkAction selects how a symlink encountered during a copy is handled. +type SymlinkAction int + +const ( + // SymlinkShallow recreates the symlink with the same target without + // dereferencing or recursing into it. This is the zero value and matches + // the default behaviour of the recursive-copy library this package replaced. + SymlinkShallow SymlinkAction = iota + // SymlinkDeep follows the symlink and copies the target's contents. + SymlinkDeep + // SymlinkSkip ignores symlinks entirely. + SymlinkSkip +) + +// CopyOptions tunes a copy operation. +type CopyOptions struct { + Symlink SymlinkAction + // AddPermission is OR'd into the mode of every copied regular file. + AddPermission os.FileMode +} + +// CopyInRoot copies src to dst where both are paths relative to the same root. +func CopyInRoot(root *os.Root, src, dst string, opts CopyOptions) error { + return CopyTree(root, src, root, dst, opts) +} + +// CopyTree copies srcRoot:src into dstRoot:dst. Directories are merged into an +// existing destination and regular files overwrite an existing one, matching +// the default behaviour of the recursive-copy library this package replaced. +func CopyTree(srcRoot *os.Root, src string, dstRoot *os.Root, dst string, opts CopyOptions) error { + info, err := srcRoot.Lstat(src) + if err != nil { + return errors.Wrap(err, "failed to stat copy source") + } + + return copyEntry(srcRoot, src, dstRoot, dst, info, opts) +} + +// Copy copies absolute src to absolute dst. It is a convenience for trusted, +// operator-controlled callers (game-server installation, test fixtures): only +// the top-level destination container is created with os.MkdirAll, while every +// recursive read and write — where symlink-escape risk lives — stays confined +// to an os.Root. +func Copy(src, dst string, opts CopyOptions) error { + info, err := os.Lstat(src) + if err != nil { + return errors.Wrap(err, "failed to stat copy source") + } + + if info.IsDir() { + srcRoot, err := os.OpenRoot(src) + if err != nil { + return errors.Wrap(err, "failed to open source root") + } + defer srcRoot.Close() + + if err := os.MkdirAll(dst, 0o755); err != nil { + return errors.Wrap(err, "failed to create destination directory") + } + + dstRoot, err := os.OpenRoot(dst) + if err != nil { + return errors.Wrap(err, "failed to open destination root") + } + defer dstRoot.Close() + + return CopyTree(srcRoot, ".", dstRoot, ".", opts) + } + + srcRoot, err := os.OpenRoot(filepath.Dir(src)) + if err != nil { + return errors.Wrap(err, "failed to open source root") + } + defer srcRoot.Close() + + dstDir := filepath.Dir(dst) + if err := os.MkdirAll(dstDir, 0o755); err != nil { + return errors.Wrap(err, "failed to create destination directory") + } + + dstRoot, err := os.OpenRoot(dstDir) + if err != nil { + return errors.Wrap(err, "failed to open destination root") + } + defer dstRoot.Close() + + return CopyTree(srcRoot, filepath.Base(src), dstRoot, filepath.Base(dst), opts) +} + +func copyEntry( + srcRoot *os.Root, src string, dstRoot *os.Root, dst string, info fs.FileInfo, opts CopyOptions, +) error { + mode := info.Mode() + + switch { + case mode&os.ModeSymlink != 0: + return copySymlink(srcRoot, src, dstRoot, dst, opts) + case mode.IsDir(): + return copyDir(srcRoot, src, dstRoot, dst, info, opts) + case mode.IsRegular(): + return copyFile(srcRoot, src, dstRoot, dst, info, opts) + default: + // Sockets, fifos and device nodes are skipped: a running game server + // keeps unix sockets in its work directory and copying them is both + // impossible and unwanted. + return nil + } +} + +func copyDir( + srcRoot *os.Root, src string, dstRoot *os.Root, dst string, info fs.FileInfo, opts CopyOptions, +) error { + if err := ensureDir(dstRoot, dst, dirPerm(info)); err != nil { + return err + } + + entries, err := fs.ReadDir(srcRoot.FS(), src) + if err != nil { + return errors.Wrap(err, "failed to read source directory") + } + + for _, entry := range entries { + childSrc := path.Join(src, entry.Name()) + childDst := path.Join(dst, entry.Name()) + + childInfo, err := srcRoot.Lstat(childSrc) + if err != nil { + return errors.Wrap(err, "failed to stat directory entry") + } + + if err := copyEntry(srcRoot, childSrc, dstRoot, childDst, childInfo, opts); err != nil { + return err + } + } + + return nil +} + +func copyFile( + srcRoot *os.Root, src string, dstRoot *os.Root, dst string, info fs.FileInfo, opts CopyOptions, +) error { + if err := ensureParent(dstRoot, dst); err != nil { + return err + } + + srcFile, err := srcRoot.Open(src) + if err != nil { + return errors.Wrap(err, "failed to open source file") + } + defer srcFile.Close() + + perm := (info.Mode() | opts.AddPermission).Perm() + + dstFile, err := dstRoot.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, perm) + if err != nil { + return errors.Wrap(err, "failed to create destination file") + } + + if _, err := io.Copy(dstFile, srcFile); err != nil { + _ = dstFile.Close() + return errors.Wrap(err, "failed to copy file contents") + } + + if err := dstFile.Close(); err != nil { + return errors.Wrap(err, "failed to close destination file") + } + + // Restore the exact source mode: OpenFile is subject to umask, which would + // otherwise strip the executable bit from game-server scripts. + if err := dstRoot.Chmod(dst, perm); err != nil { + return errors.Wrap(err, "failed to set destination file mode") + } + + return nil +} + +func copySymlink( + srcRoot *os.Root, src string, dstRoot *os.Root, dst string, opts CopyOptions, +) error { + switch opts.Symlink { + case SymlinkSkip: + return nil + case SymlinkDeep: + target, err := srcRoot.Stat(src) + if err != nil { + return errors.Wrap(err, "failed to stat symlink target") + } + + switch { + case target.IsDir(): + return copyDir(srcRoot, src, dstRoot, dst, target, opts) + case target.Mode().IsRegular(): + return copyFile(srcRoot, src, dstRoot, dst, target, opts) + default: + return nil + } + default: // SymlinkShallow + linkTarget, err := srcRoot.Readlink(src) + if err != nil { + return errors.Wrap(err, "failed to read symlink") + } + + if err := ensureParent(dstRoot, dst); err != nil { + return err + } + + _ = dstRoot.Remove(dst) + + if err := dstRoot.Symlink(linkTarget, dst); err != nil { + return errors.Wrap(err, "failed to create symlink") + } + + return nil + } +} + +func ensureParent(root *os.Root, name string) error { + return ensureDir(root, path.Dir(name), 0o755) +} + +func ensureDir(root *os.Root, name string, perm os.FileMode) error { + if name == "." || name == "" || name == "/" { + return nil + } + + if err := root.MkdirAll(name, perm); err != nil { + return errors.Wrap(err, "failed to create directory") + } + + return nil +} + +func dirPerm(info fs.FileInfo) os.FileMode { + if perm := info.Mode().Perm(); perm != 0 { + return perm + } + + return 0o755 +} diff --git a/internal/app/fsutil/copy_test.go b/internal/app/fsutil/copy_test.go new file mode 100644 index 0000000..a14b5f6 --- /dev/null +++ b/internal/app/fsutil/copy_test.go @@ -0,0 +1,105 @@ +package fsutil + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCopy_File(t *testing.T) { + dir := t.TempDir() + src := filepath.Join(dir, "src.txt") + dst := filepath.Join(dir, "out", "dst.txt") + require.NoError(t, os.WriteFile(src, []byte("payload"), 0o640)) + + require.NoError(t, Copy(src, dst, CopyOptions{})) + + got, err := os.ReadFile(dst) + require.NoError(t, err) + assert.Equal(t, "payload", string(got)) + + info, err := os.Stat(dst) + require.NoError(t, err) + assert.Equal(t, os.FileMode(0o640), info.Mode().Perm(), "source permissions must be preserved") +} + +func TestCopy_FileAddPermission(t *testing.T) { + dir := t.TempDir() + src := filepath.Join(dir, "src") + dst := filepath.Join(dir, "dst") + require.NoError(t, os.WriteFile(src, []byte("x"), 0o600)) + + require.NoError(t, Copy(src, dst, CopyOptions{AddPermission: 0o011})) + + info, err := os.Stat(dst) + require.NoError(t, err) + assert.Equal(t, os.FileMode(0o611), info.Mode().Perm(), "AddPermission must be OR'd into the mode") +} + +func TestCopy_DirRecursiveMergeAndOverwrite(t *testing.T) { + dir := t.TempDir() + src := filepath.Join(dir, "src") + dst := filepath.Join(dir, "dst") + + require.NoError(t, os.MkdirAll(filepath.Join(src, "nested"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(src, "a.txt"), []byte("A"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(src, "nested", "b.txt"), []byte("B"), 0o644)) + + // Pre-existing destination with a stale file that must be overwritten and + // an unrelated file that must be preserved (merge semantics). + require.NoError(t, os.MkdirAll(dst, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(dst, "a.txt"), []byte("OLD"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(dst, "keep.txt"), []byte("KEEP"), 0o644)) + + require.NoError(t, Copy(src, dst, CopyOptions{})) + + a, err := os.ReadFile(filepath.Join(dst, "a.txt")) + require.NoError(t, err) + assert.Equal(t, "A", string(a), "existing file must be overwritten") + + b, err := os.ReadFile(filepath.Join(dst, "nested", "b.txt")) + require.NoError(t, err) + assert.Equal(t, "B", string(b)) + + keep, err := os.ReadFile(filepath.Join(dst, "keep.txt")) + require.NoError(t, err) + assert.Equal(t, "KEEP", string(keep), "unrelated destination file must be preserved (merge)") +} + +func TestCopyTree_CrossRoot(t *testing.T) { + srcDir := t.TempDir() + dstDir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(srcDir, "f.txt"), []byte("data"), 0o644)) + + srcRoot, err := os.OpenRoot(srcDir) + require.NoError(t, err) + defer srcRoot.Close() + dstRoot, err := os.OpenRoot(dstDir) + require.NoError(t, err) + defer dstRoot.Close() + + require.NoError(t, CopyTree(srcRoot, ".", dstRoot, ".", CopyOptions{})) + + got, err := os.ReadFile(filepath.Join(dstDir, "f.txt")) + require.NoError(t, err) + assert.Equal(t, "data", string(got)) +} + +func TestCopyInRoot_SameRoot(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(dir, "a"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "a", "f.txt"), []byte("z"), 0o644)) + + root, err := os.OpenRoot(dir) + require.NoError(t, err) + defer root.Close() + + require.NoError(t, CopyInRoot(root, "a", "b", CopyOptions{})) + + got, err := os.ReadFile(filepath.Join(dir, "b", "f.txt")) + require.NoError(t, err) + assert.Equal(t, "z", string(got)) +} diff --git a/internal/app/fsutil/copy_unix_test.go b/internal/app/fsutil/copy_unix_test.go new file mode 100644 index 0000000..f24a888 --- /dev/null +++ b/internal/app/fsutil/copy_unix_test.go @@ -0,0 +1,72 @@ +//go:build linux || darwin + +package fsutil + +import ( + "os" + "path/filepath" + "syscall" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCopy_SymlinkShallowPreservesLink(t *testing.T) { + dir := t.TempDir() + src := filepath.Join(dir, "src") + dst := filepath.Join(dir, "dst") + + require.NoError(t, os.MkdirAll(src, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(src, "target.txt"), []byte("T"), 0o644)) + require.NoError(t, os.Symlink("target.txt", filepath.Join(src, "link"))) + + require.NoError(t, Copy(src, dst, CopyOptions{Symlink: SymlinkShallow})) + + info, err := os.Lstat(filepath.Join(dst, "link")) + require.NoError(t, err) + assert.NotZero(t, info.Mode()&os.ModeSymlink, "link must be copied as a symlink, not dereferenced") + + target, err := os.Readlink(filepath.Join(dst, "link")) + require.NoError(t, err) + assert.Equal(t, "target.txt", target) +} + +func TestCopy_SymlinkSkip(t *testing.T) { + dir := t.TempDir() + src := filepath.Join(dir, "src") + dst := filepath.Join(dir, "dst") + + require.NoError(t, os.MkdirAll(src, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(src, "f.txt"), []byte("F"), 0o644)) + require.NoError(t, os.Symlink("f.txt", filepath.Join(src, "link"))) + + require.NoError(t, Copy(src, dst, CopyOptions{Symlink: SymlinkSkip})) + + _, err := os.Lstat(filepath.Join(dst, "link")) + assert.ErrorIs(t, err, os.ErrNotExist, "skipped symlink must not be created") + + _, err = os.Stat(filepath.Join(dst, "f.txt")) + require.NoError(t, err, "regular files must still be copied") +} + +func TestCopy_SkipsNonRegularFiles(t *testing.T) { + dir := t.TempDir() + src := filepath.Join(dir, "src") + dst := filepath.Join(dir, "dst") + require.NoError(t, os.MkdirAll(src, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(src, "real.txt"), []byte("R"), 0o644)) + + if err := syscall.Mkfifo(filepath.Join(src, "pipe"), 0o644); err != nil { + t.Skipf("cannot create fifo: %v", err) + } + + require.NoError(t, Copy(src, dst, CopyOptions{}), "a fifo in the tree must not fail the copy") + + got, err := os.ReadFile(filepath.Join(dst, "real.txt")) + require.NoError(t, err) + assert.Equal(t, "R", string(got)) + + _, err = os.Lstat(filepath.Join(dst, "pipe")) + assert.ErrorIs(t, err, os.ErrNotExist, "the fifo must be skipped, not copied") +} diff --git a/internal/app/fsutil/path.go b/internal/app/fsutil/path.go new file mode 100644 index 0000000..b234737 --- /dev/null +++ b/internal/app/fsutil/path.go @@ -0,0 +1,57 @@ +// Package fsutil provides path confinement and recursive copy primitives built +// on top of Go's os.Root. Every file operation that handles a caller-supplied +// path should resolve it with RootRel and then act through an *os.Root opened +// at the work directory: os.Root resolves each path component through the +// directory fd and refuses symlink / ".." escapes without TOCTOU races, on +// both Linux and Windows. +// +// The recursive copy reproduces the default behaviour of the recursive-copy +// library it replaced (shallow symlinks, source permissions preserved, merge +// into existing directories) with one deliberate difference: sockets, fifos +// and device nodes are skipped instead of erroring, because game-server work +// directories legitimately contain unix sockets. +package fsutil + +import ( + "io/fs" + "path" + "path/filepath" + "strings" + + "github.com/pkg/errors" +) + +// ErrPathOutsideRoot is returned when a caller-supplied path cannot be +// expressed as a location inside the work directory. The message is kept +// stable because API clients and existing tests match on it. +var ErrPathOutsideRoot = errors.New("path is outside work directory") + +// RootRel normalizes a caller-supplied path into a clean, slash-separated, +// root-relative name suitable both for *os.Root methods and for root.FS() +// (io/fs) traversal. +// +// "" and "." map to ".". Volume names and leading separators are stripped so +// an absolute-looking path from a client is treated as relative to the work +// directory (this preserves historical behaviour and the Windows +// double-volume defense). os.Root remains the real security boundary; this +// only rejects lexically obvious escapes early with a friendly error. +func RootRel(p string) (string, error) { + p = filepath.FromSlash(p) + + if vol := filepath.VolumeName(p); vol != "" { + p = p[len(vol):] + } + + p = strings.TrimLeft(p, `/\`) + p = path.Clean(filepath.ToSlash(p)) + + if p == "" || p == "." { + return ".", nil + } + + if !fs.ValidPath(p) { + return "", ErrPathOutsideRoot + } + + return p, nil +} diff --git a/internal/app/fsutil/path_test.go b/internal/app/fsutil/path_test.go new file mode 100644 index 0000000..b6d7d3b --- /dev/null +++ b/internal/app/fsutil/path_test.go @@ -0,0 +1,70 @@ +package fsutil + +import ( + "runtime" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRootRel(t *testing.T) { + tests := []struct { + name string + in string + want string + wantErr bool + }{ + {name: "relative_path", in: "servers/x", want: "servers/x"}, + {name: "empty_is_root", in: "", want: "."}, + {name: "dot_is_root", in: ".", want: "."}, + {name: "leading_slash_stripped", in: "/servers/x", want: "servers/x"}, + {name: "trailing_slash_cleaned", in: "servers/x/", want: "servers/x"}, + {name: "interior_dotdot_kept_when_local", in: "a/../b", want: "b"}, + {name: "escape_via_dotdot_rejected", in: "../etc/passwd", wantErr: true}, + {name: "escape_after_collapse_rejected", in: "a/../../b", wantErr: true}, + {name: "lone_dotdot_rejected", in: "..", wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := RootRel(tt.in) + if tt.wantErr { + require.ErrorIs(t, err, ErrPathOutsideRoot) + return + } + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestRootRel_StripsLeadingBackslash(t *testing.T) { + got, err := RootRel(`\servers\x`) + require.NoError(t, err) + + if runtime.GOOS == "windows" { + assert.Equal(t, "servers/x", got) + } else { + // On unix a backslash is an ordinary filename character; only the + // leading separator is trimmed. + assert.Equal(t, `servers\x`, got) + } +} + +func TestRootRel_Windows(t *testing.T) { + if runtime.GOOS != "windows" { + t.Skip("Windows-only volume handling") + } + + t.Run("drive_letter_stripped_and_treated_as_relative", func(t *testing.T) { + got, err := RootRel(`C:\gameap\servers\x`) + require.NoError(t, err) + assert.Equal(t, "gameap/servers/x", got) + }) + + t.Run("dotdot_escape_still_rejected", func(t *testing.T) { + _, err := RootRel(`..\Windows\System32`) + require.ErrorIs(t, err, ErrPathOutsideRoot) + }) +} diff --git a/internal/app/game_server_commands/delete_server_test.go b/internal/app/game_server_commands/delete_server_test.go index 1955a22..27ddfe4 100644 --- a/internal/app/game_server_commands/delete_server_test.go +++ b/internal/app/game_server_commands/delete_server_test.go @@ -11,8 +11,8 @@ import ( "github.com/gameap/daemon/internal/app/components" "github.com/gameap/daemon/internal/app/config" "github.com/gameap/daemon/internal/app/domain" + "github.com/gameap/daemon/internal/app/fsutil" "github.com/gameap/daemon/internal/processmanager" - copyPkg "github.com/otiai10/copy" "github.com/stretchr/testify/suite" ) @@ -138,7 +138,7 @@ func (suite *deleteSuite) TestDeleteServerByScript_CommandFail() { func installSimpleServerFiles(t *testing.T, cfg *config.Config, server *domain.Server) { t.Helper() - err := copyPkg.Copy("../../../test/servers/simple/", server.WorkDir(cfg)) + err := fsutil.Copy("../../../test/servers/simple/", server.WorkDir(cfg), fsutil.CopyOptions{}) if err != nil { t.Fatal(err) } @@ -146,7 +146,7 @@ func installSimpleServerFiles(t *testing.T, cfg *config.Config, server *domain.S func installScripts(t *testing.T, cfg *config.Config) { t.Helper() - err := copyPkg.Copy("../../../test/servers/scripts/", cfg.WorkPath) + err := fsutil.Copy("../../../test/servers/scripts/", cfg.WorkPath, fsutil.CopyOptions{}) if err != nil { t.Fatal(err) } diff --git a/internal/app/game_server_commands/install_server.go b/internal/app/game_server_commands/install_server.go index 4ad23a0..a6f08ee 100644 --- a/internal/app/game_server_commands/install_server.go +++ b/internal/app/game_server_commands/install_server.go @@ -17,10 +17,10 @@ import ( "github.com/gameap/daemon/internal/app/config" "github.com/gameap/daemon/internal/app/contracts" "github.com/gameap/daemon/internal/app/domain" + "github.com/gameap/daemon/internal/app/fsutil" "github.com/gameap/daemon/internal/app/osowner" "github.com/gameap/daemon/pkg/logger" "github.com/hashicorp/go-getter" - "github.com/otiai10/copy" "github.com/pkg/errors" log "github.com/sirupsen/logrus" @@ -513,7 +513,7 @@ func (in *installator) copyDirectoryFromLocalRepository( ) error { in.writeOutput(ctx, "Copying files from "+source+" to "+dst+" ...") - err := copy.Copy(source, dst) + err := fsutil.Copy(source, dst, fsutil.CopyOptions{}) if err != nil { err = errors.WithMessage(err, "[game_server_commands.installator] failed to copy files") in.writeOutput(ctx, err.Error()) diff --git a/internal/app/grpc/file_handler.go b/internal/app/grpc/file_handler.go index b8220bc..2784c70 100644 --- a/internal/app/grpc/file_handler.go +++ b/internal/app/grpc/file_handler.go @@ -3,14 +3,13 @@ package grpc import ( "context" "io" + "io/fs" "os" - "path/filepath" - "runtime" + "path" "strings" "time" - cp "github.com/otiai10/copy" - + "github.com/gameap/daemon/internal/app/fsutil" "github.com/gameap/daemon/internal/app/osowner" pb "github.com/gameap/gameap/pkg/proto" "github.com/pkg/errors" @@ -22,8 +21,6 @@ const ( maxFileSize = 100 * 1024 * 1024 ) -var errPathOutsideWorkDir = errors.New("path is outside work directory") - type GRPCFileHandler struct { workDir string } @@ -34,25 +31,37 @@ func NewGRPCFileHandler(workDir string) *GRPCFileHandler { } } +// openRoot opens an os.Root at the work directory. Every path supplied by the +// caller is then resolved through this root, which refuses symlink and ".." +// escapes per path component without TOCTOU races. The root is opened per +// request because workDir is provisioned from the API after construction and +// may not exist yet at startup. +func (h *GRPCFileHandler) openRoot() (*os.Root, error) { + root, err := os.OpenRoot(h.workDir) + if err != nil { + return nil, errors.Wrap(err, "work directory unavailable") + } + + return root, nil +} + func (h *GRPCFileHandler) HandleFileRead( _ context.Context, requestID string, req *pb.FileReadRequest, ) (*pb.FileReadResponse, error) { - filePath, err := h.resolvePath(req.Path) + root, err := h.openRoot() if err != nil { - return &pb.FileReadResponse{ - RequestId: requestID, - Success: false, - Error: err.Error(), - }, nil + return &pb.FileReadResponse{RequestId: requestID, Success: false, Error: err.Error()}, nil } + defer root.Close() - info, err := os.Stat(filePath) + rel, err := fsutil.RootRel(req.Path) if err != nil { - return &pb.FileReadResponse{ - RequestId: requestID, - Success: false, - Error: err.Error(), - }, nil + return &pb.FileReadResponse{RequestId: requestID, Success: false, Error: err.Error()}, nil + } + + info, err := root.Stat(rel) + if err != nil { + return &pb.FileReadResponse{RequestId: requestID, Success: false, Error: err.Error()}, nil } if info.IsDir() { @@ -67,23 +76,15 @@ func (h *GRPCFileHandler) HandleFileRead( length := req.GetLength() if offset > 0 || length > 0 { - file, openErr := os.Open(filePath) + file, openErr := root.Open(rel) if openErr != nil { - return &pb.FileReadResponse{ - RequestId: requestID, - Success: false, - Error: openErr.Error(), - }, nil + return &pb.FileReadResponse{RequestId: requestID, Success: false, Error: openErr.Error()}, nil } defer file.Close() if offset > 0 { if _, seekErr := file.Seek(offset, io.SeekStart); seekErr != nil { - return &pb.FileReadResponse{ - RequestId: requestID, - Success: false, - Error: seekErr.Error(), - }, nil + return &pb.FileReadResponse{RequestId: requestID, Success: false, Error: seekErr.Error()}, nil } } @@ -96,18 +97,10 @@ func (h *GRPCFileHandler) HandleFileRead( data, readErr := io.ReadAll(reader) if readErr != nil { - return &pb.FileReadResponse{ - RequestId: requestID, - Success: false, - Error: readErr.Error(), - }, nil + return &pb.FileReadResponse{RequestId: requestID, Success: false, Error: readErr.Error()}, nil } - return &pb.FileReadResponse{ - RequestId: requestID, - Success: true, - Content: data, - }, nil + return &pb.FileReadResponse{RequestId: requestID, Success: true, Content: data}, nil } if info.Size() > maxFileSize { @@ -118,32 +111,26 @@ func (h *GRPCFileHandler) HandleFileRead( }, nil } - data, err := os.ReadFile(filePath) + data, err := root.ReadFile(rel) if err != nil { - return &pb.FileReadResponse{ - RequestId: requestID, - Success: false, - Error: err.Error(), - }, nil + return &pb.FileReadResponse{RequestId: requestID, Success: false, Error: err.Error()}, nil } - return &pb.FileReadResponse{ - RequestId: requestID, - Success: true, - Content: data, - }, nil + return &pb.FileReadResponse{RequestId: requestID, Success: true, Content: data}, nil } func (h *GRPCFileHandler) HandleFileWrite( _ context.Context, requestID string, req *pb.FileWriteRequest, ) (*pb.FileWriteResponse, error) { - filePath, err := h.resolvePath(req.Path) + root, err := h.openRoot() if err != nil { - return &pb.FileWriteResponse{ - RequestId: requestID, - Success: false, - Error: err.Error(), - }, nil + return &pb.FileWriteResponse{RequestId: requestID, Success: false, Error: err.Error()}, nil + } + defer root.Close() + + rel, err := fsutil.RootRel(req.Path) + if err != nil { + return &pb.FileWriteResponse{RequestId: requestID, Success: false, Error: err.Error()}, nil } owner := osowner.Options{ @@ -153,8 +140,8 @@ func (h *GRPCFileHandler) HandleFileWrite( } if req.CreateDirs { - dir := filepath.Dir(filePath) - newDirs, segErr := osowner.MissingSegments(dir) + dir := path.Dir(rel) + newDirs, segErr := osowner.MissingSegmentsInRoot(root, dir) if segErr != nil { return &pb.FileWriteResponse{ RequestId: requestID, @@ -162,7 +149,7 @@ func (h *GRPCFileHandler) HandleFileWrite( Error: errors.Wrap(segErr, "failed to inspect target directory").Error(), }, nil } - if err = os.MkdirAll(dir, 0755); err != nil { + if err = root.MkdirAll(dir, 0755); err != nil { return &pb.FileWriteResponse{ RequestId: requestID, Success: false, @@ -170,7 +157,7 @@ func (h *GRPCFileHandler) HandleFileWrite( }, nil } for _, segment := range newDirs { - if chErr := osowner.ApplyToPath(segment, owner); chErr != nil { + if chErr := osowner.ApplyToPathInRoot(root, segment, owner); chErr != nil { return &pb.FileWriteResponse{ RequestId: requestID, Success: false, @@ -185,15 +172,11 @@ func (h *GRPCFileHandler) HandleFileWrite( mode = 0644 } - if err := os.WriteFile(filePath, req.Content, mode); err != nil { - return &pb.FileWriteResponse{ - RequestId: requestID, - Success: false, - Error: err.Error(), - }, nil + if err := root.WriteFile(rel, req.Content, mode); err != nil { + return &pb.FileWriteResponse{RequestId: requestID, Success: false, Error: err.Error()}, nil } - if chErr := osowner.ApplyToPath(filePath, owner); chErr != nil { + if chErr := osowner.ApplyToPathInRoot(root, rel, owner); chErr != nil { return &pb.FileWriteResponse{ RequestId: requestID, Success: false, @@ -201,51 +184,40 @@ func (h *GRPCFileHandler) HandleFileWrite( }, nil } - return &pb.FileWriteResponse{ - RequestId: requestID, - Success: true, - }, nil + return &pb.FileWriteResponse{RequestId: requestID, Success: true}, nil } func (h *GRPCFileHandler) HandleFileList( _ context.Context, requestID string, req *pb.FileListRequest, ) (*pb.FileListResponse, error) { - dirPath, err := h.resolvePath(req.Path) + root, err := h.openRoot() if err != nil { - return &pb.FileListResponse{ - RequestId: requestID, - Success: false, - Error: err.Error(), - }, nil + return &pb.FileListResponse{RequestId: requestID, Success: false, Error: err.Error()}, nil + } + defer root.Close() + + rel, err := fsutil.RootRel(req.Path) + if err != nil { + return &pb.FileListResponse{RequestId: requestID, Success: false, Error: err.Error()}, nil } var files []*pb.FileStat if req.Recursive { - files, err = h.listRecursive(dirPath, req.Path, req.Pattern) + files, err = listRecursive(root, rel, req.Path, req.Pattern) } else { - files, err = h.listFlat(dirPath, req.Path, req.Pattern) + files, err = listFlat(root, rel, req.Path, req.Pattern) } if err != nil { - return &pb.FileListResponse{ - RequestId: requestID, - Success: false, - Error: err.Error(), - }, nil + return &pb.FileListResponse{RequestId: requestID, Success: false, Error: err.Error()}, nil } - return &pb.FileListResponse{ - RequestId: requestID, - Success: true, - Files: files, - }, nil + return &pb.FileListResponse{RequestId: requestID, Success: true, Files: files}, nil } -func (h *GRPCFileHandler) listFlat( - dirPath, requestPath, pattern string, -) ([]*pb.FileStat, error) { - entries, err := os.ReadDir(dirPath) +func listFlat(root *os.Root, rel, requestPath, pattern string) ([]*pb.FileStat, error) { + entries, err := fs.ReadDir(root.FS(), rel) if err != nil { return nil, err } @@ -253,7 +225,7 @@ func (h *GRPCFileHandler) listFlat( files := make([]*pb.FileStat, 0, len(entries)) for _, entry := range entries { if pattern != "" { - if matched, _ := filepath.Match(pattern, entry.Name()); !matched { + if matched, _ := path.Match(pattern, entry.Name()); !matched { continue } } @@ -263,37 +235,27 @@ func (h *GRPCFileHandler) listFlat( continue } - files = append(files, fileInfoToStat(filepath.Join(requestPath, entry.Name()), info)) + files = append(files, fileInfoToStat(path.Join(requestPath, entry.Name()), info)) } return files, nil } -func (h *GRPCFileHandler) listRecursive( - dirPath, requestPath, pattern string, -) ([]*pb.FileStat, error) { +func listRecursive(root *os.Root, rel, requestPath, pattern string) ([]*pb.FileStat, error) { var files []*pb.FileStat - err := filepath.WalkDir(dirPath, func(path string, d os.DirEntry, err error) error { - if err != nil { + err := fs.WalkDir(root.FS(), rel, func(name string, d fs.DirEntry, walkErr error) error { + if walkErr != nil { return nil //nolint:nilerr // skip unreadable entries } - if path == dirPath { + sub, ok := relUnder(rel, name) + if !ok { return nil } - relPath, relErr := filepath.Rel(dirPath, path) - if relErr != nil { - return nil - } - - if !strings.HasPrefix(filepath.Clean(path), dirPath) { - return filepath.SkipDir - } - if pattern != "" { - if matched, _ := filepath.Match(pattern, d.Name()); !matched { + if matched, _ := path.Match(pattern, d.Name()); !matched { if !d.IsDir() { return nil } @@ -305,7 +267,7 @@ func (h *GRPCFileHandler) listRecursive( return nil } - files = append(files, fileInfoToStat(filepath.Join(requestPath, relPath), info)) + files = append(files, fileInfoToStat(path.Join(requestPath, sub), info)) return nil }) @@ -313,6 +275,25 @@ func (h *GRPCFileHandler) listRecursive( return files, err } +// relUnder returns name expressed relative to base, or ok=false when name is +// base itself (the walk start, which is not a listed entry). os.Root + the +// io/fs walk are inherently confined, so no extra containment check is needed. +func relUnder(base, name string) (string, bool) { + if name == base { + return "", false + } + if base == "." { + return name, true + } + + prefix := base + "/" + if !strings.HasPrefix(name, prefix) { + return "", false + } + + return name[len(prefix):], true +} + func fileOpErrResp(requestID string, err error) (*pb.FileOperationResponse, error) { return &pb.FileOperationResponse{ RequestId: requestID, @@ -333,19 +314,25 @@ func (h *GRPCFileHandler) HandleFileOperation( ) (*pb.FileOperationResponse, error) { rid := req.GetRequestId() + root, err := h.openRoot() + if err != nil { + return fileOpErrResp(rid, err) + } + defer root.Close() + switch req.GetOperation() { case pb.FileOperationType_FILE_OPERATION_TYPE_STAT: p := req.GetStatParams() if p == nil { return fileOpErrResp(rid, errors.New("stat_params required")) } - resolved, err := h.resolvePath(p.GetPath()) - if err != nil { - return fileOpErrResp(rid, err) + rel, relErr := fsutil.RootRel(p.GetPath()) + if relErr != nil { + return fileOpErrResp(rid, relErr) } - info, err := os.Lstat(resolved) - if err != nil { - return fileOpErrResp(rid, err) + info, statErr := root.Lstat(rel) + if statErr != nil { + return fileOpErrResp(rid, statErr) } return &pb.FileOperationResponse{ RequestId: rid, @@ -362,40 +349,40 @@ func (h *GRPCFileHandler) HandleFileOperation( if p == nil { return fileOpErrResp(rid, errors.New("exists_params required")) } - resolved, err := h.resolvePath(p.GetPath()) - if err != nil { - return fileOpErrResp(rid, err) + rel, relErr := fsutil.RootRel(p.GetPath()) + if relErr != nil { + return fileOpErrResp(rid, relErr) } - _, err = os.Stat(resolved) + _, statErr := root.Stat(rel) return &pb.FileOperationResponse{ RequestId: rid, Success: true, Result: &pb.FileOperationResponse_ExistsResult{ ExistsResult: &pb.ExistsResult{ - Exists: err == nil, + Exists: statErr == nil, }, }, }, nil case pb.FileOperationType_FILE_OPERATION_TYPE_DELETE: - return h.handleDeleteOp(rid, req.GetDeleteParams()) + return h.handleDeleteOp(root, rid, req.GetDeleteParams()) case pb.FileOperationType_FILE_OPERATION_TYPE_MOVE: - return h.handleMoveOp(rid, req.GetMoveParams()) + return h.handleMoveOp(root, rid, req.GetMoveParams()) case pb.FileOperationType_FILE_OPERATION_TYPE_COPY: - return h.handleCopyOp(rid, req.GetCopyParams()) + return h.handleCopyOp(root, rid, req.GetCopyParams()) case pb.FileOperationType_FILE_OPERATION_TYPE_CHMOD: p := req.GetChmodParams() if p == nil { return fileOpErrResp(rid, errors.New("chmod_params required")) } - resolved, err := h.resolvePath(p.GetPath()) - if err != nil { - return fileOpErrResp(rid, err) + rel, relErr := fsutil.RootRel(p.GetPath()) + if relErr != nil { + return fileOpErrResp(rid, relErr) } - if err := os.Chmod(resolved, os.FileMode(p.GetMode())); err != nil { + if err := root.Chmod(rel, os.FileMode(p.GetMode())); err != nil { return fileOpErrResp(rid, err) } return fileOpOkResp(rid) @@ -405,20 +392,20 @@ func (h *GRPCFileHandler) HandleFileOperation( if p == nil { return fileOpErrResp(rid, errors.New("chown_params required")) } - resolved, err := h.resolvePath(p.GetPath()) - if err != nil { - return fileOpErrResp(rid, err) + rel, relErr := fsutil.RootRel(p.GetPath()) + if relErr != nil { + return fileOpErrResp(rid, relErr) } - if err := os.Chown(resolved, int(p.GetUid()), int(p.GetGid())); err != nil { + if err := root.Chown(rel, int(p.GetUid()), int(p.GetGid())); err != nil { return fileOpErrResp(rid, err) } return fileOpOkResp(rid) case pb.FileOperationType_FILE_OPERATION_TYPE_MKDIR: - return h.handleMkdirOp(rid, req.GetMkdirParams()) + return h.handleMkdirOp(root, rid, req.GetMkdirParams()) case pb.FileOperationType_FILE_OPERATION_TYPE_TOUCH: - return h.handleTouchOp(rid, req.GetTouchParams()) + return h.handleTouchOp(root, rid, req.GetTouchParams()) default: return fileOpErrResp(rid, errors.Errorf("unsupported file operation: %s", req.GetOperation())) @@ -426,19 +413,19 @@ func (h *GRPCFileHandler) HandleFileOperation( } func (h *GRPCFileHandler) handleDeleteOp( - rid string, p *pb.DeleteParams, + root *os.Root, rid string, p *pb.DeleteParams, ) (*pb.FileOperationResponse, error) { if p == nil { return fileOpErrResp(rid, errors.New("delete_params required")) } - resolved, err := h.resolvePath(p.GetPath()) + rel, err := fsutil.RootRel(p.GetPath()) if err != nil { return fileOpErrResp(rid, err) } if p.GetRecursive() { - err = os.RemoveAll(resolved) + err = root.RemoveAll(rel) } else { - err = os.Remove(resolved) + err = root.Remove(rel) } if err != nil { return fileOpErrResp(rid, err) @@ -447,52 +434,52 @@ func (h *GRPCFileHandler) handleDeleteOp( } func (h *GRPCFileHandler) handleMoveOp( - rid string, p *pb.MoveParams, + root *os.Root, rid string, p *pb.MoveParams, ) (*pb.FileOperationResponse, error) { if p == nil { return fileOpErrResp(rid, errors.New("move_params required")) } - src, err := h.resolvePath(p.GetSource()) + src, err := fsutil.RootRel(p.GetSource()) if err != nil { return fileOpErrResp(rid, err) } - dst, err := h.resolvePath(p.GetDestination()) + dst, err := fsutil.RootRel(p.GetDestination()) if err != nil { return fileOpErrResp(rid, err) } - if err := os.Rename(src, dst); err != nil { + if err := root.Rename(src, dst); err != nil { return fileOpErrResp(rid, err) } return fileOpOkResp(rid) } func (h *GRPCFileHandler) handleCopyOp( - rid string, p *pb.CopyParams, + root *os.Root, rid string, p *pb.CopyParams, ) (*pb.FileOperationResponse, error) { if p == nil { return fileOpErrResp(rid, errors.New("copy_params required")) } - src, err := h.resolvePath(p.GetSource()) + src, err := fsutil.RootRel(p.GetSource()) if err != nil { return fileOpErrResp(rid, err) } - dst, err := h.resolvePath(p.GetDestination()) + dst, err := fsutil.RootRel(p.GetDestination()) if err != nil { return fileOpErrResp(rid, err) } - if err := cp.Copy(src, dst); err != nil { + if err := fsutil.CopyInRoot(root, src, dst, fsutil.CopyOptions{}); err != nil { return fileOpErrResp(rid, err) } return fileOpOkResp(rid) } func (h *GRPCFileHandler) handleMkdirOp( - rid string, p *pb.MkdirParams, + root *os.Root, rid string, p *pb.MkdirParams, ) (*pb.FileOperationResponse, error) { if p == nil { return fileOpErrResp(rid, errors.New("mkdir_params required")) } - resolved, err := h.resolvePath(p.GetPath()) + rel, err := fsutil.RootRel(p.GetPath()) if err != nil { return fileOpErrResp(rid, err) } @@ -510,27 +497,27 @@ func (h *GRPCFileHandler) handleMkdirOp( var newDirs []string if p.GetRecursive() { - newDirs, err = osowner.MissingSegments(resolved) + newDirs, err = osowner.MissingSegmentsInRoot(root, rel) if err != nil { return fileOpErrResp(rid, errors.Wrap(err, "failed to inspect target directory")) } - err = os.MkdirAll(resolved, mode) + err = root.MkdirAll(rel, mode) } else { - if _, statErr := os.Lstat(resolved); statErr == nil { + if _, statErr := root.Lstat(rel); statErr == nil { newDirs = nil } else if errors.Is(statErr, os.ErrNotExist) { - newDirs = []string{resolved} + newDirs = []string{rel} } else { return fileOpErrResp(rid, errors.Wrap(statErr, "failed to inspect target directory")) } - err = os.Mkdir(resolved, mode) + err = root.Mkdir(rel, mode) } if err != nil { return fileOpErrResp(rid, err) } for _, segment := range newDirs { - if chErr := osowner.ApplyToPath(segment, owner); chErr != nil { + if chErr := osowner.ApplyToPathInRoot(root, segment, owner); chErr != nil { return fileOpErrResp(rid, errors.Wrap(chErr, "failed to chown new directory")) } } @@ -539,26 +526,26 @@ func (h *GRPCFileHandler) handleMkdirOp( } func (h *GRPCFileHandler) handleTouchOp( - rid string, p *pb.TouchParams, + root *os.Root, rid string, p *pb.TouchParams, ) (*pb.FileOperationResponse, error) { if p == nil { return fileOpErrResp(rid, errors.New("touch_params required")) } - resolved, err := h.resolvePath(p.GetPath()) + rel, err := fsutil.RootRel(p.GetPath()) if err != nil { return fileOpErrResp(rid, err) } - if _, err := os.Stat(resolved); os.IsNotExist(err) { - f, createErr := os.Create(resolved) + if _, statErr := root.Stat(rel); os.IsNotExist(statErr) { + f, createErr := root.Create(rel) if createErr != nil { return fileOpErrResp(rid, createErr) } f.Close() - } else if err != nil { - return fileOpErrResp(rid, err) + } else if statErr != nil { + return fileOpErrResp(rid, statErr) } else { now := time.Now() - if err := os.Chtimes(resolved, now, now); err != nil { + if err := root.Chtimes(rel, now, now); err != nil { return fileOpErrResp(rid, err) } } @@ -591,53 +578,3 @@ func fileInfoToStat(path string, info os.FileInfo) *pb.FileStat { Type: ft, } } - -// ResolvePath resolves a caller-supplied path against workDir and validates it stays within bounds. -// Defensively strips volume names and leading separators so an absolute path from a mis- -// configured client cannot be joined onto workDir a second time (which on Windows produces -// e.g. C:\gameap\C:\gameap\servers\x). -func ResolvePath(workDir, path string) (string, error) { - cleanWorkDir := filepath.Clean(workDir) - - sanitized := filepath.FromSlash(path) - if vol := filepath.VolumeName(sanitized); vol != "" { - sanitized = sanitized[len(vol):] - } - sanitized = strings.TrimLeft(sanitized, `\/`) - - resolved := filepath.Clean(filepath.Join(cleanWorkDir, sanitized)) - - if !pathWithin(resolved, cleanWorkDir) { - return "", errPathOutsideWorkDir - } - - return resolved, nil -} - -// pathWithin reports whether resolved is equal to or under base, respecting OS path -// case rules (case-insensitive on Windows) and requiring a separator boundary so that -// /a/foo is not considered within /a/foobar. -func pathWithin(resolved, base string) bool { - eq := strings.EqualFold - if runtime.GOOS != "windows" { - eq = func(a, b string) bool { return a == b } - } - - if eq(resolved, base) { - return true - } - - if len(resolved) <= len(base) { - return false - } - - if !eq(resolved[:len(base)], base) { - return false - } - - return resolved[len(base)] == filepath.Separator -} - -func (h *GRPCFileHandler) resolvePath(path string) (string, error) { - return ResolvePath(h.workDir, path) -} diff --git a/internal/app/grpc/file_handler_test.go b/internal/app/grpc/file_handler_test.go index 66cf750..8835757 100644 --- a/internal/app/grpc/file_handler_test.go +++ b/internal/app/grpc/file_handler_test.go @@ -1,168 +1,119 @@ package grpc import ( + "context" + "os" "path/filepath" "runtime" "testing" + pb "github.com/gameap/gameap/pkg/proto" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func TestResolvePath(t *testing.T) { - t.Run("relative_path_resolves_under_workdir", func(t *testing.T) { - workDir := filepath.Join(t.TempDir(), "work") +func TestGRPCFileHandler_ReadWriteWithinWorkDir(t *testing.T) { + workDir := t.TempDir() + h := NewGRPCFileHandler(workDir) + ctx := context.Background() - resolved, err := ResolvePath(workDir, "servers/x") - - require.NoError(t, err) - assert.Equal(t, filepath.Join(workDir, "servers", "x"), resolved) - }) - - t.Run("empty_path_resolves_to_workdir", func(t *testing.T) { - workDir := filepath.Join(t.TempDir(), "work") - - resolved, err := ResolvePath(workDir, "") - - require.NoError(t, err) - assert.Equal(t, filepath.Clean(workDir), resolved) - }) - - t.Run("dot_resolves_to_workdir", func(t *testing.T) { - workDir := filepath.Join(t.TempDir(), "work") - - resolved, err := ResolvePath(workDir, ".") - - require.NoError(t, err) - assert.Equal(t, filepath.Clean(workDir), resolved) - }) - - t.Run("escape_via_dot_dot_is_rejected", func(t *testing.T) { - workDir := filepath.Join(t.TempDir(), "work") - - _, err := ResolvePath(workDir, "../etc/passwd") - - require.Error(t, err) - assert.Contains(t, err.Error(), "outside work directory") + wresp, err := h.HandleFileWrite(ctx, "w", &pb.FileWriteRequest{ + Path: "sub/f.txt", Content: []byte("hello"), Mode: 0o644, CreateDirs: true, }) + require.NoError(t, err) + require.True(t, wresp.Success, wresp.Error) - t.Run("forward_slash_leading_separator_is_stripped", func(t *testing.T) { - workDir := filepath.Join(t.TempDir(), "work") - - resolved, err := ResolvePath(workDir, "/servers/x") - - require.NoError(t, err) - assert.Equal(t, filepath.Join(workDir, "servers", "x"), resolved) - }) - - t.Run("backslash_leading_separator_is_stripped", func(t *testing.T) { - workDir := filepath.Join(t.TempDir(), "work") - - resolved, err := ResolvePath(workDir, `\servers\x`) - - require.NoError(t, err) - - expected := filepath.Join(workDir, "servers", "x") - if runtime.GOOS != "windows" { - expected = filepath.Join(workDir, `servers\x`) - } - - assert.Equal(t, expected, resolved) - }) - - t.Run("forward_slash_path_with_workdir_prefix_does_not_double", func(t *testing.T) { - workDir := "/srv/gameap" - - resolved, err := ResolvePath(workDir, "/srv/gameap/servers/x") - - require.NoError(t, err) - - if runtime.GOOS == "windows" { - assert.Equal(t, filepath.Clean(`/srv/gameap/srv/gameap/servers/x`), resolved) - } else { - assert.Equal(t, "/srv/gameap/srv/gameap/servers/x", resolved) - } - }) + rresp, err := h.HandleFileRead(ctx, "r", &pb.FileReadRequest{Path: "sub/f.txt"}) + require.NoError(t, err) + require.True(t, rresp.Success, rresp.Error) + assert.Equal(t, []byte("hello"), rresp.Content) +} - t.Run("workdir_path_is_cleaned", func(t *testing.T) { - workDir := "/srv/gameap/" +func TestGRPCFileHandler_TraversalRejected(t *testing.T) { + workDir := t.TempDir() + h := NewGRPCFileHandler(workDir) - resolved, err := ResolvePath(workDir, "servers/x") + resp, err := h.HandleFileRead(context.Background(), "r", &pb.FileReadRequest{Path: "../etc/passwd"}) - require.NoError(t, err) - assert.Equal(t, filepath.Join("/srv/gameap", "servers", "x"), resolved) - }) + require.NoError(t, err) + require.False(t, resp.Success) + assert.Contains(t, resp.Error, "outside work directory") } -func TestResolvePath_Windows(t *testing.T) { - if runtime.GOOS != "windows" { - t.Skip("Windows-only path behavior") +// TestGRPCFileHandler_SymlinkEscapeBlocked is the regression anchor for this +// change: a symlink inside the work directory that points outside it (the kind +// an unprivileged game-server user can create) must not let any operation +// escape. This fails on the old string-based ResolvePath and passes with +// os.Root confinement. +func TestGRPCFileHandler_SymlinkEscapeBlocked(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlink creation needs privilege on Windows") } - t.Run("absolute_windows_path_with_drive_letter_is_stripped", func(t *testing.T) { - workDir := `C:\gameap` + base := t.TempDir() + workDir := filepath.Join(base, "work") + secret := filepath.Join(base, "secret") + require.NoError(t, os.MkdirAll(workDir, 0o755)) + require.NoError(t, os.MkdirAll(secret, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(secret, "passwd"), []byte("TOPSECRET"), 0o644)) - resolved, err := ResolvePath(workDir, `C:\gameap\servers\x`) + require.NoError(t, os.Symlink(secret, filepath.Join(workDir, "escape"))) + require.NoError(t, os.Symlink(filepath.Join(secret, "passwd"), filepath.Join(workDir, "escape_file"))) - require.NoError(t, err) - assert.Equal(t, `C:\gameap\gameap\servers\x`, resolved) - }) - - t.Run("absolute_windows_path_lowercase_drive_still_stripped", func(t *testing.T) { - workDir := `C:\gameap` - - resolved, err := ResolvePath(workDir, `c:\some\where`) + h := NewGRPCFileHandler(workDir) + ctx := context.Background() + t.Run("read_through_symlink_blocked", func(t *testing.T) { + resp, err := h.HandleFileRead(ctx, "r", &pb.FileReadRequest{Path: "escape_file"}) require.NoError(t, err) - assert.Equal(t, `C:\gameap\some\where`, resolved) + assert.False(t, resp.Success, "must not read a file outside the work directory") + assert.NotEqual(t, []byte("TOPSECRET"), resp.Content) }) - t.Run("case_insensitive_containment_check", func(t *testing.T) { - workDir := `C:\GameAP` - - resolved, err := ResolvePath(workDir, `servers\x`) - + t.Run("write_through_symlink_blocked", func(t *testing.T) { + resp, err := h.HandleFileWrite(ctx, "w", &pb.FileWriteRequest{ + Path: "escape/evil.txt", Content: []byte("x"), Mode: 0o644, + }) require.NoError(t, err) - assert.Equal(t, `C:\GameAP\servers\x`, resolved) + assert.False(t, resp.Success, "must not write through an escaping symlink") + _, statErr := os.Stat(filepath.Join(secret, "evil.txt")) + assert.ErrorIs(t, statErr, os.ErrNotExist, "file must not be created outside the work dir") }) - t.Run("forward_slash_input_resolves_via_windows_separator", func(t *testing.T) { - workDir := `C:\gameap` - - resolved, err := ResolvePath(workDir, "servers/x") - + t.Run("list_through_symlink_blocked", func(t *testing.T) { + resp, err := h.HandleFileList(ctx, "l", &pb.FileListRequest{Path: "escape"}) require.NoError(t, err) - assert.Equal(t, `C:\gameap\servers\x`, resolved) + assert.False(t, resp.Success, "must not list a directory outside the work directory") }) - t.Run("dot_dot_escape_still_rejected_on_windows", func(t *testing.T) { - workDir := `C:\gameap` - - _, err := ResolvePath(workDir, `..\Windows\System32`) - - require.Error(t, err) - assert.Contains(t, err.Error(), "outside work directory") - }) -} - -func TestPathWithin(t *testing.T) { - t.Run("exact_match_is_within", func(t *testing.T) { - assert.True(t, pathWithin("/srv/gameap", "/srv/gameap")) - }) - - t.Run("subpath_is_within", func(t *testing.T) { - assert.True(t, pathWithin(filepath.Join("/srv/gameap", "servers"), "/srv/gameap")) - }) - - t.Run("prefix_without_separator_boundary_is_not_within", func(t *testing.T) { - assert.False(t, pathWithin("/srv/gameap-other", "/srv/gameap")) - }) - - t.Run("unrelated_path_is_not_within", func(t *testing.T) { - assert.False(t, pathWithin("/etc/passwd", "/srv/gameap")) - }) + t.Run("delete_does_not_remove_secret", func(t *testing.T) { + _, err := h.HandleFileOperation(ctx, &pb.FileOperationRequest{ + RequestId: "d", + Operation: pb.FileOperationType_FILE_OPERATION_TYPE_DELETE, + Parameters: &pb.FileOperationRequest_DeleteParams{ + DeleteParams: &pb.DeleteParams{Path: "escape_file"}, + }, + }) + require.NoError(t, err) + _, statErr := os.Stat(filepath.Join(secret, "passwd")) + require.NoError(t, statErr, "deleting the symlink must never delete its target") + }) + + t.Run("copy_does_not_exfiltrate_secret", func(t *testing.T) { + _, err := h.HandleFileOperation(ctx, &pb.FileOperationRequest{ + RequestId: "c", + Operation: pb.FileOperationType_FILE_OPERATION_TYPE_COPY, + Parameters: &pb.FileOperationRequest_CopyParams{ + CopyParams: &pb.CopyParams{Source: "escape_file", Destination: "copied"}, + }, + }) + require.NoError(t, err) - t.Run("shorter_path_is_not_within", func(t *testing.T) { - assert.False(t, pathWithin("/srv", "/srv/gameap")) + rresp, rerr := h.HandleFileRead(ctx, "rc", &pb.FileReadRequest{Path: "copied"}) + require.NoError(t, rerr) + if rresp.Success { + assert.NotContains(t, string(rresp.Content), "TOPSECRET", + "a copy must never materialize the secret inside the work dir") + } }) } diff --git a/internal/app/grpc/file_owner_test.go b/internal/app/grpc/file_owner_test.go index 5607371..0458b4e 100644 --- a/internal/app/grpc/file_owner_test.go +++ b/internal/app/grpc/file_owner_test.go @@ -60,11 +60,17 @@ func TestHandleMkdirOp_WithOwner_CreatesDirectoryTree(t *testing.T) { workDir := t.TempDir() h := NewGRPCFileHandler(workDir) - resp, err := h.handleMkdirOp("req-3", &pb.MkdirParams{ - Path: "a/b/c", - Recursive: true, - Mode: 0o755, - OwnerUser: "gameap", + resp, err := h.HandleFileOperation(context.Background(), &pb.FileOperationRequest{ + RequestId: "req-3", + Operation: pb.FileOperationType_FILE_OPERATION_TYPE_MKDIR, + Parameters: &pb.FileOperationRequest_MkdirParams{ + MkdirParams: &pb.MkdirParams{ + Path: "a/b/c", + Recursive: true, + Mode: 0o755, + OwnerUser: "gameap", + }, + }, }) require.NoError(t, err) @@ -82,11 +88,17 @@ func TestHandleMkdirOp_NonRecursive_OnlyTargetSegmentNew(t *testing.T) { require.NoError(t, os.MkdirAll(filepath.Join(workDir, "existing"), 0o755)) h := NewGRPCFileHandler(workDir) - resp, err := h.handleMkdirOp("req-4", &pb.MkdirParams{ - Path: "existing/new", - Recursive: false, - Mode: 0o750, - OwnerUser: "gameap", + resp, err := h.HandleFileOperation(context.Background(), &pb.FileOperationRequest{ + RequestId: "req-4", + Operation: pb.FileOperationType_FILE_OPERATION_TYPE_MKDIR, + Parameters: &pb.FileOperationRequest_MkdirParams{ + MkdirParams: &pb.MkdirParams{ + Path: "existing/new", + Recursive: false, + Mode: 0o750, + OwnerUser: "gameap", + }, + }, }) require.NoError(t, err) diff --git a/internal/app/grpc/transfer_handler.go b/internal/app/grpc/transfer_handler.go index ba03523..a2a72bb 100644 --- a/internal/app/grpc/transfer_handler.go +++ b/internal/app/grpc/transfer_handler.go @@ -7,9 +7,10 @@ import ( "hash" "io" "os" - "path/filepath" + "path" "sync" + "github.com/gameap/daemon/internal/app/fsutil" "github.com/gameap/daemon/internal/app/osowner" pb "github.com/gameap/gameap/pkg/proto" "github.com/pkg/errors" @@ -47,6 +48,19 @@ func NewGRPCTransferHandler( } } +// openRoot opens an os.Root at the work directory so every caller-supplied +// path is resolved component-by-component without symlink/".." escapes or +// TOCTOU races. Opened per request because workDir is provisioned from the +// API after construction. +func (h *GRPCTransferHandler) openRoot() (*os.Root, error) { + root, err := os.OpenRoot(h.workDir) + if err != nil { + return nil, errors.Wrap(err, "work directory unavailable") + } + + return root, nil +} + // HandleFileUploadTask handles a file upload task from the API. // The API wants the daemon to download a file FROM the API and save it locally. func (h *GRPCTransferHandler) HandleFileUploadTask(ctx context.Context, requestID string, task *pb.FileUploadTask) { @@ -58,18 +72,26 @@ func (h *GRPCTransferHandler) HandleFileUploadTask(ctx context.Context, requestI l.Info("Handling file upload task (download from API)") - targetPath, err := ResolvePath(h.workDir, task.Path) + root, err := h.openRoot() + if err != nil { + l.WithError(err).Error("Failed to open work directory") + h.sendResponse(requestID, false, err.Error()) + return + } + defer root.Close() + + rel, err := fsutil.RootRel(task.Path) if err != nil { l.WithError(err).Error("Failed to resolve path") h.sendResponse(requestID, false, err.Error()) return } - tempPath := targetPath + ".tmp_" + task.TransferId + tempRel := rel + ".tmp_" + task.TransferId // Idempotency check: if target file already exists with matching checksum, skip. if task.ChecksumSha256 != "" { - if existingChecksum, checksumErr := computeFileChecksum(targetPath); checksumErr == nil { + if existingChecksum, checksumErr := computeFileChecksum(root, rel); checksumErr == nil { if existingChecksum == task.ChecksumSha256 { l.Info("File already exists with matching checksum, skipping download") h.sendResponse(requestID, true, "") @@ -89,14 +111,14 @@ func (h *GRPCTransferHandler) HandleFileUploadTask(ctx context.Context, requestI var offset int64 var hasher hash.Hash - if info, statErr := os.Stat(tempPath); statErr == nil && info.Size() > 0 { + if info, statErr := root.Stat(tempRel); statErr == nil && info.Size() > 0 { offset = info.Size() - hasher, err = hashFileRange(tempPath, offset) + hasher, err = hashFileRange(root, tempRel, offset) if err != nil { l.WithError(err).Warn("Failed to hash partial temp file, starting fresh") offset = 0 hasher = sha256.New() - os.Remove(tempPath) + _ = root.Remove(tempRel) } else { l.WithField("offset", offset).Info("Resuming download from offset") } @@ -135,22 +157,22 @@ func (h *GRPCTransferHandler) HandleFileUploadTask(ctx context.Context, requestI GID: task.OwnerGid, } - parentDir := filepath.Dir(targetPath) - newDirs, segErr := osowner.MissingSegments(parentDir) + parentRel := path.Dir(rel) + newDirs, segErr := osowner.MissingSegmentsInRoot(root, parentRel) if segErr != nil { l.WithError(segErr).Error("Failed to stat parent directory") h.sendResponse(requestID, false, segErr.Error()) return } - if mkErr := os.MkdirAll(parentDir, 0755); mkErr != nil { + if mkErr := root.MkdirAll(parentRel, 0755); mkErr != nil { l.WithError(mkErr).Error("Failed to create parent directory") h.sendResponse(requestID, false, mkErr.Error()) return } for _, dir := range newDirs { - if chErr := osowner.ApplyToPath(dir, owner); chErr != nil { + if chErr := osowner.ApplyToPathInRoot(root, dir, owner); chErr != nil { l.WithError(chErr).WithField("dir", dir).Error("Failed to chown new parent directory") h.sendResponse(requestID, false, chErr.Error()) return @@ -162,11 +184,11 @@ func (h *GRPCTransferHandler) HandleFileUploadTask(ctx context.Context, requestI fileMode = os.FileMode(task.Mode) & os.ModePerm } - file, err := os.OpenFile(tempPath, fileFlags, fileMode) + file, err := root.OpenFile(tempRel, fileFlags, fileMode) if err != nil { if offset > 0 { hasher = sha256.New() - file, err = os.OpenFile(tempPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, fileMode) + file, err = root.OpenFile(tempRel, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, fileMode) } if err != nil { l.WithError(err).Error("Failed to open temp file") @@ -197,7 +219,7 @@ func (h *GRPCTransferHandler) HandleFileUploadTask(ctx context.Context, requestI computedChecksum := hex.EncodeToString(hasher.Sum(nil)) if task.ChecksumSha256 != "" && computedChecksum != task.ChecksumSha256 { - os.Remove(tempPath) + _ = root.Remove(tempRel) errMsg := "checksum mismatch: expected " + task.ChecksumSha256 + ", got " + computedChecksum l.Error(errMsg) h.sendResponse(requestID, false, errMsg) @@ -205,21 +227,21 @@ func (h *GRPCTransferHandler) HandleFileUploadTask(ctx context.Context, requestI } if receivedChecksum != "" && computedChecksum != receivedChecksum { - os.Remove(tempPath) + _ = root.Remove(tempRel) errMsg := "stream checksum mismatch: expected " + receivedChecksum + ", got " + computedChecksum l.Error(errMsg) h.sendResponse(requestID, false, errMsg) return } - if chErr := osowner.ApplyToPath(tempPath, owner); chErr != nil { + if chErr := osowner.ApplyToPathInRoot(root, tempRel, owner); chErr != nil { l.WithError(chErr).Error("Failed to chown temp file before rename") - _ = os.Remove(tempPath) + _ = root.Remove(tempRel) h.sendResponse(requestID, false, chErr.Error()) return } - if err := os.Rename(tempPath, targetPath); err != nil { + if err := root.Rename(tempRel, rel); err != nil { l.WithError(err).Error("Failed to rename temp file to target") h.sendResponse(requestID, false, err.Error()) return @@ -240,14 +262,22 @@ func (h *GRPCTransferHandler) HandleFileDownloadTask(ctx context.Context, reques l.Info("Handling file download task (upload to API)") - localPath, err := ResolvePath(h.workDir, task.Path) + root, err := h.openRoot() + if err != nil { + l.WithError(err).Error("Failed to open work directory") + h.sendResponse(requestID, false, err.Error()) + return + } + defer root.Close() + + rel, err := fsutil.RootRel(task.Path) if err != nil { l.WithError(err).Error("Failed to resolve path") h.sendResponse(requestID, false, err.Error()) return } - info, err := os.Stat(localPath) + info, err := root.Stat(rel) if err != nil { l.WithError(err).Error("File not found") h.sendResponse(requestID, false, err.Error()) @@ -261,7 +291,7 @@ func (h *GRPCTransferHandler) HandleFileDownloadTask(ctx context.Context, reques // Dedup check: if API provided an expected checksum and local file matches, skip upload. if task.ChecksumSha256 != "" { - localChecksum, checksumErr := computeFileChecksum(localPath) + localChecksum, checksumErr := computeFileChecksum(root, rel) if checksumErr == nil && localChecksum == task.ChecksumSha256 { l.Info("File checksum matches expected, skipping upload (dedup)") h.sendResponse(requestID, true, "") @@ -286,7 +316,7 @@ func (h *GRPCTransferHandler) HandleFileDownloadTask(ctx context.Context, reques defer h.sem.Release(1) // Open local file. - file, err := os.Open(localPath) + file, err := root.Open(rel) if err != nil { l.WithError(err).Error("Failed to open file") h.sendResponse(requestID, false, err.Error()) @@ -323,9 +353,9 @@ func (h *GRPCTransferHandler) sendResponse(requestID string, success bool, errMs }) } -// computeFileChecksum computes the SHA256 checksum of an entire file. -func computeFileChecksum(path string) (string, error) { - file, err := os.Open(path) +// computeFileChecksum computes the SHA256 checksum of an entire file inside root. +func computeFileChecksum(root *os.Root, rel string) (string, error) { + file, err := root.Open(rel) if err != nil { return "", err } @@ -339,10 +369,10 @@ func computeFileChecksum(path string) (string, error) { return hex.EncodeToString(hasher.Sum(nil)), nil } -// hashFileRange reads the first n bytes of a file into a SHA256 hasher. -// Used to restore hash state when resuming a partial download. -func hashFileRange(path string, n int64) (hash.Hash, error) { - file, err := os.Open(path) +// hashFileRange reads the first n bytes of a file inside root into a SHA256 +// hasher. Used to restore hash state when resuming a partial download. +func hashFileRange(root *os.Root, rel string, n int64) (hash.Hash, error) { + file, err := root.Open(rel) if err != nil { return nil, err } diff --git a/internal/app/osowner/owner.go b/internal/app/osowner/owner.go index 53a4628..4cc943a 100644 --- a/internal/app/osowner/owner.go +++ b/internal/app/osowner/owner.go @@ -17,6 +17,7 @@ import ( "errors" "os" "os/user" + "path" "path/filepath" "strconv" ) @@ -187,3 +188,47 @@ func MissingSegments(target string) ([]string, error) { return missing, nil } + +// MissingSegmentsInRoot is the os.Root-confined counterpart of MissingSegments. +// rel must be a clean, slash-separated root-relative path ("." allowed). It +// returns the rel prefixes that do not exist yet, shallowest first, so callers +// can chown only the directories they are about to create and leave the +// ownership of pre-existing (often shared) parents untouched. +func MissingSegmentsInRoot(root *os.Root, rel string) ([]string, error) { + rel = path.Clean(rel) + if rel == "." || rel == "/" { + return nil, nil + } + if _, err := root.Lstat(rel); err == nil { + return nil, nil + } else if !errors.Is(err, os.ErrNotExist) { + return nil, err + } + + var missing []string + for p := rel; p != "." && p != "/" && p != path.Dir(p); p = path.Dir(p) { + if _, err := root.Lstat(p); err == nil { + break + } else if !errors.Is(err, os.ErrNotExist) { + return nil, err + } + missing = append([]string{p}, missing...) + } + + return missing, nil +} + +// ApplyToPathInRoot chowns a single rel path inside root (Lchown — symlink +// safe). No-op when chown is not applicable (non-root daemon, empty Options, +// Windows) — same gate as ApplyToPath. +func ApplyToPathInRoot(root *os.Root, rel string, opts Options) error { + uid, gid, ok, err := Resolve(opts) + if err != nil { + return err + } + if !ok { + return nil + } + + return lchownInRoot(root, rel, uid, gid) +} diff --git a/internal/app/osowner/owner_test.go b/internal/app/osowner/owner_test.go index 5cb1161..da0e356 100644 --- a/internal/app/osowner/owner_test.go +++ b/internal/app/osowner/owner_test.go @@ -118,6 +118,79 @@ func TestMissingSegments_TargetIsExistingFileReturnsEmpty(t *testing.T) { assert.Empty(t, segments) } +func TestMissingSegmentsInRoot_TargetAlreadyExistsReturnsEmpty(t *testing.T) { + root, err := os.OpenRoot(t.TempDir()) + require.NoError(t, err) + defer root.Close() + require.NoError(t, root.Mkdir("existing", 0o755)) + + segments, err := MissingSegmentsInRoot(root, "existing") + + require.NoError(t, err) + assert.Empty(t, segments) +} + +func TestMissingSegmentsInRoot_AllSegmentsMissingReturnsAllInOrder(t *testing.T) { + root, err := os.OpenRoot(t.TempDir()) + require.NoError(t, err) + defer root.Close() + + segments, err := MissingSegmentsInRoot(root, "a/b/c") + + require.NoError(t, err) + require.Len(t, segments, 3) + assert.Equal(t, "a", segments[0], "shallowest missing segment first") + assert.Equal(t, "a/b", segments[1]) + assert.Equal(t, "a/b/c", segments[2], "deepest (target) last") +} + +func TestMissingSegmentsInRoot_SomeSegmentsExistReturnsOnlyMissing(t *testing.T) { + root, err := os.OpenRoot(t.TempDir()) + require.NoError(t, err) + defer root.Close() + require.NoError(t, root.MkdirAll("a/b", 0o755)) + + segments, err := MissingSegmentsInRoot(root, "a/b/c/d") + + require.NoError(t, err) + require.Len(t, segments, 2) + assert.Equal(t, "a/b/c", segments[0]) + assert.Equal(t, "a/b/c/d", segments[1]) +} + +func TestMissingSegmentsInRoot_RootItselfReturnsEmpty(t *testing.T) { + root, err := os.OpenRoot(t.TempDir()) + require.NoError(t, err) + defer root.Close() + + segments, err := MissingSegmentsInRoot(root, ".") + + require.NoError(t, err) + assert.Empty(t, segments) +} + +func TestApplyToPathInRoot_EmptyOptionsIsNoop(t *testing.T) { + root, err := os.OpenRoot(t.TempDir()) + require.NoError(t, err) + defer root.Close() + require.NoError(t, root.WriteFile("file.txt", []byte("x"), 0o644)) + + err = ApplyToPathInRoot(root, "file.txt", Options{}) + + require.NoError(t, err) +} + +func TestApplyToPathInRoot_NonRootDaemonIsNoop(t *testing.T) { + root, err := os.OpenRoot(t.TempDir()) + require.NoError(t, err) + defer root.Close() + require.NoError(t, root.WriteFile("file.txt", []byte("x"), 0o644)) + + err = ApplyToPathInRoot(root, "file.txt", Options{User: "nonexistentuser_4j2k9c"}) + + require.NoError(t, err, "non-root daemon must not even attempt user.Lookup") +} + func TestApplyGroupSharedRecursive_EmptyOptionsIsNoop(t *testing.T) { tempDir := t.TempDir() require.NoError(t, os.Mkdir(filepath.Join(tempDir, "a"), 0o755)) diff --git a/internal/app/osowner/owner_unix.go b/internal/app/osowner/owner_unix.go index da8ce72..1789196 100644 --- a/internal/app/osowner/owner_unix.go +++ b/internal/app/osowner/owner_unix.go @@ -25,6 +25,10 @@ func lchown(path string, uid, gid int) error { return os.Lchown(path, uid, gid) } +func lchownInRoot(root *os.Root, name string, uid, gid int) error { + return root.Lchown(name, uid, gid) +} + func chownTree(path string, uid, gid int) error { root, err := os.OpenRoot(path) if err != nil { diff --git a/internal/app/osowner/owner_windows.go b/internal/app/osowner/owner_windows.go index db78f39..103a57e 100644 --- a/internal/app/osowner/owner_windows.go +++ b/internal/app/osowner/owner_windows.go @@ -3,6 +3,7 @@ package osowner import ( + "os" "os/user" log "github.com/sirupsen/logrus" @@ -23,6 +24,10 @@ func lchown(_ string, _, _ int) error { return nil } +func lchownInRoot(_ *os.Root, _ string, _, _ int) error { + return nil +} + func chownTree(_ string, _, _ int) error { return nil } diff --git a/internal/app/server/files/files.go b/internal/app/server/files/files.go index 1f986f0..08a6768 100644 --- a/internal/app/server/files/files.go +++ b/internal/app/server/files/files.go @@ -4,15 +4,16 @@ import ( "context" "fmt" "io" + "io/fs" "os" - "path/filepath" + "path" "time" "github.com/et-nik/binngo/decode" + "github.com/gameap/daemon/internal/app/fsutil" "github.com/gameap/daemon/internal/app/server/response" servercommon "github.com/gameap/daemon/internal/app/server/server_common" "github.com/gameap/daemon/pkg/logger" - "github.com/otiai10/copy" "github.com/pkg/errors" log "github.com/sirupsen/logrus" ) @@ -32,23 +33,37 @@ type connDeadlineSetter interface { type operationHandlerFunc func(ctx context.Context, message anyMessage, readWriter io.ReadWriter) error type Files struct { + workPath string handlers map[Operation]operationHandlerFunc } -func NewFiles() *Files { - handlers := map[Operation]operationHandlerFunc{ - FileSend: fileSend, - ReadDir: readDir, - MakeDir: makeDir, - FileMove: moveCopy, - FileRemove: remove, - FileInfo: fileInfo, - FileChmod: chmod, +func NewFiles(workPath string) *Files { + f := &Files{workPath: workPath} + + f.handlers = map[Operation]operationHandlerFunc{ + FileSend: f.fileSend, + ReadDir: f.readDir, + MakeDir: f.makeDir, + FileMove: f.moveCopy, + FileRemove: f.remove, + FileInfo: f.fileInfo, + FileChmod: f.chmod, } - return &Files{ - handlers: handlers, + return f +} + +// openRoot opens an os.Root at the configured work directory. All legacy file +// operations are confined to it: paths supplied by the client are resolved +// component-by-component through this root, which refuses symlink and ".." +// escapes without TOCTOU races on both Linux and Windows. +func (f *Files) openRoot() (*os.Root, error) { + root, err := os.OpenRoot(f.workPath) + if err != nil { + return nil, errors.Wrap(err, "work directory unavailable") } + + return root, nil } func (f *Files) Handle(ctx context.Context, readWriter io.ReadWriter) error { @@ -108,13 +123,24 @@ func writeError(readWriter io.Writer, message string) error { }) } -func readDir(ctx context.Context, m anyMessage, readWriter io.ReadWriter) error { +func (f *Files) readDir(ctx context.Context, m anyMessage, readWriter io.ReadWriter) error { message, err := createReadDirMessage(m) if message == nil || err != nil { return writeError(readWriter, "Invalid message") } - dir, err := os.ReadDir(message.Directory) + root, err := f.openRoot() + if err != nil { + return writeError(readWriter, err.Error()) + } + defer root.Close() + + rel, err := fsutil.RootRel(message.Directory) + if err != nil { + return writeError(readWriter, err.Error()) + } + + dir, err := fs.ReadDir(root.FS(), rel) if err != nil && errors.Is(err, os.ErrNotExist) { logger.Logger(ctx).WithFields( log.Fields{ @@ -133,8 +159,8 @@ func readDir(ctx context.Context, m anyMessage, readWriter io.ReadWriter) error resp := make([]*fileInfoResponse, len(dir)) - for i, f := range dir { - fi, err := f.Info() + for i, entry := range dir { + fi, err := entry.Info() if err != nil { continue } @@ -148,13 +174,24 @@ func readDir(ctx context.Context, m anyMessage, readWriter io.ReadWriter) error }) } -func makeDir(ctx context.Context, m anyMessage, readWriter io.ReadWriter) error { +func (f *Files) makeDir(ctx context.Context, m anyMessage, readWriter io.ReadWriter) error { message, err := createMkDirMessage(m) if message == nil || err != nil { return writeError(readWriter, "Invalid message") } - err = os.MkdirAll(message.Directory, os.ModePerm) + root, err := f.openRoot() + if err != nil { + return writeError(readWriter, err.Error()) + } + defer root.Close() + + rel, err := fsutil.RootRel(message.Directory) + if err != nil { + return writeError(readWriter, err.Error()) + } + + err = root.MkdirAll(rel, os.ModePerm) if err != nil { logger.Error(ctx, err) return writeError(readWriter, "Failed to make directory") @@ -165,36 +202,44 @@ func makeDir(ctx context.Context, m anyMessage, readWriter io.ReadWriter) error }) } -func moveCopy(ctx context.Context, m anyMessage, readWriter io.ReadWriter) error { +func (f *Files) moveCopy(ctx context.Context, m anyMessage, readWriter io.ReadWriter) error { message, err := createMoveMessage(m) if message == nil || err != nil { return writeError(readWriter, "Invalid message") } - if _, err := os.Stat(message.Source); errors.Is(err, os.ErrNotExist) { + root, err := f.openRoot() + if err != nil { + return writeError(readWriter, err.Error()) + } + defer root.Close() + + srcRel, err := fsutil.RootRel(message.Source) + if err != nil { + return writeError(readWriter, err.Error()) + } + + dstRel, err := fsutil.RootRel(message.Destination) + if err != nil { + return writeError(readWriter, err.Error()) + } + + if _, err := root.Stat(srcRel); errors.Is(err, os.ErrNotExist) { return writeError(readWriter, fmt.Sprintf("Source \"%s\" not found", message.Source)) } - if _, err := os.Stat(message.Destination); !errors.Is(err, os.ErrNotExist) { + if _, err := root.Stat(dstRel); !errors.Is(err, os.ErrNotExist) { return writeError(readWriter, fmt.Sprintf("Destination \"%s\" already exists", message.Destination)) } if message.Copy { - err := copy.Copy( - message.Source, - message.Destination, - copy.Options{ - OnSymlink: func(_ string) copy.SymlinkAction { - return copy.Shallow - }, - }, - ) + err := fsutil.CopyInRoot(root, srcRel, dstRel, fsutil.CopyOptions{Symlink: fsutil.SymlinkShallow}) if err != nil { logger.Error(ctx, err) return writeError(readWriter, "Failed to copy") } } else { - err := os.Rename(message.Source, message.Destination) + err := root.Rename(srcRel, dstRel) if err != nil { logger.Error(ctx, err) return writeError(readWriter, "Failed to move") @@ -206,7 +251,7 @@ func moveCopy(ctx context.Context, m anyMessage, readWriter io.ReadWriter) error }) } -func fileSend(ctx context.Context, m anyMessage, readWriter io.ReadWriter) error { +func (f *Files) fileSend(ctx context.Context, m anyMessage, readWriter io.ReadWriter) error { if len(m) < 2 { return writeError(readWriter, "Invalid message") } @@ -223,15 +268,15 @@ func fileSend(ctx context.Context, m anyMessage, readWriter io.ReadWriter) error switch op { case SendFileToClient: - return sendFileToClient(ctx, m, readWriter) + return f.sendFileToClient(ctx, m, readWriter) case GetFileFromClient: - return getFileFromClient(ctx, m, readWriter) + return f.getFileFromClient(ctx, m, readWriter) default: return writeError(readWriter, "Invalid file send operation") } } -func sendFileToClient(ctx context.Context, m anyMessage, readWriter io.ReadWriter) error { +func (f *Files) sendFileToClient(ctx context.Context, m anyMessage, readWriter io.ReadWriter) error { message, err := createSendFileToClientMessage(m) if message == nil || err != nil { return writeError(readWriter, "Invalid message") @@ -241,20 +286,28 @@ func sendFileToClient(ctx context.Context, m anyMessage, readWriter io.ReadWrite "filepath": message.FilePath, })) - fi, err := os.Stat(message.FilePath) + root, err := f.openRoot() + if err != nil { + return writeError(readWriter, err.Error()) + } + defer root.Close() + + rel, err := fsutil.RootRel(message.FilePath) + if err != nil { + return writeError(readWriter, err.Error()) + } + + fi, err := root.Stat(rel) if err != nil { logger.Error(ctx, err) return writeError(readWriter, fmt.Sprintf("File \"%s\" error", message.FilePath)) } - if errors.Is(err, os.ErrNotExist) { - return writeError(readWriter, fmt.Sprintf("File \"%s\" not found", message.FilePath)) - } if !fi.Mode().IsRegular() { return writeError(readWriter, fmt.Sprintf("\"%s\" is not a file", message.FilePath)) } - file, err := os.Open(message.FilePath) + file, err := root.Open(rel) defer func(file *os.File) { err := file.Close() @@ -289,7 +342,7 @@ func sendFileToClient(ctx context.Context, m anyMessage, readWriter io.ReadWrite } //nolint:funlen -func getFileFromClient(ctx context.Context, m anyMessage, readWriter io.ReadWriter) error { +func (f *Files) getFileFromClient(ctx context.Context, m anyMessage, readWriter io.ReadWriter) error { message, err := createGetFileFromClientMessage(m) if message == nil || err != nil { return writeError(readWriter, "Invalid message") @@ -302,13 +355,24 @@ func getFileFromClient(ctx context.Context, m anyMessage, readWriter io.ReadWrit logger.Debug(ctx, "Starting transferring file from client") - dir := filepath.Dir(message.FilePath) - _, err = os.Stat(dir) + root, err := f.openRoot() + if err != nil { + return writeError(readWriter, err.Error()) + } + defer root.Close() + + rel, err := fsutil.RootRel(message.FilePath) + if err != nil { + return writeError(readWriter, err.Error()) + } + + dir := path.Dir(rel) + _, err = root.Stat(dir) //nolint:nestif if err != nil && errors.Is(err, os.ErrNotExist) { if message.MakeDirs { - err := os.MkdirAll(dir, 0755) + err := root.MkdirAll(dir, 0755) if err != nil { logger.Error(ctx, err) return writeError(readWriter, fmt.Sprintf("Failed to make directory \"%s\"", dir)) @@ -321,21 +385,23 @@ func getFileFromClient(ctx context.Context, m anyMessage, readWriter io.ReadWrit return writeError(readWriter, fmt.Sprintf("Directory \"%s\" error", dir)) } - tmpFile, err := os.CreateTemp("", filepath.Base(message.FilePath)) + var permissions os.FileMode = 0o666 + if stat, statErr := root.Stat(rel); statErr == nil { + permissions = stat.Mode().Perm() + } + + tmpRel := rel + ".upload_tmp" + tmpFile, err := root.OpenFile(tmpRel, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, permissions) if err != nil { logger.Error(ctx, errors.WithMessage(err, "failed to create temp file")) return writeError(readWriter, "Failed to create temp file") } - defer func(file *os.File) { - err = file.Close() - if err != nil { - logger.Error(ctx, errors.WithMessage(err, "failed to close temp file")) - } - err = os.Remove(file.Name()) - if err != nil { - logger.Error(ctx, errors.WithMessage(err, "failed to remove temp file")) - } - }(tmpFile) + defer func() { + // On the success path the temp file has already been closed and + // renamed away; both calls then fail harmlessly. + _ = tmpFile.Close() + _ = root.Remove(tmpRel) + }() logger.Logger(ctx).WithFields(log.Fields{ "filepath": message.FilePath, @@ -385,14 +451,18 @@ func getFileFromClient(ctx context.Context, m anyMessage, readWriter io.ReadWrit return writeError(readWriter, "Failed to transfer file") } - var permissions os.FileMode = 0o666 - if stat, err := os.Stat(message.FilePath); err == nil { - permissions = stat.Mode().Perm() + if closeErr := tmpFile.Close(); closeErr != nil { + logger.Error(ctx, errors.WithMessage(closeErr, "failed to close temp file")) + return writeError(readWriter, "Failed to finalize upload") } - err = copy.Copy(tmpFile.Name(), message.FilePath, copy.Options{AddPermission: permissions}) - if err != nil { - logger.Error(ctx, errors.WithMessage(err, "failed to copy tmp file")) + if chErr := root.Chmod(tmpRel, permissions); chErr != nil { + logger.Error(ctx, errors.WithMessage(chErr, "failed to set file permissions")) + return writeError(readWriter, "Failed to set file permissions") + } + + if mvErr := root.Rename(tmpRel, rel); mvErr != nil { + logger.Error(ctx, errors.WithMessage(mvErr, "failed to move uploaded file into place")) return writeError(readWriter, "Failed to copy tmp file") } @@ -403,26 +473,35 @@ func getFileFromClient(ctx context.Context, m anyMessage, readWriter io.ReadWrit }) } -func remove(ctx context.Context, m anyMessage, readWriter io.ReadWriter) error { +func (f *Files) remove(ctx context.Context, m anyMessage, readWriter io.ReadWriter) error { msg, err := createRemoveMessage(m) if msg == nil || err != nil { return writeError(readWriter, "Invalid message") } - cleanedPath := filepath.Clean(msg.Path) + root, err := f.openRoot() + if err != nil { + return writeError(readWriter, err.Error()) + } + defer root.Close() - if cleanedPath == "." || cleanedPath == "/" { + rel, err := fsutil.RootRel(msg.Path) + if err != nil { + return writeError(readWriter, err.Error()) + } + + if rel == "." { return writeError(readWriter, "Invalid path") } - if _, err = os.Stat(cleanedPath); errors.Is(err, os.ErrNotExist) { + if _, err = root.Stat(rel); errors.Is(err, os.ErrNotExist) { return writeError(readWriter, "Path not exist") } if msg.Recursive { - err = os.RemoveAll(cleanedPath) + err = root.RemoveAll(rel) } else { - err = os.Remove(cleanedPath) + err = root.Remove(rel) } if err != nil { @@ -435,13 +514,24 @@ func remove(ctx context.Context, m anyMessage, readWriter io.ReadWriter) error { }) } -func fileInfo(_ context.Context, m anyMessage, readWriter io.ReadWriter) error { +func (f *Files) fileInfo(_ context.Context, m anyMessage, readWriter io.ReadWriter) error { msg, err := createFileInfoMessage(m) if msg == nil || err != nil { return writeError(readWriter, "Invalid message") } - r, err := createfileDetailsResponse(msg.Path) + root, err := f.openRoot() + if err != nil { + return writeError(readWriter, err.Error()) + } + defer root.Close() + + rel, err := fsutil.RootRel(msg.Path) + if err != nil { + return writeError(readWriter, err.Error()) + } + + r, err := createfileDetailsResponse(root, rel) if err != nil { return writeError(readWriter, "Failed to read file details") } @@ -452,13 +542,24 @@ func fileInfo(_ context.Context, m anyMessage, readWriter io.ReadWriter) error { }) } -func chmod(_ context.Context, m anyMessage, readWriter io.ReadWriter) error { +func (f *Files) chmod(_ context.Context, m anyMessage, readWriter io.ReadWriter) error { msg, err := createChmodMessage(m) if msg == nil || err != nil { return writeError(readWriter, "Invalid message") } - err = os.Chmod(msg.Path, os.FileMode(msg.Perm)) + root, err := f.openRoot() + if err != nil { + return writeError(readWriter, err.Error()) + } + defer root.Close() + + rel, err := fsutil.RootRel(msg.Path) + if err != nil { + return writeError(readWriter, err.Error()) + } + + err = root.Chmod(rel, os.FileMode(msg.Perm)) if err != nil && errors.Is(err, os.ErrNotExist) { return writeError(readWriter, "Path not exist") } diff --git a/internal/app/server/files/response.go b/internal/app/server/files/response.go index e244d2e..a52c4f2 100644 --- a/internal/app/server/files/response.go +++ b/internal/app/server/files/response.go @@ -67,8 +67,8 @@ type fileDetailsResponse struct { Type uint8 } -func createfileDetailsResponse(path string) (*fileDetailsResponse, error) { - fi, err := os.Lstat(path) +func createfileDetailsResponse(root *os.Root, rel string) (*fileDetailsResponse, error) { + fi, err := root.Lstat(rel) if err != nil { return nil, err } @@ -78,7 +78,13 @@ func createfileDetailsResponse(path string) (*fileDetailsResponse, error) { var mime string if fType == TypeFile && fi.Size() > 0 { - mm, err := mimetype.DetectFile(path) + file, err := root.Open(rel) + if err != nil { + return nil, err + } + + mm, err := mimetype.DetectReader(file) + _ = file.Close() if err != nil { return nil, err diff --git a/internal/app/server/server.go b/internal/app/server/server.go index 42fc105..71737db 100644 --- a/internal/app/server/server.go +++ b/internal/app/server/server.go @@ -38,6 +38,7 @@ type Server struct { quit chan struct{} ip string + workPath string certPEM []byte keyPEM []byte credConfig CredentialsConfig @@ -53,6 +54,7 @@ type componentHandler interface { func NewServer( ip string, port int, + workPath string, certPEM []byte, keyPEM []byte, credConfig CredentialsConfig, @@ -62,6 +64,7 @@ func NewServer( return &Server{ ip: ip, port: port, + workPath: workPath, certPEM: certPEM, keyPEM: keyPEM, credConfig: credConfig, @@ -211,7 +214,7 @@ func (srv *Server) serveComponent(ctx context.Context, conn net.Conn, m Mode) er case ModeCommands: handler = commands.NewCommands(srv.executor) case ModeFiles: - handler = files.NewFiles() + handler = files.NewFiles(srv.workPath) case ModeStatus: handler = status.NewStatus(srv.taskStatsReader) default: diff --git a/internal/app/services/runner.go b/internal/app/services/runner.go index 5454a92..bde661b 100644 --- a/internal/app/services/runner.go +++ b/internal/app/services/runner.go @@ -108,6 +108,7 @@ func (r *Runner) RunGDaemonServer(ctx context.Context, cfg *config.Config) func( srv, err := server.NewServer( cfg.ListenIP, cfg.ListenPort, + cfg.WorkPath, certPEM, keyPEM, server.CredentialsConfig{ diff --git a/test/functional/gdtasks/commands/suite_test.go b/test/functional/gdtasks/commands/suite_test.go index d220005..65c2b08 100644 --- a/test/functional/gdtasks/commands/suite_test.go +++ b/test/functional/gdtasks/commands/suite_test.go @@ -5,8 +5,8 @@ import ( "path/filepath" "testing" + "github.com/gameap/daemon/internal/app/fsutil" "github.com/gameap/daemon/test/functional/gdtasks" - "github.com/otiai10/copy" "github.com/stretchr/testify/suite" ) @@ -33,7 +33,7 @@ func (suite *Suite) SetupTest() { suite.T().Fatal(err) } - err = copy.Copy("../../../servers/scripts", suite.WorkPath+"/server") + err = fsutil.Copy("../../../servers/scripts", suite.WorkPath+"/server", fsutil.CopyOptions{}) if err != nil { suite.T().Fatal(err) } diff --git a/test/functional/server_tasks/suite_test.go b/test/functional/server_tasks/suite_test.go index 1c1574d..5b41707 100644 --- a/test/functional/server_tasks/suite_test.go +++ b/test/functional/server_tasks/suite_test.go @@ -10,12 +10,12 @@ import ( "github.com/gameap/daemon/internal/app/config" "github.com/gameap/daemon/internal/app/contracts" "github.com/gameap/daemon/internal/app/domain" + "github.com/gameap/daemon/internal/app/fsutil" gameservercommands "github.com/gameap/daemon/internal/app/game_server_commands" serversscheduler "github.com/gameap/daemon/internal/app/servers_scheduler" "github.com/gameap/daemon/internal/processmanager" "github.com/gameap/daemon/test/functional" "github.com/gameap/daemon/test/mocks" - "github.com/otiai10/copy" "github.com/stretchr/testify/suite" ) @@ -77,7 +77,7 @@ func (suite *Suite) SetupTest() { suite.T().Fatal(err) } - err = copy.Copy("../../servers/scripts", suite.WorkPath+"/server") + err = fsutil.Copy("../../servers/scripts", suite.WorkPath+"/server", fsutil.CopyOptions{}) if err != nil { suite.T().Fatal(err) } diff --git a/test/functional/serverscommand/install/suite_test.go b/test/functional/serverscommand/install/suite_test.go index 589bf53..c6d88a8 100644 --- a/test/functional/serverscommand/install/suite_test.go +++ b/test/functional/serverscommand/install/suite_test.go @@ -4,8 +4,8 @@ import ( "os" "testing" + "github.com/gameap/daemon/internal/app/fsutil" "github.com/gameap/daemon/test/functional/serverscommand" - "github.com/otiai10/copy" "github.com/stretchr/testify/suite" ) @@ -20,7 +20,7 @@ func (suite *Suite) SetupTest() { if err != nil { suite.T().Fatal(err) } - err = copy.Copy("../../../files/local_repository", suite.WorkPath+"/repository") + err = fsutil.Copy("../../../files/local_repository", suite.WorkPath+"/repository", fsutil.CopyOptions{}) if err != nil { suite.T().Fatal(err) } diff --git a/test/functional/serverscommand/installed_server_suite.go b/test/functional/serverscommand/installed_server_suite.go index 141caa4..110aff1 100644 --- a/test/functional/serverscommand/installed_server_suite.go +++ b/test/functional/serverscommand/installed_server_suite.go @@ -3,7 +3,7 @@ package serverscommand import ( "os" - "github.com/otiai10/copy" + "github.com/gameap/daemon/internal/app/fsutil" ) type InstalledServerSuite struct { @@ -17,7 +17,7 @@ func (suite *InstalledServerSuite) SetupTest() { if err != nil { suite.T().Fatal() } - err = copy.Copy("../../../servers/scripts", suite.WorkPath+"/server") + err = fsutil.Copy("../../../servers/scripts", suite.WorkPath+"/server", fsutil.CopyOptions{}) if err != nil { suite.T().Fatal() } diff --git a/test/functional/servertest/files/chmod_test.go b/test/functional/servertest/files/chmod_test.go index b57e27e..f32ff87 100644 --- a/test/functional/servertest/files/chmod_test.go +++ b/test/functional/servertest/files/chmod_test.go @@ -13,8 +13,7 @@ import ( func (suite *Suite) TestChmodSuccess() { suite.Auth(server.ModeFiles) - tempDir, _ := os.MkdirTemp("", "files_test_") - tempFile, _ := os.CreateTemp(tempDir, "file") + rel, abs := suite.workFile("chmod", []byte("x")) var tests []struct { name string @@ -47,12 +46,12 @@ func (suite *Suite) TestChmodSuccess() { for _, test := range tests { suite.T().Run(test.name, func(t *testing.T) { - msg := []interface{}{files.FileChmod, tempFile.Name(), test.perm} + msg := []interface{}{files.FileChmod, rel, test.perm} r := suite.ClientWriteReadAndDecodeList(msg) assert.Equal(t, response.StatusOK, response.Code(r[0].(uint8))) - stat, err := os.Stat(tempFile.Name()) + stat, err := os.Stat(abs) if err != nil { t.Fatal(err) } diff --git a/test/functional/servertest/files/file_info_test.go b/test/functional/servertest/files/file_info_test.go index 1c415b6..ca64453 100644 --- a/test/functional/servertest/files/file_info_test.go +++ b/test/functional/servertest/files/file_info_test.go @@ -10,11 +10,11 @@ import ( func (suite *Suite) TestTextFileInfoSuccess() { suite.Auth(server.ModeFiles) - err := os.Chmod("../../../../test/files/file.txt", 0664) + err := os.Chmod(suite.fixtureAbs("file.txt"), 0664) if err != nil { suite.T().Fatal(err) } - msg := []interface{}{files.FileInfo, "../../../../test/files/file.txt"} + msg := []interface{}{files.FileInfo, fixturesRel + "/file.txt"} r := suite.ClientWriteReadAndDecodeList(msg) @@ -32,11 +32,11 @@ func (suite *Suite) TestTextFileInfoSuccess() { func (suite *Suite) TestJsonFileInfoSuccess() { suite.Auth(server.ModeFiles) - err := os.Chmod("../../../../test/files/file.json", 0664) + err := os.Chmod(suite.fixtureAbs("file.json"), 0664) if err != nil { suite.T().Fatal(err) } - msg := []interface{}{files.FileInfo, "../../../../test/files/file.json"} + msg := []interface{}{files.FileInfo, fixturesRel + "/file.json"} r := suite.ClientWriteReadAndDecodeList(msg) @@ -54,11 +54,11 @@ func (suite *Suite) TestJsonFileInfoSuccess() { func (suite *Suite) TestDirectoryInfoSuccess() { suite.Auth(server.ModeFiles) - err := os.Chmod("../../../../test/files/directory", 0775) + err := os.Chmod(suite.fixtureAbs("directory"), 0775) if err != nil { suite.T().Fatal(err) } - msg := []interface{}{files.FileInfo, "../../../../test/files/directory"} + msg := []interface{}{files.FileInfo, fixturesRel + "/directory"} r := suite.ClientWriteReadAndDecodeList(msg) @@ -76,11 +76,11 @@ func (suite *Suite) TestDirectoryInfoSuccess() { func (suite *Suite) TestSymlinkInfoSuccess() { suite.Auth(server.ModeFiles) - err := os.Chmod("../../../../test/files/symlink_to_file_txt", 0777) + err := os.Chmod(suite.fixtureAbs("symlink_to_file_txt"), 0777) if err != nil { suite.T().Fatal(err) } - msg := []interface{}{files.FileInfo, "../../../../test/files/symlink_to_file_txt"} + msg := []interface{}{files.FileInfo, fixturesRel + "/symlink_to_file_txt"} r := suite.ClientWriteReadAndDecodeList(msg) @@ -98,11 +98,11 @@ func (suite *Suite) TestSymlinkInfoSuccess() { func (suite *Suite) TestFileInfo_EmptyFile_Success() { suite.Authenticate() - err := os.Chmod("../../../../test/files/empty_file.txt", 0664) + err := os.Chmod(suite.fixtureAbs("empty_file.txt"), 0664) if err != nil { suite.T().Fatal(err) } - msg := []interface{}{files.FileInfo, "../../../../test/files/empty_file.txt"} + msg := []interface{}{files.FileInfo, fixturesRel + "/empty_file.txt"} r := suite.ClientWriteReadAndDecodeList(msg) suite.Equal(response.StatusOK, response.Code(r[0].(uint8))) diff --git a/test/functional/servertest/files/list_test.go b/test/functional/servertest/files/list_test.go index c3a03ec..c6f4c53 100644 --- a/test/functional/servertest/files/list_test.go +++ b/test/functional/servertest/files/list_test.go @@ -12,11 +12,11 @@ import ( func (suite *Suite) TestListSuccess() { suite.Auth(server.ModeFiles) - err := os.Chmod("../../../../test/files/file.txt", 0664) + err := os.Chmod(suite.fixtureAbs("file.txt"), 0664) if err != nil { suite.T().Fatal(err) } - msg := []interface{}{files.ReadDir, "../../../../test/files", files.ListWithDetails} + msg := []interface{}{files.ReadDir, fixturesRel, files.ListWithDetails} r := suite.ClientWriteReadAndDecodeList(msg) @@ -46,7 +46,7 @@ func (suite *Suite) TestListSuccess() { func (suite *Suite) TestListNotExistenceDirectory() { suite.Auth(server.ModeFiles) - msg := []interface{}{files.ReadDir, "../../../../test/not-existence", files.ListWithDetails} + msg := []interface{}{files.ReadDir, fixturesRel + "/not-existence", files.ListWithDetails} r := suite.ClientWriteReadAndDecodeList(msg) diff --git a/test/functional/servertest/files/mkdir_test.go b/test/functional/servertest/files/mkdir_test.go index a68b161..6843c35 100644 --- a/test/functional/servertest/files/mkdir_test.go +++ b/test/functional/servertest/files/mkdir_test.go @@ -1,11 +1,6 @@ package files import ( - "os" - "path/filepath" - "strconv" - "time" - "github.com/gameap/daemon/internal/app/server" "github.com/gameap/daemon/internal/app/server/files" "github.com/gameap/daemon/internal/app/server/response" @@ -14,38 +9,34 @@ import ( func (suite *Suite) TestMakeDirSuccess() { suite.Auth(server.ModeFiles) - tempDir := filepath.Join(os.TempDir(), "files_test_", strconv.Itoa(int(time.Now().UnixNano()))) - defer os.RemoveAll(tempDir) - msg := []interface{}{files.MakeDir, tempDir} + rel, abs := suite.workPath("mkdir") + msg := []interface{}{files.MakeDir, rel} r := suite.ClientWriteReadAndDecodeList(msg) suite.Equal(response.StatusOK, response.Code(r[0].(uint8))) - suite.DirExists(tempDir) + suite.DirExists(abs) } func (suite *Suite) TestMakeDir_WhenThreeMessage_ExpectSuccess() { suite.Auth(server.ModeFiles) - tempDir1 := filepath.Join(os.TempDir(), "files_test", strconv.Itoa(int(time.Now().UnixNano()))) - tempDir2 := filepath.Join(os.TempDir(), "files_test", strconv.Itoa(int(time.Now().UnixNano()))) - tempDir3 := filepath.Join(os.TempDir(), "files_test", strconv.Itoa(int(time.Now().UnixNano()))) - defer os.RemoveAll(tempDir1) - defer os.RemoveAll(tempDir2) - defer os.RemoveAll(tempDir3) - msg1 := []interface{}{files.MakeDir, tempDir1} - msg2 := []interface{}{files.MakeDir, tempDir2} - msg3 := []interface{}{files.MakeDir, tempDir3} + rel1, abs1 := suite.workPath("mkdir") + rel2, abs2 := suite.workPath("mkdir") + rel3, abs3 := suite.workPath("mkdir") + msg1 := []interface{}{files.MakeDir, rel1} + msg2 := []interface{}{files.MakeDir, rel2} + msg3 := []interface{}{files.MakeDir, rel3} r1 := suite.ClientWriteReadAndDecodeList(msg1) r2 := suite.ClientWriteReadAndDecodeList(msg2) r3 := suite.ClientWriteReadAndDecodeList(msg3) suite.Equal(response.StatusOK, response.Code(r1[0].(uint8))) - suite.DirExists(tempDir1) + suite.DirExists(abs1) suite.Equal(response.StatusOK, response.Code(r2[0].(uint8))) - suite.DirExists(tempDir2) + suite.DirExists(abs2) suite.Equal(response.StatusOK, response.Code(r3[0].(uint8))) - suite.DirExists(tempDir3) + suite.DirExists(abs3) } func (suite *Suite) TestMakeDirInvalidMessage() { diff --git a/test/functional/servertest/files/move_copy_test.go b/test/functional/servertest/files/move_copy_test.go index 95c3645..06c3594 100644 --- a/test/functional/servertest/files/move_copy_test.go +++ b/test/functional/servertest/files/move_copy_test.go @@ -5,8 +5,6 @@ import ( "os" "path/filepath" "runtime" - "strconv" - "time" "github.com/gameap/daemon/internal/app/server" "github.com/gameap/daemon/internal/app/server/files" @@ -15,50 +13,43 @@ import ( func (suite *Suite) TestMoveFileSuccess() { suite.Auth(server.ModeFiles) - tempDir, _ := os.MkdirTemp("", "files_test_") - tempFile, _ := os.CreateTemp(tempDir, "file") - tempFileName := tempFile.Name() - _ = tempFile.Close() - newFile := filepath.Join(tempDir, "newFile") - msg := []interface{}{files.FileMove, tempFileName, newFile, false} + srcRel, srcAbs := suite.workFile("move", []byte("data")) + dstRel, dstAbs := suite.workPath("moved") + msg := []interface{}{files.FileMove, srcRel, dstRel, false} r := suite.ClientWriteReadAndDecodeList(msg) suite.Equal(response.StatusOK, response.Code(r[0].(uint8))) - suite.DirExists(tempDir) - suite.FileExists(newFile) - suite.NoFileExists(tempFile.Name()) + suite.FileExists(dstAbs) + suite.NoFileExists(srcAbs) } func (suite *Suite) TestCopyFileSuccess() { suite.Auth(server.ModeFiles) - tempDir, _ := os.MkdirTemp("", "files_test_") - defer os.RemoveAll(tempDir) - tempFile, _ := os.CreateTemp(tempDir, "file") - newFile := filepath.Join(tempDir, "newFile") - msg := []interface{}{files.FileMove, tempFile.Name(), newFile, true} + srcRel, srcAbs := suite.workFile("copy", []byte("data")) + dstRel, dstAbs := suite.workPath("copied") + msg := []interface{}{files.FileMove, srcRel, dstRel, true} r := suite.ClientWriteReadAndDecodeList(msg) suite.Equal(response.StatusOK, response.Code(r[0].(uint8))) - suite.DirExists(tempDir) - suite.FileExists(newFile) - suite.FileExists(tempFile.Name()) + suite.FileExists(dstAbs) + suite.FileExists(srcAbs) } func (suite *Suite) TestCopyRelativePathSuccess() { suite.Auth(server.ModeFiles) - tempDir := filepath.Join(os.TempDir(), "files_test", strconv.Itoa(int(time.Now().UnixNano()))) - msg := []interface{}{files.FileMove, "../../../../test/files", tempDir, true} + dstRel, dstAbs := suite.workPath("copytree") + msg := []interface{}{files.FileMove, fixturesRel, dstRel, true} r := suite.ClientWriteReadAndDecodeList(msg) suite.Equal(response.StatusOK, response.Code(r[0].(uint8))) - suite.DirExists(filepath.Join(tempDir, "/directory")) - suite.FileExists(filepath.Join(tempDir, "/file.json")) - suite.FileExists(filepath.Join(tempDir, "/file.txt")) - if runtime.GOOS != "windows" && suite.FileExists(filepath.Join(tempDir, "symlink_to_file_txt")) { - s, err := os.Lstat(filepath.Join(tempDir, "symlink_to_file_txt")) + suite.DirExists(filepath.Join(dstAbs, "directory")) + suite.FileExists(filepath.Join(dstAbs, "file.json")) + suite.FileExists(filepath.Join(dstAbs, "file.txt")) + if runtime.GOOS != "windows" && suite.FileExists(filepath.Join(dstAbs, "symlink_to_file_txt")) { + s, err := os.Lstat(filepath.Join(dstAbs, "symlink_to_file_txt")) if err != nil { suite.T().Fatal(err) } @@ -68,37 +59,34 @@ func (suite *Suite) TestCopyRelativePathSuccess() { func (suite *Suite) TestCopyDirectorySuccess() { suite.Auth(server.ModeFiles) - tempDirSource, _ := os.MkdirTemp("", "files_test_source_") - defer os.RemoveAll(tempDirSource) - tempDirDestination := filepath.Join(os.TempDir(), "files_test_destination", strconv.Itoa(int(time.Now().UnixNano()))) - defer os.RemoveAll(tempDirDestination) - tempFile, _ := os.CreateTemp(tempDirSource, "file") - tempFile.Close() - msg := []interface{}{files.FileMove, tempDirSource, tempDirDestination, true} + srcRel, srcAbs := suite.workDir("src") + if err := os.WriteFile(filepath.Join(srcAbs, "f.bin"), []byte("z"), 0o644); err != nil { + suite.T().Fatal(err) + } + dstRel, dstAbs := suite.workPath("dst") + msg := []interface{}{files.FileMove, srcRel, dstRel, true} r := suite.ClientWriteReadAndDecodeList(msg) suite.Equal(response.StatusOK, response.Code(r[0].(uint8))) - suite.FileExists(filepath.Join(tempDirDestination, filepath.Base(tempFile.Name()))) - suite.FileExists(filepath.Join(tempDirDestination, filepath.Base(tempFile.Name()))) + suite.FileExists(filepath.Join(dstAbs, "f.bin")) + suite.FileExists(filepath.Join(srcAbs, "f.bin")) } func (suite *Suite) TestMoveDirectorySuccess() { suite.Auth(server.ModeFiles) - tempDirSource, _ := os.MkdirTemp("", "files_test_source_") - tempDirDestination := filepath.Join(os.TempDir(), "files_test_destination", strconv.Itoa(int(time.Now().UnixNano()))) - defer os.RemoveAll(tempDirDestination) - tempFile, _ := os.CreateTemp(tempDirSource, "file") - tempFileName := tempFile.Name() - _ = tempFile.Close() - msg := []interface{}{files.FileMove, tempDirSource, tempDirDestination, false} + srcRel, srcAbs := suite.workDir("src") + if err := os.WriteFile(filepath.Join(srcAbs, "f.bin"), []byte("z"), 0o644); err != nil { + suite.T().Fatal(err) + } + dstRel, dstAbs := suite.workPath("dst") + msg := []interface{}{files.FileMove, srcRel, dstRel, false} r := suite.ClientWriteReadAndDecodeList(msg) suite.Equal(response.StatusOK, response.Code(r[0].(uint8))) - suite.NoDirExists(tempDirSource) - suite.NoFileExists(filepath.Join(tempDirSource, filepath.Base(tempFileName))) - suite.FileExists(filepath.Join(tempDirDestination, filepath.Base(tempFileName))) + suite.NoDirExists(srcAbs) + suite.FileExists(filepath.Join(dstAbs, "f.bin")) } func (suite *Suite) TestMoveInvalidSource() { @@ -113,16 +101,14 @@ func (suite *Suite) TestMoveInvalidSource() { func (suite *Suite) TestMoveInvalidDestination() { suite.Auth(server.ModeFiles) - tempDirSource, _ := os.MkdirTemp("", "files_test_source_") - defer os.RemoveAll(tempDirSource) - tempDirDestination, _ := os.MkdirTemp("", "files_test_destination_") - defer os.RemoveAll(tempDirDestination) - msg := []interface{}{files.FileMove, tempDirSource, tempDirDestination, false} + srcRel, _ := suite.workDir("src") + dstRel, _ := suite.workDir("dst") + msg := []interface{}{files.FileMove, srcRel, dstRel, false} r := suite.ClientWriteReadAndDecodeList(msg) suite.Equal(response.StatusError, response.Code(r[0].(uint8))) - suite.Equal("Destination \""+tempDirDestination+"\" already exists", r[1].(string)) + suite.Equal("Destination \""+dstRel+"\" already exists", r[1].(string)) } func (suite *Suite) TestMoveInvalidMessage() { diff --git a/test/functional/servertest/files/remove_test.go b/test/functional/servertest/files/remove_test.go index 417527e..a8d5e50 100644 --- a/test/functional/servertest/files/remove_test.go +++ b/test/functional/servertest/files/remove_test.go @@ -2,6 +2,7 @@ package files import ( "os" + "path/filepath" "github.com/gameap/daemon/internal/app/server" "github.com/gameap/daemon/internal/app/server/files" @@ -10,55 +11,53 @@ import ( func (suite *Suite) TestRemoveFileSuccess() { suite.Auth(server.ModeFiles) - tempDir, _ := os.MkdirTemp(os.TempDir(), "files_test_") - tempFile, _ := os.CreateTemp(tempDir, "file") - tempFileName := tempFile.Name() - err := tempFile.Close() - if err != nil { - suite.T().Fatal(err) - } - msg := []interface{}{files.FileRemove, tempFileName, false} + rel, abs := suite.workFile("rm", []byte("x")) + msg := []interface{}{files.FileRemove, rel, false} r := suite.ClientWriteReadAndDecodeList(msg) suite.Equal(response.StatusOK, response.Code(r[0].(uint8))) - suite.NoFileExists(tempFileName) + suite.NoFileExists(abs) } func (suite *Suite) TestRemoveEmptyDirSuccess() { suite.Auth(server.ModeFiles) - tempDir, _ := os.MkdirTemp("", "files_test_") - msg := []interface{}{files.FileRemove, tempDir, false} + rel, abs := suite.workDir("rmdir") + msg := []interface{}{files.FileRemove, rel, false} r := suite.ClientWriteReadAndDecodeList(msg) suite.Equal(response.StatusOK, response.Code(r[0].(uint8))) - suite.NoDirExists(tempDir) + suite.NoDirExists(abs) } func (suite *Suite) TestRemoveNotEmptyDirFail() { suite.Auth(server.ModeFiles) - tempDir, _ := os.MkdirTemp("", "files_test_") - _, _ = os.MkdirTemp(tempDir, "inner_dir") - msg := []interface{}{files.FileRemove, tempDir, false} + rel, abs := suite.workDir("rmdir") + if err := os.MkdirAll(filepath.Join(abs, "inner_dir"), 0o755); err != nil { + suite.T().Fatal(err) + } + msg := []interface{}{files.FileRemove, rel, false} r := suite.ClientWriteReadAndDecodeList(msg) suite.Equal(response.StatusError, response.Code(r[0].(uint8))) suite.Equal("Failed to remove", r[1].(string)) - suite.DirExists(tempDir) + suite.DirExists(abs) } func (suite *Suite) TestRemoveRecursiveNotEmptyDirSuccess() { suite.Auth(server.ModeFiles) - tempDir, _ := os.MkdirTemp("", "files_test_") - _, _ = os.MkdirTemp(tempDir, "inner_dir") - msg := []interface{}{files.FileRemove, tempDir, true} + rel, abs := suite.workDir("rmdir") + if err := os.MkdirAll(filepath.Join(abs, "inner_dir"), 0o755); err != nil { + suite.T().Fatal(err) + } + msg := []interface{}{files.FileRemove, rel, true} r := suite.ClientWriteReadAndDecodeList(msg) suite.Equal(response.StatusOK, response.Code(r[0].(uint8))) - suite.NoDirExists(tempDir) + suite.NoDirExists(abs) } func (suite *Suite) TestNotExistFileFail() { diff --git a/test/functional/servertest/files/suite_test.go b/test/functional/servertest/files/suite_test.go index fe346d0..9b4abe6 100644 --- a/test/functional/servertest/files/suite_test.go +++ b/test/functional/servertest/files/suite_test.go @@ -6,18 +6,28 @@ import ( "runtime" "strconv" "testing" - "time" "github.com/et-nik/binngo/decode" + "github.com/gameap/daemon/internal/app/fsutil" "github.com/gameap/daemon/internal/app/server" "github.com/gameap/daemon/internal/app/server/files" "github.com/gameap/daemon/test/functional/servertest" "github.com/stretchr/testify/suite" ) +// The legacy TCP file handler is now jailed to the daemon work directory +// (servertest.Suite.WorkPath). Every path sent to the daemon is therefore +// resolved relative to WorkPath, so the fixtures these tests operate on are +// mirrored into WorkPath/testfiles and the helpers below hand out +// (relative-to-WorkPath, absolute-on-disk) path pairs. +const fixturesRel = "testfiles" + type Suite struct { servertest.Suite + seq int + + relFileDestination string tempFileDestination string } @@ -25,17 +35,74 @@ func TestSuite(t *testing.T) { suite.Run(t, new(Suite)) } +func (suite *Suite) SetupSuite() { + suite.Suite.SetupSuite() + + err := fsutil.Copy( + "../../../../test/files", + filepath.Join(suite.WorkPath, fixturesRel), + fsutil.CopyOptions{Symlink: fsutil.SymlinkShallow}, + ) + if err != nil { + suite.T().Fatal(err) + } +} + func (suite *Suite) SetupTest() { suite.Suite.SetupTest() - tempDirDestination := filepath.Join(os.TempDir(), "files_test_destination_", strconv.Itoa(int(time.Now().UnixNano()))) - suite.tempFileDestination = filepath.Join(tempDirDestination, "file") + suite.relFileDestination = "upload/file" + suite.tempFileDestination = filepath.Join(suite.WorkPath, "upload", "file") } func (suite *Suite) TearDownSuite() { suite.Suite.TearDownSuite() } +// workPath returns a unique (relative-to-WorkPath, absolute-on-disk) path pair. +// The relative form is what the client sends to the jailed daemon. +func (suite *Suite) workPath(name string) (rel, abs string) { + suite.T().Helper() + suite.seq++ + rel = "ft/" + name + "_" + strconv.Itoa(suite.seq) + abs = filepath.Join(suite.WorkPath, filepath.FromSlash(rel)) + + return rel, abs +} + +// workDir creates a unique directory inside WorkPath and returns its +// (relative, absolute) path pair. +func (suite *Suite) workDir(name string) (rel, abs string) { + suite.T().Helper() + rel, abs = suite.workPath(name) + if err := os.MkdirAll(abs, 0o755); err != nil { + suite.T().Fatal(err) + } + + return rel, abs +} + +// workFile creates a unique file (with its parent directory) inside WorkPath +// and returns its (relative, absolute) path pair. +func (suite *Suite) workFile(name string, contents []byte) (rel, abs string) { + suite.T().Helper() + rel, abs = suite.workPath(name) + if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil { + suite.T().Fatal(err) + } + if err := os.WriteFile(abs, contents, 0o644); err != nil { + suite.T().Fatal(err) + } + + return rel, abs +} + +func (suite *Suite) fixtureAbs(name string) string { + suite.T().Helper() + + return filepath.Join(suite.WorkPath, fixturesRel, name) +} + func (suite *Suite) Authenticate() { suite.T().Helper() suite.Auth(server.ModeFiles) @@ -91,8 +158,10 @@ func (suite *Suite) assertFileDetails( // file type (file, directory, ...) suite.Equal(uint8(fileType), fInfo[2]) - // permissions - if runtime.GOOS != "windows" { + // permissions. A symlink's own lstat permission bits are only + // deterministic on Linux (always 0777); macOS reports the real bits, so + // the strict check is limited to where it is meaningful. + if runtime.GOOS != "windows" && (fileType != files.TypeSymlink || runtime.GOOS == "linux") { suite.Equal(permissions, fInfo[6]) } @@ -143,7 +212,7 @@ func (suite *Suite) givenUploadMessage(size int) []interface{} { return []interface{}{ files.FileSend, files.GetFileFromClient, - suite.tempFileDestination, + suite.relFileDestination, uint64(size), true, 0666, diff --git a/test/functional/servertest/files/upload_download_test.go b/test/functional/servertest/files/upload_download_test.go index c2cb34c..b8a4e4f 100644 --- a/test/functional/servertest/files/upload_download_test.go +++ b/test/functional/servertest/files/upload_download_test.go @@ -11,7 +11,7 @@ import ( func (suite *Suite) TestDownloadSuccess() { suite.Auth(server.ModeFiles) - msg := []interface{}{files.FileSend, files.SendFileToClient, "../../../../test/files/file.txt"} + msg := []interface{}{files.FileSend, files.SendFileToClient, fixturesRel + "/file.txt"} r := suite.ClientWriteReadAndDecodeList(msg) suite.Equal(response.StatusReadyToTransfer, response.Code(r[0].(uint8))) suite.Equal("File is ready to transfer", r[1].(string)) @@ -27,7 +27,7 @@ func (suite *Suite) TestDownloadSuccess() { func (suite *Suite) TestDownloadMultipleSuccess() { // First File suite.Auth(server.ModeFiles) - msg := []interface{}{files.FileSend, files.SendFileToClient, "../../../../test/files/file.txt"} + msg := []interface{}{files.FileSend, files.SendFileToClient, fixturesRel + "/file.txt"} r := suite.ClientWriteReadAndDecodeList(msg) suite.Equal(response.StatusReadyToTransfer, response.Code(r[0].(uint8))) suite.Equal("File is ready to transfer", r[1].(string)) @@ -39,7 +39,7 @@ func (suite *Suite) TestDownloadMultipleSuccess() { suite.Equal("file.txt\n", string(buf)) // Second File - msg = []interface{}{files.FileSend, files.SendFileToClient, "../../../../test/files/file2.txt"} + msg = []interface{}{files.FileSend, files.SendFileToClient, fixturesRel + "/file2.txt"} r = suite.ClientWriteReadAndDecodeList(msg) suite.Equal(response.StatusReadyToTransfer, response.Code(r[0].(uint8))) suite.Equal("File is ready to transfer", r[1].(string)) @@ -53,7 +53,7 @@ func (suite *Suite) TestDownloadMultipleSuccess() { func (suite *Suite) TestDownload_EmptyFile_Success() { suite.Auth(server.ModeFiles) - msg := []interface{}{files.FileSend, files.SendFileToClient, "../../../../test/files/empty_file.txt"} + msg := []interface{}{files.FileSend, files.SendFileToClient, fixturesRel + "/empty_file.txt"} r := suite.ClientWriteReadAndDecodeList(msg) suite.Equal(response.StatusReadyToTransfer, response.Code(r[0].(uint8))) suite.Equal("File is ready to transfer", r[1].(string)) @@ -151,7 +151,7 @@ func (suite *Suite) TestUploadRaccoonSuccess() { func (suite *Suite) TestUpload_WhenListDirectoryCommandExecutedBefore_Success() { suite.Authenticate() // Read Directory - readDirMsg := []interface{}{files.ReadDir, "../../../../test/files", files.ListWithDetails} + readDirMsg := []interface{}{files.ReadDir, fixturesRel, files.ListWithDetails} r := suite.ClientWriteReadAndDecodeList(readDirMsg) suite.Require().Equal(response.StatusOK, response.Code(r[0].(uint8))) // File arrange diff --git a/test/functional/servertest/suite.go b/test/functional/servertest/suite.go index 86ed3a8..9d8bc4c 100644 --- a/test/functional/servertest/suite.go +++ b/test/functional/servertest/suite.go @@ -34,6 +34,8 @@ type Suite struct { Server *server.Server Client *tls.Conn + WorkPath string + Executor contracts.Executor TaskStatsReader *mocks.TasksStatsReader } @@ -44,6 +46,11 @@ func (suite *Suite) SetupSuite() { suite.TaskStatsReader = &mocks.TasksStatsReader{} suite.Executor = components.NewCleanExecutor() + suite.WorkPath, err = os.MkdirTemp("", "gameap-servertest-") + if err != nil { + suite.T().Fatal(err) + } + certPEM, err := os.ReadFile(ServerCert) if err != nil { suite.T().Fatal(err) @@ -56,6 +63,7 @@ func (suite *Suite) SetupSuite() { suite.Server, err = server.NewServer( "127.0.0.1", 3717, + suite.WorkPath, certPEM, keyPEM, server.CredentialsConfig{ @@ -92,6 +100,10 @@ func (suite *Suite) TearDownTest() { func (suite *Suite) TearDownSuite() { suite.Server.Stop(context.Background()) + + if suite.WorkPath != "" { + _ = os.RemoveAll(suite.WorkPath) + } } func (suite *Suite) loadClient() {