Repository: getsops/sops (language: python)

Issue:
sops does not respect explicit XDG_CONFIG_HOME
I am on MacOS, and I have explicitly set `XDG_CONFIG_HOME` in my environment because `/Users/me/Library/Application Support` is an awkward location to store config files and `$HOME/.config` is much better. By setting this explicitly, applications should respect it.

However:

```
$ sops  <encrypted file>
Failed to get the data key required to decrypt the SOPS file.

Group 0: FAILED
  age<pukey>: FAILED
    - | failed to open file: open /Users/me/Library/Application
      | Support/sops/age/keys.txt: no such file or directory

Recovery failed because the file was encrypted with a Shamir threshold of
1, but only 0 part(s) were successfully recovered, one for each successful
key group. In order for SOPS to recover the file, at least 1 groups have to
be successful. In order for a group to be successful, decryption has to
succeed with any of the keys in that key group.

$ echo $XDG_CONFIG_HOME
/Users/me/.config
```

Sadly, the [ship has sailed](https://github.com/golang/go/issues/29960#issuecomment-505321146) for `os.UserConfigDir()`, which is what sops uses, but that doesn't mean there can't be a fallback, so it would be nice if sops could support it.


Discussion / hints:
Note that as a workaround (for now) you can use `SOPS_AGE_KEY_FILE` to point sops to the right path.
You are right, I absolutely should have mentioned that. That's what I'm doing now.

Fix patch:
diff --git a/age/keysource.go b/age/keysource.go
index a83d1a5c2..9ee6be491 100644
--- a/age/keysource.go
+++ b/age/keysource.go
@@ -7,6 +7,7 @@ import (
 	"io"
 	"os"
 	"path/filepath"
+	"runtime"
 	"strings"
 
 	"filippo.io/age"
@@ -23,8 +24,10 @@ const (
 	// age keys file.
 	SopsAgeKeyFileEnv = "SOPS_AGE_KEY_FILE"
 	// SopsAgeKeyUserConfigPath is the default age keys file path in
-	// os.UserConfigDir.
+	// getUserConfigDir().
 	SopsAgeKeyUserConfigPath = "sops/age/keys.txt"
+	// On macOS, os.UserConfigDir() ignores XDG_CONFIG_HOME. So we handle that manually.
+	xdgConfigHome = "XDG_CONFIG_HOME"
 )
 
 // log is the global logger for any age MasterKey.
@@ -222,6 +225,15 @@ func (key *MasterKey) ToMap() map[string]interface{} {
 	return out
 }
 
+func getUserConfigDir() (string, error) {
+	if runtime.GOOS == "darwin" {
+		if userConfigDir, ok := os.LookupEnv(xdgConfigHome); ok && userConfigDir != "" {
+			return userConfigDir, nil
+		}
+	}
+	return os.UserConfigDir()
+}
+
 // loadIdentities attempts to load the age identities based on runtime
 // environment configurations (e.g. SopsAgeKeyEnv, SopsAgeKeyFileEnv,
 // SopsAgeKeyUserConfigPath). It will load all found references, and expects
@@ -242,7 +254,7 @@ func (key *MasterKey) loadIdentities() (ParsedIdentities, error) {
 		readers[SopsAgeKeyFileEnv] = f
 	}
 
-	userConfigDir, err := os.UserConfigDir()
+	userConfigDir, err := getUserConfigDir()
 	if err != nil && len(readers) == 0 {
 		return nil, fmt.Errorf("user config directory could not be determined: %w", err)
 	}


Test patch:
diff --git a/age/keysource_test.go b/age/keysource_test.go
index dd56351ea..62d0fcf78 100644
--- a/age/keysource_test.go
+++ b/age/keysource_test.go
@@ -380,11 +380,23 @@ func overwriteUserConfigDir(t *testing.T, path string) {
 	switch runtime.GOOS {
 	case "windows":
 		t.Setenv("AppData", path)
-	case "darwin", "ios": // This adds "/Library/Application Support" as a suffix to $HOME
-		t.Setenv("HOME", path)
 	case "plan9": // This adds "/lib" as a suffix to $home
 		t.Setenv("home", path)
 	default: // Unix
 		t.Setenv("XDG_CONFIG_HOME", path)
 	}
 }
+
+// Make sure that on all supported platforms but Windows, XDG_CONFIG_HOME
+// can be used to specify the user's home directory. For most platforms
+// this is handled by Go's os.UserConfigDir(), but for Darwin our code
+// in getUserConfigDir() handles this explicitly.
+func TestUserConfigDir(t *testing.T) {
+	if runtime.GOOS != "windows" {
+		const dir = "/test/home/dir"
+		t.Setenv("XDG_CONFIG_HOME", dir)
+		home, err := getUserConfigDir()
+		assert.Nil(t, err)
+		assert.Equal(t, home, dir)
+	}
+}