diff --git a/cmd/syncthing/main.go b/cmd/syncthing/main.go index 0c013abe1..7cef1e543 100644 --- a/cmd/syncthing/main.go +++ b/cmd/syncthing/main.go @@ -663,6 +663,13 @@ func syncthingMain(runtimeOptions RuntimeOptions) { } } + if cfg.Raw().OriginalVersion == 15 { + // The config version 15->16 migration is about handling ignores and + // delta indexes and requires that we drop existing indexes that + // have been incorrectly ignore filtered. + ldb.DropDeltaIndexIDs() + } + m := model.NewModel(cfg, myID, myDeviceName(cfg), "syncthing", Version, ldb, protectedFiles) cfg.Subscribe(m) diff --git a/lib/config/config.go b/lib/config/config.go index 4a2a9f3a0..1059020f7 100644 --- a/lib/config/config.go +++ b/lib/config/config.go @@ -26,7 +26,7 @@ import ( const ( OldestHandledVersion = 10 - CurrentVersion = 15 + CurrentVersion = 16 MaxRescanIntervalS = 365 * 24 * 60 * 60 ) @@ -218,6 +218,9 @@ func (cfg *Configuration) prepare(myID protocol.DeviceID) error { if cfg.Version == 14 { convertV14V15(cfg) } + if cfg.Version == 15 { + convertV15V16(cfg) + } // Build a list of available devices existingDevices := make(map[protocol.DeviceID]bool) @@ -288,6 +291,11 @@ func convertV14V15(cfg *Configuration) { cfg.Version = 15 } +func convertV15V16(cfg *Configuration) { + // Triggers a database tweak + cfg.Version = 16 +} + func convertV13V14(cfg *Configuration) { // Not using the ignore cache is the new default. Disable it on existing // configurations. diff --git a/lib/config/testdata/v16.xml b/lib/config/testdata/v16.xml new file mode 100644 index 000000000..4c18e0d10 --- /dev/null +++ b/lib/config/testdata/v16.xml @@ -0,0 +1,14 @@ + + + + + 1 + -1 + + +
tcp://a
+
+ +
tcp://b
+
+
diff --git a/lib/db/leveldb_dbinstance.go b/lib/db/leveldb_dbinstance.go index 7cd4e389e..e20437d2a 100644 --- a/lib/db/leveldb_dbinstance.go +++ b/lib/db/leveldb_dbinstance.go @@ -719,6 +719,20 @@ func (db *Instance) indexIDKey(device, folder []byte) []byte { return k } +// DropDeltaIndexIDs removes all index IDs from the database. This will +// cause a full index transmission on the next connection. +func (db *Instance) DropDeltaIndexIDs() { + t := db.newReadWriteTransaction() + defer t.close() + + dbi := t.NewIterator(util.BytesPrefix([]byte{KeyTypeIndexID}), nil) + defer dbi.Release() + + for dbi.Next() { + t.Delete(dbi.Key()) + } +} + func unmarshalTrunc(bs []byte, truncate bool) (FileIntf, error) { if truncate { var tf FileInfoTruncated diff --git a/lib/model/model.go b/lib/model/model.go index 7b386a819..d4eed5df6 100644 --- a/lib/model/model.go +++ b/lib/model/model.go @@ -114,6 +114,8 @@ var ( errFolderMarkerMissing = errors.New("folder marker missing") errHomeDiskNoSpace = errors.New("home disk has insufficient free space") errFolderNoSpace = errors.New("folder has insufficient free space") + errUnsupportedSymlink = errors.New("symlink not supported") + errInvalidFilename = errors.New("filename is invalid") ) // NewModel creates and starts a new model. The model starts in read-only mode, @@ -466,7 +468,13 @@ func (m *Model) NeedSize(folder string) (nfiles int, bytes int64) { m.fmut.RLock() defer m.fmut.RUnlock() if rf, ok := m.folderFiles[folder]; ok { + ignores := m.folderIgnores[folder] + cfg := m.folderCfgs[folder] rf.WithNeedTruncated(protocol.LocalDeviceID, func(f db.FileIntf) bool { + if shouldIgnore(f, ignores, cfg.IgnoreDelete) { + return true + } + fs, de, by := sizeOfFile(f) nfiles += fs + de bytes += by @@ -526,7 +534,13 @@ func (m *Model) NeedFolderFiles(folder string, page, perpage int) ([]db.FileInfo } rest = make([]db.FileInfoTruncated, 0, perpage) + ignores := m.folderIgnores[folder] + cfg := m.folderCfgs[folder] rf.WithNeedTruncated(protocol.LocalDeviceID, func(f db.FileIntf) bool { + if shouldIgnore(f, ignores, cfg.IgnoreDelete) { + return true + } + total++ if skip > 0 { skip-- @@ -556,10 +570,8 @@ func (m *Model) Index(deviceID protocol.DeviceID, folder string, fs []protocol.F } m.fmut.RLock() - cfg := m.folderCfgs[folder] files, ok := m.folderFiles[folder] runner := m.folderRunners[folder] - ignores := m.folderIgnores[folder] m.fmut.RUnlock() if runner != nil { @@ -576,7 +588,6 @@ func (m *Model) Index(deviceID protocol.DeviceID, folder string, fs []protocol.F m.deviceDownloads[deviceID].Update(folder, makeForgetUpdate(fs)) m.pmut.RUnlock() - fs = filterIndex(folder, fs, cfg.IgnoreDelete, ignores) files.Replace(deviceID, fs) events.Default.Log(events.RemoteIndexUpdated, map[string]interface{}{ @@ -599,9 +610,7 @@ func (m *Model) IndexUpdate(deviceID protocol.DeviceID, folder string, fs []prot m.fmut.RLock() files := m.folderFiles[folder] - cfg := m.folderCfgs[folder] runner, ok := m.folderRunners[folder] - ignores := m.folderIgnores[folder] m.fmut.RUnlock() if !ok { @@ -612,7 +621,6 @@ func (m *Model) IndexUpdate(deviceID protocol.DeviceID, folder string, fs []prot m.deviceDownloads[deviceID].Update(folder, makeForgetUpdate(fs)) m.pmut.RUnlock() - fs = filterIndex(folder, fs, cfg.IgnoreDelete, ignores) files.Update(deviceID, fs) events.Default.Log(events.RemoteIndexUpdated, map[string]interface{}{ @@ -1278,11 +1286,6 @@ func sendIndexTo(minSequence int64, conn protocol.Connection, folder string, fs maxSequence = f.Sequence } - if ignores.Match(f.Name).IsIgnored() || symlinkInvalid(folder, f) { - l.Debugln("not sending update for ignored/unsupported symlink", f) - return true - } - sorter.Append(f) return true }) @@ -1513,6 +1516,18 @@ func (m *Model) internalScanFolderSubdirs(folder string, subDirs []string) error runner, ok := m.folderRunners[folder] m.fmut.Unlock() + // Check if the ignore patterns changed as part of scanning this folder. + // If they did we should schedule a pull of the folder so that we + // request things we might have suddenly become unignored and so on. + + oldHash := ignores.Hash() + defer func() { + if ignores.Hash() != oldHash { + l.Debugln("Folder", folder, "ignore patterns changed; triggering puller") + runner.IndexUpdated() + } + }() + // Folders are added to folderRunners only when they are started. We can't // scan them before they have started, so that's what we need to check for // here. @@ -2236,27 +2251,6 @@ func mapDeviceCfgs(devices []config.DeviceConfiguration) map[protocol.DeviceID]s return m } -func filterIndex(folder string, fs []protocol.FileInfo, dropDeletes bool, ignores *ignore.Matcher) []protocol.FileInfo { - for i := 0; i < len(fs); { - if fs[i].IsDeleted() && dropDeletes { - l.Debugln("dropping update for undesired delete", fs[i]) - fs[i] = fs[len(fs)-1] - fs = fs[:len(fs)-1] - } else if symlinkInvalid(folder, fs[i]) { - l.Debugln("dropping update for unsupported symlink", fs[i]) - fs[i] = fs[len(fs)-1] - fs = fs[:len(fs)-1] - } else if ignores != nil && ignores.Match(fs[i].Name).IsIgnored() { - l.Debugln("dropping update for ignored item", fs[i]) - fs[i] = fs[len(fs)-1] - fs = fs[:len(fs)-1] - } else { - i++ - } - } - return fs -} - func symlinkInvalid(folder string, fi db.FileIntf) bool { if !symlinks.Supported && fi.IsSymlink() && !fi.IsInvalid() && !fi.IsDeleted() { symlinkWarning.Do(func() { @@ -2391,3 +2385,23 @@ func makeForgetUpdate(files []protocol.FileInfo) []protocol.FileDownloadProgress } return updates } + +// shouldIgnore returns true when a file should be excluded from processing +func shouldIgnore(file db.FileIntf, matcher *ignore.Matcher, ignoreDelete bool) bool { + // We check things in a certain order here... + + switch { + case ignoreDelete && file.IsDeleted(): + // ignoreDelete first because it's a very cheap test so a win if it + // succeeds, and we might in the long run accumulate quite a few + // deleted files. + return true + + case matcher.Match(file.FileName()).IsIgnored(): + // ignore patterns second because ignoring them is a valid way to + // silence warnings about them being invalid and so on. + return true + } + + return false +} diff --git a/lib/model/model_test.go b/lib/model/model_test.go index ff95c767c..146c1a673 100644 --- a/lib/model/model_test.go +++ b/lib/model/model_test.go @@ -1185,43 +1185,6 @@ func benchmarkTree(b *testing.B, n1, n2 int) { b.ReportAllocs() } -func TestIgnoreDelete(t *testing.T) { - db := db.OpenMemory() - m := NewModel(defaultConfig, protocol.LocalDeviceID, "device", "syncthing", "dev", db, nil) - - // This folder should ignore external deletes - cfg := defaultFolderConfig - cfg.IgnoreDelete = true - - m.AddFolder(cfg) - m.ServeBackground() - m.StartFolder("default") - m.ScanFolder("default") - - // Get a currently existing file - f, ok := m.CurrentGlobalFile("default", "foo") - if !ok { - t.Fatal("foo should exist") - } - - // Mark it for deletion - f.Deleted = true - f.Version = f.Version.Update(142) // arbitrary short remote ID - f.Blocks = nil - - // Send the index - m.Index(device1, "default", []protocol.FileInfo{f}) - - // Make sure we ignored it - f, ok = m.CurrentGlobalFile("default", "foo") - if !ok { - t.Fatal("foo should exist") - } - if f.IsDeleted() { - t.Fatal("foo should not be marked for deletion") - } -} - func TestUnifySubs(t *testing.T) { cases := []struct { in []string // input to unifySubs diff --git a/lib/model/rwfolder.go b/lib/model/rwfolder.go index 2ab94bbba..9b4981f4f 100644 --- a/lib/model/rwfolder.go +++ b/lib/model/rwfolder.go @@ -13,6 +13,7 @@ import ( "math/rand" "os" "path/filepath" + "runtime" "sort" "strings" "time" @@ -29,8 +30,6 @@ import ( "github.com/syncthing/syncthing/lib/versioner" ) -// TODO: Stop on errors - func init() { folderFactories[config.FolderTypeReadWrite] = newRWFolder } @@ -84,14 +83,16 @@ type rwFolder struct { dir string versioner versioner.Versioner ignorePerms bool - copiers int - pullers int order config.PullOrder maxConflicts int sleep time.Duration pause time.Duration allowSparse bool checkFreeSpace bool + ignoreDelete bool + + copiers int + pullers int queue *jobQueue dbUpdates chan dbUpdateJob @@ -115,6 +116,7 @@ func newRWFolder(model *Model, cfg config.FolderConfiguration, ver versioner.Ver virtualMtimeRepo: db.NewVirtualMtimeRepo(model.db, cfg.ID), dir: cfg.Path(), + versioner: ver, ignorePerms: cfg.IgnorePerms, copiers: cfg.Copiers, pullers: cfg.Pullers, @@ -122,7 +124,7 @@ func newRWFolder(model *Model, cfg config.FolderConfiguration, ver versioner.Ver maxConflicts: cfg.MaxConflicts, allowSparse: !cfg.DisableSparseFiles, checkFreeSpace: cfg.MinDiskFreePct != 0, - versioner: ver, + ignoreDelete: cfg.IgnoreDelete, queue: newJobQueue(), pullTimer: time.NewTimer(time.Second), @@ -423,15 +425,20 @@ func (f *rwFolder) pullerIteration(ignores *ignore.Matcher) int { // directories as they come along, so parents before children. Files // are queued and the order may be changed later. - file := intf.(protocol.FileInfo) - - if ignores.Match(file.Name).IsIgnored() { - // This is an ignored file. Skip it, continue iteration. + if shouldIgnore(intf, ignores, f.ignoreDelete) { return true } - l.Debugln(f, "handling", file.Name) + if err := fileValid(intf); err != nil { + // The file isn't valid so we can't process it. Pretend that we + // tried and set the error for the file. + f.newError(intf.FileName(), err) + changed++ + return true + } + file := intf.(protocol.FileInfo) + l.Debugln(f, "handling", file.Name) if !handleFile(file) { // A new or changed file or symlink. This is the only case where // we do stuff concurrently in the background. We only queue @@ -1561,3 +1568,44 @@ func (l fileErrorList) Less(a, b int) bool { func (l fileErrorList) Swap(a, b int) { l[a], l[b] = l[b], l[a] } + +// fileValid returns nil when the file is valid for processing, or an error if it's not +func fileValid(file db.FileIntf) error { + switch { + case file.IsDeleted(): + // We don't care about file validity if we're not supposed to have it + return nil + + case !symlinks.Supported && file.IsSymlink(): + return errUnsupportedSymlink + + case runtime.GOOS == "windows" && windowsInvalidFilename(file.FileName()): + return errInvalidFilename + } + + return nil +} + +var windowsDisallowedCharacters = string([]rune{ + '<', '>', ':', '"', '|', '?', '*', + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, + 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, + 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, + 31, +}) + +func windowsInvalidFilename(name string) bool { + // None of the path components should end in space + for _, part := range strings.Split(name, `\`) { + if len(part) == 0 { + continue + } + if part[len(part)-1] == ' ' { + // Names ending in space are not valid. + return true + } + } + + // The path must not contain any disallowed characters + return strings.ContainsAny(name, windowsDisallowedCharacters) +} diff --git a/lib/protocol/nativemodel_windows.go b/lib/protocol/nativemodel_windows.go index 2c76f294a..0997386ac 100644 --- a/lib/protocol/nativemodel_windows.go +++ b/lib/protocol/nativemodel_windows.go @@ -4,21 +4,9 @@ package protocol -// Windows uses backslashes as file separator and disallows a bunch of -// characters in the filename +// Windows uses backslashes as file separator -import ( - "path/filepath" - "strings" -) - -var disallowedCharacters = string([]rune{ - '<', '>', ':', '"', '|', '?', '*', - 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, - 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, - 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, - 31, -}) +import "path/filepath" type nativeModel struct { Model @@ -40,16 +28,7 @@ func (m nativeModel) Request(deviceID DeviceID, folder string, name string, offs } func fixupFiles(folder string, files []FileInfo) { - for i, f := range files { - if strings.ContainsAny(f.Name, disallowedCharacters) || strings.HasSuffix(f.Name, " ") { - if f.IsDeleted() { - // Don't complain if the file is marked as deleted, since it - // can't possibly exist here anyway. - continue - } - files[i].Invalid = true - l.Warnf("File name %q (folder %q) contains invalid characters; marked as invalid.", f.Name, folder) - } + for i := range files { files[i].Name = filepath.FromSlash(files[i].Name) } } diff --git a/lib/protocol/nativemodel_windows_test.go b/lib/protocol/nativemodel_windows_test.go deleted file mode 100644 index ef2a7757d..000000000 --- a/lib/protocol/nativemodel_windows_test.go +++ /dev/null @@ -1,27 +0,0 @@ -// Copyright (C) 2016 The Protocol Authors. - -// +build windows - -package protocol - -import "testing" - -func TestFixupFiles(t *testing.T) { - fs := []FileInfo{ - {Name: "ok"}, // This file is OK - {Name: "b