All of lore.kernel.org
 help / color / mirror / Atom feed
From: Junio C Hamano <gitster@pobox.com>
To: "Lucas Zamboni Orioli via GitGitGadget" <gitgitgadget@gmail.com>
Cc: git@vger.kernel.org,  Ben Knoble <ben.knoble@gmail.com>,
	 Pablo Sabater <pabloosabaterr@gmail.com>,
	 Lucas Zamboni Orioli <lucaszam0@gmail.com>
Subject: Re: [PATCH v4 2/2] mv: reject a destination whose leading path is missing or a symlink
Date: Mon, 27 Jul 2026 15:24:21 -0700	[thread overview]
Message-ID: <xmqqbjbsgjfu.fsf@gitster.g> (raw)
In-Reply-To: <6b72efb4130d96947c7f90026042fa09a440d091.1785097071.git.gitgitgadget@gmail.com> (Lucas Zamboni Orioli via GitGitGadget's message of "Sun, 26 Jul 2026 20:17:51 +0000")

"Lucas Zamboni Orioli via GitGitGadget" <gitgitgadget@gmail.com>
writes:

> From: Lucas Zamboni Orioli <lucaszam0@gmail.com>
>
> Moving a file into a destination whose leading directories are not all
> present, real directories is only diagnosed later at rename(2), and for
> a symlinked component is not diagnosed at all.

I cannot quite parse this.  Do you mean to say something like this?

    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.

OK.  With [PATCH 1/2] in place, this is an easy case for the user to
deal with.  Either the directory name was misspelled, or the user
forgot to create intermediate levels of the destination directory.

>   - 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.

True.  'x' cannot become 'a/b' as long as 'a' is a file sitting there.

>   - 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.

Yeah, that is horrible.

> 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.

Nice touch.

> 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.

Nice bugfix.

> diff --git a/builtin/mv.c b/builtin/mv.c
> index 35e504484a..535599e6be 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"
> @@ -443,6 +444,40 @@ 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;
> +		}

With a proper helper, this part of the fix is surprisingly simple.

