Browse Source

lib/locations: Change default config/data location to new XDG recommendation (fixes #9178, fixes #9179) (#9180)

This makes the new default $XDG_STATE_HOME/syncthing or
~/.local/state/syncthing, while still looking in legacy locations first
for existing installs.

Note that this does not *move* existing installs, and nor should we.
Existing paths will continue to be used as-is, but the user can move the
dir into the new place if they want to use it (as they could prior to
this change as well, for that matter).

### Documentation

Needs update to the config docs about our default locations.
Jakob Borg 2 years ago
parent
commit
b5082f6af8
2 changed files with 204 additions and 43 deletions
  1. 102 43
      lib/locations/locations.go
  2. 102 0
      lib/locations/locations_test.go

+ 102 - 43
lib/locations/locations.go

@@ -10,7 +10,6 @@ import (
 	"fmt"
 	"fmt"
 	"os"
 	"os"
 	"path/filepath"
 	"path/filepath"
-	"runtime"
 	"strings"
 	"strings"
 	"time"
 	"time"
 
 
@@ -40,13 +39,18 @@ const (
 type BaseDirEnum string
 type BaseDirEnum string
 
 
 const (
 const (
-	// Overridden by --home flag
+	// Overridden by --home flag, $STHOMEDIR, --config flag, or $STCONFDIR
 	ConfigBaseDir BaseDirEnum = "config"
 	ConfigBaseDir BaseDirEnum = "config"
-	DataBaseDir   BaseDirEnum = "data"
+	// Overridden by --home flag, $STHOMEDIR, --data flag, or $STDATADIR
+	DataBaseDir BaseDirEnum = "data"
+
 	// User's home directory, *not* --home flag
 	// User's home directory, *not* --home flag
 	UserHomeBaseDir BaseDirEnum = "userHome"
 	UserHomeBaseDir BaseDirEnum = "userHome"
 
 
-	LevelDBDir = "index-v0.14.0.db"
+	LevelDBDir          = "index-v0.14.0.db"
+	configFileName      = "config.xml"
+	defaultStateDir     = ".local/state/syncthing"
+	oldDefaultConfigDir = ".config/syncthing"
 )
 )
 
 
 // Platform dependent directories
 // Platform dependent directories
@@ -55,12 +59,13 @@ var baseDirs = make(map[BaseDirEnum]string, 3)
 func init() {
 func init() {
 	userHome := userHomeDir()
 	userHome := userHomeDir()
 	config := defaultConfigDir(userHome)
 	config := defaultConfigDir(userHome)
+	data := defaultDataDir(userHome, config)
+
 	baseDirs[UserHomeBaseDir] = userHome
 	baseDirs[UserHomeBaseDir] = userHome
 	baseDirs[ConfigBaseDir] = config
 	baseDirs[ConfigBaseDir] = config
-	baseDirs[DataBaseDir] = defaultDataDir(userHome, config)
+	baseDirs[DataBaseDir] = data
 
 
-	err := expandLocations()
-	if err != nil {
+	if err := expandLocations(); err != nil {
 		fmt.Println(err)
 		fmt.Println(err)
 		panic("Failed to expand locations at init time")
 		panic("Failed to expand locations at init time")
 	}
 	}
@@ -92,8 +97,7 @@ func SetBaseDir(baseDirName BaseDirEnum, path string) error {
 			return err
 			return err
 		}
 		}
 	}
 	}
-	_, ok := baseDirs[baseDirName]
-	if !ok {
+	if _, ok := baseDirs[baseDirName]; !ok {
 		return fmt.Errorf("unknown base dir: %s", baseDirName)
 		return fmt.Errorf("unknown base dir: %s", baseDirName)
 	}
 	}
 	baseDirs[baseDirName] = filepath.Clean(path)
 	baseDirs[baseDirName] = filepath.Clean(path)
@@ -131,9 +135,9 @@ var locations = make(map[LocationEnum]string)
 func expandLocations() error {
 func expandLocations() error {
 	newLocations := make(map[LocationEnum]string)
 	newLocations := make(map[LocationEnum]string)
 	for key, dir := range locationTemplates {
 	for key, dir := range locationTemplates {
-		for varName, value := range baseDirs {
-			dir = strings.ReplaceAll(dir, "${"+string(varName)+"}", value)
-		}
+		dir = os.Expand(dir, func(s string) string {
+			return baseDirs[BaseDirEnum(s)]
+		})
 		var err error
 		var err error
 		dir, err = fs.ExpandTilde(dir)
 		dir, err = fs.ExpandTilde(dir)
 		if err != nil {
 		if err != nil {
@@ -175,49 +179,99 @@ func PrettyPaths() string {
 // out by various the environment variables present on each platform, or dies
 // out by various the environment variables present on each platform, or dies
 // trying.
 // trying.
 func defaultConfigDir(userHome string) string {
 func defaultConfigDir(userHome string) string {
-	switch runtime.GOOS {
-	case build.Windows:
-		if p := os.Getenv("LocalAppData"); p != "" {
-			return filepath.Join(p, "Syncthing")
-		}
-		return filepath.Join(os.Getenv("AppData"), "Syncthing")
+	switch {
+	case build.IsWindows:
+		return windowsConfigDataDir()
 
 
-	case build.Darwin:
-		return filepath.Join(userHome, "Library/Application Support/Syncthing")
+	case build.IsDarwin:
+		return darwinConfigDataDir(userHome)
 
 
 	default:
 	default:
-		if xdgCfg := os.Getenv("XDG_CONFIG_HOME"); xdgCfg != "" {
-			return filepath.Join(xdgCfg, "syncthing")
-		}
-		return filepath.Join(userHome, ".config/syncthing")
+		return unixConfigDir(userHome, os.Getenv("XDG_CONFIG_HOME"), os.Getenv("XDG_STATE_HOME"), fileExists)
 	}
 	}
 }
 }
 
 
-// defaultDataDir returns the default data directory, which usually is the
-// config directory but might be something else.
-func defaultDataDir(userHome, config string) string {
+// defaultDataDir returns the default data directory, where we store the
+// database, log files, etc.
+func defaultDataDir(userHome, configDir string) string {
 	if build.IsWindows || build.IsDarwin {
 	if build.IsWindows || build.IsDarwin {
-		return config
+		return configDir
 	}
 	}
 
 
-	// If a database exists at the "normal" location, use that anyway.
-	if _, err := os.Lstat(filepath.Join(config, LevelDBDir)); err == nil {
-		return config
+	return unixDataDir(userHome, configDir, os.Getenv("XDG_DATA_HOME"), os.Getenv("XDG_STATE_HOME"), fileExists)
+}
+
+func windowsConfigDataDir() string {
+	if p := os.Getenv("LocalAppData"); p != "" {
+		return filepath.Join(p, "Syncthing")
 	}
 	}
-	// Always use this env var, as it's explicitly set by the user
-	if xdgHome := os.Getenv("XDG_DATA_HOME"); xdgHome != "" {
-		return filepath.Join(xdgHome, "syncthing")
+	return filepath.Join(os.Getenv("AppData"), "Syncthing")
+}
+
+func darwinConfigDataDir(userHome string) string {
+	return filepath.Join(userHome, "Library/Application Support/Syncthing")
+}
+
+func unixConfigDir(userHome, xdgConfigHome, xdgStateHome string, fileExists func(string) bool) string {
+	// Legacy: if our config exists under $XDG_CONFIG_HOME/syncthing,
+	// use that. The variable should be set to an absolute path or be
+	// ignored, but that's not what we did previously, so we retain the
+	// old behavior.
+	if xdgConfigHome != "" {
+		candidate := filepath.Join(xdgConfigHome, "syncthing")
+		if fileExists(filepath.Join(candidate, configFileName)) {
+			return candidate
+		}
 	}
 	}
-	// Only use the XDG default, if a syncthing specific dir already
-	// exists. Existence of ~/.local/share is not deemed enough, as
-	// it may also exist erroneously on non-XDG systems.
-	xdgDefault := filepath.Join(userHome, ".local/share/syncthing")
-	if _, err := os.Lstat(xdgDefault); err == nil {
-		return xdgDefault
+
+	// Legacy: if our config exists under ~/.config/syncthing, use that
+	candidate := filepath.Join(userHome, oldDefaultConfigDir)
+	if fileExists(filepath.Join(candidate, configFileName)) {
+		return candidate
 	}
 	}
-	// FYI: XDG_DATA_DIRS is not relevant, as it is for system-wide
-	// data dirs, not user specific ones.
-	return config
+
+	// If XDG_STATE_HOME is set to an absolute path, use that
+	if filepath.IsAbs(xdgStateHome) {
+		return filepath.Join(xdgStateHome, "syncthing")
+	}
+
+	// Use our default
+	return filepath.Join(userHome, defaultStateDir)
+}
+
+// unixDataDir returns the default data directory, where we store the
+// database, log files, etc, on Unix-like systems.
+func unixDataDir(userHome, configDir, xdgDataHome, xdgStateHome string, fileExists func(string) bool) string {
+	// If a database exists at the config location, use that. This is the
+	// most common case for both legacy (~/.config/syncthing) and current
+	// (~/.local/state/syncthing) setups.
+	if fileExists(filepath.Join(configDir, LevelDBDir)) {
+		return configDir
+	}
+
+	// Legacy: if a database exists under $XDG_DATA_HOME/syncthing, use
+	// that. The variable should be set to an absolute path or be ignored,
+	// but that's not what we did previously, so we retain the old behavior.
+	if xdgDataHome != "" {
+		candidate := filepath.Join(xdgDataHome, "syncthing")
+		if fileExists(filepath.Join(candidate, LevelDBDir)) {
+			return candidate
+		}
+	}
+
+	// Legacy: if a database exists under ~/.config/syncthing, use that
+	candidate := filepath.Join(userHome, oldDefaultConfigDir)
+	if fileExists(filepath.Join(candidate, LevelDBDir)) {
+		return candidate
+	}
+
+	// If XDG_STATE_HOME is set to an absolute path, use that
+	if filepath.IsAbs(xdgStateHome) {
+		return filepath.Join(xdgStateHome, "syncthing")
+	}
+
+	// Use our default
+	return filepath.Join(userHome, defaultStateDir)
 }
 }
 
 
 // userHomeDir returns the user's home directory, or dies trying.
 // userHomeDir returns the user's home directory, or dies trying.
@@ -240,3 +294,8 @@ func GetTimestamped(key LocationEnum) string {
 	now := time.Now().Format("20060102-150405")
 	now := time.Now().Format("20060102-150405")
 	return strings.ReplaceAll(tpl, "${timestamp}", now)
 	return strings.ReplaceAll(tpl, "${timestamp}", now)
 }
 }
+
+func fileExists(path string) bool {
+	_, err := os.Lstat(path)
+	return err == nil
+}

+ 102 - 0
lib/locations/locations_test.go

@@ -0,0 +1,102 @@
+// Copyright (C) 2023 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/.
+
+//go:build !windows
+
+package locations
+
+import (
+	"testing"
+
+	"golang.org/x/exp/slices"
+)
+
+func TestUnixConfigDir(t *testing.T) {
+	t.Parallel()
+
+	cases := []struct {
+		userHome      string
+		xdgConfigHome string
+		xdgStateHome  string
+		filesExist    []string
+		expected      string
+	}{
+		// First some "new installations", no files exist previously.
+
+		// No variables set, use our current default
+		{"/home/user", "", "", nil, "/home/user/.local/state/syncthing"},
+		// Config home set, doesn't matter
+		{"/home/user", "/somewhere/else", "", nil, "/home/user/.local/state/syncthing"},
+		// State home set, use that
+		{"/home/user", "", "/var/state", nil, "/var/state/syncthing"},
+		// State home set, again config home doesn't matter
+		{"/home/user", "/somewhere/else", "/var/state", nil, "/var/state/syncthing"},
+
+		// Now some "upgrades", where we have files in the old locations.
+
+		// Config home set, a file exists in the default location
+		{"/home/user", "/somewhere/else", "", []string{"/home/user/.config/syncthing/config.xml"}, "/home/user/.config/syncthing"},
+		// State home set, a file exists in the default location
+		{"/home/user", "", "/var/state", []string{"/home/user/.config/syncthing/config.xml"}, "/home/user/.config/syncthing"},
+		// Both config home and state home set, a file exists in the default location
+		{"/home/user", "/somewhere/else", "/var/state", []string{"/home/user/.config/syncthing/config.xml"}, "/home/user/.config/syncthing"},
+
+		// Config home set, and a file exists at that place
+		{"/home/user", "/somewhere/else", "", []string{"/somewhere/else/syncthing/config.xml"}, "/somewhere/else/syncthing"},
+		// Config home and state home set, and a file exists in config home
+		{"/home/user", "/somewhere/else", "/var/state", []string{"/somewhere/else/syncthing/config.xml"}, "/somewhere/else/syncthing"},
+	}
+
+	for _, c := range cases {
+		fileExists := func(path string) bool { return slices.Contains(c.filesExist, path) }
+		actual := unixConfigDir(c.userHome, c.xdgConfigHome, c.xdgStateHome, fileExists)
+		if actual != c.expected {
+			t.Errorf("unixConfigDir(%q, %q, %q) == %q, expected %q", c.userHome, c.xdgConfigHome, c.xdgStateHome, actual, c.expected)
+		}
+	}
+}
+
+func TestUnixDataDir(t *testing.T) {
+	t.Parallel()
+
+	cases := []struct {
+		userHome     string
+		configDir    string
+		xdgDataHome  string
+		xdgStateHome string
+		filesExist   []string
+		expected     string
+	}{
+		// First some "new installations", no files exist previously.
+
+		// No variables set, use our current default
+		{"/home/user", "", "", "", nil, "/home/user/.local/state/syncthing"},
+		// Data home set, doesn't matter
+		{"/home/user", "", "/somewhere/else", "", nil, "/home/user/.local/state/syncthing"},
+		// State home set, use that
+		{"/home/user", "", "", "/var/state", nil, "/var/state/syncthing"},
+
+		// Now some "upgrades", where we have files in the old locations.
+
+		// A database exists in the old default location, use that
+		{"/home/user", "", "", "", []string{"/home/user/.config/syncthing/index-v0.14.0.db"}, "/home/user/.config/syncthing"},
+		{"/home/user", "/config/dir", "/xdg/data/home", "/xdg/state/home", []string{"/home/user/.config/syncthing/index-v0.14.0.db"}, "/home/user/.config/syncthing"},
+
+		// A database exists in the config dir, use that
+		{"/home/user", "/config/dir", "/xdg/data/home", "/xdg/state/home", []string{"/config/dir/index-v0.14.0.db"}, "/config/dir"},
+
+		// A database exists in the old xdg data home, use that
+		{"/home/user", "/config/dir", "/xdg/data/home", "/xdg/state/home", []string{"/xdg/data/home/syncthing/index-v0.14.0.db"}, "/xdg/data/home/syncthing"},
+	}
+
+	for _, c := range cases {
+		fileExists := func(path string) bool { return slices.Contains(c.filesExist, path) }
+		actual := unixDataDir(c.userHome, c.configDir, c.xdgDataHome, c.xdgStateHome, fileExists)
+		if actual != c.expected {
+			t.Errorf("unixDataDir(%q, %q, %q, %q) == %q, expected %q", c.userHome, c.configDir, c.xdgDataHome, c.xdgStateHome, actual, c.expected)
+		}
+	}
+}