Skip to content

Commit a46ca06

Browse files
etrclaude
andcommitted
TASK-065: RFC 5952 IPv6 canonicalization in peer_address::to_string
Replaces the uncompressed 8-group "%x:%x:..." IPv6 rendering in src/peer_address.cpp with an in-TU RFC 5952 §4 canonicalizer: - lowercase hex digits with leading-zero suppression (§4.1, §4.2.1), - longest run of >= 2 consecutive zero groups collapsed to "::" (§4.2.2), with first-occurrence tie-break on equal-length runs (§4.2.3), and single zero groups left intact (§4.3), - ::ffff:0:0/96 IPv4-mapped form rendered as "::ffff:a.b.c.d" (§5). Per action item #2 we always post-process the 16-byte address in-TU rather than gating on inet_ntop behaviour. The reason is twofold: (a) peer_address.cpp deliberately stays free of <netinet/in.h> / <sys/socket.h> (see file-header comment); (b) post-processing yields deterministic, identical output across glibc / musl / macOS / Windows builds, which the platform-dependent path cannot guarantee for the IPv4-mapped dotted-quad form. IPv4 and unspec branches are byte-for-byte unchanged. Removes the "TASK-046 can refine when telemetry firms up" comment. Adds test/unit/peer_address_to_string_test.cpp pinning the §4.2.2 examples (2001:db8::1, ::1, ::), the §4.3 single-zero non-collapse rule, the §4.2.3 longest-run and first-occurrence tie-break cases, the §5 IPv4-mapped dotted-quad form (::ffff:192.0.2.1), the IPv4 regression case (127.0.0.1), the unspec empty-string case, and a trailing-edge collapse (2001:db8:1::). Implementation is decomposed into small helpers (assemble_groups, is_ipv4_mapped, format_ipv4_mapped, find_longest_zero_run, append_group_hex, emit_canonical) to keep every function under CCN_MAX=10 per check-complexity.sh. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent e530ae3 commit a46ca06

3 files changed

Lines changed: 273 additions & 24 deletions

File tree

src/peer_address.cpp

Lines changed: 132 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -22,46 +22,155 @@
2222
// in TASK-051 to keep both TUs under FILE_LOC_MAX (the per-route hook
2323
// additions to hook_handle.cpp pushed it past the 500-line ceiling).
2424

25+
#include <array>
26+
#include <cstddef>
27+
#include <cstdint>
2528
#include <cstdio>
2629
#include <string>
2730

2831
#include "httpserver/hook_context.hpp"
2932

