mv: reject a destination whose leading path is missing or a symlink

When moving a file, if any leading directory in the destination path is missing or is not a real directory, the problem is detected only later when rename() is called. Furthermore, if a leading directory component is a symbolic link, the issue is not detected at all. Three cases reach rename(2) unchecked today: - A leading directory is missing: rename(2) fails with ENOENT, reported against the source (misleading), and "git mv -n" does not detect it since the dry run never reaches the syscall. - A leading component is a non-directory ("git mv x a/b" with 'a' a file): rename(2) fails with ENOTDIR, again only at the syscall. - A leading component is a symbolic link: "git mv" follows it. Since Git tracks symlinks, the destination is really occupied by a tracked object, and following it is wrong regardless of the link target. The move is done on disk at the resolved location while the index records the literal path, leaving the index describing a worktree that does not exist. A later "git add" can reconcile it, but "git mv" alone has already corrupted the state. Detect all three in the checking phase. Reject a destination that goes through a symlink with has_symlink_leading_path(), which uses lstat() and never follows the link, so the refusal is independent of the target. Then lstat() the leading directory: report "destination directory does not exist" for ENOENT/ENOTDIR and "destination is not a directory" for a non-directory. Other errors fall through to rename(). Guard the directory check with the same condition under which rename(2) runs, so directory moves and sparse/out-of-cone destinations are not flagged incorrectly. This changes behavior: a move through a tracked symlink that previously "succeeded" while corrupting the index is now refused. The other two cases only change when the failure is diagnosed. Signed-off-by: Lucas Zamboni Orioli <lucaszam0@gmail.com> Signed-off-by: Junio C Hamano <gitster@pobox.com>

