diff --git a/src/shared/identity.c b/src/shared/identity.c index 1f717d5..31b205b 100644 --- a/src/shared/identity.c +++ b/src/shared/identity.c @@ -3,6 +3,7 @@ #include "utils.h" #include #include +#include #include #include #include @@ -12,6 +13,8 @@ #include #include +static bool identity_id_fits_int32(unsigned long id); + /* The active identity snapshot lives in a per-process global. The TCP server * forks one child process per connection, so a connection never shares this * with another; within a connection the multithreaded receiver reads it without @@ -419,17 +422,10 @@ static int identity_parse_from(const char* token, bool is_group, int32_t* out_fr /* Not a numeric LOW-HIGH range: fall through and treat as a name (a * hyphenated account name like "wayne-smith" must still resolve). */ } - /* A sender-side name. A wildcard other than the bare '*' is matched by rsync - * against the sender's names; because FastSync transmits numeric ids only, the - * receiver cannot evaluate it, so reject rather than silently mis-match. */ - if (identity_token_has_glob(token)) { - log_message(LOG_LEVEL_ERROR, - "%smap FROM '%s': name wildcards other than '*' are not supported " - "(FastSync transmits numeric ids, so sender names are unavailable on the " - "receiver)", - is_group ? "--group" : "--user", token); - return -1; - } + /* A sender-side name. A FROM name wildcard other than the bare '*' is handled + * by identity_expand_from_glob() in the caller (it expands against the + * sender's account database at CLI-parse time), so this function only sees the + * bare '*' or a literal name here. */ int32_t id; if (identity_resolve_token(token, is_group, &id) != 0) return -1; @@ -486,6 +482,138 @@ static int identity_append_rule(IdentityMap** map, int* count, const IdentityMap return 0; } +/* True when `lo` and `hi` are adjacent ids (no overflow at INT32_MAX). */ +static bool identity_ids_adjacent(int32_t lo, int32_t hi) { + return lo < INT32_MAX && hi == lo + 1; +} + +static int identity_id_cmp(const void* a, const void* b) { + int32_t x = *(const int32_t*)a; + int32_t y = *(const int32_t*)b; + return (x > y) - (x < y); +} + +static bool identity_ids_push(int32_t** ids, size_t* count, size_t* cap, int32_t id) { + if (*count == *cap) { + size_t grown_cap = *cap ? *cap * 2 : 16; + int32_t* grown = realloc(*ids, grown_cap * sizeof(int32_t)); + if (!grown) + return false; + *ids = grown; + *cap = grown_cap; + } + (*ids)[(*count)++] = id; + return true; +} + +/* Expand a FROM name wildcard (rsync's match against sender-side account names) + * into one rule per contiguous run of matching numeric ids, all sharing the same + * TO side. FastSync transmits numeric ids only, so the wildcard must be + * resolved here -- at CLI-parse time -- against the SENDER's passwd/group + * database; the receiver has no sender names to match. Contiguous matched ids + * are collapsed into a single LOW-HIGH range (a range of adjacent ids contains + * exactly the ids it spans, so this is semantically exact). Returns 0 on + * success, -1 on an allocation failure, a wildcard that matches no sender + * account, or an expansion that would push the map past MAX_IDENTITY_MAP. */ +static int identity_expand_from_glob(Config* config, const char* glob, bool is_group, + const IdentityMap* to_rule) { + const char* optname = is_group ? "--groupmap" : "--usermap"; + size_t cap = 0; + size_t n = 0; + int32_t* ids = NULL; + bool alloc_failed = false; + + if (is_group) { + setgrent(); + struct group* gr; + while ((gr = getgrent()) != NULL) { + if (fnmatch(glob, gr->gr_name, 0) != 0) + continue; + if (!identity_id_fits_int32((unsigned long)gr->gr_gid)) + continue; + if (!identity_ids_push(&ids, &n, &cap, (int32_t)gr->gr_gid)) { + alloc_failed = true; + break; + } + } + endgrent(); + } else { + setpwent(); + struct passwd* pw; + while ((pw = getpwent()) != NULL) { + if (fnmatch(glob, pw->pw_name, 0) != 0) + continue; + if (!identity_id_fits_int32((unsigned long)pw->pw_uid)) + continue; + if (!identity_ids_push(&ids, &n, &cap, (int32_t)pw->pw_uid)) { + alloc_failed = true; + break; + } + } + endpwent(); + } + + if (alloc_failed) { + free(ids); + log_message(LOG_LEVEL_ERROR, "%s: memory allocation failed expanding FROM '%s'", optname, glob); + return -1; + } + if (n == 0) { + free(ids); + log_message(LOG_LEVEL_ERROR, "%s FROM '%s': no source account name matches the wildcard", + optname, glob); + return -1; + } + + qsort(ids, n, sizeof(int32_t), identity_id_cmp); + size_t unique = 0; + for (size_t i = 0; i < n; i++) { + if (unique == 0 || ids[unique - 1] != ids[i]) + ids[unique++] = ids[i]; + } + n = unique; + + int runs = 0; + for (size_t i = 0; i < n; i++) { + if (i == 0 || !identity_ids_adjacent(ids[i - 1], ids[i])) + runs++; + } + + IdentityMap** map = is_group ? &config->groupmap : &config->usermap; + int* count = is_group ? &config->groupmap_count : &config->usermap_count; + if (*count > MAX_IDENTITY_MAP - runs) { + log_message(LOG_LEVEL_ERROR, + "%s FROM '%s': the name wildcard expands to %d rule(s), which would exceed " + "the maximum of %d map rules", + optname, glob, runs, MAX_IDENTITY_MAP); + free(ids); + return -1; + } + + for (size_t i = 0; i < n;) { + size_t j = i; + while (j + 1 < n && identity_ids_adjacent(ids[j], ids[j + 1])) + j++; + IdentityMap rule; + rule.from = ids[i]; + rule.from_hi = ids[j]; + rule.to = to_rule->to; + rule.to_name = to_rule->to_name ? str_dup(to_rule->to_name) : NULL; + if (to_rule->to_name && !rule.to_name) { + free(ids); + return -1; + } + if (identity_append_rule(map, count, &rule) != 0) { + free(rule.to_name); + free(ids); + return -1; + } + i = j + 1; + } + free(ids); + return 0; +} + int identity_parse_map(Config* config, const char* value, bool is_group) { if (!config || !value || *value == '\0') { log_message(LOG_LEVEL_ERROR, "%smap requires a value", is_group ? "--group" : "--user"); @@ -509,6 +637,25 @@ int identity_parse_map(Config* config, const char* value, bool is_group) { char* to_token = colon + 1; IdentityMap parsed; memset(&parsed, 0, sizeof(parsed)); + /* A FROM name wildcard (anything with a glob metacharacter other than the + * bare '*') is expanded against the sender's account database here, while + * the sender's passwd/group DB is still available; the resulting numeric + * rules travel on the wire like an explicit list. The TO side is parsed + * first so every expanded rule shares it. */ + if (strcmp(from_token, "*") != 0 && identity_token_has_glob(from_token)) { + if (identity_parse_to(to_token, is_group, &parsed.to, &parsed.to_name) != 0) { + log_message(LOG_LEVEL_ERROR, "%s could not parse TO '%s' in '%s'", optname, to_token, + value); + free(list); + return -1; + } + if (identity_expand_from_glob(config, from_token, is_group, &parsed) != 0) { + free(parsed.to_name); + free(list); + return -1; + } + continue; + } if (identity_parse_from(from_token, is_group, &parsed.from, &parsed.from_hi) != 0) { log_message(LOG_LEVEL_ERROR, "%s could not resolve FROM '%s' in '%s' (a name must exist on the " diff --git a/src/shared/identity.h b/src/shared/identity.h index 327205c..a7548c2 100644 --- a/src/shared/identity.h +++ b/src/shared/identity.h @@ -25,8 +25,14 @@ /* Parse one --usermap= / --groupmap= value (comma-separated FROM:TO rules, * first match wins) into config->usermap / config->groupmap. is_group selects - * the group tables and name databases. Returns 0 on success, -1 on a - * malformed spec or an unresolvable name (never a silent no-op). */ + * the group tables and name databases. A FROM name wildcard (containing `*`, + * `?` or `[...]`, but not the bare `*`) is expanded against the SENDER's + * account database at parse time into one or more numeric id/range rules + * (contiguous ids collapse to a range) sharing the same TO, because only + * numeric ids cross the wire; the expansion is capped at MAX_IDENTITY_MAP and a + * wildcard matching no account is an error. Returns 0 on success, -1 on a + * malformed spec, an unresolvable name, an unmatched wildcard, or a map that + * would exceed MAX_IDENTITY_MAP (never a silent no-op). */ int identity_parse_map(Config* config, const char* value, bool is_group); /* Parse --chown=USER:GROUP. Supports USER:GROUP, USER (owner only), :GROUP diff --git a/tests/test_client_cli.c b/tests/test_client_cli.c index e86aec7..e6c91b8 100644 --- a/tests/test_client_cli.c +++ b/tests/test_client_cli.c @@ -10,6 +10,8 @@ #include "protocol.h" #include "test_utils.h" #include "utils.h" +#include +#include #include #include #include @@ -3537,6 +3539,172 @@ static void test_parse_args_usermap_rsync_forms() { config_delete(cfg); } +/* Independent oracle for the FROM name-glob tests: enumerate the sender's + * account database and fill `ids` with the DISTINCT ids whose name matches + * `glob`, sorted ascending. Returns the count (bounded by `max`). */ +static int cli_collect_glob_ids(const char* glob, bool is_group, int32_t* ids, int max) { + int n = 0; + if (is_group) { + setgrent(); + struct group* gr; + while ((gr = getgrent()) != NULL) { + if (fnmatch(glob, gr->gr_name, 0) != 0) + continue; + if ((unsigned long)gr->gr_gid > (unsigned long)INT32_MAX) + continue; + int32_t id = (int32_t)gr->gr_gid; + bool dup = false; + for (int i = 0; i < n; i++) + if (ids[i] == id) + dup = true; + if (!dup && n < max) + ids[n++] = id; + } + endgrent(); + } else { + setpwent(); + struct passwd* pw; + while ((pw = getpwent()) != NULL) { + if (fnmatch(glob, pw->pw_name, 0) != 0) + continue; + if ((unsigned long)pw->pw_uid > (unsigned long)INT32_MAX) + continue; + int32_t id = (int32_t)pw->pw_uid; + bool dup = false; + for (int i = 0; i < n; i++) + if (ids[i] == id) + dup = true; + if (!dup && n < max) + ids[n++] = id; + } + endpwent(); + } + for (int i = 1; i < n; i++) { + int32_t key = ids[i]; + int j = i - 1; + while (j >= 0 && ids[j] > key) { + ids[j + 1] = ids[j]; + j--; + } + ids[j + 1] = key; + } + return n; +} + +static int cli_count_runs(const int32_t* ids, int n) { + int runs = 0; + for (int i = 0; i < n; i++) { + if (i == 0 || ids[i - 1] == INT32_MAX || ids[i] != ids[i - 1] + 1) + runs++; + } + return runs; +} + +/* #294: a FROM name wildcard must expand, at CLI-parse time, against the + * sender's account database into numeric id/range rules. Prefer a prefix that + * matches >=2 DISTINCT NON-contiguous ids (exercising multi-rule expansion); if + * no such prefix exists on this host, fall back to one whose ids are contiguous + * (exercising range collapse). The expected rules are derived independently by + * enumerating the same database. */ +static void test_parse_args_identity_map_from_name_glob(bool is_group) { + int32_t ids[512]; + int chosen_n = 0; + int chosen_runs = 0; + char chosen_c = 0; + for (char c = 'a'; c <= 'z'; c++) { + const char glob[3] = {c, '*', '\0'}; + int n = cli_collect_glob_ids(glob, is_group, ids, (int)(sizeof(ids) / sizeof(ids[0]))); + if (n < 2) + continue; + int runs = cli_count_runs(ids, n); + if (runs >= 2 || chosen_c == 0) { + chosen_c = c; + chosen_n = n; + chosen_runs = runs; + } + if (runs >= 2) + break; + } + if (chosen_c == 0) + return; /* no multi-match prefix on this host (skipped, not failed) */ + + const char glob[3] = {chosen_c, '*', '\0'}; + chosen_n = cli_collect_glob_ids(glob, is_group, ids, (int)(sizeof(ids) / sizeof(ids[0]))); + chosen_runs = cli_count_runs(ids, chosen_n); + EXPECT_TRUE(chosen_n >= 2); + + char map_value[16]; + snprintf(map_value, sizeof(map_value), "%s:@0", glob); + Config* cfg = config_create(); + char* argv[] = {"fastsync", is_group ? "--groupmap" : "--usermap", map_value, "/src", "/dst"}; + int positional_args[2]; + int positional_count = 0; + EXPECT_EQ_INT(parse_args(cfg, 5, argv, positional_args, &positional_count), 0); + + int got = is_group ? cfg->groupmap_count : cfg->usermap_count; + EXPECT_EQ_INT(got, chosen_runs); + const IdentityMap* map = is_group ? cfg->groupmap : cfg->usermap; + /* Every matched id is covered by some expanded rule. */ + for (int i = 0; i < chosen_n; i++) { + bool covered = false; + for (int r = 0; r < got; r++) + if (ids[i] >= map[r].from && ids[i] <= map[r].from_hi) + covered = true; + EXPECT_TRUE(covered); + } + /* Every id inside every expanded range is one the glob actually matched, so + * the range collapse cannot over-match a name that does not fit the glob. */ + for (int r = 0; r < got; r++) { + EXPECT_EQ_INT(map[r].to, 0); + for (int32_t v = map[r].from; v <= map[r].from_hi; v++) { + bool expected = false; + for (int i = 0; i < chosen_n; i++) + if (ids[i] == v) + expected = true; + EXPECT_TRUE(expected); + if (v == INT32_MAX) + break; + } + } + config_delete(cfg); +} + +static void test_parse_args_usermap_from_name_glob() { + test_parse_args_identity_map_from_name_glob(false); +} + +static void test_parse_args_groupmap_from_name_glob() { + test_parse_args_identity_map_from_name_glob(true); +} + +/* #294: an expansion that would push the map past MAX_IDENTITY_MAP must fail + * with a clear error rather than silently truncating. Prefill the map to the + * cap and then add a wildcard guaranteed to match at least the current user. */ +static void test_parse_args_identity_map_from_name_glob_over_cap() { + const struct passwd* self = getpwuid(geteuid()); + if (!self || self->pw_name[0] == '\0') + return; + char glob[8]; + snprintf(glob, sizeof(glob), "%c*", self->pw_name[0]); + + size_t need = (size_t)MAX_IDENTITY_MAP * 6 + strlen(glob) + 4 + 1; + char* value = malloc(need); + if (!value) + return; + size_t off = 0; + for (int i = 0; i < MAX_IDENTITY_MAP; i++) + off += (size_t)snprintf(value + off, need - off, "@0:@0,"); + snprintf(value + off, need - off, "%s:@0", glob); + + Config* cfg = config_create(); + char* argv[] = {"fastsync", "--usermap", value, "/src", "/dst"}; + int positional_args[2]; + int positional_count = 0; + EXPECT_EQ_INT(parse_args(cfg, 5, argv, positional_args, &positional_count), -1); + config_delete(cfg); + free(value); +} + /* #294: rsync refuses to mix --chown with --usermap/--groupmap on the same * side (either order). --chown=USER conflicts with a prior --usermap; * --chown=:GROUP conflicts with a prior --groupmap; the opposite side is fine. */ @@ -3716,7 +3884,8 @@ static void test_parse_args_rejects_malformed_identity() { {"--usermap", "definitely_not_a_real_user_zzz:@1"}, {"--usermap", "0-"}, {"--usermap", "5-2:@1"}, - {"--usermap", "roo*:@1"}, + {"--usermap", "zzz_definitely_no_such_user_glob_zzz*:@1"}, + {"--groupmap", "zzz_definitely_no_such_group_glob_zzz*:@1"}, {"--groupmap", "@1"}, {"--groupmap", "no_such_group_qqq:x"}, {"--chown", "a:b:c"}, @@ -4882,6 +5051,9 @@ void test_client_cli() { test_parse_args_groupmap(); test_parse_args_usermap_name_resolution(); test_parse_args_usermap_rsync_forms(); + test_parse_args_usermap_from_name_glob(); + test_parse_args_groupmap_from_name_glob(); + test_parse_args_identity_map_from_name_glob_over_cap(); test_parse_args_identity_map_chown_conflict(); test_parse_args_chown(); test_parse_args_copy_as(); diff --git a/tests/test_transport_tcp.c b/tests/test_transport_tcp.c index 825a6c4..6dd09f5 100644 --- a/tests/test_transport_tcp.c +++ b/tests/test_transport_tcp.c @@ -3,11 +3,13 @@ #include "test_utils.h" #include "transport_tcp.h" #include +#include #include #include #include -#include #include +#include +#include /* -4/-6 map to a getaddrinfo ai_family hint: -4 -> AF_INET, -6 -> AF_INET6, * and neither -> AF_UNSPEC. Both flags together are rejected earlier (in @@ -246,6 +248,180 @@ static void test_tcp_nodelay_default_and_override() { server_delete(&s); } +/* Count the process's open descriptors via /proc/self/fd. The opendir + * descriptor is itself counted and closed before returning, so repeated calls + * are consistent and a before/after delta reflects only the code under test. */ +static int count_open_fds(void) { + DIR* dir = opendir("/proc/self/fd"); + if (!dir) + return -1; + int count = 0; + const struct dirent* ent; + while ((ent = readdir(dir)) != NULL) { + if (strcmp(ent->d_name, ".") == 0 || strcmp(ent->d_name, "..") == 0) + continue; + count++; + } + closedir(dir); + return count; +} + +/* Bind + listen on the SECOND address getaddrinfo returns for "localhost", so + * the first candidate is connection-refused and the shared connect loop must + * fall back to a later one. Returns the listener fd and its port, or -1 when + * this host does not resolve localhost to at least two addresses (the test then + * skips rather than claiming coverage it does not have). */ +static int bind_second_localhost_address(int* out_port) { + struct addrinfo hints; + memset(&hints, 0, sizeof(hints)); + hints.ai_family = AF_UNSPEC; + hints.ai_socktype = SOCK_STREAM; + struct addrinfo* res = NULL; + if (getaddrinfo("localhost", "0", &hints, &res) != 0 || !res) + return -1; + const struct addrinfo* chosen = res->ai_next; + if (!chosen) { + freeaddrinfo(res); + return -1; + } + int fd = socket(chosen->ai_family, chosen->ai_socktype, chosen->ai_protocol); + if (fd < 0) { + freeaddrinfo(res); + return -1; + } + int opt = 1; + setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)); + if (bind(fd, chosen->ai_addr, chosen->ai_addrlen) != 0 || listen(fd, 1) != 0) { + close(fd); + freeaddrinfo(res); + return -1; + } + struct sockaddr_storage bound; + socklen_t bound_len = sizeof(bound); + if (getsockname(fd, (struct sockaddr*)&bound, &bound_len) != 0) { + close(fd); + freeaddrinfo(res); + return -1; + } + if (bound.ss_family == AF_INET6) + *out_port = ntohs(((struct sockaddr_in6*)&bound)->sin6_port); + else + *out_port = ntohs(((struct sockaddr_in*)&bound)->sin_port); + freeaddrinfo(res); + return fd; +} + +/* #219 AC3: when the first getaddrinfo candidate is refused, the connect loop + * must fall back to the next address and end with exactly ONE open descriptor + * (proving the failed attempt's fd was closed before the retry). */ +static void test_tcp_connect_falls_back_to_next_address() { + int port = 0; + int listener = bind_second_localhost_address(&port); + if (listener < 0) + return; /* localhost is single-address on this host: cannot exercise fallback */ + int before = count_open_fds(); + Client* c = client_create(); + EXPECT_NOT_NULL(c); + EXPECT_TRUE(client_connect(c, "localhost", port)); + EXPECT_TRUE(c->file_descriptor >= 0); + if (before >= 0) + EXPECT_EQ_INT(count_open_fds(), before + 1); + client_disconnect(c); + if (before >= 0) + EXPECT_EQ_INT(count_open_fds(), before); + client_delete(c); + close(listener); +} + +/* #219 AC3: a connect that fails on every candidate leaves at most one + * descriptor (the last failed attempt) and none after client_disconnect. */ +static void test_tcp_connect_failed_attempts_do_not_leak_fds() { + /* Reserve an ephemeral port, then close it: connecting to it must fail. */ + int probe = socket(AF_INET, SOCK_STREAM, 0); + EXPECT_TRUE(probe >= 0); + struct sockaddr_in addr; + memset(&addr, 0, sizeof(addr)); + addr.sin_family = AF_INET; + addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + addr.sin_port = 0; + EXPECT_EQ_INT(bind(probe, (struct sockaddr*)&addr, sizeof(addr)), 0); + socklen_t addr_len = sizeof(addr); + EXPECT_EQ_INT(getsockname(probe, (struct sockaddr*)&addr, &addr_len), 0); + int port = ntohs(addr.sin_port); + close(probe); + + int before = count_open_fds(); + Client* c = client_create(); + EXPECT_NOT_NULL(c); + EXPECT_FALSE(client_connect(c, "localhost", port)); + if (before >= 0) + EXPECT_TRUE(count_open_fds() <= before + 1); + client_disconnect(c); + if (before >= 0) + EXPECT_EQ_INT(count_open_fds(), before); + client_delete(c); +} + +/* #219 AC3: the shared tcp_connect_socket_ex() (used by both the plain and TLS + * entry points) must install the --contimeout as SO_RCVTIMEO/SO_SNDTIMEO before + * connecting. Calling it directly lets us observe the pre-connect state (the + * plain wrapper later overrides the receive timeout with the IO --timeout). */ +static void test_tcp_connect_socket_ex_applies_contimeout() { + Server* s = server_create(0); + EXPECT_NOT_NULL(s); + EXPECT_EQ_INT(listen(s->file_descriptor, 1), 0); + struct sockaddr_in bound; + socklen_t bound_len = sizeof(bound); + EXPECT_EQ_INT(getsockname(s->file_descriptor, (struct sockaddr*)&bound, &bound_len), 0); + int port = ntohs(bound.sin_port); + + tcp_set_timeouts(30, 7); + Client* c = client_create(); + EXPECT_NOT_NULL(c); + TcpConnectOptions opts; + memset(&opts, 0, sizeof(opts)); + EXPECT_TRUE(tcp_connect_socket_ex(c, "127.0.0.1", port, &opts)); + struct timeval tv; + socklen_t tv_len = sizeof(tv); + EXPECT_EQ_INT(getsockopt(c->file_descriptor, SOL_SOCKET, SO_RCVTIMEO, &tv, &tv_len), 0); + EXPECT_EQ_INT((int)tv.tv_sec, 7); + tv_len = sizeof(tv); + EXPECT_EQ_INT(getsockopt(c->file_descriptor, SOL_SOCKET, SO_SNDTIMEO, &tv, &tv_len), 0); + EXPECT_EQ_INT((int)tv.tv_sec, 7); + client_disconnect(c); + client_delete(c); + tcp_set_timeouts(30, 10); + server_delete(&s); +} + +/* The plain wrapper applies the post-connect IO --timeout, which supersedes the + * contimeout installed during connect. */ +static void test_tcp_connect_post_timeout_applied() { + Server* s = server_create(0); + EXPECT_NOT_NULL(s); + EXPECT_EQ_INT(listen(s->file_descriptor, 1), 0); + struct sockaddr_in bound; + socklen_t bound_len = sizeof(bound); + EXPECT_EQ_INT(getsockname(s->file_descriptor, (struct sockaddr*)&bound, &bound_len), 0); + int port = ntohs(bound.sin_port); + + tcp_set_timeouts(5, 7); + Client* c = client_create(); + EXPECT_NOT_NULL(c); + EXPECT_TRUE(client_connect(c, "127.0.0.1", port)); + struct timeval tv; + socklen_t tv_len = sizeof(tv); + EXPECT_EQ_INT(getsockopt(c->file_descriptor, SOL_SOCKET, SO_RCVTIMEO, &tv, &tv_len), 0); + EXPECT_EQ_INT((int)tv.tv_sec, 5); + tv_len = sizeof(tv); + EXPECT_EQ_INT(getsockopt(c->file_descriptor, SOL_SOCKET, SO_SNDTIMEO, &tv, &tv_len), 0); + EXPECT_EQ_INT((int)tv.tv_sec, 5); + client_disconnect(c); + client_delete(c); + tcp_set_timeouts(30, 10); + server_delete(&s); +} + void test_transport_tcp() { test_server_create_ephemeral(); test_server_delete_null(); @@ -263,4 +439,8 @@ void test_transport_tcp() { test_server_create_bind_address(); test_server_create_bind_ipv6(); test_tcp_nodelay_default_and_override(); + test_tcp_connect_falls_back_to_next_address(); + test_tcp_connect_failed_attempts_do_not_leak_fds(); + test_tcp_connect_socket_ex_applies_contimeout(); + test_tcp_connect_post_timeout_applied(); } diff --git a/tests/test_transport_tls.c b/tests/test_transport_tls.c index cc13aea..1385ff8 100644 --- a/tests/test_transport_tls.c +++ b/tests/test_transport_tls.c @@ -3,8 +3,11 @@ #include "test_utils.h" #include "transport_tcp.h" #include "transport_tls.h" +#include +#include #include #include +#include #include static void test_tls_global_init() { @@ -71,9 +74,53 @@ static void test_server_create_tls_empty_certs() { EXPECT_NULL(s); } +/* Count the process's open descriptors via /proc/self/fd (see the TCP tests). */ +static int tls_count_open_fds(void) { + DIR* dir = opendir("/proc/self/fd"); + if (!dir) + return -1; + int count = 0; + const struct dirent* ent; + while ((ent = readdir(dir)) != NULL) { + if (strcmp(ent->d_name, ".") == 0 || strcmp(ent->d_name, "..") == 0) + continue; + count++; + } + closedir(dir); + return count; +} + +/* #219 AC3: client_connect_tls_ex() reuses the shared tcp_connect_socket_ex() + * for the TCP connect, and a later TLS-setup failure must release that + * descriptor. Passing no CA path makes create_ssl_ctx() fail deterministically + * AFTER a successful TCP connect, so the cleanup path is exercised without a + * TLS handshake or a certificate. (The multi-address fallback itself is covered + * by the shared tcp_connect_socket_ex() tests in test_transport_tcp.c, which the + * TLS entry point calls.) */ +static void test_client_connect_tls_releases_fd_on_setup_failure() { + Server* s = server_create(0); + EXPECT_NOT_NULL(s); + EXPECT_EQ_INT(listen(s->file_descriptor, 1), 0); + struct sockaddr_in bound; + socklen_t bound_len = sizeof(bound); + EXPECT_EQ_INT(getsockname(s->file_descriptor, (struct sockaddr*)&bound, &bound_len), 0); + int port = ntohs(bound.sin_port); + + int before = tls_count_open_fds(); + Client* c = client_create(); + EXPECT_NOT_NULL(c); + EXPECT_FALSE(client_connect_tls(c, "127.0.0.1", port, NULL, NULL, NULL)); + EXPECT_TRUE(c->file_descriptor == -1); + if (before >= 0) + EXPECT_EQ_INT(tls_count_open_fds(), before); + client_delete(c); + server_delete(&s); +} + void test_transport_tls() { test_tls_global_init(); test_server_create_tls_without_certs(); test_client_connect_tls_fail(); + test_client_connect_tls_releases_fd_on_setup_failure(); test_server_create_tls_empty_certs(); }