Skip to content

Commit

Permalink
Normalize symlinks on Windows
Browse files Browse the repository at this point in the history
  • Loading branch information
twpayne committed Aug 11, 2021
1 parent 3304f6a commit 70a85f0
Show file tree
Hide file tree
Showing 10 changed files with 203 additions and 14 deletions.
6 changes: 5 additions & 1 deletion internal/chezmoi/actualstateentry.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,11 @@ func NewActualStateEntry(system System, absPath AbsPath, info fs.FileInfo, err e
return &ActualStateSymlink{
absPath: absPath,
lazyLinkname: newLazyLinknameFunc(func() (string, error) {
return system.Readlink(absPath)
linkame, err := system.Readlink(absPath)
if err != nil {
return "", err
}
return normalizeLinkname(linkame), nil
}),
}, nil
default:
Expand Down
24 changes: 15 additions & 9 deletions internal/chezmoi/gitdiffsystem.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"io"
"io/fs"
"os/exec"
"runtime"

"github.com/go-git/go-git/v5/plumbing"
"github.com/go-git/go-git/v5/plumbing/filemode"
Expand Down Expand Up @@ -193,7 +194,12 @@ func (s *GitDiffSystem) WriteFile(filename AbsPath, data []byte, perm fs.FileMod

// WriteSymlink implements System.WriteSymlink.
func (s *GitDiffSystem) WriteSymlink(oldname string, newname AbsPath) error {
if err := s.encodeDiff(newname, append([]byte(oldname), '\n'), fs.ModeSymlink); err != nil {
toData := append([]byte(normalizeLinkname(oldname)), '\n')
toMode := fs.ModeSymlink
if runtime.GOOS == "windows" {
toMode |= 0o666
}
if err := s.encodeDiff(newname, toData, toMode); err != nil {
return err
}
return s.system.WriteSymlink(oldname, newname)
Expand All @@ -204,25 +210,25 @@ func (s *GitDiffSystem) WriteSymlink(oldname string, newname AbsPath) error {
func (s *GitDiffSystem) encodeDiff(absPath AbsPath, toData []byte, toMode fs.FileMode) error {
var fromData []byte
var fromMode fs.FileMode
switch fromInfo, err := s.system.Stat(absPath); {
case err == nil && fromInfo.Mode().IsRegular():
switch fromInfo, err := s.system.Lstat(absPath); {
case errors.Is(err, fs.ErrNotExist):
case err != nil:
return err
case fromInfo.Mode().IsRegular():
fromData, err = s.system.ReadFile(absPath)
if err != nil {
return err
}
fromMode = fromInfo.Mode()
case err == nil && fromInfo.Mode().Type() == fs.ModeSymlink:
case fromInfo.Mode().Type() == fs.ModeSymlink:
fromDataStr, err := s.system.Readlink(absPath)
if err != nil {
return err
}
fromData = []byte(fromDataStr)
fromData = append([]byte(fromDataStr), '\n')
fromMode = fromInfo.Mode()
case err == nil:
fromMode = fromInfo.Mode()
case errors.Is(err, fs.ErrNotExist):
default:
return err
fromMode = fromInfo.Mode()
}

diffPatch, err := DiffPatch(s.trimPrefix(absPath), fromData, fromMode, toData, toMode)
Expand Down
6 changes: 6 additions & 0 deletions internal/chezmoi/path_unix.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,3 +42,9 @@ func expandTilde(path string, homeDirAbsPath AbsPath) string {
return path
}
}

// normalizeLinkname returns linkname normalized. On non-Windows systems, it
// returns linkname unchanged.
func normalizeLinkname(linkname string) string {
return linkname
}
10 changes: 10 additions & 0 deletions internal/chezmoi/path_windows.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,16 @@ func expandTilde(path string, homeDirAbsPath AbsPath) string {
}
}

// normalizeLinkname returns linkname normalized. On Windows, backslashes are
// converted to forward slashes and if linkname is an absolute path then the
// volume name is converted to uppercase.
func normalizeLinkname(linkname string) string {
if filepath.IsAbs(linkname) {
return filepath.ToSlash(volumeNameToUpper(linkname))
}
return filepath.ToSlash(linkname)
}

// volumeNameLen returns length of the leading volume name on Windows. It
// returns 0 elsewhere.
func volumeNameLen(path string) int {
Expand Down
59 changes: 59 additions & 0 deletions internal/chezmoi/path_windows_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package chezmoi

import (
"testing"

"github.com/stretchr/testify/assert"
)

func TestNormalizeLinkname(t *testing.T) {
for _, tc := range []struct {
linkname string
expected string
}{
{
linkname: "rel",
expected: "rel",
},
{
linkname: "rel/forward",
expected: "rel/forward",
},
{
linkname: "rel\\backward",
expected: "rel/backward",
},
{
linkname: "rel/forward\\backward",
expected: "rel/forward/backward",
},
{
linkname: "/abs/forward",
expected: "/abs/forward",
},
{
linkname: "\\abs\\backward",
expected: "/abs/backward",
},
{
linkname: "/abs/forward\\backward",
expected: "/abs/forward/backward",
},
{
linkname: "c:/abs/forward",
expected: "C:/abs/forward",
},
{
linkname: "c:\\abs\\backward",
expected: "C:/abs/backward",
},
{
linkname: "c:/abs/forward\\backward",
expected: "C:/abs/forward/backward",
},
} {
t.Run(tc.linkname, func(t *testing.T) {
assert.Equal(t, tc.expected, normalizeLinkname(tc.linkname))
})
}
}
2 changes: 1 addition & 1 deletion internal/chezmoi/realsystem_windows.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ func (s *RealSystem) Readlink(name AbsPath) (string, error) {
if err != nil {
return "", err
}
return filepath.ToSlash(linkname), nil
return normalizeLinkname(linkname), nil
}

// WriteFile implements System.WriteFile.
Expand Down
6 changes: 4 additions & 2 deletions internal/chezmoi/sourcestate.go
Original file line number Diff line number Diff line change
Expand Up @@ -975,8 +975,9 @@ func (s *SourceState) newFileTargetStateEntryFunc(sourceRelPath SourceRelPath, f
case isEmpty(contents) && !fileAttr.Empty:
return &TargetStateRemove{}, nil
default:
linkname := normalizeLinkname(string(s.sourceDirAbsPath.Join(sourceRelPath.RelPath())))
return &TargetStateSymlink{
lazyLinkname: newLazyLinkname(string(s.sourceDirAbsPath.Join(sourceRelPath.RelPath()))),
lazyLinkname: newLazyLinkname(linkname),
}, nil
}
}
Expand Down Expand Up @@ -1107,7 +1108,8 @@ func (s *SourceState) newSymlinkTargetStateEntryFunc(sourceRelPath SourceRelPath
return "", err
}
}
return string(bytes.TrimSpace(linknameBytes)), nil
linkname := normalizeLinkname(string(bytes.TrimSpace(linknameBytes)))
return linkname, nil
}
return &TargetStateSymlink{
lazyLinkname: newLazyLinknameFunc(linknameFunc),
Expand Down
2 changes: 1 addition & 1 deletion internal/chezmoi/targetstateentry.go
Original file line number Diff line number Diff line change
Expand Up @@ -281,7 +281,7 @@ func (t *TargetStateSymlink) Apply(system System, persistentState PersistentStat
if err != nil {
return false, err
}
if actualLinkname == linkname {
if normalizeLinkname(actualLinkname) == normalizeLinkname(linkname) {
return false, nil
}
}
Expand Down
86 changes: 86 additions & 0 deletions internal/cmd/symlinks_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
package cmd

import (
"io/fs"
"testing"

"github.com/stretchr/testify/require"
"github.com/twpayne/go-vfs/v3"
"github.com/twpayne/go-vfs/v3/vfst"

"github.com/twpayne/chezmoi/v2/internal/chezmoitest"
)

func TestSymlinks(t *testing.T) {
for _, tc := range []struct {
name string
extraRoot interface{}
args []string
tests []interface{}
}{
{
name: "symlink_forward_slash_unix",
extraRoot: map[string]interface{}{
"/home/user/.local/share/chezmoi/symlink_dot_symlink": ".dir/file",
},
args: []string{"~/.symlink"},
tests: []interface{}{
vfst.TestPath("/home/user/.symlink",
vfst.TestModeType(fs.ModeSymlink),
vfst.TestSymlinkTarget(".dir/file"),
),
},
},
{
name: "symlink_forward_slash_windows",
extraRoot: map[string]interface{}{
"/home/user/.local/share/chezmoi/symlink_dot_symlink": ".dir/file",
},
args: []string{"~/.symlink"},
tests: []interface{}{
vfst.TestPath("/home/user/.symlink",
vfst.TestModeType(fs.ModeSymlink),
vfst.TestSymlinkTarget(".dir\\file"),
),
},
},
{
name: "symlink_backward_slash_windows",
extraRoot: map[string]interface{}{
"/home/user/.local/share/chezmoi/symlink_dot_symlink": ".dir\\file",
},
args: []string{"~/.symlink"},
tests: []interface{}{
vfst.TestPath("/home/user/.symlink",
vfst.TestModeType(fs.ModeSymlink),
vfst.TestSymlinkTarget(".dir\\file"),
),
},
},
{
name: "symlink_mixed_slash_windows",
extraRoot: map[string]interface{}{
"/home/user/.local/share/chezmoi/symlink_dot_symlink": ".dir/subdir\\file",
},
args: []string{"~/.symlink"},
tests: []interface{}{
vfst.TestPath("/home/user/.symlink",
vfst.TestModeType(fs.ModeSymlink),
vfst.TestSymlinkTarget(".dir\\subdir\\file"),
),
},
},
} {
t.Run(tc.name, func(t *testing.T) {
chezmoitest.SkipUnlessGOOS(t, tc.name)
chezmoitest.WithTestFS(t, nil, func(fileSystem vfs.FS) {
if tc.extraRoot != nil {
require.NoError(t, vfst.NewBuilder().Build(fileSystem, tc.extraRoot))
}
require.NoError(t, newTestConfig(t, fileSystem).execute(append([]string{"apply"}, tc.args...)))
vfst.RunTests(t, fileSystem, "", tc.tests)
require.NoError(t, newTestConfig(t, fileSystem).execute([]string{"verify"}))
})
})
}
}
16 changes: 16 additions & 0 deletions internal/cmd/testdata/scripts/symlinks_windows.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
[!windows] skip 'Windows only'

# test that chezmoi apply normalizes symlinks
chezmoi apply $HOME${/}.symlink_backward_slash $HOME${/}.symlink_forward_slash
readlink $HOME/.symlink_backward_slash $HOME\.dir\file
readlink $HOME/.symlink_forward_slash $HOME\.dir\file

# test that the persistent state matches the actual state
chezmoi verify

-- home/user/.dir/file --
# contents of .dir/file
-- home/user/.local/share/chezmoi/symlink_dot_symlink_backward_slash.tmpl --
{{ env "HOME" }}\.dir\file
-- home/user/.local/share/chezmoi/symlink_dot_symlink_forward_slash.tmpl --
{{ env "HOME" }}/.dir/file

0 comments on commit 70a85f0

Please sign in to comment.