promisor-remote: allow a client to check fields

A previous commit allowed a server to pass additional fields through
the "promisor-remote" protocol capability after the "name" and "url"
fields, specifically the "partialCloneFilter" and "token" fields.

Let's make it possible for a client to check if these fields match
what it expects before accepting a promisor remote.

We allow this by introducing a new "promisor.checkFields"
configuration variable. It should contain a comma or space separated
list of fields that will be checked.

By limiting the protocol to specific well-defined fields, we ensure
both server and client have a shared understanding of field
semantics and usage.

Signed-off-by: Christian Couder <chriscool@tuxfamily.org>
Signed-off-by: Junio C Hamano <gitster@pobox.com>
main
Christian Couder 2025-09-08 07:30:52 +02:00 committed by Junio C Hamano
parent bcb08c8375
commit c213820c51
3 changed files with 154 additions and 8 deletions

View File

@ -50,3 +50,42 @@ promisor.acceptFromServer::
lazily fetchable from this promisor remote from its responses
to "fetch" and "clone" requests from the client. Name and URL
comparisons are case sensitive. See linkgit:gitprotocol-v2[5].

promisor.checkFields::
A comma or space separated list of additional remote related
field names. A client checks if the values of these fields
transmitted by a server correspond to the values of these
fields in its own configuration before accepting a promisor
remote. Currently, "partialCloneFilter" and "token" are the
only supported field names.
+
If one of these field names (e.g., "token") is being checked for an
advertised promisor remote (e.g., "foo"), three conditions must be met
for the check of this specific field to pass:
+
1. The corresponding local configuration (e.g., `remote.foo.token`)
must be set.
2. The server must advertise the "token" field for remote "foo".
3. The value of the locally configured `remote.foo.token` must exactly
match the value advertised by the server for the "token" field.
+
If any of these conditions is not met for any field name listed in
`promisor.checkFields`, the advertised remote "foo" is rejected.
+
For the "partialCloneFilter" field, this allows the client to ensure
that the server's filter matches what it expects locally, preventing
inconsistencies in filtering behavior. For the "token" field, this can
be used to verify that authentication credentials match expected
values.
+
Field values are compared case-sensitively.
+
The "name" and "url" fields are always checked according to the
`promisor.acceptFromServer` policy, independently of this setting.
+
The field names and values should be passed by the server through the
"promisor-remote" capability by using the `promisor.sendFields` config
variable. The fields are checked only if the
`promisor.acceptFromServer` config variable is not set to "None". If
set to "None", this config variable has no effect. See
linkgit:gitprotocol-v2[5].

View File

@ -389,6 +389,20 @@ static struct string_list *fields_sent(void)
return &fields_list;
}

static struct string_list *fields_checked(void)
{
static struct string_list fields_list = STRING_LIST_INIT_NODUP;
static int initialized;

if (!initialized) {
fields_list.cmp = strcasecmp;
fields_from_config(&fields_list, "promisor.checkFields");
initialized = 1;
}

return &fields_list;
}