> +		/*
> +		 * If we are going to move SRC to DST on disk, DST's leading
> +		 * directories must already exist.
> +		 */
> +		if (!(modes[i] & (INDEX | SPARSE | SKIP_WORKTREE_DIR)) &&
> +		    !(dst_mode & (SKIP_WORKTREE_DIR | SPARSE))) {

This small piece of logic is a duplicate of the next block that
actually performs the move.  I wonder if we can have a small helper
function that takes mode and dst_mode as parameters and returns this
value?  Then this part would become:

		if (that_function(modes[i], dst_mode)) {

and the "real thing" would become

-		if (!(mode & (INDEX | SPARSE | SKIP_WORKTREE_DIR)) &&
-		    !(dst_mode & (SKIP_WORKTREE_DIR | SPARSE)) &&
+		if (that_function(mode, dst_mode) &&
		    rename(src, dst) < 0) {
			if (ignore_errors)
				continue;
			die_errno(_("renaming '%s' failed"), src);
		}

and we will never risk them drifting apart.  Naming is the tough
part, though.  I will leave it up to you and the list to come up
with a good name that fits the semantics of what that function
computes.

> +			char *dst_dir = xstrdup(dst);
> +			char *slash = strrchr(dst_dir, '/');

Are the elements of the destinations.v[] array normalized so that
they are all full final pathnames?  I mean, 'mv A B' when B is an
existing directory would succeed, remove A, and leave 'B/A' in the
resulting working tree.  If we can depend on the preprocessing code
and the element in destinations.v[] corresponding to the move is
'B/A' (and presumably the corresponding element in the sources.v[]
array would be 'A') in such a case, then stripping the final name
component and checking whether the remainder (that is, the dirname)
is a directory, as the code below does, sounds like the right
approach.

> +			if (slash) {
> +				struct stat dir_st;
> +
> +				*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 you did this instead

			const char *slash_ = strrchr(dst, '/');
			if (stash_) {
				char *dst_dir = xstrdup(dst);
				char *slash = &dst_dir[slash_ - dst];

then you need to allocate only if you need a copy.  I do not know if
it matters, though.  What do we do to elements in destinations.v[]
that lacks a slash?

> +			if (bad)
> +				goto act_on_entry;
> +		}

Thanks.

  reply	other threads:[~2026-07-27 22:24 UTC|newest]

Thread overview: 28+ messages / expand[flat|nested]  mbox.gz  Atom feed  top
2026-07-15 14:32 [PATCH] mv: report missing destination leading directory Lucas Zamboni Orioli via GitGitGadget
2026-07-15 16:46 ` Ben Knoble
2026-07-22 21:32   ` Lucas Zamboni Orioli
2026-07-23 13:13 ` [PATCH v2 0/2] " Lucas Zamboni Orioli via GitGitGadget
2026-07-23 13:13   ` [PATCH v2 1/2] mv: name both source and destination when rename fails Lucas Zamboni Orioli via GitGitGadget
2026-07-23 17:36     ` Junio C Hamano
2026-07-23 13:13   ` [PATCH v2 2/2] mv: check for missing destination directory before renaming Lucas Zamboni Orioli via GitGitGadget
2026-07-23 17:42     ` Junio C Hamano
2026-07-23 18:30     ` Junio C Hamano
2026-07-23 21:38       ` Lucas Zamboni Orioli
2026-07-23 22:40         ` Junio C Hamano
2026-07-23 23:28         ` Junio C Hamano
2026-07-26 14:59           ` Junio C Hamano
2026-07-26 17:59             ` Lucas Zamboni Orioli
2026-07-23 21:40   ` [PATCH v3 0/2] mv: report missing destination leading directory Lucas Zamboni Orioli via GitGitGadget
2026-07-23 21:40     ` [PATCH v3 1/2] mv: name both source and destination when rename fails Lucas Zamboni Orioli via GitGitGadget
2026-07-23 21:40     ` [PATCH v3 2/2] mv: check for missing destination directory before renaming Lucas Zamboni Orioli via GitGitGadget
2026-07-26 15:28       ` Pablo Sabater
2026-07-26 20:17     ` [PATCH v4 0/2] mv: report missing destination leading directory Lucas Zamboni Orioli via GitGitGadget
2026-07-26 20:17       ` [PATCH v4 1/2] mv: name both source and destination when rename fails Lucas Zamboni Orioli via GitGitGadget
2026-07-26 20:17       ` [PATCH v4 2/2] mv: reject a destination whose leading path is missing or a symlink Lucas Zamboni Orioli via GitGitGadget
2026-07-27 22:24         ` Junio C Hamano [this message]
2026-07-30 11:23           ` Lucas Zamboni Orioli
2026-07-26 23:29       ` [PATCH v4 0/2] mv: report missing destination leading directory Junio C Hamano
2026-07-30 11:28       ` [PATCH v5 " Lucas Zamboni Orioli via GitGitGadget
2026-07-30 11:28         ` [PATCH v5 1/2] mv: name both source and destination when rename fails Lucas Zamboni Orioli via GitGitGadget
2026-07-30 11:28         ` [PATCH v5 2/2] mv: reject a destination whose leading path is missing or a symlink Lucas Zamboni Orioli via GitGitGadget
2026-07-30 20:13         ` [PATCH v5 0/2] mv: report missing destination leading directory Junio C Hamano

Reply instructions:

You may reply publicly to this message via plain-text email
using any one of the following methods:

* Save the following mbox file, import it into your mail client,
  and reply-to-all from there: mbox

  Avoid top-posting and favor interleaved quoting:
  https://en.wikipedia.org/wiki/Posting_style#Interleaved_style

* Reply using the --to, --cc, and --in-reply-to
  switches of git-send-email(1):

  git send-email \
    --in-reply-to=xmqqbjbsgjfu.fsf@gitster.g \
    --to=gitster@pobox.com \
    --cc=ben.knoble@gmail.com \
    --cc=git@vger.kernel.org \
    --cc=gitgitgadget@gmail.com \
    --cc=lucaszam0@gmail.com \
    --cc=pabloosabaterr@gmail.com \
    /path/to/YOUR_REPLY

  https://kernel.org/pub/software/scm/git/docs/git-send-email.html

* If your mail client supports setting the In-Reply-To header
  via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line before the message body.
This is an external index of several public inboxes,
see mirroring instructions on how to clone and mirror
all data and code used by this external index.