Przeglądaj źródła

Case insensitive renames, part 1

Jakob Borg 10 lat temu
rodzic
commit
ddfebb17cf
3 zmienionych plików z 168 dodań i 1 usunięć
  1. 2 1
      lib/model/model.go
  2. 88 0
      lib/osutil/osutil.go
  3. 78 0
      lib/osutil/osutil_test.go

+ 2 - 1
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

+ 88 - 0
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()
+}

+ 78 - 0
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)
+	}
+}