/*
* Struct for promisor remotes involved in the "promisor-remote"
* protocol capability.
@ -534,6 +548,61 @@ enum accept_promisor {
ACCEPT_ALL
};

static int match_field_against_config(const char *field, const char *value,
struct promisor_info *config_info)
{
if (config_info->filter && !strcasecmp(field, promisor_field_filter))
return !strcmp(config_info->filter, value);
else if (config_info->token && !strcasecmp(field, promisor_field_token))
return !strcmp(config_info->token, value);

return 0;
}

static int all_fields_match(struct promisor_info *advertised,
struct string_list *config_info,
int in_list)
{
struct string_list *fields = fields_checked();
struct string_list_item *item_checked;

for_each_string_list_item(item_checked, fields) {
int match = 0;
const char *field = item_checked->string;
const char *value = NULL;
struct string_list_item *item;

if (!strcasecmp(field, promisor_field_filter))
value = advertised->filter;
else if (!strcasecmp(field, promisor_field_token))
value = advertised->token;

if (!value)
return 0;

if (in_list) {
for_each_string_list_item(item, config_info) {
struct promisor_info *p = item->util;
if (match_field_against_config(field, value, p)) {
match = 1;
break;
}
}
} else {
item = string_list_lookup(config_info, advertised->name);
if (item) {
struct promisor_info *p = item->util;
match = match_field_against_config(field, value, p);
}
}

if (!match)
return 0;
}

return 1;
}

static int should_accept_remote(enum accept_promisor accept,
struct promisor_info *advertised,
struct string_list *config_info)
@ -544,7 +613,7 @@ static int should_accept_remote(enum accept_promisor accept,
const char *remote_url = advertised->url;

if (accept == ACCEPT_ALL)
return 1;
return all_fields_match(advertised, config_info, 1);

/* Get config info for that promisor remote */
item = string_list_lookup(config_info, remote_name);
@ -556,7 +625,7 @@ static int should_accept_remote(enum accept_promisor accept,
p = item->util;

if (accept == ACCEPT_KNOWN_NAME)
return 1;
return all_fields_match(advertised, config_info, 0);

if (accept != ACCEPT_KNOWN_URL)
BUG("Unhandled 'enum accept_promisor' value '%d'", accept);
@ -567,7 +636,7 @@ static int should_accept_remote(enum accept_promisor accept,
}

if (!strcmp(p->url, remote_url))
return 1;
return all_fields_match(advertised, config_info, 0);

warning(_("known remote named '%s' but with URL '%s' instead of '%s'"),
remote_name, p->url, remote_url);
@ -605,6 +674,10 @@ static struct promisor_info *parse_one_advertised_remote(const char *remote_info
info->name = url_percent_decode(p);
else if (skip_field_name_prefix(elem, promisor_field_url, &p))
info->url = url_percent_decode(p);
else if (skip_field_name_prefix(elem, promisor_field_filter, &p))
info->filter = url_percent_decode(p);
else if (skip_field_name_prefix(elem, promisor_field_token, &p))
info->token = url_percent_decode(p);
}

string_list_clear(&elem_list, 0);
@ -646,11 +719,6 @@ static void filter_promisor_remote(struct repository *repo,
if (accept == ACCEPT_NONE)
return;

if (accept != ACCEPT_ALL) {
promisor_config_info_list(repo, &config_info, NULL);
string_list_sort(&config_info);
}

/* Parse remote info received */

string_list_split(&remote_info, info, ";", -1);
@ -663,6 +731,11 @@ static void filter_promisor_remote(struct repository *repo,
if (!advertised)
continue;

if (!config_info.nr) {
promisor_config_info_list(repo, &config_info, fields_checked());
string_list_sort(&config_info);
}

if (should_accept_remote(accept, advertised, &config_info))
strvec_push(accepted, advertised->name);


View File

@ -326,6 +326,40 @@ test_expect_success "clone with promisor.sendFields" '
check_missing_objects server 1 "$oid"
'

test_expect_success "clone with promisor.checkFields" '
git -C server config promisor.advertise true &&
test_when_finished "rm -rf client" &&

git -C server remote add otherLop "https://invalid.invalid" &&
git -C server config remote.otherLop.token "fooBar" &&
git -C server config remote.otherLop.stuff "baz" &&
git -C server config remote.otherLop.partialCloneFilter "blob:limit=10k" &&
test_when_finished "git -C server remote remove otherLop" &&
test_config -C server promisor.sendFields "partialCloneFilter, token" &&
test_when_finished "rm trace" &&

# Clone from server to create a client
GIT_TRACE_PACKET="$(pwd)/trace" GIT_NO_LAZY_FETCH=0 git clone \
-c remote.lop.promisor=true \
-c remote.lop.fetch="+refs/heads/*:refs/remotes/lop/*" \
-c remote.lop.url="file://$(pwd)/lop" \
-c remote.lop.partialCloneFilter="blob:none" \
-c promisor.acceptfromserver=All \
-c promisor.checkFields=partialcloneFilter \
--no-local --filter="blob:limit=5k" server client &&

# Check that fields are properly transmitted
ENCODED_URL=$(echo "file://$(pwd)/lop" | sed -e "s/ /%20/g") &&
PR1="name=lop,url=$ENCODED_URL,partialCloneFilter=blob:none" &&
PR2="name=otherLop,url=https://invalid.invalid,partialCloneFilter=blob:limit=10k,token=fooBar" &&
test_grep "clone< promisor-remote=$PR1;$PR2" trace &&
test_grep "clone> promisor-remote=lop" trace &&
test_grep ! "clone> promisor-remote=lop;otherLop" trace &&

# Check that the largest object is still missing on the server
check_missing_objects server 1 "$oid"
'

test_expect_success "clone with promisor.advertise set to 'true' but don't delete the client" '
git -C server config promisor.advertise true &&