Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 0 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
4 changes: 0 additions & 4 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
251 changes: 251 additions & 0 deletions internal/app/fsutil/copy.go
Original file line number Diff line number Diff line change
@@ -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
}
105 changes: 105 additions & 0 deletions internal/app/fsutil/copy_test.go
Original file line number Diff line number Diff line change
@@ -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))
}
Loading
Loading