Public release: - Remove redundant `SigningAlgorithmToString` helper in `cr... - Validate BMFF manifest box ordering during C2PA extraction. - Accept CRLF and extra white-space in the PDF startxref tr... - Add in-memory TestTracer and TestTracerFactory for tracin... - Propagate TracerFactory via AsyncContext and thread-local... - Reject PDF objects whose header does not match the xref e... - Extract BMFF box location recording into a helper function - Align relocated TIFF IFDs to a word boundary in TiffWriter. - Pin Java runtime and toolchain to remotejdk_25 in .bazelrc. - Report `algorithm.unsupported` instead of `signingCredent... - Refactor BMFF extractor to separate box discovery from pa... - Limit PDF manifest stores to the size the reader accepts - Add PDF page and annotation dictionary constants - Make PDF indirect references hashable - Add BMFF extractor test cases for box ordering and reader... - Omit CMS signingTime attribute from test timestamp tokens... - Test fix: Sort CMS SignedAttributes into canonical DER SE... - Annotate `CreateDefaultCryptoReadHandler` return value as... - Remove Android steps from the Ubuntu Kokoro build - Reject ECDSA claim signatures with wrong encoding (DER in... - Add a Kokoro build config for the Android SDK - Bound BMFF offset box parsing by the available data - Rename VerifySignature to VerifyCoseSignature in ParsedCe... - Enable on-device TSA proxy integration tests in open-sour... GitOrigin-RevId: 04b22f0a436b583efe410bf7dd099f78ed8719e0 Change-Id: I1af9885f84666d797b1a10530daab2c856a5f226
diff --git a/.bazelrc b/.bazelrc index a9dda58..ba480ff 100644 --- a/.bazelrc +++ b/.bazelrc
@@ -10,4 +10,8 @@ # Use clang for C++ compilation build --repo_env=CC=clang -build --repo_env=CXX=clang++ \ No newline at end of file +build --repo_env=CXX=clang++ + +# Pin the Java runtime to a hermetic remote JDK to prevent host JDK version skew. +build --java_runtime_version=remotejdk_25 +build --tool_java_runtime_version=remotejdk_25
diff --git a/async/BUILD b/async/BUILD index 12227c3..70a6845 100644 --- a/async/BUILD +++ b/async/BUILD
@@ -33,6 +33,7 @@ ":cancellation_token", ":executor", "@abseil-cpp//absl/base:nullability", + "@abseil-cpp//absl/container:flat_hash_map", "@abseil-cpp//absl/functional:any_invocable", "@abseil-cpp//absl/status", "@abseil-cpp//absl/status:statusor",
diff --git a/async/async_context.h b/async/async_context.h index c25eef2..d6e179c 100644 --- a/async/async_context.h +++ b/async/async_context.h
@@ -15,9 +15,12 @@ #ifndef THIRD_PARTY_CREDENTIO_ASYNC_ASYNC_CONTEXT_H_ #define THIRD_PARTY_CREDENTIO_ASYNC_ASYNC_CONTEXT_H_ +#include <memory> +#include <type_traits> #include <utility> #include "absl/base/nullability.h" +#include "absl/container/flat_hash_map.h" #include "absl/functional/any_invocable.h" #include "absl/status/status.h" #include "absl/status/statusor.h" @@ -38,6 +41,38 @@ Executor* absl_nullable executor() const { return executor_; } + // Attaches an extension of type `T` to this context, replacing any existing + // extension of the same type. Passing nullptr removes the extension. + // + // Because `AsyncContext` is threaded through asynchronous calls and captured + // into continuations, attached extensions survive executor hops. + // + // Note: Lookup in `GetExtension<T>()` is keyed by the exact type `T`. When + // attaching an implementation of an interface, specify the interface type + // explicitly (e.g. `context.SetExtension<MyInterface>(impl)`) so consumers + // can retrieve it via `context.GetExtension<MyInterface>()`. + template <typename T> + void SetExtension(absl_nullable std::shared_ptr<T> extension) { + using KeyType = std::remove_cv_t<T>; + if (extension == nullptr) { + extensions_.erase(ExtensionKey<KeyType>()); + return; + } + extensions_[ExtensionKey<KeyType>()] = std::move(extension); + } + + // Returns the extension of type `T` attached to this context, or nullptr if + // none is present. + template <typename T> + absl_nullable std::shared_ptr<T> GetExtension() const { + using KeyType = std::remove_cv_t<T>; + auto it = extensions_.find(ExtensionKey<KeyType>()); + if (it == extensions_.end()) { + return nullptr; + } + return std::static_pointer_cast<T>(it->second); + } + void Cancel() { cancellation_source_.Cancel(); } CancellationToken GetCancellationToken() const { @@ -45,8 +80,15 @@ } private: + template <typename T> + static const void* ExtensionKey() { + static const char kKey = 0; + return &kKey; + } + Executor* absl_nullable executor_ = nullptr; CancellationSource cancellation_source_; + absl::flat_hash_map<const void*, std::shared_ptr<void>> extensions_; }; // Helper class to resolve a callback on the given AsyncContext using a
diff --git a/async/async_context_test.cc b/async/async_context_test.cc index bb9e123..7b2cf32 100644 --- a/async/async_context_test.cc +++ b/async/async_context_test.cc
@@ -15,6 +15,8 @@ #include "async/async_context.h" +#include <memory> +#include <string> #include <utility> #include "absl/functional/any_invocable.h" @@ -153,5 +155,64 @@ EXPECT_TRUE(task_run); } +struct IntExtension { + int value = 0; +}; + +struct StringExtension { + std::string value; +}; + +class BaseExtension { + public: + virtual ~BaseExtension() = default; + virtual int id() const = 0; +}; + +class DerivedExtension : public BaseExtension { + public: + int id() const override { return 99; } +}; + +TEST(AsyncContextTest, ExtensionsStoreRetrieveOverwriteAndClear) { + AsyncContext context; + EXPECT_EQ(context.GetExtension<IntExtension>(), nullptr); + EXPECT_EQ(context.GetExtension<StringExtension>(), nullptr); + + auto int_ext = std::make_shared<IntExtension>(IntExtension{42}); + auto str_ext = std::make_shared<StringExtension>(StringExtension{"hello"}); + context.SetExtension(int_ext); + context.SetExtension(str_ext); + + EXPECT_EQ(context.GetExtension<IntExtension>(), int_ext); + EXPECT_EQ(context.GetExtension<StringExtension>(), str_ext); + + // Copying the context preserves attached extensions. + AsyncContext copy = context; + EXPECT_EQ(copy.GetExtension<IntExtension>(), int_ext); + EXPECT_EQ(copy.GetExtension<StringExtension>(), str_ext); + + // Overwriting and clearing on the original does not mutate the copy's map. + auto new_int_ext = std::make_shared<IntExtension>(IntExtension{7}); + context.SetExtension(new_int_ext); + EXPECT_EQ(context.GetExtension<IntExtension>(), new_int_ext); + EXPECT_EQ(copy.GetExtension<IntExtension>(), int_ext); + + context.SetExtension<IntExtension>(nullptr); + EXPECT_EQ(context.GetExtension<IntExtension>(), nullptr); + EXPECT_EQ(context.GetExtension<StringExtension>(), str_ext); +} + +TEST(AsyncContextTest, InterfaceExtensionKeyedByExplicitBaseType) { + AsyncContext context; + auto derived = std::make_shared<DerivedExtension>(); + context.SetExtension<BaseExtension>(derived); + + std::shared_ptr<BaseExtension> retrieved = + context.GetExtension<BaseExtension>(); + ASSERT_NE(retrieved, nullptr); + EXPECT_EQ(retrieved->id(), 99); +} + } // namespace } // namespace credentio
diff --git a/cose/cose_sign1.cc b/cose/cose_sign1.cc index 0ef7d4c..3036e05 100644 --- a/cose/cose_sign1.cc +++ b/cose/cose_sign1.cc
@@ -306,6 +306,12 @@ // STEP 0: Get the certificate chain and parse the certificates. auto protected_headers = DecodeProtectedHeader(cose_sign1.protected_header); if (!protected_headers.ok()) { + if (absl::IsUnimplemented(protected_headers.status())) { + RecordStatus(status_set, FailureStatusCode::kAlgorithmUnsupported, + {.url = kClaimSignatureLabel, + .explanation = protected_headers.status().message()}); + return std::nullopt; + } return RecordInvalidSigningCredential( absl::StrCat("cannot decode protected header: ", protected_headers.status().message()), @@ -414,7 +420,7 @@ // STEP 4: Verify the signature. if (options_.verify_signature) { auto signing_algorithm = protected_headers->alg; - if (absl::Status status = (*certs)->VerifySignature( + if (absl::Status status = (*certs)->VerifyCoseSignature( cose_sign1.signature, ConstructSigStructure(cose_sign1.protected_header, external_payload, "Signature1"),
diff --git a/cose/sig_structure.cc b/cose/sig_structure.cc index 055da02..734939a 100644 --- a/cose/sig_structure.cc +++ b/cose/sig_structure.cc
@@ -104,7 +104,7 @@ case -8: return SigningAlgorithm::kEdDsa; } - return absl::InvalidArgumentError( + return absl::UnimplementedError( absl::StrCat("unsupported COSE algorithm: ", *alg)); }
diff --git a/cose/sig_structure_test.cc b/cose/sig_structure_test.cc index f9d87f0..4b43bb9 100644 --- a/cose/sig_structure_test.cc +++ b/cose/sig_structure_test.cc
@@ -202,7 +202,7 @@ // hex for {1: -100, 33: "abcd"} auto cbor = absl::HexStringToBytes("a2013863214461626364"); EXPECT_THAT(DecodeProtectedHeader(cbor), - StatusIs(absl::StatusCode::kInvalidArgument, + StatusIs(absl::StatusCode::kUnimplemented, "unsupported COSE algorithm: -100")); }
diff --git a/crypto/crypto_read_handler.h b/crypto/crypto_read_handler.h index fdeb26c..2327000 100644 --- a/crypto/crypto_read_handler.h +++ b/crypto/crypto_read_handler.h
@@ -36,15 +36,16 @@ public: virtual ~ParsedCertificates() = default; - // Verifies that `signature` applies to `data`, using the public key from the - // leaf certificate. This function can be used for both claim and OCSP - // response signatures. Performs no trust checks. + // Verifies a raw signature extracted from a COSE_Sign1 message using the + // public key from the leaf certificate. This is for use with COSE signatures + // only, so ECDSA signatures must be in IEEE P1363 (not DER) format. Returns + // an OK status if the verification is successful. Performs no trust checks. // // Returns: // - INVALID_ARGUMENT if the signature is invalid. - virtual absl::Status VerifySignature(absl::string_view signature, - absl::string_view data, - SigningAlgorithm algorithm) const = 0; + virtual absl::Status VerifyCoseSignature( + absl::string_view signature, absl::string_view data, + SigningAlgorithm algorithm) const = 0; // Verifies the trustworthiness of the leaf certificate for claim signing, by // finding an ordered chain of valid certificates to a trusted root,
diff --git a/crypto/default/default_crypto_read_handler.cc b/crypto/default/default_crypto_read_handler.cc index bbd4ff3..afc9409 100644 --- a/crypto/default/default_crypto_read_handler.cc +++ b/crypto/default/default_crypto_read_handler.cc
@@ -198,10 +198,10 @@ return *chain; } - absl::Status VerifySignature(absl::string_view signature, - absl::string_view data, - SigningAlgorithm algorithm) const override { - return leaf().VerifySignature(signature, data, algorithm); + absl::Status VerifyCoseSignature(absl::string_view signature, + absl::string_view data, + SigningAlgorithm algorithm) const override { + return leaf().VerifyCoseSignature(signature, data, algorithm); } size_t GetCertificateCount() const override { @@ -412,7 +412,7 @@ all_claim_signer_roots_.get(), legacy_claim_signer_roots_.get()); } -absl::StatusOr<std::unique_ptr<CryptoReadHandler>> +absl::StatusOr<std::unique_ptr<CryptoReadHandler> absl_nonnull> CreateDefaultCryptoReadHandler(const DefaultCryptoReadHandlerOptions& options) { if (options.clock == nullptr) { return absl::InvalidArgumentError("clock must not be null");
diff --git a/crypto/default/default_crypto_read_handler.h b/crypto/default/default_crypto_read_handler.h index 63edcf6..645ddb0 100644 --- a/crypto/default/default_crypto_read_handler.h +++ b/crypto/default/default_crypto_read_handler.h
@@ -18,6 +18,7 @@ #include <memory> +#include "absl/base/nullability.h" #include "absl/status/statusor.h" #include "absl/strings/string_view.h" #include "absl/time/clock_interface.h" @@ -62,7 +63,7 @@ // Creates an implementation of CryptoReadHandler intended for use in // production. It uses Tink, BoringSSL, and SimpleCmsParser. -absl::StatusOr<std::unique_ptr<CryptoReadHandler>> +absl::StatusOr<std::unique_ptr<CryptoReadHandler> absl_nonnull> CreateDefaultCryptoReadHandler(const DefaultCryptoReadHandlerOptions& options); } // namespace credentio
diff --git a/crypto/default/x509_certificate.cc b/crypto/default/x509_certificate.cc index d976b5c..f1f4154 100644 --- a/crypto/default/x509_certificate.cc +++ b/crypto/default/x509_certificate.cc
@@ -206,7 +206,7 @@ return ComplianceChecker::IsValidC2paCertificate(cert_.get(), is_leaf); } -absl::Status X509Certificate::VerifySignature( +absl::Status X509Certificate::VerifyCoseSignature( absl::string_view signature, absl::string_view data, SigningAlgorithm algorithm) const { static const absl::NoDestructor<absl::Status> registration_status( @@ -373,8 +373,7 @@ return BigInteger(e_bytes); } -absl::StatusOr<std::pair<EcdsaParameters, EcdsaParameters>> GetEcdsaParameters( - SigningAlgorithm algorithm) { +absl::StatusOr<EcdsaParameters> GetEcdsaParameters(SigningAlgorithm algorithm) { EcdsaParameters::HashType hash_type; EcdsaParameters::CurveType curve_type; switch (algorithm) { @@ -394,23 +393,13 @@ return absl::InvalidArgumentError( absl::StrCat("Invalid signing algorithm: ", algorithm)); } - ABSL_ASSIGN_OR_RETURN( - EcdsaParameters der_ecdsa_params, - EcdsaParameters::Builder() - .SetCurveType(curve_type) - .SetHashType(hash_type) - .SetSignatureEncoding(EcdsaParameters::SignatureEncoding::kDer) - .SetVariant(EcdsaParameters::Variant::kNoPrefix) - .Build()); - ABSL_ASSIGN_OR_RETURN( - EcdsaParameters ieee_ecdsa_params, - EcdsaParameters::Builder() - .SetCurveType(curve_type) - .SetHashType(hash_type) - .SetSignatureEncoding(EcdsaParameters::SignatureEncoding::kIeeeP1363) - .SetVariant(EcdsaParameters::Variant::kNoPrefix) - .Build()); - return std::make_pair(ieee_ecdsa_params, der_ecdsa_params); + // COSE requires IEEE P1363 encoding. + return EcdsaParameters::Builder() + .SetCurveType(curve_type) + .SetHashType(hash_type) + .SetSignatureEncoding(EcdsaParameters::SignatureEncoding::kIeeeP1363) + .SetVariant(EcdsaParameters::Variant::kNoPrefix) + .Build(); } } // namespace @@ -443,9 +432,9 @@ case SigningAlgorithm::kEs256: case SigningAlgorithm::kEs384: case SigningAlgorithm::kEs512: { - ABSL_ASSIGN_OR_RETURN(auto params, GetEcdsaParameters(algorithm)); - return std::vector<X509Certificate::TinkKeyParameters>{params.first, - params.second}; + ABSL_ASSIGN_OR_RETURN(EcdsaParameters params, + GetEcdsaParameters(algorithm)); + return std::vector<X509Certificate::TinkKeyParameters>{params}; } case SigningAlgorithm::kEdDsa: { ABSL_ASSIGN_OR_RETURN(
diff --git a/crypto/default/x509_certificate.h b/crypto/default/x509_certificate.h index cda0521..6249d2d 100644 --- a/crypto/default/x509_certificate.h +++ b/crypto/default/x509_certificate.h
@@ -50,12 +50,13 @@ static absl::StatusOr<std::unique_ptr<X509Certificate>> Create( absl::string_view der); absl::Status IsValidC2paCertificate(bool is_leaf) const; - // Verifies a signature using the public key encapsulated by this - // `X509Certificate` instance. - // Returns an OK status if the verification is successful. - absl::Status VerifySignature(absl::string_view signature, - absl::string_view data, - SigningAlgorithm algorithm) const; + // Verifies a raw signature extracted from a COSE_Sign1 message using the + // public key encapsulated by this `X509Certificate` instance. This is for use + // with COSE signatures only, so ECDSA signatures must be in IEEE P1363 (not + // DER) format. Returns an OK status if the verification is successful. + absl::Status VerifyCoseSignature(absl::string_view signature, + absl::string_view data, + SigningAlgorithm algorithm) const; absl::StatusOr<absl::Time> StartTime() const; absl::StatusOr<absl::Time> EndTime() const; absl::StatusOr<std::vector<TinkKeyParameters>> GetPemKeyParams(
diff --git a/crypto/default/x509_certificate_test.cc b/crypto/default/x509_certificate_test.cc index d970024..9201007 100644 --- a/crypto/default/x509_certificate_test.cc +++ b/crypto/default/x509_certificate_test.cc
@@ -216,14 +216,6 @@ absl::StatusOr<std::vector<X509Certificate::TinkKeyParameters>> params_es256 = (*x509)->GetPemKeyParams(SigningAlgorithm::kEs256); ABSL_ASSERT_OK(params_es256.status()); - absl::StatusOr<EcdsaParameters> want_p256_der_ecdsa_params = - EcdsaParameters::Builder() - .SetCurveType(EcdsaParameters::CurveType::kNistP256) - .SetHashType(EcdsaParameters::HashType::kSha256) - .SetSignatureEncoding(EcdsaParameters::SignatureEncoding::kDer) - .SetVariant(EcdsaParameters::Variant::kNoPrefix) - .Build(); - ABSL_ASSERT_OK(want_p256_der_ecdsa_params.status()); absl::StatusOr<EcdsaParameters> want_p256_ieee_ecdsa_params = EcdsaParameters::Builder() .SetCurveType(EcdsaParameters::CurveType::kNistP256) @@ -232,20 +224,11 @@ .SetVariant(EcdsaParameters::Variant::kNoPrefix) .Build(); ABSL_ASSERT_OK(want_p256_ieee_ecdsa_params.status()); - EXPECT_THAT(*params_es256, ElementsAre(*want_p256_ieee_ecdsa_params, - *want_p256_der_ecdsa_params)); + EXPECT_THAT(*params_es256, ElementsAre(*want_p256_ieee_ecdsa_params)); absl::StatusOr<std::vector<X509Certificate::TinkKeyParameters>> params_es384 = (*x509)->GetPemKeyParams(SigningAlgorithm::kEs384); ABSL_ASSERT_OK(params_es384.status()); - absl::StatusOr<EcdsaParameters> want_p384_der_ecdsa_params = - EcdsaParameters::Builder() - .SetCurveType(EcdsaParameters::CurveType::kNistP384) - .SetHashType(EcdsaParameters::HashType::kSha384) - .SetSignatureEncoding(EcdsaParameters::SignatureEncoding::kDer) - .SetVariant(EcdsaParameters::Variant::kNoPrefix) - .Build(); - ABSL_ASSERT_OK(want_p384_der_ecdsa_params.status()); absl::StatusOr<EcdsaParameters> want_p384_ieee_ecdsa_params = EcdsaParameters::Builder() .SetCurveType(EcdsaParameters::CurveType::kNistP384) @@ -254,20 +237,11 @@ .SetVariant(EcdsaParameters::Variant::kNoPrefix) .Build(); ABSL_ASSERT_OK(want_p384_ieee_ecdsa_params.status()); - EXPECT_THAT(*params_es384, ElementsAre(*want_p384_ieee_ecdsa_params, - *want_p384_der_ecdsa_params)); + EXPECT_THAT(*params_es384, ElementsAre(*want_p384_ieee_ecdsa_params)); absl::StatusOr<std::vector<X509Certificate::TinkKeyParameters>> params_es521 = (*x509)->GetPemKeyParams(SigningAlgorithm::kEs512); ABSL_ASSERT_OK(params_es521.status()); - absl::StatusOr<EcdsaParameters> want_p521_der_ecdsa_params = - EcdsaParameters::Builder() - .SetCurveType(EcdsaParameters::CurveType::kNistP521) - .SetHashType(EcdsaParameters::HashType::kSha512) - .SetSignatureEncoding(EcdsaParameters::SignatureEncoding::kDer) - .SetVariant(EcdsaParameters::Variant::kNoPrefix) - .Build(); - ABSL_ASSERT_OK(want_p521_der_ecdsa_params.status()); absl::StatusOr<EcdsaParameters> want_p521_ieee_ecdsa_params = EcdsaParameters::Builder() .SetCurveType(EcdsaParameters::CurveType::kNistP521) @@ -276,8 +250,7 @@ .SetVariant(EcdsaParameters::Variant::kNoPrefix) .Build(); ABSL_ASSERT_OK(want_p521_ieee_ecdsa_params.status()); - EXPECT_THAT(*params_es521, ElementsAre(*want_p521_ieee_ecdsa_params, - *want_p521_der_ecdsa_params)); + EXPECT_THAT(*params_es521, ElementsAre(*want_p521_ieee_ecdsa_params)); EXPECT_THAT((*x509)->GetPemKeyParams(SigningAlgorithm::kPs256), StatusIs(absl::StatusCode::kInvalidArgument, @@ -309,8 +282,8 @@ auto x509 = X509Certificate::Create(Base64Unescape(kDerEdBase64)); ABSL_ASSERT_OK(x509.status()); EXPECT_THAT( - (*x509)->VerifySignature(Base64Unescape(kEdSignatureBase64), - "this is a test", SigningAlgorithm::kEdDsa), + (*x509)->VerifyCoseSignature(Base64Unescape(kEdSignatureBase64), + "this is a test", SigningAlgorithm::kEdDsa), IsOk()); } @@ -320,8 +293,8 @@ // The actual signature and data don't matter for covering the lines in // MaybeConvertPssToRsa, as the key conversion happens before signature // verification. We expect a failure because the signature is invalid. - EXPECT_THAT((*x509)->VerifySignature("fake_signature", "fake_data", - SigningAlgorithm::kPs256), + EXPECT_THAT((*x509)->VerifyCoseSignature("fake_signature", "fake_data", + SigningAlgorithm::kPs256), StatusIs(absl::StatusCode::kInvalidArgument, HasSubstr("Tink verification failed"))); } @@ -455,7 +428,7 @@ ASSERT_NE(x509, nullptr); X509Certificate cert(std::move(x509)); EXPECT_THAT( - cert.VerifySignature("sig", "data", SigningAlgorithm::kPs256), + cert.VerifyCoseSignature("sig", "data", SigningAlgorithm::kPs256), StatusIs(absl::StatusCode::kInvalidArgument, HasSubstr("Certificate does not have a valid public key"))); EXPECT_THAT(
diff --git a/formats/bmff/BUILD b/formats/bmff/BUILD index 4ab750b..ba545da 100644 --- a/formats/bmff/BUILD +++ b/formats/bmff/BUILD
@@ -171,6 +171,7 @@ "@abseil-cpp//absl/strings", "@abseil-cpp//absl/strings:string_view", "@googletest//:gtest_main", + "@riegeli//riegeli/base:types", "@riegeli//riegeli/bytes:string_reader", ], ) @@ -225,6 +226,7 @@ "@abseil-cpp//absl/status:status_macros", "@abseil-cpp//absl/status:statusor", "@abseil-cpp//absl/strings", + "@riegeli//riegeli/base:types", "@riegeli//riegeli/bytes:reader", "@riegeli//riegeli/bytes:writer", "@riegeli//riegeli/endian:endian_reading", @@ -243,6 +245,7 @@ "@abseil-cpp//absl/status:status_matchers", "@abseil-cpp//absl/strings", "@googletest//:gtest_main", + "@riegeli//riegeli/base:types", "@riegeli//riegeli/bytes:string_reader", "@riegeli//riegeli/bytes:string_writer", ],
diff --git a/formats/bmff/extractor.cc b/formats/bmff/extractor.cc index bbe8c73..63925d1 100644 --- a/formats/bmff/extractor.cc +++ b/formats/bmff/extractor.cc
@@ -18,6 +18,7 @@ #include <cstdint> #include <optional> #include <string> +#include <utility> #include "absl/status/status.h" #include "absl/status/status_macros.h" @@ -38,6 +39,8 @@ constexpr absl::string_view kZeroFlags("\x00\x00\x00", 3); constexpr uint64_t kMaxPayloadSize = 1024 * 1024 * 10; // 10 MiB +constexpr uint64_t kNullTerminatorSize = 1; +constexpr uint64_t kMerkleOffsetSize = sizeof(uint64_t); absl::StatusOr<std::string> ReadPurpose(riegeli::Reader& contents) { std::string purpose; @@ -52,13 +55,141 @@ std::string manifest; }; +// The first ftyp, moov and mdat boxes, and the C2PA manifest box, found among +// the top-level boxes of a BMFF file. +struct BmffC2paBoxLocations { + std::optional<BmffBoxHeader> ftyp_box; + std::optional<BmffBoxHeader> moov_box; + std::optional<BmffBoxHeader> mdat_box; + + std::optional<BmffBoxHeader> manifest; +}; + +absl::StatusOr<std::string> ReadC2paBoxPayload(riegeli::Reader& input, + const BmffBoxHeader& box, + absl::string_view purpose) { + const uint64_t box_end = box.start + box.box_size; + const uint64_t payload_start = box.start + box.header_size + + box.version_and_flags_size + purpose.size() + + kNullTerminatorSize + kMerkleOffsetSize; + // Guard against boxes too small to hold the purpose and merkle offset, which + // would otherwise underflow `payload_size` below. + if (payload_start > box_end) { + return absl::NotFoundError( + "No manifest store found; kUnexpectedEof; C2PA box too small for " + "merkle offset"); + } + if (!input.Seek(payload_start)) { + return input.StatusOrAnnotate( + absl::NotFoundError("No manifest store found; kUnexpectedEof; failed " + "to seek to C2PA payload")); + } + const uint64_t payload_size = box_end - payload_start; + if (payload_size > kMaxPayloadSize) { + return absl::NotFoundError( + absl::Substitute("No manifest store found; BMFF C2PA box is too large " + "to extract ($0 > $1)", + payload_size, kMaxPayloadSize)); + } + + std::string payload; + if (!input.Read(payload_size, payload)) { + return input.StatusOrAnnotate(absl::NotFoundError( + "No manifest store found; failed to read C2PA box payload")); + } + return payload; +} + +absl::Status RecordHeadersForC2pa(riegeli::Reader& input, + const BmffBoxHeader& header, + BmffC2paBoxLocations& c2pa_boxes) { + if (header.type == "ftyp") { + c2pa_boxes.ftyp_box = header; + return absl::OkStatus(); + } + + if (header.type == "mdat") { + if (!c2pa_boxes.mdat_box.has_value()) { + c2pa_boxes.mdat_box = header; + } + return absl::OkStatus(); + } + + if (header.type == "moov") { + if (!c2pa_boxes.moov_box.has_value()) { + c2pa_boxes.moov_box = header; + } + return absl::OkStatus(); + } + + if (header.type == "uuid" && header.user_type == kC2paBmffBoxUuid) { + if (header.version != 0) { + return absl::InvalidArgumentError(absl::Substitute( + "unsupported C2PA box version ($0)", header.version)); + } + if (header.flags != kZeroFlags) { + return absl::InvalidArgumentError( + absl::Substitute("unsupported C2PA box flags ($0)", + absl::BytesToHexString(header.flags))); + } + + ABSL_ASSIGN_OR_RETURN(auto purpose, ReadPurpose(input)); + if (purpose == "manifest") { + if (c2pa_boxes.manifest.has_value()) { + return absl::NotFoundError("multiple manifest boxes found"); + } + c2pa_boxes.manifest = header; + return absl::OkStatus(); + } + if (purpose == "original" || purpose == "update") { + return absl::InvalidArgumentError( + absl::Substitute("unsupported C2PA metadata purpose ($0)", purpose)); + } + return absl::OkStatus(); + } + + return absl::OkStatus(); +} + +absl::StatusOr<Result> ValidateLocationAndExtractManifest( + riegeli::Reader& input, const BmffC2paBoxLocations& c2pa_boxes) { + if (!c2pa_boxes.manifest.has_value()) { + return absl::NotFoundError("No manifest store found"); + } + + // Box must be after ftyp. + if (c2pa_boxes.ftyp_box.has_value() && + c2pa_boxes.ftyp_box->start > c2pa_boxes.manifest->start) { + return absl::NotFoundError( + "No manifest store found; manifest resides before ftyp"); + } + // Box must be before moov. + if (c2pa_boxes.moov_box.has_value() && + c2pa_boxes.moov_box->start < c2pa_boxes.manifest->start) { + return absl::NotFoundError( + "No manifest store found; manifest resides after moov"); + } + // Box must be before mdat. + if (c2pa_boxes.mdat_box.has_value() && + c2pa_boxes.mdat_box->start < c2pa_boxes.manifest->start) { + return absl::NotFoundError( + "No manifest store found; manifest resides after mdat"); + } + + ABSL_ASSIGN_OR_RETURN( + auto manifest, + ReadC2paBoxPayload(input, *c2pa_boxes.manifest, "manifest")); + if (manifest.empty()) { + return absl::NotFoundError( + "No manifest store found; manifest store is empty"); + } + return Result{.location = {.offset = c2pa_boxes.manifest->start, + .length = c2pa_boxes.manifest->box_size}, + .manifest = std::move(manifest)}; +} + absl::StatusOr<Result> ExtractManifest(riegeli::Reader& input, int64_t end_offset) { - Result result{ - .location{.offset = 0, .length = 0}, - .manifest = "", - }; - if (!input.SupportsSize() || !input.Size().has_value()) { return absl::InvalidArgumentError( "manifest store not embedded: reader size cannot be determined"); @@ -67,91 +198,35 @@ uint64_t eof_offset = end_offset < 0 ? *input.Size() : static_cast<uint64_t>(end_offset); - absl::Status iteration_status = IterateOverBmffBoxes( + BmffC2paBoxLocations c2pa_boxes; + auto status = IterateOverBmffBoxes( input, - [&input, &eof_offset, - &result](const BmffBoxHeader& header) -> absl::StatusOr<bool> { - // Validate that the end of the current box is within the end offset. - if (header.start + header.box_size > eof_offset) { - return absl::DataLossError("kUnexpectedEof; truncated BMFF box"); - } + [&input, eof_offset, + &c2pa_boxes](const BmffBoxHeader& header) -> absl::StatusOr<bool> { uint64_t box_end = header.start + header.box_size; - if (header.type == "mdat" || header.type == "moov") { + if (box_end > eof_offset) { + return absl::DataLossError("kUnexpectedEof; truncated BMFF box"); + } + + ABSL_RETURN_IF_ERROR(RecordHeadersForC2pa(input, header, c2pa_boxes)); + + if (box_end == eof_offset) { return false; // Terminate loop } - if (header.type != "uuid" || header.user_type != kC2paBmffBoxUuid) { - // Skip irrelevant box. - if (!input.Seek(box_end)) { - return input.StatusOrAnnotate( - absl::InternalError("failed to seek to the end of the atom")); - } - return true; // Continue - } - - // C2PA box - if (!result.manifest.empty()) { - return absl::NotFoundError("Multiple manifest stores found"); - } - if (header.version != 0) { - return absl::InvalidArgumentError(absl::Substitute( - "unsupported C2PA box version ($0)", header.version)); - } - if (header.flags != kZeroFlags) { - return absl::InvalidArgumentError( - absl::Substitute("unsupported C2PA box flags ($0)", - absl::BytesToHexString(header.flags))); - } - - ABSL_ASSIGN_OR_RETURN(auto purpose, ReadPurpose(input)); - if (purpose != "manifest") { - // Ignore non-manifest C2PA boxes. - if (!input.Seek(box_end)) { - return input.StatusOrAnnotate( - absl::InternalError("failed to seek to the end of the atom")); - } - return true; // Continue - } - - // Skip the merkle offset. - if (!input.Skip(sizeof(uint64_t))) { - return input.StatusOrAnnotate( - absl::DataLossError("kUnexpectedEof; merkle offset")); - } - - if (box_end < input.pos()) { - return input.StatusOrAnnotate( - absl::DataLossError("kUnexpectedEof; payload")); - } - - uint64_t payload_size = box_end - input.pos(); - if (payload_size > kMaxPayloadSize) { - return absl::InvalidArgumentError(absl::Substitute( - "BMFF C2PA box is too large to extract ($0 > $1)", payload_size, - kMaxPayloadSize)); - } - result.location = {.offset = header.start, - .length = box_end - header.start}; - if (!input.Read(payload_size, result.manifest)) { - return input.StatusOrAnnotate( - absl::InternalError("failed to read payload")); - } - if (!input.Seek(box_end)) { return input.StatusOrAnnotate( absl::InternalError("failed to seek to the end of the atom")); } return true; // Continue }); - if (!iteration_status.ok()) { + if (!status.ok()) { return absl::NotFoundError( - absl::StrCat("No manifest store found; ", iteration_status.message())); + absl::StrCat("No manifest store found; ", status.message())); } - if (result.manifest.empty()) { - return absl::NotFoundError("No manifest store found"); - } - return result; + + return ValidateLocationAndExtractManifest(input, c2pa_boxes); } } // namespace
diff --git a/formats/bmff/extractor_test.cc b/formats/bmff/extractor_test.cc index 5672bef..398122e 100644 --- a/formats/bmff/extractor_test.cc +++ b/formats/bmff/extractor_test.cc
@@ -35,6 +35,7 @@ #include "formats/extractor_result.h" #include "gmock/gmock.h" #include "gtest/gtest.h" +#include "riegeli/base/types.h" #include "riegeli/bytes/string_reader.h" #include "testing/jumbf_utils.h" #include "testing/test_string_utils.h" @@ -42,12 +43,15 @@ namespace credentio { namespace { +using ::absl_testing::IsOkAndHolds; using ::absl_testing::StatusIs; +using ::credentio_testing::BadBox; using ::credentio_testing::BigBox; using ::credentio_testing::Box; using ::credentio_testing::BoxFlags; using ::credentio_testing::BoxVersion; using ::credentio_testing::C2paBoxPayload; +using ::credentio_testing::C2paUuidBox; using ::credentio_testing::ManifestBigBox; using ::credentio_testing::ManifestBox; using ::credentio_testing::Uint32Str; @@ -55,6 +59,7 @@ using ::credentio_testing::UuidBoxPayload; using ::testing::Eq; using ::testing::HasSubstr; +using ::testing::Optional; constexpr int64_t kMaxChunkBytes = 10 * 1024 * 1024; // 2 MiB @@ -110,21 +115,293 @@ BigBox("zxcv", "whatever")), .result = absl::NotFoundError("No manifest store found"), }, + // --- Manifest box placement. --- TestCase{ - .name = "C2paBoxAfterMoovIgnored", - .contents = absl::StrCat(Box("blah", "irrelevant content"), - Box("moov", "also irrelevant"), - Box("uuid", absl::StrCat(kC2paBmffBoxUuid, - "foo"))), - .result = absl::NotFoundError("No manifest store found"), + .name = "ManifestBetweenFtypAndMoov", + .contents = absl::StrCat( + Box("ftyp", "isomisom"), C2paUuidBox("foo"), + Box("moov", "movie metadata"), Box("mdat", "media data")), + .result = ExtractorResult{.manifest_store = "foo", + .asset_byte_info = + {.manifest_store_location = + ByteRange{.offset = 16, + .length = 48}}}, }, TestCase{ - .name = "C2paBoxAfterMdatIgnored", - .contents = absl::StrCat(Box("blah", "irrelevant content"), - Box("mdat", "also irrelevant"), - Box("uuid", absl::StrCat(kC2paBmffBoxUuid, - "foo"))), - .result = absl::NotFoundError("No manifest store found"), + .name = "ManifestBetweenFtypAndMdat", + .contents = absl::StrCat( + Box("ftyp", "isomisom"), C2paUuidBox("foo"), + Box("mdat", "media data"), Box("moov", "movie metadata")), + .result = + ExtractorResult{ + .manifest_store = "foo", + .asset_byte_info = {.manifest_store_location = + ByteRange{ + .offset = 16, .length = 48}}}, + }, + TestCase{ + .name = "ManifestAfterFreeBeforeMoov", + .contents = absl::StrCat(Box("ftyp", "isomisom"), + Box("free", "padding"), C2paUuidBox("foo"), + Box("moov", "movie metadata"), + Box("mdat", "media data")), + .result = + ExtractorResult{ + .manifest_store = "foo", + .asset_byte_info = {.manifest_store_location = + ByteRange{ + .offset = 31, .length = 48}}}, + }, + TestCase{ + .name = "ManifestWithoutFtyp", + .contents = absl::StrCat(C2paUuidBox("foo"), + Box("moov", "movie metadata"), + Box("mdat", "media data")), + .result = + ExtractorResult{.manifest_store = "foo", + .asset_byte_info = + {.manifest_store_location = + ByteRange{.offset = 0, .length = 48}}}, + }, + TestCase{ + .name = "MerkleBoxAfterMdatIgnored", + .contents = absl::StrCat( + Box("ftyp", "isomisom"), C2paUuidBox("foo"), + Box("moov", "movie metadata"), Box("mdat", "media data"), + Box("uuid", C2paBoxPayload("merkle", "merkle data"))), + .result = + ExtractorResult{ + .manifest_store = "foo", + .asset_byte_info = {.manifest_store_location = + ByteRange{ + .offset = 16, .length = 48}}}, + }, + TestCase{ + .name = "UnknownC2paPurposeAfterMdatIgnored", + .contents = absl::StrCat( + Box("ftyp", "isomisom"), C2paUuidBox("foo"), + Box("mdat", "media data"), C2paUuidBox("bar", "unknown")), + .result = + ExtractorResult{ + .manifest_store = "foo", + .asset_byte_info = {.manifest_store_location = + ByteRange{ + .offset = 16, .length = 48}}}, + }, + TestCase{ + .name = "NonC2paUuidBoxAfterMdatIgnored", + .contents = absl::StrCat(Box("ftyp", "isomisom"), + C2paUuidBox("foo"), + Box("mdat", "media data"), + Box("uuid", "abcdefghijklmnop data")), + .result = + ExtractorResult{ + .manifest_store = "foo", + .asset_byte_info = {.manifest_store_location = + ByteRange{ + .offset = 16, .length = 48}}}, + }, + TestCase{ + .name = "ManifestBeforeFtyp", + .contents = absl::StrCat( + C2paUuidBox("foo"), Box("ftyp", "isomisom"), + Box("moov", "movie metadata"), Box("mdat", "media data")), + .result = absl::NotFoundError("manifest resides before ftyp"), + }, + TestCase{ + .name = "ManifestAfterMoov", + .contents = absl::StrCat( + Box("ftyp", "isomisom"), Box("moov", "movie metadata"), + C2paUuidBox("foo"), Box("mdat", "media data")), + .result = absl::NotFoundError("manifest resides after moov"), + }, + TestCase{ + .name = "ManifestAfterMdat", + .contents = absl::StrCat(Box("ftyp", "isomisom"), + Box("mdat", "media data"), + C2paUuidBox("foo")), + .result = absl::NotFoundError("manifest resides after mdat"), + }, + TestCase{ + .name = "ManifestBetweenMdatAndMoov", + .contents = + absl::StrCat(Box("ftyp", "isomisom"), Box("mdat", "media data"), + C2paUuidBox("foo"), Box("moov", "movie metadata")), + .result = absl::NotFoundError("manifest resides after mdat"), + }, + TestCase{ + .name = "ManifestAfterMoovWithoutFtyp", + .contents = absl::StrCat(Box("moov", "movie metadata"), + C2paUuidBox("foo")), + .result = absl::NotFoundError("manifest resides after moov"), + }, + + // --- Whole-file scanning. --- + TestCase{ + .name = "SecondManifestAfterMdat", + .contents = + absl::StrCat(Box("ftyp", "isomisom"), C2paUuidBox("foo"), + Box("mdat", "media data"), C2paUuidBox("bar")), + .result = absl::NotFoundError("multiple manifest boxes found"), + }, + TestCase{ + .name = "TruncatedBoxAfterMdat", + .contents = absl::StrCat( + Box("ftyp", "isomisom"), C2paUuidBox("foo"), + Box("mdat", "media data"), BadBox("free", "padding", 16)), + .result = absl::NotFoundError("truncated BMFF box"), + }, + TestCase{ + .name = "MalformedC2paBoxAfterMoov", + .contents = absl::StrCat( + Box("ftyp", "isomisom"), C2paUuidBox("foo"), + Box("moov", "movie metadata"), + Box("uuid", UuidBoxPayload(kC2paBmffBoxUuid, BoxVersion(1), + BoxFlags(0), ""))), + .result = absl::NotFoundError("unsupported C2PA box version"), + }, + + // --- Manifest payload bounds. --- + TestCase{ + .name = "EmptyManifestPayload", + .contents = absl::StrCat(Box("ftyp", "isomisom"), C2paUuidBox(""), + Box("mdat", "media data")), + .result = absl::NotFoundError("manifest store is empty"), + }, + // Regression: a manifest box too small for its merkle offset followed + // by another box used to underflow the payload size. + TestCase{ + .name = "ManifestTooSmallForMerkleOffsetFollowedByBox", + .contents = absl::StrCat(Box("uuid", + C2paBoxPayload("manifest", "abc")), + Box("mdat", "media data")), + .result = absl::NotFoundError("kUnexpectedEof"), + }, + + // --- Manifest vs. original/update coexistence. --- + TestCase{ + .name = "ManifestAndOriginalCoexist", + .contents = absl::StrCat( + Box("ftyp", "isomisom"), C2paUuidBox("foo"), + C2paUuidBox("bar", "original"), Box("mdat", "media data")), + .result = absl::NotFoundError( + "unsupported C2PA metadata purpose (original)"), + }, + TestCase{ + .name = "ManifestAndUpdateCoexist", + .contents = + absl::StrCat(Box("ftyp", "isomisom"), C2paUuidBox("foo"), + Box("mdat", "media data"), + Box("uuid", C2paBoxPayload("update", "bar"))), + .result = absl::NotFoundError( + "unsupported C2PA metadata purpose (update)"), + }, + TestCase{ + .name = "ManifestOriginalAndUpdateCoexist", + .contents = absl::StrCat( + Box("ftyp", "isomisom"), C2paUuidBox("foo", "original"), + C2paUuidBox("bar"), Box("mdat", "media data"), + Box("uuid", C2paBoxPayload("update", "baz"))), + .result = absl::NotFoundError( + "unsupported C2PA metadata purpose (original)"), + }, + + // --- Original + update boxes. --- + TestCase{ + .name = "OriginalAndUpdateUnsupported", + .contents = absl::StrCat( + Box("ftyp", "isomisom"), C2paUuidBox("orig", "original"), + Box("moov", "movie metadata"), Box("mdat", "media data"), + Box("uuid", C2paBoxPayload("update", "upd"))), + .result = absl::NotFoundError( + "unsupported C2PA metadata purpose (original)"), + }, + TestCase{ + .name = "OriginalDirectlyFollowedByUpdateUnsupported", + .contents = absl::StrCat(C2paUuidBox("orig", "original"), + Box("uuid", + C2paBoxPayload("update", "upd"))), + .result = absl::NotFoundError( + "unsupported C2PA metadata purpose (original)"), + }, + TestCase{ + .name = "OriginalAndBigBoxUpdateUnsupported", + .contents = absl::StrCat( + Box("ftyp", "isomisom"), C2paUuidBox("orig", "original"), + Box("mdat", "media data"), + BigBox("uuid", C2paBoxPayload("update", "upd"))), + .result = absl::NotFoundError( + "unsupported C2PA metadata purpose (original)"), + }, + TestCase{ + .name = "OriginalWithoutUpdate", + .contents = absl::StrCat(Box("ftyp", "isomisom"), + C2paUuidBox("orig", "original"), + Box("mdat", "media data")), + .result = absl::NotFoundError( + "unsupported C2PA metadata purpose (original)"), + }, + TestCase{ + .name = "UpdateWithoutOriginal", + .contents = + absl::StrCat(Box("ftyp", "isomisom"), Box("mdat", "media data"), + Box("uuid", C2paBoxPayload("update", "upd"))), + .result = absl::NotFoundError( + "unsupported C2PA metadata purpose (update)"), + }, + TestCase{ + .name = "UpdateNotLastBox", + .contents = absl::StrCat( + Box("ftyp", "isomisom"), C2paUuidBox("orig", "original"), + Box("mdat", "media data"), + Box("uuid", C2paBoxPayload("update", "upd")), + Box("free", "padding")), + .result = absl::NotFoundError( + "unsupported C2PA metadata purpose (original)"), + }, + TestCase{ + .name = "OriginalBeforeFtyp", + .contents = + absl::StrCat(C2paUuidBox("orig", "original"), + Box("ftyp", "isomisom"), Box("mdat", "media data"), + Box("uuid", C2paBoxPayload("update", "upd"))), + .result = absl::NotFoundError( + "unsupported C2PA metadata purpose (original)"), + }, + TestCase{ + .name = "OriginalAfterMoov", + .contents = absl::StrCat( + Box("ftyp", "isomisom"), Box("moov", "movie metadata"), + Box("free", "padding"), C2paUuidBox("orig", "original"), + Box("uuid", C2paBoxPayload("update", "upd"))), + .result = absl::NotFoundError( + "unsupported C2PA metadata purpose (original)"), + }, + TestCase{ + .name = "OriginalAfterMdat", + .contents = + absl::StrCat(Box("ftyp", "isomisom"), Box("mdat", "media data"), + C2paUuidBox("orig", "original"), + Box("uuid", C2paBoxPayload("update", "upd"))), + .result = absl::NotFoundError( + "unsupported C2PA metadata purpose (original)"), + }, + TestCase{ + .name = "MultipleOriginalBoxes", + .contents = absl::StrCat( + C2paUuidBox("a", "original"), C2paUuidBox("b", "original"), + Box("uuid", C2paBoxPayload("update", "upd"))), + .result = absl::NotFoundError( + "unsupported C2PA metadata purpose (original)"), + }, + TestCase{ + .name = "MultipleUpdateBoxes", + .contents = absl::StrCat(C2paUuidBox("orig", "original"), + Box("uuid", C2paBoxPayload("update", "a")), + Box("uuid", + C2paBoxPayload("update", "b"))), + .result = absl::NotFoundError( + "unsupported C2PA metadata purpose (original)"), }, TestCase{ // Quoting @@ -135,7 +412,7 @@ .name = "MultipleC2paBoxesIgnored", .contents = absl::StrCat(ManifestBox(/*merkle_offset=*/0, "foo"), ManifestBox(/*merkle_offset=*/0, "bar")), - .result = absl::NotFoundError("Multiple manifest stores found"), + .result = absl::NotFoundError("multiple manifest boxes found"), }, TestCase{ .name = "NonC2paUuidBoxIgnored", @@ -294,5 +571,99 @@ CreateStartOfManifestStorePayload())); } +TEST(BmffExtractorTest, EndOffsetStopsScanningBeforeTrailingData) { + const std::string media = absl::StrCat( + Box("ftyp", "isomisom"), C2paUuidBox("foo"), Box("mdat", "media data")); + // Data past `end_offset` would otherwise fail extraction. + const std::string contents = + absl::StrCat(media, BadBox("free", "padding", 16), C2paUuidBox("bar")); + riegeli::StringReader<> input(contents); + + EXPECT_THAT( + BmffExtractor().ExtractManifestStoreLocation( + input, {.end_offset = static_cast<int64_t>(media.size())}), + IsOkAndHolds(Optional(Eq(ByteRange{.offset = 16, .length = 48})))); +} + +TEST(BmffExtractorTest, EndOffsetWithoutLimitScansWholeFile) { + const std::string contents = + absl::StrCat(Box("ftyp", "isomisom"), C2paUuidBox("foo"), + Box("mdat", "media data"), BadBox("free", "padding", 16)); + riegeli::StringReader<> input(contents); + + EXPECT_THAT( + BmffExtractor().ExtractManifestStoreLocation(input, {}), + StatusIs(absl::StatusCode::kNotFound, HasSubstr("truncated BMFF box"))); +} + +TEST(BmffExtractorTest, EndOffsetInsideBoxFails) { + const std::string contents = absl::StrCat( + Box("ftyp", "isomisom"), C2paUuidBox("foo"), Box("mdat", "media data")); + riegeli::StringReader<> input(contents); + + EXPECT_THAT( + BmffExtractor().ExtractManifestStoreLocation( + input, {.end_offset = static_cast<int64_t>(contents.size() - 1)}), + StatusIs(absl::StatusCode::kNotFound, HasSubstr("truncated BMFF box"))); +} + +TEST(BmffExtractorTest, UpdateBoxEndingAtEndOffsetIsLast) { + const std::string media = + absl::StrCat(C2paUuidBox("orig", "original"), Box("mdat", "media data"), + Box("uuid", C2paBoxPayload("update", "upd"))); + const std::string contents = absl::StrCat(media, Box("free", "padding")); + riegeli::StringReader<> input(contents); + + EXPECT_THAT(BmffExtractor().ExtractManifestStoreLocation( + input, {.end_offset = static_cast<int64_t>(media.size())}), + StatusIs(absl::StatusCode::kNotFound, + HasSubstr("No manifest store found"))); +} + +TEST(BmffExtractorTest, LocationReturnsNulloptWhenC2paNotRequired) { + const std::string contents = absl::StrCat( + Box("ftyp", "isomisom"), Box("mdat", "media data"), C2paUuidBox("foo")); + riegeli::StringReader<> input(contents); + + EXPECT_THAT(BmffExtractor().ExtractManifestStoreLocation( + input, {.requires_c2pa = false}), + IsOkAndHolds(Eq(std::nullopt))); +} + +class NoSizeStringReader : public riegeli::StringReader<> { + public: + using riegeli::StringReader<>::StringReader; + bool SupportsSize() override { return false; } +}; + +class SizeNotValuedStringReader : public riegeli::StringReader<> { + public: + using riegeli::StringReader<>::StringReader; + bool SupportsSize() override { return true; } + + protected: + std::optional<riegeli::Position> SizeImpl() override { return std::nullopt; } +}; + +TEST(BmffExtractorTest, ReaderWithoutSizeFails) { + const std::string contents = absl::StrCat( + Box("ftyp", "isomisom"), C2paUuidBox("foo"), Box("mdat", "media data")); + NoSizeStringReader input(contents); + + EXPECT_THAT(BmffExtractor().ExtractManifestStore(input), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("reader size cannot be determined"))); +} + +TEST(BmffExtractorTest, ReaderWithUnknownSizeFails) { + const std::string contents = absl::StrCat( + Box("ftyp", "isomisom"), C2paUuidBox("foo"), Box("mdat", "media data")); + SizeNotValuedStringReader input(contents); + + EXPECT_THAT(BmffExtractor().ExtractManifestStore(input), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("reader size cannot be determined"))); +} + } // namespace } // namespace credentio
diff --git a/formats/bmff/offset_adjustment.cc b/formats/bmff/offset_adjustment.cc index 749c86e..0267541 100644 --- a/formats/bmff/offset_adjustment.cc +++ b/formats/bmff/offset_adjustment.cc
@@ -15,7 +15,9 @@ #include "formats/bmff/offset_adjustment.h" +#include <algorithm> #include <cstdint> +#include <optional> #include <string> #include <vector> @@ -24,6 +26,7 @@ #include "absl/status/statusor.h" #include "absl/strings/str_cat.h" #include "formats/bmff/box_header.h" +#include "riegeli/base/types.h" #include "riegeli/bytes/reader.h" #include "riegeli/bytes/writer.h" #include "riegeli/endian/endian_reading.h" @@ -33,6 +36,11 @@ namespace { +// Caps upfront `.reserve()` at 2,000,000 entries (~8 MB for `stco`, ~16 MB for +// `co64`/`saio`, and up to ~80 MB for v2 `iloc` items; `iloc` `extent_count` is +// uint16_t, so at most ~1.6 MB per item). +constexpr uint64_t kMaxEntryCountToReserve = 2000000; + absl::StatusOr<uint64_t> ReadVariableSize(riegeli::Reader& reader, uint8_t size) { if (size == 0) return 0; @@ -75,6 +83,33 @@ return absl::InvalidArgumentError(absl::StrCat("invalid byte_size: ", size)); } +// Returns how many of `count` entries can be reserved up front (capped at +// `kMaxEntryCountToReserve`), or an error if `reader` does not have enough data +// left for `count` entries of at least `min_entry_size` bytes each. `count` is +// read from the input, so reserving it unchecked lets a few bytes of input +// allocate gigabytes of memory. +// +// Returns 0 if the size of `reader` is unknown, in which case entries should be +// added as they are read. +absl::StatusOr<uint64_t> EntriesToReserve(riegeli::Reader& reader, + uint64_t count, + uint64_t min_entry_size) { + if (!reader.SupportsSize()) { + return 0; + } + const std::optional<riegeli::Position> size = reader.Size(); + if (!size.has_value()) { + return 0; + } + const riegeli::Position remaining = + *size > reader.pos() ? *size - reader.pos() : 0; + if (count > remaining / min_entry_size) { + return absl::DataLossError(absl::StrCat( + "entry count ", count, " exceeds the remaining ", remaining, " bytes")); + } + return std::min(count, kMaxEntryCountToReserve); +} + } // namespace absl::StatusOr<IlocBox> ParseIloc(riegeli::Reader& reader, @@ -107,7 +142,18 @@ } } - box.items.reserve(item_count); + // item_ID, [reserved and construction_method], data_reference_index, + // base_offset, and extent_count. + const uint64_t min_item_size = + (header.version == 2 ? 4 : 2) + + (header.version == 1 || header.version == 2 ? 2 : 0) + 2 + + box.base_offset_size + 2; + ABSL_ASSIGN_OR_RETURN(uint64_t items_to_reserve, + EntriesToReserve(reader, item_count, min_item_size)); + box.items.reserve(items_to_reserve); + const uint64_t extent_size = + (header.version == 1 || header.version == 2 ? box.index_size : 0) + + box.offset_size + box.length_size; for (uint32_t i = 0; i < item_count; ++i) { IlocItem item; if (header.version < 2) { @@ -141,7 +187,20 @@ return absl::DataLossError("Failed to read extent count"); } - item.extents.reserve(extent_count); + // With all extent fields absent, an extent occupies no bytes and refers to + // the entire referenced container, so an item cannot be fragmented into + // multiple extents. + uint64_t extents_to_reserve = extent_count; + if (extent_size > 0) { + ABSL_ASSIGN_OR_RETURN( + extents_to_reserve, + EntriesToReserve(reader, extent_count, extent_size)); + } else if (extent_count > 1) { + return absl::DataLossError(absl::StrCat("iloc item ", item.item_id, + " has ", extent_count, + " extents of size 0")); + } + item.extents.reserve(extents_to_reserve); for (int j = 0; j < extent_count; ++j) { IlocExtent extent; if ((header.version == 1 || header.version == 2) && box.index_size > 0) { @@ -265,7 +324,10 @@ if (!riegeli::ReadBigEndian<uint32_t>(reader, entry_count)) { return absl::DataLossError("Failed to read entry count"); } - box.offsets.reserve(entry_count); + ABSL_ASSIGN_OR_RETURN( + uint64_t entries_to_reserve, + EntriesToReserve(reader, entry_count, sizeof(uint32_t))); + box.offsets.reserve(entries_to_reserve); for (uint32_t i = 0; i < entry_count; ++i) { uint32_t offset; if (!riegeli::ReadBigEndian<uint32_t>(reader, offset)) { @@ -304,7 +366,10 @@ if (!riegeli::ReadBigEndian<uint32_t>(reader, entry_count)) { return absl::DataLossError("Failed to read entry count"); } - box.offsets.reserve(entry_count); + ABSL_ASSIGN_OR_RETURN( + uint64_t entries_to_reserve, + EntriesToReserve(reader, entry_count, sizeof(uint64_t))); + box.offsets.reserve(entries_to_reserve); for (uint32_t i = 0; i < entry_count; ++i) { uint64_t offset; if (!riegeli::ReadBigEndian<uint64_t>(reader, offset)) { @@ -355,7 +420,12 @@ return absl::DataLossError("Failed to read entry count"); } - box.offsets.reserve(entry_count); + ABSL_ASSIGN_OR_RETURN( + uint64_t entries_to_reserve, + EntriesToReserve( + reader, entry_count, + header.version == 0 ? sizeof(uint32_t) : sizeof(uint64_t))); + box.offsets.reserve(entries_to_reserve); for (uint32_t i = 0; i < entry_count; ++i) { if (header.version == 0) { uint32_t offset;
diff --git a/formats/bmff/offset_adjustment_test.cc b/formats/bmff/offset_adjustment_test.cc index eb512bc..09d8ae4 100644 --- a/formats/bmff/offset_adjustment_test.cc +++ b/formats/bmff/offset_adjustment_test.cc
@@ -15,15 +15,19 @@ #include "formats/bmff/offset_adjustment.h" +#include <cstdint> +#include <optional> #include <string> #include <vector> #include "absl/status/status.h" #include "absl/status/status_matchers.h" #include "absl/strings/str_cat.h" +#include "absl/strings/string_view.h" #include "formats/bmff/box_header.h" #include "gmock/gmock.h" #include "gtest/gtest.h" +#include "riegeli/base/types.h" #include "riegeli/bytes/string_reader.h" #include "riegeli/bytes/string_writer.h" #include "testing/test_string_utils.h" @@ -39,6 +43,37 @@ using ::testing::ElementsAre; using ::testing::HasSubstr; +class NoSizeStringReader : public riegeli::StringReader<> { + public: + using riegeli::StringReader<>::StringReader; + bool SupportsSize() override { return false; } +}; + +class SizeNotValuedStringReader : public riegeli::StringReader<> { + public: + using riegeli::StringReader<>::StringReader; + bool SupportsSize() override { return true; } + + protected: + std::optional<riegeli::Position> SizeImpl() override { return std::nullopt; } +}; + +class CustomSizeStringReader : public riegeli::StringReader<> { + public: + explicit CustomSizeStringReader(absl::string_view src, + riegeli::Position reported_size) + : riegeli::StringReader<>(src), reported_size_(reported_size) {} + bool SupportsSize() override { return true; } + + protected: + std::optional<riegeli::Position> SizeImpl() override { + return reported_size_; + } + + private: + riegeli::Position reported_size_; +}; + TEST(OffsetAdjustmentTest, Stco) { std::string data = absl::StrCat(Uint32Str(2), // entry_count Uint32Str(100), // offset 1 @@ -506,5 +541,269 @@ writer.Close(); } +// The following tests use counts that exceed the remaining data. They must fail +// before reserving memory for the declared entries, which would otherwise take +// gigabytes. + +TEST(OffsetAdjustmentTest, StcoEntryCountExceedsData) { + std::string data = absl::StrCat(Uint32Str(0xffffffff), Uint32Str(100)); + riegeli::StringReader reader(data); + EXPECT_THAT(ParseStco(reader), + StatusIs(absl::StatusCode::kDataLoss, + HasSubstr("entry count 4294967295 exceeds the " + "remaining 4 bytes"))); +} + +TEST(OffsetAdjustmentTest, Co64EntryCountExceedsData) { + // Two entries need 16 bytes, but only 15 remain. + std::string data = + absl::StrCat(Uint32Str(2), Uint64Str(100), std::string(7, '\0')); + riegeli::StringReader reader(data); + EXPECT_THAT(ParseCo64(reader), + StatusIs(absl::StatusCode::kDataLoss, + HasSubstr("entry count 2 exceeds the remaining 15 " + "bytes"))); +} + +TEST(OffsetAdjustmentTest, SaioEntryCountExceedsData) { + BmffBoxHeader header; + header.version = 1; + header.flags = std::string("\x00\x00\x00", 3); + std::string data = absl::StrCat(Uint32Str(0xffffffff), Uint64Str(100)); + riegeli::StringReader reader(data); + EXPECT_THAT(ParseSaio(reader, header), + StatusIs(absl::StatusCode::kDataLoss, + HasSubstr("entry count 4294967295 exceeds the " + "remaining 8 bytes"))); +} + +TEST(OffsetAdjustmentTest, SaioVersion0EntryCountExceedsData) { + // Two version 0 entries need 8 bytes, but only 7 remain. + BmffBoxHeader header; + header.version = 0; + header.flags = std::string("\x00\x00\x00", 3); + std::string data = + absl::StrCat(Uint32Str(2), Uint32Str(100), std::string(3, '\0')); + riegeli::StringReader reader(data); + EXPECT_THAT(ParseSaio(reader, header), + StatusIs(absl::StatusCode::kDataLoss, + HasSubstr("entry count 2 exceeds the remaining 7 " + "bytes"))); +} + +TEST(OffsetAdjustmentTest, IlocItemCountExceedsData) { + BmffBoxHeader header; + header.version = 2; + std::string data = absl::StrCat(Uint8Str(0x44), // offset/length size + Uint8Str(0x00), // base offset/index size + Uint32Str(0xffffffff), // item_count + Uint32Str(1)); // item_ID + riegeli::StringReader reader(data); + EXPECT_THAT(ParseIloc(reader, header), + StatusIs(absl::StatusCode::kDataLoss, + HasSubstr("entry count 4294967295 exceeds the " + "remaining 4 bytes"))); +} + +TEST(OffsetAdjustmentTest, IlocExtentCountExceedsData) { + BmffBoxHeader header; + header.version = 0; + std::string data = absl::StrCat(Uint8Str(0x44), // offset/length size + Uint8Str(0x00), // base offset/index size + Uint16Str(1), // item_count + Uint16Str(1), // item_ID + Uint16Str(0), // data_reference_index + Uint16Str(0xffff), // extent_count + Uint32Str(100), // extent_offset + Uint32Str(20)); // extent_length + riegeli::StringReader reader(data); + EXPECT_THAT(ParseIloc(reader, header), + StatusIs(absl::StatusCode::kDataLoss, + HasSubstr("entry count 65535 exceeds the remaining 8 " + "bytes"))); +} + +TEST(OffsetAdjustmentTest, IlocZeroSizeExtent) { + // With all extent fields absent, a single extent occupies no bytes and refers + // to the entire referenced container. + BmffBoxHeader header; + header.version = 0; + std::string data = absl::StrCat(Uint8Str(0x00), // offset/length size + Uint8Str(0x00), // base offset/index size + Uint16Str(1), // item_count + Uint16Str(1), // item_ID + Uint16Str(0), // data_reference_index + Uint16Str(1)); // extent_count + riegeli::StringReader reader(data); + auto box_or = ParseIloc(reader, header); + ABSL_ASSERT_OK(box_or); + const IlocBox& box = *box_or; + ASSERT_EQ(box.items.size(), 1); + ASSERT_EQ(box.items[0].extents.size(), 1); + EXPECT_EQ(box.items[0].extents[0].offset, 0); + EXPECT_EQ(box.items[0].extents[0].length, 0); +} + +TEST(OffsetAdjustmentTest, IlocZeroSizeMultipleExtents) { + BmffBoxHeader header; + header.version = 0; + std::string data = absl::StrCat(Uint8Str(0x00), // offset/length size + Uint8Str(0x00), // base offset/index size + Uint16Str(1), // item_count + Uint16Str(1), // item_ID + Uint16Str(0), // data_reference_index + Uint16Str(3)); // extent_count + riegeli::StringReader reader(data); + EXPECT_THAT(ParseIloc(reader, header), + StatusIs(absl::StatusCode::kDataLoss, + HasSubstr("iloc item 1 has 3 extents of size 0"))); +} + +// When the reader cannot report its size, counts cannot be checked up front, so +// parsing proceeds entry by entry and fails at the end of the data. +TEST(OffsetAdjustmentTest, Co64NoSizeReader) { + std::string data = absl::StrCat(Uint32Str(2), Uint64Str(100), Uint64Str(200)); + NoSizeStringReader reader(data); + auto box_or = ParseCo64(reader); + ABSL_ASSERT_OK(box_or); + EXPECT_THAT(box_or->offsets, ElementsAre(100, 200)); +} + +TEST(OffsetAdjustmentTest, Co64NoSizeReaderEntryCountExceedsData) { + std::string data = absl::StrCat(Uint32Str(0xffffffff), Uint64Str(100)); + NoSizeStringReader reader(data); + EXPECT_THAT(ParseCo64(reader), StatusIs(absl::StatusCode::kDataLoss, + HasSubstr("Failed to read offset"))); +} + +TEST(OffsetAdjustmentTest, Co64UnknownSizeEntryCountExceedsData) { + std::string data = absl::StrCat(Uint32Str(0xffffffff), Uint64Str(100)); + SizeNotValuedStringReader reader(data); + EXPECT_THAT(ParseCo64(reader), StatusIs(absl::StatusCode::kDataLoss, + HasSubstr("Failed to read offset"))); +} + +TEST(OffsetAdjustmentTest, Co64ReportedSizeSmallerThanPos) { + std::string data = absl::StrCat(Uint32Str(1), Uint64Str(100)); + CustomSizeStringReader reader(data, /*reported_size=*/0); + EXPECT_THAT(ParseCo64(reader), + StatusIs(absl::StatusCode::kDataLoss, + HasSubstr("entry count 1 exceeds the remaining 0 " + "bytes"))); +} + +// When the reader reports a size large enough to satisfy the entry count check +// (e.g. a sparse file or spoofed size), the initial reservation is still capped +// at kMaxEntryCountToReserve rather than allocating for all declared entries. +TEST(OffsetAdjustmentTest, StcoLargeReportedSizeCapsReserve) { + std::string data = absl::StrCat(Uint32Str(0xffffffff), Uint32Str(100)); + CustomSizeStringReader reader(data, /*reported_size=*/uint64_t{1} << 40); + EXPECT_THAT(ParseStco(reader), StatusIs(absl::StatusCode::kDataLoss, + HasSubstr("Failed to read offset"))); +} + +TEST(OffsetAdjustmentTest, Co64LargeReportedSizeCapsReserve) { + std::string data = absl::StrCat(Uint32Str(0xffffffff), Uint64Str(100)); + CustomSizeStringReader reader(data, /*reported_size=*/uint64_t{1} << 40); + EXPECT_THAT(ParseCo64(reader), StatusIs(absl::StatusCode::kDataLoss, + HasSubstr("Failed to read offset"))); +} + +TEST(OffsetAdjustmentTest, SaioLargeReportedSizeCapsReserve) { + BmffBoxHeader header; + header.version = 1; + header.flags = std::string("\x00\x00\x00", 3); + std::string data = absl::StrCat(Uint32Str(0xffffffff), Uint64Str(100)); + CustomSizeStringReader reader(data, /*reported_size=*/uint64_t{1} << 40); + EXPECT_THAT(ParseSaio(reader, header), + StatusIs(absl::StatusCode::kDataLoss, + HasSubstr("Failed to read offset"))); +} + +TEST(OffsetAdjustmentTest, IlocLargeReportedSizeCapsReserve) { + BmffBoxHeader header; + header.version = 2; + std::string data = absl::StrCat(Uint8Str(0x44), // offset/length size + Uint8Str(0x00), // base offset/index + Uint32Str(0xffffffff), // item_count + Uint32Str(1)); // item_ID + CustomSizeStringReader reader(data, /*reported_size=*/uint64_t{1} << 40); + EXPECT_THAT(ParseIloc(reader, header), StatusIs(absl::StatusCode::kDataLoss, + HasSubstr("Failed to read"))); +} + +// The following tests pin the minimum item and extent sizes for each iloc +// version: the data holds enough bytes for the count only if the size is +// underestimated. + +TEST(OffsetAdjustmentTest, IlocVersion2ItemCountUsesFourByteItemId) { + // A version 2 item has at least 10 bytes: item_ID (4), + // construction_method (2), data_reference_index (2), and extent_count (2). + BmffBoxHeader header; + header.version = 2; + std::string data = absl::StrCat(Uint8Str(0x44), // offset/length size + Uint8Str(0x00), // base offset/index size + Uint32Str(2), // item_count + std::string(16, '\0')); + riegeli::StringReader reader(data); + EXPECT_THAT(ParseIloc(reader, header), + StatusIs(absl::StatusCode::kDataLoss, + HasSubstr("entry count 2 exceeds the remaining 16 " + "bytes"))); +} + +TEST(OffsetAdjustmentTest, IlocVersion1ItemCountIncludesConstructionMethod) { + // A version 1 item has at least 8 bytes: item_ID (2), + // construction_method (2), data_reference_index (2), and extent_count (2). + BmffBoxHeader header; + header.version = 1; + std::string data = absl::StrCat(Uint8Str(0x44), // offset/length size + Uint8Str(0x00), // base offset/index size + Uint16Str(2), // item_count + std::string(12, '\0')); + riegeli::StringReader reader(data); + EXPECT_THAT(ParseIloc(reader, header), + StatusIs(absl::StatusCode::kDataLoss, + HasSubstr("entry count 2 exceeds the remaining 12 " + "bytes"))); +} + +TEST(OffsetAdjustmentTest, IlocItemCountIncludesBaseOffset) { + // A version 0 item with a 4 byte base_offset has at least 10 bytes: + // item_ID (2), data_reference_index (2), base_offset (4), and + // extent_count (2). + BmffBoxHeader header; + header.version = 0; + std::string data = absl::StrCat(Uint8Str(0x44), // offset/length size + Uint8Str(0x40), // base offset/index size + Uint16Str(2), // item_count + std::string(16, '\0')); + riegeli::StringReader reader(data); + EXPECT_THAT(ParseIloc(reader, header), + StatusIs(absl::StatusCode::kDataLoss, + HasSubstr("entry count 2 exceeds the remaining 16 " + "bytes"))); +} + +TEST(OffsetAdjustmentTest, IlocVersion1ExtentCountIncludesIndex) { + // Each extent has an extent_index (4), extent_offset (4), and + // extent_length (4). + BmffBoxHeader header; + header.version = 1; + std::string data = absl::StrCat(Uint8Str(0x44), // offset/length size + Uint8Str(0x04), // base offset/index size + Uint16Str(1), // item_count + Uint16Str(1), // item_ID + Uint16Str(0), // construction_method + Uint16Str(0), // data_reference_index + Uint16Str(2), // extent_count + std::string(16, '\0')); + riegeli::StringReader reader(data); + EXPECT_THAT(ParseIloc(reader, header), + StatusIs(absl::StatusCode::kDataLoss, + HasSubstr("entry count 2 exceeds the remaining 16 " + "bytes"))); +} + } // namespace } // namespace credentio
diff --git a/formats/pdf/constants.h b/formats/pdf/constants.h index 537a55a..b792a52 100644 --- a/formats/pdf/constants.h +++ b/formats/pdf/constants.h
@@ -43,6 +43,12 @@ constexpr absl::string_view kPdfParamsKey = "Params"; constexpr absl::string_view kPdfModDateKey = "ModDate"; +// Page and annotation dictionary constants, see ISO 32000-2 Sections 7.7.3.3 +// and 12.5.6.15. +constexpr absl::string_view kPdfAnnotsKey = "Annots"; +constexpr absl::string_view kPdfFileAttachmentSubtype = "FileAttachment"; +constexpr absl::string_view kPdfFsKey = "FS"; + // C2PA Manifest file spec constants. See // https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_adding_the_manifest_to_a_pdf. constexpr absl::string_view kPdfC2paManifestMimeType = "application/c2pa";
diff --git a/formats/pdf/objects.h b/formats/pdf/objects.h index 4b19df5..929dfd2 100644 --- a/formats/pdf/objects.h +++ b/formats/pdf/objects.h
@@ -23,6 +23,7 @@ #include <ostream> #include <sstream> #include <string> +#include <utility> #include <variant> #include <vector> @@ -165,6 +166,10 @@ const IndirectReference& r) { return os << r.object_number << " " << r.generation_number << " R"; } + template <typename H> + friend H AbslHashValue(H h, const IndirectReference& r) { + return H::combine(std::move(h), r.object_number, r.generation_number); + } }; std::variant<Boolean, Integer, RealNumber, LiteralString, HexadecimalString,
diff --git a/formats/pdf/reader.cc b/formats/pdf/reader.cc index 6c635a0..9a5345b 100644 --- a/formats/pdf/reader.cc +++ b/formats/pdf/reader.cc
@@ -40,8 +40,10 @@ namespace { // Matches the cross-reference table offset in the trailer. See ISO 32000-2 -// Section 7.5.5 -static constexpr LazyRE2 kXrefOffsetPattern = {R"(startxref\s(\d+)\s%%EOF)"}; +// Section 7.5.5. Any run of white-space is accepted around the offset: this +// covers CR LF end-of-line markers (Section 7.2.3), and readers may treat +// end-of-line markers like other white-space. +static constexpr LazyRE2 kXrefOffsetPattern = {R"(startxref\s+(\d+)\s+%%EOF)"}; // Matches a single cross-reference entry in the cross-reference table. See ISO // 32000-2 Section 7.5.4 @@ -167,6 +169,22 @@ return absl::OkStatus(); } + +// Returns an error if `obj`, read at `offset`, is not the object `ref` that the +// cross-reference table lists at that offset. +absl::Status CheckObjectHeader(const IndirectObject& obj, + const Object::IndirectReference& ref, + uint64_t offset) { + if (obj.object_number != ref.object_number || + obj.generation_number != ref.generation_number) { + return absl::InvalidArgumentError(absl::Substitute( + "Object mismatch at offset $0: the cross-reference table expects " + "object $1 $2 but found object $3 $4", + offset, ref.object_number, ref.generation_number, obj.object_number, + obj.generation_number)); + } + return absl::OkStatus(); +} } // namespace absl::StatusOr<std::unique_ptr<PdfReader>> PdfReader::Create( @@ -238,6 +256,8 @@ !status.ok()) { return status; } + ABSL_RETURN_IF_ERROR( + CheckObjectHeader(obj, current_it_->first, current_it_->second)); ++current_it_; return obj; } @@ -284,6 +304,7 @@ !status.ok()) { return status; } + ABSL_RETURN_IF_ERROR(CheckObjectHeader(obj, ref, offset)); return obj; }
diff --git a/formats/pdf/reader_test.cc b/formats/pdf/reader_test.cc index cd9cb71..df11a35 100644 --- a/formats/pdf/reader_test.cc +++ b/formats/pdf/reader_test.cc
@@ -25,6 +25,7 @@ #include "absl/status/status.h" #include "absl/status/status_matchers.h" #include "absl/status/statusor.h" +#include "absl/strings/escaping.h" #include "absl/strings/str_replace.h" #include "absl/strings/string_view.h" #include "formats/pdf/objects.h" @@ -36,6 +37,7 @@ namespace credentio { namespace { +using ::absl_testing::IsOk; using ::absl_testing::IsOkAndHolds; using ::absl_testing::StatusIs; using ::testing::ElementsAre; @@ -233,6 +235,64 @@ StatusIs(absl::StatusCode::kNotFound, HasSubstr("No offset found"))); } +// Returns the test PDF with the cross-reference entry for object 2 pointing at +// the header of object 1. +std::string GetTestPdfContentWithMismatchedObjectNumber() { + return absl::StrReplaceAll(GetTestPdfContent(), + {{"0000000041 00000 n ", "0000000015 00000 n "}}); +} + +// Returns the test PDF with object 2 declared as `2 1 obj` while the +// cross-reference table lists it as `2 0`. +std::string GetTestPdfContentWithMismatchedGenerationNumber() { + return absl::StrReplaceAll(GetTestPdfContent(), {{"2 0 obj", "2 1 obj"}}); +} + +TEST(PdfReaderTest, GetObjectWithMismatchedObjectNumberFailure) { + std::string content = GetTestPdfContentWithMismatchedObjectNumber(); + riegeli::StringReader<> input(content); + absl::StatusOr<std::unique_ptr<PdfReader>> reader = PdfReader::Create(&input); + ABSL_ASSERT_OK(reader); + + EXPECT_THAT((*reader)->GetObject(2, 0), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("expects object 2 0 but found object 1 0"))); + // Entries that point at the right header still resolve. + EXPECT_THAT((*reader)->GetObject(1, 0), + IsOkAndHolds(IndirectObject{.object_number = 1, + .generation_number = 0, + .object = CreateName("test_name")})); + EXPECT_THAT( + (*reader)->GetObject(3, 0), + IsOkAndHolds(IndirectObject{ + .object_number = 3, + .generation_number = 0, + .object = CreateDictionary({{"Type", CreateName("Catalog")}})})); +} + +TEST(PdfReaderTest, GetObjectWithMismatchedGenerationNumberFailure) { + std::string content = GetTestPdfContentWithMismatchedGenerationNumber(); + riegeli::StringReader<> input(content); + absl::StatusOr<std::unique_ptr<PdfReader>> reader = PdfReader::Create(&input); + ABSL_ASSERT_OK(reader); + + EXPECT_THAT((*reader)->GetObject(2, 0), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("expects object 2 0 but found object 2 1"))); +} + +TEST(PdfReaderTest, NextWithMismatchedObjectNumberFailure) { + std::string content = GetTestPdfContentWithMismatchedObjectNumber(); + riegeli::StringReader<> input(content); + absl::StatusOr<std::unique_ptr<PdfReader>> reader = PdfReader::Create(&input); + ABSL_ASSERT_OK(reader); + + ABSL_ASSERT_OK((*reader)->Next()); // Object 1. + EXPECT_THAT((*reader)->Next(), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("expects object 2 0 but found object 1 0"))); +} + TEST(PdfReaderTest, ShortFileCausesInitializationFailure) { std::string content = "file is too short"; riegeli::StringReader<> input(content); @@ -308,6 +368,58 @@ HasSubstr("Object is not a credentio::Object::Integer"))); } +// Returns the test PDF with its "startxref" trailer replaced by `trailer`. +std::string GetTestPdfContentWithTrailer(absl::string_view trailer) { + return absl::StrReplaceAll(GetTestPdfContent(), + {{"startxref\n98\n%%EOF", trailer}}); +} + +TEST(PdfReaderTest, ReadsTrailerWithAnyEndOfLineMarkers) { + for (absl::string_view trailer : { + "startxref\n98\n%%EOF", + "startxref\r\n98\r\n%%EOF", + "startxref\r98\r%%EOF", + "startxref\n98\n%%EOF\n", + "startxref\r\n98\r\n%%EOF\r\n", + "startxref \n\n98 \r\n\r\n%%EOF", + }) { + std::string content = GetTestPdfContentWithTrailer(trailer); + riegeli::StringReader<> input(content); + absl::StatusOr<std::unique_ptr<PdfReader>> reader = + PdfReader::Create(&input); + EXPECT_THAT(reader, IsOk()) << "trailer: " << absl::CEscape(trailer); + if (!reader.ok()) continue; + EXPECT_THAT( + (*reader)->GetObject(2, 0), + IsOkAndHolds(IndirectObject{.object_number = 2, + .generation_number = 0, + .object = CreateRealNumber("123.456")})) + << "trailer: " << absl::CEscape(trailer); + } +} + +TEST(PdfReaderTest, CrlfTrailerWithTenDigitOffsetFitsTailScan) { + // "startxref\r\n" (11) + 10 digits + "\r\n%%EOF\r\n" (9) = 30 bytes, which + // is exactly the tail scan. The offset is found, then seeking to it fails. + std::string content = + GetTestPdfContentWithTrailer("startxref\r\n9999999999\r\n%%EOF\r\n"); + riegeli::StringReader<> input(content); + EXPECT_THAT(PdfReader::Create(&input), + StatusIs(absl::StatusCode::kDataLoss, + HasSubstr("Failed to seek to offset"))); +} + +TEST(PdfReaderTest, CrlfTrailerWithElevenDigitOffsetExceedsTailScanFailure) { + // 31 bytes: "startxref" no longer fits in the 30-byte tail scan. + std::string content = + GetTestPdfContentWithTrailer("startxref\r\n99999999999\r\n%%EOF\r\n"); + riegeli::StringReader<> input(content); + EXPECT_THAT( + PdfReader::Create(&input), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("Failed to find cross-reference table offset"))); +} + TEST(PdfReaderTest, ContainingPreviousXrefTable) { std::string content = R"(%PDF-1.7 1 0 obj
diff --git a/formats/pdf/writer.cc b/formats/pdf/writer.cc index 8797136..9ef92c7 100644 --- a/formats/pdf/writer.cc +++ b/formats/pdf/writer.cc
@@ -76,7 +76,7 @@ DataHashTemplate(alg)); ABSL_ASSIGN_OR_RETURN(ManifestStoreTemplate manifest_store_template, generator_.GenerateManifestStoreTemplate(request)); - constexpr int64_t kMaxManifestStoreSizeBytes = 100 * 1024 * 1024; // 100 MiB + constexpr int64_t kMaxManifestStoreSizeBytes = 10 * 1024 * 1024; // 10 MiB if (manifest_store_template.manifest_store_size() <= 0 || manifest_store_template.manifest_store_size() > kMaxManifestStoreSizeBytes) {
diff --git a/formats/pdf/writer_test.cc b/formats/pdf/writer_test.cc index 9656960..0e1c171 100644 --- a/formats/pdf/writer_test.cc +++ b/formats/pdf/writer_test.cc
@@ -191,7 +191,32 @@ } INSTANTIATE_TEST_SUITE_P(PdfWriterInvalidSizeTests, PdfWriterInvalidSizeTest, - ::testing::Values(-5, 0, 100LL * 1024 * 1024 + 1)); + ::testing::Values(-5, 0, 10LL * 1024 * 1024 + 1)); + +TEST(PdfWriterMaxSizeTest, LargestManifestStoreCanBeReadBack) { + constexpr int64_t kMaxManifestStoreSize = 10 * 1024 * 1024; + FakeManifestStoreGenerator generator(std::string(kMaxManifestStoreSize, 'x')); + PdfWriter writer(&generator); + GenerateManifestStoreRequest request; + request.mutable_actions() + ->mutable_created() + ->mutable_action_metadata() + ->set_description("test only"); + riegeli::StringReader source(kTestPdfContent); + ABSL_ASSERT_OK(source.status()); + std::string result; + riegeli::StringWriter destination(&result); + ABSL_ASSERT_OK(destination.status()); + ABSL_ASSERT_OK(writer.Write(request, &source, &destination)); + ASSERT_TRUE(destination.Close()); + + // The location covers the whole stream object, not just its data. + riegeli::StringReader result_reader(result); + EXPECT_THAT(PdfExtractor().ExtractManifestStoreLocation(result_reader, {}), + IsOkAndHolds(Optional( + Field(&ByteRange::length, + Gt(static_cast<uint64_t>(kMaxManifestStoreSize)))))); +} } // namespace } // namespace credentio
diff --git a/formats/tiff/BUILD b/formats/tiff/BUILD index 17cdcee..41b6779 100644 --- a/formats/tiff/BUILD +++ b/formats/tiff/BUILD
@@ -221,7 +221,11 @@ "//formats:byte_range", "//generator", "//proto:generator_cc_proto", + "//proto:manifest_cc_proto", + "//proto:validation_status_cc_proto", "//testing:nonprod_generator", + "//testing:nonprod_validator", + "//validator:asset_validator", "@abseil-cpp//absl/container:flat_hash_set", "@abseil-cpp//absl/log:absl_check", "@abseil-cpp//absl/status",
diff --git a/formats/tiff/writer.cc b/formats/tiff/writer.cc index b59d26d..076c3f0 100644 --- a/formats/tiff/writer.cc +++ b/formats/tiff/writer.cc
@@ -316,7 +316,8 @@ absl::Status WriteImageFileDirectory( absl::Span<const TiffImageFileDirectoryEntry> entries, TiffEndianness endianness, std::string& destination) { - riegeli::StringWriter<> writer(&destination); + riegeli::StringWriter<> writer( + &destination, riegeli::StringWriterBase::Options().set_append(true)); ABSL_RETURN_IF_ERROR(WriteImageFileDirectory(entries, endianness, writer)); if (!writer.Close()) { return writer.status(); @@ -342,6 +343,8 @@ } uint64_t source_size = *source->Size(); + uint64_t padding_size = source_size % 2; + uint64_t ifd_offset = source_size + padding_size; auto hash_algorithm = HashAlgorithm::kSha256; @@ -351,17 +354,17 @@ ABSL_ASSIGN_OR_RETURN( ArtifactInformation artifact_information, - ScanForArtifactInformation(*source, source_size, request, + ScanForArtifactInformation(*source, ifd_offset, request, options_.exclusion_processor, assertion)); ABSL_ASSIGN_OR_RETURN(std::vector<ByteInstruction> byte_instructions, CreateByteInstructionsForIfdInsertion( artifact_information.ifd_to_add_to, - artifact_information.endianness, source_size)); + artifact_information.endianness, ifd_offset)); uint32_t file_size_after_ifd = - source_size + ByteSizeOfImageFileDirectory( - artifact_information.ifd_to_add_to.entries.size() + 1); + ifd_offset + ByteSizeOfImageFileDirectory( + artifact_information.ifd_to_add_to.entries.size() + 1); artifact_information.ifd_to_add_to.entries.push_back( TiffImageFileDirectoryEntry{ @@ -389,7 +392,7 @@ ++c2pa_entry_index; } - count_exclusion->set_start(source_size + // Existing file size + count_exclusion->set_start(ifd_offset + // Offset of relocated IFD sizeof(uint16_t) + // final IFD count // Number of entries before C2PA entry (c2pa_entry_index * kTiffIfdEntrySize) + @@ -430,6 +433,7 @@ ByteInstruction last_ifd{ .operation = ByteInstruction::Operation::kInsert, .offset = source_size, + .bytes = std::string(padding_size, '\0'), }; ABSL_RETURN_IF_ERROR(WriteImageFileDirectory( artifact_information.ifd_to_add_to.entries,
diff --git a/formats/tiff/writer_test.cc b/formats/tiff/writer_test.cc index 8fc46e5..b1b97de 100644 --- a/formats/tiff/writer_test.cc +++ b/formats/tiff/writer_test.cc
@@ -34,10 +34,14 @@ #include "gmock/gmock.h" #include "gtest/gtest.h" #include "proto/generator.pb.h" +#include "proto/manifest.pb.h" +#include "proto/validation_status.pb.h" #include "riegeli/bytes/string_reader.h" #include "riegeli/bytes/string_writer.h" #include "riegeli/endian/endian_writing.h" #include "testing/nonprod_generator.h" +#include "testing/nonprod_validator.h" +#include "validator/asset_validator.h" namespace credentio { namespace { @@ -48,6 +52,7 @@ using ::testing::Field; using ::testing::Gt; using ::testing::HasSubstr; +using ::testing::IsEmpty; using ::testing::Optional; std::string CreateMinimalTiff( @@ -93,10 +98,15 @@ ABSL_CHECK_OK(generator); generator_ = *std::move(generator); writer_ = std::make_unique<TiffWriter>(generator_.get()); + + auto validator = CreateNonProdValidator({}); + ABSL_CHECK_OK(validator); + validator_ = *std::move(validator); } std::unique_ptr<ManifestStoreGenerator> generator_; std::unique_ptr<TiffWriter> writer_; + std::unique_ptr<AssetValidator> validator_; }; TEST_F(TiffWriterTest, WriteMaintainsSortedTags) { @@ -221,5 +231,55 @@ Field(&ByteRange::length, Gt(0)))))); } +TEST_F(TiffWriterTest, WriteRelocatesIfdToWordBoundary) { + std::string buffer = CreateMinimalTiff(); + buffer.push_back('x'); + ASSERT_EQ(buffer.size() % 2, 1); + + GenerateManifestStoreRequest request; + request.mutable_actions() + ->mutable_created() + ->mutable_action_metadata() + ->set_description("test only"); + + riegeli::StringReader source(buffer); + std::string result; + riegeli::StringWriter destination(&result); + ABSL_ASSERT_OK(writer_->Write(request, &source, &destination)); + ASSERT_TRUE(source.Close()); + ASSERT_TRUE(destination.Close()); + + EXPECT_EQ(result[buffer.size()], '\0'); + + riegeli::StringReader result_reader(result); + EXPECT_THAT(TiffExtractor().ExtractManifestStoreLocation(result_reader, {}), + IsOkAndHolds(Optional(AllOf( + Field(&ByteRange::offset, + buffer.size() + 1 + ByteSizeOfImageFileDirectory(3)), + Field(&ByteRange::length, Gt(0)))))); + + ASSERT_TRUE(result_reader.Seek(0)); + absl::flat_hash_set<uint32_t> visited_offsets; + bool validated = false; + ABSL_ASSERT_OK(IterateOverImageFileDirectories( + result_reader, /*known_endianness=*/std::nullopt, visited_offsets, + [&](const TiffImageFileDirectory& ifd, + TiffEndianness endianness) -> absl::StatusOr<bool> { + validated = true; + EXPECT_EQ(ifd.offset, buffer.size() + 1); + EXPECT_EQ(ifd.offset % 2, 0); + EXPECT_EQ(ifd.entries.size(), 3); + return true; + })); + EXPECT_TRUE(validated); + + ASSERT_TRUE(result_reader.Seek(0)); + auto validation_result = validator_->Validate(result_reader, "image/tiff"); + ABSL_ASSERT_OK(validation_result); + EXPECT_THAT( + (*validation_result)->proto().active_manifest().validation().failures(), + IsEmpty()); +} + } // namespace } // namespace credentio
diff --git a/kokoro/android/build_and_test.sh b/kokoro/android/build_and_test.sh new file mode 100755 index 0000000..ffd4226 --- /dev/null +++ b/kokoro/android/build_and_test.sh
@@ -0,0 +1,122 @@ +#!/bin/bash +# Copyright 2026 Google LLC +# +# 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 +# +# https://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. +# + +# Builds and tests the C2PA Android SDK, including emulator instrumentation +# tests, on a Kokoro VM that supports nested virtualization. +set -e + +# KOKORO_ARTIFACTS_DIR is set to /tmpfs/src by default in the Kokoro Ubuntu image. +export KOKORO_ARTIFACTS_DIR="${KOKORO_ARTIFACTS_DIR:-/tmpfs/src}" +# Default to running instrumentation tests when Android environment is present. +export RUN_INSTRUMENTATION_TESTS="${RUN_INSTRUMENTATION_TESTS:-1}" +cd "${KOKORO_ARTIFACTS_DIR}/git/staging" + +EMULATOR_PID="" +function create_and_start_emulator() { + local emulator_name=c2pa_emulator + if ! sdkmanager --list_installed 2>/dev/null | grep -q "system-images;android-35;google_apis;x86_64"; then + echo "Emulator system image not pre-installed; downloading..." + echo y | sdkmanager "platforms;android-35" "emulator" "system-images;android-35;google_apis;x86_64" || true + fi + + echo no | avdmanager create avd -n "${emulator_name}" \ + --package "system-images;android-35;google_apis;x86_64" \ + --force + + mkdir -p "${KOKORO_ARTIFACTS_DIR}/emulator-logs" + "${ANDROID_HOME}/emulator/emulator" -avd "${emulator_name}" \ + -no-window \ + -gpu swiftshader_indirect \ + -no-snapshot \ + -no-audio \ + -no-boot-anim </dev/null > "${KOKORO_ARTIFACTS_DIR}/emulator-logs/emulator_${emulator_name}.log" 2>&1 & + EMULATOR_PID=$! +} + +function wait_for_emulator_to_boot() { + local retries=60 # 5 minutes (60 * 5s) + while [[ $retries -gt 0 ]]; do + local boot_completed + boot_completed=$(adb shell getprop sys.boot_completed 2>/dev/null | tr -d '\r') + if [[ "${boot_completed}" == "1" ]]; then + break + fi + echo "Waiting for emulator to boot..." + sleep 5 + ((retries--)) + done + + if [[ $retries -eq 0 ]]; then + echo "Emulator failed to boot in time." + exit 1 + fi + + echo "Emulator booted." +} + +function cleanup() { + set +e + if [ -n "${EMULATOR_PID:-}" ]; then + echo "Stopping emulator..." + adb emu kill >/dev/null 2>&1 || true + kill -9 "${EMULATOR_PID}" >/dev/null 2>&1 || true + wait "${EMULATOR_PID}" 2>/dev/null || true + fi + if [ -x "android/gradlew" ]; then + (cd android && ./gradlew --stop >/dev/null 2>&1) || true + elif [ -x "./gradlew" ]; then + ./gradlew --stop >/dev/null 2>&1 || true + fi + if command -v adb >/dev/null 2>&1; then + adb kill-server >/dev/null 2>&1 || true + fi +} +trap cleanup EXIT + +# --- Run Gradle build and tests for C2PA Android SDK --- +if [ -d "android" ] && [ -x "android/gradlew" ]; then + + cd android + + ./gradlew assembleDebug :crypto:tsa_app:assembleDebug testDebugUnitTest --no-daemon --stacktrace + + if [ "${RUN_INSTRUMENTATION_TESTS:-0}" = "1" ] || [ "${RUN_INSTRUMENTATION_TESTS:-false}" = "true" ]; then + # Fail fast with a clear message instead of timing out on emulator boot. + if [ ! -w /dev/kvm ]; then + echo "/dev/kvm is not available; the Android emulator needs nested virtualization." + exit 1 + fi + + echo "Starting Android emulator for instrumentation tests..." + create_and_start_emulator + wait_for_emulator_to_boot + + echo "Installing TSA test app APK (debug) for instrumentation test runs..." + for apk in crypto/tsa_app/build/outputs/apk/debug/*.apk; do + if [ -f "$apk" ]; then + adb install -r -t "$apk" + break + fi + done + + ./gradlew connectedAndroidTest \ + -Pandroid.testInstrumentationRunnerArguments.clearPackageData=true \ + -Pandroid.testoptions.execution=ANDROIDX_TEST_ORCHESTRATOR \ + --no-daemon --stacktrace + fi +else + echo "No android/gradlew found; skipping Android Gradle build." +fi
diff --git a/kokoro/android/continuous.cfg b/kokoro/android/continuous.cfg new file mode 100644 index 0000000..75b808c --- /dev/null +++ b/kokoro/android/continuous.cfg
@@ -0,0 +1,10 @@ +# proto-file: google3/devtools/kokoro/config/proto/build.proto +# proto-message: BuildConfig + +build_file: "staging/kokoro/android/build_and_test.sh" + +container_properties { + docker_image: "us-central1-docker.pkg.dev/kokoro-container-bakery/c2pa/builder:latest" + # The Android emulator needs /dev/kvm inside the container. + docker_privileged: true +}
diff --git a/kokoro/android/presubmit.cfg b/kokoro/android/presubmit.cfg new file mode 100644 index 0000000..75b808c --- /dev/null +++ b/kokoro/android/presubmit.cfg
@@ -0,0 +1,10 @@ +# proto-file: google3/devtools/kokoro/config/proto/build.proto +# proto-message: BuildConfig + +build_file: "staging/kokoro/android/build_and_test.sh" + +container_properties { + docker_image: "us-central1-docker.pkg.dev/kokoro-container-bakery/c2pa/builder:latest" + # The Android emulator needs /dev/kvm inside the container. + docker_privileged: true +}
diff --git a/kokoro/gcp_ubuntu/build_and_test.sh b/kokoro/gcp_ubuntu/build_and_test.sh index 7646c03..2b601f3 100755 --- a/kokoro/gcp_ubuntu/build_and_test.sh +++ b/kokoro/gcp_ubuntu/build_and_test.sh
@@ -19,8 +19,6 @@ # Navigate to the repository root # KOKORO_ARTIFACTS_DIR is set to /tmpfs/src by default in the Kokoro Ubuntu image. export KOKORO_ARTIFACTS_DIR="${KOKORO_ARTIFACTS_DIR:-/tmpfs/src}" -# Default to running instrumentation tests when Android environment is present. -export RUN_INSTRUMENTATION_TESTS="${RUN_INSTRUMENTATION_TESTS:-1}" cd "${KOKORO_ARTIFACTS_DIR}/git/staging" # Use the Bazel caches pre-warmed in the container image, if any. @@ -36,77 +34,10 @@ bazel build "${bazel_flags[@]}" ... bazel test "${bazel_flags[@]}" ... -function create_and_start_emulator() { - local emulator_name=c2pa_emulator - if ! sdkmanager --list_installed 2>/dev/null | grep -q "system-images;android-35;google_apis;x86_64"; then - echo "Emulator system image not pre-installed; downloading..." - echo y | sdkmanager "platforms;android-35" "emulator" "system-images;android-35;google_apis;x86_64" || true - fi - - echo no | avdmanager create avd -n "${emulator_name}" \ - --package "system-images;android-35;google_apis;x86_64" \ - --force - - mkdir -p "${KOKORO_ARTIFACTS_DIR}/emulator-logs" - "${ANDROID_HOME}/emulator/emulator" -avd "${emulator_name}" \ - -no-window \ - -gpu swiftshader_indirect \ - -no-snapshot \ - -no-audio \ - -no-boot-anim > "${KOKORO_ARTIFACTS_DIR}/emulator-logs/emulator_${emulator_name}.log" 2>&1 & -} - -function wait_for_emulator_to_boot() { - local retries=60 # 5 minutes (60 * 5s) - while [[ $retries -gt 0 ]]; do - local boot_completed - boot_completed=$(adb shell getprop sys.boot_completed 2>/dev/null | tr -d '\r') - if [[ "${boot_completed}" == "1" ]]; then - break - fi - echo "Waiting for emulator to boot..." - sleep 5 - ((retries--)) - done - - if [[ $retries -eq 0 ]]; then - echo "Emulator failed to boot in time." - exit 1 - fi - - echo "Emulator booted." -} - function cleanup() { set +e - if [ "${RUN_INSTRUMENTATION_TESTS:-0}" = "1" ] || [ "${RUN_INSTRUMENTATION_TESTS:-false}" = "true" ]; then - echo "Stopping emulator..." - adb emu kill 2>/dev/null || true + if command -v bazel >/dev/null 2>&1; then + bazel shutdown >/dev/null 2>&1 || true fi } trap cleanup EXIT - -# --- Run Gradle build and tests for C2PA Android SDK --- -if [ -d "android" ] && [ -x "android/gradlew" ]; then - if ! java -version 2>&1 | grep -q "17\."; then - echo "Installing OpenJDK 17..." - sudo apt-get update || true - sudo apt-get install -y openjdk-17-jdk || true - export JAVA_HOME="/usr/lib/jvm/java-17-openjdk-amd64" - export PATH="${JAVA_HOME}/bin:${PATH}" - fi - - cd android - - gradle_tasks=(assembleDebug testDebugUnitTest) - if [ "${RUN_INSTRUMENTATION_TESTS:-0}" = "1" ] || [ "${RUN_INSTRUMENTATION_TESTS:-false}" = "true" ]; then - echo "Starting Android emulator for instrumentation tests..." - create_and_start_emulator - wait_for_emulator_to_boot - gradle_tasks+=(connectedAndroidTest) - fi - - ./gradlew "${gradle_tasks[@]}" --no-daemon --stacktrace -else - echo "No android/gradlew found; skipping Android Gradle build." -fi
diff --git a/testing/cms.cc b/testing/cms.cc index b73dd6d..705feb7 100644 --- a/testing/cms.cc +++ b/testing/cms.cc
@@ -18,6 +18,7 @@ #include <cstdint> #include <ctime> #include <memory> +#include <optional> #include <string> #include <vector> @@ -126,11 +127,9 @@ } std::string hash = hasher->Digest(); - std::vector<uint8_t> signing_time_der, issuer_name, serial_number; + std::vector<uint8_t> issuer_name, serial_number; if (!SerializeCertificateIssuerName(signer_certificate, &issuer_name) || - !SerializeCertificateSerialNumber(signer_certificate, &serial_number) || - !SerializeSigningTime(absl::ToUnixSeconds(options.signing_time), - &signing_time_der)) { + !SerializeCertificateSerialNumber(signer_certificate, &serial_number)) { return absl::InternalError("Failed to serialize certificate components."); } @@ -170,13 +169,18 @@ } // Signing Time - if (CBB_add_asn1(attributes.get(), &att_seq, CBS_ASN1_SEQUENCE) != 1 || - CBB_add_asn1(&att_seq, &oid, CBS_ASN1_OBJECT) != 1 || - !AddOid(&oid, kOidSigningTime) || - CBB_add_asn1(&att_seq, &value_set, CBS_ASN1_SET) != 1 || - CBB_add_bytes(&value_set, signing_time_der.data(), - signing_time_der.size()) != 1) { - return absl::InternalError("Failed to add signing time attribute."); + if (options.signing_time.has_value()) { + std::vector<uint8_t> signing_time_der; + if (!SerializeSigningTime(absl::ToUnixSeconds(*options.signing_time), + &signing_time_der) || + CBB_add_asn1(attributes.get(), &att_seq, CBS_ASN1_SEQUENCE) != 1 || + CBB_add_asn1(&att_seq, &oid, CBS_ASN1_OBJECT) != 1 || + !AddOid(&oid, kOidSigningTime) || + CBB_add_asn1(&att_seq, &value_set, CBS_ASN1_SET) != 1 || + CBB_add_bytes(&value_set, signing_time_der.data(), + signing_time_der.size()) != 1) { + return absl::InternalError("Failed to add signing time attribute."); + } } // SMIME-CAPS @@ -236,9 +240,11 @@ } } + // Sort signed attributes into canonical DER SET OF order (RFC 5652 §5.4). uint8_t* raw_attr_data; size_t raw_attr_len; - if (CBB_finish(attributes.get(), &raw_attr_data, &raw_attr_len) != 1) { + if (CBB_flush_asn1_set_of(attributes.get()) != 1 || + CBB_finish(attributes.get(), &raw_attr_data, &raw_attr_len) != 1) { return absl::InternalError("Failed to finish attributes CBB."); } bssl::UniquePtr<uint8_t> raw_attr_data_uniq(raw_attr_data);
diff --git a/testing/cms.h b/testing/cms.h index 98c944a..8d76703 100644 --- a/testing/cms.h +++ b/testing/cms.h
@@ -17,6 +17,7 @@ #define THIRD_PARTY_CREDENTIO_TESTING_CMS_H_ #include <cstdint> +#include <optional> #include <string> #include <vector> @@ -30,7 +31,7 @@ namespace credentio_testing { struct SignedCmsOptions { - absl::Time signing_time = absl::UnixEpoch(); + std::optional<absl::Time> signing_time = std::nullopt; bool include_user_certificate = false; absl::Span<const X509* const> ca_certificates = {}; absl::string_view extra_signed_attributes = "";
diff --git a/testing/local_key_crypto_write_handler.cc b/testing/local_key_crypto_write_handler.cc index 944bb4f..71df461 100644 --- a/testing/local_key_crypto_write_handler.cc +++ b/testing/local_key_crypto_write_handler.cc
@@ -125,14 +125,17 @@ absl::StatusOr<std::string> CreateTimeStampToken( HashAlgorithm hash_algorithm, absl::string_view hashed_message, absl::Clock& clock, const PublicKeySign& tsa_signer, - absl::string_view tsa_cert_pem) { + absl::string_view tsa_cert_pem, + std::optional<absl::Time> tsa_signing_time) { ABSL_ASSIGN_OR_RETURN(absl::string_view oid, HashAlgorithmOid(hash_algorithm)); const std::string gen_time = absl::FormatTime("%Y%m%d%H%M%SZ", clock.TimeNow(), absl::UTCTimeZone()); const std::string tst_info = CreateTstInfo(gen_time, hashed_message, oid); return CreateTimeStampTokenEcdsa({tst_info}, tsa_signer, tsa_cert_pem, - /*embed_cert=*/true); + /*embed_cert=*/true, + /*signing_certificate_attribute=*/true, + tsa_signing_time); } } // namespace @@ -181,13 +184,14 @@ std::unique_ptr<PublicKeySign> absl_nullable tsa_signer, bool is_timestamping_disabled, std::vector<std::string> certificate_chain, std::vector<std::string> ocsp_responses, SigningAlgorithm algorithm, - std::string tsa_cert_pem) + std::string tsa_cert_pem, std::optional<absl::Time> tsa_signing_time) : clock_(ABSL_DIE_IF_NULL(clock)), signer_(std::move(signer), std::move(certificate_chain), std::move(ocsp_responses), clock, algorithm), tsa_signer_(std::move(tsa_signer)), is_timestamping_disabled_(is_timestamping_disabled), - tsa_cert_pem_(std::move(tsa_cert_pem)) {} + tsa_cert_pem_(std::move(tsa_cert_pem)), + tsa_signing_time_(tsa_signing_time) {} void LocalKeyCryptoWriteHandler::CreateTimestamp( HashAlgorithm hash_algorithm, std::string hashed_message, @@ -199,7 +203,8 @@ return; } resolver.Return(CreateTimeStampToken(hash_algorithm, hashed_message, *clock_, - *tsa_signer_, tsa_cert_pem_)); + *tsa_signer_, tsa_cert_pem_, + tsa_signing_time_)); } uint64_t LocalKeyCryptoWriteHandler::max_timestamp_byte_size( @@ -215,7 +220,7 @@ // unusable, so crash. absl::StatusOr<std::string> token = CreateTimeStampToken( hash_algorithm, std::string(HashSizeBytes(hash_algorithm), '\0'), - *clock_, *tsa_signer_, tsa_cert_pem_); + *clock_, *tsa_signer_, tsa_cert_pem_, tsa_signing_time_); ABSL_CHECK_OK(token); it->second = token->size() + kSizeMargin; }
diff --git a/testing/local_key_crypto_write_handler.h b/testing/local_key_crypto_write_handler.h index b851e37..4b447a1 100644 --- a/testing/local_key_crypto_write_handler.h +++ b/testing/local_key_crypto_write_handler.h
@@ -98,7 +98,8 @@ std::unique_ptr<crypto::tink::PublicKeySign> absl_nullable tsa_signer, bool is_timestamping_disabled, std::vector<std::string> certificate_chain, std::vector<std::string> ocsp_responses, SigningAlgorithm algorithm, - std::string tsa_cert_pem); + std::string tsa_cert_pem, + std::optional<absl::Time> tsa_signing_time = std::nullopt); LocalKeyCryptoWriteHandler(const LocalKeyCryptoWriteHandler&) = delete; LocalKeyCryptoWriteHandler& operator=(const LocalKeyCryptoWriteHandler&) = @@ -123,6 +124,7 @@ const std::unique_ptr<crypto::tink::PublicKeySign> absl_nullable tsa_signer_; const bool is_timestamping_disabled_; const std::string tsa_cert_pem_; + const std::optional<absl::Time> tsa_signing_time_; mutable absl::Mutex mutex_; mutable absl::flat_hash_map<HashAlgorithm, uint64_t>
diff --git a/tracing/BUILD b/tracing/BUILD new file mode 100644 index 0000000..4eb6582 --- /dev/null +++ b/tracing/BUILD
@@ -0,0 +1,149 @@ +# Copyright 2026 Google LLC +# +# 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 +# +# https://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. +# + +load("@rules_cc//cc:cc_library.bzl", "cc_library") +load("@rules_cc//cc:cc_test.bzl", "cc_test") + +package(default_visibility = ["//visibility:public"]) + +cc_library( + name = "span", + srcs = [ + "scoped_span.cc", + "thread_context.cc", + ], + hdrs = [ + "scoped_span.h", + "span.h", + "thread_context.h", + ], + deps = [ + "@abseil-cpp//absl/base:nullability", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/strings:string_view", + ], +) + +cc_test( + name = "span_test", + srcs = ["span_test.cc"], + deps = [ + ":span", + ":tracer", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/strings:string_view", + "@googletest//:gtest_main", + ], +) + +cc_test( + name = "thread_context_test", + srcs = ["thread_context_test.cc"], + deps = [ + ":span", + ":tracer", + "@googletest//:gtest_main", + ], +) + +cc_library( + name = "tracer", + srcs = [ + "noop_tracing.cc", + "tracer_factory.cc", + ], + hdrs = [ + "noop_tracing.h", + "tracer.h", + "tracer_factory.h", + ], + deps = [ + ":span", + "//async:async_context", + "@abseil-cpp//absl/base:core_headers", + "@abseil-cpp//absl/base:nullability", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/strings:string_view", + "@abseil-cpp//absl/synchronization", + ], +) + +cc_test( + name = "tracer_test", + srcs = ["tracer_test.cc"], + deps = [ + ":scoped_tracer_factory", + ":span", + ":tracer", + "//async:async_context", + "@abseil-cpp//absl/base:nullability", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/strings:string_view", + "@googletest//:gtest_main", + ], +) + +cc_test( + name = "scoped_span_test", + srcs = ["scoped_span_test.cc"], + deps = [ + ":span", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/strings:string_view", + "@googletest//:gtest_main", + ], +) + +cc_library( + name = "test_tracing", + testonly = 1, + srcs = ["test_tracing.cc"], + hdrs = ["test_tracing.h"], + deps = [ + ":span", + ":tracer", + "@abseil-cpp//absl/base:core_headers", + "@abseil-cpp//absl/container:flat_hash_map", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/strings:str_format", + "@abseil-cpp//absl/strings:string_view", + "@abseil-cpp//absl/synchronization", + "@abseil-cpp//absl/time", + ], +) + +cc_library( + name = "scoped_tracer_factory", + srcs = ["scoped_tracer_factory.cc"], + hdrs = ["scoped_tracer_factory.h"], + deps = [ + ":tracer", + "@abseil-cpp//absl/base:nullability", + ], +) + +cc_test( + name = "test_tracing_test", + srcs = ["test_tracing_test.cc"], + deps = [ + ":span", + ":test_tracing", + ":tracer", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/strings:string_view", + "@abseil-cpp//absl/time", + "@googletest//:gtest_main", + ], +)
diff --git a/tracing/noop_tracing.cc b/tracing/noop_tracing.cc new file mode 100644 index 0000000..b7f37b4 --- /dev/null +++ b/tracing/noop_tracing.cc
@@ -0,0 +1,39 @@ +// Copyright 2026 Google LLC +// +// 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 +// +// https://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. +// + +#include "tracing/noop_tracing.h" + +#include <memory> + +#include "absl/base/nullability.h" +#include "absl/strings/string_view.h" +#include "tracing/span.h" +#include "tracing/tracer.h" + +namespace credentio_tracing { + +std::unique_ptr<Span> NoopTracer::StartSpanImpl(absl::string_view, + const StartSpanOptions&) const { + return std::make_unique<NoopSpan>(); +} + +absl_nonnull std::shared_ptr<Tracer> NoopTracerFactory::GetTracer( + absl::string_view) const { + static auto* tracer = + new std::shared_ptr<Tracer>(std::make_shared<NoopTracer>()); + return *tracer; +} + +} // namespace credentio_tracing
diff --git a/tracing/noop_tracing.h b/tracing/noop_tracing.h new file mode 100644 index 0000000..2a552d4 --- /dev/null +++ b/tracing/noop_tracing.h
@@ -0,0 +1,70 @@ +// Copyright 2026 Google LLC +// +// 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 +// +// https://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. +// + +#ifndef THIRD_PARTY_CREDENTIO_TRACING_NOOP_TRACING_H_ +#define THIRD_PARTY_CREDENTIO_TRACING_NOOP_TRACING_H_ + +#include <memory> + +#include "absl/base/nullability.h" +#include "absl/status/status.h" +#include "absl/strings/string_view.h" +#include "tracing/span.h" +#include "tracing/tracer.h" +#include "tracing/tracer_factory.h" + +namespace credentio_tracing { + +// No-op implementation of Span that discards all recorded operations and +// attributes. +class NoopSpan : public Span { + public: + // Discards the attribute key-value pair. + void SetAttribute(absl::string_view, AttributeValue) override {} + + // Discards the point-in-time event. + void AddEvent(absl::string_view) override {} + + // Discards the status code update. + void SetStatus(const absl::Status&) override {} + + // Discards span completion. + void End() override {} +}; + +// No-op implementation of Tracer that creates NoopSpan instances with sub-5ns +// overhead. +class NoopTracer : public Tracer { + public: + using Tracer::StartSpan; + + // Returns a new NoopSpan instance. + std::unique_ptr<Span> StartSpanImpl( + absl::string_view span_name, + const StartSpanOptions& options) const override; +}; + +// No-op implementation of TracerFactory that returns shared NoopTracer +// instances. +class NoopTracerFactory : public TracerFactory { + public: + // Returns a thread-safe singleton NoopTracer instance. + absl_nonnull std::shared_ptr<Tracer> GetTracer( + absl::string_view) const override; +}; + +} // namespace credentio_tracing + +#endif // THIRD_PARTY_CREDENTIO_TRACING_NOOP_TRACING_H_
diff --git a/tracing/scoped_span.cc b/tracing/scoped_span.cc new file mode 100644 index 0000000..8eaec46 --- /dev/null +++ b/tracing/scoped_span.cc
@@ -0,0 +1,79 @@ +// Copyright 2026 Google LLC +// +// 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 +// +// https://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. +// + +#include "tracing/scoped_span.h" + +#include <memory> +#include <utility> + +#include "tracing/span.h" +#include "tracing/thread_context.h" + +namespace credentio_tracing { + +ScopedSpan::ScopedSpan(Span* span) + : span_(span), previous_span_(ThreadContext::GetCurrentSpan()) { + if (span_) { + ThreadContext::SetCurrentSpan(span_); + } +} + +ScopedSpan::ScopedSpan(std::unique_ptr<Span> span) + : owned_span_(std::move(span)), + span_(owned_span_.get()), + previous_span_(ThreadContext::GetCurrentSpan()) { + if (span_) { + ThreadContext::SetCurrentSpan(span_); + } +} + +void ScopedSpan::Teardown() { + if (active_) { + if (owned_span_) { + owned_span_->End(); + } + ThreadContext::SetCurrentSpan(previous_span_); + active_ = false; + } +} + +ScopedSpan::~ScopedSpan() { Teardown(); } + +ScopedSpan::ScopedSpan(ScopedSpan&& other) noexcept + : owned_span_(std::move(other.owned_span_)), + span_(std::exchange(other.span_, nullptr)), + previous_span_(std::exchange(other.previous_span_, nullptr)), + active_(std::exchange(other.active_, false)) { + // NOMUTANTS -- Defensive cleanup; previous_span_ is private and only accessed + // when active_ is true. +} + +ScopedSpan& ScopedSpan::operator=(ScopedSpan&& other) noexcept { + if (this != &other) { + Teardown(); + owned_span_ = std::move(other.owned_span_); + span_ = std::exchange(other.span_, nullptr); + // NOMUTANTS -- Defensive cleanup; previous_span_ is private and only + // accessed when active_ is true. + previous_span_ = std::exchange(other.previous_span_, nullptr); + active_ = std::exchange(other.active_, false); + if (active_ && span_) { + ThreadContext::SetCurrentSpan(span_); + } + } + return *this; +} + +} // namespace credentio_tracing
diff --git a/tracing/scoped_span.h b/tracing/scoped_span.h new file mode 100644 index 0000000..e87a59b --- /dev/null +++ b/tracing/scoped_span.h
@@ -0,0 +1,74 @@ +// Copyright 2026 Google LLC +// +// 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 +// +// https://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. +// + +#ifndef THIRD_PARTY_CREDENTIO_TRACING_SCOPED_SPAN_H_ +#define THIRD_PARTY_CREDENTIO_TRACING_SCOPED_SPAN_H_ + +#include <memory> + +#include "absl/status/status.h" +#include "tracing/span.h" + +namespace credentio_tracing { + +// RAII container for managing Span object lifetimes and thread-local stack +// binding. +// +// Automatically calls End() on the wrapped Span when ScopedSpan is destroyed, +// unless ownership has been moved, and maintains the implicit parent-child span +// hierarchy on the current thread. +class ScopedSpan { + public: + // Binds an existing Span pointer as active in ThreadContext for this scope. + explicit ScopedSpan(Span* span); + + // Wraps an existing Span unique pointer and sets it as active in + // ThreadContext. + explicit ScopedSpan(std::unique_ptr<Span> span); + + // Automatically invokes End() on the contained span (if owned) and restores + // the previous active span upon destruction. + ~ScopedSpan(); + + ScopedSpan(ScopedSpan&& other) noexcept; + ScopedSpan& operator=(ScopedSpan&& other) noexcept; + ScopedSpan(const ScopedSpan&) = delete; + ScopedSpan& operator=(const ScopedSpan&) = delete; + + // Returns a raw pointer to the underlying Span without releasing ownership. + Span* get() const { return span_; } + + // Member access operator forwarding calls to the underlying Span. + Span* operator->() const { return span_; } + + // Convenience method to set status on the underlying span if valid. + void SetStatus(const absl::Status& status) { + if (span_) { + span_->SetStatus(status); + } + } + + private: + void Teardown(); + + std::unique_ptr<Span> owned_span_; + Span* span_ = nullptr; + Span* previous_span_ = nullptr; + bool active_ = true; +}; + +} // namespace credentio_tracing + +#endif // THIRD_PARTY_CREDENTIO_TRACING_SCOPED_SPAN_H_
diff --git a/tracing/scoped_span_test.cc b/tracing/scoped_span_test.cc new file mode 100644 index 0000000..0d4898b --- /dev/null +++ b/tracing/scoped_span_test.cc
@@ -0,0 +1,355 @@ +// Copyright 2026 Google LLC +// +// 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 +// +// https://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. +// + +#include "tracing/scoped_span.h" + +#include <memory> +#include <optional> +#include <string> +#include <utility> + +#include "absl/status/status.h" +#include "absl/strings/string_view.h" +#include "gmock/gmock.h" +#include "gtest/gtest.h" +#include "tracing/span.h" +#include "tracing/thread_context.h" + +namespace credentio_tracing { +namespace { + +using ::testing::InSequence; +using ::testing::StrictMock; + +class MockSpan : public Span { + public: + MOCK_METHOD(void, SetAttribute, (absl::string_view key, AttributeValue value), + (override)); + MOCK_METHOD(void, AddEvent, (absl::string_view name), (override)); + MOCK_METHOD(void, SetStatus, (const absl::Status& status), (override)); + MOCK_METHOD(void, End, (), (override)); +}; + +TEST(ScopedSpanTest, CallsEndOnDestruction) { + auto mock_span = std::make_unique<StrictMock<MockSpan>>(); + MockSpan* raw_span = mock_span.get(); + + EXPECT_CALL(*raw_span, End()); + { + ScopedSpan scoped_span(std::move(mock_span)); + EXPECT_EQ(scoped_span.get(), raw_span); + EXPECT_EQ(scoped_span.operator->(), raw_span); + } +} + +TEST(ScopedSpanTest, ForwardsSetStatus) { + auto mock_span = std::make_unique<StrictMock<MockSpan>>(); + MockSpan* raw_span = mock_span.get(); + + absl::Status test_status = absl::InternalError("test error"); + EXPECT_CALL(*raw_span, SetStatus(test_status)); + EXPECT_CALL(*raw_span, End()); + + ScopedSpan scoped_span(std::move(mock_span)); + scoped_span.SetStatus(test_status); +} + +TEST(ScopedSpanTest, MoveConstructorTransfersOwnership) { + auto mock_span = std::make_unique<StrictMock<MockSpan>>(); + MockSpan* raw_span = mock_span.get(); + + EXPECT_CALL(*raw_span, End()); + { + ScopedSpan scoped1(std::move(mock_span)); + EXPECT_EQ(ThreadContext::GetCurrentSpan(), raw_span); + + ScopedSpan scoped2(std::move(scoped1)); + + EXPECT_EQ(scoped1.get(), nullptr); // NOLINT(bugprone-use-after-move) + EXPECT_EQ(scoped2.get(), raw_span); + EXPECT_EQ(ThreadContext::GetCurrentSpan(), raw_span); + } + EXPECT_EQ(ThreadContext::GetCurrentSpan(), nullptr); +} + +TEST(ScopedSpanTest, MoveAssignmentTransfersOwnership) { + auto mock_span1 = std::make_unique<StrictMock<MockSpan>>(); + MockSpan* raw_span1 = mock_span1.get(); + + auto mock_span2 = std::make_unique<StrictMock<MockSpan>>(); + MockSpan* raw_span2 = mock_span2.get(); + + EXPECT_CALL(*raw_span1, End()); + EXPECT_CALL(*raw_span2, End()); + + { + ScopedSpan scoped1(std::move(mock_span1)); + ScopedSpan scoped2(std::move(mock_span2)); + EXPECT_EQ(ThreadContext::GetCurrentSpan(), raw_span2); + + scoped2 = std::move(scoped1); + EXPECT_EQ(scoped1.get(), nullptr); // NOLINT(bugprone-use-after-move) + EXPECT_EQ(scoped2.get(), raw_span1); + EXPECT_EQ(ThreadContext::GetCurrentSpan(), raw_span1); + } + EXPECT_EQ(ThreadContext::GetCurrentSpan(), nullptr); +} + +TEST(ScopedSpanTest, MoveAssignmentOrderAndSequence) { + auto mock_span1 = std::make_unique<StrictMock<MockSpan>>(); + MockSpan* raw_span1 = mock_span1.get(); + + auto mock_span2 = std::make_unique<StrictMock<MockSpan>>(); + MockSpan* raw_span2 = mock_span2.get(); + + InSequence seq; + EXPECT_CALL(*raw_span2, End()); + EXPECT_CALL(*raw_span1, End()); + + { + ScopedSpan scoped1(std::move(mock_span1)); + ScopedSpan scoped2(std::move(mock_span2)); + + scoped2 = std::move(scoped1); + } +} + +TEST(ScopedSpanTest, MoveAssignmentToNullSpan) { + auto mock_span = std::make_unique<StrictMock<MockSpan>>(); + MockSpan* raw_span = mock_span.get(); + + EXPECT_CALL(*raw_span, End()); + + { + ScopedSpan scoped1(std::move(mock_span)); + ScopedSpan scoped2(nullptr); + + scoped2 = std::move(scoped1); + EXPECT_EQ(scoped1.get(), nullptr); // NOLINT(bugprone-use-after-move) + EXPECT_EQ(scoped2.get(), raw_span); + } +} + +TEST(ScopedSpanTest, SelfMoveAssignment) { + auto mock_span = std::make_unique<StrictMock<MockSpan>>(); + MockSpan* raw_span = mock_span.get(); + + EXPECT_CALL(*raw_span, End()).Times(1); + + { + ScopedSpan scoped(std::move(mock_span)); + ScopedSpan& scoped_ref = scoped; + scoped = std::move(scoped_ref); + EXPECT_EQ(scoped.get(), raw_span); + } +} + +TEST(ScopedSpanTest, NullPointerSafe) { + ScopedSpan null_scoped(nullptr); + EXPECT_EQ(null_scoped.get(), nullptr); + + // SetStatus on null span should not crash + null_scoped.SetStatus(absl::OkStatus()); + // Destruction of null span should not crash +} + +TEST(ScopedSpanTest, NestedSpanHierarchyRestoresParent) { + auto mock_outer = std::make_unique<StrictMock<MockSpan>>(); + MockSpan* raw_outer = mock_outer.get(); + + auto mock_inner = std::make_unique<StrictMock<MockSpan>>(); + MockSpan* raw_inner = mock_inner.get(); + + EXPECT_CALL(*raw_inner, End()); + EXPECT_CALL(*raw_outer, End()); + + EXPECT_EQ(ThreadContext::GetCurrentSpan(), nullptr); + { + ScopedSpan outer(std::move(mock_outer)); + EXPECT_EQ(ThreadContext::GetCurrentSpan(), raw_outer); + + { + ScopedSpan inner(std::move(mock_inner)); + EXPECT_EQ(ThreadContext::GetCurrentSpan(), raw_inner); + } + + EXPECT_EQ(ThreadContext::GetCurrentSpan(), raw_outer); + } + EXPECT_EQ(ThreadContext::GetCurrentSpan(), nullptr); +} + +TEST(ScopedSpanTest, MovedFromScopedSpanDestructionDoesNotRestoreSpan) { + MockSpan outer_span; + ThreadContext::SetCurrentSpan(&outer_span); + + auto mock_span = std::make_unique<StrictMock<MockSpan>>(); + MockSpan* raw_span = mock_span.get(); + EXPECT_CALL(*raw_span, End()); + + { + std::optional<ScopedSpan> scoped2; + { + ScopedSpan scoped1(std::move(mock_span)); + EXPECT_EQ(ThreadContext::GetCurrentSpan(), raw_span); + + scoped2.emplace(std::move(scoped1)); + EXPECT_EQ(ThreadContext::GetCurrentSpan(), raw_span); + // scoped1 is destroyed here at end of inner scope. + } + // Destruction of moved-from scoped1 must not unbind raw_span or restore + // outer_span. + EXPECT_EQ(ThreadContext::GetCurrentSpan(), raw_span); + } + + EXPECT_EQ(ThreadContext::GetCurrentSpan(), &outer_span); + ThreadContext::SetCurrentSpan(nullptr); +} + +TEST(ScopedSpanTest, MoveAssignmentIntoInactiveScopedSpanActivatesTarget) { + auto mock_span = std::make_unique<StrictMock<MockSpan>>(); + MockSpan* raw_span = mock_span.get(); + EXPECT_CALL(*raw_span, End()); + + ScopedSpan target(nullptr); + { + ScopedSpan sink(std::move(target)); + } + // target is now inactive. + EXPECT_EQ(target.get(), nullptr); // NOLINT(bugprone-use-after-move) + EXPECT_EQ(ThreadContext::GetCurrentSpan(), nullptr); + + { + ScopedSpan source(std::move(mock_span)); + EXPECT_EQ(ThreadContext::GetCurrentSpan(), raw_span); + + // Assigning active source into inactive target must activate target. + target = std::move(source); + EXPECT_EQ(target.get(), raw_span); + EXPECT_EQ(ThreadContext::GetCurrentSpan(), raw_span); + } + // source went out of scope (inactive). + // target remains active in outer scope with raw_span. + EXPECT_EQ(ThreadContext::GetCurrentSpan(), raw_span); +} + +TEST(ScopedSpanTest, MoveAssignmentFromInactiveScopedSpanDeactivatesTarget) { + MockSpan outer_span; + ThreadContext::SetCurrentSpan(&outer_span); + + auto mock_span = std::make_unique<StrictMock<MockSpan>>(); + MockSpan* raw_span = mock_span.get(); + EXPECT_CALL(*raw_span, End()); + + { + ScopedSpan inactive_scoped(nullptr); + { + ScopedSpan sink(std::move(inactive_scoped)); + } + // inactive_scoped is now inactive. + EXPECT_EQ(ThreadContext::GetCurrentSpan(), &outer_span); + + ScopedSpan active_scoped(std::move(mock_span)); + EXPECT_EQ(ThreadContext::GetCurrentSpan(), raw_span); + + // Assigning inactive into active must end active_scoped's span, + // restore outer_span, and deactivate active_scoped. + active_scoped = + std::move(inactive_scoped); // NOLINT(bugprone-use-after-move) + EXPECT_EQ(active_scoped.get(), nullptr); + EXPECT_EQ(ThreadContext::GetCurrentSpan(), &outer_span); + } + + // Destruction of deactivated active_scoped must not change ThreadContext. + EXPECT_EQ(ThreadContext::GetCurrentSpan(), &outer_span); + ThreadContext::SetCurrentSpan(nullptr); +} + +TEST(ScopedSpanTest, RawSpanConstructorBindsAndUnbindsWithoutCallingEnd) { + StrictMock<MockSpan> raw_span; + // No End() call expected for non-owning ScopedSpan(Span*). + + EXPECT_EQ(ThreadContext::GetCurrentSpan(), nullptr); + { + ScopedSpan scoped(&raw_span); + EXPECT_EQ(scoped.get(), &raw_span); + EXPECT_EQ(ThreadContext::GetCurrentSpan(), &raw_span); + } + EXPECT_EQ(ThreadContext::GetCurrentSpan(), nullptr); +} + +TEST(ScopedSpanTest, RawSpanConstructorWithNullAndActiveContext) { + StrictMock<MockSpan> outer_span; + ThreadContext::SetCurrentSpan(&outer_span); + + { + ScopedSpan null_scoped(static_cast<Span*>(nullptr)); + EXPECT_EQ(null_scoped.get(), nullptr); + EXPECT_EQ(ThreadContext::GetCurrentSpan(), &outer_span); + } + + EXPECT_EQ(ThreadContext::GetCurrentSpan(), &outer_span); + ThreadContext::SetCurrentSpan(nullptr); +} + +TEST(ScopedSpanTest, UniquePtrConstructorWithNullAndActiveContext) { + StrictMock<MockSpan> outer_span; + ThreadContext::SetCurrentSpan(&outer_span); + + { + ScopedSpan null_scoped(std::unique_ptr<Span>(nullptr)); + EXPECT_EQ(null_scoped.get(), nullptr); + EXPECT_EQ(ThreadContext::GetCurrentSpan(), &outer_span); + } + + EXPECT_EQ(ThreadContext::GetCurrentSpan(), &outer_span); + ThreadContext::SetCurrentSpan(nullptr); +} + +TEST(ScopedSpanTest, MoveConstructorWithRawSpan) { + StrictMock<MockSpan> raw_span; + + { + ScopedSpan scoped1(&raw_span); + EXPECT_EQ(ThreadContext::GetCurrentSpan(), &raw_span); + + ScopedSpan scoped2(std::move(scoped1)); + EXPECT_EQ(scoped1.get(), nullptr); // NOLINT(bugprone-use-after-move) + EXPECT_EQ(scoped2.get(), &raw_span); + EXPECT_EQ(ThreadContext::GetCurrentSpan(), &raw_span); + } + + EXPECT_EQ(ThreadContext::GetCurrentSpan(), nullptr); +} + +TEST(ScopedSpanTest, MoveAssignmentWithRawSpan) { + StrictMock<MockSpan> raw_span1; + StrictMock<MockSpan> raw_span2; + + { + ScopedSpan scoped1(&raw_span1); + ScopedSpan scoped2(&raw_span2); + EXPECT_EQ(ThreadContext::GetCurrentSpan(), &raw_span2); + + scoped2 = std::move(scoped1); + EXPECT_EQ(scoped1.get(), nullptr); // NOLINT(bugprone-use-after-move) + EXPECT_EQ(scoped2.get(), &raw_span1); + EXPECT_EQ(ThreadContext::GetCurrentSpan(), &raw_span1); + } + + EXPECT_EQ(ThreadContext::GetCurrentSpan(), nullptr); +} + +} // namespace +} // namespace credentio_tracing
diff --git a/tracing/scoped_tracer_factory.cc b/tracing/scoped_tracer_factory.cc new file mode 100644 index 0000000..b1e2a5b --- /dev/null +++ b/tracing/scoped_tracer_factory.cc
@@ -0,0 +1,36 @@ +// Copyright 2026 Google LLC +// +// 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 +// +// https://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. +// + +#include "tracing/scoped_tracer_factory.h" + +#include <memory> +#include <utility> + +#include "absl/base/nullability.h" +#include "tracing/tracer_factory.h" + +namespace credentio_tracing { + +ScopedTracerFactory::ScopedTracerFactory( + absl_nullable std::shared_ptr<TracerFactory> factory) + : previous_factory_(TracerFactory::GetThreadLocalFactory()) { + TracerFactory::SetThreadLocalFactory(std::move(factory)); +} + +ScopedTracerFactory::~ScopedTracerFactory() { + TracerFactory::SetThreadLocalFactory(std::move(previous_factory_)); +} + +} // namespace credentio_tracing
diff --git a/tracing/scoped_tracer_factory.h b/tracing/scoped_tracer_factory.h new file mode 100644 index 0000000..2c3f98e --- /dev/null +++ b/tracing/scoped_tracer_factory.h
@@ -0,0 +1,57 @@ +// Copyright 2026 Google LLC +// +// 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 +// +// https://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. +// + +#ifndef THIRD_PARTY_CREDENTIO_TRACING_SCOPED_TRACER_FACTORY_H_ +#define THIRD_PARTY_CREDENTIO_TRACING_SCOPED_TRACER_FACTORY_H_ + +#include <memory> + +#include "absl/base/nullability.h" +#include "tracing/tracer_factory.h" + +namespace credentio_tracing { + +// RAII guard that installs a TracerFactory for the current thread during its +// lifetime and restores the previous one upon destruction. +// +// Installing and clearing a thread-local factory by hand is error prone: the +// two calls must run on the same thread, which is easy to get wrong when the +// work in between hops to a callback or a dispatch queue. This guard makes +// that mistake unrepresentable for synchronous scopes. +// +// It only covers work performed on the calling thread. To scope tracing to an +// asynchronous operation, carry the factory on the operation's +// `credentio::AsyncContext` instead. +class ScopedTracerFactory { + public: + // Installs `factory` as the calling thread's factory. + explicit ScopedTracerFactory( + absl_nullable std::shared_ptr<TracerFactory> factory); + + // Restores the previously installed thread-local factory. + ~ScopedTracerFactory(); + + ScopedTracerFactory(const ScopedTracerFactory&) = delete; + ScopedTracerFactory& operator=(const ScopedTracerFactory&) = delete; + ScopedTracerFactory(ScopedTracerFactory&&) = delete; + ScopedTracerFactory& operator=(ScopedTracerFactory&&) = delete; + + private: + absl_nullable std::shared_ptr<TracerFactory> previous_factory_; +}; + +} // namespace credentio_tracing + +#endif // THIRD_PARTY_CREDENTIO_TRACING_SCOPED_TRACER_FACTORY_H_
diff --git a/tracing/span.h b/tracing/span.h new file mode 100644 index 0000000..f69902e --- /dev/null +++ b/tracing/span.h
@@ -0,0 +1,71 @@ +// Copyright 2026 Google LLC +// +// 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 +// +// https://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. +// + +#ifndef THIRD_PARTY_CREDENTIO_TRACING_SPAN_H_ +#define THIRD_PARTY_CREDENTIO_TRACING_SPAN_H_ + +#include <cstdint> +#include <string> +#include <variant> + +#include "absl/status/status.h" +#include "absl/strings/string_view.h" + +namespace credentio_tracing { + +// Represents supported primitive attribute values that can be attached to a +// tracing span. +using AttributeValue = + std::variant<std::string, uint64_t, int64_t, double, bool>; + +// Abstract interface for a tracing span representing an operation duration. +// +// Implementations of Span record information about execution status, timed +// events, and key-value attributes. +// +// Thread safety: a span may be started on one thread and ended on another -- +// the asynchronous encoding pipeline routinely does this, for example a span +// that ends inside an RPC callback while its siblings end on the calling +// thread. Implementations must therefore synchronize any state they share +// between spans, such as a destination shared by all spans of an operation. +// A single span instance must not be used concurrently from multiple threads; +// callers hand a span off to at most one thread at a time, which establishes +// the happens-before edge that makes its own state safe to access unguarded. +class Span { + public: + virtual ~Span() = default; + + // Sets a key-value attribute on the span. + virtual void SetAttribute(absl::string_view key, AttributeValue value) = 0; + + // Records a point-in-time event with the specified name within the span. + virtual void AddEvent(absl::string_view name) = 0; + + // Sets the execution status for the operation tracked by the span. + virtual void SetStatus(const absl::Status& status) = 0; + + // Completes execution of the span and records its end time. + virtual void End() = 0; + + // Sets the execution status and completes execution of the span. + void EndWithStatus(const absl::Status& status) { + SetStatus(status); + End(); + } +}; + +} // namespace credentio_tracing + +#endif // THIRD_PARTY_CREDENTIO_TRACING_SPAN_H_
diff --git a/tracing/span_test.cc b/tracing/span_test.cc new file mode 100644 index 0000000..a6c453c --- /dev/null +++ b/tracing/span_test.cc
@@ -0,0 +1,152 @@ +// Copyright 2026 Google LLC +// +// 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 +// +// https://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. +// + +#include "tracing/span.h" + +#include <cstdint> +#include <memory> +#include <string> + +#include "absl/status/status.h" +#include "absl/strings/string_view.h" +#include "gmock/gmock.h" +#include "gtest/gtest.h" +#include "tracing/noop_tracing.h" +#include "tracing/tracer.h" +#include "tracing/tracer_factory.h" + +namespace credentio_tracing { +namespace { + +class MockSpan : public Span { + public: + MOCK_METHOD(void, SetAttribute, (absl::string_view key, AttributeValue value), + (override)); + MOCK_METHOD(void, AddEvent, (absl::string_view name), (override)); + MOCK_METHOD(void, SetStatus, (const absl::Status& status), (override)); + MOCK_METHOD(void, End, (), (override)); +}; + +TEST(AttributeValueTest, SupportsExpectedTypes) { + AttributeValue str_val = std::string("test_string"); + EXPECT_EQ(std::get<std::string>(str_val), "test_string"); + + AttributeValue uint_val = uint64_t{42}; + EXPECT_EQ(std::get<uint64_t>(uint_val), 42); + + AttributeValue int_val = int64_t{42}; + EXPECT_EQ(std::get<int64_t>(int_val), 42); + + AttributeValue double_val = 3.14159; + EXPECT_DOUBLE_EQ(std::get<double>(double_val), 3.14159); + + AttributeValue bool_val = true; + EXPECT_TRUE(std::get<bool>(bool_val)); +} + +TEST(SpanTest, MockSpanInvocations) { + MockSpan span; + EXPECT_CALL(span, SetAttribute("key", AttributeValue(int64_t{123}))); + EXPECT_CALL(span, AddEvent("event_name")); + EXPECT_CALL(span, SetStatus(absl::OkStatus())); + EXPECT_CALL(span, End()); + + span.SetAttribute("key", int64_t{123}); + span.AddEvent("event_name"); + span.SetStatus(absl::OkStatus()); + span.End(); +} + +TEST(SpanTest, EndWithStatusInvocations) { + MockSpan span; + absl::Status test_status = absl::InvalidArgumentError("invalid argument"); + EXPECT_CALL(span, SetStatus(test_status)); + EXPECT_CALL(span, End()); + + span.EndWithStatus(test_status); +} + +TEST(NoopSpanTest, AllMethodsExecuteWithoutError) { + NoopSpan span; + + span.SetAttribute("key_str", std::string("value")); + span.SetAttribute("key_int", int64_t{42}); + span.SetAttribute("key_double", 3.14); + span.SetAttribute("key_bool", true); + + span.AddEvent("test_event"); + span.SetStatus(absl::OkStatus()); + span.SetStatus(absl::InvalidArgumentError("invalid argument")); + span.End(); +} + +TEST(NoopTracerTest, StartSpanReturnsNonNullNoopSpan) { + NoopTracer tracer; + std::unique_ptr<Span> span = tracer.StartSpan("test_span"); + ASSERT_NE(span, nullptr); + + span->SetAttribute("key", int64_t{100}); + span->AddEvent("event"); + span->SetStatus(absl::OkStatus()); + span->End(); +} + +TEST(TracerFactoryTest, DefaultGlobalFactoryIsNoopTracerFactory) { + auto factory = TracerFactory::GetGlobalFactory(); + ASSERT_NE(factory, nullptr); + auto tracer = factory->GetTracer("test_tracer"); + ASSERT_NE(tracer, nullptr); + auto span = tracer->StartSpan("test_span"); + ASSERT_NE(span, nullptr); +} + +class MockCustomTracerFactory : public TracerFactory { + public: + MOCK_METHOD(std::shared_ptr<Tracer>, GetTracer, + (absl::string_view tracer_name), (const, override)); +}; + +TEST(TracerFactoryTest, GlobalFactoryRegistration) { + auto initial_factory = TracerFactory::GetGlobalFactory(); + EXPECT_NE(initial_factory, nullptr); + + auto custom_factory = std::make_shared<MockCustomTracerFactory>(); + TracerFactory::RegisterGlobalFactory(custom_factory); + + EXPECT_EQ(TracerFactory::GetGlobalFactory(), custom_factory); + + // Restore original factory state after test + TracerFactory::RegisterGlobalFactory(initial_factory); +} + +TEST(TracerFactoryTest, RegisterNullptrResetsToNoopTracerFactory) { + auto initial_factory = TracerFactory::GetGlobalFactory(); + EXPECT_NE(initial_factory, nullptr); + + auto custom_factory = std::make_shared<MockCustomTracerFactory>(); + TracerFactory::RegisterGlobalFactory(custom_factory); + EXPECT_EQ(TracerFactory::GetGlobalFactory(), custom_factory); + + TracerFactory::RegisterGlobalFactory(nullptr); + auto current_factory = TracerFactory::GetGlobalFactory(); + ASSERT_NE(current_factory, nullptr); + EXPECT_NE(current_factory, custom_factory); + + // Restore original factory state after test + TracerFactory::RegisterGlobalFactory(initial_factory); +} + +} // namespace +} // namespace credentio_tracing
diff --git a/tracing/test_tracing.cc b/tracing/test_tracing.cc new file mode 100644 index 0000000..3bd6f84 --- /dev/null +++ b/tracing/test_tracing.cc
@@ -0,0 +1,276 @@ +// Copyright 2026 Google LLC +// +// 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 +// +// https://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. +// + +#include "tracing/test_tracing.h" + +#include <atomic> +#include <cstdint> +#include <memory> +#include <optional> +#include <string> +#include <utility> +#include <vector> + +#include "absl/status/status.h" +#include "absl/strings/str_format.h" +#include "absl/strings/string_view.h" +#include "absl/synchronization/mutex.h" +#include "absl/time/clock.h" +#include "absl/time/time.h" +#include "tracing/span.h" +#include "tracing/tracer.h" +#include "tracing/tracer_factory.h" + +namespace credentio_tracing { + +namespace { + +std::string GenerateTraceId() { + static std::atomic<uint64_t> counter{1}; + uint64_t id = counter.fetch_add(1, std::memory_order_relaxed); + return absl::StrFormat("%032x", id); +} + +std::string GenerateSpanId() { + static std::atomic<uint64_t> counter{1}; + uint64_t id = counter.fetch_add(1, std::memory_order_relaxed); + return absl::StrFormat("%016x", id); +} + +} // namespace + +void TestRecordedSpan::SetAttribute(absl::string_view key, + AttributeValue value) { + absl::MutexLock lock(mutex_); + for (auto& [k, v] : attributes_) { + if (k == key) { + v = std::move(value); + return; + } + } + attributes_.push_back({std::string(key), std::move(value)}); +} + +void TestRecordedSpan::AddEvent(absl::string_view name) { + absl::MutexLock lock(mutex_); + events_.push_back({std::string(name), absl::Now()}); +} + +void TestRecordedSpan::SetStatus(const absl::Status& status) { + absl::MutexLock lock(mutex_); + status_ = status; +} + +void TestRecordedSpan::End() { + absl::MutexLock lock(mutex_); + ended_ = true; + end_time_ = absl::Now(); +} + +bool TestRecordedSpan::ended() const { + absl::MutexLock lock(mutex_); + return ended_; +} + +absl::Status TestRecordedSpan::status() const { + absl::MutexLock lock(mutex_); + return status_; +} + +absl::Time TestRecordedSpan::end_time() const { + absl::MutexLock lock(mutex_); + return end_time_; +} + +std::vector<std::pair<std::string, AttributeValue>> +TestRecordedSpan::attributes() const { + absl::MutexLock lock(mutex_); + return attributes_; +} + +std::vector<TestRecordedEvent> TestRecordedSpan::events() const { + absl::MutexLock lock(mutex_); + return events_; +} + +std::optional<AttributeValue> TestRecordedSpan::GetAttribute( + absl::string_view key) const { + absl::MutexLock lock(mutex_); + for (const auto& [k, v] : attributes_) { + if (k == key) { + return v; + } + } + return std::nullopt; +} + +TestSpan::TestSpan(std::shared_ptr<TestRecordedSpan> recorded_span) + : recorded_span_(std::move(recorded_span)) {} + +void TestSpan::SetAttribute(absl::string_view key, AttributeValue value) { + if (recorded_span_) { + recorded_span_->SetAttribute(key, std::move(value)); + } +} + +void TestSpan::AddEvent(absl::string_view name) { + if (recorded_span_) { + recorded_span_->AddEvent(name); + } +} + +void TestSpan::SetStatus(const absl::Status& status) { + if (recorded_span_) { + recorded_span_->SetStatus(status); + } +} + +void TestSpan::End() { + if (recorded_span_) { + recorded_span_->End(); + } +} + +std::shared_ptr<TestRecordedSpan> TestSpan::recorded_span() const { + return recorded_span_; +} + +std::unique_ptr<Span> TestTracer::StartSpanImpl( + absl::string_view span_name, const StartSpanOptions& options) const { + auto recorded = std::make_shared<TestRecordedSpan>(); + recorded->name_ = std::string(span_name); + recorded->start_time_ = absl::Now(); + recorded->span_id_ = GenerateSpanId(); + + for (const auto& [k, v] : options.attributes) { + recorded->SetAttribute(k, v); + } + + // `Tracer::StartSpan` already resolves the implicit parent from the current + // `ThreadContext`, so `options.parent` is authoritative here. + recorded->parent_ = options.parent; + + // `dynamic_cast` yields nullptr for both a null parent and a parent that is + // not a `TestSpan`, in which case the span starts a new trace. + const auto* test_parent = dynamic_cast<const TestSpan*>(options.parent); + if (test_parent != nullptr && test_parent->recorded_span() != nullptr) { + recorded->parent_span_id_ = test_parent->recorded_span()->span_id_; + recorded->trace_id_ = test_parent->recorded_span()->trace_id_; + } + if (recorded->trace_id_.empty()) { + recorded->trace_id_ = GenerateTraceId(); + } + + absl::MutexLock lock(mutex_); + spans_.push_back(recorded); + return std::make_unique<TestSpan>(recorded); +} + +std::vector<std::shared_ptr<TestRecordedSpan>> TestTracer::GetSpans() const { + absl::MutexLock lock(mutex_); + return spans_; +} + +std::shared_ptr<TestRecordedSpan> TestTracer::GetSpan( + absl::string_view name) const { + absl::MutexLock lock(mutex_); + for (const auto& span : spans_) { + if (span->name_ == name) { + return span; + } + } + return nullptr; +} + +std::vector<std::shared_ptr<TestRecordedSpan>> TestTracer::GetSpansByName( + absl::string_view name) const { + absl::MutexLock lock(mutex_); + std::vector<std::shared_ptr<TestRecordedSpan>> result; + for (const auto& span : spans_) { + if (span->name_ == name) { + result.push_back(span); + } + } + return result; +} + +void TestTracer::Clear() { + absl::MutexLock lock(mutex_); + spans_.clear(); +} + +std::shared_ptr<TestTracerFactory> TestTracerFactory::Register() { + auto factory = std::make_shared<TestTracerFactory>(); + TracerFactory::RegisterGlobalFactory(factory); + return factory; +} + +void TestTracerFactory::Register(std::shared_ptr<TestTracerFactory> factory) { + TracerFactory::RegisterGlobalFactory(std::move(factory)); +} + +void TestTracerFactory::RegisterThis() { + TracerFactory::RegisterGlobalFactory(shared_from_this()); +} + +void TestTracerFactory::Unregister() { + TracerFactory::RegisterGlobalFactory(nullptr); +} + +std::shared_ptr<Tracer> TestTracerFactory::GetTracer( + absl::string_view tracer_name) const { + absl::MutexLock lock(mutex_); + auto& tracer = tracers_[tracer_name]; + if (tracer == nullptr) { + tracer = std::make_shared<TestTracer>(); + } + return tracer; +} + +std::shared_ptr<TestTracer> TestTracerFactory::GetTestTracer( + absl::string_view tracer_name) const { + absl::MutexLock lock(mutex_); + auto it = tracers_.find(tracer_name); + if (it != tracers_.end()) { + return it->second; + } + return nullptr; +} + +std::vector<std::shared_ptr<TestRecordedSpan>> TestTracerFactory::GetAllSpans() + const { + std::vector<std::shared_ptr<TestTracer>> tracers_copy; + { + absl::MutexLock lock(mutex_); + tracers_copy.reserve(tracers_.size()); + for (const auto& [name, tracer] : tracers_) { + tracers_copy.push_back(tracer); + } + } + + std::vector<std::shared_ptr<TestRecordedSpan>> all_spans; + for (const auto& tracer : tracers_copy) { + auto spans = tracer->GetSpans(); + all_spans.insert(all_spans.end(), spans.begin(), spans.end()); + } + return all_spans; +} + +void TestTracerFactory::Clear() { + absl::MutexLock lock(mutex_); + tracers_.clear(); +} + +} // namespace credentio_tracing
diff --git a/tracing/test_tracing.h b/tracing/test_tracing.h new file mode 100644 index 0000000..3791142 --- /dev/null +++ b/tracing/test_tracing.h
@@ -0,0 +1,202 @@ +// Copyright 2026 Google LLC +// +// 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 +// +// https://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. +// + +#ifndef THIRD_PARTY_CREDENTIO_TRACING_TEST_TRACING_H_ +#define THIRD_PARTY_CREDENTIO_TRACING_TEST_TRACING_H_ + +#include <memory> +#include <optional> +#include <string> +#include <utility> +#include <variant> +#include <vector> + +#include "absl/base/thread_annotations.h" +#include "absl/container/flat_hash_map.h" +#include "absl/status/status.h" +#include "absl/strings/string_view.h" +#include "absl/synchronization/mutex.h" +#include "absl/time/clock.h" +#include "absl/time/time.h" +#include "tracing/span.h" +#include "tracing/tracer.h" +#include "tracing/tracer_factory.h" + +namespace credentio_tracing { + +// Simple struct representing a recorded point-in-time event within a span. +struct TestRecordedEvent { + std::string name; + absl::Time time = absl::Now(); + + bool operator==(const TestRecordedEvent& other) const { + return name == other.name; + } +}; + +// Simple struct holding recorded span data for verification in tests. +class TestRecordedSpan { + public: + TestRecordedSpan() = default; + + std::string name_; + std::string trace_id_; + std::string span_id_; + std::string parent_span_id_; + const Span* parent_ = nullptr; + absl::Time start_time_ = absl::Now(); + + // Set or update an attribute key-value pair. + void SetAttribute(absl::string_view key, AttributeValue value); + + // Record an event. + void AddEvent(absl::string_view name); + + // Set status. + void SetStatus(const absl::Status& status); + + // Mark span as ended. + void End(); + + // Returns true if the span has ended. + bool ended() const; + + // Returns the recorded status. + absl::Status status() const; + + // Returns the recorded end time. + absl::Time end_time() const; + + // Returns a copy of recorded attributes. + std::vector<std::pair<std::string, AttributeValue>> attributes() const; + + // Returns a copy of recorded events. + std::vector<TestRecordedEvent> events() const; + + // Returns optional copy of attribute value for specified key. + std::optional<AttributeValue> GetAttribute(absl::string_view key) const; + + // Returns the attribute value converted to type T if found and matching, or + // std::nullopt. + template <typename T> + std::optional<T> GetAttributeAs(absl::string_view key) const { + std::optional<AttributeValue> val = GetAttribute(key); + if (val && std::holds_alternative<T>(*val)) { + return std::get<T>(*val); + } + return std::nullopt; + } + + private: + mutable absl::Mutex mutex_; + std::vector<std::pair<std::string, AttributeValue>> attributes_ + ABSL_GUARDED_BY(mutex_); + std::vector<TestRecordedEvent> events_ ABSL_GUARDED_BY(mutex_); + absl::Status status_ ABSL_GUARDED_BY(mutex_) = absl::OkStatus(); + bool ended_ ABSL_GUARDED_BY(mutex_) = false; + absl::Time end_time_ ABSL_GUARDED_BY(mutex_) = absl::InfinitePast(); +}; + +// Test implementation of Span that records operations to a TestRecordedSpan +// struct. +class TestSpan : public Span { + public: + explicit TestSpan(std::shared_ptr<TestRecordedSpan> recorded_span); + + void SetAttribute(absl::string_view key, AttributeValue value) override; + void AddEvent(absl::string_view name) override; + void SetStatus(const absl::Status& status) override; + void End() override; + + // Returns the underlying TestRecordedSpan struct pointer. + std::shared_ptr<TestRecordedSpan> recorded_span() const; + + private: + const std::shared_ptr<TestRecordedSpan> recorded_span_; +}; + +// Test implementation of Tracer that records created spans. +class TestTracer : public Tracer { + public: + using Tracer::StartSpan; + + TestTracer() = default; + + std::unique_ptr<Span> StartSpanImpl( + absl::string_view span_name, + const StartSpanOptions& options) const override; + + // Returns all recorded spans. + std::vector<std::shared_ptr<TestRecordedSpan>> GetSpans() const; + + // Returns the first span matching the given name, or nullptr if not found. + std::shared_ptr<TestRecordedSpan> GetSpan(absl::string_view name) const; + + // Returns all spans matching the given name. + std::vector<std::shared_ptr<TestRecordedSpan>> GetSpansByName( + absl::string_view name) const; + + // Clears all recorded spans. + void Clear(); + + private: + mutable absl::Mutex mutex_; + mutable std::vector<std::shared_ptr<TestRecordedSpan>> spans_ + ABSL_GUARDED_BY(mutex_); +}; + +// Test implementation of TracerFactory that manages TestTracer instances. +class TestTracerFactory + : public TracerFactory, + public std::enable_shared_from_this<TestTracerFactory> { + public: + TestTracerFactory() = default; + + // Creates a new TestTracerFactory instance, registers it as the global + // factory, and returns it. + static std::shared_ptr<TestTracerFactory> Register(); + + // Registers the specified factory instance as the global factory. + static void Register(std::shared_ptr<TestTracerFactory> factory); + + // Registers this TestTracerFactory instance as the global factory. + void RegisterThis(); + + // Unregisters the global TracerFactory instance, resetting to default. + static void Unregister(); + + std::shared_ptr<Tracer> GetTracer( + absl::string_view tracer_name) const override; + + // Returns the TestTracer associated with tracer_name, or nullptr if not + // found. + std::shared_ptr<TestTracer> GetTestTracer( + absl::string_view tracer_name) const; + + // Returns all recorded spans across all managed tracers. + std::vector<std::shared_ptr<TestRecordedSpan>> GetAllSpans() const; + + // Clears all registered tracers and their recorded spans. + void Clear(); + + private: + mutable absl::Mutex mutex_; + mutable absl::flat_hash_map<std::string, std::shared_ptr<TestTracer>> tracers_ + ABSL_GUARDED_BY(mutex_); +}; + +} // namespace credentio_tracing + +#endif // THIRD_PARTY_CREDENTIO_TRACING_TEST_TRACING_H_
diff --git a/tracing/test_tracing_test.cc b/tracing/test_tracing_test.cc new file mode 100644 index 0000000..4234896 --- /dev/null +++ b/tracing/test_tracing_test.cc
@@ -0,0 +1,322 @@ +// Copyright 2026 Google LLC +// +// 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 +// +// https://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. +// + +#include "tracing/test_tracing.h" + +#include <cstdint> +#include <memory> +#include <optional> +#include <string> +#include <thread> // NOLINT(build/c++11) +#include <vector> + +#include "absl/status/status.h" +#include "absl/strings/string_view.h" +#include "absl/time/clock.h" +#include "absl/time/time.h" +#include "gtest/gtest.h" +#include "tracing/scoped_span.h" +#include "tracing/span.h" +#include "tracing/tracer.h" +#include "tracing/tracer_factory.h" + +namespace credentio_tracing { +namespace { + +TEST(TestRecordedSpanTest, GetAttributeAndGetAttributeAs) { + TestRecordedSpan span; + span.SetAttribute("string_key", std::string("val")); + span.SetAttribute("int_key", int64_t{42}); + span.SetAttribute("double_key", 3.14); + span.SetAttribute("bool_key", true); + + EXPECT_NE(span.GetAttribute("string_key"), std::nullopt); + EXPECT_EQ(span.GetAttribute("nonexistent"), std::nullopt); + + EXPECT_EQ(span.GetAttributeAs<std::string>("string_key"), "val"); + EXPECT_EQ(span.GetAttributeAs<int64_t>("int_key"), 42); + EXPECT_EQ(span.GetAttributeAs<double>("double_key"), 3.14); + EXPECT_EQ(span.GetAttributeAs<bool>("bool_key"), true); + + // Type mismatch returns std::nullopt + EXPECT_EQ(span.GetAttributeAs<int64_t>("string_key"), std::nullopt); + EXPECT_EQ(span.GetAttributeAs<std::string>("nonexistent"), std::nullopt); +} + +TEST(TestSpanTest, RecordsAttributesEventsStatusAndEnded) { + auto recorded = std::make_shared<TestRecordedSpan>(); + TestSpan span(recorded); + + span.SetAttribute("attr1", std::string("val1")); + span.SetAttribute("attr2", int64_t{100}); + // Overwrite existing attribute + span.SetAttribute("attr1", std::string("new_val1")); + + span.AddEvent("event1"); + span.AddEvent("event2"); + + span.SetStatus(absl::InvalidArgumentError("bad input")); + span.End(); + + EXPECT_TRUE(recorded->ended()); + EXPECT_EQ(recorded->status(), absl::InvalidArgumentError("bad input")); + + ASSERT_EQ(recorded->events().size(), 2); + EXPECT_EQ(recorded->events()[0].name, "event1"); + EXPECT_EQ(recorded->events()[1].name, "event2"); + + EXPECT_EQ(recorded->GetAttributeAs<std::string>("attr1"), "new_val1"); + EXPECT_EQ(recorded->GetAttributeAs<int64_t>("attr2"), 100); +} + +TEST(TestTracerTest, StartSpanRecordsOptionsAndParent) { + TestTracer tracer; + TestSpan parent_span(std::make_shared<TestRecordedSpan>()); + + StartSpanOptions options; + options.parent = &parent_span; + options.attributes.push_back({"init_key", std::string("init_val")}); + + std::unique_ptr<Span> span = tracer.StartSpan("child_span", options); + ASSERT_NE(span, nullptr); + + auto spans = tracer.GetSpans(); + ASSERT_EQ(spans.size(), 1); + EXPECT_EQ(spans[0]->name_, "child_span"); + EXPECT_EQ(spans[0]->parent_, &parent_span); + EXPECT_EQ(spans[0]->GetAttributeAs<std::string>("init_key"), "init_val"); + + span->End(); + EXPECT_TRUE(spans[0]->ended()); +} + +TEST(TestTracerTest, QueryingSpansByName) { + TestTracer tracer; + tracer.StartSpan("op_a", {}); + tracer.StartSpan("op_b", {}); + tracer.StartSpan("op_a", {}); + + EXPECT_EQ(tracer.GetSpans().size(), 3); + + auto span_a = tracer.GetSpan("op_a"); + ASSERT_NE(span_a, nullptr); + EXPECT_EQ(span_a->name_, "op_a"); + + EXPECT_EQ(tracer.GetSpan("nonexistent"), nullptr); + + auto spans_a = tracer.GetSpansByName("op_a"); + EXPECT_EQ(spans_a.size(), 2); + + tracer.Clear(); + EXPECT_TRUE(tracer.GetSpans().empty()); +} + +TEST(TestTracerTest, IntegrationWithScopedSpan) { + TestTracer tracer; + { + ScopedSpan scoped_span(tracer.StartSpan("scoped_op", {})); + ASSERT_NE(scoped_span.get(), nullptr); + scoped_span->SetAttribute("key", std::string("value")); + scoped_span.SetStatus(absl::OkStatus()); + } + + auto spans = tracer.GetSpans(); + ASSERT_EQ(spans.size(), 1); + EXPECT_EQ(spans[0]->name_, "scoped_op"); + EXPECT_TRUE(spans[0]->ended()); + EXPECT_EQ(spans[0]->GetAttributeAs<std::string>("key"), "value"); +} + +TEST(TestTracerFactoryTest, GetTracerAndRegisterGlobalFactory) { + auto factory = std::make_shared<TestTracerFactory>(); + TestTracerFactory::Register(factory); + + auto current_factory = TracerFactory::GetGlobalFactory(); + EXPECT_EQ(current_factory, factory); + + auto tracer1 = factory->GetTracer("component_1"); + auto tracer2 = factory->GetTracer("component_1"); + EXPECT_EQ(tracer1, tracer2); + + tracer1->StartSpan("span_1", {}); + + auto test_tracer = factory->GetTestTracer("component_1"); + ASSERT_NE(test_tracer, nullptr); + EXPECT_EQ(test_tracer->GetSpans().size(), 1); + + EXPECT_EQ(factory->GetAllSpans().size(), 1); + + factory->Clear(); + EXPECT_TRUE(factory->GetAllSpans().empty()); + + TestTracerFactory::Unregister(); + EXPECT_NE(TracerFactory::GetGlobalFactory(), factory); +} + +TEST(TestTracerFactoryTest, RegisterStaticHelper) { + auto factory = TestTracerFactory::Register(); + EXPECT_EQ(TracerFactory::GetGlobalFactory(), factory); + + auto tracer = TracerFactory::GetGlobalFactory()->GetTracer("test_comp"); + tracer->StartSpan("span", {}); + EXPECT_EQ(factory->GetAllSpans().size(), 1); + + TestTracerFactory::Unregister(); + EXPECT_NE(TracerFactory::GetGlobalFactory(), factory); +} + +TEST(TestTracerFactoryTest, RegisterThisMemberMethod) { + auto factory = std::make_shared<TestTracerFactory>(); + factory->RegisterThis(); + EXPECT_EQ(TracerFactory::GetGlobalFactory(), factory); + + factory->Unregister(); + EXPECT_NE(TracerFactory::GetGlobalFactory(), factory); +} + +TEST(TestTracerTest, MultithreadedSpanCreation) { + TestTracer tracer; + constexpr int kNumThreads = 4; + constexpr int kSpansPerThread = 50; + + std::vector<std::thread> threads; + threads.reserve(kNumThreads); + + for (int t = 0; t < kNumThreads; ++t) { + threads.emplace_back([&tracer, t]() { + for (int i = 0; i < kSpansPerThread; ++i) { + auto span = tracer.StartSpan("mt_span", {}); + span->SetAttribute("thread", int64_t{t}); + span->SetAttribute("iter", int64_t{i}); + span->AddEvent("tick"); + span->SetStatus(absl::OkStatus()); + span->End(); + } + }); + } + + for (auto& thread : threads) { + thread.join(); + } + + EXPECT_EQ(tracer.GetSpans().size(), kNumThreads * kSpansPerThread); +} + +TEST(TestTracerTest, ImplicitParentLinkingWithScopedSpan) { + TestTracer tracer; + { + ScopedSpan outer_span(tracer.StartSpan("outer", {})); + { + ScopedSpan inner_span(tracer.StartSpan("inner", {})); + } + } + + auto spans = tracer.GetSpans(); + ASSERT_EQ(spans.size(), 2); + + EXPECT_EQ(spans[0]->name_, "outer"); + EXPECT_EQ(spans[1]->name_, "inner"); + + EXPECT_FALSE(spans[0]->trace_id_.empty()); + EXPECT_EQ(spans[0]->trace_id_, spans[1]->trace_id_); + EXPECT_EQ(spans[1]->parent_span_id_, spans[0]->span_id_); +} + +TEST(TestRecordedEventTest, EqualityOperator) { + TestRecordedEvent event1{"event_name"}; + TestRecordedEvent event2{"event_name"}; + TestRecordedEvent event3{"different_name"}; + + EXPECT_EQ(event1, event2); + EXPECT_NE(event1.name, event3.name); +} + +TEST(TestRecordedSpanTest, EndTimeAndAttributesAccessors) { + TestRecordedSpan span; + EXPECT_FALSE(span.ended()); + + absl::Time start = absl::Now(); + span.SetAttribute("key1", std::string("val1")); + span.SetAttribute("key2", int64_t{50}); + span.End(); + + EXPECT_TRUE(span.ended()); + EXPECT_GE(span.end_time(), start); + + auto attrs = span.attributes(); + ASSERT_EQ(attrs.size(), 2); + EXPECT_EQ(attrs[0].first, "key1"); + EXPECT_EQ(std::get<std::string>(attrs[0].second), "val1"); + EXPECT_EQ(attrs[1].first, "key2"); + EXPECT_EQ(std::get<int64_t>(attrs[1].second), 50); +} + +TEST(TestSpanTest, NullTestRecordedSpanHandledSafely) { + TestSpan span(nullptr); + EXPECT_EQ(span.recorded_span(), nullptr); + + // Calling methods on TestSpan with null recorded_span should not crash + span.SetAttribute("key", std::string("val")); + span.AddEvent("event"); + span.SetStatus(absl::OkStatus()); + span.End(); +} + +TEST(TestTracerTest, NonTestSpanParentHandling) { + TestTracer tracer; + class NonTestSpan : public Span { + public: + void SetAttribute(absl::string_view, AttributeValue) override {} + void AddEvent(absl::string_view) override {} + void SetStatus(const absl::Status&) override {} + void End() override {} + }; + + NonTestSpan custom_parent; + StartSpanOptions options; + options.parent = &custom_parent; + + auto span = tracer.StartSpan("span_with_custom_parent", options); + ASSERT_NE(span, nullptr); + + auto spans = tracer.GetSpans(); + ASSERT_EQ(spans.size(), 1); + EXPECT_EQ(spans[0]->parent_, &custom_parent); + EXPECT_TRUE(spans[0]->parent_span_id_.empty()); + EXPECT_FALSE(spans[0]->trace_id_.empty()); +} + +TEST(TestTracerTest, ParentTestSpanWithoutTestRecordedSpanStartsNewTrace) { + TestTracer tracer; + TestSpan parent_without_recording(nullptr); + + StartSpanOptions options; + options.parent = &parent_without_recording; + tracer.StartSpan("orphan_op", options); + + auto spans = tracer.GetSpans(); + ASSERT_EQ(spans.size(), 1); + EXPECT_EQ(spans[0]->parent_, &parent_without_recording); + EXPECT_TRUE(spans[0]->parent_span_id_.empty()); + EXPECT_FALSE(spans[0]->trace_id_.empty()); +} + +TEST(TestTracerFactoryTest, GetTestTracerReturnsNullForNonexistent) { + TestTracerFactory factory; + EXPECT_EQ(factory.GetTestTracer("nonexistent_tracer"), nullptr); +} + +} // namespace +} // namespace credentio_tracing
diff --git a/tracing/thread_context.cc b/tracing/thread_context.cc new file mode 100644 index 0000000..554a0a0 --- /dev/null +++ b/tracing/thread_context.cc
@@ -0,0 +1,35 @@ +// Copyright 2026 Google LLC +// +// 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 +// +// https://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. +// + +#include "tracing/thread_context.h" + +#include "absl/base/nullability.h" +#include "tracing/span.h" + +namespace credentio_tracing { + +namespace { + +thread_local Span* g_current_span = nullptr; + +} // namespace + +Span* absl_nullable ThreadContext::GetCurrentSpan() { return g_current_span; } + +void ThreadContext::SetCurrentSpan(Span* absl_nullable span) { + g_current_span = span; +} + +} // namespace credentio_tracing
diff --git a/tracing/thread_context.h b/tracing/thread_context.h new file mode 100644 index 0000000..4f9de4a --- /dev/null +++ b/tracing/thread_context.h
@@ -0,0 +1,41 @@ +// Copyright 2026 Google LLC +// +// 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 +// +// https://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. +// + +#ifndef THIRD_PARTY_CREDENTIO_TRACING_THREAD_CONTEXT_H_ +#define THIRD_PARTY_CREDENTIO_TRACING_THREAD_CONTEXT_H_ + +#include "absl/base/nullability.h" +#include "tracing/span.h" + +namespace credentio_tracing { + +// Manages thread-local active span state across execution boundaries. +class ThreadContext { + public: + // Returns the currently active Span on the calling thread, or nullptr if + // none. + static Span* absl_nullable GetCurrentSpan(); + + // Sets the active Span for the calling thread. + // + // Does not take ownership of `span`. The caller is responsible for ensuring + // `span` remains valid while set as active on the calling thread, and is + // unset or restored before `span` is destroyed. + static void SetCurrentSpan(Span* absl_nullable span); +}; + +} // namespace credentio_tracing + +#endif // THIRD_PARTY_CREDENTIO_TRACING_THREAD_CONTEXT_H_
diff --git a/tracing/thread_context_test.cc b/tracing/thread_context_test.cc new file mode 100644 index 0000000..508e283 --- /dev/null +++ b/tracing/thread_context_test.cc
@@ -0,0 +1,77 @@ +// Copyright 2026 Google LLC +// +// 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 +// +// https://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. +// + +#include "tracing/thread_context.h" + +#include <thread> // NOLINT(build/c++11) + +#include "gtest/gtest.h" +#include "tracing/noop_tracing.h" + +namespace credentio_tracing { +namespace { + +TEST(ThreadContextTest, InitialStateIsNull) { + EXPECT_EQ(ThreadContext::GetCurrentSpan(), nullptr); +} + +TEST(ThreadContextTest, SetAndGetCurrentSpan) { + NoopSpan span1; + NoopSpan span2; + + EXPECT_EQ(ThreadContext::GetCurrentSpan(), nullptr); + + ThreadContext::SetCurrentSpan(&span1); + EXPECT_EQ(ThreadContext::GetCurrentSpan(), &span1); + + ThreadContext::SetCurrentSpan(&span2); + EXPECT_EQ(ThreadContext::GetCurrentSpan(), &span2); + + ThreadContext::SetCurrentSpan(nullptr); + EXPECT_EQ(ThreadContext::GetCurrentSpan(), nullptr); +} + +TEST(ThreadContextTest, ThreadLocalIsolation) { + NoopSpan main_span; + + ThreadContext::SetCurrentSpan(&main_span); + + std::thread worker([&]() { + EXPECT_EQ(ThreadContext::GetCurrentSpan(), nullptr); + + NoopSpan worker_span; + + ThreadContext::SetCurrentSpan(&worker_span); + + EXPECT_EQ(ThreadContext::GetCurrentSpan(), &worker_span); + }); + + worker.join(); + + EXPECT_EQ(ThreadContext::GetCurrentSpan(), &main_span); + + ThreadContext::SetCurrentSpan(nullptr); +} + +TEST(ThreadContextTest, SetNullWhenAlreadyNull) { + EXPECT_EQ(ThreadContext::GetCurrentSpan(), nullptr); + + ThreadContext::SetCurrentSpan(nullptr); + + EXPECT_EQ(ThreadContext::GetCurrentSpan(), nullptr); +} + +} // namespace +} // namespace credentio_tracing
diff --git a/tracing/tracer.h b/tracing/tracer.h new file mode 100644 index 0000000..1be4ff8 --- /dev/null +++ b/tracing/tracer.h
@@ -0,0 +1,66 @@ +// Copyright 2026 Google LLC +// +// 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 +// +// https://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. +// + +#ifndef THIRD_PARTY_CREDENTIO_TRACING_TRACER_H_ +#define THIRD_PARTY_CREDENTIO_TRACING_TRACER_H_ + +#include <memory> +#include <string> +#include <utility> +#include <vector> + +#include "absl/strings/string_view.h" +#include "tracing/span.h" +#include "tracing/thread_context.h" + +namespace credentio_tracing { + +// Configuration options passed when starting a new span. +struct StartSpanOptions { + // Optional parent span to establish parent-child relationship. + Span* parent = nullptr; + + // Initial key-value attribute pairs attached to the span upon creation. + std::vector<std::pair<std::string, AttributeValue>> attributes; +}; + +// Abstract interface for creating tracing spans. +class Tracer { + public: + virtual ~Tracer() = default; + + // Convenience overload to start a span with default options. + std::unique_ptr<Span> StartSpan(absl::string_view span_name) const { + return StartSpan(span_name, {}); + } + + // Creates and starts a new Span with the given name and options. + std::unique_ptr<Span> StartSpan(absl::string_view span_name, + StartSpanOptions options) const { + // If no parent span is specified, use the current thread context span + if (options.parent == nullptr) { + options.parent = ThreadContext::GetCurrentSpan(); + } + return StartSpanImpl(span_name, options); + } + + protected: + virtual std::unique_ptr<Span> StartSpanImpl( + absl::string_view span_name, const StartSpanOptions& options) const = 0; +}; + +} // namespace credentio_tracing + +#endif // THIRD_PARTY_CREDENTIO_TRACING_TRACER_H_
diff --git a/tracing/tracer_factory.cc b/tracing/tracer_factory.cc new file mode 100644 index 0000000..eaacba9 --- /dev/null +++ b/tracing/tracer_factory.cc
@@ -0,0 +1,108 @@ +// Copyright 2026 Google LLC +// +// 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 +// +// https://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. +// + +#include "tracing/tracer_factory.h" + +#include <memory> +#include <utility> + +#include "absl/base/attributes.h" +#include "absl/base/const_init.h" +#include "absl/base/nullability.h" +#include "absl/strings/string_view.h" +#include "absl/synchronization/mutex.h" +#include "async/async_context.h" +#include "tracing/noop_tracing.h" +#include "tracing/tracer.h" + +namespace credentio_tracing { + +namespace { + +ABSL_CONST_INIT absl::Mutex g_factory_mutex(absl::kConstInit); + +// Returns a reference to the global heap-allocated shared_ptr holding the +// registered TracerFactory instance. Defaults to NoopTracerFactory. +std::shared_ptr<TracerFactory>& GlobalFactoryStorage() { + static auto* factory = + new std::shared_ptr<TracerFactory>(std::make_shared<NoopTracerFactory>()); + return *factory; +} + +// Returns a reference to the calling thread's factory, which is null unless +// one has been installed. +// +// Unlike the global storage above, this is held by value so that it is released +// when the thread exits; a heap allocation here would be reported as a leak by +// every thread that installs a factory. +std::shared_ptr<TracerFactory>& ThreadLocalFactoryStorage() { + static thread_local std::shared_ptr<TracerFactory> factory; + return factory; +} + +} // namespace + +void TracerFactory::RegisterGlobalFactory( + absl_nullable std::shared_ptr<TracerFactory> factory) { + absl::MutexLock lock(g_factory_mutex); + if (factory == nullptr) { + factory = std::make_shared<NoopTracerFactory>(); + } + GlobalFactoryStorage() = std::move(factory); +} + +absl_nonnull std::shared_ptr<TracerFactory> TracerFactory::GetGlobalFactory() { + absl::MutexLock lock(g_factory_mutex); + return GlobalFactoryStorage(); +} + +void TracerFactory::SetThreadLocalFactory( + absl_nullable std::shared_ptr<TracerFactory> factory) { + ThreadLocalFactoryStorage() = std::move(factory); +} + +absl_nullable std::shared_ptr<TracerFactory> +TracerFactory::GetThreadLocalFactory() { + return ThreadLocalFactoryStorage(); +} + +absl_nonnull std::shared_ptr<TracerFactory> TracerFactory::Get() { + if (std::shared_ptr<TracerFactory> factory = ThreadLocalFactoryStorage(); + factory != nullptr) { + return factory; + } + return GetGlobalFactory(); +} + +absl_nonnull std::shared_ptr<TracerFactory> ResolveTracerFactory( + const credentio::AsyncContext& context) { + if (std::shared_ptr<TracerFactory> factory = + context.GetExtension<TracerFactory>(); + factory != nullptr) { + return factory; + } + return TracerFactory::Get(); +} + +absl_nonnull std::shared_ptr<Tracer> GetTracer( + absl::string_view tracer_name, const credentio::AsyncContext& context) { + return ResolveTracerFactory(context)->GetTracer(tracer_name); +} + +absl_nonnull std::shared_ptr<Tracer> GetTracer(absl::string_view tracer_name) { + return TracerFactory::Get()->GetTracer(tracer_name); +} + +} // namespace credentio_tracing
diff --git a/tracing/tracer_factory.h b/tracing/tracer_factory.h new file mode 100644 index 0000000..b5d2378 --- /dev/null +++ b/tracing/tracer_factory.h
@@ -0,0 +1,94 @@ +// Copyright 2026 Google LLC +// +// 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 +// +// https://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. +// + +#ifndef THIRD_PARTY_CREDENTIO_TRACING_TRACER_FACTORY_H_ +#define THIRD_PARTY_CREDENTIO_TRACING_TRACER_FACTORY_H_ + +#include <memory> + +#include "absl/base/nullability.h" +#include "absl/strings/string_view.h" +#include "async/async_context.h" +#include "tracing/tracer.h" + +namespace credentio_tracing { + +// Abstract factory interface and registry for obtaining Tracer instances. +// +// A factory can be installed at three scopes, consulted in this order: +// +// 1. Per operation, by attaching it to the `credentio::AsyncContext` passed +// to the +// operation (`context.SetExtension<TracerFactory>(factory)`). This is the +// only scope that isolates concurrent operations from one another, and the +// only one that follows the work across executor hops. Prefer it for any +// tracer that accumulates per-operation state. +// 2. Per thread, via `SetThreadLocalFactory` (or the `ScopedTracerFactory` +// guard). Suitable for synchronous callers that cannot thread a context +// through; it does *not* follow work handed to another thread. +// 3. Process-wide, via `RegisterGlobalFactory`. Suitable for a tracer that +// is safe to share across all operations, such as one exporting to a +// metrics backend. +// +// If none is installed the no-op factory is used. +class TracerFactory { + public: + virtual ~TracerFactory() = default; + + // Obtains a Tracer associated with the specified component or module name. + virtual absl_nonnull std::shared_ptr<Tracer> GetTracer( + absl::string_view tracer_name) const = 0; + + // Registers the global TracerFactory instance. If passed nullptr, resets to + // the default NoopTracerFactory instance. Thread-safe. + static void RegisterGlobalFactory( + absl_nullable std::shared_ptr<TracerFactory> factory); + + // Returns the current global TracerFactory instance. Thread-safe. + static absl_nonnull std::shared_ptr<TracerFactory> GetGlobalFactory(); + + // Installs `factory` for the calling thread only, shadowing the global + // factory. Passing nullptr clears it. Prefer `ScopedTracerFactory`, which + // guarantees the factory is cleared on the same thread that installed it. + static void SetThreadLocalFactory( + absl_nullable std::shared_ptr<TracerFactory> factory); + + // Returns the factory installed for the calling thread, or nullptr. + static absl_nullable std::shared_ptr<TracerFactory> GetThreadLocalFactory(); + + // Returns the thread-local factory if one is installed, otherwise the global + // one. Never returns nullptr. + static absl_nonnull std::shared_ptr<TracerFactory> Get(); +}; + +// Returns the factory that should be used for work performed under `context`: +// the factory carried by the context if it has one, otherwise the thread-local +// factory, otherwise the global one. Never returns nullptr. +absl_nonnull std::shared_ptr<TracerFactory> ResolveTracerFactory( + const credentio::AsyncContext& context); + +// Returns a tracer named `tracer_name` from the factory resolved for +// `context`. This is the form instrumentation should use: passing the context +// keeps spans with the operation they belong to. +absl_nonnull std::shared_ptr<Tracer> GetTracer( + absl::string_view tracer_name, const credentio::AsyncContext& context); + +// As above, for synchronous code with no context to hand. Resolves against the +// thread-local factory, then the global one. +absl_nonnull std::shared_ptr<Tracer> GetTracer(absl::string_view tracer_name); + +} // namespace credentio_tracing + +#endif // THIRD_PARTY_CREDENTIO_TRACING_TRACER_FACTORY_H_
diff --git a/tracing/tracer_test.cc b/tracing/tracer_test.cc new file mode 100644 index 0000000..af88656 --- /dev/null +++ b/tracing/tracer_test.cc
@@ -0,0 +1,346 @@ +// Copyright 2026 Google LLC +// +// 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 +// +// https://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. +// + +#include "tracing/tracer.h" + +#include <cstdint> +#include <memory> +#include <string> +#include <thread> // NOLINT(build/c++11) +#include <vector> + +#include "absl/base/nullability.h" +#include "absl/status/status.h" +#include "absl/strings/string_view.h" +#include "async/async_context.h" +#include "gmock/gmock.h" +#include "gtest/gtest.h" +#include "tracing/noop_tracing.h" +#include "tracing/scoped_span.h" +#include "tracing/scoped_tracer_factory.h" +#include "tracing/span.h" +#include "tracing/thread_context.h" +#include "tracing/tracer_factory.h" + +namespace credentio_tracing { +namespace { + +using ::testing::_; +using ::testing::Eq; +using ::testing::Return; +using ::testing::StrictMock; + +class MockTracer : public Tracer { + public: + MOCK_METHOD(std::unique_ptr<Span>, StartSpanImpl, + (absl::string_view span_name, const StartSpanOptions& options), + (const, override)); +}; + +class MockTracerFactory : public TracerFactory { + public: + MOCK_METHOD(absl_nonnull std::shared_ptr<Tracer>, GetTracer, + (absl::string_view tracer_name), (const, override)); +}; + +TEST(StartSpanOptionsTest, DefaultOptions) { + StartSpanOptions options; + EXPECT_EQ(options.parent, nullptr); + EXPECT_TRUE(options.attributes.empty()); +} + +TEST(StartSpanOptionsTest, CustomOptions) { + NoopSpan parent_span; + StartSpanOptions options; + options.parent = &parent_span; + options.attributes.push_back({"key1", std::string("val1")}); + options.attributes.push_back({"key2", int64_t{100}}); + + EXPECT_EQ(options.parent, &parent_span); + ASSERT_EQ(options.attributes.size(), 2); + EXPECT_EQ(options.attributes[0].first, "key1"); + EXPECT_EQ(std::get<std::string>(options.attributes[0].second), "val1"); + EXPECT_EQ(options.attributes[1].first, "key2"); + EXPECT_EQ(std::get<int64_t>(options.attributes[1].second), 100); +} + +TEST(TracerTest, StartSpanInvocation) { + StrictMock<MockTracer> mock_tracer; + EXPECT_CALL(mock_tracer, StartSpanImpl(Eq("test_span"), _)) + .WillOnce([](absl::string_view, const StartSpanOptions& options) { + EXPECT_EQ(options.parent, nullptr); + return std::make_unique<NoopSpan>(); + }); + + std::unique_ptr<Span> span = mock_tracer.StartSpan("test_span", {}); + EXPECT_NE(span, nullptr); +} + +TEST(TracerTest, StartSpanSingleArgumentOverload) { + StrictMock<MockTracer> mock_tracer; + EXPECT_CALL(mock_tracer, StartSpanImpl(Eq("simple_span"), _)) + .WillOnce([](absl::string_view, const StartSpanOptions& options) { + EXPECT_EQ(options.parent, nullptr); + EXPECT_TRUE(options.attributes.empty()); + return std::make_unique<NoopSpan>(); + }); + + std::unique_ptr<Span> span = mock_tracer.StartSpan("simple_span"); + EXPECT_NE(span, nullptr); +} + +TEST(TracerTest, StartSpanImplicitParentFromThreadContext) { + NoopSpan active_thread_span; + ThreadContext::SetCurrentSpan(&active_thread_span); + + StrictMock<MockTracer> mock_tracer; + EXPECT_CALL(mock_tracer, StartSpanImpl(Eq("child_span"), _)) + .WillOnce([&active_thread_span](absl::string_view, + const StartSpanOptions& options) { + EXPECT_EQ(options.parent, &active_thread_span); + return std::make_unique<NoopSpan>(); + }); + + std::unique_ptr<Span> span = mock_tracer.StartSpan("child_span"); + EXPECT_NE(span, nullptr); + + ThreadContext::SetCurrentSpan(nullptr); +} + +TEST(TracerTest, StartSpanExplicitParentOverridesThreadContext) { + NoopSpan active_thread_span; + NoopSpan explicit_parent_span; + ThreadContext::SetCurrentSpan(&active_thread_span); + + StrictMock<MockTracer> mock_tracer; + StartSpanOptions options; + options.parent = &explicit_parent_span; + + EXPECT_CALL(mock_tracer, StartSpanImpl(Eq("child_span"), _)) + .WillOnce([&explicit_parent_span](absl::string_view, + const StartSpanOptions& options) { + EXPECT_EQ(options.parent, &explicit_parent_span); + return std::make_unique<NoopSpan>(); + }); + + std::unique_ptr<Span> span = mock_tracer.StartSpan("child_span", options); + EXPECT_NE(span, nullptr); + + ThreadContext::SetCurrentSpan(nullptr); +} + +TEST(TracerFactoryTest, GetTracerInvocation) { + StrictMock<MockTracerFactory> mock_factory; + auto mock_tracer = std::make_shared<StrictMock<MockTracer>>(); + + EXPECT_CALL(mock_factory, GetTracer(Eq("mock_tracer"))) + .WillOnce(Return(mock_tracer)); + + std::shared_ptr<Tracer> tracer = mock_factory.GetTracer("mock_tracer"); + EXPECT_EQ(tracer, mock_tracer); +} + +TEST(TracerFactoryTest, DefaultGlobalFactoryIsNoopTracerFactory) { + auto factory = TracerFactory::GetGlobalFactory(); + ASSERT_NE(factory, nullptr); + auto tracer = factory->GetTracer("mock_tracer"); + ASSERT_NE(tracer, nullptr); + auto span = tracer->StartSpan("test_span", {}); + ASSERT_NE(span, nullptr); +} + +TEST(TracerFactoryTest, GlobalFactoryRegistration) { + auto initial_factory = TracerFactory::GetGlobalFactory(); + EXPECT_NE(initial_factory, nullptr); + + auto custom_factory = std::make_shared<StrictMock<MockTracerFactory>>(); + TracerFactory::RegisterGlobalFactory(custom_factory); + + EXPECT_EQ(TracerFactory::GetGlobalFactory(), custom_factory); + + // Restore original factory state after test + TracerFactory::RegisterGlobalFactory(initial_factory); +} + +TEST(TracerFactoryTest, RegisterNullptrResetsToNoopTracerFactory) { + auto initial_factory = TracerFactory::GetGlobalFactory(); + EXPECT_NE(initial_factory, nullptr); + + auto custom_factory = std::make_shared<StrictMock<MockTracerFactory>>(); + TracerFactory::RegisterGlobalFactory(custom_factory); + EXPECT_EQ(TracerFactory::GetGlobalFactory(), custom_factory); + + TracerFactory::RegisterGlobalFactory(nullptr); + auto current_factory = TracerFactory::GetGlobalFactory(); + ASSERT_NE(current_factory, nullptr); + EXPECT_NE(current_factory, custom_factory); + + // Restore original factory state after test + TracerFactory::RegisterGlobalFactory(initial_factory); +} + +TEST(TracerFactoryTest, ConcurrentGlobalFactoryAccess) { + constexpr int kNumThreads = 8; + constexpr int kIterations = 100; + + std::vector<std::thread> threads; + threads.reserve(kNumThreads); + + for (int i = 0; i < kNumThreads; ++i) { + threads.emplace_back([&]() { + for (int j = 0; j < kIterations; ++j) { + auto factory = TracerFactory::GetGlobalFactory(); + EXPECT_NE(factory, nullptr); + auto tracer = factory->GetTracer("concurrent_test"); + EXPECT_NE(tracer, nullptr); + auto span = tracer->StartSpan("concurrent_span", {}); + EXPECT_NE(span, nullptr); + } + }); + } + + for (auto& t : threads) { + t.join(); + } +} + +TEST(TracerFactoryResolutionTest, FallsBackToGlobalFactory) { + auto initial_factory = TracerFactory::GetGlobalFactory(); + auto global_factory = std::make_shared<StrictMock<MockTracerFactory>>(); + TracerFactory::RegisterGlobalFactory(global_factory); + + EXPECT_EQ(TracerFactory::Get(), global_factory); + EXPECT_EQ(ResolveTracerFactory(credentio::AsyncContext()), global_factory); + + TracerFactory::RegisterGlobalFactory(initial_factory); +} + +TEST(TracerFactoryResolutionTest, ThreadLocalFactoryShadowsGlobal) { + auto initial_factory = TracerFactory::GetGlobalFactory(); + auto global_factory = std::make_shared<StrictMock<MockTracerFactory>>(); + auto thread_local_factory = std::make_shared<StrictMock<MockTracerFactory>>(); + TracerFactory::RegisterGlobalFactory(global_factory); + + { + ScopedTracerFactory guard(thread_local_factory); + EXPECT_EQ(TracerFactory::Get(), thread_local_factory); + EXPECT_EQ(ResolveTracerFactory(credentio::AsyncContext()), + thread_local_factory); + } + + EXPECT_EQ(TracerFactory::GetThreadLocalFactory(), nullptr); + EXPECT_EQ(TracerFactory::Get(), global_factory); + + TracerFactory::RegisterGlobalFactory(initial_factory); +} + +TEST(TracerFactoryResolutionTest, ContextFactoryShadowsThreadLocalAndGlobal) { + auto initial_factory = TracerFactory::GetGlobalFactory(); + auto global_factory = std::make_shared<StrictMock<MockTracerFactory>>(); + auto thread_local_factory = std::make_shared<StrictMock<MockTracerFactory>>(); + auto context_factory = std::make_shared<StrictMock<MockTracerFactory>>(); + TracerFactory::RegisterGlobalFactory(global_factory); + ScopedTracerFactory guard(thread_local_factory); + + credentio::AsyncContext context; + context.SetExtension<TracerFactory>(context_factory); + EXPECT_EQ(ResolveTracerFactory(context), context_factory); + + TracerFactory::RegisterGlobalFactory(initial_factory); +} + +TEST(TracerFactoryResolutionTest, ScopedTracerFactoryRestoresPrevious) { + auto outer = std::make_shared<StrictMock<MockTracerFactory>>(); + auto inner = std::make_shared<StrictMock<MockTracerFactory>>(); + + { + ScopedTracerFactory outer_guard(outer); + EXPECT_EQ(TracerFactory::GetThreadLocalFactory(), outer); + { + ScopedTracerFactory inner_guard(inner); + EXPECT_EQ(TracerFactory::GetThreadLocalFactory(), inner); + } + EXPECT_EQ(TracerFactory::GetThreadLocalFactory(), outer); + } + EXPECT_EQ(TracerFactory::GetThreadLocalFactory(), nullptr); +} + +TEST(TracerFactoryResolutionTest, ThreadLocalFactoryDoesNotLeakAcrossThreads) { + auto initial_factory = TracerFactory::GetGlobalFactory(); + auto global_factory = std::make_shared<StrictMock<MockTracerFactory>>(); + auto thread_local_factory = std::make_shared<StrictMock<MockTracerFactory>>(); + TracerFactory::RegisterGlobalFactory(global_factory); + + ScopedTracerFactory guard(thread_local_factory); + + // A thread-local factory covers only the installing thread, which is why + // asynchronous work needs the factory on the context instead. + std::shared_ptr<TracerFactory> seen_without_context; + std::shared_ptr<TracerFactory> seen_with_context; + auto context_factory = std::make_shared<StrictMock<MockTracerFactory>>(); + credentio::AsyncContext context; + context.SetExtension<TracerFactory>(context_factory); + + std::thread worker([&]() { + seen_without_context = TracerFactory::Get(); + seen_with_context = ResolveTracerFactory(context); + }); + worker.join(); + + EXPECT_EQ(seen_without_context, global_factory); + EXPECT_EQ(seen_with_context, context_factory); + + TracerFactory::RegisterGlobalFactory(initial_factory); +} + +TEST(NoopSpanTest, AllMethodsExecuteWithoutError) { + NoopSpan span; + + span.SetAttribute("key_str", std::string("value")); + span.SetAttribute("key_int", int64_t{42}); + span.SetAttribute("key_double", 3.14); + span.SetAttribute("key_bool", true); + + span.AddEvent("test_event"); + span.SetStatus(absl::OkStatus()); + span.SetStatus(absl::InvalidArgumentError("invalid argument")); + span.End(); +} + +TEST(NoopTracerTest, StartSpanReturnsNonNullNoopSpan) { + NoopTracer tracer; + StartSpanOptions options; + + std::unique_ptr<Span> span = tracer.StartSpan("test_span", options); + ASSERT_NE(span, nullptr); + + span->SetAttribute("key", int64_t{100}); + span->AddEvent("event"); + span->SetStatus(absl::OkStatus()); + span->End(); +} + +TEST(NoopTracerTest, IntegrationWithScopedSpan) { + NoopTracer tracer; + { + ScopedSpan scoped_span(tracer.StartSpan("scoped_op", {})); + ASSERT_NE(scoped_span.get(), nullptr); + scoped_span->SetAttribute("attr", std::string("val")); + scoped_span.SetStatus(absl::OkStatus()); + } +} + +} // namespace +} // namespace credentio_tracing
diff --git a/tsp/test_helpers.cc b/tsp/test_helpers.cc index 0e734e1..d34ca96 100644 --- a/tsp/test_helpers.cc +++ b/tsp/test_helpers.cc
@@ -249,8 +249,7 @@ auto cms_or = credentio_testing::CreateSignedCms( der_data, *cert, [&key](absl::string_view data) { return SignWithBoringSsl(*key, data); }, - {.signing_time = absl::FromUnixSeconds(1427738483), - .include_user_certificate = embed_cert, + {.include_user_certificate = embed_cert, .extra_signed_attributes = extra_attrs, .is_time_stamp_token = true}); ABSL_CHECK_OK(cms_or.status()); @@ -261,7 +260,8 @@ absl::Span<const absl::string_view> der_data, const crypto::tink::PublicKeySign& signer, absl::string_view cert_pem, bool embed_cert, - std::variant<absl::string_view, bool> signing_certificate_attribute) { + std::variant<absl::string_view, bool> signing_certificate_attribute, + std::optional<absl::Time> signing_time) { bssl::UniquePtr<X509> cert(PemToCertificate(cert_pem)); ABSL_CHECK(cert != nullptr); std::string extra_attrs; @@ -283,7 +283,7 @@ ABSL_ASSIGN_OR_RETURN(auto signature, signer.Sign(data)); return std::vector<uint8_t>(signature.begin(), signature.end()); }, - {.signing_time = absl::FromUnixSeconds(1427738483), + {.signing_time = signing_time, .include_user_certificate = embed_cert, .extra_signed_attributes = extra_attrs, .is_time_stamp_token = true}); @@ -342,8 +342,7 @@ ABSL_ASSIGN_OR_RETURN(auto signature, signer_ptr->Sign(data)); return std::vector<uint8_t>(signature.begin(), signature.end()); }, - {.signing_time = absl::FromUnixSeconds(1427738483), - .include_user_certificate = embed_cert, + {.include_user_certificate = embed_cert, .ca_certificates = ca_certs, .extra_signed_attributes = extra_attrs, .is_time_stamp_token = !use_wrong_e_content_type});
diff --git a/tsp/test_helpers.h b/tsp/test_helpers.h index 5d4590d..88db3c0 100644 --- a/tsp/test_helpers.h +++ b/tsp/test_helpers.h
@@ -24,6 +24,7 @@ #include "absl/base/nullability.h" #include "absl/strings/string_view.h" +#include "absl/time/time.h" #include "absl/types/span.h" #include "crypto/algorithms.h" #include "openssl/base.h" @@ -59,7 +60,8 @@ absl::Span<const absl::string_view> der_data, const crypto::tink::PublicKeySign& signer, absl::string_view cert_pem, bool embed_cert, - std::variant<absl::string_view, bool> signing_certificate_attribute = true); + std::variant<absl::string_view, bool> signing_certificate_attribute = true, + std::optional<absl::Time> signing_time = std::nullopt); // If `signing_certificate_attribute` holds a string, it will be taken as the // raw attribute value. If it holds `true` the value will be generated from
diff --git a/utils/crjson.cc b/utils/crjson.cc index 22d87fa..771a675 100644 --- a/utils/crjson.cc +++ b/utils/crjson.cc
@@ -382,26 +382,6 @@ // Signature Helpers // ============================================================================= -std::string SigningAlgorithmToString(SigningAlgorithm alg) { - switch (alg) { - case SigningAlgorithm::kEs256: - return "ES256"; - case SigningAlgorithm::kEs384: - return "ES384"; - case SigningAlgorithm::kEs512: - return "ES512"; - case SigningAlgorithm::kPs256: - return "PS256"; - case SigningAlgorithm::kPs384: - return "PS384"; - case SigningAlgorithm::kPs512: - return "PS512"; - case SigningAlgorithm::kEdDsa: - return "Ed25519"; - } - return "Unknown"; -} - Json ConvertDNParsed(X509_NAME* name) { Json dn_map = Json::object(); if (!name) return dn_map; @@ -494,7 +474,7 @@ auto protected_header = DecodeProtectedHeader(cose_sign1->protected_header); if (protected_header.ok()) { - crjson["algorithm"] = SigningAlgorithmToString(protected_header->alg); + crjson["algorithm"] = absl::StrCat(protected_header->alg); } std::vector<std::string> cert_chain;
diff --git a/utils/crjson_test.cc b/utils/crjson_test.cc index 8265058..7a857bf 100644 --- a/utils/crjson_test.cc +++ b/utils/crjson_test.cc
@@ -351,5 +351,47 @@ EXPECT_TRUE(metadata_json["regionOfInterest"].is_object()); } +TEST(ConvertManifestStoreToCrJsonTest, ClaimSignatureAlgorithmsAreConverted) { + struct TestCase { + int64_t cose_alg; + absl::string_view expected_alg_str; + }; + const TestCase kTestCases[] = { + {-7, "ES256"}, {-35, "ES384"}, {-36, "ES512"}, {-37, "PS256"}, + {-38, "PS384"}, {-39, "PS512"}, {-8, "EdDSA"}, + }; + + for (const auto& tc : kTestCases) { + cppbor::Map protected_header_map; + protected_header_map.add(cppbor::Uint(1), cppbor::Nint(tc.cose_alg)); + std::string protected_header = protected_header_map.toString(); + + cppbor::Array cose_array; + cose_array.add(cppbor::Bstr(protected_header)); + cose_array.add(cppbor::Map()); + cose_array.add(cppbor::Null()); + cose_array.add(cppbor::Bstr("dummy_sig")); + cppbor::SemanticTag cose_sign1(18, std::move(cose_array)); + std::string encoded_cose = cose_sign1.toString(); + + std::string cbor_box = jumbf::EncodeCborBox(encoded_cose); + std::string sig_box = jumbf::EncodeSuperBox( + jumbf::kCborBoxTypeUuid, kClaimSignatureLabel, {cbor_box}); + std::string manifest_box = jumbf::EncodeSuperBox( + kStandardManifestUuid, "test_manifest", {sig_box}); + std::string manifest_store_box = jumbf::EncodeSuperBox( + kManifestStoreUuid, kManifestStoreLabel, {manifest_box}); + + absl::StatusOr<nlohmann::json> j = + ConvertToCrJsonWithoutValidationResults(manifest_store_box); + ABSL_ASSERT_OK(j); + ASSERT_TRUE(j->contains("manifests")); + ASSERT_EQ((*j)["manifests"].size(), 1); + const auto& manifest = (*j)["manifests"][0]; + ASSERT_TRUE(manifest.contains("signature")); + EXPECT_EQ(manifest["signature"]["algorithm"], tc.expected_alg_str); + } +} + } // namespace } // namespace credentio