Skip to content
Open
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
9 changes: 5 additions & 4 deletions .golangci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
7 changes: 6 additions & 1 deletion localfs.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
47 changes: 47 additions & 0 deletions localfs_onefilesystem_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,13 @@
package desync

import (
"io"
"io/fs"
"os"
"path/filepath"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

Expand Down Expand Up @@ -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)
}
38 changes: 23 additions & 15 deletions localfs_other.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
}

Expand Down Expand Up @@ -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()
Expand All @@ -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}
Expand Down
Loading