promisor-remote: auto-configure unknown remotes

Previous commits have introduced the `promisor.acceptFromServerUrl` config variable to allowlist some URLs advertised by a server through the "promisor-remote" protocol capability. However the new `promisor.acceptFromServerUrl` mechanism, like the old `promisor.acceptFromServer` mechanism, still requires a remote to already exist in the client's local configuration before it can be accepted. This places a significant manual burden on users to pre-configure these remotes, and creates friction for administrators who have to troubleshoot or manually provision these setups for their teams. To eliminate this burden, let's automatically create a new `[remote]` section in the client's config when a server advertises an unknown remote whose URL matches a `promisor.acceptFromServerUrl` glob pattern. Concretely, let's add four helpers: - sanitize_remote_name(): turn an arbitrary URL-derived string into a valid remote name by replacing non-alphanumeric characters, collapsing runs of '-', and prepending "promisor-auto-". - promisor_remote_name_from_url(): normalize the URL and extract host+port+path to build a human-readable base name, then pass it through sanitize_remote_name(). - configure_auto_promisor_remote(): write the remote.*.url, remote.*.promisor and remote.*.advertisedAs keys to the repo config. - handle_matching_allowed_url(): pick the final name (user-supplied alias or auto-generated), handle collisions by appending "-1", "-2", etc., then call configure_auto_promisor_remote(). Let's also add should_accept_new_remote_url() which reuses the url_matches_accept_list() helper introduced in a previous commit to find a matching pattern, then delegates to handle_matching_allowed_url() to create the remote. And then let's call should_accept_new_remote_url() from the '!item' (unknown remote) branch of should_accept_remote(), setting `reload_config` so that the newly-written config is picked up. Finally let's document all that by: - expanding the `promisor.acceptFromServerUrl` entry to describe auto-creation, the optional "name=" prefix syntax, the "promisor-auto-*" generation rules, and numeric-suffix collision handling, and by - adding a "remote.<name>.advertisedAs" entry to "remote.adoc". Also let's extend the precedence paragraph added by a previous commit to mention this new acceptance path: until now, the only way for `promisor.acceptFromServerUrl` to trigger acceptance was to allow field updates for a known remote. With this commit, it can also trigger auto-creation of a previously-unknown remote whose advertised URL matches the allowlist. Signed-off-by: Christian Couder <chriscool@tuxfamily.org> Signed-off-by: Junio C Hamano <gitster@pobox.com>

