diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 132cb26e..7fe4e947 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -87,7 +87,7 @@ jobs: CXX: clang++ run: | set -euo pipefail - for target in json_decode cbor_decode uri server_dispatch; do + for target in json_decode cbor_decode uri server_dispatch regex; do echo "== fuzzing $target" bazelisk build --config=fuzz "//fuzz:${target}_fuzz" ./bazel-bin/fuzz/${target}_fuzz -max_total_time=30 -print_final_stats=1 @@ -181,8 +181,8 @@ jobs: # its shape is locked by the golden diff check in the codegen job. - name: clang-format run: | - find runtime examples \( -name '*.h' -o -name '*.cc' \) ! -path '*/generated/*' \ - | xargs clang-format --dry-run --Werror + find runtime examples codegen/compile-tests \( -name '*.h' -o -name '*.cc' \) \ + ! -path '*/generated/*' | xargs clang-format --dry-run --Werror # Excluded from tidy: src/json/json.cc includes the nlohmann backend, # which only exists inside the Bazel build graph; beast_src.cc is the # Boost implementation TU and only compiles with the BCR modules' diff --git a/CHANGELOG.md b/CHANGELOG.md index 92175a07..a36025d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -53,8 +53,31 @@ via `git_override` until then. size limits, graceful drain, TLS termination) and `BeastHttpClient` (keep-alive connection pool, per-request timeouts, TLS via BoringSSL with certificate + hostname verification on by default). -- Fuzz harnesses (JSON, CBOR, URI, server dispatch) and a Google Benchmark - suite (serde, codecs, per-protocol request round trips, real-TCP transport - round trips incl. Beast and Beast TLS) run in CI. +- Fuzz harnesses (JSON, CBOR, URI, server dispatch, regex) and a Google + Benchmark suite (serde, codecs, per-protocol request round trips, real-TCP + transport round trips incl. Beast and Beast TLS) run in CI. +- CBOR decoder rejects additional-information 31 on integers and tags + (RFC 8949 §3.3 not-well-formed encodings previously decoded as 0 / -1 / + an ignored tag), found by the hostile corpus below. + +### Testing & CI (issue #48) + +- **Compile-the-output harness** (`codegen/compile-tests/`): the generator + runs inside the Bazel graph on a hostile gauntlet model — C++ keyword + member names, quote/backslash/newline enum values, raw-string delimiter + attacks, int64-extreme bounds/defaults, recursion, keyword union variants — + and CI compiles the result for every protocol, client and server mode both. + Issue #43's whole bug class now fails CI instead of a consumer's build. +- Curated hostile CBOR corpus (`cbor_hostile_test.cc`): systematic + truncations, reserved encodings, indefinite-length abuse, depth bombs, + boundary integers/halves, and an every-strict-prefix-rejects property, as + the CBOR counterpart of the vendored JSONTestSuite bank. +- Direct unit tests for `core/uuid.cc` (format, version/variant bits, + uniqueness, thread-local streams) and `client/observability.cc` + (attempt observations, trace-context propagation). +- The regex ReDoS bound is a deterministic step-count assertion + (`Search(text, &steps)` instrumentation) instead of a wall-clock limit. +- `make verify` / `make verify-full`: one-command local verification + mirroring the CI jobs one-to-one. [Unreleased]: https://github.com/aaylward/smithy-cpp/commits/main diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..3b906dff --- /dev/null +++ b/Makefile @@ -0,0 +1,79 @@ +# One-command verification (issue #48): `make verify` runs everything the CI +# gate runs, in one place, so full local verification stops being eight +# commands scattered across two build systems (docs/development.md#building- +# and-testing has the background). Each aggregate target is also callable on +# its own; the recipes deliberately mirror .github/workflows/ci.yml — when a +# CI job changes, change the matching target here. + +BAZEL ?= bazelisk +GRADLE ?= gradle + +# What CI gates a PR on: the bazel test matrix (one platform of it), the +# gradle build + format check, golden freshness, and the format/starlark lint. +.PHONY: verify +verify: test codegen goldens lint + @echo "verify: OK" + +# verify plus the slower jobs: sanitizers, fuzzer smoke runs, the out-of-tree +# consumer module, and clang-tidy. +.PHONY: verify-full +verify-full: verify sanitize fuzz-smoke consumer tidy + @echo "verify-full: OK" + +.PHONY: test +test: + $(BAZEL) test //... + +.PHONY: codegen +codegen: + cd codegen && $(GRADLE) build spotlessCheck + +# The checked-in generated code is the golden output; regeneration must be +# byte-identical. +.PHONY: goldens +goldens: + cd codegen && $(GRADLE) generateFixtures generateProtocolTests + git diff --exit-code -- examples protocol-tests + +.PHONY: lint +lint: + find runtime examples codegen/compile-tests \( -name '*.h' -o -name '*.cc' \) \ + ! -path '*/generated/*' | xargs clang-format --dry-run --Werror + buildifier --lint=warn --mode=check -r . + +.PHONY: tidy +tidy: + find runtime/src examples -name '*.cc' ! -name '*_test.cc' ! -path '*/src/json/*' \ + ! -name 'beast_src.cc' ! -path '*/generated/*' -print0 \ + | xargs -0 -I{} clang-tidy --quiet {} -- -Iruntime/include -I. -std=c++20 + +.PHONY: sanitize +sanitize: + CC=clang CXX=clang++ $(BAZEL) test //... --config=asan --config=ubsan + +.PHONY: fuzz-smoke +fuzz-smoke: + set -e; for target in json_decode cbor_decode uri server_dispatch regex; do \ + echo "== fuzzing $$target"; \ + CC=clang CXX=clang++ $(BAZEL) build --config=fuzz "//fuzz:$${target}_fuzz"; \ + ./bazel-bin/fuzz/$${target}_fuzz -max_total_time=30 -print_final_stats=1; \ + done + +.PHONY: consumer +consumer: + cd examples/bazel-consumer && $(BAZEL) test //... && ./model-evolution-check.sh + +# Informational, never gates (PLAN Phase 7). +.PHONY: benchmarks +benchmarks: + $(BAZEL) run -c opt //benchmarks:serde_benchmark -- --benchmark_min_time=0.2s + $(BAZEL) run -c opt //benchmarks:request_benchmark -- --benchmark_min_time=0.2s + $(BAZEL) run -c opt //benchmarks:beast_benchmark -- --benchmark_min_time=0.2s + +# Rewrites instead of checking: the fix-it twin of `lint` + codegen's spotless. +.PHONY: format +format: + find runtime examples codegen/compile-tests \( -name '*.h' -o -name '*.cc' \) \ + ! -path '*/generated/*' | xargs clang-format -i + buildifier --lint=warn -r . + cd codegen && $(GRADLE) spotlessApply diff --git a/codegen/compile-tests/BUILD.bazel b/codegen/compile-tests/BUILD.bazel new file mode 100644 index 00000000..1e40b9dc --- /dev/null +++ b/codegen/compile-tests/BUILD.bazel @@ -0,0 +1,73 @@ +load("@rules_cc//cc:defs.bzl", "cc_test") +load("//bazel:defs.bzl", "smithy_cpp_client_library", "smithy_cpp_server_library") + +# The compile-the-output harness (issue #48): run the real generator on the +# hostile gauntlet model and *compile* what comes out, for every protocol, in +# both client and server mode. The Java unit suite asserts on source +# substrings; this package is what turns "the generator emitted uncompilable +# C++" (issue #43's whole bug class) into an ordinary CI failure. It is also +# the one place SmithyCppGenerate executes for shapes no golden fixture pins. + +MODEL = ["model/gauntlet.smithy"] + +smithy_cpp_client_library( + name = "gauntlet_rest_client", + srcs = MODEL + ["model/bindings/simplerestjson.smithy"], + namespace = "compile::gauntlet::rest", + service = "compile.gauntlet#Gauntlet", +) + +smithy_cpp_server_library( + name = "gauntlet_rest_server", + srcs = MODEL + ["model/bindings/simplerestjson.smithy"], + namespace = "compile::gauntlet::rest", + service = "compile.gauntlet#Gauntlet", +) + +smithy_cpp_client_library( + name = "gauntlet_cbor_client", + srcs = MODEL + ["model/bindings/rpcv2cbor.smithy"], + namespace = "compile::gauntlet::cbor", + service = "compile.gauntlet#Gauntlet", +) + +smithy_cpp_server_library( + name = "gauntlet_cbor_server", + srcs = MODEL + ["model/bindings/rpcv2cbor.smithy"], + namespace = "compile::gauntlet::cbor", + service = "compile.gauntlet#Gauntlet", +) + +smithy_cpp_client_library( + name = "gauntlet_jsonrpc_client", + srcs = MODEL + ["model/bindings/jsonrpc2.smithy"], + namespace = "compile::gauntlet::jsonrpc", + service = "compile.gauntlet#Gauntlet", +) + +smithy_cpp_server_library( + name = "gauntlet_jsonrpc_server", + srcs = MODEL + ["model/bindings/jsonrpc2.smithy"], + namespace = "compile::gauntlet::jsonrpc", + service = "compile.gauntlet#Gauntlet", +) + +# Linking the test forces every generated TU above through the compiler; the +# assertions then spot-check the escaping contract (keyword members get a +# trailing underscore, hostile enum values round-trip, int64-min defaults). +cc_test( + name = "gauntlet_compile_test", + size = "small", + srcs = ["gauntlet_compile_test.cc"], + deps = [ + ":gauntlet_cbor_client", + ":gauntlet_cbor_server", + ":gauntlet_jsonrpc_client", + ":gauntlet_jsonrpc_server", + ":gauntlet_rest_client", + ":gauntlet_rest_server", + "//runtime:client", + "//runtime:http", + "@googletest//:gtest_main", + ], +) diff --git a/codegen/compile-tests/gauntlet_compile_test.cc b/codegen/compile-tests/gauntlet_compile_test.cc new file mode 100644 index 00000000..9f19fcc9 --- /dev/null +++ b/codegen/compile-tests/gauntlet_compile_test.cc @@ -0,0 +1,130 @@ +// The compile-the-output harness (issue #48). Most of the test is the build: +// including every generated header and linking all six gauntlet libraries +// forces the generator's output for the hostile model through the compiler on +// every platform CI runs. The assertions below then spot-check the escaping +// contract itself — keyword members, hostile enum wire values, extreme +// numeric bounds — so a silent change in the escaping scheme fails loudly +// here rather than in a consumer's build. + +#include + +#include +#include +#include + +#include "compile/gauntlet/cbor/client.h" +#include "compile/gauntlet/cbor/server.h" +#include "compile/gauntlet/jsonrpc/client.h" +#include "compile/gauntlet/jsonrpc/server.h" +#include "compile/gauntlet/rest/client.h" +#include "compile/gauntlet/rest/server.h" + +namespace { + +namespace rest = compile::gauntlet::rest; + +TEST(GauntletCompileTest, KeywordMembersGetTrailingUnderscores) { + rest::RunGauntletInput input; + input.name = "escape me"; + input.class_ = "keyword"; + input.namespace_ = "keyword"; + input.template_ = "keyword"; + input.operator_ = true; + input.delete_ = false; + input.int_ = 7; + input.double_ = 1.5; + input.union_ = "keyword"; + input.default_ = "keyword"; + input.friend_ = "keyword"; + input.this_ = "keyword"; + input.auto_ = "keyword"; + input.register_ = 9; + input.value = "not a keyword"; + input.kind = "not a keyword"; + input._leadingUnderscore = "kept verbatim"; + EXPECT_EQ(input.int_, 7); + EXPECT_EQ(input, input); + + rest::GetReportInput report; + report.class_ = "label"; + report.switch_ = "query"; + report.case_ = "header"; + EXPECT_EQ(report.class_, "label"); + + rest::GauntletRejected rejected; + rejected.message = "still compiles"; + rejected.class_ = "keyword"; + EXPECT_EQ(rejected.class_, "keyword"); +} + +TEST(GauntletCompileTest, HostileEnumValuesRoundTrip) { + using Enum = rest::HostileEnum; + const struct { + Enum::Value value; + const char* wire; + } cases[] = { + {Enum::Value::kQuote, "he said \"more\""}, {Enum::Value::kBackslash, "C:\\temp\\new"}, + {Enum::Value::kNewline, "line one\nline two"}, {Enum::Value::kTrickyRaw, ")__smithy\""}, + {Enum::Value::kUnicodeValue, "caf\xc3\xa9"}, + }; + for (const auto& c : cases) { + const Enum parsed = Enum::FromString(c.wire); + EXPECT_EQ(parsed.value(), c.value) << c.wire; + EXPECT_EQ(parsed.ToString(), c.wire); + } + const Enum unknown = Enum::FromString("never modeled"); + EXPECT_EQ(unknown.value(), Enum::Value::kUnknown); + EXPECT_EQ(unknown.ToString(), "never modeled"); +} + +TEST(GauntletCompileTest, IntEnumCoversInt32Extremes) { + EXPECT_EQ(static_cast(rest::HostileIntEnum::kBottom), + std::numeric_limits::min()); + EXPECT_EQ(static_cast(rest::HostileIntEnum::kTop), + std::numeric_limits::max()); + EXPECT_EQ(static_cast(rest::HostileIntEnum::kNothing), 0); +} + +TEST(GauntletCompileTest, UnionKeywordVariantsWork) { + const auto number = rest::HostileUnion::FromInt(42); + ASSERT_TRUE(number.is_int_()); + EXPECT_EQ(number.as_int_(), 42); + + const auto text = rest::HostileUnion::FromClass("keyword variant"); + ASSERT_TRUE(text.is_class_()); + EXPECT_EQ(text.as_class_(), "keyword variant"); + EXPECT_FALSE(text.is_int_()); + + rest::Node leaf; + leaf.label = "leaf"; + const auto branch = rest::HostileUnion::FromNode(leaf); + ASSERT_TRUE(branch.is_node()); + EXPECT_EQ(branch.as_node().label, "leaf"); +} + +TEST(GauntletCompileTest, RecursiveShapesUseValueSemanticBoxes) { + rest::Node root; + root.label = "root"; + rest::Node child; + child.label = "child"; + root.next = smithy::Boxed(child); + root.children = std::vector{child}; + const rest::Node copy = root; // deep copy through the box + EXPECT_EQ(copy, root); + EXPECT_EQ((*copy.next)->label, "child"); +} + +// The other two protocols generate the same shapes into their own +// namespaces; touching one type from each keeps all their headers in the +// build even if the includes above ever change. +TEST(GauntletCompileTest, EveryProtocolEmitsTheGauntletShapes) { + compile::gauntlet::cbor::RunGauntletInput cbor_input; + cbor_input.class_ = "cbor"; + EXPECT_EQ(cbor_input.class_, "cbor"); + + compile::gauntlet::jsonrpc::RunGauntletInput jsonrpc_input; + jsonrpc_input.class_ = "jsonrpc"; + EXPECT_EQ(jsonrpc_input.class_, "jsonrpc"); +} + +} // namespace diff --git a/codegen/compile-tests/model/bindings/jsonrpc2.smithy b/codegen/compile-tests/model/bindings/jsonrpc2.smithy new file mode 100644 index 00000000..a5f98297 --- /dev/null +++ b/codegen/compile-tests/model/bindings/jsonrpc2.smithy @@ -0,0 +1,10 @@ +// Protocol binding overlay: pairs with model/gauntlet.smithy to bind the +// protocol-agnostic Gauntlet service to JSON-RPC 2.0. The @http traits in the +// base model are simply ignored by this protocol. +$version: "2.0" + +namespace compile.gauntlet + +use smithy.cpp.protocols#jsonRpc2 + +apply Gauntlet @jsonRpc2 diff --git a/codegen/compile-tests/model/bindings/rpcv2cbor.smithy b/codegen/compile-tests/model/bindings/rpcv2cbor.smithy new file mode 100644 index 00000000..1bfd286a --- /dev/null +++ b/codegen/compile-tests/model/bindings/rpcv2cbor.smithy @@ -0,0 +1,10 @@ +// Protocol binding overlay: pairs with model/gauntlet.smithy to bind the +// protocol-agnostic Gauntlet service to rpcv2Cbor. The @http traits in the +// base model are simply ignored by this protocol. +$version: "2.0" + +namespace compile.gauntlet + +use smithy.protocols#rpcv2Cbor + +apply Gauntlet @rpcv2Cbor diff --git a/codegen/compile-tests/model/bindings/simplerestjson.smithy b/codegen/compile-tests/model/bindings/simplerestjson.smithy new file mode 100644 index 00000000..7fd260bc --- /dev/null +++ b/codegen/compile-tests/model/bindings/simplerestjson.smithy @@ -0,0 +1,9 @@ +// Protocol binding overlay: pairs with model/gauntlet.smithy to bind the +// protocol-agnostic Gauntlet service to simpleRestJson. +$version: "2.0" + +namespace compile.gauntlet + +use alloy#simpleRestJson + +apply Gauntlet @simpleRestJson diff --git a/codegen/compile-tests/model/gauntlet.smithy b/codegen/compile-tests/model/gauntlet.smithy new file mode 100644 index 00000000..e8f286d0 --- /dev/null +++ b/codegen/compile-tests/model/gauntlet.smithy @@ -0,0 +1,186 @@ +$version: "2.0" + +namespace compile.gauntlet + +/// The compile gauntlet: legal-but-unusual Smithy that historically broke the +/// generated C++ (issue #43's escaping / name-collision class). Nothing here +/// appears in the example fixtures, so this model is what stands between a +/// regression in the generator's escaping and a consumer's broken build. +/// +/// Doc-comment hostility rides along: a stray */ sequence, a backslash \, +/// "double quotes", , and a $dollar sign. +service Gauntlet { + version: "2026-07-08" + operations: [RunGauntlet, GetReport] +} + +/// Members named after C++ keywords, int64 extremes, hostile enum values, and +/// recursive/union/collection shapes — all in one validated input so the +/// server-side ValidationGenerator paths fire too. +@http(method: "POST", uri: "/gauntlet") +operation RunGauntlet { + input := { + /// Pattern text carrying a double quote and a backslash: both must + /// survive into the generated matcher and its violation message. + @required + @length(min: 1, max: 64) + @pattern("^[A-Za-z0-9\"\\\\ ]{1,64}$") + name: String + + // Every one of these is a C++ keyword the generator must escape. + class: String + namespace: String + template: String + operator: Boolean + delete: Boolean + int: Integer + double: Double + union: String + default: String + friend: String + this: String + auto: String + register: Integer + + // Not keywords, but they shadow names generated code likes to use. + value: String + kind: String + _leadingUnderscore: String + + /// int64 extremes: the minimum is unrepresentable as a plain C++ + /// literal (negating it overflows) and must be emitted as an + /// expression (issue #43). + @range(min: -9223372036854775808, max: 9223372036854775807) + extremes: Long + + /// The same hazard through @default. + floor: Long = -9223372036854775808 + + ceiling: Long = 9223372036854775807 + + /// Enum-targeted validated member: the "expected one of" message + /// quotes every hostile wire value below. + @required + coffee: HostileEnum + + weight: HostileIntEnum + + choice: HostileUnion + + @idempotencyToken + token: String + + payload: Blob + + when: Timestamp + + tags: TagList + + holes: SparseTagList + + attributes: AttributeMap + + tree: Node + } + + output := { + @required + coffee: HostileEnum + + choice: HostileUnion + + tree: Node + } + + errors: [GauntletRejected] +} + +/// REST-binding stress: label, query, and header members whose names need +/// escaping on the C++ side while keeping their wire spelling. +@readonly +@http(method: "GET", uri: "/gauntlet/{class}") +operation GetReport { + input := { + @required + @httpLabel + class: String + + @httpQuery("switch") + switch: String + + @httpHeader("x-gauntlet-case") + case: String + } + + output := { + @required + @httpResponseCode + status: Integer + + report: String + } +} + +/// Wire values that must be escaped wherever they are quoted: in serde string +/// literals and in the validation "expected one of" message (issue #43's +/// CRITICAL case). TRICKY_RAW is the raw-string delimiter attack. +enum HostileEnum { + QUOTE = "he said \"more\"" + BACKSLASH = "C:\\temp\\new" + NEWLINE = "line one\nline two" + TRICKY_RAW = ")__smithy\"" + UNICODE_VALUE = "café" +} + +intEnum HostileIntEnum { + BOTTOM = -2147483648 + TOP = 2147483647 + NOTHING = 0 +} + +/// Keyword-named members again, this time as union variants (factory and +/// accessor names derive from them), plus a recursive branch. +union HostileUnion { + int: Integer + class: String + blob: Blob + node: Node +} + +/// Recursive through both a direct member and a list. +structure Node { + @required + label: String + + next: Node + + children: NodeList +} + +list NodeList { + member: Node +} + +list TagList { + member: String +} + +@sparse +list SparseTagList { + member: String +} + +map AttributeMap { + key: String + value: String +} + +@error("client") +@httpError(422) +structure GauntletRejected { + @required + message: String + + /// A keyword member on an error shape. + class: String +} diff --git a/docs/development.md b/docs/development.md index baf31d9c..daa3959a 100644 --- a/docs/development.md +++ b/docs/development.md @@ -15,6 +15,18 @@ Two build trees live in this repository (see PLAN §3.1): ## Building and testing +One command verifies everything the CI gate checks (bazel tests, gradle +build + format, golden freshness, lint): + +```sh +make verify # what CI gates a PR on +make verify-full # + sanitizers, fuzzer smoke runs, the consumer module, clang-tidy +``` + +Each aggregate is also callable piecemeal (`make test codegen goldens lint +sanitize fuzz-smoke consumer tidy benchmarks format`); the recipes mirror +`.github/workflows/ci.yml`, one target per job. The underlying commands: + ```sh # C++ runtime: build + run all tests bazel test //... @@ -26,6 +38,13 @@ bazel test //... --config=asan --config=ubsan cd codegen && gradle build spotlessCheck ``` +The Java suite asserts on generated-source substrings; the compile-the-output +harness under `codegen/compile-tests/` is what proves hostile-but-legal models +(keyword member names, quote/backslash enum values, int64-extreme bounds — +issue #43's class) still *compile*: it runs the generator inside the Bazel +graph for every protocol, client and server mode both, and builds the result. +Extend its `model/gauntlet.smithy` when adding a new escaping/naming rule. + ## Benchmarks ```sh diff --git a/runtime/BUILD.bazel b/runtime/BUILD.bazel index c0fa37aa..6209d362 100644 --- a/runtime/BUILD.bazel +++ b/runtime/BUILD.bazel @@ -275,6 +275,18 @@ cc_test( ], ) +cc_test( + name = "observability_test", + size = "small", + srcs = ["tests/client/observability_test.cc"], + copts = COPTS, + deps = [ + ":client", + ":http", + "@googletest//:gtest_main", + ], +) + cc_library( name = "server", srcs = [ @@ -427,6 +439,17 @@ cc_test( ], ) +cc_test( + name = "cbor_hostile_test", + size = "small", + srcs = ["tests/cbor/cbor_hostile_test.cc"], + copts = COPTS, + deps = [ + ":cbor", + "@googletest//:gtest_main", + ], +) + cc_test( name = "core_test", size = "small", @@ -437,6 +460,7 @@ cc_test( "tests/core/regex_test.cc", "tests/core/timestamp_differential_test.cc", "tests/core/timestamp_test.cc", + "tests/core/uuid_test.cc", "tests/core/version_test.cc", ], copts = COPTS, diff --git a/runtime/include/smithy/core/regex.h b/runtime/include/smithy/core/regex.h index 77f98bd6..f88f874d 100644 --- a/runtime/include/smithy/core/regex.h +++ b/runtime/include/smithy/core/regex.h @@ -35,6 +35,15 @@ class Regex { // match anywhere in text unless the pattern anchors itself with ^/$. bool Search(std::string_view text) const; + // Test instrumentation, not part of the supported API: Search while + // counting VM work (instructions processed, epsilon closures included). + // The stamp-based dedup runs each instruction at most once per input + // position, so steps <= ProgramSize() x (text.size() + 2) for every + // pattern/input pair — the linear-time property as a deterministic + // assertion instead of a wall-clock bound. + bool Search(std::string_view text, std::size_t* steps) const; + std::size_t ProgramSize() const { return program_.size(); } + // Implementation detail, public only so the compiler/parser in regex.cc // can name it; not part of the supported API. struct Inst { @@ -63,9 +72,10 @@ class Regex { // Adds pc (following epsilon transitions and pos-applicable assertions) to // the thread list; returns true when a kMatch instruction is reached. + // steps, when non-null, counts each instruction processed. bool AddThread(std::vector* list, std::vector* seen_stamp, - std::uint32_t stamp, std::uint32_t pc, std::string_view text, - std::size_t pos) const; + std::uint32_t stamp, std::uint32_t pc, std::string_view text, std::size_t pos, + std::size_t* steps) const; std::vector program_; std::vector> classes_; diff --git a/runtime/src/cbor/cbor.cc b/runtime/src/cbor/cbor.cc index ca6f3871..95c8d2cf 100644 --- a/runtime/src/cbor/cbor.cc +++ b/runtime/src/cbor/cbor.cc @@ -215,6 +215,9 @@ class Decoder { case kUnsigned: { auto value = ReadArgument(info, &indefinite); if (!value) return std::move(value).error(); + // RFC 8949 §3.3: additional information 31 is well-formed only for + // strings, arrays, and maps; an "indefinite integer" is not CBOR. + if (indefinite) return Fail("indefinite length on integer"); if (*value > static_cast(std::numeric_limits::max())) { return Fail("integer exceeds int64 range"); } @@ -223,6 +226,7 @@ class Decoder { case kNegative: { auto value = ReadArgument(info, &indefinite); if (!value) return std::move(value).error(); + if (indefinite) return Fail("indefinite length on integer"); if (*value > static_cast(std::numeric_limits::max())) { return Fail("integer exceeds int64 range"); } @@ -278,6 +282,7 @@ class Decoder { case kTag: { auto tag = ReadArgument(info, &indefinite); if (!tag) return std::move(tag).error(); + if (indefinite) return Fail("indefinite length on tag"); auto inner = DecodeValue(depth - 1); if (!inner) return std::move(inner).error(); if (*tag == kTagEpochTimestamp) { diff --git a/runtime/src/core/regex.cc b/runtime/src/core/regex.cc index 7715bb2d..c3c70e75 100644 --- a/runtime/src/core/regex.cc +++ b/runtime/src/core/regex.cc @@ -582,8 +582,8 @@ class RegexCompiler { Outcome Regex::Compile(std::string_view pattern) { return RegexCompiler::Compile(pattern); } bool Regex::AddThread(std::vector* list, std::vector* seen_stamp, - std::uint32_t stamp, std::uint32_t pc, std::string_view text, - std::size_t pos) const { + std::uint32_t stamp, std::uint32_t pc, std::string_view text, std::size_t pos, + std::size_t* steps) const { // Iterative epsilon closure; the explicit stack keeps deeply split // programs from overflowing the call stack. std::vector work{pc}; @@ -592,6 +592,7 @@ bool Regex::AddThread(std::vector* list, std::vector* list, std::vector current; std::vector next; @@ -642,7 +646,8 @@ bool Regex::Search(std::string_view text) const { std::vector seen_stamp(program_.size(), 0); for (std::size_t pos = 0; pos <= text.size(); ++pos) { // Unanchored search: a fresh attempt starts at every position. - if (AddThread(¤t, &seen_stamp, static_cast(pos) + 1, 0, text, pos)) { + if (AddThread(¤t, &seen_stamp, static_cast(pos) + 1, 0, text, pos, + steps)) { return true; } if (pos == text.size()) break; @@ -652,7 +657,7 @@ bool Regex::Search(std::string_view text) const { const Inst& inst = program_[pc]; bool matches = inst.op == Inst::Op::kByte ? inst.byte == c : classes_[inst.arg].test(c); if (matches && AddThread(&next, &seen_stamp, static_cast(pos) + 2, pc + 1, - text, pos + 1)) { + text, pos + 1, steps)) { return true; } } diff --git a/runtime/tests/cbor/cbor_hostile_test.cc b/runtime/tests/cbor/cbor_hostile_test.cc new file mode 100644 index 00000000..73140a11 --- /dev/null +++ b/runtime/tests/cbor/cbor_hostile_test.cc @@ -0,0 +1,260 @@ +// A curated hostile-input bank for the CBOR decoder, the counterpart of the +// vendored JSONTestSuite corpus the JSON parser runs against: every named +// malformation class from RFC 8949 §appendix-F plus the decoder's own +// documented limits. Complements the inline cases in cbor_test.cc (which pin +// specific regressions); this bank aims for systematic coverage — every +// multi-byte header truncated, every reserved encoding, indefinite-length +// abuse, depth bombs — so decoder changes get judged against the full +// hostile surface, not the handful of shapes the fuzzer happened to find. + +#include + +#include +#include +#include +#include +#include +#include +#include + +#include "smithy/cbor/cbor.h" + +namespace smithy::cbor { +namespace { + +Blob FromHex(std::string_view hex) { + std::vector bytes; + for (std::size_t i = 0; i + 1 < hex.size(); i += 2) { + bytes.push_back( + static_cast(std::stoi(std::string(hex.substr(i, 2)), nullptr, 16))); + } + return Blob(std::move(bytes)); +} + +struct Vector { + const char* hex; + const char* why; +}; + +// --- Must be rejected, never crash, hang, or over-read ----------------- + +constexpr Vector kTruncatedHeaders[] = { + {"18", "uint, 1-byte argument missing"}, + {"19", "uint, 2-byte argument missing"}, + {"1900", "uint, 2-byte argument half present"}, + {"1a", "uint, 4-byte argument missing"}, + {"1a0000", "uint, 4-byte argument half present"}, + {"1b", "uint, 8-byte argument missing"}, + {"1b00000000000000", "uint, 8-byte argument one byte short"}, + {"38", "negative, 1-byte argument missing"}, + {"3b00", "negative, 8-byte argument truncated"}, + {"58", "byte string, 1-byte length missing"}, + {"5b", "byte string, 8-byte length missing"}, + {"78", "text, 1-byte length missing"}, + {"79", "text, 2-byte length missing"}, + {"7a000000", "text, 4-byte length truncated"}, + {"98", "array, 1-byte count missing"}, + {"9b", "array, 8-byte count missing"}, + {"b8", "map, 1-byte count missing"}, + {"bb0000", "map, 8-byte count truncated"}, + {"d8", "tag, 1-byte number missing"}, + {"f8", "simple, 1-byte value missing"}, + {"f9", "half float, payload missing"}, + {"f97c", "half float, payload half present"}, + {"fa000000", "single float, payload truncated"}, + {"fb00000000000000", "double float, payload one byte short"}, +}; + +constexpr Vector kTruncatedBodies[] = { + {"41", "byte string claims 1 byte, has none"}, + {"58ff", "byte string claims 255 bytes, has none"}, + {"61", "text claims 1 byte, has none"}, + {"6161ff", "trailing byte after complete text"}, + {"62c3", "text ends mid UTF-8 sequence and short of its length"}, + {"820102ff", "trailing break after complete array"}, + {"81", "array claims 1 element, has none"}, + {"830102", "array claims 3 elements, has 2"}, + {"a1", "map claims 1 pair, has none"}, + {"a2616101", "map claims 2 pairs, has 1"}, + {"a16161", "map key present, value missing"}, + {"c0", "tag 0 with no content"}, + {"c1", "tag 1 with no content"}, +}; + +constexpr Vector kIndefiniteAbuse[] = { + {"5f", "indefinite byte string never terminated"}, + {"7f", "indefinite text never terminated"}, + {"9f", "indefinite array never terminated"}, + {"bf", "indefinite map never terminated"}, + {"9f9f01ff", "inner indefinite array closed, outer not"}, + {"bf61610102", "indefinite map missing its break"}, + {"bf6161ff", "indefinite map break after key, value missing"}, + {"ff", "break with no open container"}, + {"81ff", "break as a definite array's element"}, + {"a16161ff", "break as a definite map's value"}, + {"7f4161ff", "byte-string chunk inside indefinite text"}, + {"5f6161ff", "text chunk inside indefinite byte string"}, + {"7f7f6161ffff", "nested indefinite text (chunks must be definite)"}, + {"7f01ff", "integer chunk inside indefinite text"}, + {"9fc1ff", "break as tag 1 content inside indefinite array"}, + {"1f", "additional info 31 on uint (no indefinite integers)"}, + {"3f", "additional info 31 on negative"}, + {"df", "additional info 31 on tag"}, + {"df01", "additional info 31 on tag, content present"}, +}; + +constexpr Vector kReservedEncodings[] = { + {"1c", "reserved additional info 28 on uint"}, + {"1d", "reserved additional info 29 on uint"}, + {"1e", "reserved additional info 30 on uint"}, + {"3c", "reserved additional info 28 on negative"}, + {"5c", "reserved additional info 28 on byte string"}, + {"7c", "reserved additional info 28 on text"}, + {"9c", "reserved additional info 28 on array"}, + {"bc", "reserved additional info 28 on map"}, + {"dc", "reserved additional info 28 on tag"}, + {"fc", "reserved additional info 28 on simple/float"}, + {"fd", "reserved additional info 29 on simple/float"}, + {"fe", "reserved additional info 30 on simple/float"}, + {"f800", "two-byte simple value below 32"}, + {"f81f", "two-byte simple value 31 (reserved)"}, +}; + +constexpr Vector kDomainLimits[] = { + {"1bffffffffffffffff", "uint64 max exceeds the int64 document model"}, + {"1b8000000000000000", "2^63 exceeds int64 max"}, + {"3bffffffffffffffff", "-2^64 below int64 min"}, + {"3b8000000000000000", "-2^63-1 below int64 min"}, + {"a10102", "integer map key"}, + {"a1810001", "array map key"}, + {"a1a0616101", "map map key"}, + {"a1f4616101", "boolean map key"}, + {"a1f6616101", "null map key"}, + {"5bffffffffffffffff", "byte string claims 2^64-1 bytes (overflow guard)"}, + {"7bffffffffffffffff", "text claims 2^64-1 bytes (overflow guard)"}, + {"9bffffffffffffffff", "array claims 2^64-1 elements"}, + {"bbffffffffffffffff", "map claims 2^64-1 pairs"}, + {"c1616161", "tag 1 (timestamp) on non-numeric content"}, + {"c17f6161ff", "tag 1 on indefinite text"}, +}; + +std::string Repeat(std::string_view unit, int n, std::string_view tail) { + std::string hex; + for (int i = 0; i < n; ++i) hex += unit; + hex += tail; + return hex; +} + +TEST(CborHostileTest, RejectsEveryMalformedVector) { + const auto check = [](const Vector* bank, std::size_t n) { + for (std::size_t i = 0; i < n; ++i) { + EXPECT_FALSE(Decode(FromHex(bank[i].hex)).ok()) << bank[i].hex << ": " << bank[i].why; + } + }; + check(kTruncatedHeaders, std::size(kTruncatedHeaders)); + check(kTruncatedBodies, std::size(kTruncatedBodies)); + check(kIndefiniteAbuse, std::size(kIndefiniteAbuse)); + check(kReservedEncodings, std::size(kReservedEncodings)); + check(kDomainLimits, std::size(kDomainLimits)); +} + +TEST(CborHostileTest, RejectsDepthBombs) { + // The decoder caps nesting at 64; each shape that recurses must hit the + // cap instead of the process stack. + EXPECT_FALSE(Decode(FromHex(Repeat("81", 65, "01"))).ok()) << "definite arrays"; + EXPECT_FALSE(Decode(FromHex(Repeat("9f", 65, "01") + Repeat("ff", 65, ""))).ok()) + << "indefinite arrays"; + EXPECT_FALSE(Decode(FromHex(Repeat("a16161", 65, "01"))).ok()) << "definite maps"; + EXPECT_FALSE(Decode(FromHex(Repeat("c6", 65, "01"))).ok()) << "tag chains"; + EXPECT_FALSE(Decode(FromHex(Repeat("81", 4096, "01"))).ok()) << "deep bomb, still bounded"; +} + +// --- Valid but nasty: must decode, not be over-rejected ---------------- + +TEST(CborHostileTest, AcceptsBoundaryIntegers) { + const auto max = Decode(FromHex("1b7fffffffffffffff")); + ASSERT_TRUE(max.ok()); + EXPECT_EQ(max->as_int(), std::int64_t{0x7fffffffffffffff}); + const auto min = Decode(FromHex("3b7fffffffffffffff")); + ASSERT_TRUE(min.ok()); + EXPECT_EQ(min->as_int(), std::numeric_limits::min()); +} + +TEST(CborHostileTest, AcceptsHalfPrecisionEdgeCases) { + const struct { + const char* hex; + double expected; + } finite[] = { + {"f90000", 0.0}, + {"f98000", -0.0}, + {"f90001", 5.960464477539063e-8}, // smallest subnormal + {"f97bff", 65504.0}, // largest finite half + }; + for (const auto& c : finite) { + const auto doc = Decode(FromHex(c.hex)); + ASSERT_TRUE(doc.ok()) << c.hex; + EXPECT_EQ(doc->as_double(), c.expected) << c.hex; + } + const auto inf = Decode(FromHex("f97c00")); + ASSERT_TRUE(inf.ok()); + EXPECT_TRUE(std::isinf(inf->as_double())); + const auto nan = Decode(FromHex("f97e00")); + ASSERT_TRUE(nan.ok()); + EXPECT_TRUE(std::isnan(nan->as_double())); +} + +TEST(CborHostileTest, AcceptsNestingAtTheDocumentedLimitOnly) { + // 63 wrappers around a scalar sit inside the 64-frame budget; 65 must not. + EXPECT_TRUE(Decode(FromHex(Repeat("81", 63, "01"))).ok()); + EXPECT_FALSE(Decode(FromHex(Repeat("81", 65, "01"))).ok()); +} + +TEST(CborHostileTest, AcceptsUnknownTagsAndKeepsTheInnerValue) { + const auto tagged = Decode(FromHex("d82a6161")); // tag 42 around "a" + ASSERT_TRUE(tagged.ok()); + EXPECT_EQ(tagged->as_string(), "a"); +} + +TEST(CborHostileTest, AcceptsIndefiniteEverythingWithinLimits) { + // {"a": [_ "b", {_ "c": h'00'}]} with every container indefinite. + const auto doc = Decode(FromHex("bf61619f6162bf61635f4100ffffffff")); + ASSERT_TRUE(doc.ok()) << (doc.ok() ? "" : doc.error().message()); + ASSERT_TRUE(doc->is_map()); +} + +TEST(CborHostileTest, DuplicateMapKeysDecodeWithoutFault) { + const auto doc = Decode(FromHex("a2616101616102")); // {"a": 1, "a": 2} + ASSERT_TRUE(doc.ok()); + ASSERT_TRUE(doc->is_map()); + EXPECT_NE(doc->Find("a"), nullptr); +} + +// --- The JSONTestSuite-style structural property ------------------------ + +// Every strict prefix of a valid document is itself malformed (CBOR is a +// prefix-free code for a single data item): the decoder must reject all of +// them rather than silently succeed on partial input. +TEST(CborHostileTest, EveryStrictPrefixOfAValidDocumentIsRejected) { + const char* valid[] = { + "1b7fffffffffffffff", // int64 max + "fb3ff199999999999a", // 1.1 + "6449455446", // "IETF" + "4401020304", // h'01020304' + "83010203", // [1, 2, 3] + "a26161016162820203", // {"a": 1, "b": [2, 3]} + "c11a514b67b0", // tag 1 timestamp + "bf61619f6162bf61635f4100ffffffff", // indefinite nest + }; + for (const char* hex : valid) { + const Blob full = FromHex(hex); + ASSERT_TRUE(Decode(full).ok()) << hex; + const auto& bytes = full.bytes(); + for (std::size_t cut = 0; cut < bytes.size(); ++cut) { + Blob prefix(std::vector(bytes.begin(), bytes.begin() + cut)); + EXPECT_FALSE(Decode(prefix).ok()) << hex << " cut to " << cut << " bytes"; + } + } +} + +} // namespace +} // namespace smithy::cbor diff --git a/runtime/tests/client/observability_test.cc b/runtime/tests/client/observability_test.cc new file mode 100644 index 00000000..b74bdd8f --- /dev/null +++ b/runtime/tests/client/observability_test.cc @@ -0,0 +1,121 @@ +#include "smithy/client/observability.h" + +#include + +#include +#include +#include + +#include "smithy/core/error.h" +#include "smithy/core/outcome.h" +#include "smithy/http/message.h" +#include "smithy/http/trace_context.h" + +namespace { + +smithy::http::HttpRequest MakeRequest() { + smithy::http::HttpRequest request; + request.method = "POST"; + request.target = "/books?pageSize=10"; + return request; +} + +TEST(ObserveAttemptsTest, ReportsSuccessfulAttempts) { + std::vector seen; + const auto interceptor = smithy::ObserveAttempts( + [&seen](const smithy::AttemptObservation& obs) { seen.push_back(obs); }); + + smithy::http::HttpResponse response; + response.status = 201; + interceptor->ReadAfterTransmit(MakeRequest(), + smithy::Outcome(response), 1); + + ASSERT_EQ(seen.size(), 1u); + EXPECT_EQ(seen[0].method, "POST"); + EXPECT_EQ(seen[0].target, "/books?pageSize=10"); + EXPECT_EQ(seen[0].attempt, 1); + EXPECT_EQ(seen[0].status, 201); + EXPECT_TRUE(seen[0].error_message.empty()); +} + +TEST(ObserveAttemptsTest, ReportsTransportErrorsAsStatusMinusOne) { + std::vector seen; + const auto interceptor = smithy::ObserveAttempts( + [&seen](const smithy::AttemptObservation& obs) { seen.push_back(obs); }); + + interceptor->ReadAfterTransmit( + MakeRequest(), + smithy::Outcome(smithy::Error::Transport("connection refused")), + 3); + + ASSERT_EQ(seen.size(), 1u); + EXPECT_EQ(seen[0].attempt, 3); + EXPECT_EQ(seen[0].status, -1); + EXPECT_EQ(seen[0].error_message, "connection refused"); +} + +TEST(ObserveAttemptsTest, ObservesEveryAttemptOfARetryLoop) { + int calls = 0; + const auto interceptor = + smithy::ObserveAttempts([&calls](const smithy::AttemptObservation&) { ++calls; }); + smithy::http::HttpResponse throttled; + throttled.status = 429; + for (int attempt = 1; attempt <= 3; ++attempt) { + interceptor->ReadAfterTransmit(MakeRequest(), + smithy::Outcome(throttled), attempt); + } + EXPECT_EQ(calls, 3); +} + +TEST(PropagateTraceContextTest, StampsAWellFormedTraceparent) { + const auto interceptor = smithy::PropagateTraceContext(); + auto request = MakeRequest(); + interceptor->ModifyBeforeTransmit(request, 1); + + const auto header = request.headers.Get("traceparent"); + ASSERT_TRUE(header.has_value()); + const auto parsed = smithy::http::ParseTraceparent(*header); + ASSERT_TRUE(parsed.has_value()) << *header; + EXPECT_TRUE(parsed->sampled); +} + +TEST(PropagateTraceContextTest, RespectsAnExistingTraceparent) { + const auto interceptor = smithy::PropagateTraceContext(); + auto request = MakeRequest(); + const std::string preset = "00-0123456789abcdef0123456789abcdef-0123456789abcdef-01"; + request.headers.Set("traceparent", preset); + interceptor->ModifyBeforeTransmit(request, 1); + EXPECT_EQ(request.headers.Get("traceparent"), preset); +} + +TEST(PropagateTraceContextTest, UsesTheApplicationsCurrentContext) { + smithy::http::TraceContext context; + context.trace_id = "0af7651916cd43dd8448eb211c80319c"; + context.parent_id = "b7ad6b7169203331"; + context.sampled = true; + const auto interceptor = smithy::PropagateTraceContext( + [context]() -> std::optional { return context; }); + + auto request = MakeRequest(); + interceptor->ModifyBeforeTransmit(request, 1); + EXPECT_EQ(request.headers.Get("traceparent"), + "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01"); +} + +TEST(PropagateTraceContextTest, FallsBackToAFreshRootWhenTheCallbackHasNoContext) { + const auto interceptor = smithy::PropagateTraceContext( + []() -> std::optional { return std::nullopt; }); + + auto request = MakeRequest(); + interceptor->ModifyBeforeTransmit(request, 1); + const auto header = request.headers.Get("traceparent"); + ASSERT_TRUE(header.has_value()); + EXPECT_TRUE(smithy::http::ParseTraceparent(*header).has_value()) << *header; + + // Each attempt without an application context gets its own root. + auto second = MakeRequest(); + interceptor->ModifyBeforeTransmit(second, 2); + EXPECT_NE(request.headers.Get("traceparent"), second.headers.Get("traceparent")); +} + +} // namespace diff --git a/runtime/tests/core/regex_test.cc b/runtime/tests/core/regex_test.cc index bb4c2879..dd6964f9 100644 --- a/runtime/tests/core/regex_test.cc +++ b/runtime/tests/core/regex_test.cc @@ -2,7 +2,7 @@ #include -#include +#include #include #include #include @@ -127,26 +127,33 @@ TEST(RegexTest, UnsupportedConstructsFailAtCompileTime) { // The whole point: the deliberately catastrophic pattern from the protocol // test suites evaluates in linear time. Under a backtracking engine this -// input takes longer than the age of the universe; here it must be -// effectively instant (the generous bound keeps slow CI machines green). +// input explores ~2^100000 paths; the Pike VM's stamp dedup runs each +// instruction at most once per input position, so the step count is bounded +// by program size x (input size + 2). Asserting on the counter keeps the +// bound deterministic — a loaded CI runner can't flake it the way a +// wall-clock limit could. TEST(RegexTest, CatastrophicPatternIsLinear) { auto re = smithy::Regex::Compile("^([0-9]+)+$"); ASSERT_TRUE(re.ok()); std::string evil(100000, '1'); evil.push_back('!'); - const auto start = std::chrono::steady_clock::now(); - EXPECT_FALSE(re->Search(evil)); - const auto elapsed = std::chrono::steady_clock::now() - start; - EXPECT_LT(elapsed, std::chrono::seconds(5)); + std::size_t steps = 0; + EXPECT_FALSE(re->Search(evil, &steps)); + EXPECT_LE(steps, re->ProgramSize() * (evil.size() + 2)); std::string good(100000, '7'); EXPECT_TRUE(re->Search(good)); } TEST(RegexTest, MoreNestedQuantifierBombs) { std::string as(50000, 'a'); - EXPECT_FALSE(Search("^(a+)+$", as + "b")); - EXPECT_FALSE(Search("^(a|a)+$", as + "b")); - EXPECT_FALSE(Search("^(a*)*$", as + "b")); + const std::string bomb = as + "b"; + for (const char* pattern : {"^(a+)+$", "^(a|a)+$", "^(a*)*$"}) { + auto re = smithy::Regex::Compile(pattern); + ASSERT_TRUE(re.ok()) << pattern; + std::size_t steps = 0; + EXPECT_FALSE(re->Search(bomb, &steps)) << pattern; + EXPECT_LE(steps, re->ProgramSize() * (bomb.size() + 2)) << pattern; + } EXPECT_TRUE(Search("^(a+)+$", as)); } diff --git a/runtime/tests/core/uuid_test.cc b/runtime/tests/core/uuid_test.cc new file mode 100644 index 00000000..25f28158 --- /dev/null +++ b/runtime/tests/core/uuid_test.cc @@ -0,0 +1,63 @@ +#include "smithy/core/uuid.h" + +#include + +#include +#include +#include +#include +#include + +namespace { + +TEST(UuidTest, CanonicalForm) { + const std::string uuid = smithy::GenerateUuidV4(); + ASSERT_EQ(uuid.size(), 36u); + for (std::size_t i = 0; i < uuid.size(); ++i) { + if (i == 8 || i == 13 || i == 18 || i == 23) { + EXPECT_EQ(uuid[i], '-') << uuid; + } else { + EXPECT_TRUE(std::isxdigit(static_cast(uuid[i]))) << uuid; + EXPECT_FALSE(std::isupper(static_cast(uuid[i]))) << uuid; + } + } +} + +TEST(UuidTest, VersionAndVariantBits) { + // RFC 4122: the version nibble is always 4, the variant nibble 10xx — + // stable across every generated value, not just one sample. + for (int i = 0; i < 256; ++i) { + const std::string uuid = smithy::GenerateUuidV4(); + EXPECT_EQ(uuid[14], '4') << uuid; + EXPECT_TRUE(uuid[19] == '8' || uuid[19] == '9' || uuid[19] == 'a' || uuid[19] == 'b') << uuid; + } +} + +TEST(UuidTest, ValuesDoNotRepeat) { + std::set seen; + for (int i = 0; i < 1000; ++i) { + EXPECT_TRUE(seen.insert(smithy::GenerateUuidV4()).second) << "duplicate uuid"; + } +} + +TEST(UuidTest, ThreadLocalGeneratorsProduceDistinctStreams) { + // The generator is thread_local and seeded per thread from random_device; + // concurrent threads must not mirror each other's sequences. + constexpr int kThreads = 4; + constexpr int kPerThread = 100; + std::vector> results(kThreads); + std::vector threads; + threads.reserve(kThreads); + for (int t = 0; t < kThreads; ++t) { + threads.emplace_back([&results, t] { + results[t].reserve(kPerThread); + for (int i = 0; i < kPerThread; ++i) results[t].push_back(smithy::GenerateUuidV4()); + }); + } + for (auto& thread : threads) thread.join(); + std::set all; + for (const auto& batch : results) all.insert(batch.begin(), batch.end()); + EXPECT_EQ(all.size(), static_cast(kThreads * kPerThread)); +} + +} // namespace