Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
56 changes: 55 additions & 1 deletion include/c2pa.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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<uint8_t>& 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:
Expand Down Expand Up @@ -796,6 +808,25 @@ namespace c2pa
/// @throws C2paException if context is null or context->is_valid() returns false.
Reader(std::shared_ptr<IContextProvider> 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<IContextProvider> context,
const std::string& format,
std::istream& image_stream,
const std::vector<uint8_t>& 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.
Expand Down Expand Up @@ -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);
}

Expand All @@ -888,6 +922,26 @@ namespace c2pa
/// @throws C2paException for errors encountered by the C2PA library.
[[nodiscard]] std::optional<std::string> 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.
Expand Down
82 changes: 82 additions & 0 deletions src/c2pa_reader.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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<uint8_t>& manifest_jumbf)
{
if (!context.is_valid()) {
throw C2paException("Invalid Context provider IContextProvider");
}
if (manifest_jumbf.empty()) {
throw C2paException("manifest_jumbf must not be empty");
Comment thread
tmathern marked this conversation as resolved.
}

cpp_stream = std::make_unique<CppIStream>(image_stream);

c2pa_reader = c2pa_reader_from_context(context.c_context());
if (c2pa_reader == nullptr) {
throw C2paException("Failed to create reader from context");
Comment thread
tmathern marked this conversation as resolved.
}

// 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)
{
Expand All @@ -113,17 +150,59 @@ namespace c2pa
Reader::Reader(std::shared_ptr<IContextProvider> 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);
}

Reader::Reader(std::shared_ptr<IContextProvider> 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<IContextProvider> context,
const std::string& format,
std::istream& image_stream,
const std::vector<uint8_t>& 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<CppIStream>(stream);
Expand Down Expand Up @@ -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));
}

Expand Down
Binary file added tests/fixtures/dash1.m4s
Binary file not shown.
Binary file added tests/fixtures/dashinit.mp4
Binary file not shown.
124 changes: 124 additions & 0 deletions tests/reader.test.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
#include <nlohmann/json.hpp>
#include <filesystem>
#include <fstream>
#include <sstream>

#include "include/test_utils.hpp"

Expand Down Expand Up @@ -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<c2pa::Context>();

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<c2pa::Context>();
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<uint8_t> manifest; // external JUMBF, to pass as manifest_jumbf
std::vector<uint8_t> 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<uint8_t>(manifest_bytes.begin(), manifest_bytes.end()),
std::vector<uint8_t>(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<std::istream> open_asset_stream(const std::vector<uint8_t>& bytes) {
return std::make_unique<std::istringstream>(
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");
Comment thread
tmathern marked this conversation as resolved.
auto img = open_asset_stream(sc.asset_bytes);
auto ctx = std::make_shared<c2pa::Context>();
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::Context>();
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::Context>();
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<c2pa::Context>();

// 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());
}
Loading