3033
namespace httpserver {
3134

35+
namespace {
36+
37+
// Eight 16-bit groups in network byte order.
38+
using ipv6_groups = std::array<std::uint16_t, 8>;
39+
40+
// (start, length) of the zero-run RFC 5952 §4.2.2 collapses with "::".
41+
// length == 0 means "no collapse" (RFC 5952 §4.3: a single zero MUST
42+
// NOT be shortened).
43+
struct zero_run {
44+
int start = -1;
45+
int len = 0;
46+
};
47+
48+
// Assemble eight 16-bit groups in network byte order from the 16 raw
49+
// bytes. Cast each byte to unsigned before shifting to avoid signed-int
50+
// promotion UB and to match the unsigned int expected by '%x'
51+
// (CWE-704 / TASK-045 finding #1 & #25).
52+
ipv6_groups assemble_groups(const std::array<std::uint8_t, 16>& bytes) {
53+
ipv6_groups g{};
54+
for (std::size_t i = 0; i < 8; ++i) {
55+
g[i] = static_cast<std::uint16_t>(
56+
(static_cast<unsigned>(bytes[2 * i]) << 8) |
57+
static_cast<unsigned>(bytes[2 * i + 1]));
58+
}
59+
return g;
60+
}
61+
62+
// RFC 5952 §5 IPv4-mapped (::ffff:0:0/96) detection. The plain
63+
// IPv4-compatible (::a.b.c.d) form is NOT covered by §5 and stays in
64+
// hex (e.g. "::1" rather than "::0.0.0.1").
65+
bool is_ipv4_mapped(const ipv6_groups& g) {
66+
for (int i = 0; i < 5; ++i) {
67+
if (g[i] != 0) return false;
68+
}
69+
return g[5] == 0xffff;
70+
}
71+
72+
// Format the IPv4-mapped form per RFC 5952 §5.
73+
std::string format_ipv4_mapped(const std::array<std::uint8_t, 16>& bytes) {
74+
char buf[24]; // "::ffff:255.255.255.255" + NUL = 23
75+
std::snprintf(buf, sizeof(buf), "::ffff:%u.%u.%u.%u",
76+
static_cast<unsigned>(bytes[12]),
77+
static_cast<unsigned>(bytes[13]),
78+
static_cast<unsigned>(bytes[14]),
79+
static_cast<unsigned>(bytes[15]));
80+
return std::string{buf};
81+
}
82+
83+
// Find the longest run of consecutive zero groups of length >= 2.
84+
// Strict > update preserves first-occurrence tie-break (RFC 5952
85+
// §4.2.3); runs of length 1 are dropped per RFC 5952 §4.3.
86+
zero_run find_longest_zero_run(const ipv6_groups& g) {
87+
zero_run best;
88+
int cur_start = -1;
89+
int cur_len = 0;
90+
for (int i = 0; i < 8; ++i) {
91+
if (g[i] != 0) {
92+
cur_len = 0;
93+
continue;
94+
}
95+
if (cur_len == 0) cur_start = i;
96+
++cur_len;
97+
if (cur_len > best.len) {
98+
best.len = cur_len;
99+
best.start = cur_start;
100+
}
101+
}
102+
if (best.len < 2) return zero_run{};
103+
return best;
104+
}
105+
106+
// Append "%x" for a single group to `out`.
107+
void append_group_hex(std::string& out, std::uint16_t group) {
108+
char scratch[5]; // up to 4 hex chars + NUL per group
109+
std::snprintf(scratch, sizeof(scratch), "%x",
110+
static_cast<unsigned>(group));
111+
out += scratch;
112+
}
113+
114+
// Emit the canonical text form, given the assembled groups and the
115+
// collapse window. Edge cases ("::1", "1::", "::") fall out naturally
116+
// because the "::" marker brings both colons with it.
117+
std::string emit_canonical(const ipv6_groups& g, const zero_run& collapse) {
118+
std::string out;
119+
out.reserve(40); // bounded by INET6_ADDRSTRLEN
120+
for (int i = 0; i < 8;) {
121+
if (i == collapse.start) {
122+
out += "::";
123+
i += collapse.len;
124+
continue;
125+
}
126+
append_group_hex(out, g[i]);
127+
++i;
128+
// Emit a ':' separator if more groups remain AND the next slot
129+
// is not the collapse window (which brings its own leading ':').
130+
if (i < 8 && i != collapse.start) out += ':';
131+
}
132+
return out;
133+
}
134+
135+
// RFC 5952 §4 canonicalizer for the 16-byte big-endian IPv6 address in
136+
// `bytes`. The output is bounded by INET6_ADDRSTRLEN (45 + NUL = 46), so
137+
// std::string's SBO covers it on every reasonable libc++/libstdc++ build.
138+
//
139+
// We deliberately implement the canonical form in pure C++ over the
140+
// 16-byte buffer rather than delegating to `inet_ntop`:
141+
//
142+
// (a) `peer_address.cpp` stays free of <netinet/in.h> / <sys/socket.h>
143+
// (see file-header comment above), matching the original TASK-051
144+
// split's "no backend-platform headers in this TU" rule.
145+
// (b) Post-processing produces deterministic, identical output across
146+
// glibc / musl / macOS / Windows builds. Platform `inet_ntop`
147+
// behaviour for the IPv4-mapped dotted-quad form (RFC 5952 §5) is
148+
// not uniformly canonical across libc versions.
149+
std::string ipv6_canonical(const std::array<std::uint8_t, 16>& bytes) {
150+
const auto groups = assemble_groups(bytes);
151+
if (is_ipv4_mapped(groups)) return format_ipv4_mapped(bytes);
152+
return emit_canonical(groups, find_longest_zero_run(groups));
153+
}
154+
155+
} // namespace
156+
32157
std::string peer_address::to_string() const {
33158
// No <netinet/in.h> / inet_ntop here so we keep this TU free of
34-
// backend-platform headers. The format is canonical-enough for
35-
// log lines without dragging in the full POSIX socket surface.
36-
// 46 is POSIX INET6_ADDRSTRLEN; we round up for snprintf's NUL.
37-
char buf[64];
159+
// backend-platform headers. IPv6 is rendered in RFC 5952 §4 canonical
160+
// form by an in-TU post-processor (see ipv6_canonical above) for
161+
// deterministic output across libc versions.
38162
switch (fam) {
39-
case family::ipv4:
163+
case family::ipv4: {
164+
char buf[16]; // "255.255.255.255" + NUL = 16
40165
std::snprintf(buf, sizeof(buf), "%u.%u.%u.%u",
41166
static_cast<unsigned>(bytes[0]),
42167
static_cast<unsigned>(bytes[1]),
43168
static_cast<unsigned>(bytes[2]),
44169
static_cast<unsigned>(bytes[3]));
45170
return std::string{buf};
46-
case family::ipv6: {
47-
// Group as eight uint16_t big-endian words, colon-separated.
48-
// Skip zero-compression for simplicity at TASK-045; TASK-046
49-
// can refine when telemetry/log requirements firm up.
50-
// Cast each byte to unsigned before shifting to avoid signed-int
51-
// promotion UB on exotic 16-bit-int platforms and to match the
52-
// unsigned int expected by '%x' (CWE-704 / finding #1 & #25).
53-
std::snprintf(buf, sizeof(buf),
54-
"%x:%x:%x:%x:%x:%x:%x:%x",
55-
(static_cast<unsigned>(bytes[0]) << 8) | static_cast<unsigned>(bytes[1]),
56-
(static_cast<unsigned>(bytes[2]) << 8) | static_cast<unsigned>(bytes[3]),
57-
(static_cast<unsigned>(bytes[4]) << 8) | static_cast<unsigned>(bytes[5]),
58-
(static_cast<unsigned>(bytes[6]) << 8) | static_cast<unsigned>(bytes[7]),
59-
(static_cast<unsigned>(bytes[8]) << 8) | static_cast<unsigned>(bytes[9]),
60-
(static_cast<unsigned>(bytes[10]) << 8) | static_cast<unsigned>(bytes[11]),
61-
(static_cast<unsigned>(bytes[12]) << 8) | static_cast<unsigned>(bytes[13]),
62-
(static_cast<unsigned>(bytes[14]) << 8) | static_cast<unsigned>(bytes[15]));
63-
return std::string{buf};
64171
}
172+
case family::ipv6:
173+
return ipv6_canonical(bytes);
65174
case family::unspec:
66175
default:
67176
return std::string{};

test/Makefile.am

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ LDADD += -lcurl
2626

2727
AM_CPPFLAGS = -I$(top_srcdir)/src -I$(top_srcdir)/src/httpserver/ -DHTTPSERVER_COMPILATION
2828
METASOURCES = AUTO
29-
check_PROGRAMS = basic file_upload http_utils threaded nodelay string_utilities http_endpoint ban_system ws_start_stop authentication digest_challenge_format deferred http_resource http_response create_webserver create_webserver_explicit new_response_types daemon_info uri_log feature_unavailable header_hygiene_iovec header_hygiene iovec_entry http_method constants body http_response_sbo http_response_factories http_response_digest_factory http_response_move_sanitizer webserver_pimpl http_request_pimpl create_test_request http_request_arena http_request_const_getters http_request_tls_accessors http_request_operator_stream webserver_register_smartptr webserver_register_path_prefix webserver_on_methods webserver_route route_table lookup_pipeline route_table_concurrency routing_regression route_lookup_canonicalize auth_skip_normalize http_resource_allow_cache v2_dispatch_contract threadsafety_stress webserver_features webserver_ws_unavailable webserver_register_ws_smartptr webserver_dauth_unavailable consumer_fixture header_hygiene_hooks hook_api_shape hooks_no_firing hooks_accept_ctx_shape hooks_connection_lifecycle hooks_accept_decision_banned hooks_accept_decision_throwing hooks_body_chunk_ctx_shape hooks_request_received_short_circuit hooks_body_chunk_observes_progress hooks_body_chunk_short_circuit_no_leak hooks_before_handler_ctx_shape hooks_route_resolved_miss_and_hit hooks_before_handler_short_circuit hooks_alias_count hooks_alias_functional hooks_handler_exception_chain hooks_handler_exception_user_handler_throws_continues_chain hooks_handler_exception_fallback_to_hardcoded_500 hooks_handler_exception_slot hooks_response_sent_ctx_shape hooks_request_completed_ctx_shape hooks_after_handler_replaces_response hooks_after_handler_mutates_response_in_place hooks_response_sent_carries_status_bytes_timing hooks_request_completed_fires_on_early_failure hooks_log_access_alias_slot hooks_per_route_invalid_phase_throws hooks_per_route_order hooks_per_route_early_413_per_endpoint hooks_per_route_resource_destroyed_first hooks_per_route_concurrent_registration auth_handler_optional_signature auth_handler_legacy_shim cookie_header_sentinel cookie_render cookie_deprecation_sentinel http_response_cookie_wire http_request_cookies_parsed
29+
check_PROGRAMS = basic file_upload http_utils threaded nodelay string_utilities http_endpoint ban_system ws_start_stop authentication digest_challenge_format deferred http_resource http_response create_webserver create_webserver_explicit new_response_types daemon_info uri_log feature_unavailable header_hygiene_iovec header_hygiene iovec_entry http_method constants body http_response_sbo http_response_factories http_response_digest_factory http_response_move_sanitizer webserver_pimpl http_request_pimpl create_test_request http_request_arena http_request_const_getters http_request_tls_accessors http_request_operator_stream webserver_register_smartptr webserver_register_path_prefix webserver_on_methods webserver_route route_table lookup_pipeline route_table_concurrency routing_regression route_lookup_canonicalize auth_skip_normalize http_resource_allow_cache v2_dispatch_contract threadsafety_stress webserver_features webserver_ws_unavailable webserver_register_ws_smartptr webserver_dauth_unavailable consumer_fixture header_hygiene_hooks hook_api_shape hooks_no_firing hooks_accept_ctx_shape hooks_connection_lifecycle hooks_accept_decision_banned hooks_accept_decision_throwing hooks_body_chunk_ctx_shape hooks_request_received_short_circuit hooks_body_chunk_observes_progress hooks_body_chunk_short_circuit_no_leak hooks_before_handler_ctx_shape hooks_route_resolved_miss_and_hit hooks_before_handler_short_circuit hooks_alias_count hooks_alias_functional hooks_handler_exception_chain hooks_handler_exception_user_handler_throws_continues_chain hooks_handler_exception_fallback_to_hardcoded_500 hooks_handler_exception_slot hooks_response_sent_ctx_shape hooks_request_completed_ctx_shape hooks_after_handler_replaces_response hooks_after_handler_mutates_response_in_place hooks_response_sent_carries_status_bytes_timing hooks_request_completed_fires_on_early_failure hooks_log_access_alias_slot hooks_per_route_invalid_phase_throws hooks_per_route_order hooks_per_route_early_413_per_endpoint hooks_per_route_resource_destroyed_first hooks_per_route_concurrent_registration auth_handler_optional_signature auth_handler_legacy_shim cookie_header_sentinel cookie_render cookie_deprecation_sentinel http_response_cookie_wire http_request_cookies_parsed peer_address_to_string
3030

3131
MOSTLYCLEANFILES = *.gcda *.gcno *.gcov
3232

@@ -566,6 +566,11 @@ hooks_per_route_early_413_per_endpoint_SOURCES = integ/hooks_per_route_early_413
566566
hooks_per_route_resource_destroyed_first_SOURCES = integ/hooks_per_route_resource_destroyed_first.cpp
567567
hooks_per_route_concurrent_registration_SOURCES = integ/hooks_per_route_concurrent_registration.cpp
568568

569+
# TASK-065 -- RFC 5952 canonicalization of peer_address::to_string() for
570+
# IPv6 (lowercase + leading-zero suppression + longest-run zero collapse
571+
# + ::ffff:a.b.c.d dotted-quad). Pure-host CPU test -- no MHD round-trip.
572+
peer_address_to_string_SOURCES = unit/peer_address_to_string_test.cpp
573+
569574
noinst_HEADERS = littletest.hpp integ/test_utils.hpp integ/curl_helpers.hpp
570575
AM_CXXFLAGS += -Wall -fPIC -Wno-overloaded-virtual
571576

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
/*
2+
This file is part of libhttpserver
3+
Copyright (C) 2011-2026 Sebastiano Merlino
4+
5+
This library is free software; you can redistribute it and/or
6+
modify it under the terms of the GNU Lesser General Public
7+
License as published by the Free Software Foundation; either
8+
version 2.1 of the License, or (at your option) any later version.
9+
*/
10+
11+
// TASK-065: RFC 5952 §4 canonicalization for peer_address::to_string()
12+
// over IPv6 inputs. Pre-TASK-065 the IPv6 branch printed all eight
13+
// 16-bit groups uncompressed (e.g. "2001:db8:0:0:0:0:0:1"); this test
14+
// pins the §4.2.2 examples, the §4.3 "single zero MUST NOT collapse"
15+
// rule, the §4.2.3 first-occurrence tie-break, and the §5 IPv4-mapped
16+
// dotted-quad form. The IPv4 branch (unchanged by TASK-065) is also
17+
// regression-pinned so a future tweak to the canonicalizer cannot
18+
// regress the v4 path.
19+
//
20+
// Pure-host CPU test: constructs `peer_address` aggregates directly --
21+
// no MHD round-trip, no socket headers.
22+
23+
#include <array>
24+
#include <cstdint>
25+
#include <string>
26+
27+
#include "./httpserver.hpp"
28+
#include "./littletest.hpp"
29+
30+
using httpserver::peer_address;
31+
32+
namespace {
33+
34+
peer_address make_v6(const std::array<std::uint8_t, 16>& b) {
35+
peer_address p{};
36+
p.fam = peer_address::family::ipv6;
37+
p.bytes = b;
38+
return p;
39+
}
40+
41+
peer_address make_v4(std::uint8_t a, std::uint8_t b,
42+
std::uint8_t c, std::uint8_t d) {
43+
peer_address p{};
44+
p.fam = peer_address::family::ipv4;
45+
p.bytes[0] = a;
46+
p.bytes[1] = b;
47+
p.bytes[2] = c;
48+
p.bytes[3] = d;
49+
return p;
50+
}
51+
52+
} // namespace
53+
54+
LT_BEGIN_SUITE(peer_address_to_string_suite)
55+
void set_up() {}
56+
void tear_down() {}
57+
LT_END_SUITE(peer_address_to_string_suite)
58+
59+
// §4.2.2: leading-zero suppression + longest-run collapse.
60+
LT_BEGIN_AUTO_TEST(peer_address_to_string_suite, rfc5952_documentation_example)
61+
// 2001:0db8:0000:0000:0000:0000:0000:0001 -> "2001:db8::1"
62+
auto p = make_v6({0x20, 0x01, 0x0d, 0xb8, 0, 0, 0, 0,
63+
0, 0, 0, 0, 0, 0, 0, 0x01});
64+
LT_CHECK_EQ(p.to_string(), std::string{"2001:db8::1"});
65+
LT_END_AUTO_TEST(rfc5952_documentation_example)
66+
67+
// §4.2.2: loopback.
68+
LT_BEGIN_AUTO_TEST(peer_address_to_string_suite, rfc5952_loopback)
69+
auto p = make_v6({0, 0, 0, 0, 0, 0, 0, 0,
70+
0, 0, 0, 0, 0, 0, 0, 0x01});
71+
LT_CHECK_EQ(p.to_string(), std::string{"::1"});
72+
LT_END_AUTO_TEST(rfc5952_loopback)
73+
74+
// §4.2.2: unspecified all-zero address renders as "::".
75+
LT_BEGIN_AUTO_TEST(peer_address_to_string_suite, rfc5952_all_zero)
76+
auto p = make_v6({0, 0, 0, 0, 0, 0, 0, 0,
77+
0, 0, 0, 0, 0, 0, 0, 0});
78+
LT_CHECK_EQ(p.to_string(), std::string{"::"});
79+
LT_END_AUTO_TEST(rfc5952_all_zero)
80+
81+
// §4.3: a single 16-bit zero group MUST NOT be shortened.
82+
LT_BEGIN_AUTO_TEST(peer_address_to_string_suite, rfc5952_single_zero_no_collapse)
83+
// Groups: 2001:db8:0:1:1:1:1:1
84+
auto p = make_v6({0x20, 0x01, 0x0d, 0xb8, 0, 0, 0, 0x01,
85+
0, 0x01, 0, 0x01, 0, 0x01, 0, 0x01});
86+
LT_CHECK_EQ(p.to_string(), std::string{"2001:db8:0:1:1:1:1:1"});
87+
LT_END_AUTO_TEST(rfc5952_single_zero_no_collapse)
88+
89+
// §4.2.3 tie-break: longest run wins (3-group run over 2-group run).
90+
LT_BEGIN_AUTO_TEST(peer_address_to_string_suite, rfc5952_longest_run_wins)
91+
// Groups: 2001:0:0:1:0:0:0:1 -> the second (3-group) run is collapsed.
92+
auto p = make_v6({0x20, 0x01, 0, 0, 0, 0, 0, 0x01,
93+
0, 0, 0, 0, 0, 0, 0, 0x01});
94+
LT_CHECK_EQ(p.to_string(), std::string{"2001:0:0:1::1"});
95+
LT_END_AUTO_TEST(rfc5952_longest_run_wins)
96+
97+
// §4.2.3 tie-break: equal-length runs, first occurrence wins.
98+
LT_BEGIN_AUTO_TEST(peer_address_to_string_suite, rfc5952_first_occurrence_tie_break)
99+
// Groups: 1:0:0:1:0:0:1:1 -> the first (2-group) run is collapsed.
100+
auto p = make_v6({0, 0x01, 0, 0, 0, 0, 0, 0x01,
101+
0, 0, 0, 0, 0, 0x01, 0, 0x01});
102+
LT_CHECK_EQ(p.to_string(), std::string{"1::1:0:0:1:1"});
103+
LT_END_AUTO_TEST(rfc5952_first_occurrence_tie_break)
104+
105+
// §5: ::ffff:0:0/96 (IPv4-mapped) renders the last 32 bits as dotted-quad.
106+
LT_BEGIN_AUTO_TEST(peer_address_to_string_suite, rfc5952_ipv4_mapped_dotted_quad)
107+
// ::ffff:192.0.2.1 (TEST-NET-1, RFC 5737)
108+
auto p = make_v6({0, 0, 0, 0, 0, 0, 0, 0,
109+
0, 0, 0xff, 0xff, 192, 0, 2, 1});
110+
LT_CHECK_EQ(p.to_string(), std::string{"::ffff:192.0.2.1"});
111+
LT_END_AUTO_TEST(rfc5952_ipv4_mapped_dotted_quad)
112+
113+
// Regression: IPv4 path stays unchanged (acceptance criterion).
114+
LT_BEGIN_AUTO_TEST(peer_address_to_string_suite, ipv4_path_unchanged)
115+
auto p = make_v4(127, 0, 0, 1);
116+
LT_CHECK_EQ(p.to_string(), std::string{"127.0.0.1"});
117+
LT_END_AUTO_TEST(ipv4_path_unchanged)
118+
119+
// Regression: unspec family returns empty string (unchanged).
120+
LT_BEGIN_AUTO_TEST(peer_address_to_string_suite, unspec_returns_empty)
121+
peer_address p{};
122+
LT_CHECK_EQ(p.to_string(), std::string{});
123+
LT_END_AUTO_TEST(unspec_returns_empty)
124+
125+
// §4.2.2: collapse at the trailing edge.
126+
LT_BEGIN_AUTO_TEST(peer_address_to_string_suite, trailing_collapse)
127+
// Groups: 2001:db8:1:0:0:0:0:0 -> "2001:db8:1::"
128+
auto p = make_v6({0x20, 0x01, 0x0d, 0xb8, 0, 0x01, 0, 0,
129+
0, 0, 0, 0, 0, 0, 0, 0});
130+
LT_CHECK_EQ(p.to_string(), std::string{"2001:db8:1::"});
131+
LT_END_AUTO_TEST(trailing_collapse)
132+
133+
LT_BEGIN_AUTO_TEST_ENV()
134+
AUTORUN_TESTS()
135+
LT_END_AUTO_TEST_ENV()

0 commit comments

Comments
 (0)