From 0c43d9a610f4a20657c867e734f9674cf24401c4 Mon Sep 17 00:00:00 2001 From: Ethan Aldrich Date: Fri, 16 Oct 2026 09:03:21 -0700 Subject: [PATCH] Add --config-from-file-dir to search for .sops.yaml from the target file's directory. By default sops looks for .sops.yaml starting at the current working directory. With the new opt-in flag (or SOPS_CONFIG_FROM_FILE_DIR) the search starts at the directory of the file being operated on instead, and walks up from there. The flag only changes the starting point, it never merges two configs, and --config still takes precedence. Default behavior is unchanged. updatekeys looks the config up per file when the flag is set, and publish starts from its file argument. Also switch LookupConfigFile from path to path/filepath. path.Dir does not understand Windows separators, so an absolute Windows file path would have silently fallen back to the current directory. Fixes #242 Signed-off-by: Ethan Aldrich --- cmd/sops/main.go | 41 +++++++++++++++++++++++++++++++++-------- config/config.go | 9 ++++----- config/config_test.go | 20 ++++++++++++++++++++ 3 files changed, 57 insertions(+), 13 deletions(-) diff --git a/cmd/sops/main.go b/cmd/sops/main.go index 8092a3f400..e5e78a557d 100644 --- a/cmd/sops/main.go +++ b/cmd/sops/main.go @@ -415,7 +415,7 @@ func main() { if c.GlobalString("config") != "" { configPath = c.GlobalString("config") } else { - configPath, err = findConfigFile() + configPath, err = findConfigFile(c, c.Args().First()) if err != nil { return common.NewExitError(err, codes.ErrorGeneric) } @@ -740,8 +740,8 @@ func main() { var configPath string if c.GlobalString("config") != "" { configPath = c.GlobalString("config") - } else { - configPath, err = findConfigFile() + } else if !c.GlobalBool("config-from-file-dir") { + configPath, err = findConfigFile(c, "") if err != nil { return common.NewExitError(err, codes.ErrorGeneric) } @@ -751,12 +751,23 @@ func main() { } failedCounter := 0 for _, path := range c.Args() { + fileConfigPath := configPath + if fileConfigPath == "" { + // --config-from-file-dir: every file may have its own config + var err error + fileConfigPath, err = findConfigFile(c, path) + if err != nil { + failedCounter++ + log.Error(err) + continue + } + } err := updatekeys.UpdateKeys(updatekeys.Opts{ InputPath: path, ShamirThreshold: c.Int("shamir-secret-sharing-threshold"), KeyServices: keyservices(c), Interactive: !c.Bool("yes"), - ConfigPath: configPath, + ConfigPath: fileConfigPath, InputType: c.String("input-type"), }) @@ -1855,6 +1866,11 @@ func main() { Usage: "path to sops' config file. If set, sops will not search for the config file recursively.", EnvVar: "SOPS_CONFIG", }, + cli.BoolFlag{ + Name: "config-from-file-dir", + Usage: "search for the config file starting from the target file's directory instead of the current directory. Ignored when --config is set.", + EnvVar: "SOPS_CONFIG_FROM_FILE_DIR", + }, cli.StringFlag{ Name: "encryption-context", Usage: "comma separated list of KMS encryption context key:value pairs", @@ -2361,8 +2377,17 @@ func keyservices(c *cli.Context) (svcs []keyservice.KeyServiceClient) { } // Wrapper of config.LookupConfigFile that takes care of handling the returned warning. -func findConfigFile() (string, error) { - result, err := config.LookupConfigFile(".") +// The search starts at the directory of file if --config-from-file-dir is set, and at the current directory otherwise. +func findConfigFile(c *cli.Context, file string) (string, error) { + start := "." + if c.GlobalBool("config-from-file-dir") { + start = file + if info, err := os.Stat(file); err == nil && info.IsDir() { + // LookupConfigFile starts at the directory part, so make the directory itself that part + start = file + string(filepath.Separator) + } + } + result, err := config.LookupConfigFile(start) if len(result.Warning) > 0 && !showedConfigFileWarning { showedConfigFileWarning = true log.Warn(result.Warning) @@ -2374,7 +2399,7 @@ func loadStoresConfig(context *cli.Context, path string) (*config.StoresConfig, configPath := context.GlobalString("config") if configPath == "" { // Ignore config not found errors returned from findConfigFile since the config file is not mandatory - foundPath, err := findConfigFile() + foundPath, err := findConfigFile(context, path) if err != nil { return config.NewStoresConfig(), nil } @@ -2531,7 +2556,7 @@ func loadConfig(c *cli.Context, file string, kmsEncryptionContext map[string]*st configPath := c.GlobalString("config") if configPath == "" { // Ignore config not found errors returned from findConfigFile since the config file is not mandatory - configPath, err = findConfigFile() + configPath, err = findConfigFile(c, file) if err != nil { // If we can't find a config file, but we were not explicitly requested to, assume it does not exist return nil, nil diff --git a/config/config.go b/config/config.go index 511df1bc15..ba2a0645f4 100644 --- a/config/config.go +++ b/config/config.go @@ -6,7 +6,6 @@ package config //import "github.com/getsops/sops/v3/config" import ( "fmt" "os" - "path" "path/filepath" "regexp" "strings" @@ -53,11 +52,11 @@ type ConfigFileResult struct { // and on parent directories, up to the maxDepth limit. // It returns a result containing the file path and any warnings. func LookupConfigFile(start string) (ConfigFileResult, error) { - filepath := path.Dir(start) + dir := filepath.Dir(start) var foundAlternatePath string for i := 0; i < maxDepth; i++ { - configPath := path.Join(filepath, configFileName) + configPath := filepath.Join(dir, configFileName) _, err := fs.Stat(configPath) if err == nil { result := ConfigFileResult{Path: configPath} @@ -72,14 +71,14 @@ func LookupConfigFile(start string) (ConfigFileResult, error) { // Check for alternate filename if we haven't found one yet if foundAlternatePath == "" { - alternatePath := path.Join(filepath, alternateConfigName) + alternatePath := filepath.Join(dir, alternateConfigName) _, altErr := fs.Stat(alternatePath) if altErr == nil { foundAlternatePath = alternatePath } } - filepath = path.Join(filepath, "..") + dir = filepath.Join(dir, "..") } // No config file found diff --git a/config/config_test.go b/config/config_test.go index 04bed7f564..c658a7d569 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "path" + "path/filepath" "testing" "github.com/getsops/sops/v3/keys" @@ -44,6 +45,25 @@ func TestFindConfigFileCurrentDir(t *testing.T) { assert.Equal(t, expectedPath, filepath) } +func TestFindConfigFileFromFilePath(t *testing.T) { + orig := fs + t.Cleanup(func() { fs = orig }) + expectedPath := filepath.Join("a", ".sops.yaml") + fs = mockFS{stat: func(name string) (os.FileInfo, error) { + if name == expectedPath { + return nil, nil + } + return nil, &os.PathError{} + }} + found, err := FindConfigFile(filepath.Join("a", "b", "c", "secret.yaml")) + assert.Nil(t, err) + assert.Equal(t, expectedPath, found) + + // the search starts at the file's directory, so a config next to the cwd is not found + _, err = FindConfigFile(filepath.Join("x", "secret.yaml")) + assert.NotNil(t, err) +} + var sampleConfig = []byte(` creation_rules: - path_regex: foobar*