// Copyright (C) 2014 The Syncthing Authors. // // This Source Code Form is subject to the terms of the Mozilla Public // License, v. 2.0. If a copy of the MPL was not distributed with this file, // You can obtain one at https://mozilla.org/MPL/2.0/. package versioner import ( "context" "errors" "fmt" "path/filepath" "regexp" "sort" "strings" "time" "github.com/syncthing/syncthing/lib/config" "github.com/syncthing/syncthing/lib/fs" "github.com/syncthing/syncthing/lib/osutil" "github.com/syncthing/syncthing/lib/stringutil" ) var ( ErrDirectory = errors.New("cannot restore on top of a directory") errNotFound = errors.New("version not found") errFileAlreadyExists = errors.New("file already exists") ) const ( DefaultPath = ".stversions" ) // TagFilename inserts ~tag just before the extension of the filename. func TagFilename(name, tag string) string { dir, file := filepath.Dir(name), filepath.Base(name) ext := filepath.Ext(file) withoutExt := file[:len(file)-len(ext)] return filepath.Join(dir, withoutExt+"~"+tag+ext) } var tagExp = regexp.MustCompile(`.*~([^~.]+)(?:\.[^.]+)?$`) // extractTag returns the tag from a filename, whether at the end or middle. func extractTag(path string) string { match := tagExp.FindStringSubmatch(path) // match is []string{"whole match", "submatch"} when successful if len(match) != 2 { return "" } return match[1] } // UntagFilename returns the filename without tag, and the extracted tag func UntagFilename(path string) (string, string) { ext := filepath.Ext(path) versionTag := extractTag(path) // Files tagged with old style tags cannot be untagged. if versionTag == "" { return "", "" } // Old style tag if strings.HasSuffix(ext, versionTag) { return strings.TrimSuffix(path, "~"+versionTag), versionTag } withoutExt := path[:len(path)-len(ext)-len(versionTag)-1] name := withoutExt + ext return name, versionTag } func retrieveVersions(fileSystem fs.Filesystem) (map[string][]FileVersion, error) { files := make(map[string][]FileVersion) err := fileSystem.Walk(".", func(path string, f fs.FileInfo, err error) error { // Skip root (which is ok to be a symlink) if path == "." { return nil } // Skip walking if we cannot walk... if err != nil { return err } // Ignore symlinks if f.IsSymlink() { return fs.SkipDir } // No records for directories if f.IsDir() { return nil } modTime := f.ModTime().Truncate(time.Second) path = osutil.NormalizedFilename(path) name, tag := UntagFilename(path) // Something invalid, assume it's an untagged file (trashcan versioner stuff) if name == "" || tag == "" { files[path] = append(files[path], FileVersion{ VersionTime: modTime, ModTime: modTime, Size: f.Size(), }) return nil } versionTime, err := time.ParseInLocation(TimeFormat, tag, time.Local) if err != nil { // Can't parse it, welp, continue return nil } files[name] = append(files[name], FileVersion{ VersionTime: versionTime, ModTime: modTime, Size: f.Size(), }) return nil }) if err != nil { return nil, err } return files, nil } type fileTagger func(string, string) string func archiveFile(method fs.CopyRangeMethod, srcFs, dstFs fs.Filesystem, filePath string, tagger fileTagger) error { filePath = osutil.NativeFilename(filePath) info, err := srcFs.Lstat(filePath) if fs.IsNotExist(err) { l.Debugln("not archiving nonexistent file", filePath) return nil } else if err != nil { return err } if info.IsSymlink() { panic("bug: attempting to version a symlink") } _, err = dstFs.Stat(".") if err != nil { if fs.IsNotExist(err) { l.Debugln("creating versions dir") err := dstFs.MkdirAll(".", 0o755) if err != nil { return err } _ = dstFs.Hide(".") } else { return err } } file := filepath.Base(filePath) inFolderPath := filepath.Dir(filePath) err = dstFs.MkdirAll(inFolderPath, 0o755) if err != nil && !fs.IsExist(err) { l.Debugln("archiving", filePath, err) return err } now := time.Now() ver := tagger(file, now.Format(TimeFormat)) dst := filepath.Join(inFolderPath, ver) l.Debugln("archiving", filePath, "moving to", dst) err = osutil.RenameOrCopy(method, srcFs, dstFs, filePath, dst) mtime := info.ModTime() // If it's a trashcan versioner type thing, then it does not have version time in the name // so use mtime for that. if ver == file { mtime = now } _ = dstFs.Chtimes(dst, mtime, mtime) return err } func restoreFile(method fs.CopyRangeMethod, src, dst fs.Filesystem, filePath string, versionTime time.Time, tagger fileTagger) error { tag := versionTime.In(time.Local).Truncate(time.Second).Format(TimeFormat) taggedFilePath := tagger(filePath, tag) // If the something already exists where we are restoring to, archive existing file for versioning // remove if it's a symlink, or fail if it's a directory if info, err := dst.Lstat(filePath); err == nil { switch { case info.IsDir(): return ErrDirectory case info.IsSymlink(): // Remove existing symlinks (as we don't want to archive them) if err := dst.Remove(filePath); err != nil { return fmt.Errorf("removing existing symlink: %w", err) } case info.IsRegular(): if err := archiveFile(method, dst, src, filePath, tagger); err != nil { return fmt.Errorf("archiving existing file: %w", err) } default: panic("bug: unknown item type") } } else if !fs.IsNotExist(err) { return err } filePath = osutil.NativeFilename(filePath) // Try and find a file that has the correct mtime sourceFile := "" sourceMtime := time.Time{} if info, err := src.Lstat(taggedFilePath); err == nil && info.IsRegular() { sourceFile = taggedFilePath sourceMtime = info.ModTime() } else if err == nil { l.Debugln("restore:", taggedFilePath, "not regular") } else { l.Debugln("restore:", taggedFilePath, err.Error()) } // Check for untagged file if sourceFile == "" { info, err := src.Lstat(filePath) if err == nil && info.IsRegular() && info.ModTime().Truncate(time.Second).Equal(versionTime) { sourceFile = filePath sourceMtime = info.ModTime() } } if sourceFile == "" { return errNotFound } // Check that the target location of where we are supposed to restore does not exist. // This should have been taken care of by the first few lines of this function. if _, err := dst.Lstat(filePath); err == nil { return errFileAlreadyExists } else if !fs.IsNotExist(err) { return err } _ = dst.MkdirAll(filepath.Dir(filePath), 0o755) err := osutil.RenameOrCopy(method, src, dst, sourceFile, filePath) _ = dst.Chtimes(filePath, sourceMtime, sourceMtime) return err } func versionerFsFromFolderCfg(cfg config.FolderConfiguration) (versionsFs fs.Filesystem) { folderFs := cfg.Filesystem(nil) if cfg.Versioning.FSPath == "" { versionsFs = fs.NewFilesystem(folderFs.Type(), filepath.Join(folderFs.URI(), DefaultPath)) } else if cfg.Versioning.FSType == config.FilesystemTypeBasic { // Expand any leading tildes for basic filesystems, // before checking for absolute paths. path, err := fs.ExpandTilde(cfg.Versioning.FSPath) if err != nil { path = cfg.Versioning.FSPath } // We only know how to deal with relative folders for // basic filesystems, as that's the only one we know // how to check if it's absolute or relative. if !filepath.IsAbs(path) { path = filepath.Join(folderFs.URI(), path) } versionsFs = fs.NewFilesystem(cfg.Versioning.FSType.ToFS(), path) } else { versionsFs = fs.NewFilesystem(cfg.Versioning.FSType.ToFS(), cfg.Versioning.FSPath) } l.Debugf("%s (%s) folder using %s (%s) versioner dir", folderFs.URI(), folderFs.Type(), versionsFs.URI(), versionsFs.Type()) return } func findAllVersions(fs fs.Filesystem, filePath string) []string { inFolderPath := filepath.Dir(filePath) file := filepath.Base(filePath) // Glob according to the new file~timestamp.ext pattern. pattern := filepath.Join(inFolderPath, TagFilename(file, timeGlob)) versions, err := fs.Glob(pattern) if err != nil { l.Warnln("globbing:", err, "for", pattern) return nil } versions = stringutil.UniqueTrimmedStrings(versions) sort.Strings(versions) return versions } func clean(ctx context.Context, versionsFs fs.Filesystem, toRemove func([]string, time.Time) []string) error { l.Debugln("Versioner clean: Cleaning", versionsFs) if _, err := versionsFs.Stat("."); fs.IsNotExist(err) { // There is no need to clean a nonexistent dir. return nil } versionsPerFile := make(map[string][]string) dirTracker := make(emptyDirTracker) walkFn := func(path string, f fs.FileInfo, err error) error { if err != nil { return err } select { case <-ctx.Done(): return ctx.Err() default: } if f.IsDir() && !f.IsSymlink() { dirTracker.addDir(path) return nil } // Regular file, or possibly a symlink. dirTracker.addFile(path) name, _ := UntagFilename(path) if name == "" { return nil } versionsPerFile[name] = append(versionsPerFile[name], path) return nil } if err := versionsFs.Walk(".", walkFn); err != nil { if !errors.Is(err, context.Canceled) { l.Warnln("Versioner: scanning versions dir:", err) } return err } for _, versionList := range versionsPerFile { select { case <-ctx.Done(): return ctx.Err() default: } cleanVersions(versionsFs, versionList, toRemove) } dirTracker.deleteEmptyDirs(versionsFs) l.Debugln("Cleaner: Finished cleaning", versionsFs) return nil } func cleanVersions(versionsFs fs.Filesystem, versions []string, toRemove func([]string, time.Time) []string) { l.Debugln("Versioner: Expiring versions", versions) for _, file := range toRemove(versions, time.Now()) { if err := versionsFs.Remove(file); err != nil { l.Warnf("Versioner: can't remove %q: %v", file, err) } } }