Lucas Zamboni Orioli committed Jul 30, 2026 at 11:28 UTC 062d90b2a8b39142a57105706e3fb4c455c9b5a0
2 files changed +145 -2
builtin/mv.c
+43 -2
@@ -22,6 +22,7 @@
22 #include "string-list.h"
23 #include "parse-options.h"
24 #include "read-cache-ll.h"
25 +#include "symlinks.h"
26
27 #include "setup.h"
28 #include "strvec.h"
@@ -48,6 +49,12 @@ enum update_mode {
49 MOVE_VIA_PARENT_DIR = (1 << 5),
50 };
51
52 +static int needs_worktree_rename(enum update_mode mode, enum update_mode dst_mode)
53 +{
54 + return !(mode & (INDEX | SPARSE | SKIP_WORKTREE_DIR)) &&
55 + !(dst_mode & (SKIP_WORKTREE_DIR | SPARSE));
56 +}
57 +
58 #define DUP_BASENAME 1
59 #define KEEP_TRAILING_SLASH 2
60
@@ -443,6 +450,41 @@ dir_check:
450 bad = _("destination directory does not exist");
451 goto act_on_entry;
452 }
453 + if (has_symlink_leading_path(dst, strlen(dst))) {
454 + bad = _("destination is beyond a symbolic link");
455 + goto act_on_entry;
456 + }
457 +
458 + /*
459 + * If we are going to move SRC to DST on disk, DST's leading
460 + * directories must already exist.
461 + */
462 + if (needs_worktree_rename(modes[i], dst_mode)) {
463 + const char *slash_ = strrchr(dst, '/');
464 +
465 + if (slash_) {
466 + struct stat dir_st;
467 + char *dst_dir = xstrdup(dst);
468 + char *slash = &dst_dir[slash_ - dst];
469 +
470 + *slash = '\0';
471 + if (lstat(dst_dir, &dir_st) < 0) {
472 + /*
473 + * other errors fall through to rename(),
474 + * which reports them
475 + */
476 + if (errno == ENOENT || errno == ENOTDIR)
477 + bad = _("destination directory does not exist");
478 + } else if (!S_ISDIR(dir_st.st_mode)) {
479 + bad = _("destination is not a directory");
480 + }
481 +
482 + free(dst_dir);
483 + }
484 +
485 + if (bad)
486 + goto act_on_entry;
487 + }
488
489 if (ignore_sparse &&
490 (dst_mode & (SKIP_WORKTREE_DIR | SPARSE)) &&
@@ -544,8 +586,7 @@ remove_entry:
586 printf(_("Renaming %s to %s\n"), src, dst);
587 if (show_only)
588 continue;
547 - if (!(mode & (INDEX | SPARSE | SKIP_WORKTREE_DIR)) &&
548 - !(dst_mode & (SKIP_WORKTREE_DIR | SPARSE)) &&
589 + if (needs_worktree_rename(mode, dst_mode) &&
590 rename(src, dst) < 0) {
591 if (ignore_errors)
592 continue;
t/t7001-mv.sh
+102
@@ -114,6 +114,108 @@ test_expect_success 'clean up' '
114 git reset --hard
115 '
116
117 +test_expect_success 'moving file to directory without trailing slash' '
118 + git reset --hard HEAD &&
119 + rm -rf file.txt target && mkdir target &&
120 + echo content > file.txt &&
121 + git add file.txt &&
122 + git mv file.txt target &&
123 + test_path_is_file target/file.txt
124 +'
125 +
126 +test_expect_success 'moving file to a bare filename in the cwd' '
127 + git reset --hard &&
128 + rm -rf from dest.txt &&
129 + mkdir from &&
130 + echo content >from/file &&
131 + git add from/file &&
132 + git mv from/file dest.txt &&
133 + test_path_is_file dest.txt
134 +'
135 +
136 +test_expect_success 'moving to a non-existent directory' '
137 + git reset --hard &&
138 + rm -rf from && mkdir from &&
139 + echo content >from/file &&
140 + git add from/file &&
141 + test_must_fail git mv from/file no-such-dir/file 2>actual &&
142 + test_grep "destination directory does not exist" actual
143 +'
144 +
145 +test_expect_success 'moving to a destination with a file as a leading path component' '
146 + git reset --hard &&
147 + rm -rf from && mkdir from &&
148 + echo contents >from/file &&
149 + echo blocker >not-dir &&
150 + git add from/file &&
151 + test_must_fail git mv from/file not-dir/file 2>actual &&
152 + test_grep "destination is not a directory" actual
153 +'
154 +
155 +test_expect_success SYMLINKS 'moving to a destination beyond a symlink' '
156 + git reset --hard &&
157 + rm -rf from regular-dir link-to-dir &&
158 + mkdir from regular-dir &&
159 + echo contents >from/file &&
160 + ln -s regular-dir link-to-dir &&
161 + git add from/file &&
162 + test_must_fail git mv from/file link-to-dir/file 2>actual &&
163 + test_grep "destination is beyond a symbolic link" actual
164 +'
165 +
166 +test_expect_success SYMLINKS 'moving to a destination with a symlink as an intermediate component' '
167 + git reset --hard &&
168 + rm -rf from && mkdir -p from/real/inner &&
169 + echo contents >from/file &&
170 + ln -s real from/link &&
171 + git add from/file from/link &&
172 + test_must_fail git mv from/file from/link/inner/dst 2>actual &&
173 + test_grep "destination is beyond a symbolic link" actual
174 +'
175 +
176 +test_expect_success SYMLINKS 'refuses to overwrite a symlink at the destination' '
177 + git reset --hard &&
178 + rm -rf from && mkdir from &&
179 + echo contents >from/file &&
180 + ln -s target from/link &&
181 + git add from/file from/link &&
182 + test_must_fail git mv from/file from/link 2>actual &&
183 + test_grep "destination exists" actual
184 +'
185 +
186 +test_expect_success SYMLINKS 'mv through a symlinked leading path does not touch the index' '
187 + git reset --hard &&
188 + rm -rf from && mkdir from &&
189 + echo contents >from/src &&
190 + ln -s . from/link &&
191 + git add from/src from/link &&
192 + git commit -m "setup symlink case" &&
193 + git ls-files --stage >expect.index &&
194 + test_must_fail git mv from/src from/link/real/dst 2>actual &&
195 + test_grep "destination is beyond a symbolic link" actual &&
196 + git ls-files --stage >actual.index &&
197 + test_cmp expect.index actual.index
198 +'
199 +
200 +test_expect_success SYMLINKS 'mv -f does not follow a symlinked leading path' '
201 + git reset --hard &&
202 + rm -rf from && mkdir from &&
203 + echo contents >from/src &&
204 + ln -s file from/link &&
205 + git add from/src from/link &&
206 + test_must_fail git mv -f from/src from/link/dst 2>actual &&
207 + test_grep "destination is beyond a symbolic link" actual
208 +'
209 +
210 +test_expect_success 'mv --dry-run detects non-existent destination parent directory' '
211 + git reset --hard &&
212 + rm -rf from && mkdir from &&
213 + echo contents >from/file &&
214 + git add from/file &&
215 + test_must_fail git mv -n from/file no-such-dir/file 2>actual &&
216 + test_grep "destination directory does not exist" actual
217 +'
218 +
219 test_expect_success 'moving to existing untracked target with trailing slash' '
220 mkdir path1 &&
221 git mv path0/ path1/ &&