diff --git a/builtin/mv.c b/builtin/mv.c index a82fc97a19..373d4aeba3 100644 --- a/builtin/mv.c +++ b/builtin/mv.c @@ -22,6 +22,7 @@ #include "string-list.h" #include "parse-options.h" #include "read-cache-ll.h" +#include "symlinks.h" #include "setup.h" #include "strvec.h" @@ -48,6 +49,12 @@ enum update_mode { MOVE_VIA_PARENT_DIR = (1 << 5), }; +static int needs_worktree_rename(enum update_mode mode, enum update_mode dst_mode) +{ + return !(mode & (INDEX | SPARSE | SKIP_WORKTREE_DIR)) && + !(dst_mode & (SKIP_WORKTREE_DIR | SPARSE)); +} + #define DUP_BASENAME 1 #define KEEP_TRAILING_SLASH 2 @@ -443,6 +450,41 @@ dir_check: bad = _("destination directory does not exist"); goto act_on_entry; } + if (has_symlink_leading_path(dst, strlen(dst))) { + bad = _("destination is beyond a symbolic link"); + goto act_on_entry; + } + + /* + * If we are going to move SRC to DST on disk, DST's leading + * directories must already exist. + */ + if (needs_worktree_rename(modes[i], dst_mode)) { + const char *slash_ = strrchr(dst, '/'); + + if (slash_) { + struct stat dir_st; + char *dst_dir = xstrdup(dst); + char *slash = &dst_dir[slash_ - dst]; + + *slash = '\0'; + if (lstat(dst_dir, &dir_st) < 0) { + /* + * other errors fall through to rename(), + * which reports them + */ + if (errno == ENOENT || errno == ENOTDIR) + bad = _("destination directory does not exist"); + } else if (!S_ISDIR(dir_st.st_mode)) { + bad = _("destination is not a directory"); + } + + free(dst_dir); + } + + if (bad) + goto act_on_entry; + } if (ignore_sparse && (dst_mode & (SKIP_WORKTREE_DIR | SPARSE)) && @@ -544,12 +586,11 @@ remove_entry: printf(_("Renaming %s to %s\n"), src, dst); if (show_only) continue; - if (!(mode & (INDEX | SPARSE | SKIP_WORKTREE_DIR)) && - !(dst_mode & (SKIP_WORKTREE_DIR | SPARSE)) && + if (needs_worktree_rename(mode, dst_mode) && rename(src, dst) < 0) { if (ignore_errors) continue; - die_errno(_("renaming '%s' failed"), src); + die_errno(_("renaming '%s' to '%s' failed"), src, dst); } if (submodule_gitfiles[i]) { if (!update_path_in_gitmodules(src, dst)) diff --git a/t/t7001-mv.sh b/t/t7001-mv.sh index 7cf4aa5ba1..719562a118 100755 --- a/t/t7001-mv.sh +++ b/t/t7001-mv.sh @@ -114,6 +114,108 @@ test_expect_success 'clean up' ' git reset --hard ' +test_expect_success 'moving file to directory without trailing slash' ' + git reset --hard HEAD && + rm -rf file.txt target && mkdir target && + echo content > file.txt && + git add file.txt && + git mv file.txt target && + test_path_is_file target/file.txt +' + +test_expect_success 'moving file to a bare filename in the cwd' ' + git reset --hard && + rm -rf from dest.txt && + mkdir from && + echo content >from/file && + git add from/file && + git mv from/file dest.txt && + test_path_is_file dest.txt +' + +test_expect_success 'moving to a non-existent directory' ' + git reset --hard && + rm -rf from && mkdir from && + echo content >from/file && + git add from/file && + test_must_fail git mv from/file no-such-dir/file 2>actual && + test_grep "destination directory does not exist" actual +' + +test_expect_success 'moving to a destination with a file as a leading path component' ' + git reset --hard && + rm -rf from && mkdir from && + echo contents >from/file && + echo blocker >not-dir && + git add from/file && + test_must_fail git mv from/file not-dir/file 2>actual && + test_grep "destination is not a directory" actual +' + +test_expect_success SYMLINKS 'moving to a destination beyond a symlink' ' + git reset --hard && + rm -rf from regular-dir link-to-dir && + mkdir from regular-dir && + echo contents >from/file && + ln -s regular-dir link-to-dir && + git add from/file && + test_must_fail git mv from/file link-to-dir/file 2>actual && + test_grep "destination is beyond a symbolic link" actual +' + +test_expect_success SYMLINKS 'moving to a destination with a symlink as an intermediate component' ' + git reset --hard && + rm -rf from && mkdir -p from/real/inner && + echo contents >from/file && + ln -s real from/link && + git add from/file from/link && + test_must_fail git mv from/file from/link/inner/dst 2>actual && + test_grep "destination is beyond a symbolic link" actual +' + +test_expect_success SYMLINKS 'refuses to overwrite a symlink at the destination' ' + git reset --hard && + rm -rf from && mkdir from && + echo contents >from/file && + ln -s target from/link && + git add from/file from/link && + test_must_fail git mv from/file from/link 2>actual && + test_grep "destination exists" actual +' + +test_expect_success SYMLINKS 'mv through a symlinked leading path does not touch the index' ' + git reset --hard && + rm -rf from && mkdir from && + echo contents >from/src && + ln -s . from/link && + git add from/src from/link && + git commit -m "setup symlink case" && + git ls-files --stage >expect.index && + test_must_fail git mv from/src from/link/real/dst 2>actual && + test_grep "destination is beyond a symbolic link" actual && + git ls-files --stage >actual.index && + test_cmp expect.index actual.index +' + +test_expect_success SYMLINKS 'mv -f does not follow a symlinked leading path' ' + git reset --hard && + rm -rf from && mkdir from && + echo contents >from/src && + ln -s file from/link && + git add from/src from/link && + test_must_fail git mv -f from/src from/link/dst 2>actual && + test_grep "destination is beyond a symbolic link" actual +' + +test_expect_success 'mv --dry-run detects non-existent destination parent directory' ' + git reset --hard && + rm -rf from && mkdir from && + echo contents >from/file && + git add from/file && + test_must_fail git mv -n from/file no-such-dir/file 2>actual && + test_grep "destination directory does not exist" actual +' + test_expect_success 'moving to existing untracked target with trailing slash' ' mkdir path1 && git mv path0/ path1/ &&