1// Copyright (C) 2014 The Syncthing Authors. 2// 3// This Source Code Form is subject to the terms of the Mozilla Public 4// License, v. 2.0. If a copy of the MPL was not distributed with this file, 5// You can obtain one at https://mozilla.org/MPL/2.0/. 6 7package versioner 8 9import ( 10 "context" 11 "path/filepath" 12 "regexp" 13 "sort" 14 "strings" 15 "time" 16 17 "github.com/pkg/errors" 18 19 "github.com/syncthing/syncthing/lib/config" 20 "github.com/syncthing/syncthing/lib/fs" 21 "github.com/syncthing/syncthing/lib/osutil" 22 "github.com/syncthing/syncthing/lib/util" 23) 24 25var ( 26 ErrDirectory = errors.New("cannot restore on top of a directory") 27 errNotFound = errors.New("version not found") 28 errFileAlreadyExists = errors.New("file already exists") 29) 30 31// TagFilename inserts ~tag just before the extension of the filename. 32func TagFilename(name, tag string) string { 33 dir, file := filepath.Dir(name), filepath.Base(name) 34 ext := filepath.Ext(file) 35 withoutExt := file[:len(file)-len(ext)] 36 return filepath.Join(dir, withoutExt+"~"+tag+ext) 37} 38 39var tagExp = regexp.MustCompile(`.*~([^~.]+)(?:\.[^.]+)?$`) 40 41// extractTag returns the tag from a filename, whether at the end or middle. 42func extractTag(path string) string { 43 match := tagExp.FindStringSubmatch(path) 44 // match is []string{"whole match", "submatch"} when successful 45 46 if len(match) != 2 { 47 return "" 48 } 49 return match[1] 50} 51 52// UntagFilename returns the filename without tag, and the extracted tag 53func UntagFilename(path string) (string, string) { 54 ext := filepath.Ext(path) 55 versionTag := extractTag(path) 56 57 // Files tagged with old style tags cannot be untagged. 58 if versionTag == "" { 59 return "", "" 60 } 61 62 // Old style tag 63 if strings.HasSuffix(ext, versionTag) { 64 return strings.TrimSuffix(path, "~"+versionTag), versionTag 65 } 66 67 withoutExt := path[:len(path)-len(ext)-len(versionTag)-1] 68 name := withoutExt + ext 69 return name, versionTag 70} 71 72func retrieveVersions(fileSystem fs.Filesystem) (map[string][]FileVersion, error) { 73 files := make(map[string][]FileVersion) 74 75 err := fileSystem.Walk(".", func(path string, f fs.FileInfo, err error) error { 76 // Skip root (which is ok to be a symlink) 77 if path == "." { 78 return nil 79 } 80 81 // Skip walking if we cannot walk... 82 if err != nil { 83 return err 84 } 85 86 // Ignore symlinks 87 if f.IsSymlink() { 88 return fs.SkipDir 89 } 90 91 // No records for directories 92 if f.IsDir() { 93 return nil 94 } 95 96 modTime := f.ModTime().Truncate(time.Second) 97 98 path = osutil.NormalizedFilename(path) 99 100 name, tag := UntagFilename(path) 101 // Something invalid, assume it's an untagged file (trashcan versioner stuff) 102 if name == "" || tag == "" { 103 files[path] = append(files[path], FileVersion{ 104 VersionTime: modTime, 105 ModTime: modTime, 106 Size: f.Size(), 107 }) 108 return nil 109 } 110 111 versionTime, err := time.ParseInLocation(TimeFormat, tag, time.Local) 112 if err != nil { 113 // Can't parse it, welp, continue 114 return nil 115 } 116 117 files[name] = append(files[name], FileVersion{ 118 VersionTime: versionTime, 119 ModTime: modTime, 120 Size: f.Size(), 121 }) 122 123 return nil 124 }) 125 126 if err != nil { 127 return nil, err 128 } 129 130 return files, nil 131} 132 133type fileTagger func(string, string) string 134 135func archiveFile(method fs.CopyRangeMethod, srcFs, dstFs fs.Filesystem, filePath string, tagger fileTagger) error { 136 filePath = osutil.NativeFilename(filePath) 137 info, err := srcFs.Lstat(filePath) 138 if fs.IsNotExist(err) { 139 l.Debugln("not archiving nonexistent file", filePath) 140 return nil 141 } else if err != nil { 142 return err 143 } 144 if info.IsSymlink() { 145 panic("bug: attempting to version a symlink") 146 } 147 148 _, err = dstFs.Stat(".") 149 if err != nil { 150 if fs.IsNotExist(err) { 151 l.Debugln("creating versions dir") 152 err := dstFs.MkdirAll(".", 0755) 153 if err != nil { 154 return err 155 } 156 _ = dstFs.Hide(".") 157 } else { 158 return err 159 } 160 } 161 162 file := filepath.Base(filePath) 163 inFolderPath := filepath.Dir(filePath) 164 165 err = dstFs.MkdirAll(inFolderPath, 0755) 166 if err != nil && !fs.IsExist(err) { 167 l.Debugln("archiving", filePath, err) 168 return err 169 } 170 171 now := time.Now() 172 173 ver := tagger(file, now.Format(TimeFormat)) 174 dst := filepath.Join(inFolderPath, ver) 175 l.Debugln("archiving", filePath, "moving to", dst) 176 err = osutil.RenameOrCopy(method, srcFs, dstFs, filePath, dst) 177 178 mtime := info.ModTime() 179 // If it's a trashcan versioner type thing, then it does not have version time in the name 180 // so use mtime for that. 181 if ver == file { 182 mtime = now 183 } 184 185 _ = dstFs.Chtimes(dst, mtime, mtime) 186 187 return err 188} 189 190func restoreFile(method fs.CopyRangeMethod, src, dst fs.Filesystem, filePath string, versionTime time.Time, tagger fileTagger) error { 191 tag := versionTime.In(time.Local).Truncate(time.Second).Format(TimeFormat) 192 taggedFilePath := tagger(filePath, tag) 193 194 // If the something already exists where we are restoring to, archive existing file for versioning 195 // remove if it's a symlink, or fail if it's a directory 196 if info, err := dst.Lstat(filePath); err == nil { 197 switch { 198 case info.IsDir(): 199 return ErrDirectory 200 case info.IsSymlink(): 201 // Remove existing symlinks (as we don't want to archive them) 202 if err := dst.Remove(filePath); err != nil { 203 return errors.Wrap(err, "removing existing symlink") 204 } 205 case info.IsRegular(): 206 if err := archiveFile(method, dst, src, filePath, tagger); err != nil { 207 return errors.Wrap(err, "archiving existing file") 208 } 209 default: 210 panic("bug: unknown item type") 211 } 212 } else if !fs.IsNotExist(err) { 213 return err 214 } 215 216 filePath = osutil.NativeFilename(filePath) 217 218 // Try and find a file that has the correct mtime 219 sourceFile := "" 220 sourceMtime := time.Time{} 221 if info, err := src.Lstat(taggedFilePath); err == nil && info.IsRegular() { 222 sourceFile = taggedFilePath 223 sourceMtime = info.ModTime() 224 } else if err == nil { 225 l.Debugln("restore:", taggedFilePath, "not regular") 226 } else { 227 l.Debugln("restore:", taggedFilePath, err.Error()) 228 } 229 230 // Check for untagged file 231 if sourceFile == "" { 232 info, err := src.Lstat(filePath) 233 if err == nil && info.IsRegular() && info.ModTime().Truncate(time.Second).Equal(versionTime) { 234 sourceFile = filePath 235 sourceMtime = info.ModTime() 236 } 237 238 } 239 240 if sourceFile == "" { 241 return errNotFound 242 } 243 244 // Check that the target location of where we are supposed to restore does not exist. 245 // This should have been taken care of by the first few lines of this function. 246 if _, err := dst.Lstat(filePath); err == nil { 247 return errFileAlreadyExists 248 } else if !fs.IsNotExist(err) { 249 return err 250 } 251 252 _ = dst.MkdirAll(filepath.Dir(filePath), 0755) 253 err := osutil.RenameOrCopy(method, src, dst, sourceFile, filePath) 254 _ = dst.Chtimes(filePath, sourceMtime, sourceMtime) 255 return err 256} 257 258func versionerFsFromFolderCfg(cfg config.FolderConfiguration) (versionsFs fs.Filesystem) { 259 folderFs := cfg.Filesystem() 260 if cfg.Versioning.FSPath == "" { 261 versionsFs = fs.NewFilesystem(folderFs.Type(), filepath.Join(folderFs.URI(), ".stversions")) 262 } else if cfg.Versioning.FSType == fs.FilesystemTypeBasic && !filepath.IsAbs(cfg.Versioning.FSPath) { 263 // We only know how to deal with relative folders for basic filesystems, as that's the only one we know 264 // how to check if it's absolute or relative. 265 versionsFs = fs.NewFilesystem(cfg.Versioning.FSType, filepath.Join(folderFs.URI(), cfg.Versioning.FSPath)) 266 } else { 267 versionsFs = fs.NewFilesystem(cfg.Versioning.FSType, cfg.Versioning.FSPath) 268 } 269 l.Debugf("%s (%s) folder using %s (%s) versioner dir", folderFs.URI(), folderFs.Type(), versionsFs.URI(), versionsFs.Type()) 270 return 271} 272 273func findAllVersions(fs fs.Filesystem, filePath string) []string { 274 inFolderPath := filepath.Dir(filePath) 275 file := filepath.Base(filePath) 276 277 // Glob according to the new file~timestamp.ext pattern. 278 pattern := filepath.Join(inFolderPath, TagFilename(file, timeGlob)) 279 versions, err := fs.Glob(pattern) 280 if err != nil { 281 l.Warnln("globbing:", err, "for", pattern) 282 return nil 283 } 284 versions = util.UniqueTrimmedStrings(versions) 285 sort.Strings(versions) 286 287 return versions 288} 289 290func cleanByDay(ctx context.Context, versionsFs fs.Filesystem, cleanoutDays int) error { 291 if cleanoutDays <= 0 { 292 return nil 293 } 294 295 if _, err := versionsFs.Lstat("."); fs.IsNotExist(err) { 296 return nil 297 } 298 299 cutoff := time.Now().Add(time.Duration(-24*cleanoutDays) * time.Hour) 300 dirTracker := make(emptyDirTracker) 301 302 walkFn := func(path string, info fs.FileInfo, err error) error { 303 if err != nil { 304 return err 305 } 306 307 select { 308 case <-ctx.Done(): 309 return ctx.Err() 310 default: 311 } 312 313 if info.IsDir() && !info.IsSymlink() { 314 dirTracker.addDir(path) 315 return nil 316 } 317 318 if info.ModTime().Before(cutoff) { 319 // The file is too old; remove it. 320 err = versionsFs.Remove(path) 321 } else { 322 // Keep this file, and remember it so we don't unnecessarily try 323 // to remove this directory. 324 dirTracker.addFile(path) 325 } 326 return err 327 } 328 329 if err := versionsFs.Walk(".", walkFn); err != nil { 330 return err 331 } 332 333 dirTracker.deleteEmptyDirs(versionsFs) 334 335 return nil 336} 337