diff --git a/.golangci.yml b/.golangci.yml index c258289..7144c28 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -58,9 +58,10 @@ linters: text: "S1025" linters: [staticcheck] - # LocalFS.dev holds the device of Root so a one-file-system walk can skip - # other filesystems. Windows has no device number to compare, so its - # walker never reads the field, but the struct is shared. + # LocalFS.dev holds the device of Root, and LocalFS.deviceOf finds the + # device of anything else, so a one-file-system walk can skip other + # filesystems. Windows has no device number to compare, so its walker + # never reads the fields, but the struct is shared. - path: localfs\.go - text: "field dev is unused" + text: "field (dev|deviceOf) is unused" linters: [unused] diff --git a/localfs.go b/localfs.go index 88342e8..6abc85d 100644 --- a/localfs.go +++ b/localfs.go @@ -18,7 +18,12 @@ type LocalFS struct { opts LocalFSOptions - dev uint64 + // Device the root is on, and how to find the device of anything else in + // the tree. Used to stay on one filesystem with OneFileSystem. A function + // to allow tests to simulate a tree that spans more than one. + dev uint64 + deviceOf func(os.FileInfo) (uint64, bool) + once sync.Once entries chan walkEntry sErr error diff --git a/localfs_onefilesystem_test.go b/localfs_onefilesystem_test.go index d2bde9d..22f60d6 100644 --- a/localfs_onefilesystem_test.go +++ b/localfs_onefilesystem_test.go @@ -3,11 +3,13 @@ package desync import ( + "io" "io/fs" "os" "path/filepath" "testing" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -38,3 +40,48 @@ func TestLocalFSOneFileSystemStatError(t *testing.T) { } require.ErrorIs(t, err, fs.ErrPermission) } + +// TestLocalFSOneFileSystemSkipsMount reads a tree that has another filesystem +// mounted in the middle of it with OneFileSystem set. The mount point and +// everything below it is left out, while everything around it, including what +// comes after it in the walk, has to be returned. +func TestLocalFSOneFileSystemSkipsMount(t *testing.T) { + src := t.TempDir() + for _, d := range []string{"keep", "mnt/sub", "zzz"} { + require.NoError(t, os.MkdirAll(filepath.Join(src, d), 0755)) + } + for _, f := range []string{"keep/a", "mnt/file", "mnt/sub/deep", "zfile", "zzz/b"} { + require.NoError(t, os.WriteFile(filepath.Join(src, f), []byte(f), 0644)) + } + + // Mounting needs privileges, report the mount point and everything below + // it as being on another device instead. + var mounted []os.FileInfo + require.NoError(t, filepath.Walk(filepath.Join(src, "mnt"), func(_ string, info os.FileInfo, err error) error { + mounted = append(mounted, info) + return err + })) + lfs := NewLocalFS(src, LocalFSOptions{OneFileSystem: true}) + lfs.deviceOf = func(info os.FileInfo) (uint64, bool) { + for _, m := range mounted { + if os.SameFile(info, m) { + return 2, true + } + } + return 1, true + } + + var paths []string + for { + f, err := lfs.Next() + if err == io.EOF { + break + } + require.NoError(t, err) + require.NoError(t, f.Close()) + rel, err := filepath.Rel(src, f.Path) + require.NoError(t, err) + paths = append(paths, rel) + } + assert.Equal(t, []string{".", "keep", "keep/a", "zfile", "zzz", "zzz/b"}, paths) +} diff --git a/localfs_other.go b/localfs_other.go index 7c16967..c446eb0 100644 --- a/localfs_other.go +++ b/localfs_other.go @@ -21,9 +21,10 @@ import ( // can be used for tar/untar operations. func NewLocalFS(root string, opts LocalFSOptions) *LocalFS { return &LocalFS{ - Root: root, - opts: opts, - entries: make(chan walkEntry), + Root: root, + opts: opts, + entries: make(chan walkEntry), + deviceOf: deviceOf, } } @@ -369,16 +370,21 @@ func (fs *LocalFS) Next() (*File, error) { return f, nil } +// deviceOf returns the ID of the device a file is on. +func deviceOf(info os.FileInfo) (uint64, bool) { + st, ok := info.Sys().(*syscall.Stat_t) + if !ok { + return 0, false + } + // Dev (and Rdev) elements of syscall.Stat_t are uint64 on Linux, but + // int32 on MacOS. Cast it to uint64 everywhere. + return uint64(st.Dev), true +} + func (fs *LocalFS) initForReading() { if fs.opts.OneFileSystem { - info, err := os.Lstat(fs.Root) - if err == nil { - st, ok := info.Sys().(*syscall.Stat_t) - if ok { - // Dev (and Rdev) elements of syscall.Stat_t are uint64 on Linux, - // but int32 on MacOS. Cast it to uint64 everywhere. - fs.dev = uint64(st.Dev) - } + if info, err := os.Lstat(fs.Root); err == nil { + fs.dev, _ = fs.deviceOf(info) } } fs.startSerializer() @@ -390,10 +396,12 @@ func (fs *LocalFS) startSerializer() { // There is no info for an entry that couldn't be stat'ed, only the // error, which is passed on for Next() to return. if fs.dev != 0 && info != nil && info.IsDir() { - // one-file-system is set, skip other filesystems - st, ok := info.Sys().(*syscall.Stat_t) - if ok && uint64(st.Dev) != fs.dev { - return nil + // one-file-system is set. A directory on another filesystem + // is left out along with everything in it. Leaving out just + // the directory would have the walk carry on inside it, with + // entries that have no place in the tree being read. + if dev, ok := fs.deviceOf(info); ok && dev != fs.dev { + return filepath.SkipDir } } fs.entries <- walkEntry{path, info, err}