Christian Couder committed May 27, 2026 at 16:08 UTC 7a56394fc6c28572925b00c4fe3b1ff78b5f4322
4 files changed +340 -13
Documentation/config/promisor.adoc
+29 -10
@@ -54,7 +54,8 @@ promisor.acceptFromServer::
54 promisor.acceptFromServerUrl::
55 A glob pattern to specify which server-advertised URLs a
56 client is allowed to act on. When a URL matches, the client
57 - will accept the advertised remote as a promisor remote and may
57 + will accept the advertised remote as a promisor remote, may
58 + automatically create a new remote configuration for it and may
59 automatically accept field updates (such as authentication
60 tokens) from the server, even if `promisor.acceptFromServer`
61 is set to `none` (the default).
@@ -65,12 +66,13 @@ this option in _ANY_ config file read by Git.
66 +
67 When both `promisor.acceptFromServer` and `promisor.acceptFromServerUrl`
68 are set, `promisor.acceptFromServerUrl` is consulted first and takes
68 -precedence: if a matching pattern leads to acceptance (by accepting
69 -field updates for a known remote whose URL matches both the local
70 -configuration and the allowlist), the advertised remote is accepted
71 -regardless of the `promisor.acceptFromServer` setting. If no pattern
72 -in `promisor.acceptFromServerUrl` triggers acceptance, the decision
73 -is left to `promisor.acceptFromServer`.
69 +precedence: if a matching pattern leads to acceptance (either by
70 +auto-configuring an unknown remote or by accepting field updates for
71 +a known remote whose URL matches both the local configuration and the
72 +allowlist), the advertised remote is accepted regardless of the
73 +`promisor.acceptFromServer` setting. If no pattern in
74 +`promisor.acceptFromServerUrl` triggers acceptance, the decision is
75 +left to `promisor.acceptFromServer`.
76 +
77 Note however that, even when an advertised URL matches a pattern in
78 `promisor.acceptFromServerUrl`, an already-existing remote on the
@@ -85,9 +87,10 @@ documentation of that option.)
87 Be _VERY_ careful with these patterns: `*` matches any sequence of
88 characters within the 'host' and 'path' parts of a URL (but cannot
89 cross part boundaries). An overly broad pattern is a major security
88 -risk, as a matching URL allows a server to update fields (such as
89 -authentication tokens) on known remotes without further confirmation.
90 -To minimize security risks, follow these guidelines:
90 +risk, as a matching URL allows a server to auto-configure new remotes
91 +and to update fields (such as authentication tokens) on known remotes
92 +without further confirmation. To minimize security risks, follow these
93 +guidelines:
94 +
95 --
96 1. Start with a secure protocol scheme, like `https://` or `ssh://`.
@@ -123,6 +126,22 @@ ignored during matching. Note that embedding credentials in URLs is
126 discouraged. Passing authentication tokens via the `token` field of
127 the `promisor-remote` capability is strongly preferred.
128 +
129 +The glob pattern can optionally be prefixed with a remote name and an
130 +equals sign (e.g., `cdn=https://cdn.example.com/*`). If such a prefix
131 +is provided, accepted remotes will be saved under that name. If no
132 +such prefix is provided, a safe remote name will be automatically
133 +generated by sanitizing the URL and prefixing it with
134 +`promisor-auto-`.
135 ++
136 +If a remote with the chosen name already exists but points to a
137 +different URL, Git will append a numeric suffix (e.g., `-1`, `-2`) to
138 +the name to prevent overwriting existing configurations. You should
139 +make sure that this doesn't happen often though, as remotes will be
140 +rejected if the numeric suffix increases too much. In all cases, the
141 +original name advertised by the server is recorded in the
142 +`remote.<name>.advertisedAs` configuration variable for tracing and
143 +debugging purposes.
144 ++
145 For the security implications of accepting a promisor remote, see the
146 documentation of `promisor.acceptFromServer`. For details on the
147 protocol, see linkgit:gitprotocol-v2[5].
Documentation/config/remote.adoc
+9
@@ -91,6 +91,15 @@ remote.<name>.promisor::
91 When set to true, this remote will be used to fetch promisor
92 objects.
93
94 +remote.<name>.advertisedAs::
95 + When a promisor remote is automatically configured using
96 + information advertised by a server through the
97 + `promisor-remote` protocol capability (see
98 + `promisor.acceptFromServerUrl`), the server's originally
99 + advertised name is saved in this variable. This is for
100 + information, tracing and debugging purposes. Users should not
101 + typically modify or create such configuration entries.
102 +
103 remote.<name>.partialclonefilter::
104 The filter that will be applied when fetching from this promisor remote.
105 Changing or clearing this value will only affect fetches for new commits.
promisor-remote.c
+198 -3
@@ -813,10 +813,197 @@ static struct allowed_url *url_matches_accept_list(
813 return NULL;
814 }
815
816 -static int should_accept_remote(enum accept_promisor accept,
816 +/*
817 + * Sanitize the buffer to make it a valid remote name coming from the
818 + * server by:
819 + *
820 + * - replacing any non alphanumeric character with a '-'
821 + * - stripping any leading '-',
822 + * - condensing multiple '-' into one,
823 + * - prepending "promisor-auto-",
824 + * - validating the result.
825 + */
826 +static int sanitize_remote_name(struct strbuf *buf, const char *url)
827 +{
828 + char prev = '-';
829 + for (size_t i = 0; i < buf->len; ) {
830 + if (!isalnum(buf->buf[i]))
831 + buf->buf[i] = '-';
832 + if (prev == '-' && buf->buf[i] == '-') {
833 + strbuf_remove(buf, i, 1);
834 + } else {
835 + prev = buf->buf[i];
836 + i++;
837 + }
838 + }
839 +
840 + strbuf_strip_suffix(buf, "-");
841 +
842 + if (!buf->len) {
843 + warning(_("couldn't generate a valid remote name from "
844 + "advertised url '%s', ignoring this remote"), url);
845 + return -1;
846 + }
847 +
848 + strbuf_insertstr(buf, 0, "promisor-auto-");
849 +
850 + if (!valid_remote_name(buf->buf)) {
851 + warning(_("generated remote name '%s' from advertised url '%s' "
852 + "is invalid, ignoring this remote"), buf->buf, url);
853 + return -1;
854 + }
855 +
856 + return 0;
857 +}
858 +
859 +static char *promisor_remote_name_from_url(const char *url)
860 +{
861 + struct url_info url_info = { 0 };
862 + char *normalized = url_normalize(url, &url_info);
863 + struct strbuf buf = STRBUF_INIT;
864 +
865 + if (!normalized) {
866 + warning(_("couldn't normalize advertised url '%s', "
867 + "ignoring this remote"), url);
868 + return NULL;
869 + }
870 +
871 + if (url_info.host_len) {
872 + strbuf_add(&buf, normalized + url_info.host_off, url_info.host_len);
873 + strbuf_addch(&buf, '-');
874 + }
875 +
876 + if (url_info.port_len) {
877 + strbuf_add(&buf, normalized + url_info.port_off, url_info.port_len);
878 + strbuf_addch(&buf, '-');
879 + }
880 +
881 + if (url_info.path_len) {
882 + strbuf_add(&buf, normalized + url_info.path_off, url_info.path_len);
883 + strbuf_trim_trailing_dir_sep(&buf);
884 + strbuf_strip_suffix(&buf, ".git");
885 + }
886 +
887 + free(normalized);
888 +
889 + if (sanitize_remote_name(&buf, url)) {
890 + strbuf_release(&buf);
891 + return NULL;
892 + }
893 +
894 + return strbuf_detach(&buf, NULL);
895 +}
896 +
897 +static void configure_auto_promisor_remote(struct repository *repo,
898 + const char *name,
899 + const char *url,
900 + const char *advertised_as,
901 + bool reuse)
902 +{
903 + char *key;
904 +
905 + if (!reuse) {
906 + fprintf(stderr, _("Auto-creating promisor remote '%s' for URL '%s'\n"),
907 + name, url);
908 +
909 + key = xstrfmt("remote.%s.url", name);
910 + repo_config_set_gently(repo, key, url);
911 + free(key);
912 + }
913 +
914 + /* NB: when reusing, this promotes an existing non-promisor remote */
915 + key = xstrfmt("remote.%s.promisor", name);
916 + repo_config_set_gently(repo, key, "true");
917 + free(key);
918 +
919 + if (advertised_as) {
920 + key = xstrfmt("remote.%s.advertisedAs", name);
921 + repo_config_set_gently(repo, key, advertised_as);
922 + free(key);
923 + }
924 +}
925 +
926 +#define MAX_REMOTES_WITH_SIMILAR_NAMES 20
927 +
928 +/* Return the allocated local name, or NULL on failure */
929 +static char *handle_matching_allowed_url(struct repository *repo,
930 + char *allowed_name,
931 + const char *remote_url,
932 + const char *remote_name)
933 +{
934 + char *name;
935 + char *basename = allowed_name ?
936 + xstrdup(allowed_name) :
937 + promisor_remote_name_from_url(remote_url);
938 + int i = 0;
939 + bool reuse = false;
940 +
941 + if (!basename)
942 + return NULL;
943 +
944 + name = xstrdup(basename);
945 +
946 + while (i < MAX_REMOTES_WITH_SIMILAR_NAMES) {
947 + char *url_key = xstrfmt("remote.%s.url", name);
948 + const char *existing_url;
949 + int exists = !repo_config_get_string_tmp(repo, url_key, &existing_url);
950 +
951 + free(url_key);
952 +
953 + if (!exists)
954 + break; /* Free to use */
955 +
956 + if (!strcmp(existing_url, remote_url)) {
957 + reuse = true;
958 + break; /* Same URL, so safe to reuse */
959 + }
960 +
961 + i++;
962 + free(name);
963 + name = xstrfmt("%s-%d", basename, i);
964 + }
965 +
966 + if (i < MAX_REMOTES_WITH_SIMILAR_NAMES) {
967 + configure_auto_promisor_remote(repo, name,
968 + remote_url, remote_name,
969 + reuse);
970 + } else {
971 + warning(_("too many remotes accepted with name like '%s-X', "
972 + "ignoring this remote"), basename);
973 + FREE_AND_NULL(name);
974 + }
975 +
976 + free(basename);
977 + return name;
978 +}
979 +
980 +static int should_accept_new_remote_url(struct repository *repo,
981 + struct string_list *accept_urls,
982 + struct promisor_info *advertised)
983 +{
984 + struct allowed_url *allowed = url_matches_accept_list(accept_urls,
985 + advertised->url);
986 + if (allowed) {
987 + char *name = handle_matching_allowed_url(repo,
988 + allowed->remote_name,
989 + advertised->url,
990 + advertised->name);
991 + if (name) {
992 + free((char *)advertised->local_name);
993 + advertised->local_name = name;
994 + return 1;
995 + }
996 + }
997 +
998 + return 0;
999 +}
1000 +
1001 +static int should_accept_remote(struct repository *repo,
1002 + enum accept_promisor accept,
1003 struct promisor_info *advertised,
1004 struct string_list *accept_urls,
819 - struct string_list *config_info)
1005 + struct string_list *config_info,
1006 + bool *reload_config)
1007 {
1008 struct promisor_info *p;
1009 struct string_list_item *item;
@@ -833,6 +1020,13 @@ static int should_accept_remote(enum accept_promisor accept,
1020
1021 if (!item) {
1022 /* We don't know about that remote */
1023 +
1024 + int res = should_accept_new_remote_url(repo, accept_urls, advertised);
1025 + if (res) {
1026 + *reload_config = true;
1027 + return res;
1028 + }
1029 +
1030 if (accept == ACCEPT_ALL)
1031 return all_fields_match(advertised, config_info, NULL);
1032 return 0;
@@ -1093,7 +1287,8 @@ static void filter_promisor_remote(struct repository *repo,
1287 string_list_sort(&config_info);
1288 }
1289
1096 - if (should_accept_remote(accept, advertised, &accept_urls, &config_info)) {
1290 + if (should_accept_remote(repo, accept, advertised, &accept_urls,
1291 + &config_info, &reload_config)) {
1292 if (!store_info)
1293 store_info = store_info_new(repo);
1294 if (promisor_store_advertised_fields(advertised, store_info))
t/t5710-promisor-remote-capability.sh
+104
@@ -458,6 +458,107 @@ test_expect_success "clone with 'None', URL allowlisted, but client has differen
458 initialize_server 1 "$oid"
459 '
460
461 +test_expect_success "clone with URL allowlisted and no remote already configured" '
462 + git -C server config promisor.advertise true &&
463 + test_when_finished "rm -rf client" &&
464 + test_when_finished "rm -f full_names" &&
465 +
466 + GIT_NO_LAZY_FETCH=0 git clone \
467 + -c promisor.acceptfromserver=None \
468 + -c promisor.acceptFromServerUrl="$ENCODED_TRASH_DIRECTORY_URL/*" \
469 + --no-local --filter="blob:limit=5k" server client &&
470 +
471 + # Check that exactly one remote has been auto-created, identified
472 + # by "remote.<name>.advertisedAs" == "lop".
473 + git -C client config get --all --show-names --regexp \
474 + "remote\..*\.advertisedas" >full_names &&
475 + test_line_count = 1 full_names &&
476 + REMOTE_NAME=$(sed "s/^remote\.\(.*\)\.advertisedas .*$/\1/" full_names) &&
477 +
478 + # Check ".url" and ".promisor" values
479 + printf "%s\n" "$TRASH_DIRECTORY_URL/lop" "true" >expect &&
480 + git -C client config "remote.$REMOTE_NAME.url" >actual &&
481 + git -C client config "remote.$REMOTE_NAME.promisor" >>actual &&
482 + test_cmp expect actual &&
483 +
484 + # Check that the largest object is still missing on the server
485 + check_missing_objects server 1 "$oid"
486 +'
487 +
488 +test_expect_success "clone with named URL allowlisted and no pre-configured remote" '
489 + git -C server config promisor.advertise true &&
490 + test_when_finished "rm -rf client" &&
491 +
492 + GIT_NO_LAZY_FETCH=0 git clone \
493 + -c promisor.acceptfromserver=None \
494 + -c promisor.acceptFromServerUrl="cdn=$ENCODED_TRASH_DIRECTORY_URL/*" \
495 + --no-local --filter="blob:limit=5k" server client &&
496 +
497 + # Check that a remote has been auto-created with the right "cdn" name and fields.
498 + printf "%s\n" "$TRASH_DIRECTORY_URL/lop" "true" "lop" >expect &&
499 + git -C client config "remote.cdn.url" >actual &&
500 + git -C client config "remote.cdn.promisor" >>actual &&
501 + git -C client config "remote.cdn.advertisedAs" >>actual &&
502 + test_cmp expect actual &&
503 +
504 + # Check that the largest object is still missing on the server
505 + check_missing_objects server 1 "$oid"
506 +'
507 +
508 +test_expect_success "clone with URL allowlisted but colliding name" '
509 + git -C server config promisor.advertise true &&
510 + test_when_finished "rm -rf client" &&
511 +
512 + GIT_NO_LAZY_FETCH=0 git clone -c remote.cdn.promisor=true \
513 + -c remote.cdn.fetch="+refs/heads/*:refs/remotes/lop/*" \
514 + -c remote.cdn.url="https://example.com/cdn" \
515 + -c promisor.acceptfromserver=None \
516 + -c promisor.acceptFromServerUrl="cdn=$ENCODED_TRASH_DIRECTORY_URL/*" \
517 + --no-local --filter="blob:limit=5k" server client &&
518 +
519 + # Check that a remote has been auto-created with the right "cdn-1" name and fields.
520 + printf "%s\n" "$TRASH_DIRECTORY_URL/lop" "true" "lop" >expect &&
521 + git -C client config "remote.cdn-1.url" >actual &&
522 + git -C client config "remote.cdn-1.promisor" >>actual &&
523 + git -C client config "remote.cdn-1.advertisedAs" >>actual &&
524 + test_cmp expect actual &&
525 +
526 + # Check that the original "cdn" remote was not overwritten.
527 + printf "%s\n" "https://example.com/cdn" "true" >expect &&
528 + git -C client config "remote.cdn.url" >actual &&
529 + git -C client config "remote.cdn.promisor" >>actual &&
530 + test_cmp expect actual &&
531 +
532 + # Check that the largest object is still missing on the server
533 + check_missing_objects server 1 "$oid"
534 +'
535 +
536 +test_expect_success "clone with URL allowlisted and reusable remote" '
537 + git -C server config promisor.advertise true &&
538 + test_when_finished "rm -rf client" &&
539 +
540 + GIT_NO_LAZY_FETCH=0 git clone \
541 + -c remote.cdn.fetch="+refs/heads/*:refs/remotes/lop/*" \
542 + -c remote.cdn.url="$TRASH_DIRECTORY_URL/lop" \
543 + -c promisor.acceptfromserver=None \
544 + -c promisor.acceptFromServerUrl="cdn=$ENCODED_TRASH_DIRECTORY_URL/*" \
545 + --no-local --filter="blob:limit=5k" server client &&
546 +
547 + # Check that the existing "cdn" remote has been properly updated.
548 + printf "%s\n" "$TRASH_DIRECTORY_URL/lop" "true" "lop" "+refs/heads/*:refs/remotes/lop/*" >expect &&
549 + git -C client config "remote.cdn.url" >actual &&
550 + git -C client config "remote.cdn.promisor" >>actual &&
551 + git -C client config "remote.cdn.advertisedAs" >>actual &&
552 + git -C client config "remote.cdn.fetch" >>actual &&
553 + test_cmp expect actual &&
554 +
555 + # Check that no new "cdn-1" remote has been created.
556 + test_must_fail git -C client config "remote.cdn-1.url" &&
557 +
558 + # Check that the largest object is still missing on the server
559 + check_missing_objects server 1 "$oid"
560 +'
561 +
562 test_expect_success "clone with invalid promisor.acceptFromServerUrl" '
563 git -C server config promisor.advertise true &&
564 test_when_finished "rm -rf client" &&
@@ -472,6 +573,9 @@ test_expect_success "clone with invalid promisor.acceptFromServerUrl" '
573 # Check that a warning was emitted
574 test_grep "invalid remote name '\''bad name'\''" err &&
575
576 + # Check that no remote was auto-created
577 + test_must_fail git -C client config get --regexp "remote\..*\.advertisedas" &&
578 +
579 # Check that the largest object is not missing on the server
580 check_missing_objects server 0 "" &&
581