{"thread":{"id":"65969","subject":"[PATCH 0/3] Introduce a 'fromAccepted' option to GIT_NO_LAZY_FETCH","startedAt":"2026-07-10T08:52:07Z","lastAt":"2026-10-05T15:37:30Z","messageCount":69,"participants":["Christian Couder","brian m. carlson","Junio C Hamano"],"isPatch":true,"patchVersion":1,"patchTotal":3},"messages":[{"id":"547697","messageId":"20260710085137.4171240-1-christian.couder@gmail.com","threadId":"65969","inReplyTo":null,"subject":"[PATCH 0/3] Introduce a 'fromAccepted' option to GIT_NO_LAZY_FETCH","fromName":"Christian Couder","fromEmail":"christian.couder@gmail.com","sentAt":"2026-07-10T08:51:34Z","receivedAt":"2026-07-10T08:52:07Z","isPatch":true,"body":"Since 7b70e9efb1 (upload-pack: disable lazy-fetching by default,\n2024-04-16), lazy fetching has been controlled by the\n`GIT_NO_LAZY_FETCH` environment variable. This is currently an \"all or\nnothing\" boolean that is set to 'true' by default when calling `git\nupload-pack` for security reasons.\n\nRecently the \"promisor-remote\" capability was added to protocol v2,\nallowing servers and clients to agree on the promisor remotes they\ncan safely use.\n\nThis series leverages that capability to implement a pragmatic middle\nground. By setting `GIT_NO_LAZY_FETCH` to 'fromAccepted', lazy\nfetching is allowed only when fetching from promisor remotes that are\nboth advertised by the server and accepted by the client.\n\nNote that using an environment variable for this is probably not the\nbest from a usability perspective. An `upload-pack.allowLazyFetch`\nconfiguration variable would likely be better.\n\nUnfortunately the `GIT_NO_LAZY_FETCH` environment variable is the way\nthings currently work. It would be a much bigger and more invasive\nchange to implement `upload-pack.allowLazyFetch` in a way that is\ncompatible with `GIT_NO_LAZY_FETCH` which has to stay anyway for\nbackward compatibility. Therefore, transitioning to a configuration\nvariable is left for future work.\n\nHigh level overview of the patches\n==================================\n\nPatch 1/3: A refactor which separates the fetching logic from the\nerror handling and validation logic. This might also slightly increase\nperformance if there are several promisor remotes.\n\nPatch 2/3: A preparatory commit that transitions `GIT_NO_LAZY_FETCH`\nfrom a strict boolean check into an enum that can support multiple\nstates.\n\nPatch 3/3: Introduces the 'fromAccepted' option, taking advantage of\nthe previous preparatory commits.\n\nCI tests\n========\n\nThey all pass, see:\n\nhttps://github.com/chriscool/git/actions/runs/29078195030\n\n\nChristian Couder (3):\n  promisor-remote: factor out lazy_fetch_objects()\n  promisor-remote: introduce enum allow_lazy_fetch\n  promisor-remote: teach 'fromAccepted' to GIT_NO_LAZY_FETCH\n\n Documentation/git-upload-pack.adoc    |   5 ++\n Documentation/git.adoc                |   6 +-\n promisor-remote.c                     | 110 ++++++++++++++++++--------\n promisor-remote.h                     |  14 ++++\n setup.c                               |   5 +-\n t/t5710-promisor-remote-capability.sh |  49 ++++++++++++\n 6 files changed, 154 insertions(+), 35 deletions(-)\n\n-- \n2.55.0.125.g395cd2c8ec.dirty\n\n"},{"id":"547698","messageId":"20260710085137.4171240-2-christian.couder@gmail.com","threadId":"65969","inReplyTo":"20260710085137.4171240-1-christian.couder@gmail.com","subject":"[PATCH 1/3] promisor-remote: factor out lazy_fetch_objects()","fromName":"Christian Couder","fromEmail":"christian.couder@gmail.com","sentAt":"2026-07-10T08:51:35Z","receivedAt":"2026-07-10T08:52:09Z","isPatch":true,"body":"In \"promisor-remote.c:fetch_objects()\", there is a check to disable\nlazy fetching when the `GIT_NO_LAZY_FETCH` environment variable is\nset. The fetch_objects() function is called once per promisor remote\nthough. So the check might be performed more times than necessary.\n\nAlso promisor_remote_get_direct() mixes up the logic deciding which\npromisor remotes to try with the logic checking that the objects\nthat could not be fetched are promisor objects.\n\nLet's refactor the lazy fetching logic out of these two functions\ninto a new lazy_fetch_objects() function. This will make it easier\nto extend the lazy fetching logic in following commits.\n\nThis is a pure refactoring with no intended behavior change. Two\nthings shift in ways that are observably equivalent though:\n\n  - the `GIT_NO_LAZY_FETCH` check is now performed once up front,\n    instead of once per promisor remote, and\n\n  - promisor_remote_init() is no longer called when lazy fetching\n    is disabled, which is fine as nothing downstream of it, like\n    is_promisor_object(), needs it in that case.\n\nWhile at it, let's also convert try_promisor_remotes() to return\n'bool' instead of 'int', as it just returns whether all the objects\ncould be fetched, and document its return value.\n\nSigned-off-by: Christian Couder <chriscool@tuxfamily.org>\n---\n promisor-remote.c | 76 ++++++++++++++++++++++++++++-------------------\n 1 file changed, 45 insertions(+), 31 deletions(-)\n\ndiff --git a/promisor-remote.c b/promisor-remote.c\nindex 43505d1e1a..65496c69cf 100644\n--- a/promisor-remote.c\n+++ b/promisor-remote.c\n@@ -31,15 +31,6 @@ static int fetch_objects(struct repository *repo,\n \tFILE *child_in;\n \tint quiet;\n \n-\tif (git_env_bool(NO_LAZY_FETCH_ENVIRONMENT, 0)) {\n-\t\tstatic int warning_shown;\n-\t\tif (!warning_shown) {\n-\t\t\twarning_shown = 1;\n-\t\t\twarning(_(\"lazy fetching disabled; some objects may not be available\"));\n-\t\t}\n-\t\treturn -1;\n-\t}\n-\n \tchild.git_cmd = 1;\n \tchild.in = -1;\n \tif (repo != the_repository)\n@@ -270,10 +261,15 @@ static int remove_fetched_oids(struct repository *repo,\n \treturn remaining_nr;\n }\n \n-static int try_promisor_remotes(struct repository *repo,\n-\t\t\t\tstruct object_id **remaining_oids,\n-\t\t\t\tint *remaining_nr, int *to_free,\n-\t\t\t\tbool accepted_only)\n+/*\n+ * Return 'true' if all the objects could be fetched from the\n+ * (non-)accepted remotes, 'false' otherwise.\n+ */\n+static bool try_promisor_remotes(struct repository *repo,\n+\t\t\t\t struct object_id **remaining_oids,\n+\t\t\t\t int *remaining_nr,\n+\t\t\t\t int *to_free,\n+\t\t\t\t bool accepted_only)\n {\n \tstruct promisor_remote *r = repo->promisor_remote_config->promisors;\n \n@@ -290,9 +286,37 @@ static int try_promisor_remotes(struct repository *repo,\n \t\t\t\tcontinue;\n \t\t\t}\n \t\t}\n-\t\treturn 1; /* all fetched */\n+\t\treturn true; /* all fetched */\n \t}\n-\treturn 0;\n+\treturn false;\n+}\n+\n+/*\n+ * Return 'true' if all the objects could be fetched, 'false' otherwise.\n+ */\n+static bool lazy_fetch_objects(struct repository *repo,\n+\t\t\t       struct object_id **remaining_oids,\n+\t\t\t       int *remaining_nr,\n+\t\t\t       int *to_free)\n+{\n+\tif (git_env_bool(NO_LAZY_FETCH_ENVIRONMENT, 0)) {\n+\t\tstatic int warning_shown;\n+\t\tif (!warning_shown) {\n+\t\t\twarning_shown = 1;\n+\t\t\twarning(_(\"lazy fetching disabled; some objects may not be available\"));\n+\t\t}\n+\t\treturn false;\n+\t}\n+\n+\tpromisor_remote_init(repo);\n+\n+\t/* Try accepted remotes first (those the server told us to use) */\n+\tif (try_promisor_remotes(repo, remaining_oids, remaining_nr,\n+\t\t\t\t to_free, true))\n+\t\treturn true;\n+\n+\treturn try_promisor_remotes(repo, remaining_oids, remaining_nr,\n+\t\t\t\t    to_free, false);\n }\n \n void promisor_remote_get_direct(struct repository *repo,\n@@ -302,28 +326,18 @@ void promisor_remote_get_direct(struct repository *repo,\n \tstruct object_id *remaining_oids = (struct object_id *)oids;\n \tint remaining_nr = oid_nr;\n \tint to_free = 0;\n-\tint i;\n \n \tif (oid_nr == 0)\n \t\treturn;\n \n-\tpromisor_remote_init(repo);\n-\n-\t/* Try accepted remotes first (those the server told us to use) */\n-\tif (try_promisor_remotes(repo, &remaining_oids, &remaining_nr,\n-\t\t\t\t &to_free, true))\n-\t\tgoto all_fetched;\n-\tif (try_promisor_remotes(repo, &remaining_oids, &remaining_nr,\n-\t\t\t\t &to_free, false))\n-\t\tgoto all_fetched;\n-\n-\tfor (i = 0; i < remaining_nr; i++) {\n-\t\tif (is_promisor_object(repo, &remaining_oids[i]))\n-\t\t\tdie(_(\"could not fetch %s from promisor remote\"),\n-\t\t\t    oid_to_hex(&remaining_oids[i]));\n+\tif (!lazy_fetch_objects(repo, &remaining_oids, &remaining_nr, &to_free)) {\n+\t\tfor (int i = 0; i < remaining_nr; i++) {\n+\t\t\tif (is_promisor_object(repo, &remaining_oids[i]))\n+\t\t\t\tdie(_(\"could not fetch %s from promisor remote\"),\n+\t\t\t\t    oid_to_hex(&remaining_oids[i]));\n+\t\t}\n \t}\n \n-all_fetched:\n \tif (to_free)\n \t\tfree(remaining_oids);\n }\n-- \n2.55.0.125.g395cd2c8ec.dirty\n\n"},{"id":"547699","messageId":"20260710085137.4171240-3-christian.couder@gmail.com","threadId":"65969","inReplyTo":"20260710085137.4171240-1-christian.couder@gmail.com","subject":"[PATCH 2/3] promisor-remote: introduce enum allow_lazy_fetch","fromName":"Christian Couder","fromEmail":"christian.couder@gmail.com","sentAt":"2026-07-10T08:51:36Z","receivedAt":"2026-07-10T08:52:10Z","isPatch":true,"body":"The `GIT_NO_LAZY_FETCH` environment variable is currently parsed as\na Boolean, using git_env_bool(), in both \"setup.c\" and\n\"promisor-remote.c\".\n\nIn a following commit, we are going to allow a third value for this\nvariable, on top of 'true' and 'false'.\n\nTo prepare for that, let's introduce an `enum allow_lazy_fetch` with\nthe possible results of parsing the variable, along with a\nparse_allow_lazy_fetch_env() function to parse it, and let's use them\neverywhere the variable is parsed.\n\nNote that, as before, an invalid value makes us die(), only the error\nmessage changes from \"bad boolean environment value ...\" to \"bad\nenvironment value ...\".\n\nSigned-off-by: Christian Couder <chriscool@tuxfamily.org>\n---\n promisor-remote.c | 24 +++++++++++++++++++++++-\n promisor-remote.h | 13 +++++++++++++\n setup.c           |  5 ++++-\n 3 files changed, 40 insertions(+), 2 deletions(-)\n\ndiff --git a/promisor-remote.c b/promisor-remote.c\nindex 65496c69cf..56f57c5267 100644\n--- a/promisor-remote.c\n+++ b/promisor-remote.c\n@@ -21,6 +21,26 @@ struct promisor_remote_config {\n \tstruct promisor_remote **promisors_tail;\n };\n \n+enum allow_lazy_fetch parse_allow_lazy_fetch_env(void)\n+{\n+\tconst char *v = getenv(NO_LAZY_FETCH_ENVIRONMENT);\n+\tint val;\n+\n+\tif (!v)\n+\t\treturn LAZY_FETCH_ALL;\n+\n+\tval = git_parse_maybe_bool(v);\n+\n+\tif (!val)\n+\t\treturn LAZY_FETCH_ALL;\n+\tif (val > 0)\n+\t\treturn LAZY_FETCH_NONE;\n+\n+\tdie(_(\"bad environment value '%s' for '%s'; \"\n+\t      \"only 'false/0' and 'true/1' are valid\"),\n+\t    v, NO_LAZY_FETCH_ENVIRONMENT);\n+}\n+\n static int fetch_objects(struct repository *repo,\n \t\t\t const char *remote_name,\n \t\t\t const struct object_id *oids,\n@@ -299,7 +319,9 @@ static bool lazy_fetch_objects(struct repository *repo,\n \t\t\t       int *remaining_nr,\n \t\t\t       int *to_free)\n {\n-\tif (git_env_bool(NO_LAZY_FETCH_ENVIRONMENT, 0)) {\n+\tenum allow_lazy_fetch lf = parse_allow_lazy_fetch_env();\n+\n+\tif (lf == LAZY_FETCH_NONE) {\n \t\tstatic int warning_shown;\n \t\tif (!warning_shown) {\n \t\t\twarning_shown = 1;\ndiff --git a/promisor-remote.h b/promisor-remote.h\nindex 301f5ac5cb..87fc24c9eb 100644\n--- a/promisor-remote.h\n+++ b/promisor-remote.h\n@@ -25,6 +25,19 @@ void promisor_remote_clear(struct promisor_remote_config *config);\n struct promisor_remote *repo_promisor_remote_find(struct repository *r, const char *remote_name);\n int repo_has_promisor_remote(struct repository *r);\n \n+/* Enum for lazy fetching parsing */\n+enum allow_lazy_fetch {\n+\tLAZY_FETCH_NONE    = 0,  /* No lazy fetching */\n+\tLAZY_FETCH_ALL           /* Lazy fetch from any promisor remotes */\n+};\n+\n+/*\n+ * Parse the NO_LAZY_FETCH_ENVIRONMENT env variable into an\n+ * `enum allow_lazy_fetch`.\n+ * If parsing fails, then die().\n+ */\n+enum allow_lazy_fetch parse_allow_lazy_fetch_env(void);\n+\n /*\n  * Fetches all requested objects from all promisor remotes, trying them one at\n  * a time until all objects are fetched.\ndiff --git a/setup.c b/setup.c\nindex 0de56a074f..0a81d9f045 100644\n--- a/setup.c\n+++ b/setup.c\n@@ -24,6 +24,7 @@\n #include \"trace.h\"\n #include \"trace2.h\"\n #include \"worktree.h\"\n+#include \"promisor-remote.h\"\n \n enum allowed_bare_repo {\n \tALLOWED_BARE_REPO_EXPLICIT = 0,\n@@ -1051,6 +1052,7 @@ static void setup_git_env_internal(struct repository *repo,\n \tconst char *replace_ref_base;\n \tstruct set_gitdir_args args = { NULL };\n \tstruct strvec to_free = STRVEC_INIT;\n+\tenum allow_lazy_fetch lf;\n \n \targs.commondir = getenv_safe(&to_free, GIT_COMMON_DIR_ENVIRONMENT);\n \targs.graft_file = getenv_safe(&to_free, GRAFT_ENVIRONMENT);\n@@ -1072,7 +1074,8 @@ static void setup_git_env_internal(struct repository *repo,\n \tif (shallow_file)\n \t\tset_alternate_shallow_file(repo, shallow_file, 0);\n \n-\tif (git_env_bool(NO_LAZY_FETCH_ENVIRONMENT, 0))\n+\tlf = parse_allow_lazy_fetch_env();\n+\tif (lf == LAZY_FETCH_NONE)\n \t\tfetch_if_missing = 0;\n }\n \n-- \n2.55.0.125.g395cd2c8ec.dirty\n\n"},{"id":"547700","messageId":"20260710085137.4171240-4-christian.couder@gmail.com","threadId":"65969","inReplyTo":"20260710085137.4171240-1-christian.couder@gmail.com","subject":"[PATCH 3/3] promisor-remote: teach 'fromAccepted' to GIT_NO_LAZY_FETCH","fromName":"Christian Couder","fromEmail":"christian.couder@gmail.com","sentAt":"2026-07-10T08:51:37Z","receivedAt":"2026-07-10T08:52:11Z","isPatch":true,"body":"The `GIT_NO_LAZY_FETCH` environment variable can be set to 'true' or\n'false' to enable or disable lazy fetching. By default it is set to\n'true' when calling `git upload-pack` to avoid security issues, see\n7b70e9efb1 (upload-pack: disable lazy-fetching by default, 2024-04-16).\n\nRecently though, the \"promisor-remote\" capability was introduced into\nprotocol v2, which allows a server to advertise some promisor remotes\nand clients to accept them or not.\n\nWhen promisor remotes are advertised by the server and accepted by the\nclient, it means that they are quite trusted. So the security risks\nwhich come from lazy fetching from them could be considered much more\nacceptable.\n\nLet's introduce a 'fromAccepted' option on top of 'true' and 'false'\nfor `GIT_NO_LAZY_FETCH` to allow lazy fetching only from accepted\npromisor remotes.\n\nSigned-off-by: Christian Couder <chriscool@tuxfamily.org>\n---\n Documentation/git-upload-pack.adoc    |  5 +++\n Documentation/git.adoc                |  6 ++--\n promisor-remote.c                     | 14 +++++++-\n promisor-remote.h                     |  1 +\n t/t5710-promisor-remote-capability.sh | 49 +++++++++++++++++++++++++++\n 5 files changed, 71 insertions(+), 4 deletions(-)\n\ndiff --git a/Documentation/git-upload-pack.adoc b/Documentation/git-upload-pack.adoc\nindex 9167a321d0..1c2ed9d7ba 100644\n--- a/Documentation/git-upload-pack.adoc\n+++ b/Documentation/git-upload-pack.adoc\n@@ -71,6 +71,11 @@ This is implemented by having `upload-pack` internally set the\n (because you are fetching from a partial clone, and you are sure\n you trust it), you can explicitly set `GIT_NO_LAZY_FETCH` to\n `0`.\n++\n+`GIT_NO_LAZY_FETCH` can also be set to 'fromAccepted' which allows\n+lazy fetching only from remotes that are advertised and accepted using\n+the \"promisor-remote\" protocol v2 capability. See\n+linkgit:gitprotocol-v2[5]. This is safer than setting it to `0`.\n \n SECURITY\n --------\ndiff --git a/Documentation/git.adoc b/Documentation/git.adoc\nindex 8a5cdd3b3d..14a083bcdb 100644\n--- a/Documentation/git.adoc\n+++ b/Documentation/git.adoc\n@@ -947,9 +947,9 @@ for full details.\n \tpathspecs as case-insensitive.\n \n `GIT_NO_LAZY_FETCH`::\n-\tSetting this Boolean environment variable to true tells Git\n-\tnot to lazily fetch missing objects from the promisor remote\n-\ton demand.\n+\tSetting this environment variable controls whether Git is\n+\tallowed to lazily fetch missing objects from a promisor remote\n+\ton demand. See linkgit:git-upload-pack[1].\n \n `GIT_REFLOG_ACTION`::\n \tWhen a ref is updated, reflog entries are created to keep\ndiff --git a/promisor-remote.c b/promisor-remote.c\nindex 56f57c5267..c80319f966 100644\n--- a/promisor-remote.c\n+++ b/promisor-remote.c\n@@ -35,9 +35,11 @@ enum allow_lazy_fetch parse_allow_lazy_fetch_env(void)\n \t\treturn LAZY_FETCH_ALL;\n \tif (val > 0)\n \t\treturn LAZY_FETCH_NONE;\n+\tif (!strcasecmp(v, \"fromAccepted\"))\n+\t\treturn LAZY_FETCH_ACCEPTED;\n \n \tdie(_(\"bad environment value '%s' for '%s'; \"\n-\t      \"only 'false/0' and 'true/1' are valid\"),\n+\t      \"only 'false/0', 'true/1' and 'fromAccepted' are valid\"),\n \t    v, NO_LAZY_FETCH_ENVIRONMENT);\n }\n \n@@ -337,6 +339,16 @@ static bool lazy_fetch_objects(struct repository *repo,\n \t\t\t\t to_free, true))\n \t\treturn true;\n \n+\tif (lf == LAZY_FETCH_ACCEPTED) {\n+\t\tstatic int warning_shown;\n+\t\tif (!warning_shown) {\n+\t\t\twarning_shown = 1;\n+\t\t\twarning(_(\"lazy fetching from accepted promisor remotes only; \"\n+\t\t\t\t  \"some objects may not be available\"));\n+\t\t}\n+\t\treturn false;\n+\t}\n+\n \treturn try_promisor_remotes(repo, remaining_oids, remaining_nr,\n \t\t\t\t    to_free, false);\n }\ndiff --git a/promisor-remote.h b/promisor-remote.h\nindex 87fc24c9eb..0d05ff9d84 100644\n--- a/promisor-remote.h\n+++ b/promisor-remote.h\n@@ -28,6 +28,7 @@ int repo_has_promisor_remote(struct repository *r);\n /* Enum for lazy fetching parsing */\n enum allow_lazy_fetch {\n \tLAZY_FETCH_NONE    = 0,  /* No lazy fetching */\n+\tLAZY_FETCH_ACCEPTED,     /* Lazy fetching only from accepted promisor remotes */\n \tLAZY_FETCH_ALL           /* Lazy fetch from any promisor remotes */\n };\n \ndiff --git a/t/t5710-promisor-remote-capability.sh b/t/t5710-promisor-remote-capability.sh\nindex 549acff23f..1c61b100b9 100755\n--- a/t/t5710-promisor-remote-capability.sh\n+++ b/t/t5710-promisor-remote-capability.sh\n@@ -173,6 +173,55 @@ test_expect_success \"clone with promisor.acceptfromserver set to 'None'\" '\n \tinitialize_server 1 \"$oid\"\n '\n \n+test_expect_success \"clone with GIT_NO_LAZY_FETCH=fromAccepted and accepted promisor remote\" '\n+\tgit -C server config promisor.advertise true &&\n+\ttest_when_finished \"rm -rf client\" &&\n+\n+\t# Clone from server to create a client\n+\tGIT_NO_LAZY_FETCH=fromAccepted git clone -c remote.lop.promisor=true \\\n+\t\t-c remote.lop.fetch=\"+refs/heads/*:refs/remotes/lop/*\" \\\n+\t\t-c remote.lop.url=\"$TRASH_DIRECTORY_URL/lop\" \\\n+\t\t-c promisor.acceptfromserver=All \\\n+\t\t--no-local --filter=\"blob:limit=5k\" server client &&\n+\n+\t# Check that the largest object is still missing on the server\n+\tcheck_missing_objects server 1 \"$oid\"\n+'\n+\n+test_expect_success \"clone with GIT_NO_LAZY_FETCH=fromAccepted and no accepted promisor remote\" '\n+\tgit -C server config promisor.advertise true &&\n+\ttest_when_finished \"rm -rf client\" &&\n+\n+\t# Clone from server to create a client\n+\t# It should fail because the server cannot lazy fetch the missing blob\n+\ttest_must_fail env GIT_NO_LAZY_FETCH=fromAccepted git clone -c remote.lop.promisor=true \\\n+\t\t-c remote.lop.fetch=\"+refs/heads/*:refs/remotes/lop/*\" \\\n+\t\t-c remote.lop.url=\"$TRASH_DIRECTORY_URL/lop\" \\\n+\t\t-c promisor.acceptfromserver=None \\\n+\t\t--no-local --filter=\"blob:limit=5k\" server client 2>err &&\n+\n+\ttest_grep \"lazy fetching from accepted promisor remotes only\" err &&\n+\n+\t# Check that the largest object is still missing on the server\n+\tcheck_missing_objects server 1 \"$oid\"\n+'\n+\n+test_expect_success \"clone failure with GIT_NO_LAZY_FETCH=bogus\" '\n+\tgit -C server config promisor.advertise true &&\n+\ttest_when_finished \"rm -rf client\" &&\n+\n+\ttest_must_fail env GIT_NO_LAZY_FETCH=bogus git clone -c remote.lop.promisor=true \\\n+\t\t-c remote.lop.fetch=\"+refs/heads/*:refs/remotes/lop/*\" \\\n+\t\t-c remote.lop.url=\"$TRASH_DIRECTORY_URL/lop\" \\\n+\t\t-c promisor.acceptfromserver=All \\\n+\t\t--no-local --filter=\"blob:limit=5k\" server client 2>err &&\n+\n+\ttest_grep \"bad environment value\" err &&\n+\n+\t# Check that the largest object is still missing on the server\n+\tcheck_missing_objects server 1 \"$oid\"\n+'\n+\n test_expect_success \"init + fetch with promisor.advertise set to 'true'\" '\n \tgit -C server config promisor.advertise true &&\n \ttest_when_finished \"rm -rf client\" &&\n-- \n2.55.0.125.g395cd2c8ec.dirty\n\n"},{"id":"547794","messageId":"alFM-4FJQfaEjyju@fruit.crustytoothpaste.net","threadId":"65969","inReplyTo":"20260710085137.4171240-1-christian.couder@gmail.com","subject":"Re: [PATCH 0/3] Introduce a 'fromAccepted' option to GIT_NO_LAZY_FETCH","fromName":"brian m. carlson","fromEmail":"sandals@crustytoothpaste.net","sentAt":"2026-07-10T19:50:20Z","receivedAt":"2026-07-10T19:50:28Z","isPatch":true,"body":"On 2026-07-10 at 08:51:34, Christian Couder wrote:\n> Since 7b70e9efb1 (upload-pack: disable lazy-fetching by default,\n> 2024-04-16), lazy fetching has been controlled by the\n> `GIT_NO_LAZY_FETCH` environment variable. This is currently an \"all or\n> nothing\" boolean that is set to 'true' by default when calling `git\n> upload-pack` for security reasons.\n> \n> Recently the \"promisor-remote\" capability was added to protocol v2,\n> allowing servers and clients to agree on the promisor remotes they\n> can safely use.\n> \n> This series leverages that capability to implement a pragmatic middle\n> ground. By setting `GIT_NO_LAZY_FETCH` to 'fromAccepted', lazy\n> fetching is allowed only when fetching from promisor remotes that are\n> both advertised by the server and accepted by the client.\n> \n> Note that using an environment variable for this is probably not the\n> best from a usability perspective. An `upload-pack.allowLazyFetch`\n> configuration variable would likely be better.\n> \n> Unfortunately the `GIT_NO_LAZY_FETCH` environment variable is the way\n> things currently work. It would be a much bigger and more invasive\n> change to implement `upload-pack.allowLazyFetch` in a way that is\n> compatible with `GIT_NO_LAZY_FETCH` which has to stay anyway for\n> backward compatibility. Therefore, transitioning to a configuration\n> variable is left for future work.\n\nI don't think this is a good idea.  We get a lot of reports on the\nsecurity list involving various tooling that isn't within the scope of\nour threat model.  This substantially increases the amount of code which\nis now subject to that threat model and therefore our security\nguarantees and I don't think we should do that as it stands, very\nespecially while so much of our network-facing code is written in C.\n\nThe fetch code by default reads lots of configuration information from\nthe repository, including remote settings and information and we really\nwant absolutely none of that code running in the context of an untrusted\nrepository.\n-- \nbrian m. carlson (they/them)\nToronto, Ontario, CA\n"},{"id":"547880","messageId":"CAP8UFD0_S9eg_w42tcNRnT9E2ntLr_eHLnzE4c2dSu67DzZoXg@mail.gmail.com","threadId":"65969","inReplyTo":"alFM-4FJQfaEjyju@fruit.crustytoothpaste.net","subject":"Re: [PATCH 0/3] Introduce a 'fromAccepted' option to GIT_NO_LAZY_FETCH","fromName":"Christian Couder","fromEmail":"christian.couder@gmail.com","sentAt":"2026-07-12T09:06:47Z","receivedAt":"2026-07-12T09:06:59Z","isPatch":true,"body":"On Fri, Jul 10, 2026 at 9:50 PM brian m. carlson\n<sandals@crustytoothpaste.net> wrote:\n>\n> On 2026-07-10 at 08:51:34, Christian Couder wrote:\n> > Since 7b70e9efb1 (upload-pack: disable lazy-fetching by default,\n> > 2024-04-16), lazy fetching has been controlled by the\n> > `GIT_NO_LAZY_FETCH` environment variable. This is currently an \"all or\n> > nothing\" boolean that is set to 'true' by default when calling `git\n> > upload-pack` for security reasons.\n> >\n> > Recently the \"promisor-remote\" capability was added to protocol v2,\n> > allowing servers and clients to agree on the promisor remotes they\n> > can safely use.\n> >\n> > This series leverages that capability to implement a pragmatic middle\n> > ground. By setting `GIT_NO_LAZY_FETCH` to 'fromAccepted', lazy\n> > fetching is allowed only when fetching from promisor remotes that are\n> > both advertised by the server and accepted by the client.\n> >\n> > Note that using an environment variable for this is probably not the\n> > best from a usability perspective. An `upload-pack.allowLazyFetch`\n> > configuration variable would likely be better.\n> >\n> > Unfortunately the `GIT_NO_LAZY_FETCH` environment variable is the way\n> > things currently work. It would be a much bigger and more invasive\n> > change to implement `upload-pack.allowLazyFetch` in a way that is\n> > compatible with `GIT_NO_LAZY_FETCH` which has to stay anyway for\n> > backward compatibility. Therefore, transitioning to a configuration\n> > variable is left for future work.\n>\n> I don't think this is a good idea.  We get a lot of reports on the\n> security list involving various tooling that isn't within the scope of\n> our threat model.  This substantially increases the amount of code which\n> is now subject to that threat model and therefore our security\n> guarantees and I don't think we should do that as it stands, very\n> especially while so much of our network-facing code is written in C.\n\nThis small series doesn't change any defaults, especially\nGIT_NO_LAZY_FETCH is still set to 1 when calling `git upload-pack` by\ndefault. And the new option is more restrictive than the\nGIT_NO_LAZY_FETCH=0 option which already exists.\n\nSo I don't think it's fair to say that this _substantially increases_\nthe amount of code subject to some threat model.\n\nI agree that client acceptance of some promisor remotes doesn't make\nthe served repo trusted. It's a real concern, but I think it's\naddressable by different mechanisms. See below.\n\n> The fetch code by default reads lots of configuration information from\n> the repository, including remote settings and information and we really\n> want absolutely none of that code running in the context of an untrusted\n> repository.\n\nWhen a promisor remote has been accepted, it means both the client and\nthe server trust it, so at least the promisor remote is not untrusted.\n\nNow the main security issue on the server side is making sure the\nserved repo itself is also trusted. And I agree that the operator of\nthe server should decide and mark that trust, not the client.\n\nI also agree that on GitLab/GitHub-style multi-tenant hosts most\nrepositories shouldn't be marked as trusted.\n\nHowever note that:\n\n- The operator of the server is the only actor which can set\nGIT_NO_LAZY_FETCH on the server (where it matters).\n- In the case of corporate/self-hosted repos, the operator also\ncontrols the repos.\n- Different features could be developed (in future work) to improve on\nthe current state:\n    - a way for lazy fetching to work without reading config files,\ntriggering hooks, or doing potentially sensitive things,\n    - an explicit way for operators to mark trusted repos (like\nperhaps a server-side config the operator sets per-repo),\n    - operator-defined allow/deny rules, or maybe\n    - some ways/scripts/commands to scan repos and check configuration\ninformation, remote settings and everything potentially sensitive to\ndecide if a repo looks safe enough to allow lazy fetching or not.\n\nI would be happy to hear opinions about those potential features or\nany other ways to address the issue.\n\nSo I agree that this series doesn't fix all the problems on the server\nside, but I think it's still valuable to be able to restrict lazy\nfetching to accepted promisor remotes.\n\nAlso I definitely agree that the current series should have better\ndocumentation about this, and I plan to improve on that in the v2 of\nthis series.\n\nThanks for your insightful comments.\n"},{"id":"549997","messageId":"20260807135511.1818458-1-christian.couder@gmail.com","threadId":"65969","inReplyTo":"20260710085137.4171240-1-christian.couder@gmail.com","subject":"[PATCH 0/5] Introduce 'uploadpack.lazyFetchTrusted'","fromName":"Christian Couder","fromEmail":"christian.couder@gmail.com","sentAt":"2026-08-07T13:55:06Z","receivedAt":"2026-08-07T13:55:30Z","isPatch":true,"body":"Recently the \"promisor-remote\" capability was added to protocol v2,\nallowing servers and clients to agree on the promisor remotes they can\nsafely use.\n\nThe more servers use promisor remotes, the more it is important to\nproperly control if they can lazy fetch when responding to a clone or\nfetch request from the client.\n\nFor example, in the context of large object promisors (see\n\"Documentation/technical/large-object-promisors.adoc\"), if a client\nclones with a filter set to 100kB while the server has moved all of\nthe blobs >= 10kB to a promisor remote, the server will not be able to\nprovide blobs between 10kB and 100kB to the client, which will make\nthe clone fail.\n\nEven if the `--filter=auto` option is available since ef2f1845ec\n(fetch-pack: wire up and enable auto filter logic, 2026-02-16) it's\nstill a good idea to provide more control over lazy fetching on the\nserver side to server operators, as lazy fetching on the server side\ncould be useful in corporate environments.\n\nSince 7b70e9efb1 (upload-pack: disable lazy-fetching by default,\n2024-04-16), lazy fetching has been controlled by the\n`GIT_NO_LAZY_FETCH` environment variable. This is a boolean that is\nset to 'true' by default when calling `git upload-pack` for security\nreasons.\n\nThe main security issue on the server side is making sure the served\nrepo itself is also trusted, as lazily fetching runs `git fetch`,\nwhich may execute arbitrary commands specified in the configuration\nand hooks of the served repo. The operator of the server should decide\nand mark that trust, not the served repo itself, nor the client.\n\nThis series introduces a new 'uploadpack.lazyFetchTrusted' protected\nconfiguration variable similar to 'safe.directory' (see\n\"Documentation/config/safe.adoc\") to mark trusted repos where lazy\nfetching is allowed. As it is protected, this config variable will\nonly take effect if it is set in global or system scope, so only\nserver operators can control it.\n\nPrevious related work\n=====================\n\nA previous series called \"Introduce a 'fromAccepted' option to\nGIT_NO_LAZY_FETCH\" [1] took a different approach as it wanted to make\nit easier to allow lazy fetching from accepted promisor remotes. But\nafter brian replied that he didn't think it was a good idea, and after\nthinking about this more, my opinion now is that some promisor remotes\nbeing accepted or not is not really relevant to the issue.\n\nIn my reply to brian, I said:\n\n\"\"\"\nDifferent features could be developed (in future work) to improve on\nthe current state:\n    - a way for lazy fetching to work without reading config files,\ntriggering hooks, or doing potentially sensitive things,\n    - an explicit way for operators to mark trusted repos (like\nperhaps a server-side config the operator sets per-repo),\n    - operator-defined allow/deny rules, or maybe\n    - some ways/scripts/commands to scan repos and check configuration\ninformation, remote settings and everything potentially sensitive to\ndecide if a repo looks safe enough to allow lazy fetching or not.\n\"\"\"\n\nSo I decided to go with \"an explicit way for operators to mark trusted\nrepos\" and this series is an implementation of that.\n\nNote that the feature developed in this series applies to protocol\nv0/v1 as well as v2 while the previous one was only related to v2.\n\n[1]: https://lore.kernel.org/git/CAP8UFD0_S9eg_w42tcNRnT9E2ntLr_eHLnzE4c2dSu67DzZoXg@mail.gmail.com/\n\nOverview of the patches\n=======================\n\n  - Patch 1/5 is the only patch saved from the \"Introduce a\n    'fromAccepted' option to GIT_NO_LAZY_FETCH\" series. It's not\n    necessary for the rest of this series and its main feature to\n    work, but I think it's a nice refactoring related to lazy\n    fetching, so it might as well be part of this series. There is a\n    small change in the commit message (to not mention following\n    commits) compared to the version in the previous series.\n\n  - Patches 2/5 and 3/5 extract and modify code used by the\n    'safe.directory' config variable in a path_allowlist_apply()\n    function, so that this function can be reused to process\n    'uploadpack.lazyFetchTrusted' in the next patch.\n\n  - Patch 4/5 actually uses path_allowlist_apply() from a new\n    upload_pack_lazy_fetch_trusted() function to process\n    'uploadpack.lazyFetchTrusted', but the result from that processing\n    isn't actually used to have a practical effect.\n\n  - Patch 5/5 wires up the new upload_pack_lazy_fetch_trusted()\n    function to decide if lazy fetching can actually be enabled.\n\nCI tests\n========\n\nThey all pass, see:\n\nhttps://github.com/chriscool/git/actions/runs/31171494296\n\nRange diff with previous series\n===============================\n\nThe range diff with the previous (\"Introduce a 'fromAccepted' option\nto GIT_NO_LAZY_FETCH\") series is not very interesting as only the\nfirst patch has been saved, but anyway here it is:\n\n1:  8dd67ddaca ! 1:  b5b0836d19 promisor-remote: factor out lazy_fetch_objects()\n    @@ Commit message\n         that could not be fetched are promisor objects.\n     \n         Let's refactor the lazy fetching logic out of these two functions\n    -    into a new lazy_fetch_objects() function. This will make it easier\n    -    to extend the lazy fetching logic in following commits.\n    +    into a new lazy_fetch_objects() function.\n     \n         This is a pure refactoring with no intended behavior change. Two\n         things shift in ways that are observably equivalent though:\n2:  314c61cbbe < -:  ---------- promisor-remote: introduce enum allow_lazy_fetch\n3:  cb2f5447e2 < -:  ---------- promisor-remote: teach 'fromAccepted' to GIT_NO_LAZY_FETCH\n-:  ---------- > 2:  879e3a34e3 setup: extract path_allowlist_apply()\n-:  ---------- > 3:  98431ab7b3 setup: add 'allow_dot' arg to path_allowlist_apply()\n-:  ---------- > 4:  a46f4c1bb8 upload-pack: read uploadpack.lazyFetchTrusted\n-:  ---------- > 5:  4063f233aa builtin/upload-pack: set GIT_NO_LAZY_FETCH to 0 on trusted repo\n\n\nChristian Couder (5):\n  promisor-remote: factor out lazy_fetch_objects()\n  setup: extract path_allowlist_apply()\n  setup: add 'allow_dot' arg to path_allowlist_apply()\n  upload-pack: read uploadpack.lazyFetchTrusted\n  builtin/upload-pack: set GIT_NO_LAZY_FETCH to 0 on trusted repo\n\n Documentation/config/uploadpack.adoc  |  42 ++++++++++\n Documentation/git-upload-pack.adoc    |   5 ++\n Documentation/git.adoc                |   4 +-\n builtin/upload-pack.c                 |  11 +++\n promisor-remote.c                     |  76 ++++++++++--------\n setup.c                               | 108 ++++++++++++++------------\n setup.h                               |  28 +++++++\n t/t5710-promisor-remote-capability.sh |  70 +++++++++++++++++\n upload-pack.c                         |  37 +++++++++\n upload-pack.h                         |   3 +\n 10 files changed, 304 insertions(+), 80 deletions(-)\n\n-- \n2.55.0.530.gdb3615d990.dirty\n\n"},{"id":"549999","messageId":"20260807135511.1818458-2-christian.couder@gmail.com","threadId":"65969","inReplyTo":"20260807135511.1818458-1-christian.couder@gmail.com","subject":"[PATCH 1/5] promisor-remote: factor out lazy_fetch_objects()","fromName":"Christian Couder","fromEmail":"christian.couder@gmail.com","sentAt":"2026-08-07T13:55:07Z","receivedAt":"2026-08-07T13:55:32Z","isPatch":true,"body":"In \"promisor-remote.c:fetch_objects()\", there is a check to disable\nlazy fetching when the `GIT_NO_LAZY_FETCH` environment variable is\nset. The fetch_objects() function is called once per promisor remote\nthough. So the check might be performed more times than necessary.\n\nAlso promisor_remote_get_direct() mixes up the logic deciding which\npromisor remotes to try with the logic checking that the objects\nthat could not be fetched are promisor objects.\n\nLet's refactor the lazy fetching logic out of these two functions\ninto a new lazy_fetch_objects() function.\n\nThis is a pure refactoring with no intended behavior change. Two\nthings shift in ways that are observably equivalent though:\n\n  - the `GIT_NO_LAZY_FETCH` check is now performed once up front,\n    instead of once per promisor remote, and\n\n  - promisor_remote_init() is no longer called when lazy fetching\n    is disabled, which is fine as nothing downstream of it, like\n    is_promisor_object(), needs it in that case.\n\nWhile at it, let's also convert try_promisor_remotes() to return\n'bool' instead of 'int', as it just returns whether all the objects\ncould be fetched, and document its return value.\n\nSigned-off-by: Christian Couder <chriscool@tuxfamily.org>\n---\n promisor-remote.c | 76 ++++++++++++++++++++++++++++-------------------\n 1 file changed, 45 insertions(+), 31 deletions(-)\n\ndiff --git a/promisor-remote.c b/promisor-remote.c\nindex 43505d1e1a..65496c69cf 100644\n--- a/promisor-remote.c\n+++ b/promisor-remote.c\n@@ -31,15 +31,6 @@ static int fetch_objects(struct repository *repo,\n \tFILE *child_in;\n \tint quiet;\n \n-\tif (git_env_bool(NO_LAZY_FETCH_ENVIRONMENT, 0)) {\n-\t\tstatic int warning_shown;\n-\t\tif (!warning_shown) {\n-\t\t\twarning_shown = 1;\n-\t\t\twarning(_(\"lazy fetching disabled; some objects may not be available\"));\n-\t\t}\n-\t\treturn -1;\n-\t}\n-\n \tchild.git_cmd = 1;\n \tchild.in = -1;\n \tif (repo != the_repository)\n@@ -270,10 +261,15 @@ static int remove_fetched_oids(struct repository *repo,\n \treturn remaining_nr;\n }\n \n-static int try_promisor_remotes(struct repository *repo,\n-\t\t\t\tstruct object_id **remaining_oids,\n-\t\t\t\tint *remaining_nr, int *to_free,\n-\t\t\t\tbool accepted_only)\n+/*\n+ * Return 'true' if all the objects could be fetched from the\n+ * (non-)accepted remotes, 'false' otherwise.\n+ */\n+static bool try_promisor_remotes(struct repository *repo,\n+\t\t\t\t struct object_id **remaining_oids,\n+\t\t\t\t int *remaining_nr,\n+\t\t\t\t int *to_free,\n+\t\t\t\t bool accepted_only)\n {\n \tstruct promisor_remote *r = repo->promisor_remote_config->promisors;\n \n@@ -290,9 +286,37 @@ static int try_promisor_remotes(struct repository *repo,\n \t\t\t\tcontinue;\n \t\t\t}\n \t\t}\n-\t\treturn 1; /* all fetched */\n+\t\treturn true; /* all fetched */\n \t}\n-\treturn 0;\n+\treturn false;\n+}\n+\n+/*\n+ * Return 'true' if all the objects could be fetched, 'false' otherwise.\n+ */\n+static bool lazy_fetch_objects(struct repository *repo,\n+\t\t\t       struct object_id **remaining_oids,\n+\t\t\t       int *remaining_nr,\n+\t\t\t       int *to_free)\n+{\n+\tif (git_env_bool(NO_LAZY_FETCH_ENVIRONMENT, 0)) {\n+\t\tstatic int warning_shown;\n+\t\tif (!warning_shown) {\n+\t\t\twarning_shown = 1;\n+\t\t\twarning(_(\"lazy fetching disabled; some objects may not be available\"));\n+\t\t}\n+\t\treturn false;\n+\t}\n+\n+\tpromisor_remote_init(repo);\n+\n+\t/* Try accepted remotes first (those the server told us to use) */\n+\tif (try_promisor_remotes(repo, remaining_oids, remaining_nr,\n+\t\t\t\t to_free, true))\n+\t\treturn true;\n+\n+\treturn try_promisor_remotes(repo, remaining_oids, remaining_nr,\n+\t\t\t\t    to_free, false);\n }\n \n void promisor_remote_get_direct(struct repository *repo,\n@@ -302,28 +326,18 @@ void promisor_remote_get_direct(struct repository *repo,\n \tstruct object_id *remaining_oids = (struct object_id *)oids;\n \tint remaining_nr = oid_nr;\n \tint to_free = 0;\n-\tint i;\n \n \tif (oid_nr == 0)\n \t\treturn;\n \n-\tpromisor_remote_init(repo);\n-\n-\t/* Try accepted remotes first (those the server told us to use) */\n-\tif (try_promisor_remotes(repo, &remaining_oids, &remaining_nr,\n-\t\t\t\t &to_free, true))\n-\t\tgoto all_fetched;\n-\tif (try_promisor_remotes(repo, &remaining_oids, &remaining_nr,\n-\t\t\t\t &to_free, false))\n-\t\tgoto all_fetched;\n-\n-\tfor (i = 0; i < remaining_nr; i++) {\n-\t\tif (is_promisor_object(repo, &remaining_oids[i]))\n-\t\t\tdie(_(\"could not fetch %s from promisor remote\"),\n-\t\t\t    oid_to_hex(&remaining_oids[i]));\n+\tif (!lazy_fetch_objects(repo, &remaining_oids, &remaining_nr, &to_free)) {\n+\t\tfor (int i = 0; i < remaining_nr; i++) {\n+\t\t\tif (is_promisor_object(repo, &remaining_oids[i]))\n+\t\t\t\tdie(_(\"could not fetch %s from promisor remote\"),\n+\t\t\t\t    oid_to_hex(&remaining_oids[i]));\n+\t\t}\n \t}\n \n-all_fetched:\n \tif (to_free)\n \t\tfree(remaining_oids);\n }\n-- \n2.55.0.530.gdb3615d990.dirty\n\n"},{"id":"549998","messageId":"20260807135511.1818458-3-christian.couder@gmail.com","threadId":"65969","inReplyTo":"20260807135511.1818458-1-christian.couder@gmail.com","subject":"[PATCH 2/5] setup: extract path_allowlist_apply()","fromName":"Christian Couder","fromEmail":"christian.couder@gmail.com","sentAt":"2026-08-07T13:55:08Z","receivedAt":"2026-08-07T13:55:33Z","isPatch":true,"body":"In a following commit we are going to check whether a repository is\npart of an allowlist specified in a config variable.\n\nTo prepare for that let's extract existing code from\nsafe_directory_cb() into a new path_allowlist_apply() helper that will\nhelp with such checks.\n\nWhile at it let's make the helper's code simpler and more generic.\n\nSigned-off-by: Christian Couder <chriscool@tuxfamily.org>\n---\n setup.c | 107 +++++++++++++++++++++++++++++++-------------------------\n 1 file changed, 59 insertions(+), 48 deletions(-)\n\ndiff --git a/setup.c b/setup.c\nindex 95909e9603..39dfa1cc5f 100644\n--- a/setup.c\n+++ b/setup.c\n@@ -1339,6 +1339,64 @@ static int canonicalize_ceiling_entry(struct string_list_item *item,\n \t}\n }\n \n+static void path_allowlist_apply(const char *key, const char *value,\n+\t\t\t\t const char *target_path, int *is_match)\n+{\n+\tchar *allowed = NULL;\n+\tchar *normalized = NULL;\n+\n+\tif (!value || !*value) {\n+\t\t*is_match = 0;\n+\t\treturn;\n+\t}\n+\n+\tif (!strcmp(value, \"*\")) {\n+\t\t*is_match = 1;\n+\t\treturn;\n+\t}\n+\n+\tif (git_config_pathname(&allowed, key, value) || !allowed)\n+\t\treturn;\n+\n+\t/*\n+\t * Setting the config variable to a non-absolute path makes\n+\t * little sense---it won't be relative to the configuration\n+\t * file the item is defined in.  Except for \".\", which means\n+\t * \"if we are at the top level of a repository, then it is\n+\t * OK\", which is slightly tighter than \"*\" that allows\n+\t * discovery.\n+\t */\n+\tif (!is_absolute_path(allowed) && strcmp(allowed, \".\")) {\n+\t\twarning(_(\"%s '%s' not absolute\"), key, allowed);\n+\t\tgoto end;\n+\t}\n+\n+\t/*\n+\t * A .gitconfig in $HOME may be shared across different\n+\t * machines and the config variable entries may or may not\n+\t * exist as paths on all of these machines.  In other words,\n+\t * it is not a warning worthy event when there is no such path\n+\t * on this machine---the entry may be useful elsewhere.\n+\t */\n+\tnormalized = real_pathdup(allowed, 0);\n+\tif (!normalized)\n+\t\tgoto end;\n+\n+\tif (ends_with(normalized, \"/*\")) {\n+\t\tsize_t len = strlen(normalized);\n+\t\tif (!fspathncmp(normalized, target_path, len - 1))\n+\t\t\t*is_match = 1;\n+\t\tgoto end;\n+\t}\n+\n+\tif (!fspathcmp(target_path, normalized))\n+\t\t*is_match = 1;\n+\n+end:\n+\tfree(normalized);\n+\tfree(allowed);\n+}\n+\n struct safe_directory_data {\n \tchar *path;\n \tint is_safe;\n@@ -1352,54 +1410,7 @@ static int safe_directory_cb(const char *key, const char *value,\n \tif (strcmp(key, \"safe.directory\"))\n \t\treturn 0;\n \n-\tif (!value || !*value) {\n-\t\tdata->is_safe = 0;\n-\t} else if (!strcmp(value, \"*\")) {\n-\t\tdata->is_safe = 1;\n-\t} else {\n-\t\tchar *allowed = NULL;\n-\n-\t\tif (!git_config_pathname(&allowed, key, value) && allowed) {\n-\t\t\tchar *normalized = NULL;\n-\n-\t\t\t/*\n-\t\t\t * Setting safe.directory to a non-absolute path\n-\t\t\t * makes little sense---it won't be relative to\n-\t\t\t * the configuration file the item is defined in.\n-\t\t\t * Except for \".\", which means \"if we are at the top\n-\t\t\t * level of a repository, then it is OK\", which is\n-\t\t\t * slightly tighter than \"*\" that allows discovery.\n-\t\t\t */\n-\t\t\tif (!is_absolute_path(allowed) && strcmp(allowed, \".\")) {\n-\t\t\t\twarning(_(\"safe.directory '%s' not absolute\"),\n-\t\t\t\t\tallowed);\n-\t\t\t\tgoto next;\n-\t\t\t}\n-\n-\t\t\t/*\n-\t\t\t * A .gitconfig in $HOME may be shared across\n-\t\t\t * different machines and safe.directory entries\n-\t\t\t * may or may not exist as paths on all of these\n-\t\t\t * machines.  In other words, it is not a warning\n-\t\t\t * worthy event when there is no such path on this\n-\t\t\t * machine---the entry may be useful elsewhere.\n-\t\t\t */\n-\t\t\tnormalized = real_pathdup(allowed, 0);\n-\t\t\tif (!normalized)\n-\t\t\t\tgoto next;\n-\n-\t\t\tif (ends_with(normalized, \"/*\")) {\n-\t\t\t\tsize_t len = strlen(normalized);\n-\t\t\t\tif (!fspathncmp(normalized, data->path, len - 1))\n-\t\t\t\t\tdata->is_safe = 1;\n-\t\t\t} else if (!fspathcmp(data->path, normalized)) {\n-\t\t\t\tdata->is_safe = 1;\n-\t\t\t}\n-\t\tnext:\n-\t\t\tfree(normalized);\n-\t\t\tfree(allowed);\n-\t\t}\n-\t}\n+\tpath_allowlist_apply(key, value, data->path, &data->is_safe);\n \n \treturn 0;\n }\n-- \n2.55.0.530.gdb3615d990.dirty\n\n"},{"id":"550001","messageId":"20260807135511.1818458-5-christian.couder@gmail.com","threadId":"65969","inReplyTo":"20260807135511.1818458-1-christian.couder@gmail.com","subject":"[PATCH 4/5] upload-pack: read uploadpack.lazyFetchTrusted","fromName":"Christian Couder","fromEmail":"christian.couder@gmail.com","sentAt":"2026-08-07T13:55:10Z","receivedAt":"2026-08-07T13:55:36Z","isPatch":true,"body":"Previous commits created and prepared the path_allowlist_apply()\nfunction.\n\nLet's reuse this function for a new \"uploadpack.lazyFetchTrusted\"\nconfiguration variable.\n\nIt allows us to:\n\n  - read an allowlist from that config variable,\n  - check if the current repo is in that list, and\n  - return the result from a new upload_pack_lazy_fetch_trusted()\n    function.\n\nThe new function will be used in a following commit.\n\nNote that the new config variable should be read only from protected\nconfiguration files.\n\nSigned-off-by: Christian Couder <chriscool@tuxfamily.org>\n---\n upload-pack.c | 37 +++++++++++++++++++++++++++++++++++++\n upload-pack.h |  3 +++\n 2 files changed, 40 insertions(+)\n\ndiff --git a/upload-pack.c b/upload-pack.c\nindex a52856d869..29e700e43b 100644\n--- a/upload-pack.c\n+++ b/upload-pack.c\n@@ -34,6 +34,8 @@\n #include \"json-writer.h\"\n #include \"strmap.h\"\n #include \"promisor-remote.h\"\n+#include \"setup.h\"\n+#include \"abspath.h\"\n \n /* Remember to update object flag allocation in object.h */\n #define THEY_HAVE\t(1u << 11)\n@@ -1378,6 +1380,41 @@ static int upload_pack_config(const char *var, const char *value,\n \treturn parse_hide_refs_config(var, value, \"uploadpack\", &data->hidden_refs);\n }\n \n+struct lazy_fetch_trusted {\n+\tint trusted;\n+\tchar *repo_path;\n+};\n+\n+static int upload_pack_protected_lazy_fetch_config(const char *var, const char *value,\n+\t\t\t\t\t\t   const struct config_context *ctx UNUSED,\n+\t\t\t\t\t\t   void *cb_data)\n+{\n+\tstruct lazy_fetch_trusted *data = cb_data;\n+\n+\tif (!strcmp(\"uploadpack.lazyfetchtrusted\", var)) {\n+\t\tpath_allowlist_apply(var, value, data->repo_path,\n+\t\t\t\t     &data->trusted, false);\n+\t\treturn 0;\n+\t}\n+\n+\treturn 0;\n+}\n+\n+bool upload_pack_lazy_fetch_trusted(struct repository *r)\n+{\n+\tstruct lazy_fetch_trusted data = { 0 };\n+\n+\tdata.repo_path = real_pathdup(r->worktree ? r->worktree : r->gitdir, 0);\n+\tif (!data.repo_path)\n+\t\treturn false;\n+\n+\tgit_protected_config(upload_pack_protected_lazy_fetch_config, &data);\n+\n+\tfree(data.repo_path);\n+\n+\treturn !!data.trusted;\n+}\n+\n static int upload_pack_protected_config(const char *var, const char *value,\n \t\t\t\t\tconst struct config_context *ctx UNUSED,\n \t\t\t\t\tvoid *cb_data)\ndiff --git a/upload-pack.h b/upload-pack.h\nindex d6ee25ea98..b2212992c3 100644\n--- a/upload-pack.h\n+++ b/upload-pack.h\n@@ -12,4 +12,7 @@ struct strbuf;\n int upload_pack_advertise(struct repository *r,\n \t\t\t  struct strbuf *value);\n \n+/* Is this repo trusted for lazy fetching? */\n+bool upload_pack_lazy_fetch_trusted(struct repository *r);\n+\n #endif /* UPLOAD_PACK_H */\n-- \n2.55.0.530.gdb3615d990.dirty\n\n"},{"id":"550000","messageId":"20260807135511.1818458-6-christian.couder@gmail.com","threadId":"65969","inReplyTo":"20260807135511.1818458-1-christian.couder@gmail.com","subject":"[PATCH 5/5] builtin/upload-pack: set GIT_NO_LAZY_FETCH to 0 on trusted repo","fromName":"Christian Couder","fromEmail":"christian.couder@gmail.com","sentAt":"2026-08-07T13:55:11Z","receivedAt":"2026-08-07T13:55:37Z","isPatch":true,"body":"A previous commit added a new \"uploadpack.lazyFetchTrusted\" protected\nconfig variable that can contain an allowlist of repos, as well as\nfunctions to check if the current repo is in that list. But when the\ncurrent repo is in that list, we currently do nothing.\n\nLet's instead set `GIT_NO_LAZY_FETCH` to `0`, which allows\n`upload-pack` and its `pack-objects` child process to lazily fetch the\nobjects they need to serve a client, for example when the filter used\nby the client and the one used by the server don't match.\n\nThis allows server operators to properly control lazy fetching. It is\ntheir responsibility, not the client's, to decide if the served repo is\ntrusted, as the main security issue is that lazily fetching runs `git\nfetch`, which may execute arbitrary commands specified in the\nconfiguration and hooks of the served repo.\n\nAs `GIT_NO_LAZY_FETCH` is passed down to child processes through the\nenvironment, this works for `pack-objects`, which performs the lazy\nfetch when serving a client, without any further plumbing.\n\nNow that \"uploadpack.lazyFetchTrusted\" is actually doing something,\nlet's document it and reference it from GIT_NO_LAZY_FETCH's docs.\n\nSigned-off-by: Christian Couder <chriscool@tuxfamily.org>\n---\n Documentation/config/uploadpack.adoc  | 42 ++++++++++++++++\n Documentation/git-upload-pack.adoc    |  5 ++\n Documentation/git.adoc                |  4 +-\n builtin/upload-pack.c                 | 11 +++++\n t/t5710-promisor-remote-capability.sh | 70 +++++++++++++++++++++++++++\n 5 files changed, 131 insertions(+), 1 deletion(-)\n\ndiff --git a/Documentation/config/uploadpack.adoc b/Documentation/config/uploadpack.adoc\nindex 0e1dda944a..e960879c16 100644\n--- a/Documentation/config/uploadpack.adoc\n+++ b/Documentation/config/uploadpack.adoc\n@@ -86,3 +86,45 @@ uploadpack.allowRefInWant::\n \tis intended for the benefit of load-balanced servers which may\n \tnot have the same view of what OIDs their refs point to due to\n \treplication delay.\n+\n+uploadpack.lazyFetchTrusted::\n+\tThese config entries specify repositories that `upload-pack` is\n+\tallowed to lazily fetch missing objects for. By default,\n+\t`upload-pack` refuses to lazily fetch (see the description of the\n+\t`GIT_NO_LAZY_FETCH` environment variable in\n+\tlinkgit:git-upload-pack[1]), because doing so would run `git fetch`,\n+\twhich may execute arbitrary commands specified in the configuration\n+\tand hooks of the served repository. Listing a repository here tells\n+\t`upload-pack` that it is trusted, so lazy fetching from the promisor\n+\tremotes configured in it is allowed. This is equivalent to setting\n+\t`GIT_NO_LAZY_FETCH` to `0` for the matching repositories. An\n+\texplicitly set `GIT_NO_LAZY_FETCH` takes precedence over this\n+\tsetting.\n++\n+Note that this allows lazy fetching from any promisor remote\n+configured in the served repository, not only from the promisor\n+remotes that the client accepted using the \"promisor-remote\" protocol\n+v2 capability (see linkgit:gitprotocol-v2[5]). The served repository\n+is trusted as a whole, including its configuration, so the promisor\n+remotes it configures are trusted too. It is the server operator's\n+responsibility to make sure that the promisor remotes of a trusted\n+repository are also trustworthy.\n++\n+This is a multi-valued setting, i.e. you can add more than one\n+repository via `git config (--global|--system) --add`. To reset the\n+list of trusted repositories (e.g. to override any such repositories\n+specified in the system config), add a `uploadpack.lazyFetchTrusted`\n+entry with an empty value.\n++\n+A repository is identified by its worktree, or its git directory for a bare\n+repository, and the value must be an absolute path. Giving a path with `/*`\n+appended to it will trust all repositories under the named directory. To trust\n+all served repositories, set `uploadpack.lazyFetchTrusted` to the string `*`.\n++\n+The value of this setting is interpolated, i.e. `~/<path>` expands to a\n+path relative to the home directory and `%(prefix)/<path>` expands to a\n+path relative to Git's (runtime) prefix.\n++\n+Note that this configuration variable is only respected when it is specified\n+in protected configuration (see <<SCOPES>>). This prevents untrusted\n+repositories from tampering with this value.\ndiff --git a/Documentation/git-upload-pack.adoc b/Documentation/git-upload-pack.adoc\nindex 9167a321d0..90c2ba1194 100644\n--- a/Documentation/git-upload-pack.adoc\n+++ b/Documentation/git-upload-pack.adoc\n@@ -71,6 +71,11 @@ This is implemented by having `upload-pack` internally set the\n (because you are fetching from a partial clone, and you are sure\n you trust it), you can explicitly set `GIT_NO_LAZY_FETCH` to\n `0`.\n++\n+Instead of setting `GIT_NO_LAZY_FETCH` to `0` in the environment, a\n+server operator can allow lazy fetching on a per-repository basis by\n+listing trusted repositories in the `uploadpack.lazyFetchTrusted`\n+configuration variable. See linkgit:git-config[1].\n \n SECURITY\n --------\ndiff --git a/Documentation/git.adoc b/Documentation/git.adoc\nindex 8a5cdd3b3d..2e763d1f93 100644\n--- a/Documentation/git.adoc\n+++ b/Documentation/git.adoc\n@@ -949,7 +949,9 @@ for full details.\n `GIT_NO_LAZY_FETCH`::\n \tSetting this Boolean environment variable to true tells Git\n \tnot to lazily fetch missing objects from the promisor remote\n-\ton demand.\n+\ton demand. On the server side, the `uploadpack.lazyFetchTrusted`\n+\tconfiguration variable can control this per-repository. See\n+\tlinkgit:git-upload-pack[1].\n \n `GIT_REFLOG_ACTION`::\n \tWhen a ref is updated, reflog entries are created to keep\ndiff --git a/builtin/upload-pack.c b/builtin/upload-pack.c\nindex 32831fb879..8b531ca724 100644\n--- a/builtin/upload-pack.c\n+++ b/builtin/upload-pack.c\n@@ -42,10 +42,13 @@ int cmd_upload_pack(int argc,\n \t\tOPT_END()\n \t};\n \tunsigned enter_repo_flags = ENTER_REPO_ANY_OWNER_OK;\n+\tbool no_lazy_fetch_set;\n \n \tpacket_trace_identity(\"upload-pack\");\n \tdisable_replace_refs();\n \tsave_commit_buffer = 0;\n+\n+\tno_lazy_fetch_set = !!getenv(NO_LAZY_FETCH_ENVIRONMENT);\n \txsetenv(NO_LAZY_FETCH_ENVIRONMENT, \"1\", 0);\n \n \targc = parse_options(argc, argv, prefix, options, upload_pack_usage, 0);\n@@ -62,6 +65,14 @@ int cmd_upload_pack(int argc,\n \tif (!enter_repo(the_repository, dir, enter_repo_flags))\n \t\tdie(\"'%s' does not appear to be a git repository\", dir);\n \n+\t/*\n+\t * Relax the GIT_NO_LAZY_FETCH=1 default if the served repo is in\n+\t * the \"uploadpack.lazyFetchTrusted\" protected allowlist and\n+\t * GIT_NO_LAZY_FETCH was not already set explicitly.\n+\t */\n+\tif (!no_lazy_fetch_set && upload_pack_lazy_fetch_trusted(the_repository))\n+\t\txsetenv(NO_LAZY_FETCH_ENVIRONMENT, \"0\", 1);\n+\n \tswitch (determine_protocol_version_server()) {\n \tcase protocol_v2:\n \t\tif (advertise_refs)\ndiff --git a/t/t5710-promisor-remote-capability.sh b/t/t5710-promisor-remote-capability.sh\nindex 549acff23f..e6993f2761 100755\n--- a/t/t5710-promisor-remote-capability.sh\n+++ b/t/t5710-promisor-remote-capability.sh\n@@ -173,6 +173,76 @@ test_expect_success \"clone with promisor.acceptfromserver set to 'None'\" '\n \tinitialize_server 1 \"$oid\"\n '\n \n+test_expect_success \"clone with uploadpack.lazyFetchTrusted\" '\n+\t# No promisors are advertised\n+\tgit -C server config promisor.advertise false &&\n+\ttest_when_finished \"rm -rf client\" &&\n+\n+\t# The served repo is trusted for lazy fetching\n+\ttest_config_global uploadpack.lazyFetchTrusted \"$(pwd)/server\" &&\n+\n+\t# Clone without GIT_NO_LAZY_FETCH=0\n+\tgit clone --no-local --filter=\"blob:limit=5k\" server client &&\n+\n+\t# Check that the largest object is not missing on the server\n+\t# This means the server lazy fetched it\n+\tcheck_missing_objects server 0 \"\" &&\n+\n+\t# Reinitialize server so that the largest object is missing again\n+\tinitialize_server 1 \"$oid\"\n+'\n+\n+test_expect_success \"clone without uploadpack.lazyFetchTrusted fails\" '\n+\t# No promisors are advertised\n+\tgit -C server config promisor.advertise false &&\n+\ttest_when_finished \"rm -rf client\" &&\n+\n+\t# Note: no uploadpack.lazyFetchTrusted config is set here, so\n+\t# the served repo is NOT trusted for lazy fetching.\n+\n+\t# Clone without GIT_NO_LAZY_FETCH=0 fails\n+\ttest_must_fail git clone --no-local --filter=\"blob:limit=5k\" server client 2>err &&\n+\ttest_grep \"lazy fetching disabled\" err &&\n+\n+\t# Check that the largest object is still missing on the server\n+\tcheck_missing_objects server 1 \"$oid\"\n+'\n+\n+test_expect_success \"uploadpack.lazyFetchTrusted is ignored in repo config\" '\n+\t# No promisors are advertised\n+\tgit -C server config promisor.advertise false &&\n+\ttest_when_finished \"rm -rf client\" &&\n+\n+\t# The served repo is trusted for lazy fetching, but this is\n+\t# done in the repo config, not in protected config, so this is\n+\t# ignored.\n+\ttest_config -C server uploadpack.lazyFetchTrusted \"$(pwd)/server\" &&\n+\n+\t# Clone without GIT_NO_LAZY_FETCH=0 fails\n+\ttest_must_fail git clone --no-local --filter=\"blob:limit=5k\" server client 2>err &&\n+\ttest_grep \"lazy fetching disabled\" err &&\n+\n+\t# Check that the largest object is still missing on the server\n+\tcheck_missing_objects server 1 \"$oid\"\n+'\n+\n+test_expect_success \"explicit GIT_NO_LAZY_FETCH overrides uploadpack.lazyFetchTrusted\" '\n+\t# No promisors are advertised\n+\tgit -C server config promisor.advertise false &&\n+\ttest_when_finished \"rm -rf client\" &&\n+\n+\t# The served repo is trusted for lazy fetching\n+\ttest_config_global uploadpack.lazyFetchTrusted \"$(pwd)/server\" &&\n+\n+\t# But GIT_NO_LAZY_FETCH=1 disables lazy fetching, so clone fails\n+\ttest_must_fail env GIT_NO_LAZY_FETCH=1 git clone --no-local \\\n+\t\t--filter=\"blob:limit=5k\" server client 2>err &&\n+\ttest_grep \"lazy fetching disabled\" err &&\n+\n+\t# Check that the largest object is still missing on the server\n+\tcheck_missing_objects server 1 \"$oid\"\n+'\n+\n test_expect_success \"init + fetch with promisor.advertise set to 'true'\" '\n \tgit -C server config promisor.advertise true &&\n \ttest_when_finished \"rm -rf client\" &&\n-- \n2.55.0.530.gdb3615d990.dirty\n\n"},{"id":"550002","messageId":"20260807135511.1818458-4-christian.couder@gmail.com","threadId":"65969","inReplyTo":"20260807135511.1818458-1-christian.couder@gmail.com","subject":"[PATCH 3/5] setup: add 'allow_dot' arg to path_allowlist_apply()","fromName":"Christian Couder","fromEmail":"christian.couder@gmail.com","sentAt":"2026-08-07T13:55:09Z","receivedAt":"2026-08-07T13:55:37Z","isPatch":true,"body":"A previous commit created path_allowlist_apply() with the goal of later\nreusing that function. But when it will be reused in a following commit\nthis function will need to reject non-absolute paths including those\nwith a single dot that are currently accepted.\n\nTo prepare for reusing path_allowlist_apply(), let's add a\n`bool allow_dot` argument to it, and let's export this function.\n\nWhile at it let's document it properly in \"setup.h\".\n\nSigned-off-by: Christian Couder <chriscool@tuxfamily.org>\n---\n setup.c |  9 +++++----\n setup.h | 28 ++++++++++++++++++++++++++++\n 2 files changed, 33 insertions(+), 4 deletions(-)\n\ndiff --git a/setup.c b/setup.c\nindex 39dfa1cc5f..a09e697e3a 100644\n--- a/setup.c\n+++ b/setup.c\n@@ -1339,8 +1339,9 @@ static int canonicalize_ceiling_entry(struct string_list_item *item,\n \t}\n }\n \n-static void path_allowlist_apply(const char *key, const char *value,\n-\t\t\t\t const char *target_path, int *is_match)\n+void path_allowlist_apply(const char *key, const char *value,\n+\t\t\t  const char *target_path, int *is_match,\n+\t\t\t  bool allow_dot)\n {\n \tchar *allowed = NULL;\n \tchar *normalized = NULL;\n@@ -1366,7 +1367,7 @@ static void path_allowlist_apply(const char *key, const char *value,\n \t * OK\", which is slightly tighter than \"*\" that allows\n \t * discovery.\n \t */\n-\tif (!is_absolute_path(allowed) && strcmp(allowed, \".\")) {\n+\tif (!is_absolute_path(allowed) && (!allow_dot || strcmp(allowed, \".\"))) {\n \t\twarning(_(\"%s '%s' not absolute\"), key, allowed);\n \t\tgoto end;\n \t}\n@@ -1410,7 +1411,7 @@ static int safe_directory_cb(const char *key, const char *value,\n \tif (strcmp(key, \"safe.directory\"))\n \t\treturn 0;\n \n-\tpath_allowlist_apply(key, value, data->path, &data->is_safe);\n+\tpath_allowlist_apply(key, value, data->path, &data->is_safe, true);\n \n \treturn 0;\n }\ndiff --git a/setup.h b/setup.h\nindex 654f10e059..d4f8af5457 100644\n--- a/setup.h\n+++ b/setup.h\n@@ -304,4 +304,32 @@ struct startup_info {\n extern struct startup_info *startup_info;\n extern const char *tmp_original_cwd;\n \n+/*\n+ * Apply the path allowlist in 'value' against 'target_path' setting\n+ * '*is_match' accordingly.\n+ *\n+ * `value` is the value of a multi-valued config variable named `key`\n+ * that holds an allowlist of paths. `target_path` is the (normalized)\n+ * path being tested. `*is_match` is updated in place:\n+ *\n+ *   - an empty value resets it to 0 (so a later, more specific config\n+ *     scope can clear entries from a broader one),\n+ *   - \"*\" sets it to 1 (allow everything),\n+ *   - \"<path>\" sets it to 1 if <path> equals `target_path`,\n+ *   - \"<path>\" + \"/\" + \"*\" sets it to 1 if <path> is a leading\n+ *     directory of `target_path`,\n+ *   - any other (unmatching) value leaves `*is_match` unchanged.\n+ *\n+ * Non-absolute values are rejected with a warning, except \".\" when\n+ * `allow_dot` is set (used by 'safe.directory' to mean \"the top level\n+ * of the current repository\").\n+ *\n+ * Callers are expected to invoke this once per config value,\n+ * typically from a protected-config callback, so that untrusted\n+ * repository config cannot influence the decision.\n+ */\n+void path_allowlist_apply(const char *key, const char *value,\n+\t\t\t  const char *target_path, int *is_match,\n+\t\t\t  bool allow_dot);\n+\n #endif /* SETUP_H */\n-- \n2.55.0.530.gdb3615d990.dirty\n\n"},{"id":"550003","messageId":"CAP8UFD3txHujpg_NxZN9m4VbH2Yp5g38ZV3=HrXrDrFSHLpaQg@mail.gmail.com","threadId":"65969","inReplyTo":"20260807135511.1818458-2-christian.couder@gmail.com","subject":"Re: [PATCH 1/5] promisor-remote: factor out lazy_fetch_objects()","fromName":"Christian Couder","fromEmail":"christian.couder@gmail.com","sentAt":"2026-08-07T13:58:59Z","receivedAt":"2026-08-07T13:59:12Z","isPatch":true,"body":"On Fri, Aug 7, 2026 at 3:55 PM Christian Couder\n<christian.couder@gmail.com> wrote:\n\n[...]\n\n> Signed-off-by: Christian Couder <chriscool@tuxfamily.org>\n\nSorry I just realized that there is the wrong sign-off email address\nagain. Will fix it in v2.\n"},{"id":"550045","messageId":"xmqqjyq1eqah.fsf@gitster.g","threadId":"65969","inReplyTo":"20260807135511.1818458-1-christian.couder@gmail.com","subject":"Re: [PATCH 0/5] Introduce 'uploadpack.lazyFetchTrusted'","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-08-07T18:31:02Z","receivedAt":"2026-08-07T18:31:05Z","isPatch":true,"body":"Christian Couder <christian.couder@gmail.com> writes:\n\n> Range diff with previous series\n> ===============================\n>\n> The range diff with the previous (\"Introduce a 'fromAccepted' option\n> to GIT_NO_LAZY_FETCH\") series is not very interesting as only the\n> first patch has been saved, but anyway here it is:\n>\n> 1:  8dd67ddaca ! 1:  b5b0836d19 promisor-remote: factor out lazy_fetch_objects()\n>     @@ Commit message\n>          that could not be fetched are promisor objects.\n>      \n>          Let's refactor the lazy fetching logic out of these two functions\n>     -    into a new lazy_fetch_objects() function. This will make it easier\n>     -    to extend the lazy fetching logic in following commits.\n>     +    into a new lazy_fetch_objects() function.\n>      \n>          This is a pure refactoring with no intended behavior change. Two\n>          things shift in ways that are observably equivalent though:\n> 2:  314c61cbbe < -:  ---------- promisor-remote: introduce enum allow_lazy_fetch\n> 3:  cb2f5447e2 < -:  ---------- promisor-remote: teach 'fromAccepted' to GIT_NO_LAZY_FETCH\n> -:  ---------- > 2:  879e3a34e3 setup: extract path_allowlist_apply()\n> -:  ---------- > 3:  98431ab7b3 setup: add 'allow_dot' arg to path_allowlist_apply()\n> -:  ---------- > 4:  a46f4c1bb8 upload-pack: read uploadpack.lazyFetchTrusted\n> -:  ---------- > 5:  4063f233aa builtin/upload-pack: set GIT_NO_LAZY_FETCH to 0 on trusted repo\n>\n>\n> Christian Couder (5):\n>   promisor-remote: factor out lazy_fetch_objects()\n>   setup: extract path_allowlist_apply()\n>   setup: add 'allow_dot' arg to path_allowlist_apply()\n>   upload-pack: read uploadpack.lazyFetchTrusted\n>   builtin/upload-pack: set GIT_NO_LAZY_FETCH to 0 on trusted repo\n>\n>  Documentation/config/uploadpack.adoc  |  42 ++++++++++\n>  Documentation/git-upload-pack.adoc    |   5 ++\n>  Documentation/git.adoc                |   4 +-\n>  builtin/upload-pack.c                 |  11 +++\n>  promisor-remote.c                     |  76 ++++++++++--------\n>  setup.c                               | 108 ++++++++++++++------------\n>  setup.h                               |  28 +++++++\n>  t/t5710-promisor-remote-capability.sh |  70 +++++++++++++++++\n>  upload-pack.c                         |  37 +++++++++\n>  upload-pack.h                         |   3 +\n>  10 files changed, 304 insertions(+), 80 deletions(-)\n\nWhat's missing is the information on the base.  I tried applying\nthese patches to 'v2.55.0' and the recent tips of 'master':\n\n    2c78326f81 The 11th batch\n    5b2471720c The 10th batch\n    a97fcc37c2 The 9th batch\n    13c7afec21 The 8th batch\n    9a0c4701dc The 7th batch\n    5d2e770923 The 6th batch\n    48bbf81c29 The 5th batch\n    41365c2a9b The 4th batch for Git 2.56\n    d35c5399e3 The 3rd batch for Git 2.56\n    55526a1826 The 2nd batch for Git 2.56\n\nbut the series did not apply to any of them.\n\nIt turns out the reason has nothing to do with your choice of\nbase.  It is because the series structure is not understood by 'b4'.\n\nThe cover letter I am responding to is a reply to another series,\nbut the patches in this round are not marked as 'v2'.  This seems\nto cause 'b4' to grab patches from both series and smash them\ntogether, resulting in an inapplicable mess.  It seems you cannot\nhave your cake and eat it, too 😠.\n\nNext time, please do not thread the two topics together unless you\nare marking the newer iteration with a higher 'vN' number.\n\nThanks.\n"},{"id":"550161","messageId":"CAP8UFD1LxM1s-MJuffhVks6JfBXoMzKii4YU4iQRNzXJZCQkfQ@mail.gmail.com","threadId":"65969","inReplyTo":"xmqqjyq1eqah.fsf@gitster.g","subject":"Re: [PATCH 0/5] Introduce 'uploadpack.lazyFetchTrusted'","fromName":"Christian Couder","fromEmail":"christian.couder@gmail.com","sentAt":"2026-08-10T08:06:21Z","receivedAt":"2026-08-10T08:06:33Z","isPatch":true,"body":"On Fri, Aug 7, 2026 at 8:31 PM Junio C Hamano <gitster@pobox.com> wrote:\n\n> >  Documentation/config/uploadpack.adoc  |  42 ++++++++++\n> >  Documentation/git-upload-pack.adoc    |   5 ++\n> >  Documentation/git.adoc                |   4 +-\n> >  builtin/upload-pack.c                 |  11 +++\n> >  promisor-remote.c                     |  76 ++++++++++--------\n> >  setup.c                               | 108 ++++++++++++++------------\n> >  setup.h                               |  28 +++++++\n> >  t/t5710-promisor-remote-capability.sh |  70 +++++++++++++++++\n> >  upload-pack.c                         |  37 +++++++++\n> >  upload-pack.h                         |   3 +\n> >  10 files changed, 304 insertions(+), 80 deletions(-)\n>\n> What's missing is the information on the base.  I tried applying\n> these patches to 'v2.55.0' and the recent tips of 'master':\n>\n>     2c78326f81 The 11th batch\n>     5b2471720c The 10th batch\n>     a97fcc37c2 The 9th batch\n>     13c7afec21 The 8th batch\n>     9a0c4701dc The 7th batch\n>     5d2e770923 The 6th batch\n>     48bbf81c29 The 5th batch\n>     41365c2a9b The 4th batch for Git 2.56\n>     d35c5399e3 The 3rd batch for Git 2.56\n>     55526a1826 The 2nd batch for Git 2.56\n>\n> but the series did not apply to any of them.\n>\n> It turns out the reason has nothing to do with your choice of\n> base.  It is because the series structure is not understood by 'b4'.\n>\n> The cover letter I am responding to is a reply to another series,\n> but the patches in this round are not marked as 'v2'.  This seems\n> to cause 'b4' to grab patches from both series and smash them\n> together, resulting in an inapplicable mess.  It seems you cannot\n> have your cake and eat it, too 😠.\n\nI guess b4 should have, or grow, an option for that, because it's not\nuncommon that someone would post an alternative patch or patch series\nin reply to some patch(es).\n\n> Next time, please do not thread the two topics together unless you\n> are marking the newer iteration with a higher 'vN' number.\n\nOk, I will not do that. I will start a separate thread. Now I hope it\nwill work if I send a v2 in reply to the latest series.\n\nThanks.\n"},{"id":"550241","messageId":"xmqq33wl42vl.fsf@gitster.g","threadId":"65969","inReplyTo":"CAP8UFD1LxM1s-MJuffhVks6JfBXoMzKii4YU4iQRNzXJZCQkfQ@mail.gmail.com","subject":"Re: [PATCH 0/5] Introduce 'uploadpack.lazyFetchTrusted'","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-08-11T05:55:58Z","receivedAt":"2026-08-11T05:56:02Z","isPatch":true,"body":"Christian Couder <christian.couder@gmail.com> writes:\n\n>> It turns out the reason has nothing to do with your choice of\n>> base.  It is because the series structure is not understood by 'b4'.\n>>\n>> The cover letter I am responding to is a reply to another series,\n>> but the patches in this round are not marked as 'v2'.  This seems\n>> to cause 'b4' to grab patches from both series and smash them\n>> together, resulting in an inapplicable mess.  It seems you cannot\n>> have your cake and eat it, too 😠.\n>\n> I guess b4 should have, or grow, an option for that, because it's not\n> uncommon that someone would post an alternative patch or patch series\n> in reply to some patch(es).\n\nThere is an option that tells it not to crawl up the parent article\nto find siblings, and it would have worked fine in this case, but\nthen it would prevent us from noticing that a newer iteration\nexists.\n\nBut it should not be the norm.\n"},{"id":"550527","messageId":"20260813154748.2378747-1-christian.couder@gmail.com","threadId":"65969","inReplyTo":"20260807135511.1818458-1-christian.couder@gmail.com","subject":"[PATCH v2 0/5] Introduce 'uploadpack.lazyFetchTrusted'","fromName":"Christian Couder","fromEmail":"christian.couder@gmail.com","sentAt":"2026-08-13T15:47:43Z","receivedAt":"2026-08-13T15:48:43Z","isPatch":true,"body":"Recently the \"promisor-remote\" capability was added to protocol v2,\nallowing servers and clients to agree on the promisor remotes they can\nsafely use.\n\nThe more servers use promisor remotes, the more it is important to\nproperly control if they can lazy fetch when responding to a clone or\nfetch request from the client.\n\nFor example, in the context of large object promisors (see\n\"Documentation/technical/large-object-promisors.adoc\"), if a client\nclones with a filter set to 100kB while the server has moved all of\nthe blobs >= 10kB to a promisor remote, the server will not be able to\nprovide blobs between 10kB and 100kB to the client, which will make\nthe clone fail.\n\nEven if the `--filter=auto` option is available since ef2f1845ec\n(fetch-pack: wire up and enable auto filter logic, 2026-02-16) it's\nstill a good idea to provide more control over lazy fetching on the\nserver side to server operators, as lazy fetching on the server side\ncould be useful in corporate environments.\n\nSince 7b70e9efb1 (upload-pack: disable lazy-fetching by default,\n2024-04-16), lazy fetching has been controlled by the\n`GIT_NO_LAZY_FETCH` environment variable. This is a boolean that is\nset to 'true' by default when calling `git upload-pack` for security\nreasons.\n\nThe main security issue on the server side is making sure the served\nrepo itself is also trusted, as lazily fetching runs `git fetch`,\nwhich may execute arbitrary commands specified in the configuration\nand hooks of the served repo. The operator of the server should decide\nand mark that trust, not the served repo itself, nor the client.\n\nThis series introduces a new 'uploadpack.lazyFetchTrusted' protected\nconfiguration variable similar to 'safe.directory' (see\n\"Documentation/config/safe.adoc\") to mark trusted repos where lazy\nfetching is allowed. As it is protected, this config variable will\nonly take effect if it is set in global or system scope, so only\nserver operators can control it.\n\nPrevious related work\n=====================\n\nA previous series called \"Introduce a 'fromAccepted' option to\nGIT_NO_LAZY_FETCH\" [1] took a different approach as it wanted to make\nit easier to allow lazy fetching from accepted promisor remotes. But\nafter brian replied that he didn't think it was a good idea, and after\nthinking about this more, my opinion now is that some promisor remotes\nbeing accepted or not is not really relevant to the issue.\n\nIn my reply to brian, I said:\n\n\"\"\"\nDifferent features could be developed (in future work) to improve on\nthe current state:\n    - a way for lazy fetching to work without reading config files,\ntriggering hooks, or doing potentially sensitive things,\n    - an explicit way for operators to mark trusted repos (like\nperhaps a server-side config the operator sets per-repo),\n    - operator-defined allow/deny rules, or maybe\n    - some ways/scripts/commands to scan repos and check configuration\ninformation, remote settings and everything potentially sensitive to\ndecide if a repo looks safe enough to allow lazy fetching or not.\n\"\"\"\n\nSo I decided to go with \"an explicit way for operators to mark trusted\nrepos\" and this series is an implementation of that.\n\nNote that the feature developed in this series applies to protocol\nv0/v1 as well as v2 while the previous one was only related to v2.\n\n[1]: https://lore.kernel.org/git/CAP8UFD0_S9eg_w42tcNRnT9E2ntLr_eHLnzE4c2dSu67DzZoXg@mail.gmail.com/\n\nOverview of the patches\n=======================\n\n  - Patch 1/5 is the only patch saved from the \"Introduce a\n    'fromAccepted' option to GIT_NO_LAZY_FETCH\" series. It's not\n    necessary for the rest of this series and its main feature to\n    work, but I think it's a nice refactoring related to lazy\n    fetching, so it might as well be part of this series. There is a\n    small change in the commit message (to not mention following\n    commits) compared to the version in the previous series.\n\n  - Patches 2/5 and 3/5 extract and modify code used by the\n    'safe.directory' config variable in a path_allowlist_apply()\n    function, so that this function can be reused to process\n    'uploadpack.lazyFetchTrusted' in the next patch.\n\n  - Patch 4/5 actually uses path_allowlist_apply() from a new\n    upload_pack_lazy_fetch_trusted() function to process\n    'uploadpack.lazyFetchTrusted', but the result from that processing\n    isn't actually used to have a practical effect.\n\n  - Patch 5/5 wires up the new upload_pack_lazy_fetch_trusted()\n    function to decide if lazy fetching can actually be enabled.\n\nChanges since v1\n================\n\nThe only change is that the Signed-off-by email address has been fixed\nto \"christian.couder@gmail.com\", which is my primary address in\n\".mailmap\" since 6375b40aea (mailmap: change primary address for\nChristian Couder, 2026-08-03).\n\nThis version is also sent as a separate 'v2' iteration in reply to v1,\ninstead of being threaded onto the previous \"Introduce a\n'fromAccepted' option to GIT_NO_LAZY_FETCH\" series, and it now\ncontains a 'base-commit' trailer, so that 'b4' and other tools can\nfind the right base and the right patches.\n\nCI tests\n========\n\nI didn't run them as only commit messages changed since v1.\n\nRange diff with v1\n==================\n\n1:  b5b0836d19 ! 1:  1605740203 promisor-remote: factor out lazy_fetch_objects()\n    @@ Commit message\n         'bool' instead of 'int', as it just returns whether all the objects\n         could be fetched, and document its return value.\n     \n    -    Signed-off-by: Christian Couder <chriscool@tuxfamily.org>\n    +    Signed-off-by: Christian Couder <christian.couder@gmail.com>\n     \n      ## promisor-remote.c ##\n     @@ promisor-remote.c: static int fetch_objects(struct repository *repo,\n2:  879e3a34e3 ! 2:  5f226b6508 setup: extract path_allowlist_apply()\n    @@ Commit message\n     \n         While at it let's make the helper's code simpler and more generic.\n     \n    -    Signed-off-by: Christian Couder <chriscool@tuxfamily.org>\n    +    Signed-off-by: Christian Couder <christian.couder@gmail.com>\n     \n      ## setup.c ##\n     @@ setup.c: static int canonicalize_ceiling_entry(struct string_list_item *item,\n3:  98431ab7b3 ! 3:  051aa11fc9 setup: add 'allow_dot' arg to path_allowlist_apply()\n    @@ Commit message\n     \n         While at it let's document it properly in \"setup.h\".\n     \n    -    Signed-off-by: Christian Couder <chriscool@tuxfamily.org>\n    +    Signed-off-by: Christian Couder <christian.couder@gmail.com>\n     \n      ## setup.c ##\n     @@ setup.c: static int canonicalize_ceiling_entry(struct string_list_item *item,\n4:  a46f4c1bb8 ! 4:  045b5e647b upload-pack: read uploadpack.lazyFetchTrusted\n    @@ Commit message\n         Note that the new config variable should be read only from protected\n         configuration files.\n     \n    -    Signed-off-by: Christian Couder <chriscool@tuxfamily.org>\n    +    Signed-off-by: Christian Couder <christian.couder@gmail.com>\n     \n      ## upload-pack.c ##\n     @@\n5:  4063f233aa ! 5:  c116661202 builtin/upload-pack: set GIT_NO_LAZY_FETCH to 0 on trusted repo\n    @@ Commit message\n         Now that \"uploadpack.lazyFetchTrusted\" is actually doing something,\n         let's document it and reference it from GIT_NO_LAZY_FETCH's docs.\n     \n    -    Signed-off-by: Christian Couder <chriscool@tuxfamily.org>\n    +    Signed-off-by: Christian Couder <christian.couder@gmail.com>\n     \n      ## Documentation/config/uploadpack.adoc ##\n     @@ Documentation/config/uploadpack.adoc: uploadpack.allowRefInWant::\n\n\nChristian Couder (5):\n  promisor-remote: factor out lazy_fetch_objects()\n  setup: extract path_allowlist_apply()\n  setup: add 'allow_dot' arg to path_allowlist_apply()\n  upload-pack: read uploadpack.lazyFetchTrusted\n  builtin/upload-pack: set GIT_NO_LAZY_FETCH to 0 on trusted repo\n\n Documentation/config/uploadpack.adoc  |  42 ++++++++++\n Documentation/git-upload-pack.adoc    |   5 ++\n Documentation/git.adoc                |   4 +-\n builtin/upload-pack.c                 |  11 +++\n promisor-remote.c                     |  76 ++++++++++--------\n setup.c                               | 108 ++++++++++++++------------\n setup.h                               |  28 +++++++\n t/t5710-promisor-remote-capability.sh |  70 +++++++++++++++++\n upload-pack.c                         |  37 +++++++++\n upload-pack.h                         |   3 +\n 10 files changed, 304 insertions(+), 80 deletions(-)\n\n\nbase-commit: 745601a9a94110d74769ab605ccd4f61339758d2\n-- \n2.55.0.565.gc116661202\n\n"},{"id":"550528","messageId":"20260813154748.2378747-2-christian.couder@gmail.com","threadId":"65969","inReplyTo":"20260807135511.1818458-1-christian.couder@gmail.com","subject":"[PATCH v2 1/5] promisor-remote: factor out lazy_fetch_objects()","fromName":"Christian Couder","fromEmail":"christian.couder@gmail.com","sentAt":"2026-08-13T15:47:44Z","receivedAt":"2026-08-13T15:48:44Z","isPatch":true,"body":"In \"promisor-remote.c:fetch_objects()\", there is a check to disable\nlazy fetching when the `GIT_NO_LAZY_FETCH` environment variable is\nset. The fetch_objects() function is called once per promisor remote\nthough. So the check might be performed more times than necessary.\n\nAlso promisor_remote_get_direct() mixes up the logic deciding which\npromisor remotes to try with the logic checking that the objects\nthat could not be fetched are promisor objects.\n\nLet's refactor the lazy fetching logic out of these two functions\ninto a new lazy_fetch_objects() function.\n\nThis is a pure refactoring with no intended behavior change. Two\nthings shift in ways that are observably equivalent though:\n\n  - the `GIT_NO_LAZY_FETCH` check is now performed once up front,\n    instead of once per promisor remote, and\n\n  - promisor_remote_init() is no longer called when lazy fetching\n    is disabled, which is fine as nothing downstream of it, like\n    is_promisor_object(), needs it in that case.\n\nWhile at it, let's also convert try_promisor_remotes() to return\n'bool' instead of 'int', as it just returns whether all the objects\ncould be fetched, and document its return value.\n\nSigned-off-by: Christian Couder <christian.couder@gmail.com>\n---\n promisor-remote.c | 76 ++++++++++++++++++++++++++++-------------------\n 1 file changed, 45 insertions(+), 31 deletions(-)\n\ndiff --git a/promisor-remote.c b/promisor-remote.c\nindex 43505d1e1a..65496c69cf 100644\n--- a/promisor-remote.c\n+++ b/promisor-remote.c\n@@ -31,15 +31,6 @@ static int fetch_objects(struct repository *repo,\n \tFILE *child_in;\n \tint quiet;\n \n-\tif (git_env_bool(NO_LAZY_FETCH_ENVIRONMENT, 0)) {\n-\t\tstatic int warning_shown;\n-\t\tif (!warning_shown) {\n-\t\t\twarning_shown = 1;\n-\t\t\twarning(_(\"lazy fetching disabled; some objects may not be available\"));\n-\t\t}\n-\t\treturn -1;\n-\t}\n-\n \tchild.git_cmd = 1;\n \tchild.in = -1;\n \tif (repo != the_repository)\n@@ -270,10 +261,15 @@ static int remove_fetched_oids(struct repository *repo,\n \treturn remaining_nr;\n }\n \n-static int try_promisor_remotes(struct repository *repo,\n-\t\t\t\tstruct object_id **remaining_oids,\n-\t\t\t\tint *remaining_nr, int *to_free,\n-\t\t\t\tbool accepted_only)\n+/*\n+ * Return 'true' if all the objects could be fetched from the\n+ * (non-)accepted remotes, 'false' otherwise.\n+ */\n+static bool try_promisor_remotes(struct repository *repo,\n+\t\t\t\t struct object_id **remaining_oids,\n+\t\t\t\t int *remaining_nr,\n+\t\t\t\t int *to_free,\n+\t\t\t\t bool accepted_only)\n {\n \tstruct promisor_remote *r = repo->promisor_remote_config->promisors;\n \n@@ -290,9 +286,37 @@ static int try_promisor_remotes(struct repository *repo,\n \t\t\t\tcontinue;\n \t\t\t}\n \t\t}\n-\t\treturn 1; /* all fetched */\n+\t\treturn true; /* all fetched */\n \t}\n-\treturn 0;\n+\treturn false;\n+}\n+\n+/*\n+ * Return 'true' if all the objects could be fetched, 'false' otherwise.\n+ */\n+static bool lazy_fetch_objects(struct repository *repo,\n+\t\t\t       struct object_id **remaining_oids,\n+\t\t\t       int *remaining_nr,\n+\t\t\t       int *to_free)\n+{\n+\tif (git_env_bool(NO_LAZY_FETCH_ENVIRONMENT, 0)) {\n+\t\tstatic int warning_shown;\n+\t\tif (!warning_shown) {\n+\t\t\twarning_shown = 1;\n+\t\t\twarning(_(\"lazy fetching disabled; some objects may not be available\"));\n+\t\t}\n+\t\treturn false;\n+\t}\n+\n+\tpromisor_remote_init(repo);\n+\n+\t/* Try accepted remotes first (those the server told us to use) */\n+\tif (try_promisor_remotes(repo, remaining_oids, remaining_nr,\n+\t\t\t\t to_free, true))\n+\t\treturn true;\n+\n+\treturn try_promisor_remotes(repo, remaining_oids, remaining_nr,\n+\t\t\t\t    to_free, false);\n }\n \n void promisor_remote_get_direct(struct repository *repo,\n@@ -302,28 +326,18 @@ void promisor_remote_get_direct(struct repository *repo,\n \tstruct object_id *remaining_oids = (struct object_id *)oids;\n \tint remaining_nr = oid_nr;\n \tint to_free = 0;\n-\tint i;\n \n \tif (oid_nr == 0)\n \t\treturn;\n \n-\tpromisor_remote_init(repo);\n-\n-\t/* Try accepted remotes first (those the server told us to use) */\n-\tif (try_promisor_remotes(repo, &remaining_oids, &remaining_nr,\n-\t\t\t\t &to_free, true))\n-\t\tgoto all_fetched;\n-\tif (try_promisor_remotes(repo, &remaining_oids, &remaining_nr,\n-\t\t\t\t &to_free, false))\n-\t\tgoto all_fetched;\n-\n-\tfor (i = 0; i < remaining_nr; i++) {\n-\t\tif (is_promisor_object(repo, &remaining_oids[i]))\n-\t\t\tdie(_(\"could not fetch %s from promisor remote\"),\n-\t\t\t    oid_to_hex(&remaining_oids[i]));\n+\tif (!lazy_fetch_objects(repo, &remaining_oids, &remaining_nr, &to_free)) {\n+\t\tfor (int i = 0; i < remaining_nr; i++) {\n+\t\t\tif (is_promisor_object(repo, &remaining_oids[i]))\n+\t\t\t\tdie(_(\"could not fetch %s from promisor remote\"),\n+\t\t\t\t    oid_to_hex(&remaining_oids[i]));\n+\t\t}\n \t}\n \n-all_fetched:\n \tif (to_free)\n \t\tfree(remaining_oids);\n }\n-- \n2.55.0.565.gc116661202\n\n"},{"id":"550529","messageId":"20260813154748.2378747-3-christian.couder@gmail.com","threadId":"65969","inReplyTo":"20260807135511.1818458-1-christian.couder@gmail.com","subject":"[PATCH v2 2/5] setup: extract path_allowlist_apply()","fromName":"Christian Couder","fromEmail":"christian.couder@gmail.com","sentAt":"2026-08-13T15:47:45Z","receivedAt":"2026-08-13T15:48:46Z","isPatch":true,"body":"In a following commit we are going to check whether a repository is\npart of an allowlist specified in a config variable.\n\nTo prepare for that let's extract existing code from\nsafe_directory_cb() into a new path_allowlist_apply() helper that will\nhelp with such checks.\n\nWhile at it let's make the helper's code simpler and more generic.\n\nSigned-off-by: Christian Couder <christian.couder@gmail.com>\n---\n setup.c | 107 +++++++++++++++++++++++++++++++-------------------------\n 1 file changed, 59 insertions(+), 48 deletions(-)\n\ndiff --git a/setup.c b/setup.c\nindex 95909e9603..39dfa1cc5f 100644\n--- a/setup.c\n+++ b/setup.c\n@@ -1339,6 +1339,64 @@ static int canonicalize_ceiling_entry(struct string_list_item *item,\n \t}\n }\n \n+static void path_allowlist_apply(const char *key, const char *value,\n+\t\t\t\t const char *target_path, int *is_match)\n+{\n+\tchar *allowed = NULL;\n+\tchar *normalized = NULL;\n+\n+\tif (!value || !*value) {\n+\t\t*is_match = 0;\n+\t\treturn;\n+\t}\n+\n+\tif (!strcmp(value, \"*\")) {\n+\t\t*is_match = 1;\n+\t\treturn;\n+\t}\n+\n+\tif (git_config_pathname(&allowed, key, value) || !allowed)\n+\t\treturn;\n+\n+\t/*\n+\t * Setting the config variable to a non-absolute path makes\n+\t * little sense---it won't be relative to the configuration\n+\t * file the item is defined in.  Except for \".\", which means\n+\t * \"if we are at the top level of a repository, then it is\n+\t * OK\", which is slightly tighter than \"*\" that allows\n+\t * discovery.\n+\t */\n+\tif (!is_absolute_path(allowed) && strcmp(allowed, \".\")) {\n+\t\twarning(_(\"%s '%s' not absolute\"), key, allowed);\n+\t\tgoto end;\n+\t}\n+\n+\t/*\n+\t * A .gitconfig in $HOME may be shared across different\n+\t * machines and the config variable entries may or may not\n+\t * exist as paths on all of these machines.  In other words,\n+\t * it is not a warning worthy event when there is no such path\n+\t * on this machine---the entry may be useful elsewhere.\n+\t */\n+\tnormalized = real_pathdup(allowed, 0);\n+\tif (!normalized)\n+\t\tgoto end;\n+\n+\tif (ends_with(normalized, \"/*\")) {\n+\t\tsize_t len = strlen(normalized);\n+\t\tif (!fspathncmp(normalized, target_path, len - 1))\n+\t\t\t*is_match = 1;\n+\t\tgoto end;\n+\t}\n+\n+\tif (!fspathcmp(target_path, normalized))\n+\t\t*is_match = 1;\n+\n+end:\n+\tfree(normalized);\n+\tfree(allowed);\n+}\n+\n struct safe_directory_data {\n \tchar *path;\n \tint is_safe;\n@@ -1352,54 +1410,7 @@ static int safe_directory_cb(const char *key, const char *value,\n \tif (strcmp(key, \"safe.directory\"))\n \t\treturn 0;\n \n-\tif (!value || !*value) {\n-\t\tdata->is_safe = 0;\n-\t} else if (!strcmp(value, \"*\")) {\n-\t\tdata->is_safe = 1;\n-\t} else {\n-\t\tchar *allowed = NULL;\n-\n-\t\tif (!git_config_pathname(&allowed, key, value) && allowed) {\n-\t\t\tchar *normalized = NULL;\n-\n-\t\t\t/*\n-\t\t\t * Setting safe.directory to a non-absolute path\n-\t\t\t * makes little sense---it won't be relative to\n-\t\t\t * the configuration file the item is defined in.\n-\t\t\t * Except for \".\", which means \"if we are at the top\n-\t\t\t * level of a repository, then it is OK\", which is\n-\t\t\t * slightly tighter than \"*\" that allows discovery.\n-\t\t\t */\n-\t\t\tif (!is_absolute_path(allowed) && strcmp(allowed, \".\")) {\n-\t\t\t\twarning(_(\"safe.directory '%s' not absolute\"),\n-\t\t\t\t\tallowed);\n-\t\t\t\tgoto next;\n-\t\t\t}\n-\n-\t\t\t/*\n-\t\t\t * A .gitconfig in $HOME may be shared across\n-\t\t\t * different machines and safe.directory entries\n-\t\t\t * may or may not exist as paths on all of these\n-\t\t\t * machines.  In other words, it is not a warning\n-\t\t\t * worthy event when there is no such path on this\n-\t\t\t * machine---the entry may be useful elsewhere.\n-\t\t\t */\n-\t\t\tnormalized = real_pathdup(allowed, 0);\n-\t\t\tif (!normalized)\n-\t\t\t\tgoto next;\n-\n-\t\t\tif (ends_with(normalized, \"/*\")) {\n-\t\t\t\tsize_t len = strlen(normalized);\n-\t\t\t\tif (!fspathncmp(normalized, data->path, len - 1))\n-\t\t\t\t\tdata->is_safe = 1;\n-\t\t\t} else if (!fspathcmp(data->path, normalized)) {\n-\t\t\t\tdata->is_safe = 1;\n-\t\t\t}\n-\t\tnext:\n-\t\t\tfree(normalized);\n-\t\t\tfree(allowed);\n-\t\t}\n-\t}\n+\tpath_allowlist_apply(key, value, data->path, &data->is_safe);\n \n \treturn 0;\n }\n-- \n2.55.0.565.gc116661202\n\n"},{"id":"550530","messageId":"20260813154748.2378747-4-christian.couder@gmail.com","threadId":"65969","inReplyTo":"20260807135511.1818458-1-christian.couder@gmail.com","subject":"[PATCH v2 3/5] setup: add 'allow_dot' arg to path_allowlist_apply()","fromName":"Christian Couder","fromEmail":"christian.couder@gmail.com","sentAt":"2026-08-13T15:47:46Z","receivedAt":"2026-08-13T15:48:48Z","isPatch":true,"body":"A previous commit created path_allowlist_apply() with the goal of later\nreusing that function. But when it will be reused in a following commit\nthis function will need to reject non-absolute paths including those\nwith a single dot that are currently accepted.\n\nTo prepare for reusing path_allowlist_apply(), let's add a\n`bool allow_dot` argument to it, and let's export this function.\n\nWhile at it let's document it properly in \"setup.h\".\n\nSigned-off-by: Christian Couder <christian.couder@gmail.com>\n---\n setup.c |  9 +++++----\n setup.h | 28 ++++++++++++++++++++++++++++\n 2 files changed, 33 insertions(+), 4 deletions(-)\n\ndiff --git a/setup.c b/setup.c\nindex 39dfa1cc5f..a09e697e3a 100644\n--- a/setup.c\n+++ b/setup.c\n@@ -1339,8 +1339,9 @@ static int canonicalize_ceiling_entry(struct string_list_item *item,\n \t}\n }\n \n-static void path_allowlist_apply(const char *key, const char *value,\n-\t\t\t\t const char *target_path, int *is_match)\n+void path_allowlist_apply(const char *key, const char *value,\n+\t\t\t  const char *target_path, int *is_match,\n+\t\t\t  bool allow_dot)\n {\n \tchar *allowed = NULL;\n \tchar *normalized = NULL;\n@@ -1366,7 +1367,7 @@ static void path_allowlist_apply(const char *key, const char *value,\n \t * OK\", which is slightly tighter than \"*\" that allows\n \t * discovery.\n \t */\n-\tif (!is_absolute_path(allowed) && strcmp(allowed, \".\")) {\n+\tif (!is_absolute_path(allowed) && (!allow_dot || strcmp(allowed, \".\"))) {\n \t\twarning(_(\"%s '%s' not absolute\"), key, allowed);\n \t\tgoto end;\n \t}\n@@ -1410,7 +1411,7 @@ static int safe_directory_cb(const char *key, const char *value,\n \tif (strcmp(key, \"safe.directory\"))\n \t\treturn 0;\n \n-\tpath_allowlist_apply(key, value, data->path, &data->is_safe);\n+\tpath_allowlist_apply(key, value, data->path, &data->is_safe, true);\n \n \treturn 0;\n }\ndiff --git a/setup.h b/setup.h\nindex 654f10e059..d4f8af5457 100644\n--- a/setup.h\n+++ b/setup.h\n@@ -304,4 +304,32 @@ struct startup_info {\n extern struct startup_info *startup_info;\n extern const char *tmp_original_cwd;\n \n+/*\n+ * Apply the path allowlist in 'value' against 'target_path' setting\n+ * '*is_match' accordingly.\n+ *\n+ * `value` is the value of a multi-valued config variable named `key`\n+ * that holds an allowlist of paths. `target_path` is the (normalized)\n+ * path being tested. `*is_match` is updated in place:\n+ *\n+ *   - an empty value resets it to 0 (so a later, more specific config\n+ *     scope can clear entries from a broader one),\n+ *   - \"*\" sets it to 1 (allow everything),\n+ *   - \"<path>\" sets it to 1 if <path> equals `target_path`,\n+ *   - \"<path>\" + \"/\" + \"*\" sets it to 1 if <path> is a leading\n+ *     directory of `target_path`,\n+ *   - any other (unmatching) value leaves `*is_match` unchanged.\n+ *\n+ * Non-absolute values are rejected with a warning, except \".\" when\n+ * `allow_dot` is set (used by 'safe.directory' to mean \"the top level\n+ * of the current repository\").\n+ *\n+ * Callers are expected to invoke this once per config value,\n+ * typically from a protected-config callback, so that untrusted\n+ * repository config cannot influence the decision.\n+ */\n+void path_allowlist_apply(const char *key, const char *value,\n+\t\t\t  const char *target_path, int *is_match,\n+\t\t\t  bool allow_dot);\n+\n #endif /* SETUP_H */\n-- \n2.55.0.565.gc116661202\n\n"},{"id":"550531","messageId":"20260813154748.2378747-5-christian.couder@gmail.com","threadId":"65969","inReplyTo":"20260807135511.1818458-1-christian.couder@gmail.com","subject":"[PATCH v2 4/5] upload-pack: read uploadpack.lazyFetchTrusted","fromName":"Christian Couder","fromEmail":"christian.couder@gmail.com","sentAt":"2026-08-13T15:47:47Z","receivedAt":"2026-08-13T15:48:50Z","isPatch":true,"body":"Previous commits created and prepared the path_allowlist_apply()\nfunction.\n\nLet's reuse this function for a new \"uploadpack.lazyFetchTrusted\"\nconfiguration variable.\n\nIt allows us to:\n\n  - read an allowlist from that config variable,\n  - check if the current repo is in that list, and\n  - return the result from a new upload_pack_lazy_fetch_trusted()\n    function.\n\nThe new function will be used in a following commit.\n\nNote that the new config variable should be read only from protected\nconfiguration files.\n\nSigned-off-by: Christian Couder <christian.couder@gmail.com>\n---\n upload-pack.c | 37 +++++++++++++++++++++++++++++++++++++\n upload-pack.h |  3 +++\n 2 files changed, 40 insertions(+)\n\ndiff --git a/upload-pack.c b/upload-pack.c\nindex a52856d869..29e700e43b 100644\n--- a/upload-pack.c\n+++ b/upload-pack.c\n@@ -34,6 +34,8 @@\n #include \"json-writer.h\"\n #include \"strmap.h\"\n #include \"promisor-remote.h\"\n+#include \"setup.h\"\n+#include \"abspath.h\"\n \n /* Remember to update object flag allocation in object.h */\n #define THEY_HAVE\t(1u << 11)\n@@ -1378,6 +1380,41 @@ static int upload_pack_config(const char *var, const char *value,\n \treturn parse_hide_refs_config(var, value, \"uploadpack\", &data->hidden_refs);\n }\n \n+struct lazy_fetch_trusted {\n+\tint trusted;\n+\tchar *repo_path;\n+};\n+\n+static int upload_pack_protected_lazy_fetch_config(const char *var, const char *value,\n+\t\t\t\t\t\t   const struct config_context *ctx UNUSED,\n+\t\t\t\t\t\t   void *cb_data)\n+{\n+\tstruct lazy_fetch_trusted *data = cb_data;\n+\n+\tif (!strcmp(\"uploadpack.lazyfetchtrusted\", var)) {\n+\t\tpath_allowlist_apply(var, value, data->repo_path,\n+\t\t\t\t     &data->trusted, false);\n+\t\treturn 0;\n+\t}\n+\n+\treturn 0;\n+}\n+\n+bool upload_pack_lazy_fetch_trusted(struct repository *r)\n+{\n+\tstruct lazy_fetch_trusted data = { 0 };\n+\n+\tdata.repo_path = real_pathdup(r->worktree ? r->worktree : r->gitdir, 0);\n+\tif (!data.repo_path)\n+\t\treturn false;\n+\n+\tgit_protected_config(upload_pack_protected_lazy_fetch_config, &data);\n+\n+\tfree(data.repo_path);\n+\n+\treturn !!data.trusted;\n+}\n+\n static int upload_pack_protected_config(const char *var, const char *value,\n \t\t\t\t\tconst struct config_context *ctx UNUSED,\n \t\t\t\t\tvoid *cb_data)\ndiff --git a/upload-pack.h b/upload-pack.h\nindex d6ee25ea98..b2212992c3 100644\n--- a/upload-pack.h\n+++ b/upload-pack.h\n@@ -12,4 +12,7 @@ struct strbuf;\n int upload_pack_advertise(struct repository *r,\n \t\t\t  struct strbuf *value);\n \n+/* Is this repo trusted for lazy fetching? */\n+bool upload_pack_lazy_fetch_trusted(struct repository *r);\n+\n #endif /* UPLOAD_PACK_H */\n-- \n2.55.0.565.gc116661202\n\n"},{"id":"550532","messageId":"20260813154748.2378747-6-christian.couder@gmail.com","threadId":"65969","inReplyTo":"20260807135511.1818458-1-christian.couder@gmail.com","subject":"[PATCH v2 5/5] builtin/upload-pack: set GIT_NO_LAZY_FETCH to 0 on trusted repo","fromName":"Christian Couder","fromEmail":"christian.couder@gmail.com","sentAt":"2026-08-13T15:47:48Z","receivedAt":"2026-08-13T15:48:52Z","isPatch":true,"body":"A previous commit added a new \"uploadpack.lazyFetchTrusted\" protected\nconfig variable that can contain an allowlist of repos, as well as\nfunctions to check if the current repo is in that list. But when the\ncurrent repo is in that list, we currently do nothing.\n\nLet's instead set `GIT_NO_LAZY_FETCH` to `0`, which allows\n`upload-pack` and its `pack-objects` child process to lazily fetch the\nobjects they need to serve a client, for example when the filter used\nby the client and the one used by the server don't match.\n\nThis allows server operators to properly control lazy fetching. It is\ntheir responsibility, not the client's, to decide if the served repo is\ntrusted, as the main security issue is that lazily fetching runs `git\nfetch`, which may execute arbitrary commands specified in the\nconfiguration and hooks of the served repo.\n\nAs `GIT_NO_LAZY_FETCH` is passed down to child processes through the\nenvironment, this works for `pack-objects`, which performs the lazy\nfetch when serving a client, without any further plumbing.\n\nNow that \"uploadpack.lazyFetchTrusted\" is actually doing something,\nlet's document it and reference it from GIT_NO_LAZY_FETCH's docs.\n\nSigned-off-by: Christian Couder <christian.couder@gmail.com>\n---\n Documentation/config/uploadpack.adoc  | 42 ++++++++++++++++\n Documentation/git-upload-pack.adoc    |  5 ++\n Documentation/git.adoc                |  4 +-\n builtin/upload-pack.c                 | 11 +++++\n t/t5710-promisor-remote-capability.sh | 70 +++++++++++++++++++++++++++\n 5 files changed, 131 insertions(+), 1 deletion(-)\n\ndiff --git a/Documentation/config/uploadpack.adoc b/Documentation/config/uploadpack.adoc\nindex 0e1dda944a..e960879c16 100644\n--- a/Documentation/config/uploadpack.adoc\n+++ b/Documentation/config/uploadpack.adoc\n@@ -86,3 +86,45 @@ uploadpack.allowRefInWant::\n \tis intended for the benefit of load-balanced servers which may\n \tnot have the same view of what OIDs their refs point to due to\n \treplication delay.\n+\n+uploadpack.lazyFetchTrusted::\n+\tThese config entries specify repositories that `upload-pack` is\n+\tallowed to lazily fetch missing objects for. By default,\n+\t`upload-pack` refuses to lazily fetch (see the description of the\n+\t`GIT_NO_LAZY_FETCH` environment variable in\n+\tlinkgit:git-upload-pack[1]), because doing so would run `git fetch`,\n+\twhich may execute arbitrary commands specified in the configuration\n+\tand hooks of the served repository. Listing a repository here tells\n+\t`upload-pack` that it is trusted, so lazy fetching from the promisor\n+\tremotes configured in it is allowed. This is equivalent to setting\n+\t`GIT_NO_LAZY_FETCH` to `0` for the matching repositories. An\n+\texplicitly set `GIT_NO_LAZY_FETCH` takes precedence over this\n+\tsetting.\n++\n+Note that this allows lazy fetching from any promisor remote\n+configured in the served repository, not only from the promisor\n+remotes that the client accepted using the \"promisor-remote\" protocol\n+v2 capability (see linkgit:gitprotocol-v2[5]). The served repository\n+is trusted as a whole, including its configuration, so the promisor\n+remotes it configures are trusted too. It is the server operator's\n+responsibility to make sure that the promisor remotes of a trusted\n+repository are also trustworthy.\n++\n+This is a multi-valued setting, i.e. you can add more than one\n+repository via `git config (--global|--system) --add`. To reset the\n+list of trusted repositories (e.g. to override any such repositories\n+specified in the system config), add a `uploadpack.lazyFetchTrusted`\n+entry with an empty value.\n++\n+A repository is identified by its worktree, or its git directory for a bare\n+repository, and the value must be an absolute path. Giving a path with `/*`\n+appended to it will trust all repositories under the named directory. To trust\n+all served repositories, set `uploadpack.lazyFetchTrusted` to the string `*`.\n++\n+The value of this setting is interpolated, i.e. `~/<path>` expands to a\n+path relative to the home directory and `%(prefix)/<path>` expands to a\n+path relative to Git's (runtime) prefix.\n++\n+Note that this configuration variable is only respected when it is specified\n+in protected configuration (see <<SCOPES>>). This prevents untrusted\n+repositories from tampering with this value.\ndiff --git a/Documentation/git-upload-pack.adoc b/Documentation/git-upload-pack.adoc\nindex 9167a321d0..90c2ba1194 100644\n--- a/Documentation/git-upload-pack.adoc\n+++ b/Documentation/git-upload-pack.adoc\n@@ -71,6 +71,11 @@ This is implemented by having `upload-pack` internally set the\n (because you are fetching from a partial clone, and you are sure\n you trust it), you can explicitly set `GIT_NO_LAZY_FETCH` to\n `0`.\n++\n+Instead of setting `GIT_NO_LAZY_FETCH` to `0` in the environment, a\n+server operator can allow lazy fetching on a per-repository basis by\n+listing trusted repositories in the `uploadpack.lazyFetchTrusted`\n+configuration variable. See linkgit:git-config[1].\n \n SECURITY\n --------\ndiff --git a/Documentation/git.adoc b/Documentation/git.adoc\nindex 8a5cdd3b3d..2e763d1f93 100644\n--- a/Documentation/git.adoc\n+++ b/Documentation/git.adoc\n@@ -949,7 +949,9 @@ for full details.\n `GIT_NO_LAZY_FETCH`::\n \tSetting this Boolean environment variable to true tells Git\n \tnot to lazily fetch missing objects from the promisor remote\n-\ton demand.\n+\ton demand. On the server side, the `uploadpack.lazyFetchTrusted`\n+\tconfiguration variable can control this per-repository. See\n+\tlinkgit:git-upload-pack[1].\n \n `GIT_REFLOG_ACTION`::\n \tWhen a ref is updated, reflog entries are created to keep\ndiff --git a/builtin/upload-pack.c b/builtin/upload-pack.c\nindex 32831fb879..8b531ca724 100644\n--- a/builtin/upload-pack.c\n+++ b/builtin/upload-pack.c\n@@ -42,10 +42,13 @@ int cmd_upload_pack(int argc,\n \t\tOPT_END()\n \t};\n \tunsigned enter_repo_flags = ENTER_REPO_ANY_OWNER_OK;\n+\tbool no_lazy_fetch_set;\n \n \tpacket_trace_identity(\"upload-pack\");\n \tdisable_replace_refs();\n \tsave_commit_buffer = 0;\n+\n+\tno_lazy_fetch_set = !!getenv(NO_LAZY_FETCH_ENVIRONMENT);\n \txsetenv(NO_LAZY_FETCH_ENVIRONMENT, \"1\", 0);\n \n \targc = parse_options(argc, argv, prefix, options, upload_pack_usage, 0);\n@@ -62,6 +65,14 @@ int cmd_upload_pack(int argc,\n \tif (!enter_repo(the_repository, dir, enter_repo_flags))\n \t\tdie(\"'%s' does not appear to be a git repository\", dir);\n \n+\t/*\n+\t * Relax the GIT_NO_LAZY_FETCH=1 default if the served repo is in\n+\t * the \"uploadpack.lazyFetchTrusted\" protected allowlist and\n+\t * GIT_NO_LAZY_FETCH was not already set explicitly.\n+\t */\n+\tif (!no_lazy_fetch_set && upload_pack_lazy_fetch_trusted(the_repository))\n+\t\txsetenv(NO_LAZY_FETCH_ENVIRONMENT, \"0\", 1);\n+\n \tswitch (determine_protocol_version_server()) {\n \tcase protocol_v2:\n \t\tif (advertise_refs)\ndiff --git a/t/t5710-promisor-remote-capability.sh b/t/t5710-promisor-remote-capability.sh\nindex 549acff23f..e6993f2761 100755\n--- a/t/t5710-promisor-remote-capability.sh\n+++ b/t/t5710-promisor-remote-capability.sh\n@@ -173,6 +173,76 @@ test_expect_success \"clone with promisor.acceptfromserver set to 'None'\" '\n \tinitialize_server 1 \"$oid\"\n '\n \n+test_expect_success \"clone with uploadpack.lazyFetchTrusted\" '\n+\t# No promisors are advertised\n+\tgit -C server config promisor.advertise false &&\n+\ttest_when_finished \"rm -rf client\" &&\n+\n+\t# The served repo is trusted for lazy fetching\n+\ttest_config_global uploadpack.lazyFetchTrusted \"$(pwd)/server\" &&\n+\n+\t# Clone without GIT_NO_LAZY_FETCH=0\n+\tgit clone --no-local --filter=\"blob:limit=5k\" server client &&\n+\n+\t# Check that the largest object is not missing on the server\n+\t# This means the server lazy fetched it\n+\tcheck_missing_objects server 0 \"\" &&\n+\n+\t# Reinitialize server so that the largest object is missing again\n+\tinitialize_server 1 \"$oid\"\n+'\n+\n+test_expect_success \"clone without uploadpack.lazyFetchTrusted fails\" '\n+\t# No promisors are advertised\n+\tgit -C server config promisor.advertise false &&\n+\ttest_when_finished \"rm -rf client\" &&\n+\n+\t# Note: no uploadpack.lazyFetchTrusted config is set here, so\n+\t# the served repo is NOT trusted for lazy fetching.\n+\n+\t# Clone without GIT_NO_LAZY_FETCH=0 fails\n+\ttest_must_fail git clone --no-local --filter=\"blob:limit=5k\" server client 2>err &&\n+\ttest_grep \"lazy fetching disabled\" err &&\n+\n+\t# Check that the largest object is still missing on the server\n+\tcheck_missing_objects server 1 \"$oid\"\n+'\n+\n+test_expect_success \"uploadpack.lazyFetchTrusted is ignored in repo config\" '\n+\t# No promisors are advertised\n+\tgit -C server config promisor.advertise false &&\n+\ttest_when_finished \"rm -rf client\" &&\n+\n+\t# The served repo is trusted for lazy fetching, but this is\n+\t# done in the repo config, not in protected config, so this is\n+\t# ignored.\n+\ttest_config -C server uploadpack.lazyFetchTrusted \"$(pwd)/server\" &&\n+\n+\t# Clone without GIT_NO_LAZY_FETCH=0 fails\n+\ttest_must_fail git clone --no-local --filter=\"blob:limit=5k\" server client 2>err &&\n+\ttest_grep \"lazy fetching disabled\" err &&\n+\n+\t# Check that the largest object is still missing on the server\n+\tcheck_missing_objects server 1 \"$oid\"\n+'\n+\n+test_expect_success \"explicit GIT_NO_LAZY_FETCH overrides uploadpack.lazyFetchTrusted\" '\n+\t# No promisors are advertised\n+\tgit -C server config promisor.advertise false &&\n+\ttest_when_finished \"rm -rf client\" &&\n+\n+\t# The served repo is trusted for lazy fetching\n+\ttest_config_global uploadpack.lazyFetchTrusted \"$(pwd)/server\" &&\n+\n+\t# But GIT_NO_LAZY_FETCH=1 disables lazy fetching, so clone fails\n+\ttest_must_fail env GIT_NO_LAZY_FETCH=1 git clone --no-local \\\n+\t\t--filter=\"blob:limit=5k\" server client 2>err &&\n+\ttest_grep \"lazy fetching disabled\" err &&\n+\n+\t# Check that the largest object is still missing on the server\n+\tcheck_missing_objects server 1 \"$oid\"\n+'\n+\n test_expect_success \"init + fetch with promisor.advertise set to 'true'\" '\n \tgit -C server config promisor.advertise true &&\n \ttest_when_finished \"rm -rf client\" &&\n-- \n2.55.0.565.gc116661202\n\n"},{"id":"550577","messageId":"xmqqy0e9rcd9.fsf@gitster.g","threadId":"65969","inReplyTo":"20260813154748.2378747-1-christian.couder@gmail.com","subject":"Re: [PATCH v2 0/5] Introduce 'uploadpack.lazyFetchTrusted'","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-08-13T20:31:30Z","receivedAt":"2026-08-13T20:31:32Z","isPatch":true,"body":"Christian Couder <christian.couder@gmail.com> writes:\n\n> Recently the \"promisor-remote\" capability was added to protocol v2,\n> allowing servers and clients to agree on the promisor remotes they can\n> safely use.\n\nI do not know what you did, but it seems that an attempt to futz\nwith the thread structure i.e.,\n\n    This version is also sent as a separate 'v2' iteration in reply to v1,\n    instead of being threaded onto the previous \"Introduce a\n    'fromAccepted' option to GIT_NO_LAZY_FETCH\" series, and it now\n    contains a 'base-commit' trailer, so that 'b4' and other tools can\n    find the right base and the right patches.\n\nmade the patches totally unusable.\n\nThis [v2 0/5] has\n\n    Message-ID: <20260813154748.2378747-1-christian.couder@gmail.com>\n    In-reply-to: <20260807135511.1818458-1-christian.couder@gmail.com>\n\nwhich is *correct*.  It is a reply to [0/5] of the original thread.\nHowever, [v2 1/5] says:\n\n    Message-ID: <20260813154748.2378747-2-christian.couder@gmail.com>\n    In-reply-to: <20260807135511.1818458-1-christian.couder@gmail.com>\n\nwhich is quite wrong.  [v2 1/5] should be a reply to the cover\nletter of the v2 iteration.  [v2 2/5], [v2 3/5], ... should also be\nreplies to the cover letter of the v2 iteration.\n\nI have never seen either plain vanilla send-email or GitGitGadget\nproduce misthreaded series like this one.  Do you have some custom\nsettings to send things out?\n\nHere is _one_ way to create a thread of the right shape:\n\n 1. Develop this 5-patch series.\n\n 2. Run\n\n    $ git format-patch -v2 --cover-letter -5\n\n    to grab 0000-cover-letter.patch to 0005-builtin-upload-...patch\n    files.  You may also want to pass --range-diff option.\n\n 3. Edit 0000-cover-letter.patch to your satisfaction.  Do not futz\n    with In-Reply-To or References or Message-Id yourself there;\n    the tool will do this part better than manual editing.\n\n 4. Run\n\n    $ git send-email --no-chain-reply-to \\\n      --in-reply-to='<20260807135511.1818458-1-christian.couder@gmail.com>' \\\n      000[0-5]-*.patch\n\n    This will make the initial message (which is the cover letter of\n    this iteration) a reply to the named message (which is the cover\n    letter of the v1 iteration), and then the remainder replies to\n    the initial message, which is what we want to see.\n\nThere surely are other right ways to do so.  As long as the end\nresult would look like\n\n    * vN (1 < N) cover letter is a reply to v1 cover letter\n    * vN patch M (0 < M) is a reply to vN cover letter\n\nthings will flow more smoothly.\n\nHTH.\n"},{"id":"550621","messageId":"CAP8UFD0Mr=6KkJShU+7hfWGZEyi--B=Y1aamPSXO7z97sbTBmQ@mail.gmail.com","threadId":"65969","inReplyTo":"xmqqy0e9rcd9.fsf@gitster.g","subject":"Re: [PATCH v2 0/5] Introduce 'uploadpack.lazyFetchTrusted'","fromName":"Christian Couder","fromEmail":"christian.couder@gmail.com","sentAt":"2026-08-14T16:31:42Z","receivedAt":"2026-08-14T16:31:57Z","isPatch":true,"body":"On Thu, Aug 13, 2026 at 10:31 PM Junio C Hamano <gitster@pobox.com> wrote:\n>\n> Christian Couder <christian.couder@gmail.com> writes:\n>\n> > Recently the \"promisor-remote\" capability was added to protocol v2,\n> > allowing servers and clients to agree on the promisor remotes they can\n> > safely use.\n>\n> I do not know what you did, but it seems that an attempt to futz\n> with the thread structure i.e.,\n>\n>     This version is also sent as a separate 'v2' iteration in reply to v1,\n>     instead of being threaded onto the previous \"Introduce a\n>     'fromAccepted' option to GIT_NO_LAZY_FETCH\" series, and it now\n>     contains a 'base-commit' trailer, so that 'b4' and other tools can\n>     find the right base and the right patches.\n>\n> made the patches totally unusable.\n>\n> This [v2 0/5] has\n>\n>     Message-ID: <20260813154748.2378747-1-christian.couder@gmail.com>\n>     In-reply-to: <20260807135511.1818458-1-christian.couder@gmail.com>\n>\n> which is *correct*.  It is a reply to [0/5] of the original thread.\n> However, [v2 1/5] says:\n>\n>     Message-ID: <20260813154748.2378747-2-christian.couder@gmail.com>\n>     In-reply-to: <20260807135511.1818458-1-christian.couder@gmail.com>\n>\n> which is quite wrong.  [v2 1/5] should be a reply to the cover\n> letter of the v2 iteration.  [v2 2/5], [v2 3/5], ... should also be\n> replies to the cover letter of the v2 iteration.\n\nYeah, sorry. I tried to pass `--in-reply-to` to `git format-patch`\ninstead of `git send-email` but it looks like they don't behave the\nsame. Maybe because I have no `format.thread` set, so `git\nformat-patch` defaults to `--no-thread`, and in that case\n`--in-reply-to` applies to every mail, not just the cover letter. I\nshould have checked more carefully and not tried to improve too many\nthings at once in my setup.\n\n> I have never seen either plain vanilla send-email or GitGitGadget\n> produce misthreaded series like this one.  Do you have some custom\n> settings to send things out?\n\nNo, I just use `git format-patch` and `git send-email` without special\nconfiguration.\n\n> Here is _one_ way to create a thread of the right shape:\n>\n>  1. Develop this 5-patch series.\n>\n>  2. Run\n>\n>     $ git format-patch -v2 --cover-letter -5\n>\n>     to grab 0000-cover-letter.patch to 0005-builtin-upload-...patch\n>     files.  You may also want to pass --range-diff option.\n>\n>  3. Edit 0000-cover-letter.patch to your satisfaction.  Do not futz\n>     with In-Reply-To or References or Message-Id yourself there;\n>     the tool will do this part better than manual editing.\n>\n>  4. Run\n>\n>     $ git send-email --no-chain-reply-to \\\n>       --in-reply-to='<20260807135511.1818458-1-christian.couder@gmail.com>' \\\n>       000[0-5]-*.patch\n\nYeah, that's pretty much what I usually do. I don't use\n`--no-chain-reply-to` though, but I will.\n\n>     This will make the initial message (which is the cover letter of\n>     this iteration) a reply to the named message (which is the cover\n>     letter of the v1 iteration), and then the remainder replies to\n>     the initial message, which is what we want to see.\n>\n> There surely are other right ways to do so.  As long as the end\n> result would look like\n>\n>     * vN (1 < N) cover letter is a reply to v1 cover letter\n>     * vN patch M (0 < M) is a reply to vN cover letter\n\nYeah I should have checked more carefully before sending. Thanks.\n\n> things will flow more smoothly.\n>\n> HTH.\n"},{"id":"550623","messageId":"xmqqv79codt6.fsf@gitster.g","threadId":"65969","inReplyTo":"CAP8UFD0Mr=6KkJShU+7hfWGZEyi--B=Y1aamPSXO7z97sbTBmQ@mail.gmail.com","subject":"Re: [PATCH v2 0/5] Introduce 'uploadpack.lazyFetchTrusted'","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-08-14T16:40:53Z","receivedAt":"2026-08-14T16:40:56Z","isPatch":true,"body":"Christian Couder <christian.couder@gmail.com> writes:\n\n>> I have never seen either plain vanilla send-email or GitGitGadget\n>> produce misthreaded series like this one.  Do you have some custom\n>> settings to send things out?\n>\n> No, I just use `git format-patch` and `git send-email` without special\n> configuration.\n\nPerhaps you are contaminating format-patch output with In-Reply-To:\nand other message-id related headers (perhaps using its options)?  I\ndon't, and I suspect your mentees probably do not, as their patches\ndo not have this issue, either.\n\n> Yeah I should have checked more carefully before sending. Thanks.\n\nThanks.\n"},{"id":"550630","messageId":"xmqqjypsoami.fsf@gitster.g","threadId":"65969","inReplyTo":"20260813154748.2378747-2-christian.couder@gmail.com","subject":"Re: [PATCH v2 1/5] promisor-remote: factor out lazy_fetch_objects()","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-08-14T17:49:41Z","receivedAt":"2026-08-14T17:49:45Z","isPatch":true,"body":"Christian Couder <christian.couder@gmail.com> writes:\n\n> +/*\n> + * Return 'true' if all the objects could be fetched, 'false' otherwise.\n> + */\n> +static bool lazy_fetch_objects(struct repository *repo,\n> +\t\t\t       struct object_id **remaining_oids,\n> +\t\t\t       int *remaining_nr,\n> +\t\t\t       int *to_free)\n> +{\n> +\tif (git_env_bool(NO_LAZY_FETCH_ENVIRONMENT, 0)) {\n> +\t\tstatic int warning_shown;\n> +\t\tif (!warning_shown) {\n> +\t\t\twarning_shown = 1;\n> +\t\t\twarning(_(\"lazy fetching disabled; some objects may not be available\"));\n> +\t\t}\n> +\t\treturn false;\n> +\t}\n> +\n> +\tpromisor_remote_init(repo);\n> +\n> +\t/* Try accepted remotes first (those the server told us to use) */\n> +\tif (try_promisor_remotes(repo, remaining_oids, remaining_nr,\n> +\t\t\t\t to_free, true))\n> +\t\treturn true;\n> +\n> +\treturn try_promisor_remotes(repo, remaining_oids, remaining_nr,\n> +\t\t\t\t    to_free, false);\n>  }\n\nPerhaps writing it this way would make it easier to tell what is\ngoing on.  We try the preferred ones first, and then fall back to\nthe other ones.\n\n        return (try_promisor_remotes(..., true) ||\n                try_promisor_remotes(..., false));\n\nBut more importantly, I wonder if keeping the list of missing object\nnames in memory will later turn out to be problematic in real-life\napplications.  Without knowing much about how the current code for\nbulk dehydrating promisor objects is structured, I expected an API\nthat looks more like:\n\n - bulk_download_begin(): performs the early part of\n   fetch_objects(), sets up connections to the promisor remote(s),\n   and calls start_command() on the child process.\n\n - bulk_download_this(): after calling the _begin() function above,\n   it runs around and collects missing objects that it needs to do\n   its work.  For each such missing object it discovers, this\n   function is called, which sends the object name down the\n   '--stdin' file descriptor.\n\n - bulk_download_done(): tells the child process that we are done\n   feeding object names.\n\nbut that is not what I am seeing.  I guess the current arrangement\ncannot be avoided, because we are going to fetch from more than one\npromisor remote.  Under such constraints, the way to deal with a\nmassive number of missing objects will not be \"streaming\" like I\nimagined above, but needs to be done differently, like spooling to a\nfile or something silly like that.\n\nIn any case, except that this avoids checking the environment\nvariable multiple times, I can see that it is a no-op refactoring of\nthe existing code.\n\nNice and cleanly done.\n\nThanks.\n"},{"id":"550631","messageId":"xmqqecg0oabe.fsf@gitster.g","threadId":"65969","inReplyTo":"20260813154748.2378747-3-christian.couder@gmail.com","subject":"Re: [PATCH v2 2/5] setup: extract path_allowlist_apply()","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-08-14T17:56:21Z","receivedAt":"2026-08-14T17:56:24Z","isPatch":true,"body":"Christian Couder <christian.couder@gmail.com> writes:\n\n> In a following commit we are going to check whether a repository is\n> part of an allowlist specified in a config variable.\n>\n> To prepare for that let's extract existing code from\n> safe_directory_cb() into a new path_allowlist_apply() helper that will\n> help with such checks.\n>\n> While at it let's make the helper's code simpler and more generic.\n>\n> Signed-off-by: Christian Couder <christian.couder@gmail.com>\n> ---\n>  setup.c | 107 +++++++++++++++++++++++++++++++-------------------------\n>  1 file changed, 59 insertions(+), 48 deletions(-)\n>\n> diff --git a/setup.c b/setup.c\n> index 95909e9603..39dfa1cc5f 100644\n> --- a/setup.c\n> +++ b/setup.c\n> @@ -1339,6 +1339,64 @@ static int canonicalize_ceiling_entry(struct string_list_item *item,\n>  \t}\n>  }\n>  \n> +static void path_allowlist_apply(const char *key, const char *value,\n> +\t\t\t\t const char *target_path, int *is_match)\n> +{\n> +\tchar *allowed = NULL;\n> +\tchar *normalized = NULL;\n> +\n> +\tif (!value || !*value) {\n> +\t\t*is_match = 0;\n> +\t\treturn;\n> +\t}\n> +\n> +\tif (!strcmp(value, \"*\")) {\n> +\t\t*is_match = 1;\n> +\t\treturn;\n> +\t}\n> +\n> +\tif (git_config_pathname(&allowed, key, value) || !allowed)\n> +\t\treturn;\n\nThe inversion of the polarity from the original here is a nice\ntouch.  We no longer have to look at deeply indented block to tell\nimmediately that nothing will happen when the configuration variable\nis not set.\n\n> +\t/*\n> +\t * Setting the config variable to a non-absolute path makes\n> +\t * little sense---it won't be relative to the configuration\n> +\t * file the item is defined in.  Except for \".\", which means\n> +\t * \"if we are at the top level of a repository, then it is\n> +\t * OK\", which is slightly tighter than \"*\" that allows\n> +\t * discovery.\n> +\t */\n> +\tif (!is_absolute_path(allowed) && strcmp(allowed, \".\")) {\n> +\t\twarning(_(\"%s '%s' not absolute\"), key, allowed);\n> +\t\tgoto end;\n> +\t}\n> +\n> +\t/*\n> +\t * A .gitconfig in $HOME may be shared across different\n> +\t * machines and the config variable entries may or may not\n> +\t * exist as paths on all of these machines.  In other words,\n> +\t * it is not a warning worthy event when there is no such path\n> +\t * on this machine---the entry may be useful elsewhere.\n> +\t */\n> +\tnormalized = real_pathdup(allowed, 0);\n> +\tif (!normalized)\n> +\t\tgoto end;\n> +\n> +\tif (ends_with(normalized, \"/*\")) {\n> +\t\tsize_t len = strlen(normalized);\n> +\t\tif (!fspathncmp(normalized, target_path, len - 1))\n> +\t\t\t*is_match = 1;\n> +\t\tgoto end;\n> +\t}\n> +\n> +\tif (!fspathcmp(target_path, normalized))\n> +\t\t*is_match = 1;\n> +\n> +end:\n> +\tfree(normalized);\n> +\tfree(allowed);\n> +}\n\nThe name \"is_match\" somehow feels a bit awkward.  How about calling\nit \n\n    *matches = true/false;\n\ninstead?\n\n"},{"id":"550634","messageId":"xmqqy0e8mv0k.fsf@gitster.g","threadId":"65969","inReplyTo":"20260813154748.2378747-4-christian.couder@gmail.com","subject":"Re: [PATCH v2 3/5] setup: add 'allow_dot' arg to path_allowlist_apply()","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-08-14T18:12:11Z","receivedAt":"2026-08-14T18:12:14Z","isPatch":true,"body":"Christian Couder <christian.couder@gmail.com> writes:\n\n> A previous commit created path_allowlist_apply() with the goal of later\n> reusing that function. But when it will be reused in a following commit\n> this function will need to reject non-absolute paths including those\n> with a single dot that are currently accepted.\n>\n> To prepare for reusing path_allowlist_apply(), let's add a\n> `bool allow_dot` argument to it, and let's export this function.\n>\n> While at it let's document it properly in \"setup.h\".\n\nIf this is just \"I want to add an extra caller that has specific\nneed and do not care about others in the future\", this may be OK but\nas a public function, this is a bit disappointing API design.  I\nexpected, as a generally useful function, you would instead add a\ncallback function to allow replacing the use of is_absoute_path()\nplus the warning there, i.e.\n\nvoid path_allowlist_apply(const char *key, const char *value,\n\t\t\t  const char *target_path, bool *matches,\n\t\t\t  bool (*allow_path)(const char *path))\n{\n\t...\n\n\tif (!allow_path(allowed))\n\t\tgoto end;\n\nAlso to avoid limiting this to configuration callback, I might\nrecommend to have it be more like this:\n\nvoid path_allowlist_apply(const char *allowed, const char *target_path,\n\t\t\t  bool *matches,\n\t\t\t  bool (*allow_path)(const char *path, void *cbdata),\n\t\t\t  void *allow_path_cbdata)\n\nwhere the original safe-directory thing may call\ngit_config_pathname() to compute allowed before calling this helper,\nand pass the address of something like:\n\n\tstruct { const char *key, *value } cbdata = {\n\t\t.key = key, .value = value;\n\t};\n\nas the cbdata, and pass something like this\n\n\tstatic bool allow_safe_dir(const char *path, void *cbdata_)\n\t{\n\t\tstruct { const char *key, *value } *cbdata = _cbdata;\n\t\tif (is_absoute_path(path) || !strcmp(path, \".\")\n\t\t\treturn true; /* ok */\n\n\t\twarning(_(\"%s '%s' not absolute\"), cbdata->key, path);\n\t\treturn false;\n\t}\n\nas the allow_path callback function.  IOW warning, or insisting on\nit being absolute, etc., does not have to be carved in stone.\n\nThanks.\n"},{"id":"550637","messageId":"xmqqtsowmszj.fsf@gitster.g","threadId":"65969","inReplyTo":"20260813154748.2378747-5-christian.couder@gmail.com","subject":"Re: [PATCH v2 4/5] upload-pack: read uploadpack.lazyFetchTrusted","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-08-14T18:56:00Z","receivedAt":"2026-08-14T18:56:04Z","isPatch":true,"body":"Christian Couder <christian.couder@gmail.com> writes:\n\n> Previous commits created and prepared the path_allowlist_apply()\n> function.\n>\n> Let's reuse this function for a new \"uploadpack.lazyFetchTrusted\"\n> configuration variable.\n>\n> It allows us to:\n>\n>   - read an allowlist from that config variable,\n>   - check if the current repo is in that list, and\n>   - return the result from a new upload_pack_lazy_fetch_trusted()\n>     function.\n>\n> The new function will be used in a following commit.\n>\n> Note that the new config variable should be read only from protected\n> configuration files.\n>\n> Signed-off-by: Christian Couder <christian.couder@gmail.com>\n> ---\n\nOK.\n\nI am not sure if the idea of configuration variable is truly sound,\nbut if it were, I agree that this is a reasonable implementation for\nit.\n\nThanks.\n\n>  upload-pack.c | 37 +++++++++++++++++++++++++++++++++++++\n>  upload-pack.h |  3 +++\n>  2 files changed, 40 insertions(+)\n>\n> diff --git a/upload-pack.c b/upload-pack.c\n> index a52856d869..29e700e43b 100644\n> --- a/upload-pack.c\n> +++ b/upload-pack.c\n> @@ -34,6 +34,8 @@\n>  #include \"json-writer.h\"\n>  #include \"strmap.h\"\n>  #include \"promisor-remote.h\"\n> +#include \"setup.h\"\n> +#include \"abspath.h\"\n>  \n>  /* Remember to update object flag allocation in object.h */\n>  #define THEY_HAVE\t(1u << 11)\n> @@ -1378,6 +1380,41 @@ static int upload_pack_config(const char *var, const char *value,\n>  \treturn parse_hide_refs_config(var, value, \"uploadpack\", &data->hidden_refs);\n>  }\n>  \n> +struct lazy_fetch_trusted {\n> +\tint trusted;\n> +\tchar *repo_path;\n> +};\n> +\n> +static int upload_pack_protected_lazy_fetch_config(const char *var, const char *value,\n> +\t\t\t\t\t\t   const struct config_context *ctx UNUSED,\n> +\t\t\t\t\t\t   void *cb_data)\n> +{\n> +\tstruct lazy_fetch_trusted *data = cb_data;\n> +\n> +\tif (!strcmp(\"uploadpack.lazyfetchtrusted\", var)) {\n> +\t\tpath_allowlist_apply(var, value, data->repo_path,\n> +\t\t\t\t     &data->trusted, false);\n> +\t\treturn 0;\n> +\t}\n> +\n> +\treturn 0;\n> +}\n> +\n> +bool upload_pack_lazy_fetch_trusted(struct repository *r)\n> +{\n> +\tstruct lazy_fetch_trusted data = { 0 };\n> +\n> +\tdata.repo_path = real_pathdup(r->worktree ? r->worktree : r->gitdir, 0);\n> +\tif (!data.repo_path)\n> +\t\treturn false;\n> +\n> +\tgit_protected_config(upload_pack_protected_lazy_fetch_config, &data);\n> +\n> +\tfree(data.repo_path);\n> +\n> +\treturn !!data.trusted;\n> +}\n> +\n>  static int upload_pack_protected_config(const char *var, const char *value,\n>  \t\t\t\t\tconst struct config_context *ctx UNUSED,\n>  \t\t\t\t\tvoid *cb_data)\n> diff --git a/upload-pack.h b/upload-pack.h\n> index d6ee25ea98..b2212992c3 100644\n> --- a/upload-pack.h\n> +++ b/upload-pack.h\n> @@ -12,4 +12,7 @@ struct strbuf;\n>  int upload_pack_advertise(struct repository *r,\n>  \t\t\t  struct strbuf *value);\n>  \n> +/* Is this repo trusted for lazy fetching? */\n> +bool upload_pack_lazy_fetch_trusted(struct repository *r);\n> +\n>  #endif /* UPLOAD_PACK_H */\n"},{"id":"550644","messageId":"xmqq1pc0mr5i.fsf@gitster.g","threadId":"65969","inReplyTo":"20260813154748.2378747-6-christian.couder@gmail.com","subject":"Re: [PATCH v2 5/5] builtin/upload-pack: set GIT_NO_LAZY_FETCH to 0 on trusted repo","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-08-14T19:35:37Z","receivedAt":"2026-08-14T19:35:39Z","isPatch":true,"body":"Christian Couder <christian.couder@gmail.com> writes:\n\n> diff --git a/Documentation/config/uploadpack.adoc b/Documentation/config/uploadpack.adoc\n> index 0e1dda944a..e960879c16 100644\n> --- a/Documentation/config/uploadpack.adoc\n> +++ b/Documentation/config/uploadpack.adoc\n> @@ -86,3 +86,45 @@ uploadpack.allowRefInWant::\n>  \tis intended for the benefit of load-balanced servers which may\n>  \tnot have the same view of what OIDs their refs point to due to\n>  \treplication delay.\n> +\n> +uploadpack.lazyFetchTrusted::\n> +\tThese config entries specify repositories that `upload-pack` is\n\nTo somebody who designed this mechanism, it may have been clear that\nyou are talking about multi-valued configuration variable, i.e.,\n\n\t[uploadpack]\n\t\tlazyFetchTrusted = repo1\n\t\tlazyFetchTrusted = repo2\n\t\t...\n\t\tlazyFetchTrusted = repoN\n\t\t\nbut the \"config entries specify repositories\" can be misread to mean\n\n\t[uploadpack]\n\t\tlazyFetchTrusted = repo1 repo2 ... repoN\n\nespecially combined with the use of verb \"list\" in \"Listing a\nrepository here tells...\" we see below.\n\n\tA multi-valued configuration variable, each of which names a\n\trepository that `upload-pack` is allowed to ...\n\nor something, perhaps.  Say that upfront to make sure readers won't\nwaste their time wondering what the syntax is.\n\nAlso, how would one specify a repository?  A URL?  Remote nickname\nused in\n\n\t[remote \"nick\"] url = ...\n\nconfiguration?  Local directory that houses another repository?\nSomething else?\n\n> +\tallowed to lazily fetch missing objects for. By default,\n> +\t`upload-pack` refuses to lazily fetch (see the description of the\n> +\t`GIT_NO_LAZY_FETCH` environment variable in\n> +\tlinkgit:git-upload-pack[1]), because doing so would run `git fetch`,\n> +\twhich may execute arbitrary commands specified in the configuration\n> +\tand hooks of the served repository. Listing a repository here tells\n> +\t`upload-pack` that it is trusted, so lazy fetching from the promisor\n> +\tremotes configured in it is allowed. This is equivalent to setting\n> +\t`GIT_NO_LAZY_FETCH` to `0` for the matching repositories. An\n> +\texplicitly set `GIT_NO_LAZY_FETCH` takes precedence over this\n> +\tsetting.\n\nIt would be interesting to set it to point at itself.  A client asks\nyou to serve a pack, you find some objects you yourself do not have\nbecause you fetched lazily from the upstream, and you end up asking\nyou if you have that object (U+1F61B Face with Stuck-Out Tongue 😛).\n\n> +Note that this allows lazy fetching from any promisor remote\n> +configured in the served repository, not only from the promisor\n> +remotes that the client accepted using the \"promisor-remote\" protocol\n> +v2 capability (see linkgit:gitprotocol-v2[5]). The served repository\n> +is trusted as a whole, including its configuration, so the promisor\n> +remotes it configures are trusted too. It is the server operator's\n> +responsibility to make sure that the promisor remotes of a trusted\n> +repository are also trustworthy.\n> ++\n> +This is a multi-valued setting, i.e. you can add more than one\n> +repository via `git config (--global|--system) --add`. To reset the\n> +list of trusted repositories (e.g. to override any such repositories\n> +specified in the system config), add a `uploadpack.lazyFetchTrusted`\n\na -> an before `uploadpack.lazyFetchTrusted`.\n\n> +entry with an empty value.\n"},{"id":"552225","messageId":"20260908164129.560396-1-christian.couder@gmail.com","threadId":"65969","inReplyTo":"20260813154748.2378747-1-christian.couder@gmail.com","subject":"[PATCH v3 0/5] Introduce 'uploadpack.lazyFetchTrusted'","fromName":"Christian Couder","fromEmail":"christian.couder@gmail.com","sentAt":"2026-09-08T16:41:24Z","receivedAt":"2026-09-08T16:41:56Z","isPatch":true,"body":"Recently the \"promisor-remote\" capability was added to protocol v2,\nallowing servers and clients to agree on the promisor remotes they can\nsafely use.\n\nThe more servers use promisor remotes, the more it is important to\nproperly control if they can lazy fetch when responding to a clone or\nfetch request from the client.\n\nFor example, in the context of large object promisors (see\n\"Documentation/technical/large-object-promisors.adoc\"), if a client\nclones with a filter set to 100kB while the server has moved all of\nthe blobs >= 10kB to a promisor remote, the server will not be able to\nprovide blobs between 10kB and 100kB to the client, which will make\nthe clone fail.\n\nEven if the `--filter=auto` option is available since ef2f1845ec\n(fetch-pack: wire up and enable auto filter logic, 2026-02-16) it's\nstill a good idea to provide more control over lazy fetching on the\nserver side to server operators, as lazy fetching on the server side\ncould be useful in corporate environments.\n\nSince 7b70e9efb1 (upload-pack: disable lazy-fetching by default,\n2024-04-16), lazy fetching has been controlled by the\n`GIT_NO_LAZY_FETCH` environment variable. This is a boolean that is\nset to 'true' by default when calling `git upload-pack` for security\nreasons.\n\nThe main security issue on the server side is making sure the served\nrepo itself is also trusted, as lazily fetching runs `git fetch`,\nwhich may execute arbitrary commands specified in the configuration\nand hooks of the served repo. The operator of the server should decide\nand mark that trust, not the served repo itself, nor the client.\n\nThis series introduces a new 'uploadpack.lazyFetchTrusted' protected\nconfiguration variable similar to 'safe.directory' (see\n\"Documentation/config/safe.adoc\") to mark trusted repos where lazy\nfetching is allowed. As it is protected, this config variable will\nonly take effect if it is set in global or system scope, so only\nserver operators can control it.\n\nPrevious related work\n=====================\n\nA previous series called \"Introduce a 'fromAccepted' option to\nGIT_NO_LAZY_FETCH\" [1] took a different approach as it wanted to make\nit easier to allow lazy fetching from accepted promisor remotes. But\nafter brian replied that he didn't think it was a good idea, and after\nthinking about this more, my opinion now is that some promisor remotes\nbeing accepted or not is not really relevant to the issue.\n\nIn my reply to brian, I said:\n\n\"\"\"\nDifferent features could be developed (in future work) to improve on\nthe current state:\n    - a way for lazy fetching to work without reading config files,\ntriggering hooks, or doing potentially sensitive things,\n    - an explicit way for operators to mark trusted repos (like\nperhaps a server-side config the operator sets per-repo),\n    - operator-defined allow/deny rules, or maybe\n    - some ways/scripts/commands to scan repos and check configuration\ninformation, remote settings and everything potentially sensitive to\ndecide if a repo looks safe enough to allow lazy fetching or not.\n\"\"\"\n\nSo I decided to go with \"an explicit way for operators to mark trusted\nrepos\" and this series is an implementation of that.\n\nNote that the feature developed in this series applies to protocol\nv0/v1 as well as v2 while the previous one was only related to v2.\n\n[1]: https://lore.kernel.org/git/CAP8UFD0_S9eg_w42tcNRnT9E2ntLr_eHLnzE4c2dSu67DzZoXg@mail.gmail.com/\n\nOverview of the patches\n=======================\n\n  - Patch 1/5 is the only patch saved from the \"Introduce a\n    'fromAccepted' option to GIT_NO_LAZY_FETCH\" series. It's not\n    necessary for the rest of this series and its main feature to\n    work, but I think it's a nice refactoring related to lazy\n    fetching, so it might as well be part of this series. There is a\n    small change in the commit message (to not mention following\n    commits) compared to the version in the previous series.\n\n  - Patch 2/5 extracts and modifies code used by the 'safe.directory'\n    config variable in new path_allowlist_config_apply() and\n    path_allowlist_apply() functions, so that these functions can be\n    reused to process 'uploadpack.lazyFetchTrusted' in the next patch.\n\n  - Patch 3/5 uses the new functions from the previous patch in a new\n    upload_pack_lazy_fetch_trusted() function to process\n    'uploadpack.lazyFetchTrusted', but the result from that processing\n    isn't actually used to have a practical effect.\n\n  - Patch 4/5, which is new in this v3, prevents infinite lazy fetch\n    recursions that the following patch would otherwise make possible.\n    If a repo is allowed to lazy fetch and one of its promisor remotes\n    resolves back to it, for example if it is its own promisor remote\n    as Junio noticed when reviewing v2, each nested `upload-pack`\n    inherits `GIT_NO_LAZY_FETCH=0` and fetches again.\n\n  - Patch 5/5 wires up the new upload_pack_lazy_fetch_trusted()\n    function to decide if lazy fetching can actually be enabled.\n\nChanges since v2\n================\n\nThanks to Junio for reviewing the previous version.\n\nRebased on top of 3cb9185f65 (The 22nd batch, 2026-09-02) as the\nprevious version was based on a quite old commit: 745601a9a9 (mailmap:\nmap Elijah Newren's current and previous work addresses, 2026-08-12)\nand I wanted to avoid possible merge issues.\n\n - Patch 1/5 has a small simplification in how try_promisor_remotes()\n   is called first with its last argument set to 'true', and then with\n   it set to 'false'. Both calls are now chained with `||`.\n\n - Patches 2/5 and 3/5 have been squashed together and reworked\n   completely into the new patch 2/5, especially:\n\n   - path_allowlist_apply() now has a\n     `bool (*allow_path)(const char *path, void *cbdata)` argument so\n     that callers can customize which paths they accept.\n\n   - A new path_allowlist_config_apply() wrapper around\n     path_allowlist_apply() has been added to avoid code duplication\n     in the callers.\n\n   - The `int *is_match` argument of path_allowlist_apply() has been\n     changed to `bool *matches` and `int is_safe` in\n     `struct safe_directory_data` has been changed to `bool safe`\n     accordingly.\n\n - Patch 3/5 (previously 4/5) has a number of changes:\n\n   - Its commit message has been improved and adapted to the 2 other\n     big changes below.\n\n   - It defines its own allow_trusted_path() function to customize the\n     paths it accepts and pass that new function to the new functions\n     from the previous commit that it uses.\n\n   - The code and commit message have been changed so that a served\n     repository is identified only by its git dir. We wrongly used to\n     say that it could also be identified by its worktree, but\n     `upload-pack` actually uses enter_repo(), so it doesn't know\n     about worktrees.\n\n   - `int trusted` has been changed to `bool trusted` and moved after\n     the other field in `struct lazy_fetch_trusted`. This matches the\n     changes to `bool *matches` and `bool safe` in the \"setup.c\" code.\n\n - Patch 4/5 is new and prevents infinite lazy fetch recursions, using\n   a new `GIT_INTERNAL_LAZY_FETCH_DEPTH` environment variable to limit\n   the nesting depth. See the patch 4/5 description above.\n\n - Patch 5/5 has a few changes:\n\n   - The `uploadpack.lazyFetchTrusted` doc has been clarified, typo\n     fixed, reorganized, and completed with information related to the\n     changes in this v3, especially:\n       - the fact that repos are identified by their git dir, and\n       - that configuring a repo as its own remote is not a good idea.\n\n   - Two tests have been added to make sure\n     `uploadpack.lazyFetchTrusted` doesn't make infinite lazy fetch\n     recursion possible, and to show that repos are identified by\n     their git dir, and cannot be identified by a worktree.\n\nCI tests\n========\n\nThey all pass except for the \"debian-11\" one which keeps failing at\nthe \"install git in container\" step with the following error:\n\n```\nE: Release file for http://deb.debian.org/debian-security/dists/bullseye-security/InRelease\nis expired (invalid since 18h 34min 49s). Updates for this repository will not be applied.\nError: Process completed with exit code 100.\n```\n\nso it is very likely unrelated to this series.\n\nSee: https://github.com/chriscool/git/actions/runs/34232995230\n\nRange-diff compared to v2\n=========================\n\n1:  1605740203 ! 1:  9403597855 promisor-remote: factor out lazy_fetch_objects()\n    @@ promisor-remote.c: static int try_promisor_remotes(struct repository *repo,\n     +\tpromisor_remote_init(repo);\n     +\n     +\t/* Try accepted remotes first (those the server told us to use) */\n    -+\tif (try_promisor_remotes(repo, remaining_oids, remaining_nr,\n    -+\t\t\t\t to_free, true))\n    -+\t\treturn true;\n    -+\n     +\treturn try_promisor_remotes(repo, remaining_oids, remaining_nr,\n    -+\t\t\t\t    to_free, false);\n    ++\t\t\t\t    to_free, true) ||\n    ++\t\ttry_promisor_remotes(repo, remaining_oids, remaining_nr,\n    ++\t\t\t\t     to_free, false);\n      }\n      \n      void promisor_remote_get_direct(struct repository *repo,\n2:  5f226b6508 < -:  ---------- setup: extract path_allowlist_apply()\n3:  051aa11fc9 < -:  ---------- setup: add 'allow_dot' arg to path_allowlist_apply()\n-:  ---------- > 2:  2155c4202d setup: extract path_allowlist_apply()\n4:  045b5e647b ! 3:  37043ffeaf upload-pack: read uploadpack.lazyFetchTrusted\n    @@ Commit message\n         upload-pack: read uploadpack.lazyFetchTrusted\n     \n         Previous commits created and prepared the path_allowlist_apply()\n    -    function.\n    +    and path_allowlist_config_apply() functions, but used them only for the\n    +    \"safe.directory\" configuration variable.\n     \n    -    Let's reuse this function for a new \"uploadpack.lazyFetchTrusted\"\n    +    Let's reuse these functions for a new \"uploadpack.lazyFetchTrusted\"\n         configuration variable.\n     \n         It allows us to:\n    @@ Commit message\n           - return the result from a new upload_pack_lazy_fetch_trusted()\n             function.\n     \n    -    The new function will be used in a following commit.\n    +    As path_allowlist_config_apply() lets each caller decide which paths\n    +    it is willing to accept using a callback, let's pass it a new\n    +    allow_trusted_path() callback. Unlike the \"safe.directory\" callback, it\n    +    accepts only absolute paths, and not \".\", as `upload-pack` always\n    +    serves a repository given by an absolute path, so there is no \"current\n    +    repository\" for \".\" to refer to.\n    +\n    +    Note that a served repository is identified by its git directory, and\n    +    not by its worktree. This is because `upload-pack` uses enter_repo()\n    +    instead of the usual repository discovery, so it never learns about a\n    +    worktree and `r->worktree` is always NULL there. In practice this\n    +    means that a non-bare repository served as \"/srv/repo\" has to be\n    +    allowlisted as \"/srv/repo/.git\".\n    +\n    +    The new upload_pack_lazy_fetch_trusted() function will be used in a\n    +    following commit.\n     \n         Note that the new config variable should be read only from protected\n         configuration files.\n    @@ upload-pack.c: static int upload_pack_config(const char *var, const char *value,\n      \treturn parse_hide_refs_config(var, value, \"uploadpack\", &data->hidden_refs);\n      }\n      \n    ++/*\n    ++ * Only absolute paths make sense here. Unlike 'safe.directory', \".\"\n    ++ * is not accepted, as the served repository is always identified by\n    ++ * an absolute path.\n    ++ */\n    ++static bool allow_trusted_path(const char *path, void *cbdata_)\n    ++{\n    ++\tstruct path_allowlist_cb_data *cbdata = cbdata_;\n    ++\n    ++\tif (is_absolute_path(path))\n    ++\t\treturn true;\n    ++\n    ++\twarning(_(\"%s '%s' not absolute\"), cbdata->key, path);\n    ++\treturn false;\n    ++}\n    ++\n     +struct lazy_fetch_trusted {\n    -+\tint trusted;\n     +\tchar *repo_path;\n    ++\tbool trusted;\n     +};\n     +\n     +static int upload_pack_protected_lazy_fetch_config(const char *var, const char *value,\n    @@ upload-pack.c: static int upload_pack_config(const char *var, const char *value,\n     +\t\t\t\t\t\t   void *cb_data)\n     +{\n     +\tstruct lazy_fetch_trusted *data = cb_data;\n    ++\tstruct path_allowlist_cb_data cbdata = { .key = var };\n     +\n    -+\tif (!strcmp(\"uploadpack.lazyfetchtrusted\", var)) {\n    -+\t\tpath_allowlist_apply(var, value, data->repo_path,\n    -+\t\t\t\t     &data->trusted, false);\n    ++\tif (strcmp(\"uploadpack.lazyfetchtrusted\", var))\n     +\t\treturn 0;\n    -+\t}\n    ++\n    ++\tpath_allowlist_config_apply(var, value, data->repo_path, &data->trusted,\n    ++\t\t\t\t    allow_trusted_path, &cbdata);\n     +\n     +\treturn 0;\n     +}\n    @@ upload-pack.c: static int upload_pack_config(const char *var, const char *value,\n     +{\n     +\tstruct lazy_fetch_trusted data = { 0 };\n     +\n    -+\tdata.repo_path = real_pathdup(r->worktree ? r->worktree : r->gitdir, 0);\n    ++\t/*\n    ++\t * A served repository is identified by its git directory, as\n    ++\t * `upload-pack` uses enter_repo() instead of the usual repository\n    ++\t * discovery, so its worktree, if any, is never known here.\n    ++\t */\n    ++\tdata.repo_path = real_pathdup(r->gitdir, 0);\n     +\tif (!data.repo_path)\n     +\t\treturn false;\n     +\n-:  ---------- > 4:  38fc060999 promisor-remote: prevent infinite recursion when lazy fetching\n5:  c116661202 ! 5:  8cb97230e5 builtin/upload-pack: set GIT_NO_LAZY_FETCH to 0 on trusted repo\n    @@ Documentation/config/uploadpack.adoc: uploadpack.allowRefInWant::\n      \treplication delay.\n     +\n     +uploadpack.lazyFetchTrusted::\n    -+\tThese config entries specify repositories that `upload-pack` is\n    -+\tallowed to lazily fetch missing objects for. By default,\n    -+\t`upload-pack` refuses to lazily fetch (see the description of the\n    -+\t`GIT_NO_LAZY_FETCH` environment variable in\n    -+\tlinkgit:git-upload-pack[1]), because doing so would run `git fetch`,\n    -+\twhich may execute arbitrary commands specified in the configuration\n    -+\tand hooks of the served repository. Listing a repository here tells\n    -+\t`upload-pack` that it is trusted, so lazy fetching from the promisor\n    -+\tremotes configured in it is allowed. This is equivalent to setting\n    -+\t`GIT_NO_LAZY_FETCH` to `0` for the matching repositories. An\n    -+\texplicitly set `GIT_NO_LAZY_FETCH` takes precedence over this\n    -+\tsetting.\n    ++\tA multi-valued configuration variable, each of which contains the\n    ++\tabsolute local path of a repository that `upload-pack` is allowed to\n    ++\tlazily fetch missing objects for.\n    +++\n    ++A repository is identified by its git directory, i.e. the `.git`\n    ++directory of a repository that has a worktree, or the repository itself\n    ++if it is bare. So a non-bare repository served as `/srv/repo` has to be\n    ++allowlisted as `/srv/repo/.git`. Giving a path with `/*` appended to it\n    ++will trust all repositories under the named directory. To trust all\n    ++served repositories, set `uploadpack.lazyFetchTrusted` to the string\n    ++`*`.\n    +++\n    ++The value of this setting is interpolated, i.e. `~/<path>` expands to a\n    ++path relative to the home directory and `%(prefix)/<path>` expands to a\n    ++path relative to Git's (runtime) prefix.\n    +++\n    ++By default, `upload-pack` refuses to lazily fetch (see the description\n    ++of the `GIT_NO_LAZY_FETCH` environment variable in\n    ++linkgit:git-upload-pack[1]), because doing so would run `git fetch`,\n    ++which may execute arbitrary commands specified in the configuration\n    ++and hooks of the served repository. Listing a repository here tells\n    ++`upload-pack` that it is trusted, so lazy fetching from the promisor\n    ++remotes configured in it is allowed. This is equivalent to setting\n    ++`GIT_NO_LAZY_FETCH` to `0` for the matching repositories. An\n    ++explicitly set `GIT_NO_LAZY_FETCH` takes precedence over this setting.\n     ++\n     +Note that this allows lazy fetching from any promisor remote\n     +configured in the served repository, not only from the promisor\n    @@ Documentation/config/uploadpack.adoc: uploadpack.allowRefInWant::\n     +is trusted as a whole, including its configuration, so the promisor\n     +remotes it configures are trusted too. It is the server operator's\n     +responsibility to make sure that the promisor remotes of a trusted\n    -+repository are also trustworthy.\n    ++repository are also trustworthy. In particular, a trusted repository\n    ++should not be configured as its own promisor remote, as `upload-pack`\n    ++would then try to lazily fetch missing objects from the repository\n    ++itself, which is pointless.\n     ++\n    -+This is a multi-valued setting, i.e. you can add more than one\n    ++As this is a multi-valued setting, you can add more than one\n     +repository via `git config (--global|--system) --add`. To reset the\n     +list of trusted repositories (e.g. to override any such repositories\n    -+specified in the system config), add a `uploadpack.lazyFetchTrusted`\n    ++specified in the system config), add an `uploadpack.lazyFetchTrusted`\n     +entry with an empty value.\n     ++\n    -+A repository is identified by its worktree, or its git directory for a bare\n    -+repository, and the value must be an absolute path. Giving a path with `/*`\n    -+appended to it will trust all repositories under the named directory. To trust\n    -+all served repositories, set `uploadpack.lazyFetchTrusted` to the string `*`.\n    -++\n    -+The value of this setting is interpolated, i.e. `~/<path>` expands to a\n    -+path relative to the home directory and `%(prefix)/<path>` expands to a\n    -+path relative to Git's (runtime) prefix.\n    -++\n    -+Note that this configuration variable is only respected when it is specified\n    -+in protected configuration (see <<SCOPES>>). This prevents untrusted\n    -+repositories from tampering with this value.\n    ++Note that this configuration variable is only respected when it is\n    ++specified in protected configuration (see <<SCOPES>>). This prevents\n    ++untrusted repositories from tampering with this value.\n     \n      ## Documentation/git-upload-pack.adoc ##\n     @@ Documentation/git-upload-pack.adoc: This is implemented by having `upload-pack` internally set the\n    @@ t/t5710-promisor-remote-capability.sh: test_expect_success \"clone with promisor.\n     +\t# Check that the largest object is still missing on the server\n     +\tcheck_missing_objects server 1 \"$oid\"\n     +'\n    ++\n    ++test_expect_success \"trusted repo as its own promisor remote does not recurse\" '\n    ++\t# No promisors are advertised\n    ++\tgit -C server config promisor.advertise false &&\n    ++\ttest_when_finished \"rm -rf client\" &&\n    ++\n    ++\t# Add itself as its own remote\n    ++\tgit -C server remote add self \"$TRASH_DIRECTORY_URL/server\" &&\n    ++\tgit -C server config remote.self.promisor true &&\n    ++\ttest_when_finished \"git -C server remote remove self\" &&\n    ++\n    ++\t# Make \"self\" the only promisor remote of the server, so that it\n    ++\t# cannot get the missing object from \"lop\". Note that\n    ++\t# \"remote.lop.partialCloneFilter\" also makes \"lop\" a promisor\n    ++\t# remote, so it has to be unset too.\n    ++\tgit -C server config --unset remote.lop.promisor &&\n    ++\ttest_when_finished \"git -C server config remote.lop.promisor true\" &&\n    ++\tlop_filter=\"$(git -C server config remote.lop.partialCloneFilter)\" &&\n    ++\tgit -C server config --unset remote.lop.partialCloneFilter &&\n    ++\ttest_when_finished \"git -C server config remote.lop.partialCloneFilter \\\"$lop_filter\\\"\" &&\n    ++\n    ++\t# Allow lazy fetching from itself\n    ++\ttest_config_global uploadpack.lazyFetchTrusted \"$(pwd)/server\" &&\n    ++\n    ++\t# Check that lazy fetching fails\n    ++\ttest_must_fail git clone --no-local --filter=\"blob:limit=5k\" server client 2>err &&\n    ++\ttest_grep \"too many nested lazy fetches\" err &&\n    ++\n    ++\t# Check that the largest object is still missing on the server\n    ++\tcheck_missing_objects server 1 \"$oid\"\n    ++'\n    ++\n    ++test_expect_success \"uploadpack.lazyFetchTrusted needs the git dir of a non-bare repo\" '\n    ++\ttest_when_finished \"rm -rf nonbare client client2\" &&\n    ++\n    ++\t# Create a non-bare repo, without any worktree content, so that\n    ++\t# its largest object can be filtered out below\n    ++\tgit init nonbare &&\n    ++\tgit -C nonbare remote add origin \"$TRASH_DIRECTORY_URL/template\" &&\n    ++\tgit -C nonbare fetch origin &&\n    ++\tgit -C nonbare update-ref HEAD FETCH_HEAD &&\n    ++\n    ++\tgit -C nonbare remote add lop \"$TRASH_DIRECTORY_URL/lop\" &&\n    ++\tgit -C nonbare config remote.lop.promisor true &&\n    ++\tgit -C nonbare config uploadpack.allowFilter true &&\n    ++\tgit -C nonbare config uploadpack.allowAnySHA1InWant true &&\n    ++\tgit -C nonbare config promisor.advertise false &&\n    ++\n    ++\t# Repack everything, then repack without the largest object and\n    ++\t# create a promisor pack, like initialize_server() does\n    ++\tgit -C nonbare -c repack.writebitmaps=false repack -a -d &&\n    ++\trm -f nonbare/.git/objects/pack/*.promisor &&\n    ++\tgit -C nonbare -c repack.writebitmaps=false repack -a -d \\\n    ++\t\t--filter=blob:limit=5k --filter-to=\"$(pwd)/nonbare-pack\" &&\n    ++\tpromisor_file=$(ls nonbare/.git/objects/pack/*.pack | sed \"s/\\.pack/.promisor/\") &&\n    ++\t>\"$promisor_file\" &&\n    ++\tcheck_missing_objects nonbare 1 \"$oid\" &&\n    ++\n    ++\t# The worktree path does not identify the repo, so it is not\n    ++\t# trusted and the clone fails\n    ++\ttest_config_global uploadpack.lazyFetchTrusted \"$(pwd)/nonbare\" &&\n    ++\ttest_must_fail git clone --no-local --filter=\"blob:limit=1k\" \\\n    ++\t\tnonbare client 2>err &&\n    ++\ttest_grep \"lazy fetching disabled\" err &&\n    ++\tcheck_missing_objects nonbare 1 \"$oid\" &&\n    ++\n    ++\t# The git dir identifies the repo, so it is trusted and the\n    ++\t# clone succeeds\n    ++\ttest_config_global uploadpack.lazyFetchTrusted \"$(pwd)/nonbare/.git\" &&\n    ++\tgit clone --no-local --filter=\"blob:limit=1k\" nonbare client2 &&\n    ++\tcheck_missing_objects nonbare 0 \"\"\n    ++'\n     +\n      test_expect_success \"init + fetch with promisor.advertise set to 'true'\" '\n      \tgit -C server config promisor.advertise true &&\n\n\nChristian Couder (5):\n  promisor-remote: factor out lazy_fetch_objects()\n  setup: extract path_allowlist_apply()\n  upload-pack: read uploadpack.lazyFetchTrusted\n  promisor-remote: prevent infinite recursion when lazy fetching\n  builtin/upload-pack: set GIT_NO_LAZY_FETCH to 0 on trusted repo\n\n Documentation/config/uploadpack.adoc  |  49 +++++++++\n Documentation/git-upload-pack.adoc    |   5 +\n Documentation/git.adoc                |   4 +-\n builtin/upload-pack.c                 |  11 ++\n environment.h                         |   8 ++\n promisor-remote.c                     |  96 +++++++++++------\n setup.c                               | 138 ++++++++++++++++---------\n setup.h                               |  50 +++++++++\n t/t0410-partial-clone.sh              |  33 ++++++\n t/t5710-promisor-remote-capability.sh | 142 ++++++++++++++++++++++++++\n upload-pack.c                         |  59 +++++++++++\n upload-pack.h                         |   3 +\n 12 files changed, 514 insertions(+), 84 deletions(-)\n\n\nbase-commit: 3cb9185f65410273787f74333cc027d2ea5daada\n-- \n2.55.0.792.ged91fccac1.dirty\n\n"},{"id":"552226","messageId":"20260908164129.560396-2-christian.couder@gmail.com","threadId":"65969","inReplyTo":"20260908164129.560396-1-christian.couder@gmail.com","subject":"[PATCH v3 1/5] promisor-remote: factor out lazy_fetch_objects()","fromName":"Christian Couder","fromEmail":"christian.couder@gmail.com","sentAt":"2026-09-08T16:41:25Z","receivedAt":"2026-09-08T16:41:57Z","isPatch":true,"body":"In \"promisor-remote.c:fetch_objects()\", there is a check to disable\nlazy fetching when the `GIT_NO_LAZY_FETCH` environment variable is\nset. The fetch_objects() function is called once per promisor remote\nthough. So the check might be performed more times than necessary.\n\nAlso promisor_remote_get_direct() mixes up the logic deciding which\npromisor remotes to try with the logic checking that the objects\nthat could not be fetched are promisor objects.\n\nLet's refactor the lazy fetching logic out of these two functions\ninto a new lazy_fetch_objects() function.\n\nThis is a pure refactoring with no intended behavior change. Two\nthings shift in ways that are observably equivalent though:\n\n  - the `GIT_NO_LAZY_FETCH` check is now performed once up front,\n    instead of once per promisor remote, and\n\n  - promisor_remote_init() is no longer called when lazy fetching\n    is disabled, which is fine as nothing downstream of it, like\n    is_promisor_object(), needs it in that case.\n\nWhile at it, let's also convert try_promisor_remotes() to return\n'bool' instead of 'int', as it just returns whether all the objects\ncould be fetched, and document its return value.\n\nSigned-off-by: Christian Couder <christian.couder@gmail.com>\n---\n promisor-remote.c | 74 +++++++++++++++++++++++++++--------------------\n 1 file changed, 43 insertions(+), 31 deletions(-)\n\ndiff --git a/promisor-remote.c b/promisor-remote.c\nindex 43505d1e1a..df17fec3bb 100644\n--- a/promisor-remote.c\n+++ b/promisor-remote.c\n@@ -31,15 +31,6 @@ static int fetch_objects(struct repository *repo,\n \tFILE *child_in;\n \tint quiet;\n \n-\tif (git_env_bool(NO_LAZY_FETCH_ENVIRONMENT, 0)) {\n-\t\tstatic int warning_shown;\n-\t\tif (!warning_shown) {\n-\t\t\twarning_shown = 1;\n-\t\t\twarning(_(\"lazy fetching disabled; some objects may not be available\"));\n-\t\t}\n-\t\treturn -1;\n-\t}\n-\n \tchild.git_cmd = 1;\n \tchild.in = -1;\n \tif (repo != the_repository)\n@@ -270,10 +261,15 @@ static int remove_fetched_oids(struct repository *repo,\n \treturn remaining_nr;\n }\n \n-static int try_promisor_remotes(struct repository *repo,\n-\t\t\t\tstruct object_id **remaining_oids,\n-\t\t\t\tint *remaining_nr, int *to_free,\n-\t\t\t\tbool accepted_only)\n+/*\n+ * Return 'true' if all the objects could be fetched from the\n+ * (non-)accepted remotes, 'false' otherwise.\n+ */\n+static bool try_promisor_remotes(struct repository *repo,\n+\t\t\t\t struct object_id **remaining_oids,\n+\t\t\t\t int *remaining_nr,\n+\t\t\t\t int *to_free,\n+\t\t\t\t bool accepted_only)\n {\n \tstruct promisor_remote *r = repo->promisor_remote_config->promisors;\n \n@@ -290,9 +286,35 @@ static int try_promisor_remotes(struct repository *repo,\n \t\t\t\tcontinue;\n \t\t\t}\n \t\t}\n-\t\treturn 1; /* all fetched */\n+\t\treturn true; /* all fetched */\n \t}\n-\treturn 0;\n+\treturn false;\n+}\n+\n+/*\n+ * Return 'true' if all the objects could be fetched, 'false' otherwise.\n+ */\n+static bool lazy_fetch_objects(struct repository *repo,\n+\t\t\t       struct object_id **remaining_oids,\n+\t\t\t       int *remaining_nr,\n+\t\t\t       int *to_free)\n+{\n+\tif (git_env_bool(NO_LAZY_FETCH_ENVIRONMENT, 0)) {\n+\t\tstatic int warning_shown;\n+\t\tif (!warning_shown) {\n+\t\t\twarning_shown = 1;\n+\t\t\twarning(_(\"lazy fetching disabled; some objects may not be available\"));\n+\t\t}\n+\t\treturn false;\n+\t}\n+\n+\tpromisor_remote_init(repo);\n+\n+\t/* Try accepted remotes first (those the server told us to use) */\n+\treturn try_promisor_remotes(repo, remaining_oids, remaining_nr,\n+\t\t\t\t    to_free, true) ||\n+\t\ttry_promisor_remotes(repo, remaining_oids, remaining_nr,\n+\t\t\t\t     to_free, false);\n }\n \n void promisor_remote_get_direct(struct repository *repo,\n@@ -302,28 +324,18 @@ void promisor_remote_get_direct(struct repository *repo,\n \tstruct object_id *remaining_oids = (struct object_id *)oids;\n \tint remaining_nr = oid_nr;\n \tint to_free = 0;\n-\tint i;\n \n \tif (oid_nr == 0)\n \t\treturn;\n \n-\tpromisor_remote_init(repo);\n-\n-\t/* Try accepted remotes first (those the server told us to use) */\n-\tif (try_promisor_remotes(repo, &remaining_oids, &remaining_nr,\n-\t\t\t\t &to_free, true))\n-\t\tgoto all_fetched;\n-\tif (try_promisor_remotes(repo, &remaining_oids, &remaining_nr,\n-\t\t\t\t &to_free, false))\n-\t\tgoto all_fetched;\n-\n-\tfor (i = 0; i < remaining_nr; i++) {\n-\t\tif (is_promisor_object(repo, &remaining_oids[i]))\n-\t\t\tdie(_(\"could not fetch %s from promisor remote\"),\n-\t\t\t    oid_to_hex(&remaining_oids[i]));\n+\tif (!lazy_fetch_objects(repo, &remaining_oids, &remaining_nr, &to_free)) {\n+\t\tfor (int i = 0; i < remaining_nr; i++) {\n+\t\t\tif (is_promisor_object(repo, &remaining_oids[i]))\n+\t\t\t\tdie(_(\"could not fetch %s from promisor remote\"),\n+\t\t\t\t    oid_to_hex(&remaining_oids[i]));\n+\t\t}\n \t}\n \n-all_fetched:\n \tif (to_free)\n \t\tfree(remaining_oids);\n }\n-- \n2.55.0.792.ged91fccac1.dirty\n\n"},{"id":"552227","messageId":"20260908164129.560396-3-christian.couder@gmail.com","threadId":"65969","inReplyTo":"20260908164129.560396-1-christian.couder@gmail.com","subject":"[PATCH v3 2/5] setup: extract path_allowlist_apply()","fromName":"Christian Couder","fromEmail":"christian.couder@gmail.com","sentAt":"2026-09-08T16:41:26Z","receivedAt":"2026-09-08T16:41:59Z","isPatch":true,"body":"In a following commit we are going to check whether a repository is\npart of an allowlist specified in a config variable.\n\nTo prepare for that let's extract existing code from\nsafe_directory_cb() into a new path_allowlist_apply() helper that will\nhelp with such checks.\n\nWhile at it let's make the helper's code simpler and more generic, by\npassing it a `bool (*allow_path)(const char *path, void *cbdata)`\nfunction that decides if a path is acceptable by the caller.\n\nTo further simplify how to reuse that new helper, and avoid duplicating\nthe config-value handling in a future commit, let's also introduce a\npath_allowlist_config_apply() helper.\n\nFor clarity, let's change the `int is_safe` to `bool safe` in\n`struct safe_directory_data`.\n\nSigned-off-by: Christian Couder <christian.couder@gmail.com>\n---\n setup.c | 138 ++++++++++++++++++++++++++++++++++++--------------------\n setup.h |  50 ++++++++++++++++++++\n 2 files changed, 138 insertions(+), 50 deletions(-)\n\ndiff --git a/setup.c b/setup.c\nindex dfe05d9a03..366a7dc5c0 100644\n--- a/setup.c\n+++ b/setup.c\n@@ -1338,67 +1338,105 @@ static int canonicalize_ceiling_entry(struct string_list_item *item,\n \t}\n }\n \n+void path_allowlist_apply(const char *allowed, const char *target_path,\n+\t\t\t  bool *matches,\n+\t\t\t  bool (*allow_path)(const char *path, void *cbdata),\n+\t\t\t  void *allow_path_cbdata)\n+{\n+\tchar *normalized = NULL;\n+\n+\tif (!allowed || !*allowed) {\n+\t\t*matches = false;\n+\t\treturn;\n+\t}\n+\n+\tif (!strcmp(allowed, \"*\")) {\n+\t\t*matches = true;\n+\t\treturn;\n+\t}\n+\n+\tif (!allow_path(allowed, allow_path_cbdata))\n+\t\treturn;\n+\n+\t/*\n+\t * A .gitconfig in $HOME may be shared across different\n+\t * machines and the config variable entries may or may not\n+\t * exist as paths on all of these machines.  In other words,\n+\t * it is not a warning worthy event when there is no such path\n+\t * on this machine---the entry may be useful elsewhere.\n+\t */\n+\tnormalized = real_pathdup(allowed, 0);\n+\tif (!normalized)\n+\t\treturn;\n+\n+\tif (ends_with(normalized, \"/*\")) {\n+\t\tsize_t len = strlen(normalized);\n+\t\tif (!fspathncmp(normalized, target_path, len - 1))\n+\t\t\t*matches = true;\n+\t} else if (!fspathcmp(target_path, normalized)) {\n+\t\t*matches = true;\n+\t}\n+\n+\tfree(normalized);\n+}\n+\n+void path_allowlist_config_apply(const char *key, const char *value,\n+\t\t\t\t const char *target_path, bool *matches,\n+\t\t\t\t bool (*allow_path)(const char *path, void *cbdata),\n+\t\t\t\t void *allow_path_cbdata)\n+{\n+\tchar *allowed = NULL;\n+\n+\tif (!value || !*value || !strcmp(value, \"*\")) {\n+\t\tpath_allowlist_apply(value, target_path, matches,\n+\t\t\t\t     allow_path, allow_path_cbdata);\n+\t\treturn;\n+\t}\n+\n+\tif (git_config_pathname(&allowed, key, value) || !allowed)\n+\t\treturn;\n+\n+\tpath_allowlist_apply(allowed, target_path, matches,\n+\t\t\t     allow_path, allow_path_cbdata);\n+\n+\tfree(allowed);\n+}\n+\n+/*\n+ * Setting the config variable to a non-absolute path makes\n+ * little sense---it won't be relative to the configuration\n+ * file the item is defined in.  Except for \".\", which means\n+ * \"if we are at the top level of a repository, then it is\n+ * OK\", which is slightly tighter than \"*\" that allows\n+ * discovery.\n+ */\n+static bool allow_safe_dir(const char *path, void *cbdata_)\n+{\n+\tstruct path_allowlist_cb_data *cbdata = cbdata_;\n+\n+\tif (is_absolute_path(path) || !strcmp(path, \".\"))\n+\t\treturn true;\n+\n+\twarning(_(\"%s '%s' not absolute\"), cbdata->key, path);\n+\treturn false;\n+}\n+\n struct safe_directory_data {\n \tchar *path;\n-\tint is_safe;\n+\tbool safe;\n };\n \n static int safe_directory_cb(const char *key, const char *value,\n \t\t\t     const struct config_context *ctx UNUSED, void *d)\n {\n \tstruct safe_directory_data *data = d;\n+\tstruct path_allowlist_cb_data cbdata = { .key = key };\n \n \tif (strcmp(key, \"safe.directory\"))\n \t\treturn 0;\n \n-\tif (!value || !*value) {\n-\t\tdata->is_safe = 0;\n-\t} else if (!strcmp(value, \"*\")) {\n-\t\tdata->is_safe = 1;\n-\t} else {\n-\t\tchar *allowed = NULL;\n-\n-\t\tif (!git_config_pathname(&allowed, key, value) && allowed) {\n-\t\t\tchar *normalized = NULL;\n-\n-\t\t\t/*\n-\t\t\t * Setting safe.directory to a non-absolute path\n-\t\t\t * makes little sense---it won't be relative to\n-\t\t\t * the configuration file the item is defined in.\n-\t\t\t * Except for \".\", which means \"if we are at the top\n-\t\t\t * level of a repository, then it is OK\", which is\n-\t\t\t * slightly tighter than \"*\" that allows discovery.\n-\t\t\t */\n-\t\t\tif (!is_absolute_path(allowed) && strcmp(allowed, \".\")) {\n-\t\t\t\twarning(_(\"safe.directory '%s' not absolute\"),\n-\t\t\t\t\tallowed);\n-\t\t\t\tgoto next;\n-\t\t\t}\n-\n-\t\t\t/*\n-\t\t\t * A .gitconfig in $HOME may be shared across\n-\t\t\t * different machines and safe.directory entries\n-\t\t\t * may or may not exist as paths on all of these\n-\t\t\t * machines.  In other words, it is not a warning\n-\t\t\t * worthy event when there is no such path on this\n-\t\t\t * machine---the entry may be useful elsewhere.\n-\t\t\t */\n-\t\t\tnormalized = real_pathdup(allowed, 0);\n-\t\t\tif (!normalized)\n-\t\t\t\tgoto next;\n-\n-\t\t\tif (ends_with(normalized, \"/*\")) {\n-\t\t\t\tsize_t len = strlen(normalized);\n-\t\t\t\tif (!fspathncmp(normalized, data->path, len - 1))\n-\t\t\t\t\tdata->is_safe = 1;\n-\t\t\t} else if (!fspathcmp(data->path, normalized)) {\n-\t\t\t\tdata->is_safe = 1;\n-\t\t\t}\n-\t\tnext:\n-\t\t\tfree(normalized);\n-\t\t\tfree(allowed);\n-\t\t}\n-\t}\n+\tpath_allowlist_config_apply(key, value, data->path, &data->safe,\n+\t\t\t\t    allow_safe_dir, &cbdata);\n \n \treturn 0;\n }\n@@ -1440,7 +1478,7 @@ static int ensure_valid_ownership(const char *gitfile,\n \tgit_protected_config(safe_directory_cb, &data);\n \n \tfree(data.path);\n-\treturn data.is_safe;\n+\treturn data.safe;\n }\n \n void die_upon_dubious_ownership(const char *gitfile, const char *worktree,\ndiff --git a/setup.h b/setup.h\nindex 763fd384e8..6b84fbe507 100644\n--- a/setup.h\n+++ b/setup.h\n@@ -304,4 +304,54 @@ struct startup_info {\n extern struct startup_info *startup_info;\n extern const char *tmp_original_cwd;\n \n+/* Path allowlist */\n+\n+struct path_allowlist_cb_data {\n+\tconst char *key;\n+};\n+\n+/*\n+ * Check the allowlist entry in `allowed` against `target_path`,\n+ * updating `*matches` accordingly.\n+ *\n+ * `allowed` is a single entry of an allowlist of paths, typically one\n+ * value of a multi-valued config variable, already expanded by\n+ * git_config_pathname(). `target_path` is the (normalized) path being\n+ * tested. `*matches` is updated in place:\n+ *\n+ *   - an empty `allowed` resets it to 'false' (so a later, more\n+ *     specific config scope can clear entries from a broader one),\n+ *   - \"*\" sets it to 'true' (allow everything),\n+ *   - \"<path>\" sets it to 'true' if <path> equals `target_path`,\n+ *   - \"<path>\" + \"/\" + \"*\" sets it to 'true' if <path> is a leading\n+ *     directory of `target_path`,\n+ *   - anything else leaves `*matches` unchanged.\n+ *\n+ * `allow_path` is called with `allowed` and `allow_path_cbdata`, and\n+ * should return 'true' if the entry is acceptable to the caller. It\n+ * lets each caller decide which paths it is willing to consider, and\n+ * whether to warn about the ones it rejects. Returning 'false' leaves\n+ * `*matches` unchanged.\n+ *\n+ * Callers are expected to invoke this once per allowlist entry,\n+ * typically from a protected-config callback, so that untrusted\n+ * repository config cannot influence the decision.\n+ */\n+void path_allowlist_apply(const char *allowed, const char *target_path,\n+\t\t\t  bool *matches,\n+\t\t\t  bool (*allow_path)(const char *path, void *cbdata),\n+\t\t\t  void *allow_path_cbdata);\n+\n+/*\n+ * Apply one value of a multi-valued config variable holding an\n+ * allowlist of paths, expanding it with git_config_pathname() before\n+ * checking it against `target_path`. Empty and \"*\" values are passed\n+ * through without expansion, as interpolating them is not\n+ * meaningful. See path_allowlist_apply().\n+ */\n+void path_allowlist_config_apply(const char *key, const char *value,\n+\t\t\t\t const char *target_path, bool *matches,\n+\t\t\t\t bool (*allow_path)(const char *path, void *cbdata),\n+\t\t\t\t void *allow_path_cbdata);\n+\n #endif /* SETUP_H */\n-- \n2.55.0.792.ged91fccac1.dirty\n\n"},{"id":"552228","messageId":"20260908164129.560396-4-christian.couder@gmail.com","threadId":"65969","inReplyTo":"20260908164129.560396-1-christian.couder@gmail.com","subject":"[PATCH v3 3/5] upload-pack: read uploadpack.lazyFetchTrusted","fromName":"Christian Couder","fromEmail":"christian.couder@gmail.com","sentAt":"2026-09-08T16:41:27Z","receivedAt":"2026-09-08T16:42:00Z","isPatch":true,"body":"Previous commits created and prepared the path_allowlist_apply()\nand path_allowlist_config_apply() functions, but used them only for the\n\"safe.directory\" configuration variable.\n\nLet's reuse these functions for a new \"uploadpack.lazyFetchTrusted\"\nconfiguration variable.\n\nIt allows us to:\n\n  - read an allowlist from that config variable,\n  - check if the current repo is in that list, and\n  - return the result from a new upload_pack_lazy_fetch_trusted()\n    function.\n\nAs path_allowlist_config_apply() lets each caller decide which paths\nit is willing to accept using a callback, let's pass it a new\nallow_trusted_path() callback. Unlike the \"safe.directory\" callback, it\naccepts only absolute paths, and not \".\", as `upload-pack` always\nserves a repository given by an absolute path, so there is no \"current\nrepository\" for \".\" to refer to.\n\nNote that a served repository is identified by its git directory, and\nnot by its worktree. This is because `upload-pack` uses enter_repo()\ninstead of the usual repository discovery, so it never learns about a\nworktree and `r->worktree` is always NULL there. In practice this\nmeans that a non-bare repository served as \"/srv/repo\" has to be\nallowlisted as \"/srv/repo/.git\".\n\nThe new upload_pack_lazy_fetch_trusted() function will be used in a\nfollowing commit.\n\nNote that the new config variable should be read only from protected\nconfiguration files.\n\nSigned-off-by: Christian Couder <christian.couder@gmail.com>\n---\n upload-pack.c | 59 +++++++++++++++++++++++++++++++++++++++++++++++++++\n upload-pack.h |  3 +++\n 2 files changed, 62 insertions(+)\n\ndiff --git a/upload-pack.c b/upload-pack.c\nindex 22573ad365..a300870fa9 100644\n--- a/upload-pack.c\n+++ b/upload-pack.c\n@@ -34,6 +34,8 @@\n #include \"json-writer.h\"\n #include \"strmap.h\"\n #include \"promisor-remote.h\"\n+#include \"setup.h\"\n+#include \"abspath.h\"\n \n /* Remember to update object flag allocation in object.h */\n #define THEY_HAVE\t(1u << 11)\n@@ -1343,6 +1345,63 @@ static int upload_pack_config(const char *var, const char *value,\n \treturn parse_hide_refs_config(var, value, \"uploadpack\", &data->hidden_refs);\n }\n \n+/*\n+ * Only absolute paths make sense here. Unlike 'safe.directory', \".\"\n+ * is not accepted, as the served repository is always identified by\n+ * an absolute path.\n+ */\n+static bool allow_trusted_path(const char *path, void *cbdata_)\n+{\n+\tstruct path_allowlist_cb_data *cbdata = cbdata_;\n+\n+\tif (is_absolute_path(path))\n+\t\treturn true;\n+\n+\twarning(_(\"%s '%s' not absolute\"), cbdata->key, path);\n+\treturn false;\n+}\n+\n+struct lazy_fetch_trusted {\n+\tchar *repo_path;\n+\tbool trusted;\n+};\n+\n+static int upload_pack_protected_lazy_fetch_config(const char *var, const char *value,\n+\t\t\t\t\t\t   const struct config_context *ctx UNUSED,\n+\t\t\t\t\t\t   void *cb_data)\n+{\n+\tstruct lazy_fetch_trusted *data = cb_data;\n+\tstruct path_allowlist_cb_data cbdata = { .key = var };\n+\n+\tif (strcmp(\"uploadpack.lazyfetchtrusted\", var))\n+\t\treturn 0;\n+\n+\tpath_allowlist_config_apply(var, value, data->repo_path, &data->trusted,\n+\t\t\t\t    allow_trusted_path, &cbdata);\n+\n+\treturn 0;\n+}\n+\n+bool upload_pack_lazy_fetch_trusted(struct repository *r)\n+{\n+\tstruct lazy_fetch_trusted data = { 0 };\n+\n+\t/*\n+\t * A served repository is identified by its git directory, as\n+\t * `upload-pack` uses enter_repo() instead of the usual repository\n+\t * discovery, so its worktree, if any, is never known here.\n+\t */\n+\tdata.repo_path = real_pathdup(r->gitdir, 0);\n+\tif (!data.repo_path)\n+\t\treturn false;\n+\n+\tgit_protected_config(upload_pack_protected_lazy_fetch_config, &data);\n+\n+\tfree(data.repo_path);\n+\n+\treturn !!data.trusted;\n+}\n+\n static int upload_pack_protected_config(const char *var, const char *value,\n \t\t\t\t\tconst struct config_context *ctx UNUSED,\n \t\t\t\t\tvoid *cb_data)\ndiff --git a/upload-pack.h b/upload-pack.h\nindex d6ee25ea98..b2212992c3 100644\n--- a/upload-pack.h\n+++ b/upload-pack.h\n@@ -12,4 +12,7 @@ struct strbuf;\n int upload_pack_advertise(struct repository *r,\n \t\t\t  struct strbuf *value);\n \n+/* Is this repo trusted for lazy fetching? */\n+bool upload_pack_lazy_fetch_trusted(struct repository *r);\n+\n #endif /* UPLOAD_PACK_H */\n-- \n2.55.0.792.ged91fccac1.dirty\n\n"},{"id":"552229","messageId":"20260908164129.560396-5-christian.couder@gmail.com","threadId":"65969","inReplyTo":"20260908164129.560396-1-christian.couder@gmail.com","subject":"[PATCH v3 4/5] promisor-remote: prevent infinite recursion when lazy fetching","fromName":"Christian Couder","fromEmail":"christian.couder@gmail.com","sentAt":"2026-09-08T16:41:28Z","receivedAt":"2026-09-08T16:42:01Z","isPatch":true,"body":"If a repository R is configured to lazy fetch from a promisor remote P\nwhich is also configured to in turn lazy fetch from R, there is an\ninfinite recursion: R asks P for a missing object, P asks R for it,\nand so on. The simplest case of this is a repository configured as its\nown promisor remote.\n\nThis is not reachable when serving a repository by default, as\n`upload-pack` sets `GIT_NO_LAZY_FETCH` to 1, which makes the nested\n`upload-pack` refuse to lazily fetch. A following commit will let\nserver operators allow lazy fetching for repositories they trust\nthough, and as `GIT_NO_LAZY_FETCH` is then set to 0 and passed down to\nchild processes, nothing stops the recursion anymore.\n\nIt does not recurse forever in practice, but only because each level\nadds one more variable to the environment of the child process, so\nafter a while `exec()` fails with:\n\n    fatal: cannot exec 'git-upload-pack ...': Argument list too long\n    fatal: unable to fork\n\nTo avoid this pathological case altogether, let's use a new\n`GIT_INTERNAL_LAZY_FETCH_DEPTH` to count the recursion depth, and let's\ncheck that it doesn't exceed a MAX_LAZY_FETCH_DEPTH limit (set to 5 for\nnow).\n\nNote that some nesting is legitimate: when `git fetch` runs\n`index-pack`, it can lazily fetch REF_DELTA bases that are missing\nlocally, so the limit should not be 1.\n\nSigned-off-by: Christian Couder <christian.couder@gmail.com>\n---\n environment.h            |  8 ++++++++\n promisor-remote.c        | 26 ++++++++++++++++++++++----\n t/t0410-partial-clone.sh | 33 +++++++++++++++++++++++++++++++++\n 3 files changed, 63 insertions(+), 4 deletions(-)\n\ndiff --git a/environment.h b/environment.h\nindex e7ec5b0437..f2833be9fe 100644\n--- a/environment.h\n+++ b/environment.h\n@@ -52,6 +52,14 @@\n  */\n #define GIT_ADVICE_ENVIRONMENT \"GIT_ADVICE\"\n \n+/*\n+ * Environment variable used to detect that a lazy fetch is already in\n+ * progress in a parent process, to prevent infinite recursion when a\n+ * promisor remote resolves back to the repository being served.\n+ * This is an internal variable that should not be set by the user.\n+ */\n+#define LAZY_FETCH_DEPTH_ENVIRONMENT \"GIT_INTERNAL_LAZY_FETCH_DEPTH\"\n+\n /*\n  * Environment variable used in handshaking the wire protocol.\n  * Contains a colon ':' separated list of keys with optional values\ndiff --git a/promisor-remote.c b/promisor-remote.c\nindex df17fec3bb..e9c5b413f1 100644\n--- a/promisor-remote.c\n+++ b/promisor-remote.c\n@@ -24,7 +24,7 @@ struct promisor_remote_config {\n static int fetch_objects(struct repository *repo,\n \t\t\t const char *remote_name,\n \t\t\t const struct object_id *oids,\n-\t\t\t int oid_nr)\n+\t\t\t int oid_nr, unsigned long depth)\n {\n \tstruct child_process child = CHILD_PROCESS_INIT;\n \tint i;\n@@ -41,6 +41,7 @@ static int fetch_objects(struct repository *repo,\n \t\t     \"--filter=blob:none\", \"--stdin\", NULL);\n \tif (!repo_config_get_bool(repo, \"promisor.quiet\", &quiet) && quiet)\n \t\tstrvec_push(&child.args, \"--quiet\");\n+\tstrvec_pushf(&child.env, \"%s=%lu\", LAZY_FETCH_DEPTH_ENVIRONMENT, depth + 1);\n \tif (start_command(&child))\n \t\tdie(_(\"promisor-remote: unable to fork off fetch subprocess\"));\n \tchild_in = xfdopen(child.in, \"w\");\n@@ -269,6 +270,7 @@ static bool try_promisor_remotes(struct repository *repo,\n \t\t\t\t struct object_id **remaining_oids,\n \t\t\t\t int *remaining_nr,\n \t\t\t\t int *to_free,\n+\t\t\t\t unsigned long depth,\n \t\t\t\t bool accepted_only)\n {\n \tstruct promisor_remote *r = repo->promisor_remote_config->promisors;\n@@ -276,7 +278,8 @@ static bool try_promisor_remotes(struct repository *repo,\n \tfor (; r; r = r->next) {\n \t\tif (accepted_only != r->accepted)\n \t\t\tcontinue;\n-\t\tif (fetch_objects(repo, r->name, *remaining_oids, *remaining_nr) < 0) {\n+\t\tif (fetch_objects(repo, r->name,\n+\t\t\t\t  *remaining_oids, *remaining_nr, depth) < 0) {\n \t\t\tif (*remaining_nr == 1)\n \t\t\t\tcontinue;\n \t\t\t*remaining_nr = remove_fetched_oids(repo, remaining_oids,\n@@ -291,6 +294,8 @@ static bool try_promisor_remotes(struct repository *repo,\n \treturn false;\n }\n \n+#define MAX_LAZY_FETCH_DEPTH 5\n+\n /*\n  * Return 'true' if all the objects could be fetched, 'false' otherwise.\n  */\n@@ -299,6 +304,8 @@ static bool lazy_fetch_objects(struct repository *repo,\n \t\t\t       int *remaining_nr,\n \t\t\t       int *to_free)\n {\n+\tunsigned long depth = git_env_ulong(LAZY_FETCH_DEPTH_ENVIRONMENT, 0);\n+\n \tif (git_env_bool(NO_LAZY_FETCH_ENVIRONMENT, 0)) {\n \t\tstatic int warning_shown;\n \t\tif (!warning_shown) {\n@@ -308,13 +315,24 @@ static bool lazy_fetch_objects(struct repository *repo,\n \t\treturn false;\n \t}\n \n+\tif (depth >= MAX_LAZY_FETCH_DEPTH) {\n+\t\tstatic int warning_shown;\n+\t\tif (!warning_shown) {\n+\t\t\twarning_shown = 1;\n+\t\t\twarning(_(\"too many nested lazy fetches (%lu); \"\n+\t\t\t\t  \"is a promisor remote pointing at the repository itself?\"),\n+\t\t\t\tdepth);\n+\t\t}\n+\t\treturn false;\n+\t}\n+\n \tpromisor_remote_init(repo);\n \n \t/* Try accepted remotes first (those the server told us to use) */\n \treturn try_promisor_remotes(repo, remaining_oids, remaining_nr,\n-\t\t\t\t    to_free, true) ||\n+\t\t\t\t    to_free, depth, true) ||\n \t\ttry_promisor_remotes(repo, remaining_oids, remaining_nr,\n-\t\t\t\t     to_free, false);\n+\t\t\t\t     to_free, depth, false);\n }\n \n void promisor_remote_get_direct(struct repository *repo,\ndiff --git a/t/t0410-partial-clone.sh b/t/t0410-partial-clone.sh\nindex 788e9a1631..a54685e3c7 100755\n--- a/t/t0410-partial-clone.sh\n+++ b/t/t0410-partial-clone.sh\n@@ -709,6 +709,39 @@ test_expect_success 'lazy-fetch when accessing object not in the_repository' '\n \ttest_grep ! \"[?]$FILE_HASH\" out\n '\n \n+test_expect_success 'lazy-fetch does not recurse infinitely between two promisor remotes' '\n+\trm -rf full partial1.git partial2.git &&\n+\n+\t# Create a repo with a blob\n+\ttest_create_repo full &&\n+\ttest_config -C full uploadpack.allowfilter 1 &&\n+\ttest_config -C full uploadpack.allowanysha1inwant 1 &&\n+\ttest_commit -C full create-a-file file.txt &&\n+\tFILE_HASH=$(git -C full rev-parse HEAD:file.txt) &&\n+\n+\t# Create partial clone repos without blobs\n+\tgit clone --filter=blob:none --bare \"file://$(pwd)/full\" partial1.git &&\n+\tgit clone --filter=blob:none --bare \"file://$(pwd)/full\" partial2.git &&\n+\ttest_config -C partial1.git uploadpack.allowfilter 1 &&\n+\ttest_config -C partial1.git uploadpack.allowanysha1inwant 1 &&\n+\ttest_config -C partial2.git uploadpack.allowfilter 1 &&\n+\ttest_config -C partial2.git uploadpack.allowanysha1inwant 1 &&\n+\n+\t# Configure the partial repos as remotes of each other\n+\tgit -C partial2.git remote set-url origin \"file://$(pwd)/partial1.git\" &&\n+\tgit -C partial1.git remote set-url origin \"file://$(pwd)/partial2.git\" &&\n+\n+\t# Make sure lazy fetching fails\n+\ttest_must_fail env GIT_TRACE=\"$(pwd)/trace\" GIT_NO_LAZY_FETCH=0 \\\n+\t\tgit -C partial1.git cat-file -e \"$FILE_HASH\" 2>err &&\n+\ttest_grep \"too many nested lazy fetches\" err &&\n+\n+\t# Make sure the recursion was bounded, i.e. that only\n+\t# MAX_LAZY_FETCH_DEPTH \"git fetch\" subprocesses were spawned\n+\tgrep \"run_command: GIT_INTERNAL_LAZY_FETCH_DEPTH\" trace >fetches &&\n+\ttest_line_count = 5 fetches\n+'\n+\n test_expect_success 'push should not fetch new commit objects' '\n \trm -rf server client &&\n \ttest_create_repo server &&\n-- \n2.55.0.792.ged91fccac1.dirty\n\n"},{"id":"552230","messageId":"20260908164129.560396-6-christian.couder@gmail.com","threadId":"65969","inReplyTo":"20260908164129.560396-1-christian.couder@gmail.com","subject":"[PATCH v3 5/5] builtin/upload-pack: set GIT_NO_LAZY_FETCH to 0 on trusted repo","fromName":"Christian Couder","fromEmail":"christian.couder@gmail.com","sentAt":"2026-09-08T16:41:29Z","receivedAt":"2026-09-08T16:42:02Z","isPatch":true,"body":"A previous commit added a new \"uploadpack.lazyFetchTrusted\" protected\nconfig variable that can contain an allowlist of repos, as well as\nfunctions to check if the current repo is in that list. But when the\ncurrent repo is in that list, we currently do nothing.\n\nLet's instead set `GIT_NO_LAZY_FETCH` to `0`, which allows\n`upload-pack` and its `pack-objects` child process to lazily fetch the\nobjects they need to serve a client, for example when the filter used\nby the client and the one used by the server don't match.\n\nThis allows server operators to properly control lazy fetching. It is\ntheir responsibility, not the client's, to decide if the served repo is\ntrusted, as the main security issue is that lazily fetching runs `git\nfetch`, which may execute arbitrary commands specified in the\nconfiguration and hooks of the served repo.\n\nAs `GIT_NO_LAZY_FETCH` is passed down to child processes through the\nenvironment, this works for `pack-objects`, which performs the lazy\nfetch when serving a client, without any further plumbing.\n\nNow that \"uploadpack.lazyFetchTrusted\" is actually doing something,\nlet's document it and reference it from GIT_NO_LAZY_FETCH's docs.\n\nSigned-off-by: Christian Couder <christian.couder@gmail.com>\n---\n Documentation/config/uploadpack.adoc  |  49 +++++++++\n Documentation/git-upload-pack.adoc    |   5 +\n Documentation/git.adoc                |   4 +-\n builtin/upload-pack.c                 |  11 ++\n t/t5710-promisor-remote-capability.sh | 142 ++++++++++++++++++++++++++\n 5 files changed, 210 insertions(+), 1 deletion(-)\n\ndiff --git a/Documentation/config/uploadpack.adoc b/Documentation/config/uploadpack.adoc\nindex 0e1dda944a..e143de93aa 100644\n--- a/Documentation/config/uploadpack.adoc\n+++ b/Documentation/config/uploadpack.adoc\n@@ -86,3 +86,52 @@ uploadpack.allowRefInWant::\n \tis intended for the benefit of load-balanced servers which may\n \tnot have the same view of what OIDs their refs point to due to\n \treplication delay.\n+\n+uploadpack.lazyFetchTrusted::\n+\tA multi-valued configuration variable, each of which contains the\n+\tabsolute local path of a repository that `upload-pack` is allowed to\n+\tlazily fetch missing objects for.\n++\n+A repository is identified by its git directory, i.e. the `.git`\n+directory of a repository that has a worktree, or the repository itself\n+if it is bare. So a non-bare repository served as `/srv/repo` has to be\n+allowlisted as `/srv/repo/.git`. Giving a path with `/*` appended to it\n+will trust all repositories under the named directory. To trust all\n+served repositories, set `uploadpack.lazyFetchTrusted` to the string\n+`*`.\n++\n+The value of this setting is interpolated, i.e. `~/<path>` expands to a\n+path relative to the home directory and `%(prefix)/<path>` expands to a\n+path relative to Git's (runtime) prefix.\n++\n+By default, `upload-pack` refuses to lazily fetch (see the description\n+of the `GIT_NO_LAZY_FETCH` environment variable in\n+linkgit:git-upload-pack[1]), because doing so would run `git fetch`,\n+which may execute arbitrary commands specified in the configuration\n+and hooks of the served repository. Listing a repository here tells\n+`upload-pack` that it is trusted, so lazy fetching from the promisor\n+remotes configured in it is allowed. This is equivalent to setting\n+`GIT_NO_LAZY_FETCH` to `0` for the matching repositories. An\n+explicitly set `GIT_NO_LAZY_FETCH` takes precedence over this setting.\n++\n+Note that this allows lazy fetching from any promisor remote\n+configured in the served repository, not only from the promisor\n+remotes that the client accepted using the \"promisor-remote\" protocol\n+v2 capability (see linkgit:gitprotocol-v2[5]). The served repository\n+is trusted as a whole, including its configuration, so the promisor\n+remotes it configures are trusted too. It is the server operator's\n+responsibility to make sure that the promisor remotes of a trusted\n+repository are also trustworthy. In particular, a trusted repository\n+should not be configured as its own promisor remote, as `upload-pack`\n+would then try to lazily fetch missing objects from the repository\n+itself, which is pointless.\n++\n+As this is a multi-valued setting, you can add more than one\n+repository via `git config (--global|--system) --add`. To reset the\n+list of trusted repositories (e.g. to override any such repositories\n+specified in the system config), add an `uploadpack.lazyFetchTrusted`\n+entry with an empty value.\n++\n+Note that this configuration variable is only respected when it is\n+specified in protected configuration (see <<SCOPES>>). This prevents\n+untrusted repositories from tampering with this value.\ndiff --git a/Documentation/git-upload-pack.adoc b/Documentation/git-upload-pack.adoc\nindex 9167a321d0..90c2ba1194 100644\n--- a/Documentation/git-upload-pack.adoc\n+++ b/Documentation/git-upload-pack.adoc\n@@ -71,6 +71,11 @@ This is implemented by having `upload-pack` internally set the\n (because you are fetching from a partial clone, and you are sure\n you trust it), you can explicitly set `GIT_NO_LAZY_FETCH` to\n `0`.\n++\n+Instead of setting `GIT_NO_LAZY_FETCH` to `0` in the environment, a\n+server operator can allow lazy fetching on a per-repository basis by\n+listing trusted repositories in the `uploadpack.lazyFetchTrusted`\n+configuration variable. See linkgit:git-config[1].\n \n SECURITY\n --------\ndiff --git a/Documentation/git.adoc b/Documentation/git.adoc\nindex 8a5cdd3b3d..2e763d1f93 100644\n--- a/Documentation/git.adoc\n+++ b/Documentation/git.adoc\n@@ -949,7 +949,9 @@ for full details.\n `GIT_NO_LAZY_FETCH`::\n \tSetting this Boolean environment variable to true tells Git\n \tnot to lazily fetch missing objects from the promisor remote\n-\ton demand.\n+\ton demand. On the server side, the `uploadpack.lazyFetchTrusted`\n+\tconfiguration variable can control this per-repository. See\n+\tlinkgit:git-upload-pack[1].\n \n `GIT_REFLOG_ACTION`::\n \tWhen a ref is updated, reflog entries are created to keep\ndiff --git a/builtin/upload-pack.c b/builtin/upload-pack.c\nindex 32831fb879..8b531ca724 100644\n--- a/builtin/upload-pack.c\n+++ b/builtin/upload-pack.c\n@@ -42,10 +42,13 @@ int cmd_upload_pack(int argc,\n \t\tOPT_END()\n \t};\n \tunsigned enter_repo_flags = ENTER_REPO_ANY_OWNER_OK;\n+\tbool no_lazy_fetch_set;\n \n \tpacket_trace_identity(\"upload-pack\");\n \tdisable_replace_refs();\n \tsave_commit_buffer = 0;\n+\n+\tno_lazy_fetch_set = !!getenv(NO_LAZY_FETCH_ENVIRONMENT);\n \txsetenv(NO_LAZY_FETCH_ENVIRONMENT, \"1\", 0);\n \n \targc = parse_options(argc, argv, prefix, options, upload_pack_usage, 0);\n@@ -62,6 +65,14 @@ int cmd_upload_pack(int argc,\n \tif (!enter_repo(the_repository, dir, enter_repo_flags))\n \t\tdie(\"'%s' does not appear to be a git repository\", dir);\n \n+\t/*\n+\t * Relax the GIT_NO_LAZY_FETCH=1 default if the served repo is in\n+\t * the \"uploadpack.lazyFetchTrusted\" protected allowlist and\n+\t * GIT_NO_LAZY_FETCH was not already set explicitly.\n+\t */\n+\tif (!no_lazy_fetch_set && upload_pack_lazy_fetch_trusted(the_repository))\n+\t\txsetenv(NO_LAZY_FETCH_ENVIRONMENT, \"0\", 1);\n+\n \tswitch (determine_protocol_version_server()) {\n \tcase protocol_v2:\n \t\tif (advertise_refs)\ndiff --git a/t/t5710-promisor-remote-capability.sh b/t/t5710-promisor-remote-capability.sh\nindex 549acff23f..62f4b56006 100755\n--- a/t/t5710-promisor-remote-capability.sh\n+++ b/t/t5710-promisor-remote-capability.sh\n@@ -173,6 +173,148 @@ test_expect_success \"clone with promisor.acceptfromserver set to 'None'\" '\n \tinitialize_server 1 \"$oid\"\n '\n \n+test_expect_success \"clone with uploadpack.lazyFetchTrusted\" '\n+\t# No promisors are advertised\n+\tgit -C server config promisor.advertise false &&\n+\ttest_when_finished \"rm -rf client\" &&\n+\n+\t# The served repo is trusted for lazy fetching\n+\ttest_config_global uploadpack.lazyFetchTrusted \"$(pwd)/server\" &&\n+\n+\t# Clone without GIT_NO_LAZY_FETCH=0\n+\tgit clone --no-local --filter=\"blob:limit=5k\" server client &&\n+\n+\t# Check that the largest object is not missing on the server\n+\t# This means the server lazy fetched it\n+\tcheck_missing_objects server 0 \"\" &&\n+\n+\t# Reinitialize server so that the largest object is missing again\n+\tinitialize_server 1 \"$oid\"\n+'\n+\n+test_expect_success \"clone without uploadpack.lazyFetchTrusted fails\" '\n+\t# No promisors are advertised\n+\tgit -C server config promisor.advertise false &&\n+\ttest_when_finished \"rm -rf client\" &&\n+\n+\t# Note: no uploadpack.lazyFetchTrusted config is set here, so\n+\t# the served repo is NOT trusted for lazy fetching.\n+\n+\t# Clone without GIT_NO_LAZY_FETCH=0 fails\n+\ttest_must_fail git clone --no-local --filter=\"blob:limit=5k\" server client 2>err &&\n+\ttest_grep \"lazy fetching disabled\" err &&\n+\n+\t# Check that the largest object is still missing on the server\n+\tcheck_missing_objects server 1 \"$oid\"\n+'\n+\n+test_expect_success \"uploadpack.lazyFetchTrusted is ignored in repo config\" '\n+\t# No promisors are advertised\n+\tgit -C server config promisor.advertise false &&\n+\ttest_when_finished \"rm -rf client\" &&\n+\n+\t# The served repo is trusted for lazy fetching, but this is\n+\t# done in the repo config, not in protected config, so this is\n+\t# ignored.\n+\ttest_config -C server uploadpack.lazyFetchTrusted \"$(pwd)/server\" &&\n+\n+\t# Clone without GIT_NO_LAZY_FETCH=0 fails\n+\ttest_must_fail git clone --no-local --filter=\"blob:limit=5k\" server client 2>err &&\n+\ttest_grep \"lazy fetching disabled\" err &&\n+\n+\t# Check that the largest object is still missing on the server\n+\tcheck_missing_objects server 1 \"$oid\"\n+'\n+\n+test_expect_success \"explicit GIT_NO_LAZY_FETCH overrides uploadpack.lazyFetchTrusted\" '\n+\t# No promisors are advertised\n+\tgit -C server config promisor.advertise false &&\n+\ttest_when_finished \"rm -rf client\" &&\n+\n+\t# The served repo is trusted for lazy fetching\n+\ttest_config_global uploadpack.lazyFetchTrusted \"$(pwd)/server\" &&\n+\n+\t# But GIT_NO_LAZY_FETCH=1 disables lazy fetching, so clone fails\n+\ttest_must_fail env GIT_NO_LAZY_FETCH=1 git clone --no-local \\\n+\t\t--filter=\"blob:limit=5k\" server client 2>err &&\n+\ttest_grep \"lazy fetching disabled\" err &&\n+\n+\t# Check that the largest object is still missing on the server\n+\tcheck_missing_objects server 1 \"$oid\"\n+'\n+\n+test_expect_success \"trusted repo as its own promisor remote does not recurse\" '\n+\t# No promisors are advertised\n+\tgit -C server config promisor.advertise false &&\n+\ttest_when_finished \"rm -rf client\" &&\n+\n+\t# Add itself as its own remote\n+\tgit -C server remote add self \"$TRASH_DIRECTORY_URL/server\" &&\n+\tgit -C server config remote.self.promisor true &&\n+\ttest_when_finished \"git -C server remote remove self\" &&\n+\n+\t# Make \"self\" the only promisor remote of the server, so that it\n+\t# cannot get the missing object from \"lop\". Note that\n+\t# \"remote.lop.partialCloneFilter\" also makes \"lop\" a promisor\n+\t# remote, so it has to be unset too.\n+\tgit -C server config --unset remote.lop.promisor &&\n+\ttest_when_finished \"git -C server config remote.lop.promisor true\" &&\n+\tlop_filter=\"$(git -C server config remote.lop.partialCloneFilter)\" &&\n+\tgit -C server config --unset remote.lop.partialCloneFilter &&\n+\ttest_when_finished \"git -C server config remote.lop.partialCloneFilter \\\"$lop_filter\\\"\" &&\n+\n+\t# Allow lazy fetching from itself\n+\ttest_config_global uploadpack.lazyFetchTrusted \"$(pwd)/server\" &&\n+\n+\t# Check that lazy fetching fails\n+\ttest_must_fail git clone --no-local --filter=\"blob:limit=5k\" server client 2>err &&\n+\ttest_grep \"too many nested lazy fetches\" err &&\n+\n+\t# Check that the largest object is still missing on the server\n+\tcheck_missing_objects server 1 \"$oid\"\n+'\n+\n+test_expect_success \"uploadpack.lazyFetchTrusted needs the git dir of a non-bare repo\" '\n+\ttest_when_finished \"rm -rf nonbare client client2\" &&\n+\n+\t# Create a non-bare repo, without any worktree content, so that\n+\t# its largest object can be filtered out below\n+\tgit init nonbare &&\n+\tgit -C nonbare remote add origin \"$TRASH_DIRECTORY_URL/template\" &&\n+\tgit -C nonbare fetch origin &&\n+\tgit -C nonbare update-ref HEAD FETCH_HEAD &&\n+\n+\tgit -C nonbare remote add lop \"$TRASH_DIRECTORY_URL/lop\" &&\n+\tgit -C nonbare config remote.lop.promisor true &&\n+\tgit -C nonbare config uploadpack.allowFilter true &&\n+\tgit -C nonbare config uploadpack.allowAnySHA1InWant true &&\n+\tgit -C nonbare config promisor.advertise false &&\n+\n+\t# Repack everything, then repack without the largest object and\n+\t# create a promisor pack, like initialize_server() does\n+\tgit -C nonbare -c repack.writebitmaps=false repack -a -d &&\n+\trm -f nonbare/.git/objects/pack/*.promisor &&\n+\tgit -C nonbare -c repack.writebitmaps=false repack -a -d \\\n+\t\t--filter=blob:limit=5k --filter-to=\"$(pwd)/nonbare-pack\" &&\n+\tpromisor_file=$(ls nonbare/.git/objects/pack/*.pack | sed \"s/\\.pack/.promisor/\") &&\n+\t>\"$promisor_file\" &&\n+\tcheck_missing_objects nonbare 1 \"$oid\" &&\n+\n+\t# The worktree path does not identify the repo, so it is not\n+\t# trusted and the clone fails\n+\ttest_config_global uploadpack.lazyFetchTrusted \"$(pwd)/nonbare\" &&\n+\ttest_must_fail git clone --no-local --filter=\"blob:limit=1k\" \\\n+\t\tnonbare client 2>err &&\n+\ttest_grep \"lazy fetching disabled\" err &&\n+\tcheck_missing_objects nonbare 1 \"$oid\" &&\n+\n+\t# The git dir identifies the repo, so it is trusted and the\n+\t# clone succeeds\n+\ttest_config_global uploadpack.lazyFetchTrusted \"$(pwd)/nonbare/.git\" &&\n+\tgit clone --no-local --filter=\"blob:limit=1k\" nonbare client2 &&\n+\tcheck_missing_objects nonbare 0 \"\"\n+'\n+\n test_expect_success \"init + fetch with promisor.advertise set to 'true'\" '\n \tgit -C server config promisor.advertise true &&\n \ttest_when_finished \"rm -rf client\" &&\n-- \n2.55.0.792.ged91fccac1.dirty\n\n"},{"id":"552231","messageId":"CAP8UFD0qSA_giG1o8ydwxUOyscQaJ9pSNyngCdC7OEa7G_C5jA@mail.gmail.com","threadId":"65969","inReplyTo":"xmqqecg0oabe.fsf@gitster.g","subject":"Re: [PATCH v2 2/5] setup: extract path_allowlist_apply()","fromName":"Christian Couder","fromEmail":"christian.couder@gmail.com","sentAt":"2026-09-08T16:46:05Z","receivedAt":"2026-09-08T16:46:17Z","isPatch":true,"body":"On Fri, Aug 14, 2026 at 7:56 PM Junio C Hamano <gitster@pobox.com> wrote:\n>\n> Christian Couder <christian.couder@gmail.com> writes:\n\n> > diff --git a/setup.c b/setup.c\n> > index 95909e9603..39dfa1cc5f 100644\n> > --- a/setup.c\n> > +++ b/setup.c\n> > @@ -1339,6 +1339,64 @@ static int canonicalize_ceiling_entry(struct string_list_item *item,\n\n[...]\n\n> > +\n> > +     if (ends_with(normalized, \"/*\")) {\n> > +             size_t len = strlen(normalized);\n> > +             if (!fspathncmp(normalized, target_path, len - 1))\n> > +                     *is_match = 1;\n> > +             goto end;\n> > +     }\n> > +\n> > +     if (!fspathcmp(target_path, normalized))\n> > +             *is_match = 1;\n> > +\n> > +end:\n> > +     free(normalized);\n> > +     free(allowed);\n> > +}\n>\n> The name \"is_match\" somehow feels a bit awkward.  How about calling\n> it\n>\n>     *matches = true/false;\n>\n> instead?\n\nIt was `int is_match` to match with `int is_safe` in `struct\nsafe_directory_data`, as the function is called this way:\n\npath_allowlist_apply(key, value, data->path, &data->is_safe);\n\nBut OK, I have changed both `int is_match` and `int is_safe` to `bool\nmatches` and `bool safe` in the v3 I just sent.\n\nThanks.\n"},{"id":"552232","messageId":"CAP8UFD1T_+EKRu7BcdNn_ga=vPz27xZb+yeVWVCnKrnU3zFRpQ@mail.gmail.com","threadId":"65969","inReplyTo":"xmqqy0e8mv0k.fsf@gitster.g","subject":"Re: [PATCH v2 3/5] setup: add 'allow_dot' arg to path_allowlist_apply()","fromName":"Christian Couder","fromEmail":"christian.couder@gmail.com","sentAt":"2026-09-08T16:55:07Z","receivedAt":"2026-09-08T16:55:20Z","isPatch":true,"body":"On Fri, Aug 14, 2026 at 8:12 PM Junio C Hamano <gitster@pobox.com> wrote:\n\n> If this is just \"I want to add an extra caller that has specific\n> need and do not care about others in the future\", this may be OK but\n> as a public function, this is a bit disappointing API design.\n\nI thought that flags might be enough at least for some time, but I\nagree that it could soon make the code difficult to reason about,\nwhich is not a good thing for this kind of code.\n\n> I expected, as a generally useful function, you would instead add a\n> callback function to allow replacing the use of is_absoute_path()\n> plus the warning there, i.e.\n>\n> void path_allowlist_apply(const char *key, const char *value,\n>                           const char *target_path, bool *matches,\n>                           bool (*allow_path)(const char *path))\n> {\n>         ...\n>\n>         if (!allow_path(allowed))\n>                 goto end;\n>\n> Also to avoid limiting this to configuration callback, I might\n> recommend to have it be more like this:\n>\n> void path_allowlist_apply(const char *allowed, const char *target_path,\n>                           bool *matches,\n>                           bool (*allow_path)(const char *path, void *cbdata),\n>                           void *allow_path_cbdata)\n>\n> where the original safe-directory thing may call\n> git_config_pathname() to compute allowed before calling this helper,\n> and pass the address of something like:\n>\n>         struct { const char *key, *value } cbdata = {\n>                 .key = key, .value = value;\n>         };\n>\n> as the cbdata, and pass something like this\n>\n>         static bool allow_safe_dir(const char *path, void *cbdata_)\n>         {\n>                 struct { const char *key, *value } *cbdata = _cbdata;\n>                 if (is_absoute_path(path) || !strcmp(path, \".\")\n>                         return true; /* ok */\n>\n>                 warning(_(\"%s '%s' not absolute\"), cbdata->key, path);\n>                 return false;\n>         }\n>\n> as the allow_path callback function.  IOW warning, or insisting on\n> it being absolute, etc., does not have to be carved in stone.\n\nI have tried to implement it like you suggest in the v3 I just sent.\n\nThanks.\n"},{"id":"552233","messageId":"CAP8UFD07ssLAAsc_00W3Q=vzPXry-=nK-mO66_eoHxEGTEYAgw@mail.gmail.com","threadId":"65969","inReplyTo":"xmqq1pc0mr5i.fsf@gitster.g","subject":"Re: [PATCH v2 5/5] builtin/upload-pack: set GIT_NO_LAZY_FETCH to 0 on trusted repo","fromName":"Christian Couder","fromEmail":"christian.couder@gmail.com","sentAt":"2026-09-08T17:02:12Z","receivedAt":"2026-09-08T17:02:24Z","isPatch":true,"body":"On Fri, Aug 14, 2026 at 9:35 PM Junio C Hamano <gitster@pobox.com> wrote:\n\n> To somebody who designed this mechanism, it may have been clear that\n> you are talking about multi-valued configuration variable, i.e.,\n>\n>         [uploadpack]\n>                 lazyFetchTrusted = repo1\n>                 lazyFetchTrusted = repo2\n>                 ...\n>                 lazyFetchTrusted = repoN\n>\n> but the \"config entries specify repositories\" can be misread to mean\n>\n>         [uploadpack]\n>                 lazyFetchTrusted = repo1 repo2 ... repoN\n>\n> especially combined with the use of verb \"list\" in \"Listing a\n> repository here tells...\" we see below.\n>\n>         A multi-valued configuration variable, each of which names a\n>         repository that `upload-pack` is allowed to ...\n>\n> or something, perhaps.  Say that upfront to make sure readers won't\n> waste their time wondering what the syntax is.\n\nI have used that in the v3 I just sent.\n\n> Also, how would one specify a repository?  A URL?  Remote nickname\n> used in\n>\n>         [remote \"nick\"] url = ...\n>\n> configuration?  Local directory that houses another repository?\n> Something else?\n\nThe v3 has improved regarding this as I think it makes it clearer that\nrepos are identified by having their git dir, or a parent directory of\nit, in this config variable.\n\n> > +     allowed to lazily fetch missing objects for. By default,\n> > +     `upload-pack` refuses to lazily fetch (see the description of the\n> > +     `GIT_NO_LAZY_FETCH` environment variable in\n> > +     linkgit:git-upload-pack[1]), because doing so would run `git fetch`,\n> > +     which may execute arbitrary commands specified in the configuration\n> > +     and hooks of the served repository. Listing a repository here tells\n> > +     `upload-pack` that it is trusted, so lazy fetching from the promisor\n> > +     remotes configured in it is allowed. This is equivalent to setting\n> > +     `GIT_NO_LAZY_FETCH` to `0` for the matching repositories. An\n> > +     explicitly set `GIT_NO_LAZY_FETCH` takes precedence over this\n> > +     setting.\n>\n> It would be interesting to set it to point at itself.  A client asks\n> you to serve a pack, you find some objects you yourself do not have\n> because you fetched lazily from the upstream, and you end up asking\n> you if you have that object (U+1F61B Face with Stuck-Out Tongue 😛).\n\nActually it happens that it could recursively lazy fetch in v2, but\nthis has been fixed with a new patch and a few tests in v3. Thanks for\nthe suggestion.\n\n> > +Note that this allows lazy fetching from any promisor remote\n> > +configured in the served repository, not only from the promisor\n> > +remotes that the client accepted using the \"promisor-remote\" protocol\n> > +v2 capability (see linkgit:gitprotocol-v2[5]). The served repository\n> > +is trusted as a whole, including its configuration, so the promisor\n> > +remotes it configures are trusted too. It is the server operator's\n> > +responsibility to make sure that the promisor remotes of a trusted\n> > +repository are also trustworthy.\n> > ++\n> > +This is a multi-valued setting, i.e. you can add more than one\n> > +repository via `git config (--global|--system) --add`. To reset the\n> > +list of trusted repositories (e.g. to override any such repositories\n> > +specified in the system config), add a `uploadpack.lazyFetchTrusted`\n>\n> a -> an before `uploadpack.lazyFetchTrusted`.\n\nFixed in v3.\n\nThanks.\n"},{"id":"552234","messageId":"CAP8UFD3DUAYpBpfcrub6CPJ0AHFvJGEi52=eXYwrBT+YXpu7PA@mail.gmail.com","threadId":"65969","inReplyTo":"xmqqjypsoami.fsf@gitster.g","subject":"Re: [PATCH v2 1/5] promisor-remote: factor out lazy_fetch_objects()","fromName":"Christian Couder","fromEmail":"christian.couder@gmail.com","sentAt":"2026-09-08T17:11:48Z","receivedAt":"2026-09-08T17:12:01Z","isPatch":true,"body":"On Fri, Aug 14, 2026 at 7:49 PM Junio C Hamano <gitster@pobox.com> wrote:\n\n> Perhaps writing it this way would make it easier to tell what is\n> going on.  We try the preferred ones first, and then fall back to\n> the other ones.\n>\n>         return (try_promisor_remotes(..., true) ||\n>                 try_promisor_remotes(..., false));\n\nYes, this is used in v3.\n\n> But more importantly, I wonder if keeping the list of missing object\n> names in memory will later turn out to be problematic in real-life\n> applications.  Without knowing much about how the current code for\n> bulk dehydrating promisor objects is structured, I expected an API\n> that looks more like:\n>\n>  - bulk_download_begin(): performs the early part of\n>    fetch_objects(), sets up connections to the promisor remote(s),\n>    and calls start_command() on the child process.\n>\n>  - bulk_download_this(): after calling the _begin() function above,\n>    it runs around and collects missing objects that it needs to do\n>    its work.  For each such missing object it discovers, this\n>    function is called, which sends the object name down the\n>    '--stdin' file descriptor.\n>\n>  - bulk_download_done(): tells the child process that we are done\n>    feeding object names.\n>\n> but that is not what I am seeing.  I guess the current arrangement\n> cannot be avoided, because we are going to fetch from more than one\n> promisor remote.  Under such constraints, the way to deal with a\n> massive number of missing objects will not be \"streaming\" like I\n> imagined above, but needs to be done differently, like spooling to a\n> file or something silly like that.\n>\n> In any case, except that this avoids checking the environment\n> variable multiple times, I can see that it is a no-op refactoring of\n> the existing code.\n\nYeah, I prefer to avoid working on a big refactoring in this area\nuntil we have evidence showing that there is a bottleneck here.\n\n> Nice and cleanly done.\n\nThanks.\n"},{"id":"552236","messageId":"xmqq7bkvy74h.fsf@gitster.g","threadId":"65969","inReplyTo":"20260908164129.560396-2-christian.couder@gmail.com","subject":"Re: [PATCH v3 1/5] promisor-remote: factor out lazy_fetch_objects()","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-09-08T17:39:58Z","receivedAt":"2026-09-08T17:40:01Z","isPatch":true,"body":"Christian Couder <christian.couder@gmail.com> writes:\n\n> In \"promisor-remote.c:fetch_objects()\", there is a check to disable\n> lazy fetching when the `GIT_NO_LAZY_FETCH` environment variable is\n> set. The fetch_objects() function is called once per promisor remote\n> though. So the check might be performed more times than necessary.\n>\n> Also promisor_remote_get_direct() mixes up the logic deciding which\n> promisor remotes to try with the logic checking that the objects\n> that could not be fetched are promisor objects.\n>\n> Let's refactor the lazy fetching logic out of these two functions\n> into a new lazy_fetch_objects() function.\n>\n> This is a pure refactoring with no intended behavior change. Two\n> things shift in ways that are observably equivalent though:\n>\n>   - the `GIT_NO_LAZY_FETCH` check is now performed once up front,\n>     instead of once per promisor remote, and\n>\n>   - promisor_remote_init() is no longer called when lazy fetching\n>     is disabled, which is fine as nothing downstream of it, like\n>     is_promisor_object(), needs it in that case.\n\nYeah, I too noticed these while reading the patch.  The latter\nchange may be a very good thing, in that the calling sequence around\npromisor_remote_init() seems to be anybody who needs to access the\npromisor remote information is expected to _init() the system\nbeforehand.  If it were \"call _init() once at the very beginning and\nthen do random things on promisor remotes\", then moving its callsite\nmay have to be done more carefully, but with the \"user makes sure it\nis initialized beforehand\" convention, the postimage of this patch\nfollows the pattern exactly.\n\n> While at it, let's also convert try_promisor_remotes() to return\n> 'bool' instead of 'int', as it just returns whether all the objects\n> could be fetched, and document its return value.\n\nMeh.\n\n> +/*\n> + * Return 'true' if all the objects could be fetched from the\n> + * (non-)accepted remotes, 'false' otherwise.\n> + */\n\nThe comment was not quite understandable, at least to me,\nespecially around \"from the (non-)accepted\" part of the sentence.\n\nAlso \"could be fetched\" made it sound as if this were dry-run but\nisn't this function actually doing the fetching and reporting if\neverything got fetched or there are still objects remaining to be\nfetched?\n\n    /*\n     * fetch remaining objects (given in remaining_oids) from\n     * the known promisor remotes.  If accepted_only is true,\n     * ignore promisor remotes with .accepted member unset.\n     * return true when all requested objects have been fetched,\n     * false otherwise.\n     */\n\nThe above only mentions half of how the remaining_oids parameter is\nused (i.e., only on the input side), but if we are adding a comment,\nwe should document how remaining_oids and to_free are used as well.\n\nThe semantics of to_free in the entire callchain is especially\ntricky to describe correctly, I am afraid.\n"},{"id":"552237","messageId":"xmqq33vjy6qz.fsf@gitster.g","threadId":"65969","inReplyTo":"20260908164129.560396-3-christian.couder@gmail.com","subject":"Re: [PATCH v3 2/5] setup: extract path_allowlist_apply()","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-09-08T17:48:04Z","receivedAt":"2026-09-08T17:48:06Z","isPatch":true,"body":"Christian Couder <christian.couder@gmail.com> writes:\n\n> For clarity, let's change the `int is_safe` to `bool safe` in\n> `struct safe_directory_data`.\n\nI am not sure if this clarifies, though.\n\n> diff --git a/setup.c b/setup.c\n> index dfe05d9a03..366a7dc5c0 100644\n> --- a/setup.c\n> +++ b/setup.c\n> @@ -1338,67 +1338,105 @@ static int canonicalize_ceiling_entry(struct string_list_item *item,\n>  \t}\n>  }\n>  \n> +void path_allowlist_apply(const char *allowed, const char *target_path,\n> +\t\t\t  bool *matches,\n> +\t\t\t  bool (*allow_path)(const char *path, void *cbdata),\n> +\t\t\t  void *allow_path_cbdata)\n> +{\n> +\tchar *normalized = NULL;\n> +\n> +\tif (!allowed || !*allowed) {\n> +\t\t*matches = false;\n> +\t\treturn;\n> +\t}\n> +\n> +\tif (!strcmp(allowed, \"*\")) {\n> +\t\t*matches = true;\n> +\t\treturn;\n> +\t}\n> +\n> +\tif (!allow_path(allowed, allow_path_cbdata))\n> +\t\treturn;\n> +\n> +\t/*\n> +\t * A .gitconfig in $HOME may be shared across different\n> +\t * machines and the config variable entries may or may not\n> +\t * exist as paths on all of these machines.  In other words,\n> +\t * it is not a warning worthy event when there is no such path\n> +\t * on this machine---the entry may be useful elsewhere.\n> +\t */\n\nThis is inherited from the preimage and not something you would want\nto fix in this patch, but I do not think ignoring missing path like\nthis is healthy.  You do not know if the path given is missing by\ndesign (i.e., the set of paths is union of paths that could exist)\nor if it is missing due to an error (i.e., a filesystem that should\nhave been mounted is not mounted).  In the latter case, ignoring it\nmay make the system behave in a way that the user did not intend to.\n\n\n> +\tnormalized = real_pathdup(allowed, 0);\n> +\tif (!normalized)\n> +\t\treturn;\n> +\n> +\tif (ends_with(normalized, \"/*\")) {\n> +\t\tsize_t len = strlen(normalized);\n> +\t\tif (!fspathncmp(normalized, target_path, len - 1))\n> +\t\t\t*matches = true;\n> +\t} else if (!fspathcmp(target_path, normalized)) {\n> +\t\t*matches = true;\n> +\t}\n> +\n> +\tfree(normalized);\n> +}\n"},{"id":"552238","messageId":"xmqqy0dbws4k.fsf@gitster.g","threadId":"65969","inReplyTo":"CAP8UFD0qSA_giG1o8ydwxUOyscQaJ9pSNyngCdC7OEa7G_C5jA@mail.gmail.com","subject":"Re: [PATCH v2 2/5] setup: extract path_allowlist_apply()","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-09-08T17:49:15Z","receivedAt":"2026-09-08T17:49:18Z","isPatch":true,"body":"Christian Couder <christian.couder@gmail.com> writes:\n\n> path_allowlist_apply(key, value, data->path, &data->is_safe);\n>\n> But OK, I have changed both `int is_match` and `int is_safe` to `bool\n> matches` and `bool safe` in the v3 I just sent.\n\nI hate to say this but I think is_safe was perfectly good.  is_match\nwas not quite grammatrical (it is either \"matches\" ir \"is_a_match\"),\nbut \"is_safe\" is perfectly fine.\n"},{"id":"552240","messageId":"xmqqqzj3wr24.fsf@gitster.g","threadId":"65969","inReplyTo":"20260908164129.560396-5-christian.couder@gmail.com","subject":"Re: [PATCH v3 4/5] promisor-remote: prevent infinite recursion when lazy fetching","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-09-08T18:12:19Z","receivedAt":"2026-09-08T18:12:22Z","isPatch":true,"body":"Christian Couder <christian.couder@gmail.com> writes:\n\n> It does not recurse forever in practice, but only because each level\n> adds one more variable to the environment of the child process, so\n> after a while `exec()` fails with:\n>\n>     fatal: cannot exec 'git-upload-pack ...': Argument list too long\n>     fatal: unable to fork\n>\n> To avoid this pathological case altogether, let's use a new\n> `GIT_INTERNAL_LAZY_FETCH_DEPTH` to count the recursion depth, and let's\n> check that it doesn't exceed a MAX_LAZY_FETCH_DEPTH limit (set to 5 for\n> now).\n\nGood.\n\nDoes it have to be \"unsigned long\", though?  Just like oid_nr, I'd\nprefer to see a number whose range or signedness does not matter in\npractice be typed as platform natural \"int\".  Even though one could\nargue that \"anything_nr cannot be negative so it must be unsigned\",\nor \"int might be too small for some platforms\" or \"int or ulong have\ndifferent width on different platforms\", or even \"anything we count\nwe should count in size_t\", I do not think any of them is a good\nargument against it, especially when the value we start with is 5\n;-).\n\n"},{"id":"552243","messageId":"xmqqmrtrwq0k.fsf@gitster.g","threadId":"65969","inReplyTo":"20260908164129.560396-6-christian.couder@gmail.com","subject":"Re: [PATCH v3 5/5] builtin/upload-pack: set GIT_NO_LAZY_FETCH to 0 on trusted repo","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-09-08T18:34:51Z","receivedAt":"2026-09-08T18:34:56Z","isPatch":true,"body":"Christian Couder <christian.couder@gmail.com> writes:\n\n> A previous commit added a new \"uploadpack.lazyFetchTrusted\" protected\n> config variable that can contain an allowlist of repos, as well as\n> functions to check if the current repo is in that list. But when the\n> current repo is in that list, we currently do nothing.\n>\n> Let's instead set `GIT_NO_LAZY_FETCH` to `0`, which allows\n> `upload-pack` and its `pack-objects` child process to lazily fetch the\n> objects they need to serve a client, for example when the filter used\n> by the client and the one used by the server don't match.\n\nWhile I agree that it is a good idea to make it more lenient to work\nwith remotes that are explicitly marked as trusted, it somehow feels\na bit unnatural for a configuration variable, or a conclusion\nderived from the setting of a configuration variable, overriding an\nenvironment variable.  Who is setting this environment variable in\nthe first place?\n\nIf NO_LAZY_FETCH is what server operators set and export, I strongly\nsuspect that not honoring it merely because the new variable could\nbe used to give them a finer-grained control would be very\nsurprising experience for them.\n\nIf the answer is \"this never comes from the end-user or the server\noperator.  We used to automatically set NO_LAZY_FETCH from the\nprocess that spawns uploadpack because we trusted nobody\", then I'd\nimagine that we would prefer to see that code that automatically\nsets NO_LAZY_FETCH to inspect the configuration variable and to\ndecide not to do so.\n\nAnd I think that is what the code is doing (in other words, from a\ncursory read, I think the new code is doing the right thing and it\nis just the way how the above is explained that I found it iffy).\nWe used to say \"when serving a client, we do not lazy fetch what we\nare missing from our promisor remotes by setting NO_LAZY_FETCH\" and\nit was unconditional.\n\nI think what we want to happen is:\n\n * If the server operator has NO_LAZY_FETCH set, we honor it and do\n   not do anything.\n\n * If the server operator does not have NO_LAZY_FETCH set, then we\n   see if the configuration variable is there, and if there is, we\n   let it take care of which promisor remote to allow by not futzing\n   with NO_LAZY_FETCH ourselves.\n\n * Otherwise, we set and export NO_LAZY_FETCH just we used to.\n\nand what you have in the patch is close enough to that (you left the\nhistorical \"disable lazy fetch upfront\" so worst case you export the\nthing twice which is not necessary).\n\n> This allows server operators to properly control lazy fetching. It is\n> their responsibility, not the client's, to decide if the served repo is\n> trusted,\n\nIf \"the served repo\" refers to where the client is fetching from,\ntrusting that repository or not is up to the client; if they do not\ntrust it, they should not be coming to you.\n\nI may be misunderstanding what you are trying to say here, but what\nis up to the server operator to decide is if the promisor remotes,\nwhich the repo that is serving the client uses, is trustworthy,\nright?\n\n> As `GIT_NO_LAZY_FETCH` is passed down to child processes through the\n> environment, this works for `pack-objects`, which performs the lazy\n> fetch when serving a client, without any further plumbing.\n>\n> Now that \"uploadpack.lazyFetchTrusted\" is actually doing something,\n> let's document it and reference it from GIT_NO_LAZY_FETCH's docs.\n\n> diff --git a/builtin/upload-pack.c b/builtin/upload-pack.c\n> index 32831fb879..8b531ca724 100644\n> --- a/builtin/upload-pack.c\n> +++ b/builtin/upload-pack.c\n> @@ -42,10 +42,13 @@ int cmd_upload_pack(int argc,\n>  \t\tOPT_END()\n>  \t};\n>  \tunsigned enter_repo_flags = ENTER_REPO_ANY_OWNER_OK;\n> +\tbool no_lazy_fetch_set;\n>  \n>  \tpacket_trace_identity(\"upload-pack\");\n>  \tdisable_replace_refs();\n>  \tsave_commit_buffer = 0;\n> +\n> +\tno_lazy_fetch_set = !!getenv(NO_LAZY_FETCH_ENVIRONMENT);\n>  \txsetenv(NO_LAZY_FETCH_ENVIRONMENT, \"1\", 0);\n\nI am not seeing what is in the postcontext of this hunk and in the\nprecontext of the next hunk, but I wonder if we can just remove this\nxsetenv (without \"no_lazy_fetch_set\" variable at all) here ...\n\n>  \targc = parse_options(argc, argv, prefix, options, upload_pack_usage, 0);\n> @@ -62,6 +65,14 @@ int cmd_upload_pack(int argc,\n>  \tif (!enter_repo(the_repository, dir, enter_repo_flags))\n>  \t\tdie(\"'%s' does not appear to be a git repository\", dir);\n>  \n> +\t/*\n> +\t * Relax the GIT_NO_LAZY_FETCH=1 default if the served repo is in\n> +\t * the \"uploadpack.lazyFetchTrusted\" protected allowlist and\n> +\t * GIT_NO_LAZY_FETCH was not already set explicitly.\n> +\t */\n> +\tif (!no_lazy_fetch_set && upload_pack_lazy_fetch_trusted(the_repository))\n> +\t\txsetenv(NO_LAZY_FETCH_ENVIRONMENT, \"0\", 1);\n\n... and instead check the existing environment here, and do the\nchoice from three possibilities I listed above here.\n\nOther than that, this is a great endgame of the series.\n\nThanks.\n"},{"id":"552312","messageId":"CAP8UFD0WUQX4ts_US2Ehdp7hBmEs1_ztjJiGJMYA2ek4awduMg@mail.gmail.com","threadId":"65969","inReplyTo":"xmqqqzj3wr24.fsf@gitster.g","subject":"Re: [PATCH v3 4/5] promisor-remote: prevent infinite recursion when lazy fetching","fromName":"Christian Couder","fromEmail":"christian.couder@gmail.com","sentAt":"2026-09-09T10:00:51Z","receivedAt":"2026-09-09T10:01:04Z","isPatch":true,"body":"On Tue, Sep 8, 2026 at 8:12 PM Junio C Hamano <gitster@pobox.com> wrote:\n>\n> Christian Couder <christian.couder@gmail.com> writes:\n>\n> > It does not recurse forever in practice, but only because each level\n> > adds one more variable to the environment of the child process, so\n> > after a while `exec()` fails with:\n> >\n> >     fatal: cannot exec 'git-upload-pack ...': Argument list too long\n> >     fatal: unable to fork\n> >\n> > To avoid this pathological case altogether, let's use a new\n> > `GIT_INTERNAL_LAZY_FETCH_DEPTH` to count the recursion depth, and let's\n> > check that it doesn't exceed a MAX_LAZY_FETCH_DEPTH limit (set to 5 for\n> > now).\n>\n> Good.\n>\n> Does it have to be \"unsigned long\", though?  Just like oid_nr, I'd\n> prefer to see a number whose range or signedness does not matter in\n> practice be typed as platform natural \"int\".  Even though one could\n> argue that \"anything_nr cannot be negative so it must be unsigned\",\n> or \"int might be too small for some platforms\" or \"int or ulong have\n> different width on different platforms\", or even \"anything we count\n> we should count in size_t\", I do not think any of them is a good\n> argument against it, especially when the value we start with is 5\n> ;-).\n\nI agree that using a plain \"int\" seems like the most straightforward,\nbut we don't have git_env_int() while we have git_env_ulong().\n\nSo would you be fine with something like:\n\n    int depth = (int)git_env_ulong(LAZY_FETCH_DEPTH_ENVIRONMENT, 0);\n\nwhich is similar to the following in builtin/pack-objects.c:\n\n    name_hash_version = (int)git_env_ulong(\"GIT_TEST_NAME_HASH_VERSION\", 1);\n\n? Or do you think it's time to introduce git_env_int() in a preparatory patch?\n"},{"id":"552385","messageId":"xmqqa4pqp0j2.fsf@gitster.g","threadId":"65969","inReplyTo":"CAP8UFD0WUQX4ts_US2Ehdp7hBmEs1_ztjJiGJMYA2ek4awduMg@mail.gmail.com","subject":"Re: [PATCH v3 4/5] promisor-remote: prevent infinite recursion when lazy fetching","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-09-09T21:39:29Z","receivedAt":"2026-09-09T21:39:34Z","isPatch":true,"body":"Christian Couder <christian.couder@gmail.com> writes:\n\n> I agree that using a plain \"int\" seems like the most straightforward,\n> but we don't have git_env_int() while we have git_env_ulong().\n>\n> So would you be fine with something like:\n>\n>     int depth = (int)git_env_ulong(LAZY_FETCH_DEPTH_ENVIRONMENT, 0);\n>\n> which is similar to the following in builtin/pack-objects.c:\n>\n>     name_hash_version = (int)git_env_ulong(\"GIT_TEST_NAME_HASH_VERSION\", 1);\n>\n> ? Or do you think it's time to introduce git_env_int() in a preparatory patch?\n\nThere are 13 existing callers, among which one that you found\nexplicitly casts to int, but many others make assignments with\nimplicit cast (e.g., members of bloom_settings used in\ncommit-graph.c are of type uint32_t), and config.c reads\nGIT_TEST_INDEX_THREADS into an \"int val\" with implicit cast.\nprogress.c:get_defalut_delay() does the same.\n\nSo I would say that it is up to you to pile on existing technical\ndebt by mimicking config.c:repo_config_get_index_threads() and\nprogress.c:get_default_delay(), or audit all callers of\ngit_env_ulong() and migrate appropriate ones among them to use\ngit_env_int().  From my cursory survey, I suspect that not many\ncallers of git_get_ulong() would survive.\n\nThanks.\n\n\n"},{"id":"553466","messageId":"20260928133846.2094261-1-christian.couder@gmail.com","threadId":"65969","inReplyTo":"20260908164129.560396-1-christian.couder@gmail.com","subject":"[PATCH v4 0/5] Introduce 'uploadpack.lazyFetchTrusted'","fromName":"Christian Couder","fromEmail":"christian.couder@gmail.com","sentAt":"2026-09-28T13:38:41Z","receivedAt":"2026-09-28T13:39:08Z","isPatch":true,"body":"Recently the \"promisor-remote\" capability was added to protocol v2,\nallowing servers and clients to agree on the promisor remotes they can\nsafely use.\n\nThe more servers use promisor remotes, the more it is important to\nproperly control if they can lazy fetch when responding to a clone or\nfetch request from the client.\n\nFor example, in the context of large object promisors (see\n\"Documentation/technical/large-object-promisors.adoc\"), if a client\nclones with a filter set to 100kB while the server has moved all of\nthe blobs >= 10kB to a promisor remote, the server will not be able to\nprovide blobs between 10kB and 100kB to the client, which will make\nthe clone fail.\n\nEven if the `--filter=auto` option is available since ef2f1845ec\n(fetch-pack: wire up and enable auto filter logic, 2026-02-16) it's\nstill a good idea to provide more control over lazy fetching on the\nserver side to server operators, as lazy fetching on the server side\ncould be useful in corporate environments.\n\nSince 7b70e9efb1 (upload-pack: disable lazy-fetching by default,\n2024-04-16), lazy fetching has been controlled by the\n`GIT_NO_LAZY_FETCH` environment variable. This is a boolean that is\nset to 'true' by default when calling `git upload-pack` for security\nreasons.\n\nThe main security issue on the server side is making sure the served\nrepo itself is also trusted, as lazily fetching runs `git fetch`,\nwhich may execute arbitrary commands specified in the configuration\nand hooks of the served repo. The operator of the server should decide\nand mark that trust, not the served repo itself, nor the client.\n\nThis series introduces a new 'uploadpack.lazyFetchTrusted' protected\nconfiguration variable similar to 'safe.directory' (see\n\"Documentation/config/safe.adoc\") to mark trusted repos where lazy\nfetching is allowed. As it is protected, this config variable will\nonly take effect if it is set in global or system scope, so only\nserver operators can control it.\n\nPrevious related work\n=====================\n\nA previous series called \"Introduce a 'fromAccepted' option to\nGIT_NO_LAZY_FETCH\" [1] took a different approach as it wanted to make\nit easier to allow lazy fetching from accepted promisor remotes. But\nafter brian replied that he didn't think it was a good idea, and after\nthinking about this more, my opinion now is that some promisor remotes\nbeing accepted or not is not really relevant to the issue.\n\nIn my reply to brian, I said:\n\n\"\"\"\nDifferent features could be developed (in future work) to improve on\nthe current state:\n    - a way for lazy fetching to work without reading config files,\ntriggering hooks, or doing potentially sensitive things,\n    - an explicit way for operators to mark trusted repos (like\nperhaps a server-side config the operator sets per-repo),\n    - operator-defined allow/deny rules, or maybe\n    - some ways/scripts/commands to scan repos and check configuration\ninformation, remote settings and everything potentially sensitive to\ndecide if a repo looks safe enough to allow lazy fetching or not.\n\"\"\"\n\nSo I decided to go with \"an explicit way for operators to mark trusted\nrepos\" and this series is an implementation of that.\n\nNote that the feature developed in this series applies to protocol\nv0/v1 as well as v2 while the previous one was only related to v2.\n\n[1]: https://lore.kernel.org/git/CAP8UFD0_S9eg_w42tcNRnT9E2ntLr_eHLnzE4c2dSu67DzZoXg@mail.gmail.com/\n\nOverview of the patches\n=======================\n\n  - Patch 1/5 is the only patch saved from the \"Introduce a\n    'fromAccepted' option to GIT_NO_LAZY_FETCH\" series. It's not\n    necessary for the rest of this series and its main feature to\n    work, but I think it's a nice refactoring related to lazy\n    fetching, so it might as well be part of this series.\n\n  - Patch 2/5 extracts and modifies code used by the 'safe.directory'\n    config variable in new path_allowlist_config_apply() and\n    path_allowlist_apply() functions, so that these functions can be\n    reused to process 'uploadpack.lazyFetchTrusted' in the next patch.\n\n  - Patch 3/5 uses the new functions from the previous patch in a new\n    upload_pack_lazy_fetch_trusted() function to process\n    'uploadpack.lazyFetchTrusted', but the result from that processing\n    isn't actually used to have a practical effect.\n\n  - Patch 4/5 prevents infinite lazy fetch recursions that the\n    following patch would otherwise make possible. If a repo is\n    allowed to lazy fetch and one of its promisor remotes resolves\n    back to it, for example if it is its own promisor remote as Junio\n    noticed when reviewing v2, each nested `upload-pack` inherits\n    `GIT_NO_LAZY_FETCH=0` and fetches again.\n\n  - Patch 5/5 wires up the new upload_pack_lazy_fetch_trusted()\n    function to decide if lazy fetching can actually be enabled.\n\nChanges since v3\n================\n\nThanks to Junio for reviewing previous versions of this series.\n\nRebased on top of 34f06850c1 (Merge tag 'l10n-2.56.0-v1' of\nhttps://github.com/git-l10n/git-po, 2026-09-27) as I wanted to avoid\npossible merge issues and be based on a stable commit close to\nv2.56.0.\n\nExcept for some functions and variables that are now typed using a\nregular `int` instead of a `bool` or an `unsigned long`, there are\nmostly commit message and code comment changes in the first 4 patches\nof this version compared to v3.\n\nThe most significant changes are in patch 5/5.\n\n - In patch 1/5:\n\n   - The commit message explains why it's fine to not call\n     promisor_remote_init() when lazy fetching is disabled.\n\n   - The code comments documenting both try_promisor_remotes() and the\n     new lazy_fetch_objects() function are improved.\n\n   - These two functions now keep returning `int`, as before this\n     series, instead of `bool` as in v3.\n\n - In patch 2/5, instead of changing `int is_safe` to `bool safe` in\n   `struct safe_directory_data`, only the type of this member is\n   changed from `int` to `bool`. The commit message also better\n   explains this change.\n\n - Patch 3/5 is unchanged. \n\n - In patch 4/5, the variable and function arguments called `depth`\n   are changed from an `unsigned long` to a regular `int`.\n\n - In patch 5/5:\n\n   - In the code, instead of checking if GIT_NO_LAZY_FETCH is already\n     set at the beginning of the command, then unconditionally setting\n     it to 1, and later setting it to 0 if the repo is trusted, we now\n     only check if it is set after entering the repo, and set it to 1\n     if it isn't and the repo isn't trusted.\n\n   - The patch title is changed accordingly from \"set\n     GIT_NO_LAZY_FETCH to 0 on trusted repo\" to \"don't disable lazy\n     fetching on trusted repo\".\n\n   - The commit message is changed too:\n\n     - the patch is reframed as teaching the code that sets\n       `GIT_NO_LAZY_FETCH` to consult the config, rather than config\n       overriding the environment,\n\n     - the 3 possible cases regarding `GIT_NO_LAZY_FETCH` are listed,\n\n     - the \"served repo is trusted\" sentence is fixed to say that the\n       operator vouches for the promisor remotes, configuration and\n       hooks of the listed repo.\n\nCI tests\n========\n\nThey all pass, see:\n\nhttps://github.com/chriscool/git/actions/runs/36414541860\n\nRange-diff compared to v3\n=========================\n\n1:  9403597855 ! 1:  e7332d0aa4 promisor-remote: factor out lazy_fetch_objects()\n    @@ Commit message\n             instead of once per promisor remote, and\n     \n           - promisor_remote_init() is no longer called when lazy fetching\n    -        is disabled, which is fine as nothing downstream of it, like\n    -        is_promisor_object(), needs it in that case.\n    +        is disabled.\n     \n    -    While at it, let's also convert try_promisor_remotes() to return\n    -    'bool' instead of 'int', as it just returns whether all the objects\n    -    could be fetched, and document its return value.\n    +    The latter is fine because the convention around promisor_remote_init()\n    +    is that whoever needs to access the promisor remote information is\n    +    expected to initialize it beforehand, and not that it should be\n    +    initialized once at the very beginning before doing random things on\n    +    promisor remotes. So moving its call site into lazy_fetch_objects(),\n    +    which is the only code that needs the promisor remotes here, follows\n    +    that convention. Nothing downstream of it, like is_promisor_object(),\n    +    needs it when lazy fetching is disabled.\n    +\n    +    While at it, let's document try_promisor_remotes() and the new\n    +    lazy_fetch_objects() function, especially how their `remaining_oids`,\n    +    `remaining_nr` and `to_free` arguments are used, as the ownership\n    +    rules around `to_free` are easy to get wrong.\n     \n         Signed-off-by: Christian Couder <christian.couder@gmail.com>\n     \n    @@ promisor-remote.c: static int remove_fetched_oids(struct repository *repo,\n      \treturn remaining_nr;\n      }\n      \n    --static int try_promisor_remotes(struct repository *repo,\n    --\t\t\t\tstruct object_id **remaining_oids,\n    --\t\t\t\tint *remaining_nr, int *to_free,\n    --\t\t\t\tbool accepted_only)\n     +/*\n    -+ * Return 'true' if all the objects could be fetched from the\n    -+ * (non-)accepted remotes, 'false' otherwise.\n    ++ * Fetch the remaining objects (given in '*remaining_oids', which\n    ++ * contains '*remaining_nr' object ids) from the known promisor\n    ++ * remotes. If 'accepted_only' is true, ignore promisor remotes with\n    ++ * their 'accepted' member unset.\n    ++ *\n    ++ * When a fetch from a remote fails, the objects that are still\n    ++ * missing are computed, and '*remaining_oids' and '*remaining_nr' are\n    ++ * updated accordingly before trying the next remote. In that case\n    ++ * '*remaining_oids' points to a new array that this function\n    ++ * allocated, and '*to_free' is set to 1 to tell the caller that it\n    ++ * owns that array and should free it. '*to_free' should be 0 on the\n    ++ * first call.\n    ++ *\n    ++ * Return 1 when all the requested objects have been fetched, 0\n    ++ * otherwise.\n     + */\n    -+static bool try_promisor_remotes(struct repository *repo,\n    -+\t\t\t\t struct object_id **remaining_oids,\n    -+\t\t\t\t int *remaining_nr,\n    -+\t\t\t\t int *to_free,\n    -+\t\t\t\t bool accepted_only)\n    + static int try_promisor_remotes(struct repository *repo,\n    + \t\t\t\tstruct object_id **remaining_oids,\n    +-\t\t\t\tint *remaining_nr, int *to_free,\n    ++\t\t\t\tint *remaining_nr,\n    ++\t\t\t\tint *to_free,\n    + \t\t\t\tbool accepted_only)\n      {\n      \tstruct promisor_remote *r = repo->promisor_remote_config->promisors;\n    - \n     @@ promisor-remote.c: static int try_promisor_remotes(struct repository *repo,\n    - \t\t\t\tcontinue;\n    - \t\t\t}\n    - \t\t}\n    --\t\treturn 1; /* all fetched */\n    -+\t\treturn true; /* all fetched */\n    - \t}\n    --\treturn 0;\n    -+\treturn false;\n    -+}\n    -+\n    + \treturn 0;\n    + }\n    + \n     +/*\n    -+ * Return 'true' if all the objects could be fetched, 'false' otherwise.\n    ++ * Lazily fetch the objects given in '*remaining_oids' from the\n    ++ * promisor remotes, trying the accepted ones first. See\n    ++ * try_promisor_remotes() above for how '*remaining_oids',\n    ++ * '*remaining_nr' and '*to_free' are used.\n    ++ *\n    ++ * Return 1 when all the requested objects have been fetched, 0\n    ++ * otherwise.\n     + */\n    -+static bool lazy_fetch_objects(struct repository *repo,\n    -+\t\t\t       struct object_id **remaining_oids,\n    -+\t\t\t       int *remaining_nr,\n    -+\t\t\t       int *to_free)\n    ++static int lazy_fetch_objects(struct repository *repo,\n    ++\t\t\t      struct object_id **remaining_oids,\n    ++\t\t\t      int *remaining_nr,\n    ++\t\t\t      int *to_free)\n     +{\n     +\tif (git_env_bool(NO_LAZY_FETCH_ENVIRONMENT, 0)) {\n     +\t\tstatic int warning_shown;\n    @@ promisor-remote.c: static int try_promisor_remotes(struct repository *repo,\n     +\t\t\twarning_shown = 1;\n     +\t\t\twarning(_(\"lazy fetching disabled; some objects may not be available\"));\n     +\t\t}\n    -+\t\treturn false;\n    ++\t\treturn 0;\n     +\t}\n     +\n     +\tpromisor_remote_init(repo);\n    @@ promisor-remote.c: static int try_promisor_remotes(struct repository *repo,\n     +\t\t\t\t    to_free, true) ||\n     +\t\ttry_promisor_remotes(repo, remaining_oids, remaining_nr,\n     +\t\t\t\t     to_free, false);\n    - }\n    - \n    ++}\n    ++\n      void promisor_remote_get_direct(struct repository *repo,\n    + \t\t\t\tconst struct object_id *oids,\n    + \t\t\t\tint oid_nr)\n     @@ promisor-remote.c: void promisor_remote_get_direct(struct repository *repo,\n      \tstruct object_id *remaining_oids = (struct object_id *)oids;\n      \tint remaining_nr = oid_nr;\n2:  2155c4202d ! 2:  b4a63e3e4e setup: extract path_allowlist_apply()\n    @@ Commit message\n         the config-value handling in a future commit, let's also introduce a\n         path_allowlist_config_apply() helper.\n     \n    -    For clarity, let's change the `int is_safe` to `bool safe` in\n    -    `struct safe_directory_data`.\n    +    As the new path_allowlist_apply() function reports its result through\n    +    a `bool *matches` argument, let's also change the `int is_safe` member\n    +    of `struct safe_directory_data` to a `bool`, so that its address can\n    +    be passed as that argument.\n     \n         Signed-off-by: Christian Couder <christian.couder@gmail.com>\n     \n    @@ setup.c: static int canonicalize_ceiling_entry(struct string_list_item *item,\n      struct safe_directory_data {\n      \tchar *path;\n     -\tint is_safe;\n    -+\tbool safe;\n    ++\tbool is_safe;\n      };\n      \n      static int safe_directory_cb(const char *key, const char *value,\n    @@ setup.c: static int canonicalize_ceiling_entry(struct string_list_item *item,\n     -\t\t\tfree(allowed);\n     -\t\t}\n     -\t}\n    -+\tpath_allowlist_config_apply(key, value, data->path, &data->safe,\n    ++\tpath_allowlist_config_apply(key, value, data->path, &data->is_safe,\n     +\t\t\t\t    allow_safe_dir, &cbdata);\n      \n      \treturn 0;\n      }\n    -@@ setup.c: static int ensure_valid_ownership(const char *gitfile,\n    - \tgit_protected_config(safe_directory_cb, &data);\n    - \n    - \tfree(data.path);\n    --\treturn data.is_safe;\n    -+\treturn data.safe;\n    - }\n    - \n    - void die_upon_dubious_ownership(const char *gitfile, const char *worktree,\n     \n      ## setup.h ##\n     @@ setup.h: struct startup_info {\n3:  37043ffeaf = 3:  1e2d2d4b4f upload-pack: read uploadpack.lazyFetchTrusted\n4:  38fc060999 ! 4:  3e88ec41a4 promisor-remote: prevent infinite recursion when lazy fetching\n    @@ promisor-remote.c: struct promisor_remote_config {\n      \t\t\t const char *remote_name,\n      \t\t\t const struct object_id *oids,\n     -\t\t\t int oid_nr)\n    -+\t\t\t int oid_nr, unsigned long depth)\n    ++\t\t\t int oid_nr, int depth)\n      {\n      \tstruct child_process child = CHILD_PROCESS_INIT;\n      \tint i;\n    @@ promisor-remote.c: static int fetch_objects(struct repository *repo,\n      \t\t     \"--filter=blob:none\", \"--stdin\", NULL);\n      \tif (!repo_config_get_bool(repo, \"promisor.quiet\", &quiet) && quiet)\n      \t\tstrvec_push(&child.args, \"--quiet\");\n    -+\tstrvec_pushf(&child.env, \"%s=%lu\", LAZY_FETCH_DEPTH_ENVIRONMENT, depth + 1);\n    ++\tstrvec_pushf(&child.env, \"%s=%d\", LAZY_FETCH_DEPTH_ENVIRONMENT, depth + 1);\n      \tif (start_command(&child))\n      \t\tdie(_(\"promisor-remote: unable to fork off fetch subprocess\"));\n      \tchild_in = xfdopen(child.in, \"w\");\n    -@@ promisor-remote.c: static bool try_promisor_remotes(struct repository *repo,\n    - \t\t\t\t struct object_id **remaining_oids,\n    - \t\t\t\t int *remaining_nr,\n    - \t\t\t\t int *to_free,\n    -+\t\t\t\t unsigned long depth,\n    - \t\t\t\t bool accepted_only)\n    +@@ promisor-remote.c: static int try_promisor_remotes(struct repository *repo,\n    + \t\t\t\tstruct object_id **remaining_oids,\n    + \t\t\t\tint *remaining_nr,\n    + \t\t\t\tint *to_free,\n    ++\t\t\t\tint depth,\n    + \t\t\t\tbool accepted_only)\n      {\n      \tstruct promisor_remote *r = repo->promisor_remote_config->promisors;\n    -@@ promisor-remote.c: static bool try_promisor_remotes(struct repository *repo,\n    +@@ promisor-remote.c: static int try_promisor_remotes(struct repository *repo,\n      \tfor (; r; r = r->next) {\n      \t\tif (accepted_only != r->accepted)\n      \t\t\tcontinue;\n    @@ promisor-remote.c: static bool try_promisor_remotes(struct repository *repo,\n      \t\t\tif (*remaining_nr == 1)\n      \t\t\t\tcontinue;\n      \t\t\t*remaining_nr = remove_fetched_oids(repo, remaining_oids,\n    -@@ promisor-remote.c: static bool try_promisor_remotes(struct repository *repo,\n    - \treturn false;\n    +@@ promisor-remote.c: static int try_promisor_remotes(struct repository *repo,\n    + \treturn 0;\n      }\n      \n     +#define MAX_LAZY_FETCH_DEPTH 5\n     +\n      /*\n    -  * Return 'true' if all the objects could be fetched, 'false' otherwise.\n    -  */\n    -@@ promisor-remote.c: static bool lazy_fetch_objects(struct repository *repo,\n    - \t\t\t       int *remaining_nr,\n    - \t\t\t       int *to_free)\n    +  * Lazily fetch the objects given in '*remaining_oids' from the\n    +  * promisor remotes, trying the accepted ones first. See\n    +@@ promisor-remote.c: static int lazy_fetch_objects(struct repository *repo,\n    + \t\t\t      int *remaining_nr,\n    + \t\t\t      int *to_free)\n      {\n    -+\tunsigned long depth = git_env_ulong(LAZY_FETCH_DEPTH_ENVIRONMENT, 0);\n    ++\tint depth = (int)git_env_ulong(LAZY_FETCH_DEPTH_ENVIRONMENT, 0);\n     +\n      \tif (git_env_bool(NO_LAZY_FETCH_ENVIRONMENT, 0)) {\n      \t\tstatic int warning_shown;\n      \t\tif (!warning_shown) {\n    -@@ promisor-remote.c: static bool lazy_fetch_objects(struct repository *repo,\n    - \t\treturn false;\n    +@@ promisor-remote.c: static int lazy_fetch_objects(struct repository *repo,\n    + \t\treturn 0;\n      \t}\n      \n     +\tif (depth >= MAX_LAZY_FETCH_DEPTH) {\n     +\t\tstatic int warning_shown;\n     +\t\tif (!warning_shown) {\n     +\t\t\twarning_shown = 1;\n    -+\t\t\twarning(_(\"too many nested lazy fetches (%lu); \"\n    ++\t\t\twarning(_(\"too many nested lazy fetches (%d); \"\n     +\t\t\t\t  \"is a promisor remote pointing at the repository itself?\"),\n     +\t\t\t\tdepth);\n     +\t\t}\n    -+\t\treturn false;\n    ++\t\treturn 0;\n     +\t}\n     +\n      \tpromisor_remote_init(repo);\n5:  8cb97230e5 ! 5:  ad8814984b builtin/upload-pack: set GIT_NO_LAZY_FETCH to 0 on trusted repo\n    @@ Metadata\n     Author: Christian Couder <christian.couder@gmail.com>\n     \n      ## Commit message ##\n    -    builtin/upload-pack: set GIT_NO_LAZY_FETCH to 0 on trusted repo\n    +    builtin/upload-pack: don't disable lazy fetching on trusted repo\n     \n         A previous commit added a new \"uploadpack.lazyFetchTrusted\" protected\n         config variable that can contain an allowlist of repos, as well as\n         functions to check if the current repo is in that list. But when the\n         current repo is in that list, we currently do nothing.\n     \n    -    Let's instead set `GIT_NO_LAZY_FETCH` to `0`, which allows\n    -    `upload-pack` and its `pack-objects` child process to lazily fetch the\n    -    objects they need to serve a client, for example when the filter used\n    -    by the client and the one used by the server don't match.\n    +    Since 7b70e9efb1 (upload-pack: disable lazy-fetching by default,\n    +    2024-04-16), `upload-pack` sets `GIT_NO_LAZY_FETCH` to 1 itself,\n    +    unconditionally, because by default it shouldn't trust the repositories\n    +    it serves. Lazily fetching runs `git fetch`, which may execute\n    +    arbitrary commands specified in the configuration and hooks of the\n    +    served repo.\n     \n    -    This allows server operators to properly control lazy fetching. It is\n    -    their responsibility, not the client's, to decide if the served repo is\n    -    trusted, as the main security issue is that lazily fetching runs `git\n    -    fetch`, which may execute arbitrary commands specified in the\n    -    configuration and hooks of the served repo.\n    +    The new \"uploadpack.lazyFetchTrusted\" protected config variable is not\n    +    about overriding an environment variable. It's rather about teaching\n    +    the code that automatically sets `GIT_NO_LAZY_FETCH` (because it had no\n    +    way to know if the served repo could be trusted) to look at the new\n    +    config variable to find out if a server operator actually vouched for\n    +    that repo.\n    +\n    +    Let's implement that, so we now have the following cases:\n    +\n    +      - if `GIT_NO_LAZY_FETCH` is already set, we honor it and leave it\n    +        alone, as it comes from the server operator,\n    +\n    +      - otherwise, if the served repo is in the\n    +        \"uploadpack.lazyFetchTrusted\" allowlist, we don't disable lazy\n    +        fetching,\n    +\n    +      - otherwise, we disable lazy fetching, as we used to.\n    +\n    +    This allows `upload-pack` and its `pack-objects` child process to\n    +    lazily fetch the objects they need to serve a client, for example when\n    +    the filter used by the client and the one used by the server don't\n    +    match.\n    +\n    +    Note that what a server operator vouches for by listing a repo there\n    +    is that the promisor remotes this repo is configured to lazily fetch\n    +    from, as well as its configuration and hooks, are trustworthy. Whether\n    +    a client trusts the repo it fetches from is a separate matter, and up\n    +    to the client.\n     \n         As `GIT_NO_LAZY_FETCH` is passed down to child processes through the\n         environment, this works for `pack-objects`, which performs the lazy\n    @@ Documentation/git.adoc: for full details.\n     \n      ## builtin/upload-pack.c ##\n     @@ builtin/upload-pack.c: int cmd_upload_pack(int argc,\n    - \t\tOPT_END()\n    - \t};\n    - \tunsigned enter_repo_flags = ENTER_REPO_ANY_OWNER_OK;\n    -+\tbool no_lazy_fetch_set;\n    - \n      \tpacket_trace_identity(\"upload-pack\");\n      \tdisable_replace_refs();\n      \tsave_commit_buffer = 0;\n    -+\n    -+\tno_lazy_fetch_set = !!getenv(NO_LAZY_FETCH_ENVIRONMENT);\n    - \txsetenv(NO_LAZY_FETCH_ENVIRONMENT, \"1\", 0);\n    +-\txsetenv(NO_LAZY_FETCH_ENVIRONMENT, \"1\", 0);\n      \n      \targc = parse_options(argc, argv, prefix, options, upload_pack_usage, 0);\n    + \n     @@ builtin/upload-pack.c: int cmd_upload_pack(int argc,\n      \tif (!enter_repo(the_repository, dir, enter_repo_flags))\n      \t\tdie(\"'%s' does not appear to be a git repository\", dir);\n      \n     +\t/*\n    -+\t * Relax the GIT_NO_LAZY_FETCH=1 default if the served repo is in\n    -+\t * the \"uploadpack.lazyFetchTrusted\" protected allowlist and\n    -+\t * GIT_NO_LAZY_FETCH was not already set explicitly.\n    ++\t * Lazily fetching while serving a client would run `git fetch`,\n    ++\t * which may execute arbitrary commands from the configuration\n    ++\t * and hooks of the served repo, so we disable it by default as\n    ++\t * we trust nobody. There are two ways for a server operator to\n    ++\t * allow it though:\n    ++\t *\n    ++\t *   - if GIT_NO_LAZY_FETCH is already set, we leave it alone and\n    ++\t *     honor whatever the operator put there,\n    ++\t *\n    ++\t *   - otherwise, if the served repo is in the\n    ++\t *     \"uploadpack.lazyFetchTrusted\" protected allowlist, we\n    ++\t *     don't disable lazy fetching.\n     +\t */\n    -+\tif (!no_lazy_fetch_set && upload_pack_lazy_fetch_trusted(the_repository))\n    -+\t\txsetenv(NO_LAZY_FETCH_ENVIRONMENT, \"0\", 1);\n    ++\tif (!getenv(NO_LAZY_FETCH_ENVIRONMENT) &&\n    ++\t    !upload_pack_lazy_fetch_trusted(the_repository))\n    ++\t\txsetenv(NO_LAZY_FETCH_ENVIRONMENT, \"1\", 1);\n     +\n      \tswitch (determine_protocol_version_server()) {\n      \tcase protocol_v2:\n\n\nChristian Couder (5):\n  promisor-remote: factor out lazy_fetch_objects()\n  setup: extract path_allowlist_apply()\n  upload-pack: read uploadpack.lazyFetchTrusted\n  promisor-remote: prevent infinite recursion when lazy fetching\n  builtin/upload-pack: don't disable lazy fetching on trusted repo\n\n Documentation/config/uploadpack.adoc  |  49 +++++++++\n Documentation/git-upload-pack.adoc    |   5 +\n Documentation/git.adoc                |   4 +-\n builtin/upload-pack.c                 |  19 +++-\n environment.h                         |   8 ++\n promisor-remote.c                     | 105 ++++++++++++++-----\n setup.c                               | 136 +++++++++++++++---------\n setup.h                               |  50 +++++++++\n t/t0410-partial-clone.sh              |  33 ++++++\n t/t5710-promisor-remote-capability.sh | 142 ++++++++++++++++++++++++++\n upload-pack.c                         |  59 +++++++++++\n upload-pack.h                         |   3 +\n 12 files changed, 534 insertions(+), 79 deletions(-)\n\n\nbase-commit: 34f06850c16c7f7ac822b1adc71354f11b0f2ca3\n-- \n2.56.0.rc2.20.g34f06850c1\n\n"},{"id":"553467","messageId":"20260928133846.2094261-2-christian.couder@gmail.com","threadId":"65969","inReplyTo":"20260928133846.2094261-1-christian.couder@gmail.com","subject":"[PATCH v4 1/5] promisor-remote: factor out lazy_fetch_objects()","fromName":"Christian Couder","fromEmail":"christian.couder@gmail.com","sentAt":"2026-09-28T13:38:42Z","receivedAt":"2026-09-28T13:39:09Z","isPatch":true,"body":"In \"promisor-remote.c:fetch_objects()\", there is a check to disable\nlazy fetching when the `GIT_NO_LAZY_FETCH` environment variable is\nset. The fetch_objects() function is called once per promisor remote\nthough. So the check might be performed more times than necessary.\n\nAlso promisor_remote_get_direct() mixes up the logic deciding which\npromisor remotes to try with the logic checking that the objects\nthat could not be fetched are promisor objects.\n\nLet's refactor the lazy fetching logic out of these two functions\ninto a new lazy_fetch_objects() function.\n\nThis is a pure refactoring with no intended behavior change. Two\nthings shift in ways that are observably equivalent though:\n\n  - the `GIT_NO_LAZY_FETCH` check is now performed once up front,\n    instead of once per promisor remote, and\n\n  - promisor_remote_init() is no longer called when lazy fetching\n    is disabled.\n\nThe latter is fine because the convention around promisor_remote_init()\nis that whoever needs to access the promisor remote information is\nexpected to initialize it beforehand, and not that it should be\ninitialized once at the very beginning before doing random things on\npromisor remotes. So moving its call site into lazy_fetch_objects(),\nwhich is the only code that needs the promisor remotes here, follows\nthat convention. Nothing downstream of it, like is_promisor_object(),\nneeds it when lazy fetching is disabled.\n\nWhile at it, let's document try_promisor_remotes() and the new\nlazy_fetch_objects() function, especially how their `remaining_oids`,\n`remaining_nr` and `to_free` arguments are used, as the ownership\nrules around `to_free` are easy to get wrong.\n\nSigned-off-by: Christian Couder <christian.couder@gmail.com>\n---\n promisor-remote.c | 83 ++++++++++++++++++++++++++++++++---------------\n 1 file changed, 57 insertions(+), 26 deletions(-)\n\ndiff --git a/promisor-remote.c b/promisor-remote.c\nindex 43505d1e1a..91245fe9a8 100644\n--- a/promisor-remote.c\n+++ b/promisor-remote.c\n@@ -31,15 +31,6 @@ static int fetch_objects(struct repository *repo,\n \tFILE *child_in;\n \tint quiet;\n \n-\tif (git_env_bool(NO_LAZY_FETCH_ENVIRONMENT, 0)) {\n-\t\tstatic int warning_shown;\n-\t\tif (!warning_shown) {\n-\t\t\twarning_shown = 1;\n-\t\t\twarning(_(\"lazy fetching disabled; some objects may not be available\"));\n-\t\t}\n-\t\treturn -1;\n-\t}\n-\n \tchild.git_cmd = 1;\n \tchild.in = -1;\n \tif (repo != the_repository)\n@@ -270,9 +261,27 @@ static int remove_fetched_oids(struct repository *repo,\n \treturn remaining_nr;\n }\n \n+/*\n+ * Fetch the remaining objects (given in '*remaining_oids', which\n+ * contains '*remaining_nr' object ids) from the known promisor\n+ * remotes. If 'accepted_only' is true, ignore promisor remotes with\n+ * their 'accepted' member unset.\n+ *\n+ * When a fetch from a remote fails, the objects that are still\n+ * missing are computed, and '*remaining_oids' and '*remaining_nr' are\n+ * updated accordingly before trying the next remote. In that case\n+ * '*remaining_oids' points to a new array that this function\n+ * allocated, and '*to_free' is set to 1 to tell the caller that it\n+ * owns that array and should free it. '*to_free' should be 0 on the\n+ * first call.\n+ *\n+ * Return 1 when all the requested objects have been fetched, 0\n+ * otherwise.\n+ */\n static int try_promisor_remotes(struct repository *repo,\n \t\t\t\tstruct object_id **remaining_oids,\n-\t\t\t\tint *remaining_nr, int *to_free,\n+\t\t\t\tint *remaining_nr,\n+\t\t\t\tint *to_free,\n \t\t\t\tbool accepted_only)\n {\n \tstruct promisor_remote *r = repo->promisor_remote_config->promisors;\n@@ -295,6 +304,38 @@ static int try_promisor_remotes(struct repository *repo,\n \treturn 0;\n }\n \n+/*\n+ * Lazily fetch the objects given in '*remaining_oids' from the\n+ * promisor remotes, trying the accepted ones first. See\n+ * try_promisor_remotes() above for how '*remaining_oids',\n+ * '*remaining_nr' and '*to_free' are used.\n+ *\n+ * Return 1 when all the requested objects have been fetched, 0\n+ * otherwise.\n+ */\n+static int lazy_fetch_objects(struct repository *repo,\n+\t\t\t      struct object_id **remaining_oids,\n+\t\t\t      int *remaining_nr,\n+\t\t\t      int *to_free)\n+{\n+\tif (git_env_bool(NO_LAZY_FETCH_ENVIRONMENT, 0)) {\n+\t\tstatic int warning_shown;\n+\t\tif (!warning_shown) {\n+\t\t\twarning_shown = 1;\n+\t\t\twarning(_(\"lazy fetching disabled; some objects may not be available\"));\n+\t\t}\n+\t\treturn 0;\n+\t}\n+\n+\tpromisor_remote_init(repo);\n+\n+\t/* Try accepted remotes first (those the server told us to use) */\n+\treturn try_promisor_remotes(repo, remaining_oids, remaining_nr,\n+\t\t\t\t    to_free, true) ||\n+\t\ttry_promisor_remotes(repo, remaining_oids, remaining_nr,\n+\t\t\t\t     to_free, false);\n+}\n+\n void promisor_remote_get_direct(struct repository *repo,\n \t\t\t\tconst struct object_id *oids,\n \t\t\t\tint oid_nr)\n@@ -302,28 +343,18 @@ void promisor_remote_get_direct(struct repository *repo,\n \tstruct object_id *remaining_oids = (struct object_id *)oids;\n \tint remaining_nr = oid_nr;\n \tint to_free = 0;\n-\tint i;\n \n \tif (oid_nr == 0)\n \t\treturn;\n \n-\tpromisor_remote_init(repo);\n-\n-\t/* Try accepted remotes first (those the server told us to use) */\n-\tif (try_promisor_remotes(repo, &remaining_oids, &remaining_nr,\n-\t\t\t\t &to_free, true))\n-\t\tgoto all_fetched;\n-\tif (try_promisor_remotes(repo, &remaining_oids, &remaining_nr,\n-\t\t\t\t &to_free, false))\n-\t\tgoto all_fetched;\n-\n-\tfor (i = 0; i < remaining_nr; i++) {\n-\t\tif (is_promisor_object(repo, &remaining_oids[i]))\n-\t\t\tdie(_(\"could not fetch %s from promisor remote\"),\n-\t\t\t    oid_to_hex(&remaining_oids[i]));\n+\tif (!lazy_fetch_objects(repo, &remaining_oids, &remaining_nr, &to_free)) {\n+\t\tfor (int i = 0; i < remaining_nr; i++) {\n+\t\t\tif (is_promisor_object(repo, &remaining_oids[i]))\n+\t\t\t\tdie(_(\"could not fetch %s from promisor remote\"),\n+\t\t\t\t    oid_to_hex(&remaining_oids[i]));\n+\t\t}\n \t}\n \n-all_fetched:\n \tif (to_free)\n \t\tfree(remaining_oids);\n }\n-- \n2.56.0.rc2.20.g34f06850c1\n\n"},{"id":"553468","messageId":"20260928133846.2094261-3-christian.couder@gmail.com","threadId":"65969","inReplyTo":"20260928133846.2094261-1-christian.couder@gmail.com","subject":"[PATCH v4 2/5] setup: extract path_allowlist_apply()","fromName":"Christian Couder","fromEmail":"christian.couder@gmail.com","sentAt":"2026-09-28T13:38:43Z","receivedAt":"2026-09-28T13:39:11Z","isPatch":true,"body":"In a following commit we are going to check whether a repository is\npart of an allowlist specified in a config variable.\n\nTo prepare for that let's extract existing code from\nsafe_directory_cb() into a new path_allowlist_apply() helper that will\nhelp with such checks.\n\nWhile at it let's make the helper's code simpler and more generic, by\npassing it a `bool (*allow_path)(const char *path, void *cbdata)`\nfunction that decides if a path is acceptable by the caller.\n\nTo further simplify how to reuse that new helper, and avoid duplicating\nthe config-value handling in a future commit, let's also introduce a\npath_allowlist_config_apply() helper.\n\nAs the new path_allowlist_apply() function reports its result through\na `bool *matches` argument, let's also change the `int is_safe` member\nof `struct safe_directory_data` to a `bool`, so that its address can\nbe passed as that argument.\n\nSigned-off-by: Christian Couder <christian.couder@gmail.com>\n---\n setup.c | 136 ++++++++++++++++++++++++++++++++++++--------------------\n setup.h |  50 +++++++++++++++++++++\n 2 files changed, 137 insertions(+), 49 deletions(-)\n\ndiff --git a/setup.c b/setup.c\nindex 0d157ac254..adef789d54 100644\n--- a/setup.c\n+++ b/setup.c\n@@ -1355,67 +1355,105 @@ static int canonicalize_ceiling_entry(struct string_list_item *item,\n \t}\n }\n \n+void path_allowlist_apply(const char *allowed, const char *target_path,\n+\t\t\t  bool *matches,\n+\t\t\t  bool (*allow_path)(const char *path, void *cbdata),\n+\t\t\t  void *allow_path_cbdata)\n+{\n+\tchar *normalized = NULL;\n+\n+\tif (!allowed || !*allowed) {\n+\t\t*matches = false;\n+\t\treturn;\n+\t}\n+\n+\tif (!strcmp(allowed, \"*\")) {\n+\t\t*matches = true;\n+\t\treturn;\n+\t}\n+\n+\tif (!allow_path(allowed, allow_path_cbdata))\n+\t\treturn;\n+\n+\t/*\n+\t * A .gitconfig in $HOME may be shared across different\n+\t * machines and the config variable entries may or may not\n+\t * exist as paths on all of these machines.  In other words,\n+\t * it is not a warning worthy event when there is no such path\n+\t * on this machine---the entry may be useful elsewhere.\n+\t */\n+\tnormalized = real_pathdup(allowed, 0);\n+\tif (!normalized)\n+\t\treturn;\n+\n+\tif (ends_with(normalized, \"/*\")) {\n+\t\tsize_t len = strlen(normalized);\n+\t\tif (!fspathncmp(normalized, target_path, len - 1))\n+\t\t\t*matches = true;\n+\t} else if (!fspathcmp(target_path, normalized)) {\n+\t\t*matches = true;\n+\t}\n+\n+\tfree(normalized);\n+}\n+\n+void path_allowlist_config_apply(const char *key, const char *value,\n+\t\t\t\t const char *target_path, bool *matches,\n+\t\t\t\t bool (*allow_path)(const char *path, void *cbdata),\n+\t\t\t\t void *allow_path_cbdata)\n+{\n+\tchar *allowed = NULL;\n+\n+\tif (!value || !*value || !strcmp(value, \"*\")) {\n+\t\tpath_allowlist_apply(value, target_path, matches,\n+\t\t\t\t     allow_path, allow_path_cbdata);\n+\t\treturn;\n+\t}\n+\n+\tif (git_config_pathname(&allowed, key, value) || !allowed)\n+\t\treturn;\n+\n+\tpath_allowlist_apply(allowed, target_path, matches,\n+\t\t\t     allow_path, allow_path_cbdata);\n+\n+\tfree(allowed);\n+}\n+\n+/*\n+ * Setting the config variable to a non-absolute path makes\n+ * little sense---it won't be relative to the configuration\n+ * file the item is defined in.  Except for \".\", which means\n+ * \"if we are at the top level of a repository, then it is\n+ * OK\", which is slightly tighter than \"*\" that allows\n+ * discovery.\n+ */\n+static bool allow_safe_dir(const char *path, void *cbdata_)\n+{\n+\tstruct path_allowlist_cb_data *cbdata = cbdata_;\n+\n+\tif (is_absolute_path(path) || !strcmp(path, \".\"))\n+\t\treturn true;\n+\n+\twarning(_(\"%s '%s' not absolute\"), cbdata->key, path);\n+\treturn false;\n+}\n+\n struct safe_directory_data {\n \tchar *path;\n-\tint is_safe;\n+\tbool is_safe;\n };\n \n static int safe_directory_cb(const char *key, const char *value,\n \t\t\t     const struct config_context *ctx UNUSED, void *d)\n {\n \tstruct safe_directory_data *data = d;\n+\tstruct path_allowlist_cb_data cbdata = { .key = key };\n \n \tif (strcmp(key, \"safe.directory\"))\n \t\treturn 0;\n \n-\tif (!value || !*value) {\n-\t\tdata->is_safe = 0;\n-\t} else if (!strcmp(value, \"*\")) {\n-\t\tdata->is_safe = 1;\n-\t} else {\n-\t\tchar *allowed = NULL;\n-\n-\t\tif (!git_config_pathname(&allowed, key, value) && allowed) {\n-\t\t\tchar *normalized = NULL;\n-\n-\t\t\t/*\n-\t\t\t * Setting safe.directory to a non-absolute path\n-\t\t\t * makes little sense---it won't be relative to\n-\t\t\t * the configuration file the item is defined in.\n-\t\t\t * Except for \".\", which means \"if we are at the top\n-\t\t\t * level of a repository, then it is OK\", which is\n-\t\t\t * slightly tighter than \"*\" that allows discovery.\n-\t\t\t */\n-\t\t\tif (!is_absolute_path(allowed) && strcmp(allowed, \".\")) {\n-\t\t\t\twarning(_(\"safe.directory '%s' not absolute\"),\n-\t\t\t\t\tallowed);\n-\t\t\t\tgoto next;\n-\t\t\t}\n-\n-\t\t\t/*\n-\t\t\t * A .gitconfig in $HOME may be shared across\n-\t\t\t * different machines and safe.directory entries\n-\t\t\t * may or may not exist as paths on all of these\n-\t\t\t * machines.  In other words, it is not a warning\n-\t\t\t * worthy event when there is no such path on this\n-\t\t\t * machine---the entry may be useful elsewhere.\n-\t\t\t */\n-\t\t\tnormalized = real_pathdup(allowed, 0);\n-\t\t\tif (!normalized)\n-\t\t\t\tgoto next;\n-\n-\t\t\tif (ends_with(normalized, \"/*\")) {\n-\t\t\t\tsize_t len = strlen(normalized);\n-\t\t\t\tif (!fspathncmp(normalized, data->path, len - 1))\n-\t\t\t\t\tdata->is_safe = 1;\n-\t\t\t} else if (!fspathcmp(data->path, normalized)) {\n-\t\t\t\tdata->is_safe = 1;\n-\t\t\t}\n-\t\tnext:\n-\t\t\tfree(normalized);\n-\t\t\tfree(allowed);\n-\t\t}\n-\t}\n+\tpath_allowlist_config_apply(key, value, data->path, &data->is_safe,\n+\t\t\t\t    allow_safe_dir, &cbdata);\n \n \treturn 0;\n }\ndiff --git a/setup.h b/setup.h\nindex 7394473e95..7362467ee5 100644\n--- a/setup.h\n+++ b/setup.h\n@@ -305,4 +305,54 @@ struct startup_info {\n extern struct startup_info *startup_info;\n extern const char *tmp_original_cwd;\n \n+/* Path allowlist */\n+\n+struct path_allowlist_cb_data {\n+\tconst char *key;\n+};\n+\n+/*\n+ * Check the allowlist entry in `allowed` against `target_path`,\n+ * updating `*matches` accordingly.\n+ *\n+ * `allowed` is a single entry of an allowlist of paths, typically one\n+ * value of a multi-valued config variable, already expanded by\n+ * git_config_pathname(). `target_path` is the (normalized) path being\n+ * tested. `*matches` is updated in place:\n+ *\n+ *   - an empty `allowed` resets it to 'false' (so a later, more\n+ *     specific config scope can clear entries from a broader one),\n+ *   - \"*\" sets it to 'true' (allow everything),\n+ *   - \"<path>\" sets it to 'true' if <path> equals `target_path`,\n+ *   - \"<path>\" + \"/\" + \"*\" sets it to 'true' if <path> is a leading\n+ *     directory of `target_path`,\n+ *   - anything else leaves `*matches` unchanged.\n+ *\n+ * `allow_path` is called with `allowed` and `allow_path_cbdata`, and\n+ * should return 'true' if the entry is acceptable to the caller. It\n+ * lets each caller decide which paths it is willing to consider, and\n+ * whether to warn about the ones it rejects. Returning 'false' leaves\n+ * `*matches` unchanged.\n+ *\n+ * Callers are expected to invoke this once per allowlist entry,\n+ * typically from a protected-config callback, so that untrusted\n+ * repository config cannot influence the decision.\n+ */\n+void path_allowlist_apply(const char *allowed, const char *target_path,\n+\t\t\t  bool *matches,\n+\t\t\t  bool (*allow_path)(const char *path, void *cbdata),\n+\t\t\t  void *allow_path_cbdata);\n+\n+/*\n+ * Apply one value of a multi-valued config variable holding an\n+ * allowlist of paths, expanding it with git_config_pathname() before\n+ * checking it against `target_path`. Empty and \"*\" values are passed\n+ * through without expansion, as interpolating them is not\n+ * meaningful. See path_allowlist_apply().\n+ */\n+void path_allowlist_config_apply(const char *key, const char *value,\n+\t\t\t\t const char *target_path, bool *matches,\n+\t\t\t\t bool (*allow_path)(const char *path, void *cbdata),\n+\t\t\t\t void *allow_path_cbdata);\n+\n #endif /* SETUP_H */\n-- \n2.56.0.rc2.20.g34f06850c1\n\n"},{"id":"553469","messageId":"20260928133846.2094261-4-christian.couder@gmail.com","threadId":"65969","inReplyTo":"20260928133846.2094261-1-christian.couder@gmail.com","subject":"[PATCH v4 3/5] upload-pack: read uploadpack.lazyFetchTrusted","fromName":"Christian Couder","fromEmail":"christian.couder@gmail.com","sentAt":"2026-09-28T13:38:44Z","receivedAt":"2026-09-28T13:39:12Z","isPatch":true,"body":"Previous commits created and prepared the path_allowlist_apply()\nand path_allowlist_config_apply() functions, but used them only for the\n\"safe.directory\" configuration variable.\n\nLet's reuse these functions for a new \"uploadpack.lazyFetchTrusted\"\nconfiguration variable.\n\nIt allows us to:\n\n  - read an allowlist from that config variable,\n  - check if the current repo is in that list, and\n  - return the result from a new upload_pack_lazy_fetch_trusted()\n    function.\n\nAs path_allowlist_config_apply() lets each caller decide which paths\nit is willing to accept using a callback, let's pass it a new\nallow_trusted_path() callback. Unlike the \"safe.directory\" callback, it\naccepts only absolute paths, and not \".\", as `upload-pack` always\nserves a repository given by an absolute path, so there is no \"current\nrepository\" for \".\" to refer to.\n\nNote that a served repository is identified by its git directory, and\nnot by its worktree. This is because `upload-pack` uses enter_repo()\ninstead of the usual repository discovery, so it never learns about a\nworktree and `r->worktree` is always NULL there. In practice this\nmeans that a non-bare repository served as \"/srv/repo\" has to be\nallowlisted as \"/srv/repo/.git\".\n\nThe new upload_pack_lazy_fetch_trusted() function will be used in a\nfollowing commit.\n\nNote that the new config variable should be read only from protected\nconfiguration files.\n\nSigned-off-by: Christian Couder <christian.couder@gmail.com>\n---\n upload-pack.c | 59 +++++++++++++++++++++++++++++++++++++++++++++++++++\n upload-pack.h |  3 +++\n 2 files changed, 62 insertions(+)\n\ndiff --git a/upload-pack.c b/upload-pack.c\nindex 22573ad365..a300870fa9 100644\n--- a/upload-pack.c\n+++ b/upload-pack.c\n@@ -34,6 +34,8 @@\n #include \"json-writer.h\"\n #include \"strmap.h\"\n #include \"promisor-remote.h\"\n+#include \"setup.h\"\n+#include \"abspath.h\"\n \n /* Remember to update object flag allocation in object.h */\n #define THEY_HAVE\t(1u << 11)\n@@ -1343,6 +1345,63 @@ static int upload_pack_config(const char *var, const char *value,\n \treturn parse_hide_refs_config(var, value, \"uploadpack\", &data->hidden_refs);\n }\n \n+/*\n+ * Only absolute paths make sense here. Unlike 'safe.directory', \".\"\n+ * is not accepted, as the served repository is always identified by\n+ * an absolute path.\n+ */\n+static bool allow_trusted_path(const char *path, void *cbdata_)\n+{\n+\tstruct path_allowlist_cb_data *cbdata = cbdata_;\n+\n+\tif (is_absolute_path(path))\n+\t\treturn true;\n+\n+\twarning(_(\"%s '%s' not absolute\"), cbdata->key, path);\n+\treturn false;\n+}\n+\n+struct lazy_fetch_trusted {\n+\tchar *repo_path;\n+\tbool trusted;\n+};\n+\n+static int upload_pack_protected_lazy_fetch_config(const char *var, const char *value,\n+\t\t\t\t\t\t   const struct config_context *ctx UNUSED,\n+\t\t\t\t\t\t   void *cb_data)\n+{\n+\tstruct lazy_fetch_trusted *data = cb_data;\n+\tstruct path_allowlist_cb_data cbdata = { .key = var };\n+\n+\tif (strcmp(\"uploadpack.lazyfetchtrusted\", var))\n+\t\treturn 0;\n+\n+\tpath_allowlist_config_apply(var, value, data->repo_path, &data->trusted,\n+\t\t\t\t    allow_trusted_path, &cbdata);\n+\n+\treturn 0;\n+}\n+\n+bool upload_pack_lazy_fetch_trusted(struct repository *r)\n+{\n+\tstruct lazy_fetch_trusted data = { 0 };\n+\n+\t/*\n+\t * A served repository is identified by its git directory, as\n+\t * `upload-pack` uses enter_repo() instead of the usual repository\n+\t * discovery, so its worktree, if any, is never known here.\n+\t */\n+\tdata.repo_path = real_pathdup(r->gitdir, 0);\n+\tif (!data.repo_path)\n+\t\treturn false;\n+\n+\tgit_protected_config(upload_pack_protected_lazy_fetch_config, &data);\n+\n+\tfree(data.repo_path);\n+\n+\treturn !!data.trusted;\n+}\n+\n static int upload_pack_protected_config(const char *var, const char *value,\n \t\t\t\t\tconst struct config_context *ctx UNUSED,\n \t\t\t\t\tvoid *cb_data)\ndiff --git a/upload-pack.h b/upload-pack.h\nindex d6ee25ea98..b2212992c3 100644\n--- a/upload-pack.h\n+++ b/upload-pack.h\n@@ -12,4 +12,7 @@ struct strbuf;\n int upload_pack_advertise(struct repository *r,\n \t\t\t  struct strbuf *value);\n \n+/* Is this repo trusted for lazy fetching? */\n+bool upload_pack_lazy_fetch_trusted(struct repository *r);\n+\n #endif /* UPLOAD_PACK_H */\n-- \n2.56.0.rc2.20.g34f06850c1\n\n"},{"id":"553470","messageId":"20260928133846.2094261-5-christian.couder@gmail.com","threadId":"65969","inReplyTo":"20260928133846.2094261-1-christian.couder@gmail.com","subject":"[PATCH v4 4/5] promisor-remote: prevent infinite recursion when lazy fetching","fromName":"Christian Couder","fromEmail":"christian.couder@gmail.com","sentAt":"2026-09-28T13:38:45Z","receivedAt":"2026-09-28T13:39:14Z","isPatch":true,"body":"If a repository R is configured to lazy fetch from a promisor remote P\nwhich is also configured to in turn lazy fetch from R, there is an\ninfinite recursion: R asks P for a missing object, P asks R for it,\nand so on. The simplest case of this is a repository configured as its\nown promisor remote.\n\nThis is not reachable when serving a repository by default, as\n`upload-pack` sets `GIT_NO_LAZY_FETCH` to 1, which makes the nested\n`upload-pack` refuse to lazily fetch. A following commit will let\nserver operators allow lazy fetching for repositories they trust\nthough, and as `GIT_NO_LAZY_FETCH` is then set to 0 and passed down to\nchild processes, nothing stops the recursion anymore.\n\nIt does not recurse forever in practice, but only because each level\nadds one more variable to the environment of the child process, so\nafter a while `exec()` fails with:\n\n    fatal: cannot exec 'git-upload-pack ...': Argument list too long\n    fatal: unable to fork\n\nTo avoid this pathological case altogether, let's use a new\n`GIT_INTERNAL_LAZY_FETCH_DEPTH` to count the recursion depth, and let's\ncheck that it doesn't exceed a MAX_LAZY_FETCH_DEPTH limit (set to 5 for\nnow).\n\nNote that some nesting is legitimate: when `git fetch` runs\n`index-pack`, it can lazily fetch REF_DELTA bases that are missing\nlocally, so the limit should not be 1.\n\nSigned-off-by: Christian Couder <christian.couder@gmail.com>\n---\n environment.h            |  8 ++++++++\n promisor-remote.c        | 26 ++++++++++++++++++++++----\n t/t0410-partial-clone.sh | 33 +++++++++++++++++++++++++++++++++\n 3 files changed, 63 insertions(+), 4 deletions(-)\n\ndiff --git a/environment.h b/environment.h\nindex e7ec5b0437..f2833be9fe 100644\n--- a/environment.h\n+++ b/environment.h\n@@ -52,6 +52,14 @@\n  */\n #define GIT_ADVICE_ENVIRONMENT \"GIT_ADVICE\"\n \n+/*\n+ * Environment variable used to detect that a lazy fetch is already in\n+ * progress in a parent process, to prevent infinite recursion when a\n+ * promisor remote resolves back to the repository being served.\n+ * This is an internal variable that should not be set by the user.\n+ */\n+#define LAZY_FETCH_DEPTH_ENVIRONMENT \"GIT_INTERNAL_LAZY_FETCH_DEPTH\"\n+\n /*\n  * Environment variable used in handshaking the wire protocol.\n  * Contains a colon ':' separated list of keys with optional values\ndiff --git a/promisor-remote.c b/promisor-remote.c\nindex 91245fe9a8..316d9950aa 100644\n--- a/promisor-remote.c\n+++ b/promisor-remote.c\n@@ -24,7 +24,7 @@ struct promisor_remote_config {\n static int fetch_objects(struct repository *repo,\n \t\t\t const char *remote_name,\n \t\t\t const struct object_id *oids,\n-\t\t\t int oid_nr)\n+\t\t\t int oid_nr, int depth)\n {\n \tstruct child_process child = CHILD_PROCESS_INIT;\n \tint i;\n@@ -41,6 +41,7 @@ static int fetch_objects(struct repository *repo,\n \t\t     \"--filter=blob:none\", \"--stdin\", NULL);\n \tif (!repo_config_get_bool(repo, \"promisor.quiet\", &quiet) && quiet)\n \t\tstrvec_push(&child.args, \"--quiet\");\n+\tstrvec_pushf(&child.env, \"%s=%d\", LAZY_FETCH_DEPTH_ENVIRONMENT, depth + 1);\n \tif (start_command(&child))\n \t\tdie(_(\"promisor-remote: unable to fork off fetch subprocess\"));\n \tchild_in = xfdopen(child.in, \"w\");\n@@ -282,6 +283,7 @@ static int try_promisor_remotes(struct repository *repo,\n \t\t\t\tstruct object_id **remaining_oids,\n \t\t\t\tint *remaining_nr,\n \t\t\t\tint *to_free,\n+\t\t\t\tint depth,\n \t\t\t\tbool accepted_only)\n {\n \tstruct promisor_remote *r = repo->promisor_remote_config->promisors;\n@@ -289,7 +291,8 @@ static int try_promisor_remotes(struct repository *repo,\n \tfor (; r; r = r->next) {\n \t\tif (accepted_only != r->accepted)\n \t\t\tcontinue;\n-\t\tif (fetch_objects(repo, r->name, *remaining_oids, *remaining_nr) < 0) {\n+\t\tif (fetch_objects(repo, r->name,\n+\t\t\t\t  *remaining_oids, *remaining_nr, depth) < 0) {\n \t\t\tif (*remaining_nr == 1)\n \t\t\t\tcontinue;\n \t\t\t*remaining_nr = remove_fetched_oids(repo, remaining_oids,\n@@ -304,6 +307,8 @@ static int try_promisor_remotes(struct repository *repo,\n \treturn 0;\n }\n \n+#define MAX_LAZY_FETCH_DEPTH 5\n+\n /*\n  * Lazily fetch the objects given in '*remaining_oids' from the\n  * promisor remotes, trying the accepted ones first. See\n@@ -318,6 +323,8 @@ static int lazy_fetch_objects(struct repository *repo,\n \t\t\t      int *remaining_nr,\n \t\t\t      int *to_free)\n {\n+\tint depth = (int)git_env_ulong(LAZY_FETCH_DEPTH_ENVIRONMENT, 0);\n+\n \tif (git_env_bool(NO_LAZY_FETCH_ENVIRONMENT, 0)) {\n \t\tstatic int warning_shown;\n \t\tif (!warning_shown) {\n@@ -327,13 +334,24 @@ static int lazy_fetch_objects(struct repository *repo,\n \t\treturn 0;\n \t}\n \n+\tif (depth >= MAX_LAZY_FETCH_DEPTH) {\n+\t\tstatic int warning_shown;\n+\t\tif (!warning_shown) {\n+\t\t\twarning_shown = 1;\n+\t\t\twarning(_(\"too many nested lazy fetches (%d); \"\n+\t\t\t\t  \"is a promisor remote pointing at the repository itself?\"),\n+\t\t\t\tdepth);\n+\t\t}\n+\t\treturn 0;\n+\t}\n+\n \tpromisor_remote_init(repo);\n \n \t/* Try accepted remotes first (those the server told us to use) */\n \treturn try_promisor_remotes(repo, remaining_oids, remaining_nr,\n-\t\t\t\t    to_free, true) ||\n+\t\t\t\t    to_free, depth, true) ||\n \t\ttry_promisor_remotes(repo, remaining_oids, remaining_nr,\n-\t\t\t\t     to_free, false);\n+\t\t\t\t     to_free, depth, false);\n }\n \n void promisor_remote_get_direct(struct repository *repo,\ndiff --git a/t/t0410-partial-clone.sh b/t/t0410-partial-clone.sh\nindex 788e9a1631..a54685e3c7 100755\n--- a/t/t0410-partial-clone.sh\n+++ b/t/t0410-partial-clone.sh\n@@ -709,6 +709,39 @@ test_expect_success 'lazy-fetch when accessing object not in the_repository' '\n \ttest_grep ! \"[?]$FILE_HASH\" out\n '\n \n+test_expect_success 'lazy-fetch does not recurse infinitely between two promisor remotes' '\n+\trm -rf full partial1.git partial2.git &&\n+\n+\t# Create a repo with a blob\n+\ttest_create_repo full &&\n+\ttest_config -C full uploadpack.allowfilter 1 &&\n+\ttest_config -C full uploadpack.allowanysha1inwant 1 &&\n+\ttest_commit -C full create-a-file file.txt &&\n+\tFILE_HASH=$(git -C full rev-parse HEAD:file.txt) &&\n+\n+\t# Create partial clone repos without blobs\n+\tgit clone --filter=blob:none --bare \"file://$(pwd)/full\" partial1.git &&\n+\tgit clone --filter=blob:none --bare \"file://$(pwd)/full\" partial2.git &&\n+\ttest_config -C partial1.git uploadpack.allowfilter 1 &&\n+\ttest_config -C partial1.git uploadpack.allowanysha1inwant 1 &&\n+\ttest_config -C partial2.git uploadpack.allowfilter 1 &&\n+\ttest_config -C partial2.git uploadpack.allowanysha1inwant 1 &&\n+\n+\t# Configure the partial repos as remotes of each other\n+\tgit -C partial2.git remote set-url origin \"file://$(pwd)/partial1.git\" &&\n+\tgit -C partial1.git remote set-url origin \"file://$(pwd)/partial2.git\" &&\n+\n+\t# Make sure lazy fetching fails\n+\ttest_must_fail env GIT_TRACE=\"$(pwd)/trace\" GIT_NO_LAZY_FETCH=0 \\\n+\t\tgit -C partial1.git cat-file -e \"$FILE_HASH\" 2>err &&\n+\ttest_grep \"too many nested lazy fetches\" err &&\n+\n+\t# Make sure the recursion was bounded, i.e. that only\n+\t# MAX_LAZY_FETCH_DEPTH \"git fetch\" subprocesses were spawned\n+\tgrep \"run_command: GIT_INTERNAL_LAZY_FETCH_DEPTH\" trace >fetches &&\n+\ttest_line_count = 5 fetches\n+'\n+\n test_expect_success 'push should not fetch new commit objects' '\n \trm -rf server client &&\n \ttest_create_repo server &&\n-- \n2.56.0.rc2.20.g34f06850c1\n\n"},{"id":"553471","messageId":"20260928133846.2094261-6-christian.couder@gmail.com","threadId":"65969","inReplyTo":"20260928133846.2094261-1-christian.couder@gmail.com","subject":"[PATCH v4 5/5] builtin/upload-pack: don't disable lazy fetching on trusted repo","fromName":"Christian Couder","fromEmail":"christian.couder@gmail.com","sentAt":"2026-09-28T13:38:46Z","receivedAt":"2026-09-28T13:39:16Z","isPatch":true,"body":"A previous commit added a new \"uploadpack.lazyFetchTrusted\" protected\nconfig variable that can contain an allowlist of repos, as well as\nfunctions to check if the current repo is in that list. But when the\ncurrent repo is in that list, we currently do nothing.\n\nSince 7b70e9efb1 (upload-pack: disable lazy-fetching by default,\n2024-04-16), `upload-pack` sets `GIT_NO_LAZY_FETCH` to 1 itself,\nunconditionally, because by default it shouldn't trust the repositories\nit serves. Lazily fetching runs `git fetch`, which may execute\narbitrary commands specified in the configuration and hooks of the\nserved repo.\n\nThe new \"uploadpack.lazyFetchTrusted\" protected config variable is not\nabout overriding an environment variable. It's rather about teaching\nthe code that automatically sets `GIT_NO_LAZY_FETCH` (because it had no\nway to know if the served repo could be trusted) to look at the new\nconfig variable to find out if a server operator actually vouched for\nthat repo.\n\nLet's implement that, so we now have the following cases:\n\n  - if `GIT_NO_LAZY_FETCH` is already set, we honor it and leave it\n    alone, as it comes from the server operator,\n\n  - otherwise, if the served repo is in the\n    \"uploadpack.lazyFetchTrusted\" allowlist, we don't disable lazy\n    fetching,\n\n  - otherwise, we disable lazy fetching, as we used to.\n\nThis allows `upload-pack` and its `pack-objects` child process to\nlazily fetch the objects they need to serve a client, for example when\nthe filter used by the client and the one used by the server don't\nmatch.\n\nNote that what a server operator vouches for by listing a repo there\nis that the promisor remotes this repo is configured to lazily fetch\nfrom, as well as its configuration and hooks, are trustworthy. Whether\na client trusts the repo it fetches from is a separate matter, and up\nto the client.\n\nAs `GIT_NO_LAZY_FETCH` is passed down to child processes through the\nenvironment, this works for `pack-objects`, which performs the lazy\nfetch when serving a client, without any further plumbing.\n\nNow that \"uploadpack.lazyFetchTrusted\" is actually doing something,\nlet's document it and reference it from GIT_NO_LAZY_FETCH's docs.\n\nSigned-off-by: Christian Couder <christian.couder@gmail.com>\n---\n Documentation/config/uploadpack.adoc  |  49 +++++++++\n Documentation/git-upload-pack.adoc    |   5 +\n Documentation/git.adoc                |   4 +-\n builtin/upload-pack.c                 |  19 +++-\n t/t5710-promisor-remote-capability.sh | 142 ++++++++++++++++++++++++++\n 5 files changed, 217 insertions(+), 2 deletions(-)\n\ndiff --git a/Documentation/config/uploadpack.adoc b/Documentation/config/uploadpack.adoc\nindex 0e1dda944a..e143de93aa 100644\n--- a/Documentation/config/uploadpack.adoc\n+++ b/Documentation/config/uploadpack.adoc\n@@ -86,3 +86,52 @@ uploadpack.allowRefInWant::\n \tis intended for the benefit of load-balanced servers which may\n \tnot have the same view of what OIDs their refs point to due to\n \treplication delay.\n+\n+uploadpack.lazyFetchTrusted::\n+\tA multi-valued configuration variable, each of which contains the\n+\tabsolute local path of a repository that `upload-pack` is allowed to\n+\tlazily fetch missing objects for.\n++\n+A repository is identified by its git directory, i.e. the `.git`\n+directory of a repository that has a worktree, or the repository itself\n+if it is bare. So a non-bare repository served as `/srv/repo` has to be\n+allowlisted as `/srv/repo/.git`. Giving a path with `/*` appended to it\n+will trust all repositories under the named directory. To trust all\n+served repositories, set `uploadpack.lazyFetchTrusted` to the string\n+`*`.\n++\n+The value of this setting is interpolated, i.e. `~/<path>` expands to a\n+path relative to the home directory and `%(prefix)/<path>` expands to a\n+path relative to Git's (runtime) prefix.\n++\n+By default, `upload-pack` refuses to lazily fetch (see the description\n+of the `GIT_NO_LAZY_FETCH` environment variable in\n+linkgit:git-upload-pack[1]), because doing so would run `git fetch`,\n+which may execute arbitrary commands specified in the configuration\n+and hooks of the served repository. Listing a repository here tells\n+`upload-pack` that it is trusted, so lazy fetching from the promisor\n+remotes configured in it is allowed. This is equivalent to setting\n+`GIT_NO_LAZY_FETCH` to `0` for the matching repositories. An\n+explicitly set `GIT_NO_LAZY_FETCH` takes precedence over this setting.\n++\n+Note that this allows lazy fetching from any promisor remote\n+configured in the served repository, not only from the promisor\n+remotes that the client accepted using the \"promisor-remote\" protocol\n+v2 capability (see linkgit:gitprotocol-v2[5]). The served repository\n+is trusted as a whole, including its configuration, so the promisor\n+remotes it configures are trusted too. It is the server operator's\n+responsibility to make sure that the promisor remotes of a trusted\n+repository are also trustworthy. In particular, a trusted repository\n+should not be configured as its own promisor remote, as `upload-pack`\n+would then try to lazily fetch missing objects from the repository\n+itself, which is pointless.\n++\n+As this is a multi-valued setting, you can add more than one\n+repository via `git config (--global|--system) --add`. To reset the\n+list of trusted repositories (e.g. to override any such repositories\n+specified in the system config), add an `uploadpack.lazyFetchTrusted`\n+entry with an empty value.\n++\n+Note that this configuration variable is only respected when it is\n+specified in protected configuration (see <<SCOPES>>). This prevents\n+untrusted repositories from tampering with this value.\ndiff --git a/Documentation/git-upload-pack.adoc b/Documentation/git-upload-pack.adoc\nindex 9167a321d0..90c2ba1194 100644\n--- a/Documentation/git-upload-pack.adoc\n+++ b/Documentation/git-upload-pack.adoc\n@@ -71,6 +71,11 @@ This is implemented by having `upload-pack` internally set the\n (because you are fetching from a partial clone, and you are sure\n you trust it), you can explicitly set `GIT_NO_LAZY_FETCH` to\n `0`.\n++\n+Instead of setting `GIT_NO_LAZY_FETCH` to `0` in the environment, a\n+server operator can allow lazy fetching on a per-repository basis by\n+listing trusted repositories in the `uploadpack.lazyFetchTrusted`\n+configuration variable. See linkgit:git-config[1].\n \n SECURITY\n --------\ndiff --git a/Documentation/git.adoc b/Documentation/git.adoc\nindex 6f0075f918..ff78ce6eec 100644\n--- a/Documentation/git.adoc\n+++ b/Documentation/git.adoc\n@@ -952,7 +952,9 @@ for full details.\n `GIT_NO_LAZY_FETCH`::\n \tSetting this Boolean environment variable to true tells Git\n \tnot to lazily fetch missing objects from the promisor remote\n-\ton demand.\n+\ton demand. On the server side, the `uploadpack.lazyFetchTrusted`\n+\tconfiguration variable can control this per-repository. See\n+\tlinkgit:git-upload-pack[1].\n \n `GIT_REFLOG_ACTION`::\n \tWhen a ref is updated, reflog entries are created to keep\ndiff --git a/builtin/upload-pack.c b/builtin/upload-pack.c\nindex 32831fb879..53e76deb23 100644\n--- a/builtin/upload-pack.c\n+++ b/builtin/upload-pack.c\n@@ -46,7 +46,6 @@ int cmd_upload_pack(int argc,\n \tpacket_trace_identity(\"upload-pack\");\n \tdisable_replace_refs();\n \tsave_commit_buffer = 0;\n-\txsetenv(NO_LAZY_FETCH_ENVIRONMENT, \"1\", 0);\n \n \targc = parse_options(argc, argv, prefix, options, upload_pack_usage, 0);\n \n@@ -62,6 +61,24 @@ int cmd_upload_pack(int argc,\n \tif (!enter_repo(the_repository, dir, enter_repo_flags))\n \t\tdie(\"'%s' does not appear to be a git repository\", dir);\n \n+\t/*\n+\t * Lazily fetching while serving a client would run `git fetch`,\n+\t * which may execute arbitrary commands from the configuration\n+\t * and hooks of the served repo, so we disable it by default as\n+\t * we trust nobody. There are two ways for a server operator to\n+\t * allow it though:\n+\t *\n+\t *   - if GIT_NO_LAZY_FETCH is already set, we leave it alone and\n+\t *     honor whatever the operator put there,\n+\t *\n+\t *   - otherwise, if the served repo is in the\n+\t *     \"uploadpack.lazyFetchTrusted\" protected allowlist, we\n+\t *     don't disable lazy fetching.\n+\t */\n+\tif (!getenv(NO_LAZY_FETCH_ENVIRONMENT) &&\n+\t    !upload_pack_lazy_fetch_trusted(the_repository))\n+\t\txsetenv(NO_LAZY_FETCH_ENVIRONMENT, \"1\", 1);\n+\n \tswitch (determine_protocol_version_server()) {\n \tcase protocol_v2:\n \t\tif (advertise_refs)\ndiff --git a/t/t5710-promisor-remote-capability.sh b/t/t5710-promisor-remote-capability.sh\nindex 549acff23f..62f4b56006 100755\n--- a/t/t5710-promisor-remote-capability.sh\n+++ b/t/t5710-promisor-remote-capability.sh\n@@ -173,6 +173,148 @@ test_expect_success \"clone with promisor.acceptfromserver set to 'None'\" '\n \tinitialize_server 1 \"$oid\"\n '\n \n+test_expect_success \"clone with uploadpack.lazyFetchTrusted\" '\n+\t# No promisors are advertised\n+\tgit -C server config promisor.advertise false &&\n+\ttest_when_finished \"rm -rf client\" &&\n+\n+\t# The served repo is trusted for lazy fetching\n+\ttest_config_global uploadpack.lazyFetchTrusted \"$(pwd)/server\" &&\n+\n+\t# Clone without GIT_NO_LAZY_FETCH=0\n+\tgit clone --no-local --filter=\"blob:limit=5k\" server client &&\n+\n+\t# Check that the largest object is not missing on the server\n+\t# This means the server lazy fetched it\n+\tcheck_missing_objects server 0 \"\" &&\n+\n+\t# Reinitialize server so that the largest object is missing again\n+\tinitialize_server 1 \"$oid\"\n+'\n+\n+test_expect_success \"clone without uploadpack.lazyFetchTrusted fails\" '\n+\t# No promisors are advertised\n+\tgit -C server config promisor.advertise false &&\n+\ttest_when_finished \"rm -rf client\" &&\n+\n+\t# Note: no uploadpack.lazyFetchTrusted config is set here, so\n+\t# the served repo is NOT trusted for lazy fetching.\n+\n+\t# Clone without GIT_NO_LAZY_FETCH=0 fails\n+\ttest_must_fail git clone --no-local --filter=\"blob:limit=5k\" server client 2>err &&\n+\ttest_grep \"lazy fetching disabled\" err &&\n+\n+\t# Check that the largest object is still missing on the server\n+\tcheck_missing_objects server 1 \"$oid\"\n+'\n+\n+test_expect_success \"uploadpack.lazyFetchTrusted is ignored in repo config\" '\n+\t# No promisors are advertised\n+\tgit -C server config promisor.advertise false &&\n+\ttest_when_finished \"rm -rf client\" &&\n+\n+\t# The served repo is trusted for lazy fetching, but this is\n+\t# done in the repo config, not in protected config, so this is\n+\t# ignored.\n+\ttest_config -C server uploadpack.lazyFetchTrusted \"$(pwd)/server\" &&\n+\n+\t# Clone without GIT_NO_LAZY_FETCH=0 fails\n+\ttest_must_fail git clone --no-local --filter=\"blob:limit=5k\" server client 2>err &&\n+\ttest_grep \"lazy fetching disabled\" err &&\n+\n+\t# Check that the largest object is still missing on the server\n+\tcheck_missing_objects server 1 \"$oid\"\n+'\n+\n+test_expect_success \"explicit GIT_NO_LAZY_FETCH overrides uploadpack.lazyFetchTrusted\" '\n+\t# No promisors are advertised\n+\tgit -C server config promisor.advertise false &&\n+\ttest_when_finished \"rm -rf client\" &&\n+\n+\t# The served repo is trusted for lazy fetching\n+\ttest_config_global uploadpack.lazyFetchTrusted \"$(pwd)/server\" &&\n+\n+\t# But GIT_NO_LAZY_FETCH=1 disables lazy fetching, so clone fails\n+\ttest_must_fail env GIT_NO_LAZY_FETCH=1 git clone --no-local \\\n+\t\t--filter=\"blob:limit=5k\" server client 2>err &&\n+\ttest_grep \"lazy fetching disabled\" err &&\n+\n+\t# Check that the largest object is still missing on the server\n+\tcheck_missing_objects server 1 \"$oid\"\n+'\n+\n+test_expect_success \"trusted repo as its own promisor remote does not recurse\" '\n+\t# No promisors are advertised\n+\tgit -C server config promisor.advertise false &&\n+\ttest_when_finished \"rm -rf client\" &&\n+\n+\t# Add itself as its own remote\n+\tgit -C server remote add self \"$TRASH_DIRECTORY_URL/server\" &&\n+\tgit -C server config remote.self.promisor true &&\n+\ttest_when_finished \"git -C server remote remove self\" &&\n+\n+\t# Make \"self\" the only promisor remote of the server, so that it\n+\t# cannot get the missing object from \"lop\". Note that\n+\t# \"remote.lop.partialCloneFilter\" also makes \"lop\" a promisor\n+\t# remote, so it has to be unset too.\n+\tgit -C server config --unset remote.lop.promisor &&\n+\ttest_when_finished \"git -C server config remote.lop.promisor true\" &&\n+\tlop_filter=\"$(git -C server config remote.lop.partialCloneFilter)\" &&\n+\tgit -C server config --unset remote.lop.partialCloneFilter &&\n+\ttest_when_finished \"git -C server config remote.lop.partialCloneFilter \\\"$lop_filter\\\"\" &&\n+\n+\t# Allow lazy fetching from itself\n+\ttest_config_global uploadpack.lazyFetchTrusted \"$(pwd)/server\" &&\n+\n+\t# Check that lazy fetching fails\n+\ttest_must_fail git clone --no-local --filter=\"blob:limit=5k\" server client 2>err &&\n+\ttest_grep \"too many nested lazy fetches\" err &&\n+\n+\t# Check that the largest object is still missing on the server\n+\tcheck_missing_objects server 1 \"$oid\"\n+'\n+\n+test_expect_success \"uploadpack.lazyFetchTrusted needs the git dir of a non-bare repo\" '\n+\ttest_when_finished \"rm -rf nonbare client client2\" &&\n+\n+\t# Create a non-bare repo, without any worktree content, so that\n+\t# its largest object can be filtered out below\n+\tgit init nonbare &&\n+\tgit -C nonbare remote add origin \"$TRASH_DIRECTORY_URL/template\" &&\n+\tgit -C nonbare fetch origin &&\n+\tgit -C nonbare update-ref HEAD FETCH_HEAD &&\n+\n+\tgit -C nonbare remote add lop \"$TRASH_DIRECTORY_URL/lop\" &&\n+\tgit -C nonbare config remote.lop.promisor true &&\n+\tgit -C nonbare config uploadpack.allowFilter true &&\n+\tgit -C nonbare config uploadpack.allowAnySHA1InWant true &&\n+\tgit -C nonbare config promisor.advertise false &&\n+\n+\t# Repack everything, then repack without the largest object and\n+\t# create a promisor pack, like initialize_server() does\n+\tgit -C nonbare -c repack.writebitmaps=false repack -a -d &&\n+\trm -f nonbare/.git/objects/pack/*.promisor &&\n+\tgit -C nonbare -c repack.writebitmaps=false repack -a -d \\\n+\t\t--filter=blob:limit=5k --filter-to=\"$(pwd)/nonbare-pack\" &&\n+\tpromisor_file=$(ls nonbare/.git/objects/pack/*.pack | sed \"s/\\.pack/.promisor/\") &&\n+\t>\"$promisor_file\" &&\n+\tcheck_missing_objects nonbare 1 \"$oid\" &&\n+\n+\t# The worktree path does not identify the repo, so it is not\n+\t# trusted and the clone fails\n+\ttest_config_global uploadpack.lazyFetchTrusted \"$(pwd)/nonbare\" &&\n+\ttest_must_fail git clone --no-local --filter=\"blob:limit=1k\" \\\n+\t\tnonbare client 2>err &&\n+\ttest_grep \"lazy fetching disabled\" err &&\n+\tcheck_missing_objects nonbare 1 \"$oid\" &&\n+\n+\t# The git dir identifies the repo, so it is trusted and the\n+\t# clone succeeds\n+\ttest_config_global uploadpack.lazyFetchTrusted \"$(pwd)/nonbare/.git\" &&\n+\tgit clone --no-local --filter=\"blob:limit=1k\" nonbare client2 &&\n+\tcheck_missing_objects nonbare 0 \"\"\n+'\n+\n test_expect_success \"init + fetch with promisor.advertise set to 'true'\" '\n \tgit -C server config promisor.advertise true &&\n \ttest_when_finished \"rm -rf client\" &&\n-- \n2.56.0.rc2.20.g34f06850c1\n\n"},{"id":"553472","messageId":"CAP8UFD2QgC+dBs40=En9sgg=dLKfkmV4ejdChYfgGggY+mXMuw@mail.gmail.com","threadId":"65969","inReplyTo":"xmqq7bkvy74h.fsf@gitster.g","subject":"Re: [PATCH v3 1/5] promisor-remote: factor out lazy_fetch_objects()","fromName":"Christian Couder","fromEmail":"christian.couder@gmail.com","sentAt":"2026-09-28T13:39:58Z","receivedAt":"2026-09-28T13:40:13Z","isPatch":true,"body":"On Tue, Sep 8, 2026 at 7:40 PM Junio C Hamano <gitster@pobox.com> wrote:\n>\n> Christian Couder <christian.couder@gmail.com> writes:\n\n> > This is a pure refactoring with no intended behavior change. Two\n> > things shift in ways that are observably equivalent though:\n> >\n> >   - the `GIT_NO_LAZY_FETCH` check is now performed once up front,\n> >     instead of once per promisor remote, and\n> >\n> >   - promisor_remote_init() is no longer called when lazy fetching\n> >     is disabled, which is fine as nothing downstream of it, like\n> >     is_promisor_object(), needs it in that case.\n>\n> Yeah, I too noticed these while reading the patch.  The latter\n> change may be a very good thing, in that the calling sequence around\n> promisor_remote_init() seems to be anybody who needs to access the\n> promisor remote information is expected to _init() the system\n> beforehand.  If it were \"call _init() once at the very beginning and\n> then do random things on promisor remotes\", then moving its callsite\n> may have to be done more carefully, but with the \"user makes sure it\n> is initialized beforehand\" convention, the postimage of this patch\n> follows the pattern exactly.\n\nYeah, I have tried to explain this in the commit message of the v4 I just sent.\n\n> > While at it, let's also convert try_promisor_remotes() to return\n> > 'bool' instead of 'int', as it just returns whether all the objects\n> > could be fetched, and document its return value.\n>\n> Meh.\n\ntry_promisor_remotes() is not converted to return 'bool' in v4 then.\n\n> > +/*\n> > + * Return 'true' if all the objects could be fetched from the\n> > + * (non-)accepted remotes, 'false' otherwise.\n> > + */\n>\n> The comment was not quite understandable, at least to me,\n> especially around \"from the (non-)accepted\" part of the sentence.\n>\n> Also \"could be fetched\" made it sound as if this were dry-run but\n> isn't this function actually doing the fetching and reporting if\n> everything got fetched or there are still objects remaining to be\n> fetched?\n>\n>     /*\n>      * fetch remaining objects (given in remaining_oids) from\n>      * the known promisor remotes.  If accepted_only is true,\n>      * ignore promisor remotes with .accepted member unset.\n>      * return true when all requested objects have been fetched,\n>      * false otherwise.\n>      */\n>\n> The above only mentions half of how the remaining_oids parameter is\n> used (i.e., only on the input side), but if we are adding a comment,\n> we should document how remaining_oids and to_free are used as well.\n>\n> The semantics of to_free in the entire callchain is especially\n> tricky to describe correctly, I am afraid.\n\nThe comment before try_promisor_remotes() is now the following in v4:\n\n+/*\n+ * Fetch the remaining objects (given in '*remaining_oids', which\n+ * contains '*remaining_nr' object ids) from the known promisor\n+ * remotes. If 'accepted_only' is true, ignore promisor remotes with\n+ * their 'accepted' member unset.\n+ *\n+ * When a fetch from a remote fails, the objects that are still\n+ * missing are computed, and '*remaining_oids' and '*remaining_nr' are\n+ * updated accordingly before trying the next remote. In that case\n+ * '*remaining_oids' points to a new array that this function\n+ * allocated, and '*to_free' is set to 1 to tell the caller that it\n+ * owns that array and should free it. '*to_free' should be 0 on the\n+ * first call.\n+ *\n+ * Return 1 when all the requested objects have been fetched, 0\n+ * otherwise.\n+ */\n\nI hope it's better.\n\nThanks.\n"},{"id":"553473","messageId":"CAP8UFD0da+K3FLVAgmds8CUr3aFrLjsmG7qO3mYX4foNLMYrMg@mail.gmail.com","threadId":"65969","inReplyTo":"xmqq33vjy6qz.fsf@gitster.g","subject":"Re: [PATCH v3 2/5] setup: extract path_allowlist_apply()","fromName":"Christian Couder","fromEmail":"christian.couder@gmail.com","sentAt":"2026-09-28T13:40:40Z","receivedAt":"2026-09-28T13:40:54Z","isPatch":true,"body":"On Tue, Sep 8, 2026 at 7:48 PM Junio C Hamano <gitster@pobox.com> wrote:\n>\n> Christian Couder <christian.couder@gmail.com> writes:\n>\n> > For clarity, let's change the `int is_safe` to `bool safe` in\n> > `struct safe_directory_data`.\n>\n> I am not sure if this clarifies, though.\n\nThe change is now explained in the following way:\n\n    +    As the new path_allowlist_apply() function reports its result through\n    +    a `bool *matches` argument, let's also change the `int is_safe` member\n    +    of `struct safe_directory_data` to a `bool`, so that its address can\n    +    be passed as that argument.\n\nand only the type of the variable is changed in v4. The \"is_safe\"\noriginal name is kept.\n\n> > diff --git a/setup.c b/setup.c\n> > index dfe05d9a03..366a7dc5c0 100644\n> > --- a/setup.c\n> > +++ b/setup.c\n> > @@ -1338,67 +1338,105 @@ static int canonicalize_ceiling_entry(struct string_list_item *item,\n> >       }\n> >  }\n> >\n> > +void path_allowlist_apply(const char *allowed, const char *target_path,\n> > +                       bool *matches,\n> > +                       bool (*allow_path)(const char *path, void *cbdata),\n> > +                       void *allow_path_cbdata)\n> > +{\n> > +     char *normalized = NULL;\n> > +\n> > +     if (!allowed || !*allowed) {\n> > +             *matches = false;\n> > +             return;\n> > +     }\n> > +\n> > +     if (!strcmp(allowed, \"*\")) {\n> > +             *matches = true;\n> > +             return;\n> > +     }\n> > +\n> > +     if (!allow_path(allowed, allow_path_cbdata))\n> > +             return;\n> > +\n> > +     /*\n> > +      * A .gitconfig in $HOME may be shared across different\n> > +      * machines and the config variable entries may or may not\n> > +      * exist as paths on all of these machines.  In other words,\n> > +      * it is not a warning worthy event when there is no such path\n> > +      * on this machine---the entry may be useful elsewhere.\n> > +      */\n>\n> This is inherited from the preimage and not something you would want\n> to fix in this patch, but I do not think ignoring missing path like\n> this is healthy.  You do not know if the path given is missing by\n> design (i.e., the set of paths is union of paths that could exist)\n> or if it is missing due to an error (i.e., a filesystem that should\n> have been mounted is not mounted).  In the latter case, ignoring it\n> may make the system behave in a way that the user did not intend to.\n\nIn dc0edbb01c (safe.directory: normalize the configured path,\n2024-07-30) you say:\n\n     - A configured safe.directory may be coming from .gitignore in the\n       home directory that may be shared across machines.  The path\n       meant to match with an entry may not necessarily exist on all of\n       such machines, so not being able to convert them to real path on\n       this machine is *not* a condition that is worthy of warning.\n       Hence, we ignore a path that cannot be converted to a real path.\n\nSo I don't know what is the right thing to do. Maybe it's safer to\nwarn by default but have a config option to not warn? Or maybe we\nshould remember the unresolvable entries, and mention them only when\nthe overall check fails?\n\nAnyway I can add a NEEDSWORK here for now in a separate patch in this\nseries or maybe in a followup series. In v4 nothing was changed\nregarding this.\n"},{"id":"553474","messageId":"CAP8UFD0iuUEBgUFmH0yK34THTXihgC1AACH3Qm3xJcO3ZunzkQ@mail.gmail.com","threadId":"65969","inReplyTo":"xmqqa4pqp0j2.fsf@gitster.g","subject":"Re: [PATCH v3 4/5] promisor-remote: prevent infinite recursion when lazy fetching","fromName":"Christian Couder","fromEmail":"christian.couder@gmail.com","sentAt":"2026-09-28T13:41:17Z","receivedAt":"2026-09-28T13:41:31Z","isPatch":true,"body":"On Wed, Sep 9, 2026 at 11:39 PM Junio C Hamano <gitster@pobox.com> wrote:\n>\n> Christian Couder <christian.couder@gmail.com> writes:\n>\n> > I agree that using a plain \"int\" seems like the most straightforward,\n> > but we don't have git_env_int() while we have git_env_ulong().\n> >\n> > So would you be fine with something like:\n> >\n> >     int depth = (int)git_env_ulong(LAZY_FETCH_DEPTH_ENVIRONMENT, 0);\n> >\n> > which is similar to the following in builtin/pack-objects.c:\n> >\n> >     name_hash_version = (int)git_env_ulong(\"GIT_TEST_NAME_HASH_VERSION\", 1);\n> >\n> > ? Or do you think it's time to introduce git_env_int() in a preparatory patch?\n>\n> There are 13 existing callers, among which one that you found\n> explicitly casts to int, but many others make assignments with\n> implicit cast (e.g., members of bloom_settings used in\n> commit-graph.c are of type uint32_t), and config.c reads\n> GIT_TEST_INDEX_THREADS into an \"int val\" with implicit cast.\n> progress.c:get_defalut_delay() does the same.\n>\n> So I would say that it is up to you to pile on existing technical\n> debt by mimicking config.c:repo_config_get_index_threads() and\n> progress.c:get_default_delay(), or audit all callers of\n> git_env_ulong() and migrate appropriate ones among them to use\n> git_env_int().  From my cursory survey, I suspect that not many\n> callers of git_get_ulong() would survive.\n\nLet me pile on existing technical debt and explicitly cast to int with\nthe following in v4 then:\n\n int depth = (int)git_env_ulong(LAZY_FETCH_DEPTH_ENVIRONMENT, 0);\n\nThanks.\n"},{"id":"553476","messageId":"CAP8UFD3gsp1wJnsf=de=5KT47Zm5KiJFNOQha5FKurordf2VCA@mail.gmail.com","threadId":"65969","inReplyTo":"xmqqmrtrwq0k.fsf@gitster.g","subject":"Re: [PATCH v3 5/5] builtin/upload-pack: set GIT_NO_LAZY_FETCH to 0 on trusted repo","fromName":"Christian Couder","fromEmail":"christian.couder@gmail.com","sentAt":"2026-09-28T13:42:45Z","receivedAt":"2026-09-28T13:42:58Z","isPatch":true,"body":"On Tue, Sep 8, 2026 at 8:34 PM Junio C Hamano <gitster@pobox.com> wrote:\n>\n> Christian Couder <christian.couder@gmail.com> writes:\n>\n> > A previous commit added a new \"uploadpack.lazyFetchTrusted\" protected\n> > config variable that can contain an allowlist of repos, as well as\n> > functions to check if the current repo is in that list. But when the\n> > current repo is in that list, we currently do nothing.\n> >\n> > Let's instead set `GIT_NO_LAZY_FETCH` to `0`, which allows\n> > `upload-pack` and its `pack-objects` child process to lazily fetch the\n> > objects they need to serve a client, for example when the filter used\n> > by the client and the one used by the server don't match.\n>\n> While I agree that it is a good idea to make it more lenient to work\n> with remotes that are explicitly marked as trusted, it somehow feels\n> a bit unnatural for a configuration variable, or a conclusion\n> derived from the setting of a configuration variable, overriding an\n> environment variable.  Who is setting this environment variable in\n> the first place?\n>\n> If NO_LAZY_FETCH is what server operators set and export, I strongly\n> suspect that not honoring it merely because the new variable could\n> be used to give them a finer-grained control would be very\n> surprising experience for them.\n>\n> If the answer is \"this never comes from the end-user or the server\n> operator.  We used to automatically set NO_LAZY_FETCH from the\n> process that spawns uploadpack because we trusted nobody\", then I'd\n> imagine that we would prefer to see that code that automatically\n> sets NO_LAZY_FETCH to inspect the configuration variable and to\n> decide not to do so.\n>\n> And I think that is what the code is doing (in other words, from a\n> cursory read, I think the new code is doing the right thing and it\n> is just the way how the above is explained that I found it iffy).\n\nI have tried to improve on that in v4 by rewording the title and commit message.\n\n> We used to say \"when serving a client, we do not lazy fetch what we\n> are missing from our promisor remotes by setting NO_LAZY_FETCH\" and\n> it was unconditional.\n>\n> I think what we want to happen is:\n>\n>  * If the server operator has NO_LAZY_FETCH set, we honor it and do\n>    not do anything.\n>\n>  * If the server operator does not have NO_LAZY_FETCH set, then we\n>    see if the configuration variable is there, and if there is, we\n>    let it take care of which promisor remote to allow by not futzing\n>    with NO_LAZY_FETCH ourselves.\n>\n>  * Otherwise, we set and export NO_LAZY_FETCH just we used to.\n>\n> and what you have in the patch is close enough to that (you left the\n> historical \"disable lazy fetch upfront\" so worst case you export the\n> thing twice which is not necessary).\n\nThis should be fixed in v4, see below.\n\n> > This allows server operators to properly control lazy fetching. It is\n> > their responsibility, not the client's, to decide if the served repo is\n> > trusted,\n>\n> If \"the served repo\" refers to where the client is fetching from,\n> trusting that repository or not is up to the client; if they do not\n> trust it, they should not be coming to you.\n>\n> I may be misunderstanding what you are trying to say here, but what\n> is up to the server operator to decide is if the promisor remotes,\n> which the repo that is serving the client uses, is trustworthy,\n> right?\n\nI think that by listing a repo in uploadpack.lazyFetchTrusted, the\noperator vouches for the following:\n\n- the repo's configuration and hooks, because git fetch will execute them,\n- the promisor remotes it is configured to lazily fetch from, because\nobjects will come from there.\n\nI have tried to clarify this in v4 with the following in the commit message:\n\n    +    Note that what a server operator vouches for by listing a repo there\n    +    is that the promisor remotes this repo is configured to lazily fetch\n    +    from, as well as its configuration and hooks, are trustworthy. Whether\n    +    a client trusts the repo it fetches from is a separate matter, and up\n    +    to the client.\n\n> > As `GIT_NO_LAZY_FETCH` is passed down to child processes through the\n> > environment, this works for `pack-objects`, which performs the lazy\n> > fetch when serving a client, without any further plumbing.\n> >\n> > Now that \"uploadpack.lazyFetchTrusted\" is actually doing something,\n> > let's document it and reference it from GIT_NO_LAZY_FETCH's docs.\n>\n> > diff --git a/builtin/upload-pack.c b/builtin/upload-pack.c\n> > index 32831fb879..8b531ca724 100644\n> > --- a/builtin/upload-pack.c\n> > +++ b/builtin/upload-pack.c\n> > @@ -42,10 +42,13 @@ int cmd_upload_pack(int argc,\n> >               OPT_END()\n> >       };\n> >       unsigned enter_repo_flags = ENTER_REPO_ANY_OWNER_OK;\n> > +     bool no_lazy_fetch_set;\n> >\n> >       packet_trace_identity(\"upload-pack\");\n> >       disable_replace_refs();\n> >       save_commit_buffer = 0;\n> > +\n> > +     no_lazy_fetch_set = !!getenv(NO_LAZY_FETCH_ENVIRONMENT);\n> >       xsetenv(NO_LAZY_FETCH_ENVIRONMENT, \"1\", 0);\n>\n> I am not seeing what is in the postcontext of this hunk and in the\n> precontext of the next hunk, but I wonder if we can just remove this\n> xsetenv (without \"no_lazy_fetch_set\" variable at all) here ...\n>\n> >       argc = parse_options(argc, argv, prefix, options, upload_pack_usage, 0);\n> > @@ -62,6 +65,14 @@ int cmd_upload_pack(int argc,\n> >       if (!enter_repo(the_repository, dir, enter_repo_flags))\n> >               die(\"'%s' does not appear to be a git repository\", dir);\n> >\n> > +     /*\n> > +      * Relax the GIT_NO_LAZY_FETCH=1 default if the served repo is in\n> > +      * the \"uploadpack.lazyFetchTrusted\" protected allowlist and\n> > +      * GIT_NO_LAZY_FETCH was not already set explicitly.\n> > +      */\n> > +     if (!no_lazy_fetch_set && upload_pack_lazy_fetch_trusted(the_repository))\n> > +             xsetenv(NO_LAZY_FETCH_ENVIRONMENT, \"0\", 1);\n>\n> ... and instead check the existing environment here, and do the\n> choice from three possibilities I listed above here.\n\nYes, that's what is implemented in v4. The three possibilities are\nalso listed in the commit message now.\n\n> Other than that, this is a great endgame of the series.\n\nThanks.\n"},{"id":"553619","messageId":"xmqqy0ckgea3.fsf@gitster.g","threadId":"65969","inReplyTo":"20260928133846.2094261-3-christian.couder@gmail.com","subject":"Re: [PATCH v4 2/5] setup: extract path_allowlist_apply()","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-09-29T17:26:12Z","receivedAt":"2026-09-29T17:26:16Z","isPatch":true,"body":"Christian Couder <christian.couder@gmail.com> writes:\n\n> +\t/*\n> +\t * A .gitconfig in $HOME may be shared across different\n> +\t * machines and the config variable entries may or may not\n> +\t * exist as paths on all of these machines.  In other words,\n> +\t * it is not a warning worthy event when there is no such path\n> +\t * on this machine---the entry may be useful elsewhere.\n> +\t */\n\nThis might be a minor point (as not many people may be using the\nsafe.directory feature that this was moved from), and this dates\nback two years, starting with dc0edbb01c (safe.directory: normalize\nthe configured path, 2024-07-30), but the above design decision cuts\nboth ways.  If you misspelled a pathname, you would never be told\nabout it.\n\nI wonder if we want to allow users to explicitly mark that it is OK if\na path does not exist, in much the same way that a pathname-typed\nconfiguration variable can be prefixed with :(optional) to tell the\nsystem \"if this path exists on the system, use it, but if not, instead\nof warning, pretend that you did not see this specified\".\n\nThat way, a user can first specify the value normally, and then when\nthey reuse the .gitconfig file somewhere else that does not have the\npath, they see a warning message.  You would help them by giving a\nhint, e.g.,\n\n    Specified path foo/bar does not exist.  If you spelled the\n    pathname correctly, and the path is allowed to be missing,\n    mark it as optional, i.e., \":(optional)foo/bar\".\n\nor something along those lines in the warning message and the world\nwould be a much better place.\n\nIn any case, it is outside the scope of this series, beyond leaving\na NEEDSWORK comment here, and/or a #leftoverbits comment in the\nreview.\n"},{"id":"553621","messageId":"xmqqse2sgda6.fsf@gitster.g","threadId":"65969","inReplyTo":"20260928133846.2094261-6-christian.couder@gmail.com","subject":"Re: [PATCH v4 5/5] builtin/upload-pack: don't disable lazy fetching on trusted repo","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-09-29T17:47:45Z","receivedAt":"2026-09-29T17:47:47Z","isPatch":true,"body":"Christian Couder <christian.couder@gmail.com> writes:\n\n>  Documentation/config/uploadpack.adoc  |  49 +++++++++\n>  Documentation/git-upload-pack.adoc    |   5 +\n>  Documentation/git.adoc                |   4 +-\n>  builtin/upload-pack.c                 |  19 +++-\n>  t/t5710-promisor-remote-capability.sh | 142 ++++++++++++++++++++++++++\n>  5 files changed, 217 insertions(+), 2 deletions(-)\n\nThe diffstat above is pleasing to see, with ample documentation to\nhelp users, tests with (hopefully) reasonable coverage, and a\nminimal amount of actual code changes to enable the feature, thanks\nto the preparatory work done in earlier steps.\n\n> +uploadpack.lazyFetchTrusted::\n> +\tA multi-valued configuration variable, each of which contains the\n> +\tabsolute local path of a repository that `upload-pack` is allowed to\n> +\tlazily fetch missing objects for.\n\n\"each of which\" lacks a plural noun to modify.  Perhaps\n\n\teach value of which specifies the absolute local path of a\n\trepository from which upload-pack is allowed to lazily fetch\n\tmissing objects.\n\n> ++\n> +A repository is identified by its git directory, i.e. the `.git`\n\n\"i.e.\" -> \"i.e.,\" (similarly \"e.g.\" -> \"e.g.,\" below).\n\n> diff --git a/builtin/upload-pack.c b/builtin/upload-pack.c\n> index 32831fb879..53e76deb23 100644\n> --- a/builtin/upload-pack.c\n> +++ b/builtin/upload-pack.c\n> @@ -46,7 +46,6 @@ int cmd_upload_pack(int argc,\n>  \tpacket_trace_identity(\"upload-pack\");\n>  \tdisable_replace_refs();\n>  \tsave_commit_buffer = 0;\n> -\txsetenv(NO_LAZY_FETCH_ENVIRONMENT, \"1\", 0);\n>  \n>  \targc = parse_options(argc, argv, prefix, options, upload_pack_usage, 0);\n>  \n> @@ -62,6 +61,24 @@ int cmd_upload_pack(int argc,\n>  \tif (!enter_repo(the_repository, dir, enter_repo_flags))\n>  \t\tdie(\"'%s' does not appear to be a git repository\", dir);\n>  \n> +\t/*\n> +\t * Lazily fetching while serving a client would run `git fetch`,\n> +\t * which may execute arbitrary commands from the configuration\n> +\t * and hooks of the served repo, so we disable it by default as\n> +\t * we trust nobody. There are two ways for a server operator to\n> +\t * allow it though:\n> +\t *\n> +\t *   - if GIT_NO_LAZY_FETCH is already set, we leave it alone and\n> +\t *     honor whatever the operator put there,\n> +\t *\n> +\t *   - otherwise, if the served repo is in the\n> +\t *     \"uploadpack.lazyFetchTrusted\" protected allowlist, we\n> +\t *     don't disable lazy fetching.\n> +\t */\n> +\tif (!getenv(NO_LAZY_FETCH_ENVIRONMENT) &&\n> +\t    !upload_pack_lazy_fetch_trusted(the_repository))\n> +\t\txsetenv(NO_LAZY_FETCH_ENVIRONMENT, \"1\", 1);\n> +\n\nOK, the logic is so trivially obvious and clear that it wouldn't\neven need the above comment.  Very nice.\n\n"},{"id":"553919","messageId":"20261002082322.2682869-1-christian.couder@gmail.com","threadId":"65969","inReplyTo":"20260928133846.2094261-1-christian.couder@gmail.com","subject":"[PATCH v5 0/5] Introduce 'uploadpack.lazyFetchTrusted'","fromName":"Christian Couder","fromEmail":"christian.couder@gmail.com","sentAt":"2026-10-02T08:23:17Z","receivedAt":"2026-10-02T08:23:43Z","isPatch":true,"body":"Recently the \"promisor-remote\" capability was added to protocol v2,\nallowing servers and clients to agree on the promisor remotes they can\nsafely use.\n\nThe more servers use promisor remotes, the more it is important to\nproperly control if they can lazy fetch when responding to a clone or\nfetch request from the client.\n\nFor example, in the context of large object promisors (see\n\"Documentation/technical/large-object-promisors.adoc\"), if a client\nclones with a filter set to 100kB while the server has moved all of\nthe blobs >= 10kB to a promisor remote, the server will not be able to\nprovide blobs between 10kB and 100kB to the client, which will make\nthe clone fail.\n\nEven if the `--filter=auto` option is available since ef2f1845ec\n(fetch-pack: wire up and enable auto filter logic, 2026-02-16) it's\nstill a good idea to provide more control over lazy fetching on the\nserver side to server operators, as lazy fetching on the server side\ncould be useful in corporate environments.\n\nSince 7b70e9efb1 (upload-pack: disable lazy-fetching by default,\n2024-04-16), lazy fetching has been controlled by the\n`GIT_NO_LAZY_FETCH` environment variable. This is a boolean that is\nset to 'true' by default when calling `git upload-pack` for security\nreasons.\n\nThe main security issue on the server side is making sure the served\nrepo itself is also trusted, as lazily fetching runs `git fetch`,\nwhich may execute arbitrary commands specified in the configuration\nand hooks of the served repo. The operator of the server should decide\nand mark that trust, not the served repo itself, nor the client.\n\nThis series introduces a new 'uploadpack.lazyFetchTrusted' protected\nconfiguration variable similar to 'safe.directory' (see\n\"Documentation/config/safe.adoc\") to mark trusted repos where lazy\nfetching is allowed. As it is protected, this config variable will\nonly take effect if it is set in global or system scope, so only\nserver operators can control it.\n\nPrevious related work\n=====================\n\nA previous series called \"Introduce a 'fromAccepted' option to\nGIT_NO_LAZY_FETCH\" [1] took a different approach as it wanted to make\nit easier to allow lazy fetching from accepted promisor remotes. But\nafter brian replied that he didn't think it was a good idea, and after\nthinking about this more, my opinion now is that some promisor remotes\nbeing accepted or not is not really relevant to the issue.\n\nIn my reply to brian, I said:\n\n\"\"\"\nDifferent features could be developed (in future work) to improve on\nthe current state:\n    - a way for lazy fetching to work without reading config files,\ntriggering hooks, or doing potentially sensitive things,\n    - an explicit way for operators to mark trusted repos (like\nperhaps a server-side config the operator sets per-repo),\n    - operator-defined allow/deny rules, or maybe\n    - some ways/scripts/commands to scan repos and check configuration\ninformation, remote settings and everything potentially sensitive to\ndecide if a repo looks safe enough to allow lazy fetching or not.\n\"\"\"\n\nSo I decided to go with \"an explicit way for operators to mark trusted\nrepos\" and this series is an implementation of that.\n\nNote that the feature developed in this series applies to protocol\nv0/v1 as well as v2 while the previous one was only related to v2.\n\n[1]: https://lore.kernel.org/git/CAP8UFD0_S9eg_w42tcNRnT9E2ntLr_eHLnzE4c2dSu67DzZoXg@mail.gmail.com/\n\nOverview of the patches\n=======================\n\n  - Patch 1/5 is the only patch saved from the \"Introduce a\n    'fromAccepted' option to GIT_NO_LAZY_FETCH\" series. It's not\n    necessary for the rest of this series and its main feature to\n    work, but I think it's a nice refactoring related to lazy\n    fetching, so it might as well be part of this series.\n\n  - Patch 2/5 extracts and modifies code used by the 'safe.directory'\n    config variable in new path_allowlist_config_apply() and\n    path_allowlist_apply() functions, so that these functions can be\n    reused to process 'uploadpack.lazyFetchTrusted' in the next patch.\n\n  - Patch 3/5 uses the new functions from the previous patch in a new\n    upload_pack_lazy_fetch_trusted() function to process\n    'uploadpack.lazyFetchTrusted', but the result from that processing\n    isn't actually used to have a practical effect.\n\n  - Patch 4/5 prevents infinite lazy fetch recursions that the\n    following patch would otherwise make possible. If a repo is\n    allowed to lazy fetch and one of its promisor remotes resolves\n    back to it, for example if it is its own promisor remote as Junio\n    noticed when reviewing v2, each nested `upload-pack` inherits\n    `GIT_NO_LAZY_FETCH=0` and fetches again.\n\n  - Patch 5/5 wires up the new upload_pack_lazy_fetch_trusted()\n    function to decide if lazy fetching can actually be enabled.\n\nChanges since v4\n================\n\nThanks to Junio for reviewing previous versions of this series.\n\nRebased on top of a018953688 (Git 2.56, 2026-09-27) to be on a stable\nbase.\n\nThere are no functional code changes compared to v4. Only code\ncomments, documentation, tests and commit messages have changed, and\nthose changes are relatively small.\n\n - In patch 2/5, a NEEDSWORK code comment has been added to say that\n   we may want to warn in case of a missing path unless that path is\n   marked with an \":(optional)\" prefix. Also the commit message\n   now mentions that NEEDSWORK code comment.\n\n - In the commit message of patch 3/5 and the documentation in patch\n   5/5, the way a repository is identified by its git directory is\n   worded more correctly and explained in more detail respectively. A\n   test for the case where a repo is initialized using\n   `--separate-git-dir=...` is added to the tests in patch 5/5.\n\n - In patch 5/5:\n\n   - In both \"uploadpack.adoc\" and \"git-upload-pack.adoc\", as well as\n     the commit message, it is now explained that while setting\n     `GIT_NO_LAZY_FETCH` to 0 propagates to promisor remotes (on the\n     same machine) which could also want to lazily fetch in turn,\n     setting \"uploadpack.lazyFetchTrusted\" doesn't. A test is also\n     added to check that.\n\n   - \"uploadpack.lazyFetchTrusted\" is now described as \"A multi-valued\n     configuration variable, each value of which specifies the\n     absolute local path of a repository ...\" which fixes the grammar\n     of \"each of which\".\n\n   - \"i.e.\" and \"e.g.\" are now followed by a comma.\n\n   - The test called \"uploadpack.lazyFetchTrusted needs the git dir of\n     a non-bare repo\" now cleans up the \"nonbare-pack-*\" files it\n     generates.\n\nCI tests\n========\n\nThey all pass, see:\n\nhttps://github.com/chriscool/git/actions/runs/36861893004\n\nRange-diff compared to v4\n=========================\n\n1:  e7332d0aa4 = 1:  d03dbba92c promisor-remote: factor out lazy_fetch_objects()\n2:  b4a63e3e4e ! 2:  a067c2396b setup: extract path_allowlist_apply()\n    @@ Commit message\n     \n         While at it let's make the helper's code simpler and more generic, by\n         passing it a `bool (*allow_path)(const char *path, void *cbdata)`\n    -    function that decides if a path is acceptable by the caller.\n    +    function that decides if a path is acceptable by the caller, and let's\n    +    add a NEEDSWORK comment about it silently ignoring missing, possibly\n    +    misspelled, paths.\n     \n         To further simplify how to reuse that new helper, and avoid duplicating\n         the config-value handling in a future commit, let's also introduce a\n    @@ setup.c: static int canonicalize_ceiling_entry(struct string_list_item *item,\n     +\t * exist as paths on all of these machines.  In other words,\n     +\t * it is not a warning worthy event when there is no such path\n     +\t * on this machine---the entry may be useful elsewhere.\n    ++\t *\n    ++\t * NEEDSWORK: this also silently ignores misspelled paths. We\n    ++\t * may want to warn about a missing path unless it is marked\n    ++\t * as allowed to be missing, e.g., with an \":(optional)\"\n    ++\t * prefix like pathname-typed configuration values, and hint\n    ++\t * about that prefix in the warning.\n     +\t */\n     +\tnormalized = real_pathdup(allowed, 0);\n     +\tif (!normalized)\n3:  1e2d2d4b4f ! 3:  1c489de88f upload-pack: read uploadpack.lazyFetchTrusted\n    @@ Commit message\n         instead of the usual repository discovery, so it never learns about a\n         worktree and `r->worktree` is always NULL there. In practice this\n         means that a non-bare repository served as \"/srv/repo\" has to be\n    -    allowlisted as \"/srv/repo/.git\".\n    +    allowlisted as \"/srv/repo/.git\" (or as the directory its \".git\" file\n    +    points to, if it has a \".git\" file instead of a \".git\" directory).\n     \n         The new upload_pack_lazy_fetch_trusted() function will be used in a\n         following commit.\n4:  3e88ec41a4 = 4:  69592b1b86 promisor-remote: prevent infinite recursion when lazy fetching\n5:  ad8814984b ! 5:  6d0e72c357 builtin/upload-pack: don't disable lazy fetching on trusted repo\n    @@ Commit message\n         a client trusts the repo it fetches from is a separate matter, and up\n         to the client.\n     \n    -    As `GIT_NO_LAZY_FETCH` is passed down to child processes through the\n    -    environment, this works for `pack-objects`, which performs the lazy\n    -    fetch when serving a client, without any further plumbing.\n    +    As `pack-objects`, which performs the lazy fetch when serving a\n    +    client, is a child process of `upload-pack`, not setting\n    +    `GIT_NO_LAZY_FETCH` in `upload-pack` is enough for it to be allowed to\n    +    lazily fetch, without any further plumbing.\n    +\n    +    On the other hand, as we leave `GIT_NO_LAZY_FETCH` unset for a trusted\n    +    repo instead of setting it to 0, the trust doesn't propagate: if a\n    +    trusted repo lazily fetches from a promisor remote that is itself\n    +    served by `upload-pack` on the same machine, that nested `upload-pack`\n    +    decides for its own repo. This is unlike when a server operator sets\n    +    `GIT_NO_LAZY_FETCH` to 0, as that is inherited by all child processes.\n     \n         Now that \"uploadpack.lazyFetchTrusted\" is actually doing something,\n    -    let's document it and reference it from GIT_NO_LAZY_FETCH's docs.\n    +    let's document it (including this difference with `GIT_NO_LAZY_FETCH`),\n    +    let's reference it from `GIT_NO_LAZY_FETCH`'s docs, and let's add tests\n    +    for it.\n     \n         Signed-off-by: Christian Couder <christian.couder@gmail.com>\n     \n    @@ Documentation/config/uploadpack.adoc: uploadpack.allowRefInWant::\n      \treplication delay.\n     +\n     +uploadpack.lazyFetchTrusted::\n    -+\tA multi-valued configuration variable, each of which contains the\n    -+\tabsolute local path of a repository that `upload-pack` is allowed to\n    -+\tlazily fetch missing objects for.\n    ++\tA multi-valued configuration variable, each value of which\n    ++\tspecifies the absolute local path of a repository that\n    ++\t`upload-pack` is allowed to lazily fetch missing objects for.\n     ++\n    -+A repository is identified by its git directory, i.e. the `.git`\n    -+directory of a repository that has a worktree, or the repository itself\n    -+if it is bare. So a non-bare repository served as `/srv/repo` has to be\n    -+allowlisted as `/srv/repo/.git`. Giving a path with `/*` appended to it\n    -+will trust all repositories under the named directory. To trust all\n    -+served repositories, set `uploadpack.lazyFetchTrusted` to the string\n    -+`*`.\n    ++A repository is identified by its git directory, after following any\n    ++`.git` file and resolving symbolic links. That is the repository\n    ++itself if it is bare, the `.git` directory of a repository that has a\n    ++worktree, or the directory that a `.git` file points to, for example\n    ++when the repository was created with `--separate-git-dir` or for a\n    ++linked worktree (see linkgit:git-worktree[1]). So a non-bare\n    ++repository served as `/srv/repo` usually has to be allowlisted as\n    ++`/srv/repo/.git`. Giving a path with `/*` appended to it will trust\n    ++all repositories under the named directory. To trust all served\n    ++repositories, set `uploadpack.lazyFetchTrusted` to the string `*`.\n     ++\n    -+The value of this setting is interpolated, i.e. `~/<path>` expands to a\n    -+path relative to the home directory and `%(prefix)/<path>` expands to a\n    -+path relative to Git's (runtime) prefix.\n    ++The value of this setting is interpolated, i.e., `~/<path>` expands to\n    ++a path relative to the home directory and `%(prefix)/<path>` expands\n    ++to a path relative to Git's (runtime) prefix.\n     ++\n     +By default, `upload-pack` refuses to lazily fetch (see the description\n     +of the `GIT_NO_LAZY_FETCH` environment variable in\n    @@ Documentation/config/uploadpack.adoc: uploadpack.allowRefInWant::\n     +which may execute arbitrary commands specified in the configuration\n     +and hooks of the served repository. Listing a repository here tells\n     +`upload-pack` that it is trusted, so lazy fetching from the promisor\n    -+remotes configured in it is allowed. This is equivalent to setting\n    -+`GIT_NO_LAZY_FETCH` to `0` for the matching repositories. An\n    ++remotes configured in it is allowed. This is similar to setting\n    ++`GIT_NO_LAZY_FETCH` to `0`, but only for the matching repositories:\n    ++unlike that environment variable, the trust is not inherited by child\n    ++processes. So if a trusted repository lazily fetches from a promisor\n    ++remote that is itself served by `upload-pack` on the same machine,\n    ++for example through a local path or a `file://` URL, lazy fetching is\n    ++allowed there only if that promisor remote is also listed here. An\n     +explicitly set `GIT_NO_LAZY_FETCH` takes precedence over this setting.\n     ++\n     +Note that this allows lazy fetching from any promisor remote\n    @@ Documentation/config/uploadpack.adoc: uploadpack.allowRefInWant::\n     ++\n     +As this is a multi-valued setting, you can add more than one\n     +repository via `git config (--global|--system) --add`. To reset the\n    -+list of trusted repositories (e.g. to override any such repositories\n    ++list of trusted repositories (e.g., to override any such repositories\n     +specified in the system config), add an `uploadpack.lazyFetchTrusted`\n     +entry with an empty value.\n     ++\n    @@ Documentation/config/uploadpack.adoc: uploadpack.allowRefInWant::\n     \n      ## Documentation/git-upload-pack.adoc ##\n     @@ Documentation/git-upload-pack.adoc: This is implemented by having `upload-pack` internally set the\n    + `GIT_NO_LAZY_FETCH` variable to `1`. If you want to override it\n      (because you are fetching from a partial clone, and you are sure\n      you trust it), you can explicitly set `GIT_NO_LAZY_FETCH` to\n    - `0`.\n    +-`0`.\n    ++`0`. As it is an environment variable, it is also inherited by child\n    ++processes, including any `upload-pack` run to lazily fetch from a\n    ++promisor remote on the same machine.\n     ++\n     +Instead of setting `GIT_NO_LAZY_FETCH` to `0` in the environment, a\n     +server operator can allow lazy fetching on a per-repository basis by\n    @@ t/t5710-promisor-remote-capability.sh: test_expect_success \"clone with promisor.\n     +'\n     +\n     +test_expect_success \"uploadpack.lazyFetchTrusted needs the git dir of a non-bare repo\" '\n    -+\ttest_when_finished \"rm -rf nonbare client client2\" &&\n    ++\ttest_when_finished \"rm -rf nonbare nonbare-pack-* client client2\" &&\n     +\n     +\t# Create a non-bare repo, without any worktree content, so that\n     +\t# its largest object can be filtered out below\n    @@ t/t5710-promisor-remote-capability.sh: test_expect_success \"clone with promisor.\n     +\tgit clone --no-local --filter=\"blob:limit=1k\" nonbare client2 &&\n     +\tcheck_missing_objects nonbare 0 \"\"\n     +'\n    ++\n    ++test_expect_success \"uploadpack.lazyFetchTrusted needs the git dir a .git file points to\" '\n    ++\ttest_when_finished \"rm -rf sepwt sepgit sep-pack-* client client2\" &&\n    ++\n    ++\t# Create a non-bare repo with a \".git\" file pointing to a\n    ++\t# separate git dir, without any worktree content, so that its\n    ++\t# largest object can be filtered out below\n    ++\tgit init --separate-git-dir=\"$(pwd)/sepgit\" sepwt &&\n    ++\ttest_path_is_file sepwt/.git &&\n    ++\tgit -C sepwt remote add origin \"$TRASH_DIRECTORY_URL/template\" &&\n    ++\tgit -C sepwt fetch origin &&\n    ++\tgit -C sepwt update-ref HEAD FETCH_HEAD &&\n    ++\n    ++\tgit -C sepwt remote add lop \"$TRASH_DIRECTORY_URL/lop\" &&\n    ++\tgit -C sepwt config remote.lop.promisor true &&\n    ++\tgit -C sepwt config uploadpack.allowFilter true &&\n    ++\tgit -C sepwt config uploadpack.allowAnySHA1InWant true &&\n    ++\tgit -C sepwt config promisor.advertise false &&\n    ++\n    ++\t# Repack everything, then repack without the largest object and\n    ++\t# create a promisor pack, like initialize_server() does\n    ++\tgit -C sepwt -c repack.writebitmaps=false repack -a -d &&\n    ++\trm -f sepgit/objects/pack/*.promisor &&\n    ++\tgit -C sepwt -c repack.writebitmaps=false repack -a -d \\\n    ++\t\t--filter=blob:limit=5k --filter-to=\"$(pwd)/sep-pack\" &&\n    ++\tpromisor_file=$(ls sepgit/objects/pack/*.pack | sed \"s/\\.pack/.promisor/\") &&\n    ++\t>\"$promisor_file\" &&\n    ++\tcheck_missing_objects sepwt 1 \"$oid\" &&\n    ++\n    ++\t# The \".git\" file does not identify the repo, so it is not\n    ++\t# trusted and the clone fails\n    ++\ttest_config_global uploadpack.lazyFetchTrusted \"$(pwd)/sepwt/.git\" &&\n    ++\ttest_must_fail git clone --no-local --filter=\"blob:limit=1k\" \\\n    ++\t\tsepwt client 2>err &&\n    ++\ttest_grep \"lazy fetching disabled\" err &&\n    ++\tcheck_missing_objects sepwt 1 \"$oid\" &&\n    ++\n    ++\t# The git dir the \".git\" file points to identifies the repo, so\n    ++\t# it is trusted and the clone succeeds\n    ++\ttest_config_global uploadpack.lazyFetchTrusted \"$(pwd)/sepgit\" &&\n    ++\tgit clone --no-local --filter=\"blob:limit=1k\" sepwt client2 &&\n    ++\tcheck_missing_objects sepwt 0 \"\"\n    ++'\n    ++\n    ++test_expect_success \"uploadpack.lazyFetchTrusted trust does not propagate to promisor remotes\" '\n    ++\t# No promisors are advertised\n    ++\tgit -C server config promisor.advertise false &&\n    ++\ttest_when_finished \"rm -rf client lop2\" &&\n    ++\n    ++\t# Create \"lop2\", a partial clone that is also missing the\n    ++\t# largest object, and that can lazily fetch it from \"lop\"\n    ++\ttest_config -C template uploadpack.allowFilter true &&\n    ++\tgit clone --bare --no-local --filter=\"blob:limit=5k\" \\\n    ++\t\t\"$TRASH_DIRECTORY_URL/template\" lop2 &&\n    ++\tgit -C lop2 remote set-url origin \"$TRASH_DIRECTORY_URL/lop\" &&\n    ++\tgit -C lop2 config uploadpack.allowFilter true &&\n    ++\tgit -C lop2 config uploadpack.allowAnySHA1InWant true &&\n    ++\tcheck_missing_objects lop2 1 \"$oid\" &&\n    ++\n    ++\t# Make \"lop2\" the only promisor remote of the server. Note that\n    ++\t# \"remote.lop.partialCloneFilter\" also makes \"lop\" a promisor\n    ++\t# remote, so it has to be unset too.\n    ++\tgit -C server remote add lop2 \"$TRASH_DIRECTORY_URL/lop2\" &&\n    ++\tgit -C server config remote.lop2.promisor true &&\n    ++\ttest_when_finished \"git -C server remote remove lop2\" &&\n    ++\tgit -C server config --unset remote.lop.promisor &&\n    ++\ttest_when_finished \"git -C server config remote.lop.promisor true\" &&\n    ++\tlop_filter=\"$(git -C server config remote.lop.partialCloneFilter)\" &&\n    ++\tgit -C server config --unset remote.lop.partialCloneFilter &&\n    ++\ttest_when_finished \"git -C server config remote.lop.partialCloneFilter \\\"$lop_filter\\\"\" &&\n    ++\n    ++\t# Only the server is trusted, not \"lop2\", so the upload-pack\n    ++\t# serving \"lop2\" to the server refuses to lazily fetch from \"lop\"\n    ++\ttest_config_global uploadpack.lazyFetchTrusted \"$(pwd)/server\" &&\n    ++\ttest_must_fail git clone --no-local --filter=\"blob:limit=5k\" \\\n    ++\t\tserver client 2>err &&\n    ++\ttest_grep \"lazy fetching disabled\" err &&\n    ++\tcheck_missing_objects server 1 \"$oid\" &&\n    ++\tcheck_missing_objects lop2 1 \"$oid\" &&\n    ++\n    ++\t# Once \"lop2\" is also trusted, the clone succeeds\n    ++\tgit config --global --add uploadpack.lazyFetchTrusted \"$(pwd)/lop2\" &&\n    ++\tgit clone --no-local --filter=\"blob:limit=5k\" server client &&\n    ++\tcheck_missing_objects server 0 \"\" &&\n    ++\n    ++\t# Reinitialize server so that the largest object is missing again\n    ++\tinitialize_server 1 \"$oid\"\n    ++'\n     +\n      test_expect_success \"init + fetch with promisor.advertise set to 'true'\" '\n      \tgit -C server config promisor.advertise true &&\n\n\nChristian Couder (5):\n  promisor-remote: factor out lazy_fetch_objects()\n  setup: extract path_allowlist_apply()\n  upload-pack: read uploadpack.lazyFetchTrusted\n  promisor-remote: prevent infinite recursion when lazy fetching\n  builtin/upload-pack: don't disable lazy fetching on trusted repo\n\n Documentation/config/uploadpack.adoc  |  57 +++++++\n Documentation/git-upload-pack.adoc    |   9 +-\n Documentation/git.adoc                |   4 +-\n builtin/upload-pack.c                 |  19 ++-\n environment.h                         |   8 +\n promisor-remote.c                     | 105 ++++++++----\n setup.c                               | 142 ++++++++++------\n setup.h                               |  50 ++++++\n t/t0410-partial-clone.sh              |  33 ++++\n t/t5710-promisor-remote-capability.sh | 230 ++++++++++++++++++++++++++\n upload-pack.c                         |  59 +++++++\n upload-pack.h                         |   3 +\n 12 files changed, 639 insertions(+), 80 deletions(-)\n\n\nbase-commit: a018953688f1b10bddf91bff8747068f5f4746a4\n-- \n2.56.0.rc2.20.g34f06850c1\n\n"},{"id":"553920","messageId":"20261002082322.2682869-2-christian.couder@gmail.com","threadId":"65969","inReplyTo":"20261002082322.2682869-1-christian.couder@gmail.com","subject":"[PATCH v5 1/5] promisor-remote: factor out lazy_fetch_objects()","fromName":"Christian Couder","fromEmail":"christian.couder@gmail.com","sentAt":"2026-10-02T08:23:18Z","receivedAt":"2026-10-02T08:23:44Z","isPatch":true,"body":"In \"promisor-remote.c:fetch_objects()\", there is a check to disable\nlazy fetching when the `GIT_NO_LAZY_FETCH` environment variable is\nset. The fetch_objects() function is called once per promisor remote\nthough. So the check might be performed more times than necessary.\n\nAlso promisor_remote_get_direct() mixes up the logic deciding which\npromisor remotes to try with the logic checking that the objects\nthat could not be fetched are promisor objects.\n\nLet's refactor the lazy fetching logic out of these two functions\ninto a new lazy_fetch_objects() function.\n\nThis is a pure refactoring with no intended behavior change. Two\nthings shift in ways that are observably equivalent though:\n\n  - the `GIT_NO_LAZY_FETCH` check is now performed once up front,\n    instead of once per promisor remote, and\n\n  - promisor_remote_init() is no longer called when lazy fetching\n    is disabled.\n\nThe latter is fine because the convention around promisor_remote_init()\nis that whoever needs to access the promisor remote information is\nexpected to initialize it beforehand, and not that it should be\ninitialized once at the very beginning before doing random things on\npromisor remotes. So moving its call site into lazy_fetch_objects(),\nwhich is the only code that needs the promisor remotes here, follows\nthat convention. Nothing downstream of it, like is_promisor_object(),\nneeds it when lazy fetching is disabled.\n\nWhile at it, let's document try_promisor_remotes() and the new\nlazy_fetch_objects() function, especially how their `remaining_oids`,\n`remaining_nr` and `to_free` arguments are used, as the ownership\nrules around `to_free` are easy to get wrong.\n\nSigned-off-by: Christian Couder <christian.couder@gmail.com>\n---\n promisor-remote.c | 83 ++++++++++++++++++++++++++++++++---------------\n 1 file changed, 57 insertions(+), 26 deletions(-)\n\ndiff --git a/promisor-remote.c b/promisor-remote.c\nindex 43505d1e1a..91245fe9a8 100644\n--- a/promisor-remote.c\n+++ b/promisor-remote.c\n@@ -31,15 +31,6 @@ static int fetch_objects(struct repository *repo,\n \tFILE *child_in;\n \tint quiet;\n \n-\tif (git_env_bool(NO_LAZY_FETCH_ENVIRONMENT, 0)) {\n-\t\tstatic int warning_shown;\n-\t\tif (!warning_shown) {\n-\t\t\twarning_shown = 1;\n-\t\t\twarning(_(\"lazy fetching disabled; some objects may not be available\"));\n-\t\t}\n-\t\treturn -1;\n-\t}\n-\n \tchild.git_cmd = 1;\n \tchild.in = -1;\n \tif (repo != the_repository)\n@@ -270,9 +261,27 @@ static int remove_fetched_oids(struct repository *repo,\n \treturn remaining_nr;\n }\n \n+/*\n+ * Fetch the remaining objects (given in '*remaining_oids', which\n+ * contains '*remaining_nr' object ids) from the known promisor\n+ * remotes. If 'accepted_only' is true, ignore promisor remotes with\n+ * their 'accepted' member unset.\n+ *\n+ * When a fetch from a remote fails, the objects that are still\n+ * missing are computed, and '*remaining_oids' and '*remaining_nr' are\n+ * updated accordingly before trying the next remote. In that case\n+ * '*remaining_oids' points to a new array that this function\n+ * allocated, and '*to_free' is set to 1 to tell the caller that it\n+ * owns that array and should free it. '*to_free' should be 0 on the\n+ * first call.\n+ *\n+ * Return 1 when all the requested objects have been fetched, 0\n+ * otherwise.\n+ */\n static int try_promisor_remotes(struct repository *repo,\n \t\t\t\tstruct object_id **remaining_oids,\n-\t\t\t\tint *remaining_nr, int *to_free,\n+\t\t\t\tint *remaining_nr,\n+\t\t\t\tint *to_free,\n \t\t\t\tbool accepted_only)\n {\n \tstruct promisor_remote *r = repo->promisor_remote_config->promisors;\n@@ -295,6 +304,38 @@ static int try_promisor_remotes(struct repository *repo,\n \treturn 0;\n }\n \n+/*\n+ * Lazily fetch the objects given in '*remaining_oids' from the\n+ * promisor remotes, trying the accepted ones first. See\n+ * try_promisor_remotes() above for how '*remaining_oids',\n+ * '*remaining_nr' and '*to_free' are used.\n+ *\n+ * Return 1 when all the requested objects have been fetched, 0\n+ * otherwise.\n+ */\n+static int lazy_fetch_objects(struct repository *repo,\n+\t\t\t      struct object_id **remaining_oids,\n+\t\t\t      int *remaining_nr,\n+\t\t\t      int *to_free)\n+{\n+\tif (git_env_bool(NO_LAZY_FETCH_ENVIRONMENT, 0)) {\n+\t\tstatic int warning_shown;\n+\t\tif (!warning_shown) {\n+\t\t\twarning_shown = 1;\n+\t\t\twarning(_(\"lazy fetching disabled; some objects may not be available\"));\n+\t\t}\n+\t\treturn 0;\n+\t}\n+\n+\tpromisor_remote_init(repo);\n+\n+\t/* Try accepted remotes first (those the server told us to use) */\n+\treturn try_promisor_remotes(repo, remaining_oids, remaining_nr,\n+\t\t\t\t    to_free, true) ||\n+\t\ttry_promisor_remotes(repo, remaining_oids, remaining_nr,\n+\t\t\t\t     to_free, false);\n+}\n+\n void promisor_remote_get_direct(struct repository *repo,\n \t\t\t\tconst struct object_id *oids,\n \t\t\t\tint oid_nr)\n@@ -302,28 +343,18 @@ void promisor_remote_get_direct(struct repository *repo,\n \tstruct object_id *remaining_oids = (struct object_id *)oids;\n \tint remaining_nr = oid_nr;\n \tint to_free = 0;\n-\tint i;\n \n \tif (oid_nr == 0)\n \t\treturn;\n \n-\tpromisor_remote_init(repo);\n-\n-\t/* Try accepted remotes first (those the server told us to use) */\n-\tif (try_promisor_remotes(repo, &remaining_oids, &remaining_nr,\n-\t\t\t\t &to_free, true))\n-\t\tgoto all_fetched;\n-\tif (try_promisor_remotes(repo, &remaining_oids, &remaining_nr,\n-\t\t\t\t &to_free, false))\n-\t\tgoto all_fetched;\n-\n-\tfor (i = 0; i < remaining_nr; i++) {\n-\t\tif (is_promisor_object(repo, &remaining_oids[i]))\n-\t\t\tdie(_(\"could not fetch %s from promisor remote\"),\n-\t\t\t    oid_to_hex(&remaining_oids[i]));\n+\tif (!lazy_fetch_objects(repo, &remaining_oids, &remaining_nr, &to_free)) {\n+\t\tfor (int i = 0; i < remaining_nr; i++) {\n+\t\t\tif (is_promisor_object(repo, &remaining_oids[i]))\n+\t\t\t\tdie(_(\"could not fetch %s from promisor remote\"),\n+\t\t\t\t    oid_to_hex(&remaining_oids[i]));\n+\t\t}\n \t}\n \n-all_fetched:\n \tif (to_free)\n \t\tfree(remaining_oids);\n }\n-- \n2.56.0.rc2.20.g34f06850c1\n\n"},{"id":"553921","messageId":"20261002082322.2682869-3-christian.couder@gmail.com","threadId":"65969","inReplyTo":"20261002082322.2682869-1-christian.couder@gmail.com","subject":"[PATCH v5 2/5] setup: extract path_allowlist_apply()","fromName":"Christian Couder","fromEmail":"christian.couder@gmail.com","sentAt":"2026-10-02T08:23:19Z","receivedAt":"2026-10-02T08:23:45Z","isPatch":true,"body":"In a following commit we are going to check whether a repository is\npart of an allowlist specified in a config variable.\n\nTo prepare for that let's extract existing code from\nsafe_directory_cb() into a new path_allowlist_apply() helper that will\nhelp with such checks.\n\nWhile at it let's make the helper's code simpler and more generic, by\npassing it a `bool (*allow_path)(const char *path, void *cbdata)`\nfunction that decides if a path is acceptable by the caller, and let's\nadd a NEEDSWORK comment about it silently ignoring missing, possibly\nmisspelled, paths.\n\nTo further simplify how to reuse that new helper, and avoid duplicating\nthe config-value handling in a future commit, let's also introduce a\npath_allowlist_config_apply() helper.\n\nAs the new path_allowlist_apply() function reports its result through\na `bool *matches` argument, let's also change the `int is_safe` member\nof `struct safe_directory_data` to a `bool`, so that its address can\nbe passed as that argument.\n\nSigned-off-by: Christian Couder <christian.couder@gmail.com>\n---\n setup.c | 142 +++++++++++++++++++++++++++++++++++++-------------------\n setup.h |  50 ++++++++++++++++++++\n 2 files changed, 143 insertions(+), 49 deletions(-)\n\ndiff --git a/setup.c b/setup.c\nindex 0d157ac254..25c10f472f 100644\n--- a/setup.c\n+++ b/setup.c\n@@ -1355,67 +1355,111 @@ static int canonicalize_ceiling_entry(struct string_list_item *item,\n \t}\n }\n \n+void path_allowlist_apply(const char *allowed, const char *target_path,\n+\t\t\t  bool *matches,\n+\t\t\t  bool (*allow_path)(const char *path, void *cbdata),\n+\t\t\t  void *allow_path_cbdata)\n+{\n+\tchar *normalized = NULL;\n+\n+\tif (!allowed || !*allowed) {\n+\t\t*matches = false;\n+\t\treturn;\n+\t}\n+\n+\tif (!strcmp(allowed, \"*\")) {\n+\t\t*matches = true;\n+\t\treturn;\n+\t}\n+\n+\tif (!allow_path(allowed, allow_path_cbdata))\n+\t\treturn;\n+\n+\t/*\n+\t * A .gitconfig in $HOME may be shared across different\n+\t * machines and the config variable entries may or may not\n+\t * exist as paths on all of these machines.  In other words,\n+\t * it is not a warning worthy event when there is no such path\n+\t * on this machine---the entry may be useful elsewhere.\n+\t *\n+\t * NEEDSWORK: this also silently ignores misspelled paths. We\n+\t * may want to warn about a missing path unless it is marked\n+\t * as allowed to be missing, e.g., with an \":(optional)\"\n+\t * prefix like pathname-typed configuration values, and hint\n+\t * about that prefix in the warning.\n+\t */\n+\tnormalized = real_pathdup(allowed, 0);\n+\tif (!normalized)\n+\t\treturn;\n+\n+\tif (ends_with(normalized, \"/*\")) {\n+\t\tsize_t len = strlen(normalized);\n+\t\tif (!fspathncmp(normalized, target_path, len - 1))\n+\t\t\t*matches = true;\n+\t} else if (!fspathcmp(target_path, normalized)) {\n+\t\t*matches = true;\n+\t}\n+\n+\tfree(normalized);\n+}\n+\n+void path_allowlist_config_apply(const char *key, const char *value,\n+\t\t\t\t const char *target_path, bool *matches,\n+\t\t\t\t bool (*allow_path)(const char *path, void *cbdata),\n+\t\t\t\t void *allow_path_cbdata)\n+{\n+\tchar *allowed = NULL;\n+\n+\tif (!value || !*value || !strcmp(value, \"*\")) {\n+\t\tpath_allowlist_apply(value, target_path, matches,\n+\t\t\t\t     allow_path, allow_path_cbdata);\n+\t\treturn;\n+\t}\n+\n+\tif (git_config_pathname(&allowed, key, value) || !allowed)\n+\t\treturn;\n+\n+\tpath_allowlist_apply(allowed, target_path, matches,\n+\t\t\t     allow_path, allow_path_cbdata);\n+\n+\tfree(allowed);\n+}\n+\n+/*\n+ * Setting the config variable to a non-absolute path makes\n+ * little sense---it won't be relative to the configuration\n+ * file the item is defined in.  Except for \".\", which means\n+ * \"if we are at the top level of a repository, then it is\n+ * OK\", which is slightly tighter than \"*\" that allows\n+ * discovery.\n+ */\n+static bool allow_safe_dir(const char *path, void *cbdata_)\n+{\n+\tstruct path_allowlist_cb_data *cbdata = cbdata_;\n+\n+\tif (is_absolute_path(path) || !strcmp(path, \".\"))\n+\t\treturn true;\n+\n+\twarning(_(\"%s '%s' not absolute\"), cbdata->key, path);\n+\treturn false;\n+}\n+\n struct safe_directory_data {\n \tchar *path;\n-\tint is_safe;\n+\tbool is_safe;\n };\n \n static int safe_directory_cb(const char *key, const char *value,\n \t\t\t     const struct config_context *ctx UNUSED, void *d)\n {\n \tstruct safe_directory_data *data = d;\n+\tstruct path_allowlist_cb_data cbdata = { .key = key };\n \n \tif (strcmp(key, \"safe.directory\"))\n \t\treturn 0;\n \n-\tif (!value || !*value) {\n-\t\tdata->is_safe = 0;\n-\t} else if (!strcmp(value, \"*\")) {\n-\t\tdata->is_safe = 1;\n-\t} else {\n-\t\tchar *allowed = NULL;\n-\n-\t\tif (!git_config_pathname(&allowed, key, value) && allowed) {\n-\t\t\tchar *normalized = NULL;\n-\n-\t\t\t/*\n-\t\t\t * Setting safe.directory to a non-absolute path\n-\t\t\t * makes little sense---it won't be relative to\n-\t\t\t * the configuration file the item is defined in.\n-\t\t\t * Except for \".\", which means \"if we are at the top\n-\t\t\t * level of a repository, then it is OK\", which is\n-\t\t\t * slightly tighter than \"*\" that allows discovery.\n-\t\t\t */\n-\t\t\tif (!is_absolute_path(allowed) && strcmp(allowed, \".\")) {\n-\t\t\t\twarning(_(\"safe.directory '%s' not absolute\"),\n-\t\t\t\t\tallowed);\n-\t\t\t\tgoto next;\n-\t\t\t}\n-\n-\t\t\t/*\n-\t\t\t * A .gitconfig in $HOME may be shared across\n-\t\t\t * different machines and safe.directory entries\n-\t\t\t * may or may not exist as paths on all of these\n-\t\t\t * machines.  In other words, it is not a warning\n-\t\t\t * worthy event when there is no such path on this\n-\t\t\t * machine---the entry may be useful elsewhere.\n-\t\t\t */\n-\t\t\tnormalized = real_pathdup(allowed, 0);\n-\t\t\tif (!normalized)\n-\t\t\t\tgoto next;\n-\n-\t\t\tif (ends_with(normalized, \"/*\")) {\n-\t\t\t\tsize_t len = strlen(normalized);\n-\t\t\t\tif (!fspathncmp(normalized, data->path, len - 1))\n-\t\t\t\t\tdata->is_safe = 1;\n-\t\t\t} else if (!fspathcmp(data->path, normalized)) {\n-\t\t\t\tdata->is_safe = 1;\n-\t\t\t}\n-\t\tnext:\n-\t\t\tfree(normalized);\n-\t\t\tfree(allowed);\n-\t\t}\n-\t}\n+\tpath_allowlist_config_apply(key, value, data->path, &data->is_safe,\n+\t\t\t\t    allow_safe_dir, &cbdata);\n \n \treturn 0;\n }\ndiff --git a/setup.h b/setup.h\nindex 7394473e95..7362467ee5 100644\n--- a/setup.h\n+++ b/setup.h\n@@ -305,4 +305,54 @@ struct startup_info {\n extern struct startup_info *startup_info;\n extern const char *tmp_original_cwd;\n \n+/* Path allowlist */\n+\n+struct path_allowlist_cb_data {\n+\tconst char *key;\n+};\n+\n+/*\n+ * Check the allowlist entry in `allowed` against `target_path`,\n+ * updating `*matches` accordingly.\n+ *\n+ * `allowed` is a single entry of an allowlist of paths, typically one\n+ * value of a multi-valued config variable, already expanded by\n+ * git_config_pathname(). `target_path` is the (normalized) path being\n+ * tested. `*matches` is updated in place:\n+ *\n+ *   - an empty `allowed` resets it to 'false' (so a later, more\n+ *     specific config scope can clear entries from a broader one),\n+ *   - \"*\" sets it to 'true' (allow everything),\n+ *   - \"<path>\" sets it to 'true' if <path> equals `target_path`,\n+ *   - \"<path>\" + \"/\" + \"*\" sets it to 'true' if <path> is a leading\n+ *     directory of `target_path`,\n+ *   - anything else leaves `*matches` unchanged.\n+ *\n+ * `allow_path` is called with `allowed` and `allow_path_cbdata`, and\n+ * should return 'true' if the entry is acceptable to the caller. It\n+ * lets each caller decide which paths it is willing to consider, and\n+ * whether to warn about the ones it rejects. Returning 'false' leaves\n+ * `*matches` unchanged.\n+ *\n+ * Callers are expected to invoke this once per allowlist entry,\n+ * typically from a protected-config callback, so that untrusted\n+ * repository config cannot influence the decision.\n+ */\n+void path_allowlist_apply(const char *allowed, const char *target_path,\n+\t\t\t  bool *matches,\n+\t\t\t  bool (*allow_path)(const char *path, void *cbdata),\n+\t\t\t  void *allow_path_cbdata);\n+\n+/*\n+ * Apply one value of a multi-valued config variable holding an\n+ * allowlist of paths, expanding it with git_config_pathname() before\n+ * checking it against `target_path`. Empty and \"*\" values are passed\n+ * through without expansion, as interpolating them is not\n+ * meaningful. See path_allowlist_apply().\n+ */\n+void path_allowlist_config_apply(const char *key, const char *value,\n+\t\t\t\t const char *target_path, bool *matches,\n+\t\t\t\t bool (*allow_path)(const char *path, void *cbdata),\n+\t\t\t\t void *allow_path_cbdata);\n+\n #endif /* SETUP_H */\n-- \n2.56.0.rc2.20.g34f06850c1\n\n"},{"id":"553922","messageId":"20261002082322.2682869-4-christian.couder@gmail.com","threadId":"65969","inReplyTo":"20261002082322.2682869-1-christian.couder@gmail.com","subject":"[PATCH v5 3/5] upload-pack: read uploadpack.lazyFetchTrusted","fromName":"Christian Couder","fromEmail":"christian.couder@gmail.com","sentAt":"2026-10-02T08:23:20Z","receivedAt":"2026-10-02T08:23:47Z","isPatch":true,"body":"Previous commits created and prepared the path_allowlist_apply()\nand path_allowlist_config_apply() functions, but used them only for the\n\"safe.directory\" configuration variable.\n\nLet's reuse these functions for a new \"uploadpack.lazyFetchTrusted\"\nconfiguration variable.\n\nIt allows us to:\n\n  - read an allowlist from that config variable,\n  - check if the current repo is in that list, and\n  - return the result from a new upload_pack_lazy_fetch_trusted()\n    function.\n\nAs path_allowlist_config_apply() lets each caller decide which paths\nit is willing to accept using a callback, let's pass it a new\nallow_trusted_path() callback. Unlike the \"safe.directory\" callback, it\naccepts only absolute paths, and not \".\", as `upload-pack` always\nserves a repository given by an absolute path, so there is no \"current\nrepository\" for \".\" to refer to.\n\nNote that a served repository is identified by its git directory, and\nnot by its worktree. This is because `upload-pack` uses enter_repo()\ninstead of the usual repository discovery, so it never learns about a\nworktree and `r->worktree` is always NULL there. In practice this\nmeans that a non-bare repository served as \"/srv/repo\" has to be\nallowlisted as \"/srv/repo/.git\" (or as the directory its \".git\" file\npoints to, if it has a \".git\" file instead of a \".git\" directory).\n\nThe new upload_pack_lazy_fetch_trusted() function will be used in a\nfollowing commit.\n\nNote that the new config variable should be read only from protected\nconfiguration files.\n\nSigned-off-by: Christian Couder <christian.couder@gmail.com>\n---\n upload-pack.c | 59 +++++++++++++++++++++++++++++++++++++++++++++++++++\n upload-pack.h |  3 +++\n 2 files changed, 62 insertions(+)\n\ndiff --git a/upload-pack.c b/upload-pack.c\nindex 22573ad365..a300870fa9 100644\n--- a/upload-pack.c\n+++ b/upload-pack.c\n@@ -34,6 +34,8 @@\n #include \"json-writer.h\"\n #include \"strmap.h\"\n #include \"promisor-remote.h\"\n+#include \"setup.h\"\n+#include \"abspath.h\"\n \n /* Remember to update object flag allocation in object.h */\n #define THEY_HAVE\t(1u << 11)\n@@ -1343,6 +1345,63 @@ static int upload_pack_config(const char *var, const char *value,\n \treturn parse_hide_refs_config(var, value, \"uploadpack\", &data->hidden_refs);\n }\n \n+/*\n+ * Only absolute paths make sense here. Unlike 'safe.directory', \".\"\n+ * is not accepted, as the served repository is always identified by\n+ * an absolute path.\n+ */\n+static bool allow_trusted_path(const char *path, void *cbdata_)\n+{\n+\tstruct path_allowlist_cb_data *cbdata = cbdata_;\n+\n+\tif (is_absolute_path(path))\n+\t\treturn true;\n+\n+\twarning(_(\"%s '%s' not absolute\"), cbdata->key, path);\n+\treturn false;\n+}\n+\n+struct lazy_fetch_trusted {\n+\tchar *repo_path;\n+\tbool trusted;\n+};\n+\n+static int upload_pack_protected_lazy_fetch_config(const char *var, const char *value,\n+\t\t\t\t\t\t   const struct config_context *ctx UNUSED,\n+\t\t\t\t\t\t   void *cb_data)\n+{\n+\tstruct lazy_fetch_trusted *data = cb_data;\n+\tstruct path_allowlist_cb_data cbdata = { .key = var };\n+\n+\tif (strcmp(\"uploadpack.lazyfetchtrusted\", var))\n+\t\treturn 0;\n+\n+\tpath_allowlist_config_apply(var, value, data->repo_path, &data->trusted,\n+\t\t\t\t    allow_trusted_path, &cbdata);\n+\n+\treturn 0;\n+}\n+\n+bool upload_pack_lazy_fetch_trusted(struct repository *r)\n+{\n+\tstruct lazy_fetch_trusted data = { 0 };\n+\n+\t/*\n+\t * A served repository is identified by its git directory, as\n+\t * `upload-pack` uses enter_repo() instead of the usual repository\n+\t * discovery, so its worktree, if any, is never known here.\n+\t */\n+\tdata.repo_path = real_pathdup(r->gitdir, 0);\n+\tif (!data.repo_path)\n+\t\treturn false;\n+\n+\tgit_protected_config(upload_pack_protected_lazy_fetch_config, &data);\n+\n+\tfree(data.repo_path);\n+\n+\treturn !!data.trusted;\n+}\n+\n static int upload_pack_protected_config(const char *var, const char *value,\n \t\t\t\t\tconst struct config_context *ctx UNUSED,\n \t\t\t\t\tvoid *cb_data)\ndiff --git a/upload-pack.h b/upload-pack.h\nindex d6ee25ea98..b2212992c3 100644\n--- a/upload-pack.h\n+++ b/upload-pack.h\n@@ -12,4 +12,7 @@ struct strbuf;\n int upload_pack_advertise(struct repository *r,\n \t\t\t  struct strbuf *value);\n \n+/* Is this repo trusted for lazy fetching? */\n+bool upload_pack_lazy_fetch_trusted(struct repository *r);\n+\n #endif /* UPLOAD_PACK_H */\n-- \n2.56.0.rc2.20.g34f06850c1\n\n"},{"id":"553923","messageId":"20261002082322.2682869-5-christian.couder@gmail.com","threadId":"65969","inReplyTo":"20261002082322.2682869-1-christian.couder@gmail.com","subject":"[PATCH v5 4/5] promisor-remote: prevent infinite recursion when lazy fetching","fromName":"Christian Couder","fromEmail":"christian.couder@gmail.com","sentAt":"2026-10-02T08:23:21Z","receivedAt":"2026-10-02T08:23:48Z","isPatch":true,"body":"If a repository R is configured to lazy fetch from a promisor remote P\nwhich is also configured to in turn lazy fetch from R, there is an\ninfinite recursion: R asks P for a missing object, P asks R for it,\nand so on. The simplest case of this is a repository configured as its\nown promisor remote.\n\nThis is not reachable when serving a repository by default, as\n`upload-pack` sets `GIT_NO_LAZY_FETCH` to 1, which makes the nested\n`upload-pack` refuse to lazily fetch. A following commit will let\nserver operators allow lazy fetching for repositories they trust\nthough, and as `GIT_NO_LAZY_FETCH` is then set to 0 and passed down to\nchild processes, nothing stops the recursion anymore.\n\nIt does not recurse forever in practice, but only because each level\nadds one more variable to the environment of the child process, so\nafter a while `exec()` fails with:\n\n    fatal: cannot exec 'git-upload-pack ...': Argument list too long\n    fatal: unable to fork\n\nTo avoid this pathological case altogether, let's use a new\n`GIT_INTERNAL_LAZY_FETCH_DEPTH` to count the recursion depth, and let's\ncheck that it doesn't exceed a MAX_LAZY_FETCH_DEPTH limit (set to 5 for\nnow).\n\nNote that some nesting is legitimate: when `git fetch` runs\n`index-pack`, it can lazily fetch REF_DELTA bases that are missing\nlocally, so the limit should not be 1.\n\nSigned-off-by: Christian Couder <christian.couder@gmail.com>\n---\n environment.h            |  8 ++++++++\n promisor-remote.c        | 26 ++++++++++++++++++++++----\n t/t0410-partial-clone.sh | 33 +++++++++++++++++++++++++++++++++\n 3 files changed, 63 insertions(+), 4 deletions(-)\n\ndiff --git a/environment.h b/environment.h\nindex e7ec5b0437..f2833be9fe 100644\n--- a/environment.h\n+++ b/environment.h\n@@ -52,6 +52,14 @@\n  */\n #define GIT_ADVICE_ENVIRONMENT \"GIT_ADVICE\"\n \n+/*\n+ * Environment variable used to detect that a lazy fetch is already in\n+ * progress in a parent process, to prevent infinite recursion when a\n+ * promisor remote resolves back to the repository being served.\n+ * This is an internal variable that should not be set by the user.\n+ */\n+#define LAZY_FETCH_DEPTH_ENVIRONMENT \"GIT_INTERNAL_LAZY_FETCH_DEPTH\"\n+\n /*\n  * Environment variable used in handshaking the wire protocol.\n  * Contains a colon ':' separated list of keys with optional values\ndiff --git a/promisor-remote.c b/promisor-remote.c\nindex 91245fe9a8..316d9950aa 100644\n--- a/promisor-remote.c\n+++ b/promisor-remote.c\n@@ -24,7 +24,7 @@ struct promisor_remote_config {\n static int fetch_objects(struct repository *repo,\n \t\t\t const char *remote_name,\n \t\t\t const struct object_id *oids,\n-\t\t\t int oid_nr)\n+\t\t\t int oid_nr, int depth)\n {\n \tstruct child_process child = CHILD_PROCESS_INIT;\n \tint i;\n@@ -41,6 +41,7 @@ static int fetch_objects(struct repository *repo,\n \t\t     \"--filter=blob:none\", \"--stdin\", NULL);\n \tif (!repo_config_get_bool(repo, \"promisor.quiet\", &quiet) && quiet)\n \t\tstrvec_push(&child.args, \"--quiet\");\n+\tstrvec_pushf(&child.env, \"%s=%d\", LAZY_FETCH_DEPTH_ENVIRONMENT, depth + 1);\n \tif (start_command(&child))\n \t\tdie(_(\"promisor-remote: unable to fork off fetch subprocess\"));\n \tchild_in = xfdopen(child.in, \"w\");\n@@ -282,6 +283,7 @@ static int try_promisor_remotes(struct repository *repo,\n \t\t\t\tstruct object_id **remaining_oids,\n \t\t\t\tint *remaining_nr,\n \t\t\t\tint *to_free,\n+\t\t\t\tint depth,\n \t\t\t\tbool accepted_only)\n {\n \tstruct promisor_remote *r = repo->promisor_remote_config->promisors;\n@@ -289,7 +291,8 @@ static int try_promisor_remotes(struct repository *repo,\n \tfor (; r; r = r->next) {\n \t\tif (accepted_only != r->accepted)\n \t\t\tcontinue;\n-\t\tif (fetch_objects(repo, r->name, *remaining_oids, *remaining_nr) < 0) {\n+\t\tif (fetch_objects(repo, r->name,\n+\t\t\t\t  *remaining_oids, *remaining_nr, depth) < 0) {\n \t\t\tif (*remaining_nr == 1)\n \t\t\t\tcontinue;\n \t\t\t*remaining_nr = remove_fetched_oids(repo, remaining_oids,\n@@ -304,6 +307,8 @@ static int try_promisor_remotes(struct repository *repo,\n \treturn 0;\n }\n \n+#define MAX_LAZY_FETCH_DEPTH 5\n+\n /*\n  * Lazily fetch the objects given in '*remaining_oids' from the\n  * promisor remotes, trying the accepted ones first. See\n@@ -318,6 +323,8 @@ static int lazy_fetch_objects(struct repository *repo,\n \t\t\t      int *remaining_nr,\n \t\t\t      int *to_free)\n {\n+\tint depth = (int)git_env_ulong(LAZY_FETCH_DEPTH_ENVIRONMENT, 0);\n+\n \tif (git_env_bool(NO_LAZY_FETCH_ENVIRONMENT, 0)) {\n \t\tstatic int warning_shown;\n \t\tif (!warning_shown) {\n@@ -327,13 +334,24 @@ static int lazy_fetch_objects(struct repository *repo,\n \t\treturn 0;\n \t}\n \n+\tif (depth >= MAX_LAZY_FETCH_DEPTH) {\n+\t\tstatic int warning_shown;\n+\t\tif (!warning_shown) {\n+\t\t\twarning_shown = 1;\n+\t\t\twarning(_(\"too many nested lazy fetches (%d); \"\n+\t\t\t\t  \"is a promisor remote pointing at the repository itself?\"),\n+\t\t\t\tdepth);\n+\t\t}\n+\t\treturn 0;\n+\t}\n+\n \tpromisor_remote_init(repo);\n \n \t/* Try accepted remotes first (those the server told us to use) */\n \treturn try_promisor_remotes(repo, remaining_oids, remaining_nr,\n-\t\t\t\t    to_free, true) ||\n+\t\t\t\t    to_free, depth, true) ||\n \t\ttry_promisor_remotes(repo, remaining_oids, remaining_nr,\n-\t\t\t\t     to_free, false);\n+\t\t\t\t     to_free, depth, false);\n }\n \n void promisor_remote_get_direct(struct repository *repo,\ndiff --git a/t/t0410-partial-clone.sh b/t/t0410-partial-clone.sh\nindex 788e9a1631..a54685e3c7 100755\n--- a/t/t0410-partial-clone.sh\n+++ b/t/t0410-partial-clone.sh\n@@ -709,6 +709,39 @@ test_expect_success 'lazy-fetch when accessing object not in the_repository' '\n \ttest_grep ! \"[?]$FILE_HASH\" out\n '\n \n+test_expect_success 'lazy-fetch does not recurse infinitely between two promisor remotes' '\n+\trm -rf full partial1.git partial2.git &&\n+\n+\t# Create a repo with a blob\n+\ttest_create_repo full &&\n+\ttest_config -C full uploadpack.allowfilter 1 &&\n+\ttest_config -C full uploadpack.allowanysha1inwant 1 &&\n+\ttest_commit -C full create-a-file file.txt &&\n+\tFILE_HASH=$(git -C full rev-parse HEAD:file.txt) &&\n+\n+\t# Create partial clone repos without blobs\n+\tgit clone --filter=blob:none --bare \"file://$(pwd)/full\" partial1.git &&\n+\tgit clone --filter=blob:none --bare \"file://$(pwd)/full\" partial2.git &&\n+\ttest_config -C partial1.git uploadpack.allowfilter 1 &&\n+\ttest_config -C partial1.git uploadpack.allowanysha1inwant 1 &&\n+\ttest_config -C partial2.git uploadpack.allowfilter 1 &&\n+\ttest_config -C partial2.git uploadpack.allowanysha1inwant 1 &&\n+\n+\t# Configure the partial repos as remotes of each other\n+\tgit -C partial2.git remote set-url origin \"file://$(pwd)/partial1.git\" &&\n+\tgit -C partial1.git remote set-url origin \"file://$(pwd)/partial2.git\" &&\n+\n+\t# Make sure lazy fetching fails\n+\ttest_must_fail env GIT_TRACE=\"$(pwd)/trace\" GIT_NO_LAZY_FETCH=0 \\\n+\t\tgit -C partial1.git cat-file -e \"$FILE_HASH\" 2>err &&\n+\ttest_grep \"too many nested lazy fetches\" err &&\n+\n+\t# Make sure the recursion was bounded, i.e. that only\n+\t# MAX_LAZY_FETCH_DEPTH \"git fetch\" subprocesses were spawned\n+\tgrep \"run_command: GIT_INTERNAL_LAZY_FETCH_DEPTH\" trace >fetches &&\n+\ttest_line_count = 5 fetches\n+'\n+\n test_expect_success 'push should not fetch new commit objects' '\n \trm -rf server client &&\n \ttest_create_repo server &&\n-- \n2.56.0.rc2.20.g34f06850c1\n\n"},{"id":"553924","messageId":"20261002082322.2682869-6-christian.couder@gmail.com","threadId":"65969","inReplyTo":"20261002082322.2682869-1-christian.couder@gmail.com","subject":"[PATCH v5 5/5] builtin/upload-pack: don't disable lazy fetching on trusted repo","fromName":"Christian Couder","fromEmail":"christian.couder@gmail.com","sentAt":"2026-10-02T08:23:22Z","receivedAt":"2026-10-02T08:23:51Z","isPatch":true,"body":"A previous commit added a new \"uploadpack.lazyFetchTrusted\" protected\nconfig variable that can contain an allowlist of repos, as well as\nfunctions to check if the current repo is in that list. But when the\ncurrent repo is in that list, we currently do nothing.\n\nSince 7b70e9efb1 (upload-pack: disable lazy-fetching by default,\n2024-04-16), `upload-pack` sets `GIT_NO_LAZY_FETCH` to 1 itself,\nunconditionally, because by default it shouldn't trust the repositories\nit serves. Lazily fetching runs `git fetch`, which may execute\narbitrary commands specified in the configuration and hooks of the\nserved repo.\n\nThe new \"uploadpack.lazyFetchTrusted\" protected config variable is not\nabout overriding an environment variable. It's rather about teaching\nthe code that automatically sets `GIT_NO_LAZY_FETCH` (because it had no\nway to know if the served repo could be trusted) to look at the new\nconfig variable to find out if a server operator actually vouched for\nthat repo.\n\nLet's implement that, so we now have the following cases:\n\n  - if `GIT_NO_LAZY_FETCH` is already set, we honor it and leave it\n    alone, as it comes from the server operator,\n\n  - otherwise, if the served repo is in the\n    \"uploadpack.lazyFetchTrusted\" allowlist, we don't disable lazy\n    fetching,\n\n  - otherwise, we disable lazy fetching, as we used to.\n\nThis allows `upload-pack` and its `pack-objects` child process to\nlazily fetch the objects they need to serve a client, for example when\nthe filter used by the client and the one used by the server don't\nmatch.\n\nNote that what a server operator vouches for by listing a repo there\nis that the promisor remotes this repo is configured to lazily fetch\nfrom, as well as its configuration and hooks, are trustworthy. Whether\na client trusts the repo it fetches from is a separate matter, and up\nto the client.\n\nAs `pack-objects`, which performs the lazy fetch when serving a\nclient, is a child process of `upload-pack`, not setting\n`GIT_NO_LAZY_FETCH` in `upload-pack` is enough for it to be allowed to\nlazily fetch, without any further plumbing.\n\nOn the other hand, as we leave `GIT_NO_LAZY_FETCH` unset for a trusted\nrepo instead of setting it to 0, the trust doesn't propagate: if a\ntrusted repo lazily fetches from a promisor remote that is itself\nserved by `upload-pack` on the same machine, that nested `upload-pack`\ndecides for its own repo. This is unlike when a server operator sets\n`GIT_NO_LAZY_FETCH` to 0, as that is inherited by all child processes.\n\nNow that \"uploadpack.lazyFetchTrusted\" is actually doing something,\nlet's document it (including this difference with `GIT_NO_LAZY_FETCH`),\nlet's reference it from `GIT_NO_LAZY_FETCH`'s docs, and let's add tests\nfor it.\n\nSigned-off-by: Christian Couder <christian.couder@gmail.com>\n---\n Documentation/config/uploadpack.adoc  |  57 +++++++\n Documentation/git-upload-pack.adoc    |   9 +-\n Documentation/git.adoc                |   4 +-\n builtin/upload-pack.c                 |  19 ++-\n t/t5710-promisor-remote-capability.sh | 230 ++++++++++++++++++++++++++\n 5 files changed, 316 insertions(+), 3 deletions(-)\n\ndiff --git a/Documentation/config/uploadpack.adoc b/Documentation/config/uploadpack.adoc\nindex 0e1dda944a..242a2c485a 100644\n--- a/Documentation/config/uploadpack.adoc\n+++ b/Documentation/config/uploadpack.adoc\n@@ -86,3 +86,60 @@ uploadpack.allowRefInWant::\n \tis intended for the benefit of load-balanced servers which may\n \tnot have the same view of what OIDs their refs point to due to\n \treplication delay.\n+\n+uploadpack.lazyFetchTrusted::\n+\tA multi-valued configuration variable, each value of which\n+\tspecifies the absolute local path of a repository that\n+\t`upload-pack` is allowed to lazily fetch missing objects for.\n++\n+A repository is identified by its git directory, after following any\n+`.git` file and resolving symbolic links. That is the repository\n+itself if it is bare, the `.git` directory of a repository that has a\n+worktree, or the directory that a `.git` file points to, for example\n+when the repository was created with `--separate-git-dir` or for a\n+linked worktree (see linkgit:git-worktree[1]). So a non-bare\n+repository served as `/srv/repo` usually has to be allowlisted as\n+`/srv/repo/.git`. Giving a path with `/*` appended to it will trust\n+all repositories under the named directory. To trust all served\n+repositories, set `uploadpack.lazyFetchTrusted` to the string `*`.\n++\n+The value of this setting is interpolated, i.e., `~/<path>` expands to\n+a path relative to the home directory and `%(prefix)/<path>` expands\n+to a path relative to Git's (runtime) prefix.\n++\n+By default, `upload-pack` refuses to lazily fetch (see the description\n+of the `GIT_NO_LAZY_FETCH` environment variable in\n+linkgit:git-upload-pack[1]), because doing so would run `git fetch`,\n+which may execute arbitrary commands specified in the configuration\n+and hooks of the served repository. Listing a repository here tells\n+`upload-pack` that it is trusted, so lazy fetching from the promisor\n+remotes configured in it is allowed. This is similar to setting\n+`GIT_NO_LAZY_FETCH` to `0`, but only for the matching repositories:\n+unlike that environment variable, the trust is not inherited by child\n+processes. So if a trusted repository lazily fetches from a promisor\n+remote that is itself served by `upload-pack` on the same machine,\n+for example through a local path or a `file://` URL, lazy fetching is\n+allowed there only if that promisor remote is also listed here. An\n+explicitly set `GIT_NO_LAZY_FETCH` takes precedence over this setting.\n++\n+Note that this allows lazy fetching from any promisor remote\n+configured in the served repository, not only from the promisor\n+remotes that the client accepted using the \"promisor-remote\" protocol\n+v2 capability (see linkgit:gitprotocol-v2[5]). The served repository\n+is trusted as a whole, including its configuration, so the promisor\n+remotes it configures are trusted too. It is the server operator's\n+responsibility to make sure that the promisor remotes of a trusted\n+repository are also trustworthy. In particular, a trusted repository\n+should not be configured as its own promisor remote, as `upload-pack`\n+would then try to lazily fetch missing objects from the repository\n+itself, which is pointless.\n++\n+As this is a multi-valued setting, you can add more than one\n+repository via `git config (--global|--system) --add`. To reset the\n+list of trusted repositories (e.g., to override any such repositories\n+specified in the system config), add an `uploadpack.lazyFetchTrusted`\n+entry with an empty value.\n++\n+Note that this configuration variable is only respected when it is\n+specified in protected configuration (see <<SCOPES>>). This prevents\n+untrusted repositories from tampering with this value.\ndiff --git a/Documentation/git-upload-pack.adoc b/Documentation/git-upload-pack.adoc\nindex 9167a321d0..9e3a3fe142 100644\n--- a/Documentation/git-upload-pack.adoc\n+++ b/Documentation/git-upload-pack.adoc\n@@ -70,7 +70,14 @@ This is implemented by having `upload-pack` internally set the\n `GIT_NO_LAZY_FETCH` variable to `1`. If you want to override it\n (because you are fetching from a partial clone, and you are sure\n you trust it), you can explicitly set `GIT_NO_LAZY_FETCH` to\n-`0`.\n+`0`. As it is an environment variable, it is also inherited by child\n+processes, including any `upload-pack` run to lazily fetch from a\n+promisor remote on the same machine.\n++\n+Instead of setting `GIT_NO_LAZY_FETCH` to `0` in the environment, a\n+server operator can allow lazy fetching on a per-repository basis by\n+listing trusted repositories in the `uploadpack.lazyFetchTrusted`\n+configuration variable. See linkgit:git-config[1].\n \n SECURITY\n --------\ndiff --git a/Documentation/git.adoc b/Documentation/git.adoc\nindex 6f0075f918..ff78ce6eec 100644\n--- a/Documentation/git.adoc\n+++ b/Documentation/git.adoc\n@@ -952,7 +952,9 @@ for full details.\n `GIT_NO_LAZY_FETCH`::\n \tSetting this Boolean environment variable to true tells Git\n \tnot to lazily fetch missing objects from the promisor remote\n-\ton demand.\n+\ton demand. On the server side, the `uploadpack.lazyFetchTrusted`\n+\tconfiguration variable can control this per-repository. See\n+\tlinkgit:git-upload-pack[1].\n \n `GIT_REFLOG_ACTION`::\n \tWhen a ref is updated, reflog entries are created to keep\ndiff --git a/builtin/upload-pack.c b/builtin/upload-pack.c\nindex 32831fb879..53e76deb23 100644\n--- a/builtin/upload-pack.c\n+++ b/builtin/upload-pack.c\n@@ -46,7 +46,6 @@ int cmd_upload_pack(int argc,\n \tpacket_trace_identity(\"upload-pack\");\n \tdisable_replace_refs();\n \tsave_commit_buffer = 0;\n-\txsetenv(NO_LAZY_FETCH_ENVIRONMENT, \"1\", 0);\n \n \targc = parse_options(argc, argv, prefix, options, upload_pack_usage, 0);\n \n@@ -62,6 +61,24 @@ int cmd_upload_pack(int argc,\n \tif (!enter_repo(the_repository, dir, enter_repo_flags))\n \t\tdie(\"'%s' does not appear to be a git repository\", dir);\n \n+\t/*\n+\t * Lazily fetching while serving a client would run `git fetch`,\n+\t * which may execute arbitrary commands from the configuration\n+\t * and hooks of the served repo, so we disable it by default as\n+\t * we trust nobody. There are two ways for a server operator to\n+\t * allow it though:\n+\t *\n+\t *   - if GIT_NO_LAZY_FETCH is already set, we leave it alone and\n+\t *     honor whatever the operator put there,\n+\t *\n+\t *   - otherwise, if the served repo is in the\n+\t *     \"uploadpack.lazyFetchTrusted\" protected allowlist, we\n+\t *     don't disable lazy fetching.\n+\t */\n+\tif (!getenv(NO_LAZY_FETCH_ENVIRONMENT) &&\n+\t    !upload_pack_lazy_fetch_trusted(the_repository))\n+\t\txsetenv(NO_LAZY_FETCH_ENVIRONMENT, \"1\", 1);\n+\n \tswitch (determine_protocol_version_server()) {\n \tcase protocol_v2:\n \t\tif (advertise_refs)\ndiff --git a/t/t5710-promisor-remote-capability.sh b/t/t5710-promisor-remote-capability.sh\nindex 549acff23f..463e9e00b0 100755\n--- a/t/t5710-promisor-remote-capability.sh\n+++ b/t/t5710-promisor-remote-capability.sh\n@@ -173,6 +173,236 @@ test_expect_success \"clone with promisor.acceptfromserver set to 'None'\" '\n \tinitialize_server 1 \"$oid\"\n '\n \n+test_expect_success \"clone with uploadpack.lazyFetchTrusted\" '\n+\t# No promisors are advertised\n+\tgit -C server config promisor.advertise false &&\n+\ttest_when_finished \"rm -rf client\" &&\n+\n+\t# The served repo is trusted for lazy fetching\n+\ttest_config_global uploadpack.lazyFetchTrusted \"$(pwd)/server\" &&\n+\n+\t# Clone without GIT_NO_LAZY_FETCH=0\n+\tgit clone --no-local --filter=\"blob:limit=5k\" server client &&\n+\n+\t# Check that the largest object is not missing on the server\n+\t# This means the server lazy fetched it\n+\tcheck_missing_objects server 0 \"\" &&\n+\n+\t# Reinitialize server so that the largest object is missing again\n+\tinitialize_server 1 \"$oid\"\n+'\n+\n+test_expect_success \"clone without uploadpack.lazyFetchTrusted fails\" '\n+\t# No promisors are advertised\n+\tgit -C server config promisor.advertise false &&\n+\ttest_when_finished \"rm -rf client\" &&\n+\n+\t# Note: no uploadpack.lazyFetchTrusted config is set here, so\n+\t# the served repo is NOT trusted for lazy fetching.\n+\n+\t# Clone without GIT_NO_LAZY_FETCH=0 fails\n+\ttest_must_fail git clone --no-local --filter=\"blob:limit=5k\" server client 2>err &&\n+\ttest_grep \"lazy fetching disabled\" err &&\n+\n+\t# Check that the largest object is still missing on the server\n+\tcheck_missing_objects server 1 \"$oid\"\n+'\n+\n+test_expect_success \"uploadpack.lazyFetchTrusted is ignored in repo config\" '\n+\t# No promisors are advertised\n+\tgit -C server config promisor.advertise false &&\n+\ttest_when_finished \"rm -rf client\" &&\n+\n+\t# The served repo is trusted for lazy fetching, but this is\n+\t# done in the repo config, not in protected config, so this is\n+\t# ignored.\n+\ttest_config -C server uploadpack.lazyFetchTrusted \"$(pwd)/server\" &&\n+\n+\t# Clone without GIT_NO_LAZY_FETCH=0 fails\n+\ttest_must_fail git clone --no-local --filter=\"blob:limit=5k\" server client 2>err &&\n+\ttest_grep \"lazy fetching disabled\" err &&\n+\n+\t# Check that the largest object is still missing on the server\n+\tcheck_missing_objects server 1 \"$oid\"\n+'\n+\n+test_expect_success \"explicit GIT_NO_LAZY_FETCH overrides uploadpack.lazyFetchTrusted\" '\n+\t# No promisors are advertised\n+\tgit -C server config promisor.advertise false &&\n+\ttest_when_finished \"rm -rf client\" &&\n+\n+\t# The served repo is trusted for lazy fetching\n+\ttest_config_global uploadpack.lazyFetchTrusted \"$(pwd)/server\" &&\n+\n+\t# But GIT_NO_LAZY_FETCH=1 disables lazy fetching, so clone fails\n+\ttest_must_fail env GIT_NO_LAZY_FETCH=1 git clone --no-local \\\n+\t\t--filter=\"blob:limit=5k\" server client 2>err &&\n+\ttest_grep \"lazy fetching disabled\" err &&\n+\n+\t# Check that the largest object is still missing on the server\n+\tcheck_missing_objects server 1 \"$oid\"\n+'\n+\n+test_expect_success \"trusted repo as its own promisor remote does not recurse\" '\n+\t# No promisors are advertised\n+\tgit -C server config promisor.advertise false &&\n+\ttest_when_finished \"rm -rf client\" &&\n+\n+\t# Add itself as its own remote\n+\tgit -C server remote add self \"$TRASH_DIRECTORY_URL/server\" &&\n+\tgit -C server config remote.self.promisor true &&\n+\ttest_when_finished \"git -C server remote remove self\" &&\n+\n+\t# Make \"self\" the only promisor remote of the server, so that it\n+\t# cannot get the missing object from \"lop\". Note that\n+\t# \"remote.lop.partialCloneFilter\" also makes \"lop\" a promisor\n+\t# remote, so it has to be unset too.\n+\tgit -C server config --unset remote.lop.promisor &&\n+\ttest_when_finished \"git -C server config remote.lop.promisor true\" &&\n+\tlop_filter=\"$(git -C server config remote.lop.partialCloneFilter)\" &&\n+\tgit -C server config --unset remote.lop.partialCloneFilter &&\n+\ttest_when_finished \"git -C server config remote.lop.partialCloneFilter \\\"$lop_filter\\\"\" &&\n+\n+\t# Allow lazy fetching from itself\n+\ttest_config_global uploadpack.lazyFetchTrusted \"$(pwd)/server\" &&\n+\n+\t# Check that lazy fetching fails\n+\ttest_must_fail git clone --no-local --filter=\"blob:limit=5k\" server client 2>err &&\n+\ttest_grep \"too many nested lazy fetches\" err &&\n+\n+\t# Check that the largest object is still missing on the server\n+\tcheck_missing_objects server 1 \"$oid\"\n+'\n+\n+test_expect_success \"uploadpack.lazyFetchTrusted needs the git dir of a non-bare repo\" '\n+\ttest_when_finished \"rm -rf nonbare nonbare-pack-* client client2\" &&\n+\n+\t# Create a non-bare repo, without any worktree content, so that\n+\t# its largest object can be filtered out below\n+\tgit init nonbare &&\n+\tgit -C nonbare remote add origin \"$TRASH_DIRECTORY_URL/template\" &&\n+\tgit -C nonbare fetch origin &&\n+\tgit -C nonbare update-ref HEAD FETCH_HEAD &&\n+\n+\tgit -C nonbare remote add lop \"$TRASH_DIRECTORY_URL/lop\" &&\n+\tgit -C nonbare config remote.lop.promisor true &&\n+\tgit -C nonbare config uploadpack.allowFilter true &&\n+\tgit -C nonbare config uploadpack.allowAnySHA1InWant true &&\n+\tgit -C nonbare config promisor.advertise false &&\n+\n+\t# Repack everything, then repack without the largest object and\n+\t# create a promisor pack, like initialize_server() does\n+\tgit -C nonbare -c repack.writebitmaps=false repack -a -d &&\n+\trm -f nonbare/.git/objects/pack/*.promisor &&\n+\tgit -C nonbare -c repack.writebitmaps=false repack -a -d \\\n+\t\t--filter=blob:limit=5k --filter-to=\"$(pwd)/nonbare-pack\" &&\n+\tpromisor_file=$(ls nonbare/.git/objects/pack/*.pack | sed \"s/\\.pack/.promisor/\") &&\n+\t>\"$promisor_file\" &&\n+\tcheck_missing_objects nonbare 1 \"$oid\" &&\n+\n+\t# The worktree path does not identify the repo, so it is not\n+\t# trusted and the clone fails\n+\ttest_config_global uploadpack.lazyFetchTrusted \"$(pwd)/nonbare\" &&\n+\ttest_must_fail git clone --no-local --filter=\"blob:limit=1k\" \\\n+\t\tnonbare client 2>err &&\n+\ttest_grep \"lazy fetching disabled\" err &&\n+\tcheck_missing_objects nonbare 1 \"$oid\" &&\n+\n+\t# The git dir identifies the repo, so it is trusted and the\n+\t# clone succeeds\n+\ttest_config_global uploadpack.lazyFetchTrusted \"$(pwd)/nonbare/.git\" &&\n+\tgit clone --no-local --filter=\"blob:limit=1k\" nonbare client2 &&\n+\tcheck_missing_objects nonbare 0 \"\"\n+'\n+\n+test_expect_success \"uploadpack.lazyFetchTrusted needs the git dir a .git file points to\" '\n+\ttest_when_finished \"rm -rf sepwt sepgit sep-pack-* client client2\" &&\n+\n+\t# Create a non-bare repo with a \".git\" file pointing to a\n+\t# separate git dir, without any worktree content, so that its\n+\t# largest object can be filtered out below\n+\tgit init --separate-git-dir=\"$(pwd)/sepgit\" sepwt &&\n+\ttest_path_is_file sepwt/.git &&\n+\tgit -C sepwt remote add origin \"$TRASH_DIRECTORY_URL/template\" &&\n+\tgit -C sepwt fetch origin &&\n+\tgit -C sepwt update-ref HEAD FETCH_HEAD &&\n+\n+\tgit -C sepwt remote add lop \"$TRASH_DIRECTORY_URL/lop\" &&\n+\tgit -C sepwt config remote.lop.promisor true &&\n+\tgit -C sepwt config uploadpack.allowFilter true &&\n+\tgit -C sepwt config uploadpack.allowAnySHA1InWant true &&\n+\tgit -C sepwt config promisor.advertise false &&\n+\n+\t# Repack everything, then repack without the largest object and\n+\t# create a promisor pack, like initialize_server() does\n+\tgit -C sepwt -c repack.writebitmaps=false repack -a -d &&\n+\trm -f sepgit/objects/pack/*.promisor &&\n+\tgit -C sepwt -c repack.writebitmaps=false repack -a -d \\\n+\t\t--filter=blob:limit=5k --filter-to=\"$(pwd)/sep-pack\" &&\n+\tpromisor_file=$(ls sepgit/objects/pack/*.pack | sed \"s/\\.pack/.promisor/\") &&\n+\t>\"$promisor_file\" &&\n+\tcheck_missing_objects sepwt 1 \"$oid\" &&\n+\n+\t# The \".git\" file does not identify the repo, so it is not\n+\t# trusted and the clone fails\n+\ttest_config_global uploadpack.lazyFetchTrusted \"$(pwd)/sepwt/.git\" &&\n+\ttest_must_fail git clone --no-local --filter=\"blob:limit=1k\" \\\n+\t\tsepwt client 2>err &&\n+\ttest_grep \"lazy fetching disabled\" err &&\n+\tcheck_missing_objects sepwt 1 \"$oid\" &&\n+\n+\t# The git dir the \".git\" file points to identifies the repo, so\n+\t# it is trusted and the clone succeeds\n+\ttest_config_global uploadpack.lazyFetchTrusted \"$(pwd)/sepgit\" &&\n+\tgit clone --no-local --filter=\"blob:limit=1k\" sepwt client2 &&\n+\tcheck_missing_objects sepwt 0 \"\"\n+'\n+\n+test_expect_success \"uploadpack.lazyFetchTrusted trust does not propagate to promisor remotes\" '\n+\t# No promisors are advertised\n+\tgit -C server config promisor.advertise false &&\n+\ttest_when_finished \"rm -rf client lop2\" &&\n+\n+\t# Create \"lop2\", a partial clone that is also missing the\n+\t# largest object, and that can lazily fetch it from \"lop\"\n+\ttest_config -C template uploadpack.allowFilter true &&\n+\tgit clone --bare --no-local --filter=\"blob:limit=5k\" \\\n+\t\t\"$TRASH_DIRECTORY_URL/template\" lop2 &&\n+\tgit -C lop2 remote set-url origin \"$TRASH_DIRECTORY_URL/lop\" &&\n+\tgit -C lop2 config uploadpack.allowFilter true &&\n+\tgit -C lop2 config uploadpack.allowAnySHA1InWant true &&\n+\tcheck_missing_objects lop2 1 \"$oid\" &&\n+\n+\t# Make \"lop2\" the only promisor remote of the server. Note that\n+\t# \"remote.lop.partialCloneFilter\" also makes \"lop\" a promisor\n+\t# remote, so it has to be unset too.\n+\tgit -C server remote add lop2 \"$TRASH_DIRECTORY_URL/lop2\" &&\n+\tgit -C server config remote.lop2.promisor true &&\n+\ttest_when_finished \"git -C server remote remove lop2\" &&\n+\tgit -C server config --unset remote.lop.promisor &&\n+\ttest_when_finished \"git -C server config remote.lop.promisor true\" &&\n+\tlop_filter=\"$(git -C server config remote.lop.partialCloneFilter)\" &&\n+\tgit -C server config --unset remote.lop.partialCloneFilter &&\n+\ttest_when_finished \"git -C server config remote.lop.partialCloneFilter \\\"$lop_filter\\\"\" &&\n+\n+\t# Only the server is trusted, not \"lop2\", so the upload-pack\n+\t# serving \"lop2\" to the server refuses to lazily fetch from \"lop\"\n+\ttest_config_global uploadpack.lazyFetchTrusted \"$(pwd)/server\" &&\n+\ttest_must_fail git clone --no-local --filter=\"blob:limit=5k\" \\\n+\t\tserver client 2>err &&\n+\ttest_grep \"lazy fetching disabled\" err &&\n+\tcheck_missing_objects server 1 \"$oid\" &&\n+\tcheck_missing_objects lop2 1 \"$oid\" &&\n+\n+\t# Once \"lop2\" is also trusted, the clone succeeds\n+\tgit config --global --add uploadpack.lazyFetchTrusted \"$(pwd)/lop2\" &&\n+\tgit clone --no-local --filter=\"blob:limit=5k\" server client &&\n+\tcheck_missing_objects server 0 \"\" &&\n+\n+\t# Reinitialize server so that the largest object is missing again\n+\tinitialize_server 1 \"$oid\"\n+'\n+\n test_expect_success \"init + fetch with promisor.advertise set to 'true'\" '\n \tgit -C server config promisor.advertise true &&\n \ttest_when_finished \"rm -rf client\" &&\n-- \n2.56.0.rc2.20.g34f06850c1\n\n"},{"id":"553928","messageId":"CAP8UFD2Ks9mJ+Gdw02VXjpKv16HTxXTtQ3_5_heP_1TOfsHb-A@mail.gmail.com","threadId":"65969","inReplyTo":"xmqqse2sgda6.fsf@gitster.g","subject":"Re: [PATCH v4 5/5] builtin/upload-pack: don't disable lazy fetching on trusted repo","fromName":"Christian Couder","fromEmail":"christian.couder@gmail.com","sentAt":"2026-10-02T08:57:07Z","receivedAt":"2026-10-02T08:57:20Z","isPatch":true,"body":"On Tue, Sep 29, 2026 at 7:47 PM Junio C Hamano <gitster@pobox.com> wrote:\n>\n> Christian Couder <christian.couder@gmail.com> writes:\n\n> > +uploadpack.lazyFetchTrusted::\n> > +     A multi-valued configuration variable, each of which contains the\n> > +     absolute local path of a repository that `upload-pack` is allowed to\n> > +     lazily fetch missing objects for.\n>\n> \"each of which\" lacks a plural noun to modify.  Perhaps\n>\n>         each value of which specifies the absolute local path of a\n\nYeah, \"each value of which specifies\" is used in the v5 I just sent.\n\n>         repository from which upload-pack is allowed to lazily fetch\n\n\"from which\" would not be quite right, because the client would lazily\nfetch from the promisor remotes of this server repo (using the\n\"promisor-remote\" capability), not directly from this repo. So the\nrest of the sentence hasn't changed in v5.\n\n>         missing objects.\n>\n> > ++\n> > +A repository is identified by its git directory, i.e. the `.git`\n>\n> \"i.e.\" -> \"i.e.,\" (similarly \"e.g.\" -> \"e.g.,\" below).\n\nApplied in v5.\n\nThanks!\n"},{"id":"553929","messageId":"CAP8UFD2OFRv1-MXVK3mR+_hMAmXvH1Y=f7j8zhMHo3wkbcK32w@mail.gmail.com","threadId":"65969","inReplyTo":"xmqqy0ckgea3.fsf@gitster.g","subject":"Re: [PATCH v4 2/5] setup: extract path_allowlist_apply()","fromName":"Christian Couder","fromEmail":"christian.couder@gmail.com","sentAt":"2026-10-02T09:00:22Z","receivedAt":"2026-10-02T09:00:35Z","isPatch":true,"body":"On Tue, Sep 29, 2026 at 7:26 PM Junio C Hamano <gitster@pobox.com> wrote:\n>\n> Christian Couder <christian.couder@gmail.com> writes:\n>\n> > +     /*\n> > +      * A .gitconfig in $HOME may be shared across different\n> > +      * machines and the config variable entries may or may not\n> > +      * exist as paths on all of these machines.  In other words,\n> > +      * it is not a warning worthy event when there is no such path\n> > +      * on this machine---the entry may be useful elsewhere.\n> > +      */\n>\n> This might be a minor point (as not many people may be using the\n> safe.directory feature that this was moved from), and this dates\n> back two years, starting with dc0edbb01c (safe.directory: normalize\n> the configured path, 2024-07-30), but the above design decision cuts\n> both ways.  If you misspelled a pathname, you would never be told\n> about it.\n>\n> I wonder if we want to allow users to explicitly mark that it is OK if\n> a path does not exist, in much the same way that a pathname-typed\n> configuration variable can be prefixed with :(optional) to tell the\n> system \"if this path exists on the system, use it, but if not, instead\n> of warning, pretend that you did not see this specified\".\n>\n> That way, a user can first specify the value normally, and then when\n> they reuse the .gitconfig file somewhere else that does not have the\n> path, they see a warning message.  You would help them by giving a\n> hint, e.g.,\n>\n>     Specified path foo/bar does not exist.  If you spelled the\n>     pathname correctly, and the path is allowed to be missing,\n>     mark it as optional, i.e., \":(optional)foo/bar\".\n>\n> or something along those lines in the warning message and the world\n> would be a much better place.\n>\n> In any case, it is outside the scope of this series, beyond leaving\n> a NEEDSWORK comment here, and/or a #leftoverbits comment in the\n> review.\n\nThere is the following new NEEDSWORK comment in the v5 I just sent:\n\n+        * NEEDSWORK: this also silently ignores misspelled paths. We\n+        * may want to warn about a missing path unless it is marked\n+        * as allowed to be missing, e.g., with an \":(optional)\"\n+        * prefix like pathname-typed configuration values, and hint\n+        * about that prefix in the warning.\n\nThanks.\n"},{"id":"553932","messageId":"CAP8UFD00nFxs_wXwdJQL2NxojUcnYozjf2pHm0=MZRAEm-nsrA@mail.gmail.com","threadId":"65969","inReplyTo":"CAP8UFD2Ks9mJ+Gdw02VXjpKv16HTxXTtQ3_5_heP_1TOfsHb-A@mail.gmail.com","subject":"Re: [PATCH v4 5/5] builtin/upload-pack: don't disable lazy fetching on trusted repo","fromName":"Christian Couder","fromEmail":"christian.couder@gmail.com","sentAt":"2026-10-02T09:18:45Z","receivedAt":"2026-10-02T09:18:58Z","isPatch":true,"body":"On Fri, Oct 2, 2026 at 10:57 AM Christian Couder\n<christian.couder@gmail.com> wrote:\n>\n> On Tue, Sep 29, 2026 at 7:47 PM Junio C Hamano <gitster@pobox.com> wrote:\n> >\n> > Christian Couder <christian.couder@gmail.com> writes:\n>\n> > > +uploadpack.lazyFetchTrusted::\n> > > +     A multi-valued configuration variable, each of which contains the\n> > > +     absolute local path of a repository that `upload-pack` is allowed to\n> > > +     lazily fetch missing objects for.\n> >\n> > \"each of which\" lacks a plural noun to modify.  Perhaps\n> >\n> >         each value of which specifies the absolute local path of a\n>\n> Yeah, \"each value of which specifies\" is used in the v5 I just sent.\n>\n> >         repository from which upload-pack is allowed to lazily fetch\n>\n> \"from which\" would not be quite right, because the client would lazily\n> fetch from the promisor remotes of this server repo (using the\n> \"promisor-remote\" capability), not directly from this repo. So the\n> rest of the sentence hasn't changed in v5.\n\nActually \"from which\" would not be quite right, but not for the reason\nI just gave. Sorry. It is not about the client, nor about the\n\"promisor-remote\" capability.\n\n\"uploadpack.lazyFetchTrusted\" controls server-side lazy fetching. So\nwhen a repo is listed in that config option, the server's\n`upload-pack` (via `pack-objects`), while serving that repo, lazily\nfetches missing objects _for_ that repo (not from it).\n\n> >         missing objects.\n"},{"id":"554189","messageId":"xmqqo6d8ma4l.fsf@gitster.g","threadId":"65969","inReplyTo":"20261002082322.2682869-1-christian.couder@gmail.com","subject":"Re: [PATCH v5 0/5] Introduce 'uploadpack.lazyFetchTrusted'","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-10-05T15:37:30Z","receivedAt":"2026-10-05T15:37:30Z","isPatch":true,"body":"Christian Couder <christian.couder@gmail.com> writes:\n\n> Changes since v4\n> ================\n>\n> Thanks to Junio for reviewing previous versions of this series.\n>\n> Rebased on top of a018953688 (Git 2.56, 2026-09-27) to be on a stable\n> base.\n>\n> There are no functional code changes compared to v4. Only code\n> comments, documentation, tests and commit messages have changed, and\n> those changes are relatively small.\n>\n>  - In patch 2/5, a NEEDSWORK code comment has been added to say that\n>    we may want to warn in case of a missing path unless that path is\n>    marked with an \":(optional)\" prefix. Also the commit message\n>    now mentions that NEEDSWORK code comment.\n\n\nI was hoping to see more substantial reviews from others (compared\nto my rather nitpicky review on v4), but nobody has bitten yet.  Shall\nwe declare that we have reached the point of diminishing returns and\nmark the topic for 'next'?\n\nThanks.\n\n"}]}