Subject: [PATCH v3 5/5] submodule: error out if gitdir name is too long

From: Adrian Ratiu <[email-masked]>
Date: Mon, 6 Oct 2025 14:25:18 +0300

Encoding submodule names increases their name size, so there is an
increased risk to hit the max filename length in the gitdir path.
(the likelihood is still rather small, so it's an acceptable risk)

This gitdir file-name-too-long corner case can be be addressed in
multiple ways, including sharding or trimming, however for now, just
add the portable logic (suggested by Peff) to detect the corner case
then error out to avoid committing to a specific policy (or policies).

In the future, instead of throwing an error (which we do now anyway
without submodule encoding), we could maybe let the user specify via
configs how to address this case, e.g. pick trimming or sharding.

At least now we print a nice error instead of the OS defaults which
can be rather cryptic for users.

Suggested-by: Jeff King <[email-masked]>
Signed-off-by: Adrian Ratiu <[email-masked]>
---
 Makefile                      |  5 +++++
 compat/pathconf.c             | 10 ++++++++++
 compat/posix.h                |  8 ++++++++
 config.mak.uname              |  2 ++
 meson.build                   |  1 +
 submodule.c                   | 16 ++++++++++++++++
 t/t7425-submodule-encoding.sh | 16 ++++++++++++++++
 7 files changed, 58 insertions(+)
 create mode 100644 compat/pathconf.c

diff --git a/Makefile b/Makefile
index 92fd8d86d8..9f76a67d4b 100644
--- a/Makefile
+++ b/Makefile
@@ -2215,6 +2215,11 @@ ifndef HAVE_PLATFORM_PROCINFO
 	COMPAT_OBJS += compat/stub/procinfo.o
 endif
 
+ifdef NO_PATHCONF
+	COMPAT_CFLAGS += -DNO_PATHCONF
+	COMPAT_OBJS += compat/pathconf.o
+endif
+
 ifdef RUNTIME_PREFIX
 
         ifdef HAVE_BSD_KERN_PROC_SYSCTL
diff --git a/compat/pathconf.c b/compat/pathconf.c
new file mode 100644
index 0000000000..37500cfa0d
--- /dev/null
+++ b/compat/pathconf.c
@@ -0,0 +1,10 @@
+#include "git-compat-util.h"
+
+/*
+ * Minimal stub for platforms without pathconf() (e.g. Windows),
+ * to fall back to NAME_MAX from limits.h or compat/posix.h.
+ */
+long git_pathconf(const char *path UNUSED, int name UNUSED)
+{
+	return -1;
+}
diff --git a/compat/posix.h b/compat/posix.h
index 067a00f33b..aa050fd58c 100644
--- a/compat/posix.h
+++ b/compat/posix.h
@@ -250,6 +250,14 @@ char *gitdirname(char *);
 #define NAME_MAX 255
 #endif
 
+#ifdef NO_PATHCONF
+#ifndef _PC_NAME_MAX
+#define _PC_NAME_MAX 1 /* dummy value, only used for git_pathconf */
+#endif
+#define pathconf(a,b) git_pathconf(a,b)
+long git_pathconf(const char *path, int name);
+#endif
+
 typedef uintmax_t timestamp_t;
 #define PRItime PRIuMAX
 #define parse_timestamp strtoumax
diff --git a/config.mak.uname b/config.mak.uname
index 1691c6ae6e..49ba3de39d 100644
--- a/config.mak.uname
+++ b/config.mak.uname
@@ -473,6 +473,7 @@ ifeq ($(uname_S),Windows)
 	NEEDS_CRYPTO_WITH_SSL = YesPlease
 	NO_LIBGEN_H = YesPlease
 	NO_POLL = YesPlease
+	NO_PATHCONF = YesPlease
 	NO_SYMLINK_HEAD = YesPlease
 	NO_IPV6 = YesPlease
 	NO_SETENV = YesPlease
@@ -688,6 +689,7 @@ ifeq ($(uname_S),MINGW)
 	NEEDS_CRYPTO_WITH_SSL = YesPlease
 	NO_LIBGEN_H = YesPlease
 	NO_POLL = YesPlease
+	NO_PATHCONF = YesPlease
 	NO_SYMLINK_HEAD = YesPlease
 	NO_SETENV = YesPlease
 	NO_STRCASESTR = YesPlease
diff --git a/meson.build b/meson.build
index db1710e229..b65b5e0911 100644
--- a/meson.build
+++ b/meson.build
@@ -1394,6 +1394,7 @@ checkfuncs = {
   'initgroups' : [],
   'strtoumax' : ['strtoumax.c', 'strtoimax.c'],
   'pread' : ['pread.c'],
+  'pathconf' : ['pathconf.c'],
 }
 
 if host_machine.system() == 'windows'
diff --git a/submodule.c b/submodule.c
index 23b79c9192..201e8a8fe5 100644
--- a/submodule.c
+++ b/submodule.c
@@ -2625,13 +2625,29 @@ void submodule_name_to_gitdir(struct strbuf *buf, struct repository *r,
 
 	if (the_repository->repository_format_submodule_encoding) {
 		struct strbuf tmp = STRBUF_INIT;
+		size_t base_len;
+		long name_max;
 
 		strbuf_reset(buf);
 		repo_git_path_append(r, buf, "modules/");
+		base_len = buf->len;
 
 		strbuf_addstr_urlencode(&tmp, submodule_name, is_rfc3986_unreserved);
 		strbuf_addstr_case_encode(buf, tmp.buf);
 
+		/* Ensure final path length is below NAME_MAX after encoding */
+		name_max = pathconf(buf->buf, _PC_NAME_MAX);
+		if (name_max == -1)
+			name_max = NAME_MAX;
+
+		if (buf->len - base_len > name_max)
+			/*
+			 * TODO: make this smarter; instead of erroring out, maybe we could trim or
+			 * shard the gitdir names to make them fit under NAME_MAX.
+			 */
+			die(_("submodule name %s is too long (%"PRIuMAX" bytes, limit %"PRIuMAX")"),
+			    buf->buf, (uintmax_t)buf->len - base_len, (uintmax_t)name_max);
+
 		strbuf_release(&tmp);
 	}
 }
diff --git a/t/t7425-submodule-encoding.sh b/t/t7425-submodule-encoding.sh
index 4ea385d882..8041781491 100755
--- a/t/t7425-submodule-encoding.sh
+++ b/t/t7425-submodule-encoding.sh
@@ -143,4 +143,20 @@ test_expect_success 'submodule git dir nesting detection must work with parallel
 	verify_submodule_gitdir_path clone_parallel hippo/hooks modules/hippo%2fhooks
 '
 
+test_expect_success 'submodule encoded name exceeds max name limit' '
+	(
+		cd main &&
+
+		# find the system NAME_MAX (fall back to 255 if unknown)
+		name_max=$(getconf NAME_MAX . 2>/dev/null || echo 255) &&
+
+		# each "%" char encodes to "%25" (3 chars), ensure we exceed NAME_MAX
+		count=$((name_max + 10)) &&
+		longname=$(test_seq -f "%%%0.s" 1 $count | tr -d "\n") &&
+
+		test_must_fail git submodule add ../new-sub "$longname" 2>err &&
+		test_grep "fatal: submodule name .* is too long" err
+	)
+'
+
 test_done
-- 
2.49.1