diff --git a/CMakeLists.txt b/CMakeLists.txt index 07f8545a..bcca6912 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -14,7 +14,7 @@ cmake_minimum_required(VERSION 3.27) # This is the current version of this C++ project -project(c2pa-c VERSION 0.25.0) +project(c2pa-c VERSION 0.25.1) # Set the version of the c2pa_rs library used set(C2PA_VERSION "0.89.0") diff --git a/include/c2pa.hpp b/include/c2pa.hpp index 47410250..02e06076 100644 --- a/include/c2pa.hpp +++ b/include/c2pa.hpp @@ -748,6 +748,18 @@ namespace c2pa void init_from_context(IContextProvider& context, const std::string &format, std::istream &stream); void init_from_context(IContextProvider& context, const std::filesystem::path &source_path); + void init_from_manifest_data_and_stream(IContextProvider& context, + const std::string& format, + std::istream& image_stream, + const std::vector& manifest_jumbf); + + /// @brief Throw if the Reader holds no valid native handle. + void ensure_initialized() const { + if (c2pa_reader == nullptr) { + throw C2paException("Reader is not initialized"); + } + } + Reader() : c2pa_reader(nullptr) {} public: @@ -796,6 +808,25 @@ namespace c2pa /// @throws C2paException if context is null or context->is_valid() returns false. Reader(std::shared_ptr context, const std::filesystem::path &source_path); + /// @brief Create a Reader from a shared context, image stream, and external JUMBF manifest. + /// @details Performs full C2PA binding verification between @p image_stream and the provided + /// JUMBF manifest bytes without requiring the manifest to be embedded in the asset. + /// Typical use: sidecar .c2pa files, database-stored manifests, in-memory pipelines. + /// The Reader retains a shared reference to the context for its lifetime. + /// @param context Shared context provider (trust anchors, verification policy). + /// @param format MIME type of the image stream (e.g., "image/jpeg"). + /// @param image_stream Asset data. Must support seeking (used for exclusion-range hashing). + /// @param manifest_jumbf Raw JUMBF manifest bytes (e.g., contents of a .c2pa sidecar file). + /// @note @p image_stream, @p manifest_jumbf, and @p format need only remain valid for the + /// duration of the construction call. The Reader does not retain their references. + /// @throws C2paException if context is null, + /// context->is_valid() is false, + /// manifest_jumbf is empty, or the C2PA library reports an error. + Reader(std::shared_ptr context, + const std::string& format, + std::istream& image_stream, + const std::vector& manifest_jumbf); + /// @brief Create a Reader from a stream (will use global settings if any loaded). /// @details The validation_status field in the JSON contains validation results. /// @param format The mime format of the stream. @@ -877,8 +908,11 @@ namespace c2pa /// @brief Check if the reader was created from an embedded manifest. /// @return true if the manifest was embedded in the asset, false if external. - /// @throws C2paException for errors encountered by the C2PA library. + /// @throws C2paException if the Reader holds no valid handle (e.g. it was moved + /// from, or a prior with_fragment() failed and consumed it), or for other + /// errors encountered by the C2PA library. [[nodiscard]] inline bool is_embedded() const { + ensure_initialized(); return c2pa_reader_is_embedded(c2pa_reader); } @@ -888,6 +922,26 @@ namespace c2pa /// @throws C2paException for errors encountered by the C2PA library. [[nodiscard]] std::optional remote_url() const; + /// @brief Process a BMFF fragment stream with this Reader instance. + /// @details Used for fragmented BMFF media (DASH/HLS streaming) where content is split + /// into an init/main segment and separate fragment files. The Reader is + /// created from the init segment (e.g. Reader(context, "video/mp4", init)), + /// then goes through one fragment at a time via this method. + /// The underlying native SDK consumes the current reader and + /// returns a new one configured with the fragment. + /// This method swaps the handle in place and returns *this for chaining + /// across fragments as the reading steps go through fragments. + /// @param format MIME type of the media (e.g., "video/mp4"). + /// @param stream The main/init segment. + /// @param fragment The current fragment to process. + /// @return *this, for chaining across multiple fragments. + /// @note @p stream and @p fragment need only remain valid for the duration of this call. + /// The Reader does not retain a reference to either after returning. + /// @throws C2paException if the C2PA library reports an error. + /// On failure the underlying reader handle is consumed and + /// this Reader must not be used further. + Reader& with_fragment(const std::string& format, std::istream& stream, std::istream& fragment); + /// @brief Get the manifest as a JSON string. /// @return The manifest as a JSON string. /// @throws C2paException for errors encountered by the C2PA library. diff --git a/src/c2pa_reader.cpp b/src/c2pa_reader.cpp index a2301a00..0564fc63 100644 --- a/src/c2pa_reader.cpp +++ b/src/c2pa_reader.cpp @@ -98,6 +98,43 @@ namespace c2pa c2pa_reader = updated; } + void Reader::init_from_manifest_data_and_stream( + IContextProvider& context, + const std::string& format, + std::istream& image_stream, + const std::vector& manifest_jumbf) + { + if (!context.is_valid()) { + throw C2paException("Invalid Context provider IContextProvider"); + } + if (manifest_jumbf.empty()) { + throw C2paException("manifest_jumbf must not be empty"); + } + + cpp_stream = std::make_unique(image_stream); + + c2pa_reader = c2pa_reader_from_context(context.c_context()); + if (c2pa_reader == nullptr) { + throw C2paException("Failed to create reader from context"); + } + + // c2pa_reader_with_manifest_data_and_stream always consumes c2pa_reader. + C2paReader* updated = c2pa_reader_with_manifest_data_and_stream( + c2pa_reader, + format.c_str(), + cpp_stream->c_stream, + manifest_jumbf.data(), + manifest_jumbf.size()); + c2pa_reader = nullptr; + if (updated == nullptr) { + throw C2paException(); + } + c2pa_reader = updated; + + // Stream not retained by C FFI + cpp_stream.reset(); + } + Reader::Reader(IContextProvider& context, const std::string &format, std::istream &stream) : c2pa_reader(nullptr) { @@ -113,6 +150,9 @@ namespace c2pa Reader::Reader(std::shared_ptr context, const std::string &format, std::istream &stream) : c2pa_reader(nullptr) { + if (!context) { + throw C2paException("context must not be null"); + } init_from_context(*context, format, stream); context_ref = std::move(context); } @@ -120,10 +160,49 @@ namespace c2pa Reader::Reader(std::shared_ptr context, const std::filesystem::path &source_path) : c2pa_reader(nullptr) { + if (!context) { + throw C2paException("context must not be null"); + } init_from_context(*context, source_path); context_ref = std::move(context); } + Reader::Reader(std::shared_ptr context, + const std::string& format, + std::istream& image_stream, + const std::vector& manifest_jumbf) + : c2pa_reader(nullptr) + { + if (!context) { + throw C2paException("context must not be null"); + } + init_from_manifest_data_and_stream(*context, format, image_stream, manifest_jumbf); + context_ref = std::move(context); + } + + Reader& Reader::with_fragment(const std::string& format, std::istream& stream, std::istream& fragment) + { + ensure_initialized(); + + CppIStream main_wrapper(stream); + CppIStream fragment_wrapper(fragment); + + // c2pa_reader_with_fragment consumes the existing reader and returns a new one. + // *this is returned for chaining so reading can go through all segments. + C2paReader* updated = c2pa_reader_with_fragment( + c2pa_reader, + format.c_str(), + main_wrapper.c_stream, + fragment_wrapper.c_stream); + c2pa_reader = nullptr; + if (updated == nullptr) { + throw C2paException(); + } + c2pa_reader = updated; + + return *this; + } + Reader::Reader(const std::string &format, std::istream &stream) { cpp_stream = std::make_unique(stream); @@ -161,16 +240,19 @@ namespace c2pa std::string Reader::json() const { + ensure_initialized(); return detail::c_string_to_string(c2pa_reader_json(c2pa_reader)); } std::string Reader::detailed_json() const { + ensure_initialized(); return detail::c_string_to_string(c2pa_reader_detailed_json(c2pa_reader)); } std::string Reader::crjson() const { + ensure_initialized(); return detail::c_string_to_string(c2pa_reader_crjson(c2pa_reader)); } diff --git a/tests/fixtures/dash1.m4s b/tests/fixtures/dash1.m4s new file mode 100644 index 00000000..1a9a9964 Binary files /dev/null and b/tests/fixtures/dash1.m4s differ diff --git a/tests/fixtures/dashinit.mp4 b/tests/fixtures/dashinit.mp4 new file mode 100644 index 00000000..b1f703a8 Binary files /dev/null and b/tests/fixtures/dashinit.mp4 differ diff --git a/tests/reader.test.cpp b/tests/reader.test.cpp index 84081a97..e3568f44 100644 --- a/tests/reader.test.cpp +++ b/tests/reader.test.cpp @@ -15,6 +15,7 @@ #include #include #include +#include #include "include/test_utils.hpp" @@ -722,3 +723,126 @@ TEST(Reader, StreamWithExceptions) { // An error result is acceptable; crossing the FFI with an exception is not. } } + +TEST_F(ReaderTest, ReaderFromFragmentDashFixtures) { + auto ctx = std::make_shared(); + + std::ifstream init(c2pa_test::get_fixture_path("dashinit.mp4"), std::ios::binary); + ASSERT_TRUE(init.is_open()); + c2pa::Reader reader(ctx, "video/mp4", init); + + std::ifstream main_seg(c2pa_test::get_fixture_path("dashinit.mp4"), std::ios::binary); + std::ifstream fragment(c2pa_test::get_fixture_path("dash1.m4s"), std::ios::binary); + ASSERT_TRUE(main_seg.is_open()); + ASSERT_TRUE(fragment.is_open()); + + auto& same = reader.with_fragment("video/mp4", main_seg, fragment); + EXPECT_EQ(&same, &reader); + EXPECT_FALSE(reader.json().empty()); +} + +TEST_F(ReaderTest, ReaderFromFragmentReaderCanMove) { + auto ctx = std::make_shared(); + std::ifstream init(c2pa_test::get_fixture_path("dashinit.mp4"), std::ios::binary); + ASSERT_TRUE(init.is_open()); + c2pa::Reader reader(ctx, "video/mp4", init); + + { + std::ifstream main_seg(c2pa_test::get_fixture_path("dashinit.mp4"), std::ios::binary); + std::ifstream fragment(c2pa_test::get_fixture_path("dash1.m4s"), std::ios::binary); + reader.with_fragment("video/mp4", main_seg, fragment); + } + + c2pa::Reader moved = std::move(reader); + EXPECT_FALSE(moved.json().empty()); +} + +class ReaderSidecarTest : public ReaderTest { +public: + // A sidecar manifest plus the asset bytes it is bound to. + struct TestSidecar { + std::vector manifest; // external JUMBF, to pass as manifest_jumbf + std::vector asset_bytes; // the asset the manifest's dataHash covers + }; + + // Create manifest bytes for an asset. + static TestSidecar make_test_sidecar_bytes(const fs::path& asset, + const std::string& format) { + auto signer = c2pa_test::create_test_signer(); + auto context = c2pa::Context(); + auto manifest_json = c2pa_test::read_text_file(c2pa_test::get_fixture_path("training.json")); + auto builder = c2pa::Builder(context, manifest_json); + builder.set_no_embed(); + std::ifstream source(asset, std::ios::binary); + std::stringstream dest(std::ios::in | std::ios::out | std::ios::binary); + auto manifest_bytes = builder.sign(format, source, dest, signer); + + std::string signed_str = dest.str(); + return TestSidecar{ + std::vector(manifest_bytes.begin(), manifest_bytes.end()), + std::vector(signed_str.begin(), signed_str.end())}; + } + + // True if any validation_status entry's code contains "dataHash". + static bool has_data_hash_failure(const std::string& reader_json) { + auto obj = nlohmann::json::parse(reader_json); + for (auto& status : obj.value("validation_status", nlohmann::json::array())) { + if (status.value("code", std::string()).find("dataHash") != std::string::npos) { + return true; + } + } + return false; + } + + // Open the signed asset bytes as a seekable binary stream. + static std::unique_ptr open_asset_stream(const std::vector& bytes) { + return std::make_unique( + std::string(bytes.begin(), bytes.end()), std::ios::binary); + } +}; + +TEST_F(ReaderSidecarTest, ReaderCanReadSidecar) { + auto sc = make_test_sidecar_bytes(c2pa_test::get_fixture_path("C.jpg"), "image/jpeg"); + auto img = open_asset_stream(sc.asset_bytes); + auto ctx = std::make_shared(); + c2pa::Reader r(ctx, "image/jpeg", *img, sc.manifest); + EXPECT_FALSE(r.json().empty()); + EXPECT_FALSE(r.is_embedded()); + EXPECT_TRUE(nlohmann::json::parse(r.json()).contains("manifests")); + EXPECT_FALSE(has_data_hash_failure(r.json())); +} + +TEST_F(ReaderSidecarTest, ReaderCanReadSidecarSpecialChars) { +#ifdef _WIN32 + auto asset = c2pa_test::get_fixture_path(L"CÖÄ_.jpg"); +#else + auto asset = c2pa_test::get_fixture_path("CÖÄ_.jpg"); +#endif + auto sc = make_test_sidecar_bytes(asset, "image/jpeg"); + auto img = open_asset_stream(sc.asset_bytes); + auto ctx = std::make_shared(); + c2pa::Reader r(ctx, "image/jpeg", *img, sc.manifest); + EXPECT_FALSE(r.json().empty()); + EXPECT_FALSE(r.is_embedded()); + EXPECT_FALSE(has_data_hash_failure(r.json())); +} + +TEST_F(ReaderSidecarTest, SidecarReaderCanMove) { + auto sc = make_test_sidecar_bytes(c2pa_test::get_fixture_path("C.jpg"), "image/jpeg"); + auto img = open_asset_stream(sc.asset_bytes); + auto ctx = std::make_shared(); + c2pa::Reader r1(ctx, "image/jpeg", *img, sc.manifest); + c2pa::Reader r2 = std::move(r1); + EXPECT_FALSE(r2.json().empty()); +} + +TEST_F(ReaderSidecarTest, SidecarReaderResetsStreamPosition) { + auto sc = make_test_sidecar_bytes(c2pa_test::get_fixture_path("C.jpg"), "image/jpeg"); + auto img = open_asset_stream(sc.asset_bytes); + img->seekg(249); + auto ctx = std::make_shared(); + + // Reader can read even if stream img is at another pos than 0 + c2pa::Reader r(ctx, "image/jpeg", *img, sc.manifest); + EXPECT_FALSE(r.json().empty()); +}