From ddfebb17cf46e6ec930ee40e22a2bb93a018edce Mon Sep 17 00:00:00 2001 From: Jakob Borg Date: Wed, 30 Sep 2015 12:41:29 +0200 Subject: [PATCH] Case insensitive renames, part 1 --- lib/model/model.go | 3 +- lib/osutil/osutil.go | 88 +++++++++++++++++++++++++++++++++++++++ lib/osutil/osutil_test.go | 78 ++++++++++++++++++++++++++++++++++ 3 files changed, 168 insertions(+), 1 deletion(-) diff --git a/lib/model/model.go b/lib/model/model.go index 59871b296..84e99302a 100644 --- a/lib/model/model.go +++ b/lib/model/model.go @@ -1370,6 +1370,7 @@ nextSub: // TODO: We should limit the Have scanning to start at sub seenPrefix := false var iterError error + css := osutil.NewCachedCaseSensitiveStat(folderCfg.Path()) fs.WithHaveTruncated(protocol.LocalDeviceID, func(fi db.FileIntf) bool { f := fi.(db.FileInfoTruncated) hasPrefix := len(subs) == 0 @@ -1413,7 +1414,7 @@ nextSub: Version: f.Version, // The file is still the same, so don't bump version } batch = append(batch, nf) - } else if _, err := osutil.Lstat(filepath.Join(folderCfg.Path(), f.Name)); err != nil { + } else if _, err := css.Lstat(filepath.Join(folderCfg.Path(), f.Name)); err != nil { // File has been deleted. // We don't specifically verify that the error is diff --git a/lib/osutil/osutil.go b/lib/osutil/osutil.go index f4e15885a..a77ed5cf2 100644 --- a/lib/osutil/osutil.go +++ b/lib/osutil/osutil.go @@ -15,6 +15,7 @@ import ( "os" "path/filepath" "runtime" + "sort" "strings" "time" @@ -241,3 +242,90 @@ func SetTCPOptions(conn *net.TCPConn) error { } return nil } + +// The CachedCaseSensitiveStat provides an Lstat() method similar to +// os.Lstat(), but that is always case sensitive regardless of underlying file +// system semantics. The "Cached" part refers to the fact that it lists the +// contents of a directory the first time it's needed and then retains this +// information for the duration. It's expected that instances of this type are +// fairly short lived. +// +// There's some song and dance to check directories that are parents to the +// checked path as well, that is we want to catch the situation that someone +// calls Lstat("foo/BAR/baz") when the actual path is "foo/bar/baz" and return +// NotExist appropriately. But we don't want to do this check too high up, as +// the user may have told us the folder path is ~/Sync while it is actually +// ~/sync and this *should* work properly... Sigh. Hence the "base" parameter. +type CachedCaseSensitiveStat struct { + base string // base directory, we should not check stuff above this + results map[string][]os.FileInfo // directory path => list of children +} + +func NewCachedCaseSensitiveStat(base string) *CachedCaseSensitiveStat { + return &CachedCaseSensitiveStat{ + base: strings.ToLower(base), + results: make(map[string][]os.FileInfo), + } +} + +func (c *CachedCaseSensitiveStat) Lstat(name string) (os.FileInfo, error) { + dir := filepath.Dir(name) + base := filepath.Base(name) + + if !strings.HasPrefix(strings.ToLower(dir), c.base) { + // We only validate things within the base directory, which we need to + // compare case insensitively against. + return nil, os.ErrInvalid + } + + // If we don't already have a list of directory entries for this + // directory, try to list it. Return error if this fails. + l, ok := c.results[dir] + if !ok { + if len(dir) > len(c.base) { + // We are checking in a subdirectory of base. Must make sure *it* + // exists with the specified casing, up to the base directory. + if _, err := c.Lstat(dir); err != nil { + return nil, err + } + } + + fd, err := os.Open(dir) + if err != nil { + return nil, err + } + defer fd.Close() + + l, err = fd.Readdir(-1) + if err != nil { + return nil, err + } + + sort.Sort(fileInfoList(l)) + c.results[dir] = l + } + + // Get the index of the first entry with name >= base using binary search. + idx := sort.Search(len(l), func(i int) bool { + return l[i].Name() >= base + }) + + if idx >= len(l) || l[idx].Name() != base { + // The search didn't find any such entry + return nil, os.ErrNotExist + } + + return l[idx], nil +} + +type fileInfoList []os.FileInfo + +func (l fileInfoList) Len() int { + return len(l) +} +func (l fileInfoList) Swap(a, b int) { + l[a], l[b] = l[b], l[a] +} +func (l fileInfoList) Less(a, b int) bool { + return l[a].Name() < l[b].Name() +} diff --git a/lib/osutil/osutil_test.go b/lib/osutil/osutil_test.go index d4d5c15bc..697bf6e86 100644 --- a/lib/osutil/osutil_test.go +++ b/lib/osutil/osutil_test.go @@ -7,8 +7,11 @@ package osutil_test import ( + "io/ioutil" "os" + "path/filepath" "runtime" + "strings" "testing" "github.com/syncthing/syncthing/lib/osutil" @@ -179,3 +182,78 @@ func TestDiskUsage(t *testing.T) { t.Error("Disk is full?", free) } } + +func TestCaseSensitiveStat(t *testing.T) { + switch runtime.GOOS { + case "windows", "darwin": + break // We can test! + default: + t.Skip("Cannot test on this platform") + return + } + + dir, err := ioutil.TempDir("", "TestCaseSensitiveStat") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(dir) + + if err := ioutil.WriteFile(filepath.Join(dir, "File"), []byte("data"), 0644); err != nil { + t.Fatal(err) + } + + if _, err := os.Lstat(filepath.Join(dir, "File")); err != nil { + // Standard Lstat should report the file exists + t.Fatal("Unexpected error:", err) + } + if _, err := os.Lstat(filepath.Join(dir, "fILE")); err != nil { + // ... also with the incorrect case spelling + t.Fatal("Unexpected error:", err) + } + + // Create the case sensitive stat:er. We stress it a little by giving it a + // base path with an intentionally incorrect casing. + + css := osutil.NewCachedCaseSensitiveStat(strings.ToUpper(dir)) + + if _, err := css.Lstat(filepath.Join(dir, "File")); err != nil { + // Our Lstat should report the file exists + t.Fatal("Unexpected error:", err) + } + if _, err := css.Lstat(filepath.Join(dir, "fILE")); err == nil || !os.IsNotExist(err) { + // ... but with the incorrect case we should get ErrNotExist + t.Fatal("Unexpected non-IsNotExist error:", err) + } + + // Now do the same tests for a file in a case-sensitive directory. + + if err := os.Mkdir(filepath.Join(dir, "Dir"), 0755); err != nil { + t.Fatal(err) + } + if err := ioutil.WriteFile(filepath.Join(dir, "Dir/File"), []byte("data"), 0644); err != nil { + t.Fatal(err) + } + + if _, err := os.Lstat(filepath.Join(dir, "Dir/File")); err != nil { + // Standard Lstat should report the file exists + t.Fatal("Unexpected error:", err) + } + if _, err := os.Lstat(filepath.Join(dir, "dIR/File")); err != nil { + // ... also with the incorrect case spelling + t.Fatal("Unexpected error:", err) + } + + // Recreate the case sensitive stat:er. We stress it a little by giving it a + // base path with an intentionally incorrect casing. + + css = osutil.NewCachedCaseSensitiveStat(strings.ToLower(dir)) + + if _, err := css.Lstat(filepath.Join(dir, "Dir/File")); err != nil { + // Our Lstat should report the file exists + t.Fatal("Unexpected error:", err) + } + if _, err := css.Lstat(filepath.Join(dir, "dIR/File")); err == nil || !os.IsNotExist(err) { + // ... but with the incorrect case we should get ErrNotExist + t.Fatal("Unexpected non-IsNotExist error:", err) + } +}