diff --git a/.bazelrc b/.bazelrc index 5ab9634..9018ba0 100644 --- a/.bazelrc +++ b/.bazelrc @@ -1,4 +1,5 @@ # TODO(#57): Workaround for errors related to linker script when compiling abseil: # error: relocation refers to local symbol "...", which is defined in a discarded section build --copt=-fno-asynchronous-unwind-tables -build --linkopt=-Wl,--gc-sections \ No newline at end of file +build --linkopt=-Wl,--gc-sections + diff --git a/.clang-tidy b/.clang-tidy index cb28593..bbd5888 100644 --- a/.clang-tidy +++ b/.clang-tidy @@ -10,7 +10,7 @@ Checks: " -modernize-use-nodiscard, " -HeaderFilterRegex: ".*/flagd/.*" +HeaderFilterRegex: ".*/flagd|hooks/.*" ExcludeHeaderFilterRegex: ".*\\.pb\\.h|.*\\.grpc\\.pb\\.h" # Treat all warnings as errors (optional, good for strict CI/CD) diff --git a/.github/workflows/ci-format-test-lint.yml b/.github/workflows/ci-format-test-lint.yml index a4a9b9f..1121839 100644 --- a/.github/workflows/ci-format-test-lint.yml +++ b/.github/workflows/ci-format-test-lint.yml @@ -24,7 +24,7 @@ jobs: run: bazel build //... - name: Test - run: bazel test //providers/... + run: bazel test //... lint: runs-on: ubuntu-latest diff --git a/MODULE.bazel b/MODULE.bazel index 4680ec6..d9ec7a6 100644 --- a/MODULE.bazel +++ b/MODULE.bazel @@ -4,7 +4,7 @@ bazel_dep(name = "rules_cc", version = "0.2.25") bazel_dep(name = "rules_python", version = "2.3.3") bazel_dep(name = "rules_proto", version = "7.1.0") bazel_dep(name = "googletest", version = "1.18.0.bcr.1") -bazel_dep(name = "abseil-cpp", version = "20250814.2") +bazel_dep(name = "abseil-cpp", version = "20260817.0") bazel_dep(name = "openfeature_cpp_sdk") git_override( module_name = "openfeature_cpp_sdk", diff --git a/hooks/codereadiness/LICENSE b/hooks/codereadiness/LICENSE new file mode 100644 index 0000000..d60b0aa --- /dev/null +++ b/hooks/codereadiness/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright OpenFeature Maintainers + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. \ No newline at end of file diff --git a/hooks/codereadiness/README.md b/hooks/codereadiness/README.md new file mode 100644 index 0000000..de5caa7 --- /dev/null +++ b/hooks/codereadiness/README.md @@ -0,0 +1,175 @@ +# Code Readiness Hook + +The `codereadiness` hook allows controlling feature flag evaluation based on the version of the application code. +It does this by comparing the current application version with a required minimum version specified in the flag's metadata. +If the comparison fails (i.e., the application version is lower than the required version), the hook throws an exception, causing the flag evaluation to resolve to its default value. + +## Setup + +First, add the hook as a dependency of your Bazel target: + +```python +cc_binary( + name = "my_app", + srcs = ["main.cpp"], + deps = [ + "@cpp_sdk_contrib//hooks/codereadiness/src:codereadiness", + "@openfeature_cpp_sdk//openfeature:openfeature_api", + ], +) +``` + +Then, include the OpenFeature SDK and the code readiness hook: + +```cpp +#include "codereadiness/codereadiness.h" +#include "openfeature/openfeature_api.h" +``` + +Finally, create the hook with the current version of the application code and register it: + +```cpp +// current_version is the current version of the code, which can be retrieved +// from environment variables, build tags, or configuration files. +std::string current_version = "1.4.2"; + +absl::StatusOr> hook = + codereadiness::CodeReadinessHook::Create(current_version); +if (!hook.ok()) { + // Handle the error, e.g. empty current_version. + LOG(ERROR) << "Failed to create code readiness hook: " << hook.status(); + return; +} + +// Register the hook globally (it can also be registered on a specific client +// with `client->AddHook(*hook)`). +openfeature::OpenFeatureAPI::GetInstance().AddHook(*hook); +``` + +`Create` returns an error when: + +* `current_version` is empty, +* the metadata key option is empty, +* the comparator fails to initialize with `current_version` (e.g. it's not a + valid semver). This applies regardless of `strict_validation`. + +## How It Works + +1. The hook runs during the **After** phase of flag evaluation. +2. It extracts the metadata associated with the evaluated flag. +3. It looks for a specific metadata key (by default, `minCodeVersion`). The metadata value must be a **string**. +4. If found, it compares the current application version against the required minimum version using the configured comparator (by default, a semver comparison). +5. If the current version is **lower** than the required version, it throws a `std::runtime_error`. This triggers the OpenFeature SDK's fallback mechanism, returning the flag's **default value** to the caller. + +For example, given the following flag metadata (e.g. in a flagd configuration): + +```json +{ + "flags": { + "new-checkout": { + "state": "ENABLED", + "variants": { "on": true, "off": false }, + "defaultVariant": "on", + "metadata": { "minCodeVersion": "1.5.0" } + } + } +} +``` + +an application created with `current_version = "1.4.2"` evaluates `new-checkout` to the default value passed by the caller, while an application with `current_version = "1.5.0"` or higher evaluates it to `true`. + +## Options + +The behavior of the hook can be customized by passing a `CodeReadinessHookOptions` struct to `Create`: + +| Field | Type | Default | Description | +| ------------------------------ | ------------------------------------ | --------------------------- | ---------------------------------------------------------------------- | +| `strict_validation` | `bool` | `false` | Fail evaluation when version data is missing or invalid. | +| `min_version_metadata_key` | `std::string` | `"minCodeVersion"` | Flag metadata key holding the required minimum version. Must not be empty. | +| `comparator` | `std::unique_ptr` | `nullptr` (`SemVerComparator`) | Custom version comparison logic. | + +### Strict Validation + +By default, the hook will **not** fail if the version values are missing, empty or have an invalid version format; it logs the reason and skips the validation. To enforce validation and fail the evaluation when version values are missing or invalid, set `strict_validation` to `true`. + +```cpp +codereadiness::CodeReadinessHookOptions options; +options.strict_validation = true; + +absl::StatusOr> hook = + codereadiness::CodeReadinessHook::Create(current_version, + std::move(options)); +``` + +The table below summarizes the behavior in both modes: + +| Situation | `strict_validation = false` | `strict_validation = true` | +| ------------------------------------------------------ | ------------------------------ | -------------------------- | +| `current_version` is invalid (at `Create`) | `Create` returns an error | `Create` returns an error | +| Flag metadata is empty | Skip | Evaluation fails | +| Metadata key is missing | Skip | Evaluation fails | +| Metadata value is not a **string** or is **empty** | Skip | Evaluation fails | +| Comparator returns an error | Skip | Evaluation fails | +| Current version **lower** than the required version | Evaluation fails | Evaluation fails | +| Current version greater than or equal to the required version | Pass | Pass | + +### Custom Metadata Key + +To configure the hook to look for a key other than the default `"minCodeVersion"` in the flag's metadata, set `min_version_metadata_key`. + +```cpp +codereadiness::CodeReadinessHookOptions options; +options.min_version_metadata_key = "requiredAppVersion"; + +absl::StatusOr> hook = + codereadiness::CodeReadinessHook::Create(current_version, + std::move(options)); +``` + +### Custom Comparator + +By default, the hook uses `SemVerComparator`, which implements [Semantic Versioning 2.0.0](https://semver.org/) precedence. It accepts an optional leading `v`/`V` (e.g. `v1.2.3`/`V1.2.3`) and the `MAJOR` and `MAJOR.MINOR` shorthands (e.g. `2` is treated as `2.0.0`); build metadata (`+build`) is ignored when comparing. Version components must fit in a 64-bit unsigned integer. + +If the application uses a different versioning scheme (such as date-based versioning, revision numbers, or custom build numbers), a custom comparator implementing the `VersionComparator` interface can be provided using the `comparator` field: + +```cpp +#include "codereadiness/version_comparator.h" + +// Compares plain build numbers, e.g. "1042" >= "1000". +class BuildNumberComparator final : public codereadiness::VersionComparator { + public: + // Called once by `Create` with the application's current version. A non-OK + // status makes `Create` fail. + absl::Status Initialize(std::string_view current_version) override { + if (!absl::SimpleAtoi(current_version, ¤t_)) { + return absl::InvalidArgumentError("invalid build number"); + } + return absl::OkStatus(); + } + + // Returns true if the current version >= required_version. Only called + // after a successful `Initialize`. Called concurrently from different flag + // evaluations, so it must be thread-safe. + absl::StatusOr Compare( + std::string_view required_version) const override { + uint64_t required = 0; + if (!absl::SimpleAtoi(required_version, &required)) { + return absl::InvalidArgumentError("invalid required build number"); + } + return current_ >= required; + } + + private: + uint64_t current_ = 0; +}; + +codereadiness::CodeReadinessHookOptions options; +options.comparator = std::make_unique(); + +absl::StatusOr> hook = + codereadiness::CodeReadinessHook::Create("1042", std::move(options)); +``` + +### Logging + +The hook logs skipped validations using Abseil verbose logging at level 1 (`VLOG(1)`). To see these messages, enable verbose logging, for example with `--v=1` or `--vmodule=codereadiness=1`. diff --git a/hooks/codereadiness/src/BUILD b/hooks/codereadiness/src/BUILD new file mode 100644 index 0000000..bef02de --- /dev/null +++ b/hooks/codereadiness/src/BUILD @@ -0,0 +1,65 @@ +load("@rules_cc//cc:defs.bzl", "cc_library") + +package(default_visibility = ["//visibility:public"]) + +# Interface for comparing the binary's current version against a flag's +# required minimum version. +cc_library( + name = "version_comparator", + hdrs = ["version_comparator.h"], + include_prefix = "codereadiness", + deps = [ + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:statusor", + ], +) + +# SemVer 2.0.0 parsing and precedence. +cc_library( + name = "semver", + srcs = ["semver.cpp"], + hdrs = ["semver.h"], + include_prefix = "codereadiness", + deps = [ + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings", + ], +) + +# Default VersionComparator implementation, based on SemVer. +cc_library( + name = "semver_comparator", + srcs = ["semver_comparator.cpp"], + hdrs = ["semver_comparator.h"], + include_prefix = "codereadiness", + deps = [ + ":semver", + ":version_comparator", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings", + ], +) + +# OpenFeature `after` hook that fails flag evaluation when the current code +# version is lower than the flag's required minimum version. +cc_library( + name = "codereadiness", + srcs = ["codereadiness.cpp"], + hdrs = ["codereadiness.h"], + include_prefix = "codereadiness", + deps = [ + ":semver_comparator", + ":version_comparator", + "@abseil-cpp//absl/log", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings", + "@openfeature_cpp_sdk//openfeature:flag_metadata", + "@openfeature_cpp_sdk//openfeature:general_flag_evaluation_details", + "@openfeature_cpp_sdk//openfeature:general_hook", + "@openfeature_cpp_sdk//openfeature:general_hook_context", + "@openfeature_cpp_sdk//openfeature:hook_hints", + ], +) diff --git a/hooks/codereadiness/src/codereadiness.cpp b/hooks/codereadiness/src/codereadiness.cpp new file mode 100644 index 0000000..cdcfdad --- /dev/null +++ b/hooks/codereadiness/src/codereadiness.cpp @@ -0,0 +1,119 @@ +#include "codereadiness/codereadiness.h" + +#include +#include +#include +#include +#include +#include + +#include "absl/log/log.h" +#include "absl/status/status.h" +#include "absl/status/statusor.h" +#include "absl/strings/str_cat.h" +#include "codereadiness/semver_comparator.h" +#include "openfeature/flag_metadata.h" +#include "openfeature/general_flag_evaluation_details.h" +#include "openfeature/general_hook.h" +#include "openfeature/general_hook_context.h" +#include "openfeature/hook_hints.h" + +namespace codereadiness { + +absl::StatusOr> CodeReadinessHook::Create( + std::string current_version, CodeReadinessHookOptions options) { + if (current_version.empty()) { + return absl::InvalidArgumentError( + "codereadiness: current_version cannot be empty"); + } + std::unique_ptr comparator = std::move(options.comparator); + if (comparator == nullptr) { + comparator = std::make_unique(); + } + if (options.min_version_metadata_key.empty()) { + return absl::InvalidArgumentError( + "codereadiness: min_version_metadata_key cannot be empty"); + } + if (absl::Status status = comparator->Initialize(current_version); + !status.ok()) { + return absl::Status( + status.code(), + absl::StrCat("codereadiness: comparator initialization failed: ", + status.message())); + } + return std::shared_ptr(new CodeReadinessHook( + std::move(current_version), std::move(comparator), + options.strict_validation, std::move(options.min_version_metadata_key))); +} + +CodeReadinessHook::CodeReadinessHook( + std::string current_version, std::unique_ptr comparator, + bool strict_validation, std::string metadata_min_ver_key) + : current_version_(std::move(current_version)), + comparator_(std::move(comparator)), + strict_validation_(strict_validation), + metadata_min_ver_key_(std::move(metadata_min_ver_key)) {} + +void CodeReadinessHook::After( + const openfeature::GeneralHookContext& ctx, + const openfeature::GeneralFlagEvaluationDetails& details, + const openfeature::HookHints& hints) { + const std::string_view flag_key = ctx.GetFlagKey(); + const auto& metadata = details.GetFlagMetadata().data; + if (metadata.empty()) { + FailOrSkip(flag_key, "flag metadata is empty"); + return; + } + + auto it_min_code_version = metadata.find(metadata_min_ver_key_); + if (it_min_code_version == metadata.end()) { + FailOrSkip(flag_key, absl::StrCat("metadata key \"", metadata_min_ver_key_, + "\" is missing")); + return; + } + + const std::string* min_code_version = + std::get_if(&it_min_code_version->second); + if (min_code_version == nullptr) { + FailOrSkip(flag_key, absl::StrCat("metadata key \"", metadata_min_ver_key_, + "\" is not a string")); + return; + } + if (min_code_version->empty()) { + FailOrSkip(flag_key, absl::StrCat("metadata key \"", metadata_min_ver_key_, + "\" value is empty")); + return; + } + + absl::StatusOr meets_min_version = + comparator_->Compare(*min_code_version); + if (!meets_min_version.ok()) { + FailOrSkip(flag_key, absl::StrCat("invalid version values current: \"", + current_version_, "\" - minimum: \"", + *min_code_version, "\": ", + meets_min_version.status().message())); + return; + } + if (!*meets_min_version) { + throw std::runtime_error(FormatError( + flag_key, absl::StrCat("current version \"", current_version_, + "\" is lower than required minimum version \"", + *min_code_version, "\""))); + } +} + +std::string CodeReadinessHook::FormatError(std::string_view flag_key, + std::string_view detail) { + return absl::StrCat("codereadiness: flag \"", flag_key, "\": ", detail); +} + +void CodeReadinessHook::FailOrSkip(std::string_view flag_key, + std::string_view detail) const { + std::string message = FormatError(flag_key, detail); + if (strict_validation_) { + throw std::runtime_error(message); + } + VLOG(1) << message << ", skipping validation"; +} + +} // namespace codereadiness diff --git a/hooks/codereadiness/src/codereadiness.h b/hooks/codereadiness/src/codereadiness.h new file mode 100644 index 0000000..69830f4 --- /dev/null +++ b/hooks/codereadiness/src/codereadiness.h @@ -0,0 +1,63 @@ +#ifndef CPP_SDK_CONTRIB_HOOKS_CODEREADINESS_CODEREADINESS_H_ +#define CPP_SDK_CONTRIB_HOOKS_CODEREADINESS_CODEREADINESS_H_ + +#include +#include +#include + +#include "absl/status/statusor.h" +#include "codereadiness/version_comparator.h" +#include "openfeature/general_flag_evaluation_details.h" +#include "openfeature/general_hook.h" +#include "openfeature/general_hook_context.h" +#include "openfeature/hook_hints.h" + +namespace codereadiness { + +struct CodeReadinessHookOptions { + // Compares versions. If null, a `SemVerComparator` is used. + std::unique_ptr comparator; + // Flag metadata key holding the flag's minimum required code version. + std::string min_version_metadata_key = "minCodeVersion"; + // If true, malformed or missing version data fails the evaluation; + // If false, it is logged at VLOG(1) and the check is skipped. A well-formed + // current version lower than the required one always fails the evaluation. + bool strict_validation = false; +}; + +// OpenFeature hook that fails a flag evaluation when the binary's version is +// lower than the minimum version stored in the flag's metadata. +// +// Thread-safe: a single instance may be shared across evaluations. +class CodeReadinessHook final : public openfeature::GeneralHook { + public: + // Returns `kInvalidArgument` if `current_version` or the metadata key is + // empty. Returns the comparator's error (regardless of `strict_validation`) + // if it fails to initialize with `current_version`. + static absl::StatusOr> Create( + std::string current_version, CodeReadinessHookOptions options = {}); + + // Throws `std::runtime_error` to fail the evaluation. + void After(const openfeature::GeneralHookContext& ctx, + const openfeature::GeneralFlagEvaluationDetails& details, + const openfeature::HookHints& hints) override; + + private: + CodeReadinessHook(std::string current_version, + std::unique_ptr comparator, + bool strict_validation, std::string metadata_min_ver_key); + + static std::string FormatError(std::string_view flag_key, + std::string_view detail); + + void FailOrSkip(std::string_view flag_key, std::string_view detail) const; + + const std::string current_version_; + const std::unique_ptr comparator_; + const bool strict_validation_; + const std::string metadata_min_ver_key_; +}; + +} // namespace codereadiness + +#endif // CPP_SDK_CONTRIB_HOOKS_CODEREADINESS_CODEREADINESS_H_ diff --git a/hooks/codereadiness/src/semver.cpp b/hooks/codereadiness/src/semver.cpp new file mode 100644 index 0000000..cc6b385 --- /dev/null +++ b/hooks/codereadiness/src/semver.cpp @@ -0,0 +1,200 @@ +#include "codereadiness/semver.h" + +#include +#include +#include +#include +#include +#include + +#include "absl/status/status.h" +#include "absl/status/statusor.h" +#include "absl/strings/ascii.h" +#include "absl/strings/numbers.h" +#include "absl/strings/str_cat.h" +#include "absl/strings/str_split.h" + +namespace codereadiness { +namespace { + +// Checks if a string has a leading zero (and is not just "0"). +bool HasLeadingZero(std::string_view str) { + return str.size() > 1 && str[0] == '0'; +} + +// Returns true if the string is non-empty and consists only of ASCII digits. +bool IsNumeric(std::string_view str) { + return !str.empty() && std::all_of(str.begin(), str.end(), [](char chr) { + return absl::ascii_isdigit(static_cast(chr)); + }); +} + +// SemVer identifier characters are [0-9A-Za-z-] (ASCII only). +bool IsIdentifierChar(char chr) { + return absl::ascii_isalnum(static_cast(chr)) || chr == '-'; +} + +// Parses number according to SemVer 2.0.0 specification. +absl::StatusOr ParseSemVerNum(std::string_view num_str, + std::string_view name) { + if (!IsNumeric(num_str)) { + return absl::InvalidArgumentError( + absl::StrCat("Invalid SemVer ", name, " digits: \"", num_str, "\"")); + } + if (HasLeadingZero(num_str)) { + return absl::InvalidArgumentError(absl::StrCat( + name, " version MUST NOT contain leading zeros: ", num_str)); + } + uint64_t value = 0; + if (!absl::SimpleAtoi(num_str, &value)) { + return absl::InvalidArgumentError( + absl::StrCat("SemVer ", name, " is too large: ", num_str)); + } + return value; +} + +// Validates a dot-separated list of identifiers (pre-release or build). +absl::Status ValidateIdentifiers(std::string_view identifiers, + std::string_view kind, + bool reject_numeric_leading_zero) { + for (std::string_view ident : absl::StrSplit(identifiers, '.')) { + if (ident.empty()) { + return absl::InvalidArgumentError( + absl::StrCat("Empty ", kind, " identifier")); + } + if (!std::all_of(ident.begin(), ident.end(), IsIdentifierChar)) { + return absl::InvalidArgumentError(absl::StrCat( + "Invalid character in ", kind, " identifier: \"", ident, "\"")); + } + if (reject_numeric_leading_zero && IsNumeric(ident) && + HasLeadingZero(ident)) { + return absl::InvalidArgumentError(absl::StrCat( + "Numeric ", kind, " identifiers MUST NOT contain leading zeros")); + } + } + return absl::OkStatus(); +} + +} // namespace + +absl::StatusOr SemVer::Parse(std::string_view text) { + if (!text.empty() && (text[0] == 'v')) { + text.remove_prefix(1); + } + + // 1. Split off build metadata at the first '+' and validate it + bool has_build = false; + if (size_t plus = text.find('+'); plus != std::string_view::npos) { + has_build = true; + std::string_view build = text.substr(plus + 1); + text = text.substr(0, plus); + if (absl::Status status = ValidateIdentifiers(build, "build", false); + !status.ok()) { + return status; + } + } + + // 2. Separate core and pre-release at the first '-'. + bool has_pre_release = false; + std::string_view pre_release_text; + if (size_t dash = text.find('-'); dash != std::string_view::npos) { + has_pre_release = true; + pre_release_text = text.substr(dash + 1); + text = text.substr(0, dash); + } + std::string_view core = text; + + // 3. Parse core components (major[.minor[.patch]]). + std::vector core_parts = absl::StrSplit(core, '.'); + if (core_parts.empty() || core_parts.size() > 3) { + return absl::InvalidArgumentError( + absl::StrCat("Invalid SemVer core: ", core)); + } + // 4. Validate that shorthand versions do not have pre-release or build + // metadata. + if (core_parts.size() < 3 && (has_pre_release || has_build)) { + return absl::InvalidArgumentError( + absl::StrCat("Shorthand SemVer \"", core, + "\" cannot have pre-release or build metadata")); + } + + absl::StatusOr major = ParseSemVerNum(core_parts[0], "Major"); + if (!major.ok()) return major.status(); + + uint64_t minor = 0; + if (core_parts.size() >= 2) { + absl::StatusOr minor_res = ParseSemVerNum(core_parts[1], "Minor"); + if (!minor_res.ok()) return minor_res.status(); + minor = *minor_res; + } + + uint64_t patch = 0; + if (core_parts.size() == 3) { + absl::StatusOr patch_res = ParseSemVerNum(core_parts[2], "Patch"); + if (!patch_res.ok()) return patch_res.status(); + patch = *patch_res; + } + + // 4. Parse pre-release identifiers. + std::vector pre_release; + if (has_pre_release) { + if (absl::Status status = + ValidateIdentifiers(pre_release_text, "pre-release", true); + !status.ok()) { + return status; + } + for (std::string_view ident : absl::StrSplit(pre_release_text, '.')) { + pre_release.emplace_back(ident); + } + } + + return SemVer(*major, minor, patch, std::move(pre_release)); +} + +int SemVer::Compare(const SemVer& other) const { + if (major_ != other.major_) return major_ < other.major_ ? -1 : 1; + if (minor_ != other.minor_) return minor_ < other.minor_ ? -1 : 1; + if (patch_ != other.patch_) return patch_ < other.patch_ ? -1 : 1; + + // A normal version has higher precedence than a pre-release version + if (pre_release_.empty() && !other.pre_release_.empty()) return 1; + if (!pre_release_.empty() && other.pre_release_.empty()) return -1; + if (pre_release_.empty() && other.pre_release_.empty()) return 0; + + size_t len = std::min(pre_release_.size(), other.pre_release_.size()); + for (size_t i = 0; i < len; ++i) { + const std::string& lhs_part = pre_release_[i]; + const std::string& rhs_part = other.pre_release_[i]; + + // Numeric identifiers have lower precedence than non-numeric identifiers. + const bool lhs_is_num = IsNumeric(lhs_part); + const bool rhs_is_num = IsNumeric(rhs_part); + + if (lhs_is_num && rhs_is_num) { + // Compare numerically by length first, then lexicographically. + // This supports arbitrary precision integers without overflow. + if (lhs_part.length() != rhs_part.length()) { + return lhs_part.length() < rhs_part.length() ? -1 : 1; + } + if (lhs_part != rhs_part) return lhs_part < rhs_part ? -1 : 1; + } else if (lhs_is_num && !rhs_is_num) { + return -1; + } else if (!lhs_is_num && rhs_is_num) { + return 1; + } else { + // Non-numeric identifiers are compared lexicographically in ASCII sort + // order. + if (lhs_part != rhs_part) return lhs_part < rhs_part ? -1 : 1; + } + } + + // A larger set of pre-release fields has a higher precedence than a smaller + // set. + if (pre_release_.size() != other.pre_release_.size()) { + return pre_release_.size() < other.pre_release_.size() ? -1 : 1; + } + + return 0; +} + +} // namespace codereadiness diff --git a/hooks/codereadiness/src/semver.h b/hooks/codereadiness/src/semver.h new file mode 100644 index 0000000..7e5ba2f --- /dev/null +++ b/hooks/codereadiness/src/semver.h @@ -0,0 +1,49 @@ +#ifndef CPP_SDK_CONTRIB_HOOKS_CODEREADINESS_SEMVER_H_ +#define CPP_SDK_CONTRIB_HOOKS_CODEREADINESS_SEMVER_H_ + +#include +#include +#include +#include +#include + +#include "absl/status/statusor.h" + +namespace codereadiness { + +// Represents a Semantic Version (SemVer 2.0.0) for comparison. +// Parsing follows golang.org/x/mod/semver: optional leading 'v', MAJOR and +// MAJOR.MINOR shorthands (without pre-release/build), strict identifiers. +class SemVer { + public: + // Parses `text` as a SemVer 2.0.0 version. Accepts an optional leading + // 'v' and the MAJOR / MAJOR.MINOR shorthands (missing parts default to + // 0; shorthands cannot have pre-release or build metadata). Build metadata is + // validated but discarded. Returns `kInvalidArgument` on malformed input. + static absl::StatusOr Parse(std::string_view text); + explicit SemVer(uint64_t major = 0, uint64_t minor = 0, uint64_t patch = 0, + std::vector pre_release = {}) + : major_(major), + minor_(minor), + patch_(patch), + pre_release_(std::move(pre_release)) {} + + uint64_t GetMajor() const { return major_; } + uint64_t GetMinor() const { return minor_; } + uint64_t GetPatch() const { return patch_; } + const std::vector& GetPreRelease() const { return pre_release_; } + + // SemVer 2.0.0 precedence. Returns -1 if this < other, 0 if equal, 1 if this + // > other. + int Compare(const SemVer& other) const; + + private: + uint64_t major_; + uint64_t minor_; + uint64_t patch_; + std::vector pre_release_; +}; + +} // namespace codereadiness + +#endif // CPP_SDK_CONTRIB_HOOKS_CODEREADINESS_SEMVER_H_ diff --git a/hooks/codereadiness/src/semver_comparator.cpp b/hooks/codereadiness/src/semver_comparator.cpp new file mode 100644 index 0000000..cce2e6f --- /dev/null +++ b/hooks/codereadiness/src/semver_comparator.cpp @@ -0,0 +1,36 @@ +#include "codereadiness/semver_comparator.h" + +#include +#include + +#include "absl/status/status.h" +#include "absl/status/statusor.h" +#include "absl/strings/str_cat.h" +#include "codereadiness/semver.h" + +namespace codereadiness { + +absl::Status SemVerComparator::Initialize(std::string_view current_version) { + absl::StatusOr parsed = SemVer::Parse(current_version); + if (!parsed.ok()) { + return absl::InvalidArgumentError( + absl::StrCat("invalid current semver: \"", current_version, + "\": ", parsed.status().message())); + } + parsed_current_ = *std::move(parsed); + return absl::OkStatus(); +} + +absl::StatusOr SemVerComparator::Compare( + std::string_view required_version) const { + absl::StatusOr required = SemVer::Parse(required_version); + if (!required.ok()) { + return absl::InvalidArgumentError( + absl::StrCat("invalid required semver: \"", required_version, + "\": ", required.status().message())); + } + + return parsed_current_.Compare(*required) >= 0; +} + +} // namespace codereadiness diff --git a/hooks/codereadiness/src/semver_comparator.h b/hooks/codereadiness/src/semver_comparator.h new file mode 100644 index 0000000..6648220 --- /dev/null +++ b/hooks/codereadiness/src/semver_comparator.h @@ -0,0 +1,29 @@ +#ifndef CPP_SDK_CONTRIB_HOOKS_CODEREADINESS_SEMVER_COMPARATOR_H_ +#define CPP_SDK_CONTRIB_HOOKS_CODEREADINESS_SEMVER_COMPARATOR_H_ + +#include + +#include "absl/status/status.h" +#include "absl/status/statusor.h" +#include "codereadiness/semver.h" +#include "codereadiness/version_comparator.h" + +namespace codereadiness { + +// Default `VersionComparator`: interprets both versions as SemVer 2.0.0 (see +// `SemVer::Parse` for accepted formats) and reports whether the current +// version has precedence >= the required version. +class SemVerComparator final : public VersionComparator { + public: + absl::Status Initialize(std::string_view current_version) override; + + absl::StatusOr Compare( + std::string_view required_version) const override; + + private: + SemVer parsed_current_; +}; + +} // namespace codereadiness + +#endif // CPP_SDK_CONTRIB_HOOKS_CODEREADINESS_SEMVER_COMPARATOR_H_ diff --git a/hooks/codereadiness/src/version_comparator.h b/hooks/codereadiness/src/version_comparator.h new file mode 100644 index 0000000..a05b0f5 --- /dev/null +++ b/hooks/codereadiness/src/version_comparator.h @@ -0,0 +1,34 @@ +#ifndef CPP_SDK_CONTRIB_HOOKS_CODEREADINESS_VERSION_COMPARATOR_H_ +#define CPP_SDK_CONTRIB_HOOKS_CODEREADINESS_VERSION_COMPARATOR_H_ + +#include + +#include "absl/status/status.h" +#include "absl/status/statusor.h" + +namespace codereadiness { + +// Compares the binary's current version against a flag's required +// minimum version. +class VersionComparator { + public: + VersionComparator() = default; + VersionComparator(const VersionComparator&) = delete; + VersionComparator& operator=(const VersionComparator&) = delete; + virtual ~VersionComparator() = default; + + // Called once when the hook is created, with the binary's current version. + // A non-OK status makes hook creation fail. + virtual absl::Status Initialize(std::string_view current_version) = 0; + + // Returns true if current version >= required_version, or an error if + // `required_version` can't be parsed. Only called after a successful + // `Initialize`. Must be thread-safe since it's called concurrently from + // different flag evaluations. + virtual absl::StatusOr Compare( + std::string_view required_version) const = 0; +}; + +} // namespace codereadiness + +#endif // CPP_SDK_CONTRIB_HOOKS_CODEREADINESS_VERSION_COMPARATOR_H_ diff --git a/hooks/codereadiness/tests/BUILD b/hooks/codereadiness/tests/BUILD new file mode 100644 index 0000000..b74dfe9 --- /dev/null +++ b/hooks/codereadiness/tests/BUILD @@ -0,0 +1,52 @@ +load("@rules_cc//cc:defs.bzl", "cc_test") + +cc_test( + name = "semver_test", + size = "small", + srcs = ["semver_test.cpp"], + deps = [ + "//hooks/codereadiness/src:semver", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_matchers", + "@abseil-cpp//absl/status:statusor", + "@googletest//:gtest_main", + ], +) + +cc_test( + name = "semver_comparator_test", + size = "small", + srcs = ["semver_comparator_test.cpp"], + deps = [ + "//hooks/codereadiness/src:semver_comparator", + "//hooks/codereadiness/src:version_comparator", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_matchers", + "@abseil-cpp//absl/status:statusor", + "@googletest//:gtest_main", + ], +) + +cc_test( + name = "codereadiness_test", + size = "small", + srcs = ["codereadiness_test.cpp"], + deps = [ + "//hooks/codereadiness/src:codereadiness", + "//hooks/codereadiness/src:version_comparator", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_matchers", + "@abseil-cpp//absl/status:statusor", + "@googletest//:gtest_main", + "@openfeature_cpp_sdk//openfeature:evaluation_context", + "@openfeature_cpp_sdk//openfeature:flag_evaluation_details", + "@openfeature_cpp_sdk//openfeature:flag_metadata", + "@openfeature_cpp_sdk//openfeature:flag_type_value", + "@openfeature_cpp_sdk//openfeature:general_hook", + "@openfeature_cpp_sdk//openfeature:hook_context", + "@openfeature_cpp_sdk//openfeature:hook_data", + "@openfeature_cpp_sdk//openfeature:hook_hints", + "@openfeature_cpp_sdk//openfeature:metadata", + "@openfeature_cpp_sdk//openfeature:reason", + ], +) diff --git a/hooks/codereadiness/tests/codereadiness_test.cpp b/hooks/codereadiness/tests/codereadiness_test.cpp new file mode 100644 index 0000000..9c09e68 --- /dev/null +++ b/hooks/codereadiness/tests/codereadiness_test.cpp @@ -0,0 +1,476 @@ +#include "codereadiness/codereadiness.h" + +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +#include "absl/status/status.h" +#include "absl/status/status_matchers.h" +#include "absl/status/statusor.h" +#include "codereadiness/version_comparator.h" +#include "openfeature/evaluation_context.h" +#include "openfeature/flag_evaluation_details.h" +#include "openfeature/flag_metadata.h" +#include "openfeature/flag_type_value.h" +#include "openfeature/general_hook.h" +#include "openfeature/hook_context.h" +#include "openfeature/hook_data.h" +#include "openfeature/hook_hints.h" +#include "openfeature/metadata.h" +#include "openfeature/reason.h" + +namespace codereadiness { + +using ::absl_testing::IsOk; +using ::absl_testing::StatusIs; +using ::openfeature::FlagMetadata; +using ::testing::_; +using ::testing::AllOf; +using ::testing::HasSubstr; +using ::testing::NotNull; +using ::testing::Return; +using ::testing::StartsWith; +using ::testing::StrEq; +using ::testing::StrictMock; +using ::testing::ThrowsMessage; + +constexpr std::string_view kFlagKey = "my-flag"; +constexpr std::string_view kDefaultKey = "minCodeVersion"; +constexpr std::string_view kMetadataClient = "client"; +constexpr std::string_view kMetadataProvider = "provider"; + +// A gMock implementation of VersionComparator, used to verify how the hook +// interacts with a custom comparator. +class MockVersionComparator : public VersionComparator { + public: + MOCK_METHOD(absl::Status, Initialize, (std::string_view current_version), + (override)); + MOCK_METHOD(absl::StatusOr, Compare, + (std::string_view required_version), (const, override)); +}; + +openfeature::BoolHookContext MakeHookContext( + std::string flag_key = std::string(kFlagKey)) { + return openfeature::BoolHookContext( + std::move(flag_key), openfeature::FlagValueType::kBoolean, + /*default_value=*/false, + openfeature::EvaluationContext::Builder().build(), + openfeature::Metadata{std::string(kMetadataClient)}, + openfeature::Metadata{std::string(kMetadataProvider)}, + std::make_shared()); +} + +openfeature::BoolFlagEvaluationDetails MakeDetails( + const FlagMetadata& metadata) { + return {std::string(kFlagKey), /*value=*/true, + openfeature::Reason::kTargetingMatch, + /*variant=*/"on", metadata}; +} + +FlagMetadata MetadataWithMinVersion( + std::string min_version, std::string key = std::string(kDefaultKey)) { + FlagMetadata metadata; + metadata.data[std::move(key)] = std::move(min_version); + return metadata; +} + +std::shared_ptr CreateHookOrDie( + std::string current_version, CodeReadinessHookOptions options = {}) { + absl::StatusOr> hook = + CodeReadinessHook::Create(std::move(current_version), std::move(options)); + EXPECT_THAT(hook, IsOk()); + return hook.ok() ? *std::move(hook) : nullptr; +} + +CodeReadinessHookOptions StrictOptions() { + CodeReadinessHookOptions options; + options.strict_validation = true; + return options; +} + +// Runs the hook's After stage with `metadata` for flag `kFlagKey`. +void RunAfter(CodeReadinessHook& hook, const FlagMetadata& metadata) { + hook.After(MakeHookContext(), MakeDetails(metadata), + openfeature::HookHints{}); +} + +TEST(CodeReadinessHookCreateTest, SucceedsWithValidVersion) { + absl::StatusOr> hook = + CodeReadinessHook::Create("1.2.3"); + + ASSERT_THAT(hook, IsOk()); + EXPECT_THAT(*hook, NotNull()); +} + +TEST(CodeReadinessHookCreateTest, FailsWithEmptyCurrentVersion) { + EXPECT_THAT(CodeReadinessHook::Create(""), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("current_version cannot be empty"))); +} + +TEST(CodeReadinessHookCreateTest, FailsWithEmptyCurrentVersionInStrictMode) { + EXPECT_THAT(CodeReadinessHook::Create("", StrictOptions()), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("current_version cannot be empty"))); +} + +TEST(CodeReadinessHookCreateTest, FailsWithEmptyMetadataKey) { + CodeReadinessHookOptions options; + options.min_version_metadata_key = ""; + + EXPECT_THAT(CodeReadinessHook::Create("1.2.3", std::move(options)), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("min_version_metadata_key cannot be empty"))); +} + +TEST(CodeReadinessHookCreateTest, InvalidCurrentVersionFailsInLenientMode) { + EXPECT_THAT( + CodeReadinessHook::Create("not-a-version"), + StatusIs(absl::StatusCode::kInvalidArgument, + AllOf(HasSubstr("comparator initialization failed"), + HasSubstr("invalid current semver: \"not-a-version\"")))); +} + +TEST(CodeReadinessHookCreateTest, InvalidCurrentVersionFailsInStrictMode) { + EXPECT_THAT( + CodeReadinessHook::Create("not-a-version", StrictOptions()), + StatusIs(absl::StatusCode::kInvalidArgument, + AllOf(HasSubstr("comparator initialization failed"), + HasSubstr("invalid current semver: \"not-a-version\"")))); +} + +TEST(CodeReadinessHookCreateTest, + InitializesCustomComparatorWithCurrentVersion) { + auto comparator = std::make_unique>(); + EXPECT_CALL(*comparator, Initialize("build-42")) + .WillOnce(Return(absl::OkStatus())); + CodeReadinessHookOptions options; + options.comparator = std::move(comparator); + + EXPECT_THAT(CodeReadinessHook::Create("build-42", std::move(options)), + IsOk()); +} + +TEST(CodeReadinessHookCreateTest, CustomComparatorInitFailureInStrictMode) { + auto comparator = std::make_unique>(); + EXPECT_CALL(*comparator, Initialize(_)) + .WillOnce(Return(absl::InvalidArgumentError("custom failure"))); + CodeReadinessHookOptions options = StrictOptions(); + options.comparator = std::move(comparator); + + EXPECT_THAT(CodeReadinessHook::Create("build-42", std::move(options)), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("custom failure"))); +} + +TEST(CodeReadinessHookCreateTest, CustomComparatorInitFailureInLenientMode) { + auto comparator = std::make_unique>(); + EXPECT_CALL(*comparator, Initialize(_)) + .WillOnce(Return(absl::InvalidArgumentError("custom failure"))); + CodeReadinessHookOptions options; + options.comparator = std::move(comparator); + + EXPECT_THAT(CodeReadinessHook::Create("build-42", std::move(options)), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("custom failure"))); +} + +// Runs every version-check test in both modes, because the +// outcome of a successful comparison must not depend on `strict_validation`. +class CodeReadinessHookVersionCheckTest + : public ::testing::TestWithParam { + protected: + CodeReadinessHookOptions Options() const { + CodeReadinessHookOptions options; + options.strict_validation = GetParam(); + return options; + } +}; + +TEST_P(CodeReadinessHookVersionCheckTest, PassesWhenCurrentEqualsRequired) { + std::shared_ptr hook = CreateHookOrDie("1.2.3", Options()); + ASSERT_THAT(hook, NotNull()); + + EXPECT_NO_THROW(RunAfter(*hook, MetadataWithMinVersion("1.2.3"))); +} + +TEST_P(CodeReadinessHookVersionCheckTest, PassesWhenCurrentIsHigher) { + std::shared_ptr hook = CreateHookOrDie("2.0.0", Options()); + ASSERT_THAT(hook, NotNull()); + + EXPECT_NO_THROW(RunAfter(*hook, MetadataWithMinVersion("1.9.9"))); +} + +TEST_P(CodeReadinessHookVersionCheckTest, PassesWithCLNumbers) { + std::shared_ptr hook = + CreateHookOrDie("527319014", Options()); + ASSERT_THAT(hook, NotNull()); + + EXPECT_NO_THROW(RunAfter(*hook, MetadataWithMinVersion("527319013"))); +} + +TEST_P(CodeReadinessHookVersionCheckTest, ThrowsWhenCurrentIsLower) { + std::shared_ptr hook = CreateHookOrDie("1.2.3", Options()); + ASSERT_THAT(hook, NotNull()); + + EXPECT_THAT([&] { RunAfter(*hook, MetadataWithMinVersion("1.3.0")); }, + ThrowsMessage( + StrEq("codereadiness: flag \"my-flag\": current version " + "\"1.2.3\" is lower than required minimum version " + "\"1.3.0\""))); +} + +TEST_P(CodeReadinessHookVersionCheckTest, ThrowsWhenCurrentIsPreRelease) { + std::shared_ptr hook = + CreateHookOrDie("1.0.0-rc.1", Options()); + ASSERT_THAT(hook, NotNull()); + + EXPECT_THROW(RunAfter(*hook, MetadataWithMinVersion("1.0.0")), + std::runtime_error); +} + +TEST_P(CodeReadinessHookVersionCheckTest, ThrowsWithLowerCLNumber) { + std::shared_ptr hook = + CreateHookOrDie("527319012", Options()); + ASSERT_THAT(hook, NotNull()); + + EXPECT_THROW(RunAfter(*hook, MetadataWithMinVersion("527319013")), + std::runtime_error); +} + +TEST_P(CodeReadinessHookVersionCheckTest, IgnoresUnrelatedMetadata) { + std::shared_ptr hook = CreateHookOrDie("1.2.3", Options()); + ASSERT_THAT(hook, NotNull()); + FlagMetadata metadata = MetadataWithMinVersion("1.0.0"); + metadata.data["owner"] = std::string("team-a"); + constexpr int64_t kPriority = 5; + metadata.data["priority"] = kPriority; + + EXPECT_NO_THROW(RunAfter(*hook, metadata)); +} + +INSTANTIATE_TEST_SUITE_P(StrictAndLenient, CodeReadinessHookVersionCheckTest, + ::testing::Bool(), + [](const ::testing::TestParamInfo& info) { + return info.param ? "Strict" : "Lenient"; + }); + +struct InvalidDataCase { + std::string test_name; + std::string current_version; + FlagMetadata metadata; + // Expected substring of the exception message in strict mode. + std::string strict_message; +}; + +std::vector InvalidDataCases() { + constexpr int64_t kIntegerMinVersion = 527319013; + constexpr double kDoubleMinVersion = 1.5; + + FlagMetadata bool_value; + bool_value.data[std::string(kDefaultKey)] = true; + FlagMetadata int_value; + int_value.data[std::string(kDefaultKey)] = kIntegerMinVersion; + FlagMetadata double_value; + double_value.data[std::string(kDefaultKey)] = kDoubleMinVersion; + FlagMetadata other_key; + other_key.data["otherKey"] = std::string("1.0.0"); + + return { + {"EmptyMetadata", "1.2.3", FlagMetadata{}, + R"(codereadiness: flag "my-flag": flag metadata is empty)"}, + {"MissingKey", "1.2.3", other_key, + R"(codereadiness: flag "my-flag": metadata key "minCodeVersion" is missing)"}, + {"BoolValue", "1.2.3", bool_value, + R"(codereadiness: flag "my-flag": metadata key "minCodeVersion" is not a string)"}, + {"IntegerValue", "1.2.3", int_value, + R"(codereadiness: flag "my-flag": metadata key "minCodeVersion" is not a string)"}, + {"DoubleValue", "1.2.3", double_value, + R"(codereadiness: flag "my-flag": metadata key "minCodeVersion" is not a string)"}, + {"EmptyValue", "1.2.3", MetadataWithMinVersion(""), + R"(codereadiness: flag "my-flag": metadata key "minCodeVersion" value is empty)"}, + {"InvalidRequiredVersion", "1.2.3", MetadataWithMinVersion("1.x"), + R"(codereadiness: flag "my-flag": invalid version values current: "1.2.3" - minimum: "1.x")"}, + }; +} + +std::string InvalidDataCaseName( + const ::testing::TestParamInfo& info) { + return info.param.test_name; +} + +class CodeReadinessHookLenientTest + : public ::testing::TestWithParam {}; + +TEST_P(CodeReadinessHookLenientTest, SkipsValidation) { + const InvalidDataCase& test_case = GetParam(); + std::shared_ptr hook = + CreateHookOrDie(test_case.current_version); + ASSERT_THAT(hook, NotNull()); + + EXPECT_NO_THROW(RunAfter(*hook, test_case.metadata)); +} + +INSTANTIATE_TEST_SUITE_P(InvalidData, CodeReadinessHookLenientTest, + ::testing::ValuesIn(InvalidDataCases()), + InvalidDataCaseName); + +class CodeReadinessHookStrictTest + : public ::testing::TestWithParam {}; + +TEST_P(CodeReadinessHookStrictTest, ThrowsWithDescriptiveMessage) { + const InvalidDataCase& test_case = GetParam(); + std::shared_ptr hook = + CreateHookOrDie(test_case.current_version, StrictOptions()); + ASSERT_THAT(hook, NotNull()); + + EXPECT_THAT( + [&] { RunAfter(*hook, test_case.metadata); }, + ThrowsMessage(HasSubstr(test_case.strict_message))); +} + +INSTANTIATE_TEST_SUITE_P(InvalidData, CodeReadinessHookStrictTest, + ::testing::ValuesIn(InvalidDataCases()), + InvalidDataCaseName); + +TEST(CodeReadinessHookMetadataKeyTest, ReadsVersionFromCustomKey) { + CodeReadinessHookOptions options; + options.min_version_metadata_key = "requiredAppVersion"; + std::shared_ptr hook = + CreateHookOrDie("1.0.0", std::move(options)); + ASSERT_THAT(hook, NotNull()); + + EXPECT_THROW( + RunAfter(*hook, MetadataWithMinVersion("2.0.0", "requiredAppVersion")), + std::runtime_error); +} + +TEST(CodeReadinessHookMetadataKeyTest, IgnoresDefaultKeyWhenCustomKeyIsSet) { + CodeReadinessHookOptions options = StrictOptions(); + options.min_version_metadata_key = "requiredAppVersion"; + std::shared_ptr hook = + CreateHookOrDie("1.0.0", std::move(options)); + ASSERT_THAT(hook, NotNull()); + + // "minCodeVersion" would fail the check, but the hook must not read it. + EXPECT_THAT( + [&] { + RunAfter(*hook, + MetadataWithMinVersion("2.0.0", std::string(kDefaultKey))); + }, + ThrowsMessage( + HasSubstr("metadata key \"requiredAppVersion\" is missing"))); +} + +class CodeReadinessHookCustomComparatorTest : public ::testing::Test { + protected: + // Creates a hook owning a StrictMock comparator. `comparator_` stays valid + // for the lifetime of `hook`. + std::shared_ptr CreateHook(bool strict_validation) { + auto comparator = std::make_unique>(); + comparator_ = comparator.get(); + EXPECT_CALL(*comparator_, Initialize("build-42")) + .WillOnce(Return(absl::OkStatus())); + CodeReadinessHookOptions options; + options.comparator = std::move(comparator); + options.strict_validation = strict_validation; + return CreateHookOrDie("build-42", std::move(options)); + } + + StrictMock* comparator_ = nullptr; +}; + +TEST_F(CodeReadinessHookCustomComparatorTest, PassesMetadataValueToCompare) { + std::shared_ptr hook = CreateHook(/*strict=*/false); + ASSERT_THAT(hook, NotNull()); + EXPECT_CALL(*comparator_, Compare("build-40")).WillOnce(Return(true)); + + EXPECT_NO_THROW(RunAfter(*hook, MetadataWithMinVersion("build-40"))); +} + +TEST_F(CodeReadinessHookCustomComparatorTest, ThrowsWhenCompareReturnsFalse) { + std::shared_ptr hook = CreateHook(/*strict=*/false); + ASSERT_THAT(hook, NotNull()); + EXPECT_CALL(*comparator_, Compare("build-50")).WillOnce(Return(false)); + + EXPECT_THAT([&] { RunAfter(*hook, MetadataWithMinVersion("build-50")); }, + ThrowsMessage( + StrEq("codereadiness: flag \"my-flag\": current version " + "\"build-42\" is lower than required minimum version " + "\"build-50\""))); +} + +TEST_F(CodeReadinessHookCustomComparatorTest, SkipsOnCompareErrorWhenLenient) { + std::shared_ptr hook = CreateHook(/*strict=*/false); + ASSERT_THAT(hook, NotNull()); + EXPECT_CALL(*comparator_, Compare(_)) + .WillOnce(Return(absl::InvalidArgumentError("bad build"))); + + EXPECT_NO_THROW(RunAfter(*hook, MetadataWithMinVersion("build-x"))); +} + +TEST_F(CodeReadinessHookCustomComparatorTest, ThrowsOnCompareErrorWhenStrict) { + std::shared_ptr hook = CreateHook(/*strict=*/true); + ASSERT_THAT(hook, NotNull()); + EXPECT_CALL(*comparator_, Compare(_)) + .WillOnce(Return(absl::InvalidArgumentError("bad build"))); + + EXPECT_THAT([&] { RunAfter(*hook, MetadataWithMinVersion("build-x")); }, + ThrowsMessage( + AllOf(StartsWith("codereadiness: flag \"my-flag\": "), + HasSubstr("bad build")))); +} + +TEST_F(CodeReadinessHookCustomComparatorTest, + DoesNotCallCompareWhenMetadataIsMissing) { + std::shared_ptr hook = CreateHook(/*strict=*/false); + ASSERT_THAT(hook, NotNull()); + EXPECT_NO_THROW(RunAfter(*hook, FlagMetadata{})); + EXPECT_NO_THROW(RunAfter(*hook, MetadataWithMinVersion(""))); +} + +TEST(CodeReadinessHookIntegrationTest, WorksThroughGeneralHookInterface) { + std::shared_ptr hook = CreateHookOrDie("1.0.0"); + ASSERT_THAT(hook, NotNull()); + + EXPECT_THROW(hook->After(MakeHookContext(), + MakeDetails(MetadataWithMinVersion("2.0.0")), + openfeature::HookHints{}), + std::runtime_error); +} + +TEST(CodeReadinessHookIntegrationTest, WorksForNonBooleanFlags) { + std::shared_ptr hook = CreateHookOrDie("1.0.0"); + ASSERT_THAT(hook, NotNull()); + openfeature::StringHookContext ctx( + "string-flag", openfeature::FlagValueType::kString, "default", + openfeature::EvaluationContext::Builder().build(), + openfeature::Metadata{"client"}, openfeature::Metadata{"provider"}, + std::make_shared()); + openfeature::StringFlagEvaluationDetails details( + "string-flag", "value", openfeature::Reason::kStatic, "v1", + MetadataWithMinVersion("2.0.0")); + + EXPECT_THAT([&] { hook->After(ctx, details, openfeature::HookHints{}); }, + ThrowsMessage( + StartsWith("codereadiness: flag \"string-flag\": "))); +} + +TEST(CodeReadinessHookIntegrationTest, CanBeReusedAcrossEvaluations) { + std::shared_ptr hook = CreateHookOrDie("1.5.0"); + ASSERT_THAT(hook, NotNull()); + + EXPECT_NO_THROW(RunAfter(*hook, MetadataWithMinVersion("1.0.0"))); + EXPECT_THROW(RunAfter(*hook, MetadataWithMinVersion("2.0.0")), + std::runtime_error); + EXPECT_NO_THROW(RunAfter(*hook, MetadataWithMinVersion("1.5.0"))); +} + +} // namespace codereadiness diff --git a/hooks/codereadiness/tests/semver_comparator_test.cpp b/hooks/codereadiness/tests/semver_comparator_test.cpp new file mode 100644 index 0000000..7f155bc --- /dev/null +++ b/hooks/codereadiness/tests/semver_comparator_test.cpp @@ -0,0 +1,196 @@ +#include "codereadiness/semver_comparator.h" + +#include +#include + +#include +#include +#include +#include + +#include "absl/status/status.h" +#include "absl/status/status_matchers.h" +#include "absl/status/statusor.h" +#include "codereadiness/version_comparator.h" + +namespace codereadiness { + +using ::absl_testing::IsOk; +using ::absl_testing::IsOkAndHolds; +using ::absl_testing::StatusIs; +using ::testing::AllOf; +using ::testing::HasSubstr; +using ::testing::Not; + +TEST(SemVerComparatorInitializeTest, AcceptsValidVersion) { + SemVerComparator comparator; + + EXPECT_THAT(comparator.Initialize("1.2.3"), IsOk()); +} + +TEST(SemVerComparatorInitializeTest, AcceptsVersionsSupportedBySemVerParse) { + for (const char* version : + {"v1.2.3", "1", "1.2", "527319013", "1.0.0-rc.1+build.5"}) { + SemVerComparator comparator; + EXPECT_THAT(comparator.Initialize(version), IsOk()) << version; + } +} + +TEST(SemVerComparatorInitializeTest, RejectsInvalidVersion) { + SemVerComparator comparator; + + EXPECT_THAT(comparator.Initialize("not-a-version"), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("invalid current semver: \"not-a-version\""))); +} + +TEST(SemVerComparatorInitializeTest, RejectsEmptyVersion) { + SemVerComparator comparator; + + EXPECT_THAT(comparator.Initialize(""), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("invalid current semver: \"\""))); +} + +TEST(SemVerComparatorInitializeTest, ErrorIncludesUnderlyingParseError) { + SemVerComparator comparator; + + EXPECT_THAT(comparator.Initialize("01.2.3"), + StatusIs(absl::StatusCode::kInvalidArgument, + AllOf(HasSubstr("\"01.2.3\""), + HasSubstr("MUST NOT contain leading zeros")))); +} + +TEST(SemVerComparatorCompareTest, FailsWhenRequiredVersionIsInvalid) { + SemVerComparator comparator; + ASSERT_THAT(comparator.Initialize("1.2.3"), IsOk()); + + EXPECT_THAT(comparator.Compare("1.2.x"), + StatusIs(absl::StatusCode::kInvalidArgument, + AllOf(HasSubstr("invalid required semver: \"1.2.x\""), + HasSubstr("Invalid SemVer Patch digits")))); +} + +TEST(SemVerComparatorCompareTest, FailsWhenRequiredVersionIsEmpty) { + SemVerComparator comparator; + ASSERT_THAT(comparator.Initialize("1.2.3"), IsOk()); + + EXPECT_THAT(comparator.Compare(""), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("invalid required semver: \"\""))); +} + +struct CompareCase { + std::string test_name; + std::string current; + std::string required; + bool expected; // `current` >= `required`. +}; + +class SemVerComparatorResultTest + : public ::testing::TestWithParam {}; + +TEST_P(SemVerComparatorResultTest, ReturnsWhetherCurrentMeetsRequired) { + const CompareCase& test_case = GetParam(); + SemVerComparator comparator; + ASSERT_THAT(comparator.Initialize(test_case.current), IsOk()); + + EXPECT_THAT(comparator.Compare(test_case.required), + IsOkAndHolds(test_case.expected)) + << "current: " << test_case.current + << ", required: " << test_case.required; +} + +INSTANTIATE_TEST_SUITE_P( + Results, SemVerComparatorResultTest, + ::testing::ValuesIn(std::vector{ + // Equal versions meet the requirement. + {"Equal", "1.2.3", "1.2.3", true}, + {"EqualPreRelease", "1.2.3-rc.1", "1.2.3-rc.1", true}, + // Higher current version. + {"HigherMajor", "2.0.0", "1.9.9", true}, + {"HigherMinor", "1.3.0", "1.2.9", true}, + {"HigherPatch", "1.2.4", "1.2.3", true}, + // Lower current version. + {"LowerMajor", "1.9.9", "2.0.0", false}, + {"LowerMinor", "1.2.9", "1.3.0", false}, + {"LowerPatch", "1.2.3", "1.2.4", false}, + // Numeric, not lexicographic, comparison. + {"NumericNotLexicographic", "1.10.0", "1.9.0", true}, + // Pre-release precedence. + {"ReleaseMeetsItsPreRelease", "1.0.0", "1.0.0-rc.1", true}, + {"PreReleaseDoesNotMeetRelease", "1.0.0-rc.1", "1.0.0", false}, + {"LaterPreRelease", "1.0.0-beta.11", "1.0.0-beta.2", true}, + {"EarlierPreRelease", "1.0.0-alpha", "1.0.0-alpha.1", false}, + // Formats accepted by SemVer::Parse. + {"VPrefixOnCurrent", "v1.2.3", "1.2.3", true}, + {"VPrefixOnRequired", "1.2.3", "v1.2.4", false}, + {"ShorthandCurrent", "2", "1.9.9", true}, + {"ShorthandRequired", "1.2.3", "1.3", false}, + {"BuildMetadataIgnored", "1.2.3+a", "1.2.3+b", true}, + // CL numbers, as used by EZ Flags. + {"CLEqual", "527319013", "527319013", true}, + {"CLHigher", "527319014", "527319013", true}, + {"CLLower", "527319012", "527319013", false}, + }), + [](const ::testing::TestParamInfo& info) { + return info.param.test_name; + }); + +TEST(SemVerComparatorReinitializeTest, ValidVersionReplacesPreviousVersion) { + SemVerComparator comparator; + ASSERT_THAT(comparator.Initialize("1.0.0"), IsOk()); + ASSERT_THAT(comparator.Initialize("2.0.0"), IsOk()); + + EXPECT_THAT(comparator.Compare("1.5.0"), IsOkAndHolds(true)); +} + +TEST(SemVerComparatorReinitializeTest, ValidVersionRecoversFromFailure) { + SemVerComparator comparator; + ASSERT_THAT(comparator.Initialize("bad"), Not(IsOk())); + ASSERT_THAT(comparator.Initialize("1.0.0"), IsOk()); + + EXPECT_THAT(comparator.Compare("1.0.0"), IsOkAndHolds(true)); +} + +TEST(SemVerComparatorInterfaceTest, WorksThroughVersionComparatorInterface) { + std::unique_ptr comparator = + std::make_unique(); + ASSERT_THAT(comparator->Initialize("1.2.3"), IsOk()); + + const VersionComparator& const_comparator = *comparator; + EXPECT_THAT(const_comparator.Compare("1.2.0"), IsOkAndHolds(true)); + EXPECT_THAT(const_comparator.Compare("1.3.0"), IsOkAndHolds(false)); +} + +TEST(SemVerComparatorInterfaceTest, CompareIsSafeToCallConcurrently) { + SemVerComparator comparator; + ASSERT_THAT(comparator.Initialize("1.5.0"), IsOk()); + const SemVerComparator& const_comparator = comparator; + + constexpr int kNumThreads = 8; + constexpr int kIterations = 1000; + std::vector threads; + std::vector failures(kNumThreads, 0); + threads.reserve(kNumThreads); + for (int thread_idx = 0; thread_idx < kNumThreads; ++thread_idx) { + threads.emplace_back([&const_comparator, &failures, thread_idx] { + for (int i = 0; i < kIterations; ++i) { + absl::StatusOr lower = const_comparator.Compare("1.4.0"); + absl::StatusOr higher = const_comparator.Compare("1.6.0"); + if (!lower.ok() || !*lower || !higher.ok() || *higher) { + ++failures[thread_idx]; + } + } + }); + } + for (std::thread& thread : threads) { + thread.join(); + } + + for (int thread_idx = 0; thread_idx < kNumThreads; ++thread_idx) { + EXPECT_EQ(failures[thread_idx], 0) << "thread " << thread_idx; + } +} + +} // namespace codereadiness diff --git a/hooks/codereadiness/tests/semver_test.cpp b/hooks/codereadiness/tests/semver_test.cpp new file mode 100644 index 0000000..f506f4f --- /dev/null +++ b/hooks/codereadiness/tests/semver_test.cpp @@ -0,0 +1,321 @@ +#include "codereadiness/semver.h" + +#include +#include + +#include +#include +#include +#include + +#include "absl/status/status.h" +#include "absl/status/status_matchers.h" +#include "absl/status/statusor.h" + +namespace codereadiness { + +using ::absl_testing::IsOk; +using ::absl_testing::StatusIs; +using ::testing::ElementsAre; +using ::testing::HasSubstr; +using ::testing::IsEmpty; +using ::testing::Not; + +// Parses `text`, failing the test if it is not a valid SemVer. +SemVer ParseOrDie(std::string_view text) { + absl::StatusOr version = SemVer::Parse(text); + EXPECT_THAT(version, IsOk()) << "for input \"" << text << "\""; + return version.ok() ? *version : SemVer(); +} + +TEST(SemVerTest, DefaultConstructorIsZeroVersion) { + SemVer version; + + EXPECT_EQ(version.GetMajor(), 0); + EXPECT_EQ(version.GetMinor(), 0); + EXPECT_EQ(version.GetPatch(), 0); + EXPECT_THAT(version.GetPreRelease(), IsEmpty()); +} + +TEST(SemVerTest, ConstructorSetsAllFields) { + SemVer version(1, 2, 3, {"alpha", "1"}); + + EXPECT_EQ(version.GetMajor(), 1); + EXPECT_EQ(version.GetMinor(), 2); + EXPECT_EQ(version.GetPatch(), 3); + EXPECT_THAT(version.GetPreRelease(), ElementsAre("alpha", "1")); +} + +TEST(SemVerParseTest, ParsesFullVersion) { + SemVer version = ParseOrDie("1.2.3"); + + EXPECT_EQ(version.GetMajor(), 1); + EXPECT_EQ(version.GetMinor(), 2); + EXPECT_EQ(version.GetPatch(), 3); + EXPECT_THAT(version.GetPreRelease(), IsEmpty()); +} + +TEST(SemVerParseTest, ParsesZeroVersion) { + SemVer version = ParseOrDie("0.0.0"); + + EXPECT_EQ(version.GetMajor(), 0); + EXPECT_EQ(version.GetMinor(), 0); + EXPECT_EQ(version.GetPatch(), 0); +} + +TEST(SemVerParseTest, StripsLowercaseVPrefix) { + SemVer version = ParseOrDie("v1.2.3"); + + EXPECT_EQ(version.GetMajor(), 1); + EXPECT_EQ(version.GetMinor(), 2); + EXPECT_EQ(version.GetPatch(), 3); +} + +TEST(SemVerParseTest, FailsToStripUppercaseVPrefix) { + EXPECT_THAT(SemVer::Parse("V1.2.3"), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("Invalid SemVer Major digits: \"V1\""))); +} + +TEST(SemVerParseTest, MajorOnlyShorthandDefaultsMinorAndPatchToZero) { + SemVer version = ParseOrDie("527319013"); + + EXPECT_EQ(version.GetMajor(), 527319013); + EXPECT_EQ(version.GetMinor(), 0); + EXPECT_EQ(version.GetPatch(), 0); +} + +TEST(SemVerParseTest, MajorMinorShorthandDefaultsPatchToZero) { + SemVer version = ParseOrDie("v7.4"); + + EXPECT_EQ(version.GetMajor(), 7); + EXPECT_EQ(version.GetMinor(), 4); + EXPECT_EQ(version.GetPatch(), 0); +} + +TEST(SemVerParseTest, ParsesMaxUint64Component) { + SemVer version = ParseOrDie("18446744073709551615.0.0"); + + EXPECT_EQ(version.GetMajor(), UINT64_MAX); +} + +TEST(SemVerParseTest, ParsesPreReleaseIdentifiers) { + SemVer version = ParseOrDie("1.0.0-alpha.1.x-y"); + + EXPECT_THAT(version.GetPreRelease(), ElementsAre("alpha", "1", "x-y")); +} + +TEST(SemVerParseTest, PreReleaseMayStartWithHyphen) { + SemVer version = ParseOrDie("1.2.3--a"); + + EXPECT_THAT(version.GetPreRelease(), ElementsAre("-a")); +} + +TEST(SemVerParseTest, PreReleaseAlphanumericWithLeadingZeroIsAllowed) { + // The leading zero rule applies only to purely numeric identifiers. + SemVer version = ParseOrDie("1.2.3-0a"); + + EXPECT_THAT(version.GetPreRelease(), ElementsAre("0a")); +} + +TEST(SemVerParseTest, PreReleaseZeroIsAllowed) { + SemVer version = ParseOrDie("1.2.3-0"); + + EXPECT_THAT(version.GetPreRelease(), ElementsAre("0")); +} + +TEST(SemVerParseTest, BuildMetadataIsValidatedAndDropped) { + SemVer version = ParseOrDie("1.2.3+build.5"); + + EXPECT_EQ(version.GetMajor(), 1); + EXPECT_EQ(version.GetMinor(), 2); + EXPECT_EQ(version.GetPatch(), 3); + EXPECT_THAT(version.GetPreRelease(), IsEmpty()); +} + +TEST(SemVerParseTest, BuildMetadataNumericWithLeadingZeroIsAllowed) { + EXPECT_THAT(SemVer::Parse("1.2.3+build.01"), IsOk()); +} + +TEST(SemVerParseTest, ParsesPreReleaseAndBuildMetadata) { + SemVer version = ParseOrDie("1.0.0-rc.1+sha.5114f85"); + + EXPECT_THAT(version.GetPreRelease(), ElementsAre("rc", "1")); +} + +// Build metadata is split off before looking for the pre-release '-'. +TEST(SemVerParseTest, HyphenInBuildMetadataIsNotPreRelease) { + SemVer version = ParseOrDie("1.2.3+build-1"); + + EXPECT_THAT(version.GetPreRelease(), IsEmpty()); +} + +struct InvalidParseCase { + std::string test_name; + std::string input; + std::string expected_message; +}; + +class SemVerParseInvalidTest + : public ::testing::TestWithParam {}; + +TEST_P(SemVerParseInvalidTest, ReturnsInvalidArgument) { + const InvalidParseCase& test_case = GetParam(); + + EXPECT_THAT(SemVer::Parse(test_case.input), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr(test_case.expected_message))); +} + +INSTANTIATE_TEST_SUITE_P( + InvalidInputs, SemVerParseInvalidTest, + ::testing::ValuesIn(std::vector{ + // Empty or missing core. + {"Empty", "", "Invalid SemVer Major digits"}, + {"OnlyVPrefix", "v", "Invalid SemVer Major digits"}, + {"DoubleVPrefix", "vv1.2.3", "Invalid SemVer Major digits"}, + {"EmptyMinor", "1..3", "Invalid SemVer Minor digits"}, + {"TrailingDot", "1.2.", "Invalid SemVer Patch digits"}, + {"TooManyComponents", "1.2.3.4", "Invalid SemVer core"}, + // Non-digit characters in the core. + {"LeadingWhitespace", " 1.2.3", "Invalid SemVer Major digits"}, + {"TrailingWhitespace", "1.2.3 ", "Invalid SemVer Patch digits"}, + // The first '-' starts the pre-release, so the core here is "1.". + {"NegativeNumber", "1.-2.3", "Shorthand SemVer"}, + {"PlusSign", "1.2.+3", "Invalid SemVer Patch digits"}, + {"Letters", "1.x.3", "Invalid SemVer Minor digits"}, + // Leading zeros in the core. + {"MajorLeadingZero", "01.2.3", "Major version MUST NOT contain"}, + {"MinorLeadingZero", "1.02.3", "Minor version MUST NOT contain"}, + {"PatchLeadingZero", "1.2.03", "Patch version MUST NOT contain"}, + // Overflow. + {"MajorOverflow", "18446744073709551616", "Major is too large"}, + // Shorthands cannot carry pre-release or build metadata. + {"MajorShorthandWithPreRelease", "1-alpha", "Shorthand SemVer"}, + {"MinorShorthandWithPreRelease", "1.2-rc1", "Shorthand SemVer"}, + {"MinorShorthandWithBuild", "1.2+build", "Shorthand SemVer"}, + {"OnlyBuild", "+1.2.3", "Shorthand SemVer"}, + // Pre-release identifiers. + {"EmptyPreRelease", "1.2.3-", "Empty pre-release identifier"}, + {"EmptyPreReleaseIdentifier", "1.2.3-a..b", + "Empty pre-release identifier"}, + {"TrailingDotInPreRelease", "1.2.3-a.", "Empty pre-release identifier"}, + {"InvalidCharInPreRelease", "1.2.3-alpha_1", + "Invalid character in pre-release identifier"}, + {"NonAsciiInPreRelease", "1.2.3-\xC3\xBC", + "Invalid character in pre-release identifier"}, + {"NumericPreReleaseLeadingZero", "1.2.3-01", + "Numeric pre-release identifiers MUST NOT contain leading zeros"}, + // Build metadata identifiers. + {"EmptyBuild", "1.2.3+", "Empty build identifier"}, + {"EmptyBuildIdentifier", "1.2.3+a..b", "Empty build identifier"}, + {"SecondPlusInBuild", "1.2.3+a+b", + "Invalid character in build identifier"}, + {"InvalidCharInBuild", "1.2.3+build_1", + "Invalid character in build identifier"}, + }), + [](const ::testing::TestParamInfo& info) { + return info.param.test_name; + }); + +struct CompareCase { + std::string test_name; + std::string lhs; + std::string rhs; + int expected; // Expected result of lhs.Compare(rhs). +}; + +class SemVerCompareTest : public ::testing::TestWithParam {}; + +TEST_P(SemVerCompareTest, ReturnsExpectedOrdering) { + const CompareCase& test_case = GetParam(); + SemVer lhs = ParseOrDie(test_case.lhs); + SemVer rhs = ParseOrDie(test_case.rhs); + + EXPECT_EQ(lhs.Compare(rhs), test_case.expected) + << test_case.lhs << " vs " << test_case.rhs; +} + +TEST_P(SemVerCompareTest, IsAntisymmetric) { + const CompareCase& test_case = GetParam(); + SemVer lhs = ParseOrDie(test_case.lhs); + SemVer rhs = ParseOrDie(test_case.rhs); + + EXPECT_EQ(rhs.Compare(lhs), -test_case.expected) + << test_case.rhs << " vs " << test_case.lhs; +} + +INSTANTIATE_TEST_SUITE_P( + Precedence, SemVerCompareTest, + ::testing::ValuesIn(std::vector{ + // Equality. + {"Equal", "1.2.3", "1.2.3", 0}, + {"EqualWithVPrefix", "v1.2.3", "1.2.3", 0}, + {"MajorShorthandEqualsFull", "1", "1.0.0", 0}, + {"MinorShorthandEqualsFull", "1.2", "1.2.0", 0}, + {"EqualPreRelease", "1.2.3-rc.1", "1.2.3-rc.1", 0}, + {"BuildMetadataIgnored", "1.2.3+a", "1.2.3+b", 0}, + {"BuildMetadataIgnoredVsNone", "1.2.3+a", "1.2.3", 0}, + // Core components, compared numerically (not lexicographically). + {"MajorLess", "1.9.9", "2.0.0", -1}, + {"MinorLess", "1.2.9", "1.3.0", -1}, + {"PatchLess", "1.2.3", "1.2.4", -1}, + {"MajorNumericNotLexicographic", "2.0.0", "10.0.0", -1}, + {"MinorNumericNotLexicographic", "1.2.0", "1.10.0", -1}, + {"PatchNumericNotLexicographic", "1.0.9", "1.0.10", -1}, + {"CLNumbers", "527319013", "527319014", -1}, + {"MaxUint64", "18446744073709551614", "18446744073709551615", -1}, + // Pre-release has lower precedence than the release. + {"PreReleaseLessThanRelease", "1.0.0-alpha", "1.0.0", -1}, + {"PreReleaseOfHigherPatch", "1.0.0", "1.0.1-alpha", -1}, + // Pre-release precedence (examples from semver.org, item 11). + {"AlphaLessThanAlpha1", "1.0.0-alpha", "1.0.0-alpha.1", -1}, + {"Alpha1LessThanAlphaBeta", "1.0.0-alpha.1", "1.0.0-alpha.beta", -1}, + {"AlphaBetaLessThanBeta", "1.0.0-alpha.beta", "1.0.0-beta", -1}, + {"BetaLessThanBeta2", "1.0.0-beta", "1.0.0-beta.2", -1}, + {"Beta2LessThanBeta11", "1.0.0-beta.2", "1.0.0-beta.11", -1}, + {"Beta11LessThanRc1", "1.0.0-beta.11", "1.0.0-rc.1", -1}, + // Pre-release identifier rules. + {"NumericLessThanAlphanumeric", "1.0.0-1", "1.0.0-a", -1}, + {"NumericComparedNumerically", "1.0.0-9", "1.0.0-10", -1}, + {"AlphanumericComparedInAscii", "1.0.0-Beta", "1.0.0-alpha", -1}, + {"HyphenSortsBeforeLetters", "1.0.0--", "1.0.0-a", -1}, + {"LongerPreReleaseWins", "1.0.0-a.b", "1.0.0-a.b.c", -1}, + {"ArbitraryLengthNumericIdentifier", "1.0.0-99999999999999999999", + "1.0.0-100000000000000000000", -1}, + }), + [](const ::testing::TestParamInfo& info) { + return info.param.test_name; + }); + +TEST(SemVerCompareTest, IsReflexive) { + for (std::string_view text : + {"0.0.0", "1.2.3", "1.0.0-alpha.1", "527319013"}) { + SemVer version = ParseOrDie(text); + EXPECT_EQ(version.Compare(version), 0) << text; + } +} + +TEST(SemVerCompareTest, SemVerOrgPrecedenceChainIsStrictlyIncreasing) { + const std::vector ordered = { + "1.0.0-alpha", "1.0.0-alpha.1", "1.0.0-alpha.beta", + "1.0.0-beta", "1.0.0-beta.2", "1.0.0-beta.11", + "1.0.0-rc.1", "1.0.0", "1.0.1", + "1.1.0", "2.0.0", + }; + + for (size_t i = 0; i < ordered.size(); ++i) { + for (size_t j = i + 1; j < ordered.size(); ++j) { + EXPECT_EQ(ParseOrDie(ordered[i]).Compare(ParseOrDie(ordered[j])), -1) + << ordered[i] << " should be lower than " << ordered[j]; + } + } +} + +TEST(SemVerCompareTest, ParsedVersionMatchesConstructedVersion) { + EXPECT_EQ( + ParseOrDie("v1.2.3-rc.1+build").Compare(SemVer(1, 2, 3, {"rc", "1"})), 0); + EXPECT_THAT(SemVer::Parse("1.2.3-rc.01"), Not(IsOk())); +} + +} // namespace codereadiness