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