diff --git a/cmd/add.go b/cmd/add.go index 526e338c6b5..882823ec14b 100644 --- a/cmd/add.go +++ b/cmd/add.go @@ -50,7 +50,7 @@ func (c *Config) runAddCmd(fs vfs.FS, args []string) (err error) { if err := c.ensureSourceDirectory(fs, mutator); err != nil { return err } - destDirPrefix := ts.DestDir + "/" + destDirPrefix := filepath.FromSlash(ts.DestDir + "/") var quit int // quit is an int with a unique address defer func() { if r := recover(); r != nil { diff --git a/cmd/add_test.go b/cmd/add_test.go index e934466e508..5e62d123067 100644 --- a/cmd/add_test.go +++ b/cmd/add_test.go @@ -336,10 +336,14 @@ func TestAddCommand(t *testing.T) { { name: "dest_dir_is_symlink", args: []string{"/home/user/foo"}, - root: map[string]interface{}{ - "/home/user": &vfst.Symlink{Target: "../local/home/user"}, - "/local/home/user/.chezmoi": &vfst.Dir{Perm: 0700}, - "/local/home/user/foo": "bar", + root: []interface{}{ + map[string]interface{}{ + "/local/home/user/.chezmoi": &vfst.Dir{Perm: 0700}, + "/local/home/user/foo": "bar", + }, + map[string]interface{}{ + "/home/user": &vfst.Symlink{Target: "../local/home/user"}, + }, }, tests: []vfst.Test{ vfst.TestPath("/home/user/.chezmoi", @@ -385,15 +389,19 @@ func TestAddCommand(t *testing.T) { } func TestIssue192(t *testing.T) { - root := map[string]interface{}{ - "/local/home/offbyone": &vfst.Dir{ - Perm: 0750, - Entries: map[string]interface{}{ - ".local/share/chezmoi": &vfst.Dir{Perm: 0700}, - "snoop/.list": "# contents of .list\n", + root := []interface{}{ + map[string]interface{}{ + "/local/home/offbyone": &vfst.Dir{ + Perm: 0750, + Entries: map[string]interface{}{ + ".local/share/chezmoi": &vfst.Dir{Perm: 0700}, + "snoop/.list": "# contents of .list\n", + }, }, }, - "/home/offbyone": &vfst.Symlink{Target: "/local/home/offbyone/"}, + map[string]interface{}{ + "/home/offbyone": &vfst.Symlink{Target: "/local/home/offbyone/"}, + }, } c := &Config{ SourceDir: "/home/offbyone/.local/share/chezmoi", diff --git a/cmd/apply_test.go b/cmd/apply_test.go index cc676f1bffb..2c06702b58f 100644 --- a/cmd/apply_test.go +++ b/cmd/apply_test.go @@ -4,7 +4,6 @@ import ( "io/ioutil" "os" "path/filepath" - "strings" "testing" "github.com/stretchr/testify/assert" @@ -245,85 +244,17 @@ func TestApplyScript(t *testing.T) { defer func() { require.NoError(t, os.RemoveAll(tempDir)) }() - for _, tc := range []struct { - name string - root interface{} - data map[string]interface{} - tests []vfst.Test - }{ - { - name: "simple", - root: map[string]interface{}{ - "/home/user/.local/share/chezmoi/run_true": "#!/bin/sh\necho foo >>" + filepath.Join(tempDir, "evidence") + "\n", - }, - tests: []vfst.Test{ - vfst.TestPath(filepath.Join(tempDir, "evidence"), - vfst.TestModeIsRegular, - vfst.TestContentsString("foo\nfoo\nfoo\n"), - ), - }, - }, - { - name: "simple_once", - root: map[string]interface{}{ - "/home/user/.local/share/chezmoi/run_once_true": "#!/bin/sh\necho foo >>" + filepath.Join(tempDir, "evidence") + "\n", - }, - tests: []vfst.Test{ - vfst.TestPath(filepath.Join(tempDir, "evidence"), - vfst.TestModeIsRegular, - vfst.TestContentsString("foo\n"), - ), - }, - }, - { - name: "template", - root: map[string]interface{}{ - "/home/user/.local/share/chezmoi/run_true.tmpl": "#!/bin/sh\necho {{ .Foo }} >>" + filepath.Join(tempDir, "evidence") + "\n", - }, - data: map[string]interface{}{ - "Foo": "foo", - }, - tests: []vfst.Test{ - vfst.TestPath(filepath.Join(tempDir, "evidence"), - vfst.TestModeIsRegular, - vfst.TestContentsString("foo\nfoo\nfoo\n"), - ), - }, - }, - { - name: "issue_353", - root: map[string]interface{}{ - "/home/user/.local/share/chezmoi": map[string]interface{}{ - "run_050_giraffe": "#!/usr/bin/env bash\necho giraffe >>" + filepath.Join(tempDir, "evidence") + "\n", - "run_150_elephant": "#!/usr/bin/env bash\necho elephant >>" + filepath.Join(tempDir, "evidence") + "\n", - "run_once_100_miauw.sh": "#!/usr/bin/env bash\necho miauw >>" + filepath.Join(tempDir, "evidence") + "\n", - }, - }, - tests: []vfst.Test{ - vfst.TestPath(filepath.Join(tempDir, "evidence"), - vfst.TestModeIsRegular, - vfst.TestContentsString(strings.Join([]string{ - "giraffe\n", - "miauw\n", - "elephant\n", - "giraffe\n", - "elephant\n", - "giraffe\n", - "elephant\n", - }, "")), - ), - }, - }, - } { + for _, tc := range getApplyScriptTestCases(tempDir) { t.Run(tc.name, func(t *testing.T) { fs := vfs.NewPathFS(vfs.OSFS, tempDir) + require.NoError(t, vfst.NewBuilder().Build(fs, tc.root)) + persistentState, err := chezmoi.NewBoltPersistentState(fs, "/home/user/.config/chezmoi/chezmoistate.boltdb") + require.NoError(t, err) defer func() { + require.NoError(t, persistentState.Close()) require.NoError(t, os.RemoveAll(tempDir)) require.NoError(t, os.Mkdir(tempDir, 0700)) }() - require.NoError(t, vfst.NewBuilder().Build(fs, tc.root)) - persistentState, err := chezmoi.NewBoltPersistentState(fs, "/home/user/.config/chezmoi/chezmoistate.boltdb") - require.NoError(t, err) c := &Config{ SourceDir: "/home/user/.local/share/chezmoi", DestDir: "/", @@ -352,10 +283,14 @@ func TestApplyRunOnce(t *testing.T) { }() tempFile := filepath.Join(tempDir, "foo") - fs, cleanup, err := vfst.NewTestFS(map[string]interface{}{ - filepath.Dir(statePath): &vfst.Dir{Perm: 0755}, - "/home/user/.local/share/chezmoi/run_once_foo.tmpl": "#!/bin/sh\necho bar >> {{ .TempFile }}\n", - }) + fs, cleanup, err := vfst.NewTestFS( + []interface{}{ + map[string]interface{}{ + filepath.Dir(statePath): &vfst.Dir{Perm: 0755}, + }, + getRunOnceFiles(), + }, + ) require.NoError(t, err) defer cleanup() @@ -379,6 +314,7 @@ func TestApplyRunOnce(t *testing.T) { vfst.TestModeIsRegular, ), ) + actualData, err := ioutil.ReadFile(tempFile) require.NoError(t, err) assert.Equal(t, []byte("bar\n"), actualData) diff --git a/cmd/apply_test_posix.go b/cmd/apply_test_posix.go new file mode 100644 index 00000000000..41a817fabe2 --- /dev/null +++ b/cmd/apply_test_posix.go @@ -0,0 +1,91 @@ +// +build !windows + +package cmd + +import ( + "path/filepath" + "strings" + + "github.com/twpayne/go-vfs/vfst" +) + +type scriptTestCase struct { + name string + root interface{} + data map[string]interface{} + tests []vfst.Test +} + +func getApplyScriptTestCases(tempDir string) []scriptTestCase { + return []scriptTestCase{ + { + name: "simple", + root: map[string]interface{}{ + "/home/user/.local/share/chezmoi/run_true": "#!/bin/sh\necho foo >>" + filepath.Join(tempDir, "evidence") + "\n", + }, + tests: []vfst.Test{ + vfst.TestPath(filepath.Join(tempDir, "evidence"), + vfst.TestModeIsRegular, + vfst.TestContentsString("foo\nfoo\nfoo\n"), + ), + }, + }, + { + name: "simple_once", + root: map[string]interface{}{ + "/home/user/.local/share/chezmoi/run_once_true": "#!/bin/sh\necho foo >>" + filepath.Join(tempDir, "evidence") + "\n", + }, + tests: []vfst.Test{ + vfst.TestPath(filepath.Join(tempDir, "evidence"), + vfst.TestModeIsRegular, + vfst.TestContentsString("foo\n"), + ), + }, + }, + { + name: "template", + root: map[string]interface{}{ + "/home/user/.local/share/chezmoi/run_true.tmpl": "#!/bin/sh\necho {{ .Foo }} >>" + filepath.Join(tempDir, "evidence") + "\n", + }, + data: map[string]interface{}{ + "Foo": "foo", + }, + tests: []vfst.Test{ + vfst.TestPath(filepath.Join(tempDir, "evidence"), + vfst.TestModeIsRegular, + vfst.TestContentsString("foo\nfoo\nfoo\n"), + ), + }, + }, + { + name: "issue_353", + root: map[string]interface{}{ + "/home/user/.local/share/chezmoi": map[string]interface{}{ + "run_050_giraffe": "#!/usr/bin/env bash\necho giraffe >>" + filepath.Join(tempDir, "evidence") + "\n", + "run_150_elephant": "#!/usr/bin/env bash\necho elephant >>" + filepath.Join(tempDir, "evidence") + "\n", + "run_once_100_miauw.sh": "#!/usr/bin/env bash\necho miauw >>" + filepath.Join(tempDir, "evidence") + "\n", + }, + }, + tests: []vfst.Test{ + vfst.TestPath(filepath.Join(tempDir, "evidence"), + vfst.TestModeIsRegular, + vfst.TestContentsString(strings.Join([]string{ + "giraffe\n", + "miauw\n", + "elephant\n", + "giraffe\n", + "elephant\n", + "giraffe\n", + "elephant\n", + }, "")), + ), + }, + }, + } +} + +func getRunOnceFiles() map[string]interface{} { + return map[string]interface{}{ + "/home/user/.local/share/chezmoi/run_once_foo.tmpl": "#!/bin/sh\necho bar >> {{ .TempFile }}\n", + } +} diff --git a/cmd/apply_test_windows.go b/cmd/apply_test_windows.go new file mode 100644 index 00000000000..2750ab63cce --- /dev/null +++ b/cmd/apply_test_windows.go @@ -0,0 +1,95 @@ +// +build windows + +package cmd + +import ( + "path/filepath" + "strings" + + "github.com/twpayne/go-vfs/vfst" +) + +type scriptTestCase struct { + name string + root interface{} + data map[string]interface{} + tests []vfst.Test +} + +func getApplyScriptTestCases(tempDir string) []scriptTestCase { + return []scriptTestCase{ + { + name: "simple", + root: map[string]interface{}{ + "/home/user/.local/share/chezmoi/run_true.bat": "@echo foo>>" + filepath.Join(tempDir, "evidence") + "\n", + }, + tests: []vfst.Test{ + vfst.TestPath(filepath.Join(tempDir, "evidence"), + vfst.TestModeIsRegular, + vfst.TestContentsString("foo\r\nfoo\r\nfoo\r\n"), + ), + }, + }, + { + name: "simple_once", + root: map[string]interface{}{ + "/home/user/.local/share/chezmoi/run_once_true.bat": "@echo foo>>" + filepath.Join(tempDir, "evidence") + "\n", + }, + tests: []vfst.Test{ + vfst.TestPath(filepath.Join(tempDir, "evidence"), + vfst.TestModeIsRegular, + vfst.TestContentsString("foo\r\n"), + ), + }, + }, + { + name: "template", + root: map[string]interface{}{ + "/home/user/.local/share/chezmoi/run_true.bat.tmpl": "@echo {{ .Foo }}>>" + filepath.Join(tempDir, "evidence") + "\n", + }, + data: map[string]interface{}{ + "Foo": "foo", + }, + tests: []vfst.Test{ + vfst.TestPath(filepath.Join(tempDir, "evidence"), + vfst.TestModeIsRegular, + vfst.TestContentsString("foo\r\nfoo\r\nfoo\r\n"), + ), + }, + }, + { + name: "issue_353", + root: map[string]interface{}{ + "/home/user/.local/share/chezmoi": map[string]interface{}{ + "run_050_giraffe.bat": "@echo giraffe>>" + filepath.Join(tempDir, "evidence") + "\n", + "run_150_elephant.bat": "@echo elephant>>" + filepath.Join(tempDir, "evidence") + "\n", + "run_once_100_miauw.bat": "@echo miauw>>" + filepath.Join(tempDir, "evidence") + "\n", + }, + }, + tests: []vfst.Test{ + vfst.TestPath(filepath.Join(tempDir, "evidence"), + vfst.TestModeIsRegular, + vfst.TestContentsString(strings.Join([]string{ + "giraffe\r\n", + "miauw\r\n", + "elephant\r\n", + "giraffe\r\n", + "elephant\r\n", + "giraffe\r\n", + "elephant\r\n", + }, "")), + ), + }, + }, + } +} + +func getRunOnceFiles() map[string]interface{} { + return map[string]interface{}{ + // Windows batch script does not include any way to print a string to the console with only a linefeed (0x0A) + // and no carriage return (0x0D), but it can be done with Powershell. The default action for Powershell script + // files on Windows is to open them in the default text editor rather than to execute them (for security + // reasons). The easiest solution is to make a batch file that calls Powershell. + "/home/user/.local/share/chezmoi/run_once_foo.bat.tmpl": "@powershell.exe -NoProfile -NonInteractive -c \"Write-Host -NoNewLine ('bar{0}' -f (0x0A -as [char]))\">> {{ .TempFile }}\n", + } +} diff --git a/cmd/archive_test.go b/cmd/archive_test.go index da2a1fe3682..0aac1f9e499 100644 --- a/cmd/archive_test.go +++ b/cmd/archive_test.go @@ -5,6 +5,7 @@ import ( "bytes" "io" "io/ioutil" + "path/filepath" "testing" "github.com/stretchr/testify/assert" @@ -34,7 +35,7 @@ func TestArchiveCmd(t *testing.T) { h, err = r.Next() assert.NoError(t, err) - assert.Equal(t, "dir/file", h.Name) + assert.Equal(t, filepath.Join("dir", "file"), h.Name) data, err := ioutil.ReadAll(r) assert.NoError(t, err) assert.Equal(t, []byte("contents"), data) diff --git a/cmd/cd.go b/cmd/cd.go index 9ccd865b5de..da2df5cece7 100644 --- a/cmd/cd.go +++ b/cmd/cd.go @@ -1,12 +1,6 @@ package cmd -import ( - "os" - - "github.com/spf13/cobra" - shell "github.com/twpayne/go-shell" - vfs "github.com/twpayne/go-vfs" -) +import "github.com/spf13/cobra" var cdCmd = &cobra.Command{ Use: "cd", @@ -21,19 +15,3 @@ var cdCmd = &cobra.Command{ func init() { rootCmd.AddCommand(cdCmd) } - -func (c *Config) runCDCmd(fs vfs.FS, args []string) error { - mutator := c.getDefaultMutator(fs) - if err := c.ensureSourceDirectory(fs, mutator); err != nil { - return err - } - - if err := os.Chdir(c.SourceDir); err != nil { - return err - } - shell, err := shell.CurrentUserShell() - if err != nil { - return err - } - return c.exec([]string{shell}) -} diff --git a/cmd/cd_posix.go b/cmd/cd_posix.go new file mode 100644 index 00000000000..a0adf325acc --- /dev/null +++ b/cmd/cd_posix.go @@ -0,0 +1,28 @@ +// +build !windows + +package cmd + +import ( + "os" + + shell "github.com/twpayne/go-shell" + vfs "github.com/twpayne/go-vfs" +) + +func (c *Config) runCDCmd(fs vfs.FS, args []string) error { + mutator := c.getDefaultMutator(fs) + if err := c.ensureSourceDirectory(fs, mutator); err != nil { + return err + } + + shell, err := shell.CurrentUserShell() + if err != nil { + return err + } + + if err := os.Chdir(c.SourceDir); err != nil { + return err + } + + return c.exec([]string{shell}) +} diff --git a/cmd/cd_windows.go b/cmd/cd_windows.go new file mode 100644 index 00000000000..dd355337ea3 --- /dev/null +++ b/cmd/cd_windows.go @@ -0,0 +1,22 @@ +// +build windows + +package cmd + +import ( + shell "github.com/twpayne/go-shell" + vfs "github.com/twpayne/go-vfs" +) + +func (c *Config) runCDCmd(fs vfs.FS, args []string) error { + mutator := c.getDefaultMutator(fs) + if err := c.ensureSourceDirectory(fs, mutator); err != nil { + return err + } + + shell, err := shell.CurrentUserShell() + if err != nil { + return err + } + + return c.run(c.SourceDir, shell) +} diff --git a/cmd/config.go b/cmd/config.go index 5acf79209ad..a40d8feadbb 100644 --- a/cmd/config.go +++ b/cmd/config.go @@ -12,7 +12,6 @@ import ( "path/filepath" "runtime" "strings" - "syscall" "text/template" "unicode" @@ -171,14 +170,14 @@ func (c *Config) ensureNoError(cmd *cobra.Command, args []string) error { return nil } -func (c *Config) ensureSourceDirectory(fs vfs.Stater, mutator chezmoi.Mutator) error { +func (c *Config) ensureSourceDirectory(fs chezmoi.PrivacyStater, mutator chezmoi.Mutator) error { if err := vfs.MkdirAll(mutator, filepath.Dir(c.SourceDir), 0777&^os.FileMode(c.Umask)); err != nil { return err } info, err := fs.Stat(c.SourceDir) switch { case err == nil && info.IsDir(): - if info.Mode().Perm()&^os.FileMode(c.Umask) != 0700&^os.FileMode(c.Umask) { + if !chezmoi.IsPrivate(fs, c.SourceDir, os.FileMode(c.Umask)) { if err := mutator.Chmod(c.SourceDir, 0700&^os.FileMode(c.Umask)); err != nil { return err } @@ -193,20 +192,6 @@ func (c *Config) ensureSourceDirectory(fs vfs.Stater, mutator chezmoi.Mutator) e } } -func (c *Config) exec(argv []string) error { - path, err := exec.LookPath(argv[0]) - if err != nil { - return err - } - if c.Verbose { - fmt.Printf("exec %s\n", strings.Join(argv, " ")) - } - if c.DryRun { - return nil - } - return syscall.Exec(path, argv, os.Environ()) -} - func (c *Config) execEditor(argv ...string) error { return c.exec(append([]string{c.getEditor()}, argv...)) } @@ -265,11 +250,21 @@ func (c *Config) getTargetState(fs vfs.FS, populateOptions *chezmoi.PopulateOpti for key, value := range c.Data { data[key] = value } + + destDir := c.DestDir + if destDir != "" { + destDir, err = filepath.Abs(c.DestDir) + if err != nil { + return nil, err + } + } + // For backwards compatibility, prioritize gpgRecipient over gpg.recipient. if c.GPGRecipient != "" { c.GPG.Recipient = c.GPGRecipient } - ts := chezmoi.NewTargetState(c.DestDir, os.FileMode(c.Umask), c.SourceDir, data, c.templateFuncs, &c.GPG) + + ts := chezmoi.NewTargetState(destDir, os.FileMode(c.Umask), c.SourceDir, data, c.templateFuncs, &c.GPG) if err := ts.Populate(fs, populateOptions); err != nil { return nil, err } @@ -299,7 +294,8 @@ func (c *Config) prompt(s, choices string) (byte, error) { if err != nil { return 0, err } - if len(line) == 2 && strings.IndexByte(choices, line[0]) != -1 { + line = strings.TrimRight(line, "\r\n") + if len(line) == 1 && strings.IndexByte(choices, line[0]) != -1 { return line[0], nil } } @@ -369,8 +365,9 @@ func getDefaultData(fs vfs.FS) (map[string]interface{}, error) { group, err := user.LookupGroupId(currentUser.Gid) if err == nil { data["group"] = group.Name - } else if cgoEnabled { - // Only return an error if CGO is enabled. + } else if cgoEnabled && runtime.GOOS != "windows" { + // Only return an error if CGO is enabled and the platform is + // non-Windows (groups don't really mean much on Windows). return nil, err } diff --git a/cmd/config_posix.go b/cmd/config_posix.go new file mode 100644 index 00000000000..9f8bfeb6008 --- /dev/null +++ b/cmd/config_posix.go @@ -0,0 +1,26 @@ +// +build !windows + +package cmd + +import ( + "fmt" + "os" + "os/exec" + "strings" + "syscall" +) + +func (c *Config) exec(argv []string) error { + path, err := exec.LookPath(argv[0]) + if err != nil { + return err + } + if c.Verbose { + fmt.Printf("exec %s\n", strings.Join(argv, " ")) + } + if c.DryRun { + return nil + } + + return syscall.Exec(path, argv, os.Environ()) +} diff --git a/cmd/config_windows.go b/cmd/config_windows.go new file mode 100644 index 00000000000..bce336fc495 --- /dev/null +++ b/cmd/config_windows.go @@ -0,0 +1,8 @@ +// +build windows + +package cmd + +// on windows, implement exec in terms of run since legit exec doesn't really exist +func (c *Config) exec(argv []string) error { + return c.run("", argv[0], argv[1:]...) +} diff --git a/cmd/dump_test.go b/cmd/dump_test.go index 5f097068fa8..6e1639df48f 100644 --- a/cmd/dump_test.go +++ b/cmd/dump_test.go @@ -4,6 +4,7 @@ import ( "bytes" "encoding/json" "fmt" + "path/filepath" "testing" "github.com/stretchr/testify/assert" @@ -35,15 +36,15 @@ func TestDumpCmd(t *testing.T) { expected := []interface{}{ map[string]interface{}{ "type": "dir", - "sourcePath": "/home/user/.local/share/chezmoi/dir", + "sourcePath": filepath.Join("/home", "user", ".local", "share", "chezmoi", "dir"), "targetPath": "dir", "exact": false, "perm": float64(0755), "entries": []interface{}{ map[string]interface{}{ "type": "file", - "sourcePath": "/home/user/.local/share/chezmoi/dir/file", - "targetPath": "dir/file", + "sourcePath": filepath.Join("/home", "user", ".local", "share", "chezmoi", "dir", "file"), + "targetPath": filepath.Join("dir", "file"), "empty": false, "encrypted": false, "perm": float64(0644), @@ -54,7 +55,7 @@ func TestDumpCmd(t *testing.T) { }, map[string]interface{}{ "type": "symlink", - "sourcePath": "/home/user/.local/share/chezmoi/symlink_symlink", + "sourcePath": filepath.Join("/home", "user", ".local", "share", "chezmoi", "symlink_symlink"), "targetPath": "symlink", "template": false, "linkname": "target", diff --git a/cmd/import.go b/cmd/import.go index f3f69cd2eb9..0d1698783e9 100644 --- a/cmd/import.go +++ b/cmd/import.go @@ -82,5 +82,5 @@ func (c *Config) runImportCmd(fs vfs.FS, args []string) error { return err } } - return ts.ImportTAR(tar.NewReader(r), c._import.importTAROptions, mutator) + return ts.ImportTAR(tar.NewReader(r), c._import.importTAROptions, mutator, fs) } diff --git a/cmd/noupgrade.go b/cmd/noupgrade.go index 9ed90b7ce03..83b2b88ce8a 100644 --- a/cmd/noupgrade.go +++ b/cmd/noupgrade.go @@ -1,5 +1,4 @@ -// +build noupgrade -// +build windows +// +build noupgrade windows package cmd diff --git a/cmd/root.go b/cmd/root.go index f67b289591b..c8550db74b1 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -142,8 +142,8 @@ func (c *Config) persistentPreRunRootE(fs vfs.FS, args []string) error { switch { case err == nil && !info.IsDir(): return fmt.Errorf("%s: not a directory", c.SourceDir) - case err == nil && info.Mode().Perm() != 0700: - fmt.Printf("%s: want permissions 0700, got 0%o\n", c.SourceDir, info.Mode().Perm()) + case err == nil && !chezmoi.IsPrivate(fs, c.SourceDir, os.FileMode(c.Umask)): + fmt.Fprintf(os.Stderr, "%s: not private, but should be\n", c.SourceDir) case err != nil && !os.IsNotExist(err): return err } diff --git a/cmd/secretgeneric_test.go b/cmd/secretgeneric_test.go index d0983fa41bf..19865741697 100644 --- a/cmd/secretgeneric_test.go +++ b/cmd/secretgeneric_test.go @@ -9,12 +9,9 @@ import ( func TestSecretFunc(t *testing.T) { t.Parallel() - c := &Config{ - GenericSecret: genericSecretCmdConfig{ - Command: "date", - }, - } - args := []string{"+%Y-%M-%DT%H:%M:%SZ"} + + c, args := getSecretTestConfig() + var value interface{} assert.NotPanics(t, func() { value = c.secretFunc(args...) @@ -25,12 +22,9 @@ func TestSecretFunc(t *testing.T) { func TestSecretJSONFunc(t *testing.T) { t.Parallel() - c := &Config{ - GenericSecret: genericSecretCmdConfig{ - Command: "date", - }, - } - args := []string{`+{"date":"%Y-%M-%DT%H:%M:%SZ"}`} + + c, args := getSecretJSONTestConfig() + var value interface{} assert.NotPanics(t, func() { value = c.secretJSONFunc(args...) diff --git a/cmd/secretgeneric_test_posix.go b/cmd/secretgeneric_test_posix.go new file mode 100644 index 00000000000..f582ae92b80 --- /dev/null +++ b/cmd/secretgeneric_test_posix.go @@ -0,0 +1,21 @@ +// +build !windows + +package cmd + +func getSecretTestConfig() (*Config, []string) { + return &Config{ + GenericSecret: genericSecretCmdConfig{ + Command: "date", + }, + }, + []string{"+%Y-%M-%DT%H:%M:%SZ"} +} + +func getSecretJSONTestConfig() (*Config, []string) { + return &Config{ + GenericSecret: genericSecretCmdConfig{ + Command: "date", + }, + }, + []string{`+{"date":"%Y-%M-%DT%H:%M:%SZ"}`} +} diff --git a/cmd/secretgeneric_test_windows.go b/cmd/secretgeneric_test_windows.go new file mode 100644 index 00000000000..c589f1b964c --- /dev/null +++ b/cmd/secretgeneric_test_windows.go @@ -0,0 +1,23 @@ +// +build windows + +package cmd + +func getSecretTestConfig() (*Config, []string) { + // Windows doesn't (usually) have "date", but powershell is included with + // all versions of Windows v7 or newer. + return &Config{ + GenericSecret: genericSecretCmdConfig{ + Command: "powershell.exe", + }, + }, + []string{"-NoProfile", "-NonInteractive", "-Command", "Get-Date"} +} + +func getSecretJSONTestConfig() (*Config, []string) { + return &Config{ + GenericSecret: genericSecretCmdConfig{ + Command: "powershell.exe", + }, + }, + []string{"-NoProfile", "-NonInteractive", "-Command", "Get-Date | ConvertTo-Json"} +} diff --git a/cmd/upgrade.go b/cmd/upgrade.go index 596980d3edf..2fe0300617a 100644 --- a/cmd/upgrade.go +++ b/cmd/upgrade.go @@ -397,6 +397,7 @@ func getMethod(fs vfs.Stater, executableFilename string) (string, error) { if err != nil { return "", err } + executableStat := info.Sys().(*syscall.Stat_t) uid := os.Getuid() switch runtime.GOOS { diff --git a/lib/chezmoi/fsmutator.go b/lib/chezmoi/fsmutator.go index 1f833a5fed2..a0300aecc2a 100644 --- a/lib/chezmoi/fsmutator.go +++ b/lib/chezmoi/fsmutator.go @@ -1,10 +1,7 @@ package chezmoi import ( - "errors" "os" - "path/filepath" - "syscall" "github.com/google/renameio" vfs "github.com/twpayne/go-vfs" @@ -26,47 +23,6 @@ func NewFSMutator(fs vfs.FS) *FSMutator { } } -// WriteFile implements Mutator.WriteFile. -func (a *FSMutator) WriteFile(name string, data []byte, perm os.FileMode, currData []byte) error { - // Special case: if writing to the real filesystem, use github.com/google/renameio - if a.FS == vfs.OSFS { - dir := filepath.Dir(name) - dev, ok := a.devCache[dir] - if !ok { - info, err := a.Stat(dir) - if err != nil { - return err - } - statT, ok := info.Sys().(*syscall.Stat_t) - if !ok { - return errors.New("os.FileInfo.Sys() cannot be converted to a *syscall.Stat_t") - } - dev = uint(statT.Dev) - a.devCache[dir] = dev - } - tempDir, ok := a.tempDirCache[dev] - if !ok { - tempDir = renameio.TempDir(dir) - a.tempDirCache[dev] = tempDir - } - t, err := renameio.TempFile(tempDir, name) - if err != nil { - return err - } - defer func() { - _ = t.Cleanup() - }() - if err := t.Chmod(perm); err != nil { - return err - } - if _, err := t.Write(data); err != nil { - return err - } - return t.CloseAtomicallyReplace() - } - return a.FS.WriteFile(name, data, perm) -} - // WriteSymlink implements Mutator.WriteSymlink. func (a *FSMutator) WriteSymlink(oldname, newname string) error { // Special case: if writing to the real filesystem, use github.com/google/renameio diff --git a/lib/chezmoi/fsmutator_posix.go b/lib/chezmoi/fsmutator_posix.go new file mode 100644 index 00000000000..21403574b79 --- /dev/null +++ b/lib/chezmoi/fsmutator_posix.go @@ -0,0 +1,54 @@ +// +build !windows + +package chezmoi + +import ( + "errors" + "os" + "path/filepath" + "syscall" + + "github.com/google/renameio" + vfs "github.com/twpayne/go-vfs" +) + +// WriteFile implements Mutator.WriteFile. +func (a *FSMutator) WriteFile(name string, data []byte, perm os.FileMode, currData []byte) error { + // Special case: if writing to the real filesystem, use github.com/google/renameio + if a.FS == vfs.OSFS { + dir := filepath.Dir(name) + dev, ok := a.devCache[dir] + if !ok { + info, err := a.Stat(dir) + if err != nil { + return err + } + statT, ok := info.Sys().(*syscall.Stat_t) + if !ok { + return errors.New("os.FileInfo.Sys() cannot be converted to a *syscall.Stat_t") + } + dev = uint(statT.Dev) + a.devCache[dir] = dev + } + tempDir, ok := a.tempDirCache[dev] + if !ok { + tempDir = renameio.TempDir(dir) + a.tempDirCache[dev] = tempDir + } + t, err := renameio.TempFile(tempDir, name) + if err != nil { + return err + } + defer func() { + _ = t.Cleanup() + }() + if err := t.Chmod(perm); err != nil { + return err + } + if _, err := t.Write(data); err != nil { + return err + } + return t.CloseAtomicallyReplace() + } + return a.FS.WriteFile(name, data, perm) +} diff --git a/lib/chezmoi/fsmutator_windows.go b/lib/chezmoi/fsmutator_windows.go new file mode 100644 index 00000000000..a47fed3b1c9 --- /dev/null +++ b/lib/chezmoi/fsmutator_windows.go @@ -0,0 +1,80 @@ +// +build windows + +package chezmoi + +import ( + "os" + "path/filepath" + "syscall" + + "github.com/google/renameio" + "golang.org/x/sys/windows" + + vfs "github.com/twpayne/go-vfs" +) + +// WriteFile implements Mutator.WriteFile. +func (a *FSMutator) WriteFile(name string, data []byte, perm os.FileMode, currData []byte) error { + // Special case: if writing to the real filesystem, use github.com/google/renameio + if a.FS == vfs.OSFS { + dir := filepath.Dir(name) + dev, ok := a.devCache[dir] + if !ok { + volumeID, err := getVolumeSerialNumber(name) + if err != nil { + return err + } + + dev = volumeID + a.devCache[dir] = dev + } + tempDir, ok := a.tempDirCache[dev] + if !ok { + tempDir = renameio.TempDir(dir) + a.tempDirCache[dev] = tempDir + } + t, err := renameio.TempFile(tempDir, name) + if err != nil { + return err + } + defer func() { + _ = t.Cleanup() + }() + if err := a.Chmod(t.Name(), perm); err != nil { + return err + } + if _, err := t.Write(data); err != nil { + return err + } + return t.CloseAtomicallyReplace() + } + return a.FS.WriteFile(name, data, perm) +} + +func getVolumeSerialNumber(Path string) (uint, error) { + fp, err := filepath.Abs(Path) + if err != nil { + return 0, err + } + + // Input rootpath + rootPathName := filepath.VolumeName(fp) + "\\" + + // Output volume info + var serialNumber uint32 + + err = windows.GetVolumeInformation( + syscall.StringToUTF16Ptr(rootPathName), + nil, 0, + &serialNumber, + nil, // maximum component length + nil, // filesystem flags + nil, 0, // filesystem name buffer + ) + + if err != windows.Errno(0) { + return 0, err + } + + return uint(serialNumber), nil +} diff --git a/lib/chezmoi/private.go b/lib/chezmoi/private.go new file mode 100644 index 00000000000..c8c092d11a8 --- /dev/null +++ b/lib/chezmoi/private.go @@ -0,0 +1,8 @@ +package chezmoi + +import "os" + +type PrivacyStater interface { + RawPath(name string) (string, error) + Stat(name string) (os.FileInfo, error) +} diff --git a/lib/chezmoi/private_posix.go b/lib/chezmoi/private_posix.go new file mode 100644 index 00000000000..10dd7347dc0 --- /dev/null +++ b/lib/chezmoi/private_posix.go @@ -0,0 +1,16 @@ +// +build !windows + +package chezmoi + +import "os" + +// This implementation doesn't use the extra features of PrivacyStater, but the Windows implementation needs them. +// nolint:interfacer +func IsPrivate(fs PrivacyStater, file string, umask os.FileMode) bool { + info, err := fs.Stat(file) + if err != nil { + return false + } + + return info.Mode().Perm()&^umask == 0700&^umask +} diff --git a/lib/chezmoi/private_windows.go b/lib/chezmoi/private_windows.go new file mode 100644 index 00000000000..7320c0b6259 --- /dev/null +++ b/lib/chezmoi/private_windows.go @@ -0,0 +1,62 @@ +// +build windows + +package chezmoi + +import ( + "os" + "path/filepath" + + "github.com/hectane/go-acl" +) + +// Use the same default value as Linux (as of kernel 4.19) +const MaxSymlinks = 40 + +func resolveSymlink(file string) (string, error) { + // if file is a symlink, get the path it links to. this emulates + // unix-style behavior, where symlinks can't have their own independent + // permissions. + + resolved := file + for i := 0; i < MaxSymlinks; i++ { + fi, err := os.Lstat(resolved) + if err != nil { + return "", err + } + + if fi.Mode()&os.ModeSymlink == 0 { + // not a link, all done + break + } + + next, err := os.Readlink(resolved) + if err != nil { + return "", err + } + + if next != "" && !filepath.IsAbs(next) { + resolved = filepath.Join(filepath.Dir(resolved), next) + } + } + + return resolved, nil +} + +func IsPrivate(fs PrivacyStater, file string, umask os.FileMode) bool { + file, err := fs.RawPath(file) + if err != nil { + return false + } + + file, err = resolveSymlink(file) + if err != nil { + return false + } + + mode, err := acl.GetEffectiveAccessMode(file) + if err != nil { + return false + } + + return (uint32(mode) & 0007) == 0 +} diff --git a/lib/chezmoi/script.go b/lib/chezmoi/script.go index d95d0ced327..76d703ee998 100644 --- a/lib/chezmoi/script.go +++ b/lib/chezmoi/script.go @@ -120,11 +120,13 @@ func (s *Script) Apply(fs vfs.FS, mutator Mutator, applyOptions *ApplyOptions) e return nil } - // Write the temporary script file. - f, err := ioutil.TempFile("", filepath.Base(s.targetName)) + // Write the temporary script file. Put the randomness on the front of the filename to preserve any file extension + // for Windows scripts. + f, err := ioutil.TempFile("", "*."+filepath.Base(s.targetName)) if err != nil { return err } + defer func() { _ = os.RemoveAll(f.Name()) }() diff --git a/lib/chezmoi/targetstate.go b/lib/chezmoi/targetstate.go index d2e0e94193a..02570edcc64 100644 --- a/lib/chezmoi/targetstate.go +++ b/lib/chezmoi/targetstate.go @@ -8,13 +8,10 @@ import ( "io" "io/ioutil" "os" - "os/user" "path/filepath" "sort" - "strconv" "strings" "text/template" - "time" "github.com/coreos/go-semver/semver" vfs "github.com/twpayne/go-vfs" @@ -158,7 +155,7 @@ func (ts *TargetState) Add(fs vfs.FS, addOptions AddOptions, targetPath string, return err } } - return ts.addFile(targetName, entries, parentDirSourceName, info, addOptions.Encrypt, addOptions.Template, contents, mutator) + return ts.addFile(targetName, entries, parentDirSourceName, info, addOptions.Encrypt, addOptions.Template, contents, mutator, fs) case info.Mode()&os.ModeType == os.ModeSymlink: linkname, err := fs.Readlink(targetPath) if err != nil { @@ -185,7 +182,7 @@ func (ts *TargetState) Apply(fs vfs.FS, mutator Mutator, applyOptions *ApplyOpti return err } for _, match := range matches { - relPath := strings.TrimPrefix(match, ts.DestDir+"/") + relPath := strings.TrimPrefix(match, ts.DestDir+string(filepath.Separator)) // Don't remove targets that are ignored. if ts.TargetIgnore.Match(relPath) { continue @@ -225,34 +222,13 @@ func (ts *TargetState) Apply(fs vfs.FS, mutator Mutator, applyOptions *ApplyOpti // Archive writes ts to w. func (ts *TargetState) Archive(w *tar.Writer, umask os.FileMode) error { - currentUser, err := user.Current() + headerTemplate, err := ts.getTarHeaderTemplate() if err != nil { return err } - uid, err := strconv.Atoi(currentUser.Uid) - if err != nil { - return err - } - gid, err := strconv.Atoi(currentUser.Gid) - if err != nil { - return err - } - group, err := user.LookupGroupId(currentUser.Gid) - if err != nil { - return err - } - now := time.Now() - headerTemplate := tar.Header{ - Uid: uid, - Gid: gid, - Uname: currentUser.Username, - Gname: group.Name, - ModTime: now, - AccessTime: now, - ChangeTime: now, - } + for _, entryName := range sortedEntryNames(ts.Entries) { - if err := ts.Entries[entryName].archive(w, ts.TargetIgnore.Match, &headerTemplate, umask); err != nil { + if err := ts.Entries[entryName].archive(w, ts.TargetIgnore.Match, headerTemplate, umask); err != nil { return err } } @@ -301,7 +277,7 @@ func (ts *TargetState) Get(fs vfs.Stater, target string) (Entry, error) { } // ImportTAR imports a tar archive. -func (ts *TargetState) ImportTAR(r *tar.Reader, importTAROptions ImportTAROptions, mutator Mutator) error { +func (ts *TargetState) ImportTAR(r *tar.Reader, importTAROptions ImportTAROptions, mutator Mutator, fs PrivacyStater) error { for { header, err := r.Next() if err == io.EOF { @@ -311,7 +287,7 @@ func (ts *TargetState) ImportTAR(r *tar.Reader, importTAROptions ImportTAROption } switch header.Typeflag { case tar.TypeDir, tar.TypeReg, tar.TypeSymlink: - if err := ts.importHeader(r, importTAROptions, header, mutator); err != nil { + if err := ts.importHeader(r, importTAROptions, header, mutator, fs); err != nil { return err } case tar.TypeXGlobalHeader: @@ -495,7 +471,7 @@ func (ts *TargetState) addDir(targetName string, entries map[string]Entry, paren return nil } -func (ts *TargetState) addFile(targetName string, entries map[string]Entry, parentDirSourceName string, info os.FileInfo, encrypted, template bool, contents []byte, mutator Mutator) error { +func (ts *TargetState) addFile(targetName string, entries map[string]Entry, parentDirSourceName string, info os.FileInfo, encrypted, template bool, contents []byte, mutator Mutator, fs PrivacyStater) error { name := filepath.Base(targetName) var existingFile *File var existingContents []byte @@ -510,7 +486,18 @@ func (ts *TargetState) addFile(targetName string, entries map[string]Entry, pare return err } } + perm := info.Mode().Perm() + destFile := filepath.Join(ts.DestDir, name) + if IsPrivate(fs, destFile, ts.Umask) { + // since Windows doesn't really have the concept of "groups", the + // group permission bits might be set even on a file that should + // be considered private. This will clear them. Posix-style platforms + // remain unaffected because IsPrivate will only return true if those + // bits weren't set in the first place + perm &^= 0077 + } + empty := info.Size() == 0 sourceName := FileAttributes{ Name: name, @@ -698,7 +685,7 @@ func (ts *TargetState) findEntry(name string) (Entry, error) { return entries[names[len(names)-1]], nil } -func (ts *TargetState) importHeader(r io.Reader, importTAROptions ImportTAROptions, header *tar.Header, mutator Mutator) error { +func (ts *TargetState) importHeader(r io.Reader, importTAROptions ImportTAROptions, header *tar.Header, mutator Mutator, fs PrivacyStater) error { targetPath := header.Name if importTAROptions.StripComponents > 0 { targetPath = filepath.Join(strings.Split(targetPath, string(os.PathSeparator))[importTAROptions.StripComponents:]...) @@ -737,7 +724,7 @@ func (ts *TargetState) importHeader(r io.Reader, importTAROptions ImportTAROptio if err != nil { return err } - return ts.addFile(targetName, entries, parentDirSourceName, info, false, false, contents, mutator) + return ts.addFile(targetName, entries, parentDirSourceName, info, false, false, contents, mutator, fs) case tar.TypeSymlink: linkname := header.Linkname return ts.addSymlink(targetName, entries, parentDirSourceName, linkname, mutator) diff --git a/lib/chezmoi/targetstate_posix.go b/lib/chezmoi/targetstate_posix.go new file mode 100644 index 00000000000..ad21bdfa463 --- /dev/null +++ b/lib/chezmoi/targetstate_posix.go @@ -0,0 +1,42 @@ +// +build !windows + +package chezmoi + +import ( + "archive/tar" + "os/user" + "strconv" + "time" +) + +func (ts *TargetState) getTarHeaderTemplate() (*tar.Header, error) { + currentUser, err := user.Current() + if err != nil { + return nil, err + } + + now := time.Now() + + uid, err := strconv.Atoi(currentUser.Uid) + if err != nil { + return nil, err + } + gid, err := strconv.Atoi(currentUser.Gid) + if err != nil { + return nil, err + } + group, err := user.LookupGroupId(currentUser.Gid) + if err != nil { + return nil, err + } + + return &tar.Header{ + Uid: uid, + Gid: gid, + Uname: currentUser.Username, + Gname: group.Name, + ModTime: now, + AccessTime: now, + ChangeTime: now, + }, nil +} diff --git a/lib/chezmoi/targetstate_test.go b/lib/chezmoi/targetstate_test.go index f16e94b2362..6d76d6f3c21 100644 --- a/lib/chezmoi/targetstate_test.go +++ b/lib/chezmoi/targetstate_test.go @@ -2,6 +2,7 @@ package chezmoi import ( "os" + "path/filepath" "testing" "text/template" @@ -207,8 +208,8 @@ func TestTargetStatePopulate(t *testing.T) { Perm: 0777, Entries: map[string]Entry{ "bar": &File{ - sourceName: "foo/bar", - targetName: "foo/bar", + sourceName: filepath.Join("foo", "bar"), + targetName: filepath.Join("foo", "bar"), Perm: 0666, contents: []byte("baz"), }, @@ -237,8 +238,8 @@ func TestTargetStatePopulate(t *testing.T) { Perm: 0700, Entries: map[string]Entry{ "bar": &File{ - sourceName: "private_dot_foo/bar", - targetName: ".foo/bar", + sourceName: filepath.Join("private_dot_foo", "bar"), + targetName: filepath.Join(".foo", "bar"), Perm: 0666, contents: []byte("baz"), }, @@ -296,8 +297,8 @@ func TestTargetStatePopulate(t *testing.T) { Perm: 0777, Entries: map[string]Entry{ "foo": &File{ - sourceName: "exact_dir/foo", - targetName: "dir/foo", + sourceName: filepath.Join("exact_dir", "foo"), + targetName: filepath.Join("dir", "foo"), Perm: 0666, contents: []byte("bar"), }, @@ -436,10 +437,10 @@ func TestTargetStatePopulate(t *testing.T) { DestDir: "/", TargetIgnore: &PatternSet{ includes: map[string]struct{}{ - "dir/foo": {}, + filepath.Join("dir", "foo"): {}, }, excludes: map[string]struct{}{ - "dir/bar": {}, + filepath.Join("dir", "bar"): {}, }, }, TargetRemove: NewPatternSet(), diff --git a/lib/chezmoi/targetstate_windows.go b/lib/chezmoi/targetstate_windows.go new file mode 100644 index 00000000000..e34e7a8bba7 --- /dev/null +++ b/lib/chezmoi/targetstate_windows.go @@ -0,0 +1,25 @@ +// +build windows + +package chezmoi + +import ( + "archive/tar" + "os/user" + "time" +) + +func (ts *TargetState) getTarHeaderTemplate() (*tar.Header, error) { + currentUser, err := user.Current() + if err != nil { + return nil, err + } + + now := time.Now() + + return &tar.Header{ + Uname: currentUser.Username, + ModTime: now, + AccessTime: now, + ChangeTime: now, + }, nil +}