diff --git a/docs/usage.md b/docs/usage.md index 57b72cff..7f88a504 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -18,9 +18,29 @@ Use the `Reader` constructor to read C2PA data from a stream. This constructor e The parameters are: - `context` - A `Context` (or any `IContextProvider`) that configures SDK behavior. See [Configuring the SDK with Context and Settings](context-settings.md). -- `` - A MIME string format for the stream; must be one of the [supported file formats](supported-formats.md). +- `` - A MIME type or file extension for the stream, for example `image/jpeg` or `jpg`; see the [supported file formats](https://github.com/contentauth/c2pa-rs/blob/main/docs/supported-formats.md). Pass an empty string to take the format from the stream's leading bytes instead. - `` - An open readable iostream. +Passing an empty string (or use a format-less overload of a `Reader`) asks the native core library to guess the format (the read will still fail if the format can't be guessed or is unsupported): + +```cpp +c2pa::Context context; +std::ifstream ifs("asset.bin", std::ios::binary); +auto reader = c2pa::Reader(std::make_shared(), ifs); +``` + +The used stream must support seeking, since it is rewound after inspection. A file path with no extension is read the same way. + +Prefer passing the format when you know it. When you do pass one, it is reconciled against the container detected in the leading bytes: + +- Both identify the same container: your format is used, keeping the more specific spelling (`dng` stays `dng` rather than widening to `image/tiff`). +- They identify different containers: the detected container wins. +- Detection finds no container: the format is used as given. + +If detection detects no container and no format was supplied, the read fails with an unsupported-type error. Note that the format is a hint, not a constraint: the library does not reject a format merely because it is absent from `Reader::supported_mime_types()`, and an unrecognized format on recognizable bytes still reads. + +`Builder` always requires an explicit format, because the container type decides how the asset is written and hashed. Signing with a blank format, or to a destination path with no extension, throws. + For example: ```cpp diff --git a/include/c2pa.hpp b/include/c2pa.hpp index 02e06076..ec0bcea6 100644 --- a/include/c2pa.hpp +++ b/include/c2pa.hpp @@ -741,7 +741,7 @@ namespace c2pa class C2PA_CPP_API Reader { private: - C2paReader *c2pa_reader; + C2paReader *c2pa_reader = nullptr; std::unique_ptr owned_stream; // Owns file stream when created from path std::unique_ptr cpp_stream; // Wraps stream for C API; destroyed before owned_stream std::shared_ptr context_ref; @@ -795,17 +795,37 @@ namespace c2pa /// @details The Reader retains a shared reference to the context, keeping it /// alive for the lifetime of the Reader. /// @param context Shared context provider. - /// @param format The mime format of the stream. - /// @param stream The input stream to read from. - /// @throws C2paException if context is null or context->is_valid() returns false. + /// @param format The mime type or file extension of the stream. Treated as a hint and + /// reconciled against the container detected in the leading bytes: the same + /// container keeps @p format (so "dng" is not widened to "image/tiff"), a + /// different one wins over @p format, and if nothing is detected @p format is + /// used as given. Pass an empty string to rely on detection alone. A format + /// absent from supported_mime_types() is not rejected here. + /// @param stream The input stream to read from. Must support seeking; it is rewound + /// before inspection. + /// @throws C2paException if context is null or context->is_valid() returns false, or if + /// no container is detected and @p format does not name a supported one. Reader(std::shared_ptr context, const std::string &format, std::istream &stream); + /// @brief Create a Reader from a shared context and stream, guessing the format from content. + /// @details Equivalent to passing an empty format. Detection is best effort; prefer the + /// overload taking a format when the format is known. + /// The stream is rewound before inspection, so it must support seeking. + /// @param context Shared context provider. + /// @param stream The input stream to read from. Must support seeking. + /// @throws C2paException if context is null, context->is_valid() returns false, or the + /// container type cannot be determined from the bytes. + Reader(std::shared_ptr context, std::istream &stream); + /// @brief Create a Reader from a shared context and file path. /// @details The Reader retains a shared reference to the context, keeping it /// alive for the lifetime of the Reader. /// @param context Shared context provider. /// @param source_path The path to the file to read. /// @throws C2paException if context is null or context->is_valid() returns false. + /// @note The extension is used as a hint only. With no extension, the container is + /// guessed from the leading bytes (best-effort). A wrong or unrecognized + /// extension is accepted, since a detected container overrides it. 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. @@ -877,10 +897,24 @@ namespace c2pa /// @brief Try to create a Reader from a shared context and stream when the asset may lack C2PA data. /// @details The Reader retains a shared reference to the context if C2PA data is found. + /// @param context Shared context provider. + /// @param format The mime type or file extension of the stream, used as a hint and + /// reconciled against the detected container. See the corresponding constructor. + /// @param stream The input stream to read from. Must support seeking. /// @return A Reader if JUMBF (c2pa/manifest) data is present; std::nullopt if none. /// @throws C2paException for errors other than a missing manifest. static std::optional from_asset(std::shared_ptr context, const std::string &format, std::istream &stream); + /// @brief Try to create a Reader from a shared context and stream, guessing the format from content. + /// @details The Reader retains a shared reference to the context if C2PA data is found. + /// Detection is best effort; prefer the overload taking a format when it is known. + /// @param context Shared context provider. + /// @param stream The input stream to read from. Must support seeking. + /// @return A Reader if JUMBF (c2pa/manifest) data is present; std::nullopt if none. + /// @throws C2paException for errors other than a missing manifest. Content whose + /// container cannot be identified raises an error rather than returning nullopt. + static std::optional from_asset(std::shared_ptr context, std::istream &stream); + // Non-copyable Reader(const Reader&) = delete; diff --git a/src/c2pa_builder.cpp b/src/c2pa_builder.cpp index 38ac1cb0..2b8eec73 100644 --- a/src/c2pa_builder.cpp +++ b/src/c2pa_builder.cpp @@ -222,6 +222,8 @@ namespace c2pa std::vector Builder::sign(const std::string &format, std::istream &source, std::ostream &dest, Signer &signer) { + const std::string normalized_format = detail::normalize_format(format); + // Caller's source/dest streams must outlive this call // Stream wrappers are stack locals that wrap the caller's streams CppIStream c_source(source); @@ -229,12 +231,14 @@ namespace c2pa const unsigned char *c2pa_manifest_bytes = nullptr; // c2pa_builder_sign() uses streams synchronously and completes before returning - auto result = c2pa_builder_sign(builder, format.c_str(), c_source.c_stream, c_dest.c_stream, signer.c2pa_signer(), &c2pa_manifest_bytes); + auto result = c2pa_builder_sign(builder, normalized_format.c_str(), c_source.c_stream, c_dest.c_stream, signer.c2pa_signer(), &c2pa_manifest_bytes); return detail::to_byte_vector(c2pa_manifest_bytes, result); } std::vector Builder::sign(const std::string &format, std::istream &source, std::iostream &dest, Signer &signer) { + const std::string normalized_format = detail::normalize_format(format); + // Caller's source/dest streams must outlive this call // Stream wrappers are stack locals that wrap the caller's streams CppIStream c_source(source); @@ -242,7 +246,7 @@ namespace c2pa const unsigned char *c2pa_manifest_bytes = nullptr; // c2pa_builder_sign() uses streams synchronously and completes before returning - auto result = c2pa_builder_sign(builder, format.c_str(), c_source.c_stream, c_dest.c_stream, signer.c2pa_signer(), &c2pa_manifest_bytes); + auto result = c2pa_builder_sign(builder, normalized_format.c_str(), c_source.c_stream, c_dest.c_stream, signer.c2pa_signer(), &c2pa_manifest_bytes); return detail::to_byte_vector(c2pa_manifest_bytes, result); } @@ -254,6 +258,9 @@ namespace c2pa /// @throws C2pa::C2paException for errors encountered by the C2PA library. std::vector Builder::sign(const std::filesystem::path &source_path, const std::filesystem::path &dest_path, Signer &signer) { + const std::string format = + detail::resolve_format(detail::extract_file_extension(dest_path)); + auto source = detail::open_file_binary(source_path); // Ensure the destination directory exists auto dest_dir = dest_path.parent_path(); @@ -270,23 +277,27 @@ namespace c2pa { throw std::runtime_error("Failed to open destination file: " + dest_path.string()); } - auto format = detail::extract_file_extension(dest_path); auto result = sign(format, *source, dest, signer); return result; } std::vector Builder::sign(const std::string &format, std::istream &source, std::iostream &dest) { + const std::string normalized_format = detail::normalize_format(format); + CppIStream c_source(source); CppIOStream c_dest(dest); const unsigned char *c2pa_manifest_bytes = nullptr; - auto result = c2pa_builder_sign_context(builder, format.c_str(), c_source.c_stream, c_dest.c_stream, &c2pa_manifest_bytes); + auto result = c2pa_builder_sign_context(builder, normalized_format.c_str(), c_source.c_stream, c_dest.c_stream, &c2pa_manifest_bytes); return detail::to_byte_vector(c2pa_manifest_bytes, result); } std::vector Builder::sign(const std::filesystem::path &source_path, const std::filesystem::path &dest_path) { + const std::string format = + detail::resolve_format(detail::extract_file_extension(dest_path)); + auto source = detail::open_file_binary(source_path); auto dest_dir = dest_path.parent_path(); if (!std::filesystem::exists(dest_dir)) @@ -302,7 +313,6 @@ namespace c2pa { throw std::runtime_error("Failed to open destination file: " + dest_path.string()); } - auto format = detail::extract_file_extension(dest_path); auto result = sign(format, *source, dest); return result; } @@ -386,30 +396,36 @@ namespace c2pa std::vector Builder::data_hashed_placeholder(uintptr_t reserve_size, const std::string &format) { + const std::string normalized_format = detail::normalize_format(format); + const unsigned char *c2pa_manifest_bytes = nullptr; - auto result = c2pa_builder_data_hashed_placeholder(builder, reserve_size, format.c_str(), &c2pa_manifest_bytes); + auto result = c2pa_builder_data_hashed_placeholder(builder, reserve_size, normalized_format.c_str(), &c2pa_manifest_bytes); return detail::to_byte_vector(c2pa_manifest_bytes, result); } std::vector Builder::sign_data_hashed_embeddable(Signer &signer, const std::string &data_hash, const std::string &format, std::istream *asset) { + const std::string normalized_format = detail::normalize_format(format); + int64_t result; const unsigned char *c2pa_manifest_bytes = nullptr; if (asset) { CppIStream c_asset(*asset); - result = c2pa_builder_sign_data_hashed_embeddable(builder, signer.c2pa_signer(), data_hash.c_str(), format.c_str(), c_asset.c_stream, &c2pa_manifest_bytes); + result = c2pa_builder_sign_data_hashed_embeddable(builder, signer.c2pa_signer(), data_hash.c_str(), normalized_format.c_str(), c_asset.c_stream, &c2pa_manifest_bytes); } else { - result = c2pa_builder_sign_data_hashed_embeddable(builder, signer.c2pa_signer(), data_hash.c_str(), format.c_str(), nullptr, &c2pa_manifest_bytes); + result = c2pa_builder_sign_data_hashed_embeddable(builder, signer.c2pa_signer(), data_hash.c_str(), normalized_format.c_str(), nullptr, &c2pa_manifest_bytes); } return detail::to_byte_vector(c2pa_manifest_bytes, result); } bool Builder::needs_placeholder(const std::string &format) { - int result = c2pa_builder_needs_placeholder(builder, format.c_str()); + const std::string resolved_format = detail::resolve_format(format); + + int result = c2pa_builder_needs_placeholder(builder, resolved_format.c_str()); if (result < 0) { throw C2paException(); @@ -419,8 +435,11 @@ namespace c2pa std::vector Builder::placeholder(const std::string &format) { + // The format selects the hash assertion written into the builder. + const std::string normalized_format = detail::normalize_format(format); + const unsigned char *c2pa_manifest_bytes = nullptr; - auto result = c2pa_builder_placeholder(builder, format.c_str(), &c2pa_manifest_bytes); + auto result = c2pa_builder_placeholder(builder, normalized_format.c_str(), &c2pa_manifest_bytes); return detail::to_byte_vector(c2pa_manifest_bytes, result); } @@ -443,8 +462,11 @@ namespace c2pa void Builder::update_hash_from_stream(const std::string &format, std::istream &stream) { + // The format decides which hash assertion is updated. + const std::string resolved_format = detail::resolve_format(format); + CppIStream c_stream(stream); - int result = c2pa_builder_update_hash_from_stream(builder, format.c_str(), c_stream.c_stream); + int result = c2pa_builder_update_hash_from_stream(builder, resolved_format.c_str(), c_stream.c_stream); if (result < 0) { throw C2paException(); @@ -453,15 +475,19 @@ namespace c2pa std::vector Builder::sign_embeddable(const std::string &format) { + const std::string normalized_format = detail::normalize_format(format); + const unsigned char *c2pa_manifest_bytes = nullptr; - auto result = c2pa_builder_sign_embeddable(builder, format.c_str(), &c2pa_manifest_bytes); + auto result = c2pa_builder_sign_embeddable(builder, normalized_format.c_str(), &c2pa_manifest_bytes); return detail::to_byte_vector(c2pa_manifest_bytes, result); } std::vector Builder::format_embeddable(const std::string &format, std::vector &data) { + const std::string normalized_format = detail::normalize_format(format); + const unsigned char *c2pa_manifest_bytes = nullptr; - auto result = c2pa_format_embeddable(format.c_str(), data.data(), data.size(), &c2pa_manifest_bytes); + auto result = c2pa_format_embeddable(normalized_format.c_str(), data.data(), data.size(), &c2pa_manifest_bytes); return detail::to_byte_vector(c2pa_manifest_bytes, result); } diff --git a/src/c2pa_context.cpp b/src/c2pa_context.cpp index 53374953..c763520a 100644 --- a/src/c2pa_context.cpp +++ b/src/c2pa_context.cpp @@ -183,8 +183,7 @@ namespace c2pa if (!raw) { throw C2paException("Signer is not valid"); } - // On error the signer may not have been consumed by the C API, - // surface an error + if (c2pa_context_builder_set_signer(context_builder, raw) != 0) { throw C2paException(); } @@ -204,7 +203,6 @@ namespace c2pa throw C2paException("Failed to create HTTP resolver"); } if (c2pa_context_builder_set_http_resolver(context_builder, resolver) != 0) { - c2pa_free(resolver); throw C2paException(); } return *this; diff --git a/src/c2pa_internal.hpp b/src/c2pa_internal.hpp index 1cf16e06..ee50d324 100644 --- a/src/c2pa_internal.hpp +++ b/src/c2pa_internal.hpp @@ -18,10 +18,13 @@ #ifndef C2PA_INTERNAL_HPP #define C2PA_INTERNAL_HPP +#include +#include #include #include #include #include +#include #include #include @@ -242,6 +245,43 @@ inline std::string extract_file_extension(const std::filesystem::path &path) noe return ext.empty() ? "" : ext.substr(1); } +/// @brief Format asking the library to determine the container type from content. +/// @details The C API rejects a null format, so absent must be spelled empty. +inline constexpr const char *kDetectFormatFromContent = ""; + +/// @brief ASCII whitespace ignored around a format. +/// @details Formats are MIME types or extensions, so non-ASCII spaces are +/// deliberately excluded. +inline constexpr const char *kFormatWhitespace = " \t\n\r\f\v"; + +/// @brief Trim and lowercase a caller-supplied format; empty when @p format is blank. +/// @details Empty means detection from content, so this alone is enough wherever +/// a blank format is allowed to request that. +[[nodiscard]] inline std::string normalize_format(const std::string &format) { + const auto first = format.find_first_not_of(kFormatWhitespace); + if (first == std::string::npos) { + return {}; // Empty or all whitespace. + } + const auto last = format.find_last_not_of(kFormatWhitespace); + std::string normalized(std::string_view(format).substr(first, last - first + 1)); + std::transform(normalized.begin(), normalized.end(), normalized.begin(), + [](unsigned char c) { return static_cast(std::tolower(c)); }); + return normalized; +} + +/// @brief Resolve a format that must be stated explicitly. +/// These paths never infer the container type from content, +/// so a blank format is rejected. +/// @return @p format trimmed and lowercased. +/// @throws C2paException if @p format is blank. +[[nodiscard]] inline std::string resolve_format(const std::string &format) { + std::string normalized = normalize_format(format); + if (normalized.empty()) { + throw C2paException("An explicit format is required."); + } + return normalized; +} + /// @brief Convert C string result to C++ string with cleanup /// @param c_result Raw C string from C API /// @return C++ string (throws if null) diff --git a/src/c2pa_reader.cpp b/src/c2pa_reader.cpp index 0564fc63..3faff774 100644 --- a/src/c2pa_reader.cpp +++ b/src/c2pa_reader.cpp @@ -46,6 +46,8 @@ namespace c2pa throw C2paException("Invalid Context provider IContextProvider"); } + const std::string normalized_format = detail::normalize_format(format); + // Create the stream wrapper before the reader handle cpp_stream = std::make_unique(stream); @@ -56,7 +58,7 @@ namespace c2pa // Update reader with stream. // Note: c2pa_reader_with_stream consumes the reader pointer. - C2paReader* updated = c2pa_reader_with_stream(c2pa_reader, format.c_str(), cpp_stream->c_stream); + C2paReader* updated = c2pa_reader_with_stream(c2pa_reader, normalized_format.c_str(), cpp_stream->c_stream); c2pa_reader = nullptr; if (updated == nullptr) { throw C2paException(); @@ -79,7 +81,7 @@ namespace c2pa throw std::system_error(errno, std::system_category(), "Failed to open file: " + source_path.string()); } - std::string extension = detail::extract_file_extension(source_path); + std::string extension = detail::normalize_format(detail::extract_file_extension(source_path)); // CppIStream stores reference to owned_stream, which lives as long as Reader cpp_stream = std::make_unique(*owned_stream); @@ -111,6 +113,9 @@ namespace c2pa throw C2paException("manifest_jumbf must not be empty"); } + // The format is a hint for hash verification against the supplied manifest. + const std::string normalized_format = detail::normalize_format(format); + cpp_stream = std::make_unique(image_stream); c2pa_reader = c2pa_reader_from_context(context.c_context()); @@ -121,7 +126,7 @@ namespace c2pa // 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(), + normalized_format.c_str(), cpp_stream->c_stream, manifest_jumbf.data(), manifest_jumbf.size()); @@ -157,6 +162,11 @@ namespace c2pa context_ref = std::move(context); } + Reader::Reader(std::shared_ptr context, std::istream &stream) + : Reader(std::move(context), detail::kDetectFormatFromContent, stream) + { + } + Reader::Reader(std::shared_ptr context, const std::filesystem::path &source_path) : c2pa_reader(nullptr) { @@ -184,6 +194,12 @@ namespace c2pa { ensure_initialized(); + // Validate the format before the FFI call below, + // which consumes (frees) the current reader handle. + // The core rejects a blank format, but only after taking ownership of the reader, + // leaving the Reader in an unusable state. + const std::string resolved_format = detail::resolve_format(format); + CppIStream main_wrapper(stream); CppIStream fragment_wrapper(fragment); @@ -191,7 +207,7 @@ namespace c2pa // *this is returned for chaining so reading can go through all segments. C2paReader* updated = c2pa_reader_with_fragment( c2pa_reader, - format.c_str(), + resolved_format.c_str(), main_wrapper.c_stream, fragment_wrapper.c_stream); c2pa_reader = nullptr; @@ -205,8 +221,10 @@ namespace c2pa Reader::Reader(const std::string &format, std::istream &stream) { + const std::string normalized_format = detail::normalize_format(format); + cpp_stream = std::make_unique(stream); - c2pa_reader = c2pa_reader_from_stream(format.c_str(), cpp_stream->c_stream); + c2pa_reader = c2pa_reader_from_stream(normalized_format.c_str(), cpp_stream->c_stream); if (c2pa_reader == nullptr) { throw C2paException(); @@ -221,7 +239,7 @@ namespace c2pa throw std::system_error(errno, std::system_category(), "Failed to open file: " + source_path.string()); } - std::string extension = detail::extract_file_extension(source_path); + std::string extension = detail::normalize_format(detail::extract_file_extension(source_path)); // CppIStream stores reference to owned_stream, which lives as long as Reader cpp_stream = std::make_unique(*owned_stream); @@ -321,4 +339,10 @@ namespace c2pa std::optional Reader::from_asset(std::shared_ptr context, const std::string& format, std::istream& stream) { return reader_from_asset_impl([&]() { return Reader(std::move(context), format, stream); }); } + + std::optional Reader::from_asset(std::shared_ptr context, std::istream& stream) { + return reader_from_asset_impl([&]() { + return Reader(std::move(context), detail::kDetectFormatFromContent, stream); + }); + } } // namespace c2pa diff --git a/tests/builder.test.cpp b/tests/builder.test.cpp index 18f748d0..8ea51353 100644 --- a/tests/builder.test.cpp +++ b/tests/builder.test.cpp @@ -82,6 +82,12 @@ class BuilderTest : public ::testing::Test { temp_dirs.clear(); } + // Helper: Creates a Builder with a test manifest. + c2pa::Builder make_builder() { + auto manifest = c2pa_test::read_text_file(c2pa_test::get_fixture_path("training.json")); + return c2pa::Builder(manifest); + } + // Helper: Creates an ingredient archive (.c2pa) from a single ingredient. void create_ingredient_archive( const fs::path& archive_path, @@ -7506,3 +7512,136 @@ TEST_F(BuilderTest, ArchiveToFstreamBackedCppOStream) { EXPECT_GT(std::filesystem::file_size(archive_path), 0u); } + +// Signing always needs the container type, which decides how it hashes. +class BuilderBlankFormatTest : public BuilderTest, public ::testing::WithParamInterface {}; + +INSTANTIATE_TEST_SUITE_P(BuilderBlankFormatSignTests, BuilderBlankFormatTest, + ::testing::ValuesIn(c2pa_test::kBlankFormats)); + +TEST_P(BuilderBlankFormatTest, SignRejectsBlankFormat) { + auto signer = c2pa_test::create_test_signer(); + auto builder = make_builder(); + + std::ifstream source(c2pa_test::get_fixture_path("A.jpg"), std::ios::binary); + ASSERT_TRUE(source.is_open()); + std::stringstream dest(std::ios::in | std::ios::out | std::ios::binary); + + EXPECT_THROW({ builder.sign(GetParam(), source, dest, signer); }, c2pa::C2paException); +} + +TEST_F(BuilderTest, SignDoesNotTruncateDestinationWhenFormatIsRejected) { + // The destination opens with trunc, so the format is checked before it. + auto signer = c2pa_test::create_test_signer(); + auto builder = make_builder(); + + fs::path dest = get_temp_path("existing-destination-noext"); + const std::string original = "existing contents that must survive"; + { + std::ofstream f(dest, std::ios::binary | std::ios::trunc); + ASSERT_TRUE(f.is_open()); + f << original; + } + + EXPECT_THROW( + { builder.sign(c2pa_test::get_fixture_path("A.jpg"), dest, signer); }, + c2pa::C2paException); + + ASSERT_TRUE(fs::exists(dest)); + std::ifstream check(dest, std::ios::binary); + std::stringstream after; + after << check.rdbuf(); + EXPECT_EQ(after.str(), original); +} + +TEST_F(BuilderTest, SignWithUnsupportedFormatThrows) { + auto signer = c2pa_test::create_test_signer(); + auto builder = make_builder(); + + std::ifstream source(c2pa_test::get_fixture_path("A.jpg"), std::ios::binary); + ASSERT_TRUE(source.is_open()); + std::stringstream dest(std::ios::in | std::ios::out | std::ios::binary); + + EXPECT_THROW({ builder.sign("application/zip", source, dest, signer); }, + c2pa::C2paException); +} + +TEST_F(BuilderTest, AddIngredientAcceptsUnknownFormat) { + auto builder = make_builder(); + const std::string json = R"({"title": "A.jpg", "relationship": "componentOf"})"; + + for (const std::string& format : {std::string("application/zip"), std::string("zzz")}) { + std::ifstream src(c2pa_test::get_fixture_path("A.jpg"), std::ios::binary); + ASSERT_TRUE(src.is_open()); + EXPECT_NO_THROW({ builder.add_ingredient(json, format, src); }) << "format: " << format; + } +} + +TEST_F(BuilderTest, AddIngredientFromExtensionlessPath) { + auto builder = make_builder(); + fs::path ingredient = get_temp_path("ingredient-noext"); + { + std::ifstream src(c2pa_test::get_fixture_path("A.jpg"), std::ios::binary); + ASSERT_TRUE(src.is_open()); + std::ofstream out(ingredient, std::ios::binary | std::ios::trunc); + ASSERT_TRUE(out.is_open()); + out << src.rdbuf(); + } + + EXPECT_NO_THROW( + { builder.add_ingredient(R"({"title": "A.jpg", "relationship": "componentOf"})", ingredient); }); +} + +TEST_F(BuilderTest, SignProducesEquivalentManifestForEverySpelling) { + auto signer = c2pa_test::create_test_signer(); + + for (const char* format : + {"image/jpeg", "IMAGE/JPEG", "Image/Jpeg", "jpg", "JPG"}) { + auto builder = make_builder(); + + std::ifstream source(c2pa_test::get_fixture_path("A.jpg"), std::ios::binary); + ASSERT_TRUE(source.is_open()); + std::stringstream dest(std::ios::in | std::ios::out | std::ios::binary); + ASSERT_NO_THROW({ builder.sign(format, source, dest, signer); }) << "format: " << format; + + dest.seekg(0); + auto ctx = std::make_shared(); + c2pa::Reader reader(ctx, "image/jpeg", dest); + + auto store = json::parse(reader.json()); + const auto& active = store["manifests"][store["active_manifest"].get()]; + ASSERT_TRUE(active.contains("thumbnail")) + << "signing with " << format << " dropped the claim thumbnail"; + EXPECT_EQ(active["thumbnail"]["format"], "image/jpeg"); + + // The reference must resolve to a real JPEG, not just be present. + std::stringstream thumbnail(std::ios::in | std::ios::out | std::ios::binary); + reader.get_resource(active["thumbnail"]["identifier"].get(), thumbnail); + const std::string bytes = thumbnail.str(); + EXPECT_GT(bytes.size(), 1000u) + << "signing with " << format << " produced an empty claim thumbnail"; + ASSERT_GE(bytes.size(), 3u); + EXPECT_EQ(bytes.compare(0, 3, "\xff\xd8\xff"), 0) + << "signing with " << format << " produced a thumbnail that is not a JPEG"; + } +} + +TEST_F(BuilderTest, SidecarSignAcceptsUnknownFormat) { + // A sidecar manifest travels beside the asset, so the container never has + // to be parsed or rewritten and any format is signable. + auto signer = c2pa_test::create_test_signer(); + + for (const std::string& format : {std::string("application/zip"), std::string("zzz")}) { + auto builder = make_builder(); + builder.set_no_embed(); + + std::ifstream source(c2pa_test::get_fixture_path("A.jpg"), std::ios::binary); + ASSERT_TRUE(source.is_open()); + std::stringstream dest(std::ios::in | std::ios::out | std::ios::binary); + + std::vector manifest; + ASSERT_NO_THROW({ manifest = builder.sign(format, source, dest, signer); }) + << "format: " << format; + EXPECT_FALSE(manifest.empty()) << "format: " << format; + } +} diff --git a/tests/embeddable.test.cpp b/tests/embeddable.test.cpp index 67e00bd8..3205b08d 100644 --- a/tests/embeddable.test.cpp +++ b/tests/embeddable.test.cpp @@ -50,6 +50,12 @@ class EmbeddableTest : public ::testing::Test { } temp_files.clear(); } + + // Helper: Creates a Builder with a test manifest. + c2pa::Builder make_builder() { + auto manifest = c2pa_test::read_text_file(c2pa_test::get_fixture_path("training.json")); + return c2pa::Builder(manifest); + } }; // e2e workflow with A.jpg (has no existing C2PA) @@ -646,3 +652,21 @@ TEST_F(EmbeddableTest, DirectEmbeddingWithFormat) { EXPECT_EQ(jpeg_manifest.size(), placeholder.size()) << "Direct JPEG format output matches placeholder size"; } + +class EmbeddableBlankFormatTest : public EmbeddableTest, public ::testing::WithParamInterface {}; +// needs_placeholder() and update_hash_from_stream() are the only embeddable steps when the core is silent on a blank format. + +INSTANTIATE_TEST_SUITE_P(EmbeddableBlankFormats, EmbeddableBlankFormatTest, + ::testing::ValuesIn(c2pa_test::kBlankFormats)); + +TEST_P(EmbeddableBlankFormatTest, NeedsPlaceholderRejectsBlankFormat) { + auto builder = make_builder(); + EXPECT_THROW({ builder.needs_placeholder(GetParam()); }, c2pa::C2paException); +} + +TEST_F(EmbeddableTest, UpdateHashFromStreamRejectsBlankFormat) { + auto builder = make_builder(); + std::ifstream asset(c2pa_test::get_fixture_path("A.jpg"), std::ios::binary); + ASSERT_TRUE(asset.is_open()); + EXPECT_THROW({ builder.update_hash_from_stream("", asset); }, c2pa::C2paException); +} diff --git a/tests/fixtures/C2.DNG b/tests/fixtures/C2.DNG new file mode 100644 index 00000000..361a7a0f Binary files /dev/null and b/tests/fixtures/C2.DNG differ diff --git a/tests/include/test_utils.hpp b/tests/include/test_utils.hpp index 91292c10..16089811 100644 --- a/tests/include/test_utils.hpp +++ b/tests/include/test_utils.hpp @@ -20,6 +20,7 @@ #include #include #include +#include #include "c2pa.hpp" @@ -27,6 +28,10 @@ namespace c2pa_test { namespace fs = std::filesystem; +/// Formats that count as absent: empty, or only ASCII whitespace. +inline const std::vector kBlankFormats = { + "", " ", " ", "\t", "\n", "\t\n ", "\r\n", "\v\f"}; + /// @brief Read a text file into a string /// @param path Path to the file to read /// @return Contents of the file as a string diff --git a/tests/reader.test.cpp b/tests/reader.test.cpp index e3568f44..26cc459d 100644 --- a/tests/reader.test.cpp +++ b/tests/reader.test.cpp @@ -40,6 +40,27 @@ class ReaderTest : public ::testing::Test { return temp_path; } + // Copy a fixture to a temp path with any (or no) extension. + fs::path copy_fixture_to(const std::string& fixture, const std::string& temp_name) { + fs::path dest = get_temp_path(temp_name); + std::ifstream src(c2pa_test::get_fixture_path(fixture), std::ios::binary); + EXPECT_TRUE(src.is_open()) << "Failed to open fixture: " << fixture; + std::ofstream out(dest, std::ios::binary | std::ios::trunc); + EXPECT_TRUE(out.is_open()) << "Failed to create temp file: " << dest; + out << src.rdbuf(); + out.close(); + return dest; + } + + // Read a whole fixture into a string, for use with std::istringstream. + static std::string fixture_bytes(const std::string& fixture) { + std::ifstream f(c2pa_test::get_fixture_path(fixture), std::ios::binary); + EXPECT_TRUE(f.is_open()) << "Failed to open fixture: " << fixture; + std::ostringstream ss; + ss << f.rdbuf(); + return ss.str(); + } + void TearDown() override { if (cleanup_temp_files) { for (const auto& path : temp_files) { @@ -81,7 +102,16 @@ INSTANTIATE_TEST_SUITE_P(ReaderStreamWithManifestTests, StreamWithManifestTests, // (filename, type or mimetype, expected_content = Title from the manifest) std::make_tuple("video1.mp4", "video/mp4", "My Title"), std::make_tuple("sample1_signed.wav", "wav", "sample1_signed.wav"), - std::make_tuple("C.dng", "DNG", "C.jpg"))); + std::make_tuple("C.dng", "DNG", "C.jpg"), + // The supported list holds extensions as well as + // MIME types, and matching ignores case. + std::make_tuple("C.jpg", "jpg", "C.jpg"), + std::make_tuple("C.jpg", "JPG", "C.jpg"), + std::make_tuple("C.jpg", "image/jpeg", "C.jpg"), + std::make_tuple("C.jpg", "Image/JPEG", "C.jpg"), + std::make_tuple("C2.DNG", "dng", "C.jpg"), + std::make_tuple("C2.DNG", "image/dng", "C.jpg"), + std::make_tuple("C2.DNG", "image/x-adobe-dng", "C.jpg"))); TEST_P(StreamWithManifestTests, StreamWithManifest) { auto filename = std::get<0>(GetParam()); @@ -846,3 +876,308 @@ TEST_F(ReaderSidecarTest, SidecarReaderResetsStreamPosition) { c2pa::Reader r(ctx, "image/jpeg", *img, sc.manifest); EXPECT_FALSE(r.json().empty()); } + +// A blank format should trigger content guessing from the core lib. +class BlankFormatDetectionTest + : public ReaderTest, + public ::testing::WithParamInterface> {}; + +INSTANTIATE_TEST_SUITE_P( + ReaderBlankFormatDetectionTest, BlankFormatDetectionTest, + ::testing::Values( + // (format, fixture, expected content in the manifest store JSON) + std::make_tuple("", "C.jpg", "C.jpg"), + std::make_tuple("", "video1.mp4", "My Title"), + std::make_tuple("", "sample1_signed.wav", "sample1_signed.wav"), + std::make_tuple("", "C.dng", "C.jpg"), + std::make_tuple(" ", "C.jpg", "C.jpg"), + std::make_tuple(" ", "C.jpg", "C.jpg"), + std::make_tuple("\t", "C.jpg", "C.jpg"), + std::make_tuple("\n", "C.jpg", "C.jpg"), + std::make_tuple("\t\n ", "C.jpg", "C.jpg"), + std::make_tuple("\r\n", "C.jpg", "C.jpg"), + std::make_tuple("\v\f", "C.jpg", "C.jpg"))); + +TEST_P(BlankFormatDetectionTest, ResolvesFormatOnSharedContext) { + const auto& [format, filename, expected_content] = GetParam(); + std::string bytes = fixture_bytes(filename); + std::istringstream stream(bytes, std::ios::binary); + auto ctx = std::make_shared(); + + c2pa::Reader reader(ctx, format, stream); + EXPECT_NE(reader.json().find(expected_content), std::string::npos); +} + +TEST_F(ReaderTest, ExtensionlessFilePathReadsManifest) { + fs::path noext = copy_fixture_to("C.jpg", "detect-noext"); + ASSERT_TRUE(fs::exists(noext)); + ASSERT_TRUE(noext.extension().empty()) << "temp path must have no extension"; + + c2pa::Reader reader(noext); + EXPECT_NE(reader.json().find("C.jpg"), std::string::npos); +} + +TEST_F(ReaderTest, UnlistedExtensionDefersToContent) { + // The extension describes the filename; the bytes decide the format. + fs::path odd = copy_fixture_to("C.jpg", "detect-unlisted-ext.zzz"); + c2pa::Reader reader(odd); + EXPECT_NE(reader.json().find("C.jpg"), std::string::npos); +} + +TEST_F(ReaderTest, WrongButSupportedExtensionDefersToContent) { + fs::path mislabeled = copy_fixture_to("C.jpg", "detect-mislabeled.png"); + c2pa::Reader reader(mislabeled); + EXPECT_NE(reader.json().find("C.jpg"), std::string::npos); +} + +TEST_F(ReaderTest, EmptyFormatMatchesExplicitFormatJson) { + std::string bytes = fixture_bytes("C.jpg"); + + std::istringstream detected_stream(bytes, std::ios::binary); + c2pa::Reader detected("", detected_stream); + + std::istringstream explicit_stream(bytes, std::ios::binary); + c2pa::Reader explicitly("image/jpeg", explicit_stream); + + auto a = json::parse(detected.json()); + auto b = json::parse(explicitly.json()); + EXPECT_EQ(a["active_manifest"], b["active_manifest"]); + EXPECT_EQ(a["manifests"].size(), b["manifests"].size()); + EXPECT_EQ(detected.is_embedded(), explicitly.is_embedded()); +} + +TEST_F(ReaderTest, DngExtensionWithJpegContentIsCorrected) { + fs::path mislabeled = copy_fixture_to("C.jpg", "detect-mislabeled.dng"); + c2pa::Reader reader(mislabeled); + EXPECT_NE(reader.json().find("C.jpg"), std::string::npos); +} + +TEST_F(ReaderTest, WhitespaceFormatFromAssetReturnsReader) { + std::string bytes = fixture_bytes("C.jpg"); + std::istringstream stream(bytes, std::ios::binary); + auto ctx = std::make_shared(); + + auto reader = c2pa::Reader::from_asset(ctx, " ", stream); + ASSERT_TRUE(reader.has_value()); + EXPECT_NE(reader->json().find("C.jpg"), std::string::npos); +} + +TEST_F(ReaderTest, PaddedFormatIsTrimmed) { + // Padding is never meaningful in a format, so it is trimmed before the + // format reaches the library, which does no trimming of its own. + std::string bytes = fixture_bytes("C.jpg"); + std::istringstream stream(bytes, std::ios::binary); + std::istringstream clean(bytes, std::ios::binary); + + c2pa::Reader padded(std::make_shared(), " \t image/jpeg \n ", stream); + c2pa::Reader exact(std::make_shared(), "image/jpeg", clean); + EXPECT_EQ(padded.json(), exact.json()); +} + +TEST_F(ReaderTest, PaddedUnknownFormatStillDefersToContent) { + // Trimming does not make an unknown format known; content detection is + // still what resolves the container. + std::string bytes = fixture_bytes("C.jpg"); + std::istringstream stream(bytes, std::ios::binary); + std::istringstream clean(bytes, std::ios::binary); + + c2pa::Reader padded(std::make_shared(), " application/zip ", stream); + c2pa::Reader exact(std::make_shared(), "image/jpeg", clean); + EXPECT_EQ(padded.json(), exact.json()); +} + +TEST_F(ReaderTest, UnsupportedFormatOnStreamDefersToContent) { + // The library reconciles the hint against the container it detects. + std::string bytes = fixture_bytes("C.jpg"); + std::istringstream stream(bytes, std::ios::binary); + std::istringstream clean(bytes, std::ios::binary); + + c2pa::Reader mismatched(std::make_shared(), "application/zip", stream); + c2pa::Reader exact(std::make_shared(), "image/jpeg", clean); + EXPECT_EQ(mismatched.json(), exact.json()); +} + +TEST_F(ReaderTest, NoFormatOverloadDetectsFromContent) { + std::ifstream file(c2pa_test::get_fixture_path("C.jpg"), std::ios::binary); + ASSERT_TRUE(file.is_open()); + auto ctx = std::make_shared(); + + c2pa::Reader reader(ctx, file); + EXPECT_NE(reader.json().find("C.jpg"), std::string::npos); +} + +TEST_F(ReaderTest, NoFormatOverloadMatchesEmptyFormat) { + std::string bytes = fixture_bytes("C.jpg"); + auto ctx = std::make_shared(); + + std::istringstream overload_stream(bytes, std::ios::binary); + c2pa::Reader from_overload(ctx, overload_stream); + + std::istringstream empty_stream(bytes, std::ios::binary); + c2pa::Reader from_empty(ctx, "", empty_stream); + + auto a = json::parse(from_overload.json()); + auto b = json::parse(from_empty.json()); + EXPECT_EQ(a["active_manifest"], b["active_manifest"]); + EXPECT_EQ(a["manifests"].size(), b["manifests"].size()); +} + +TEST_F(ReaderTest, NoFormatOverloadResolvesUnambiguously) { + // Compile-level guard that each call selects exactly one overload. + auto ctx = std::make_shared(); + fs::path asset = c2pa_test::get_fixture_path("C.jpg"); + + std::ifstream ifs(asset, std::ios::binary); + ASSERT_TRUE(ifs.is_open()); + EXPECT_NO_THROW({ c2pa::Reader reader(ctx, ifs); }); + + std::string bytes = fixture_bytes("C.jpg"); + std::istringstream iss(bytes, std::ios::binary); + EXPECT_NO_THROW({ c2pa::Reader reader(ctx, iss); }); + + std::istringstream base_source(bytes, std::ios::binary); + std::istream& base_ref = base_source; + EXPECT_NO_THROW({ c2pa::Reader reader(ctx, base_ref); }); + + EXPECT_NO_THROW({ c2pa::Reader reader(ctx, asset); }); +} + +TEST_F(ReaderTest, FromAssetNoFormatOverloadReturnsNullopt) { + std::string bytes = fixture_bytes("A.jpg"); + std::istringstream stream(bytes, std::ios::binary); + auto ctx = std::make_shared(); + + auto reader = c2pa::Reader::from_asset(ctx, stream); + EXPECT_FALSE(reader.has_value()); +} + +TEST_F(ReaderTest, SubMagicLengthStreamWithEmptyFormatThrows) { + // Detection needs two bytes, so one leaves nothing to identify. + std::string tiny("\xff", 1); + std::istringstream stream(tiny, std::ios::binary); + EXPECT_THROW({ c2pa::Reader reader("", stream); }, c2pa::C2paException); +} + +TEST_F(ReaderTest, EmptyStreamWithEmptyFormatThrows) { + std::istringstream stream(std::string{}, std::ios::binary); + EXPECT_THROW({ c2pa::Reader reader("", stream); }, c2pa::C2paException); +} + +TEST_F(ReaderTest, CorruptedJpegBodyWithEmptyFormatThrows) { + // The JPEG signature survives, so detection succeeds and parsing fails after. + std::string bytes = fixture_bytes("C.jpg"); + ASSERT_GT(bytes.size(), 64u); + for (size_t i = 16; i < bytes.size(); ++i) { + bytes[i] = static_cast(bytes[i] ^ 0x5a); + } + std::istringstream stream(bytes, std::ios::binary); + EXPECT_THROW({ c2pa::Reader reader("", stream); }, c2pa::C2paException); +} + +TEST_F(ReaderTest, ReaderSidecarBlankFormatReadsValidManifest) { + // A sidecar (external) manifest is parsed from the supplied bytes, not from the container. + // The core accepts a blank format here too. + auto signer = c2pa_test::create_test_signer(); + auto manifest = c2pa_test::read_text_file(c2pa_test::get_fixture_path("training.json")); + auto ctx = std::make_shared(); + + // Produce a valid sidecar manifest over A.jpg (no_embed returns the JUMBF bytes). + c2pa::Builder builder(manifest); + builder.set_no_embed(); + std::ifstream src(c2pa_test::get_fixture_path("A.jpg"), std::ios::binary); + ASSERT_TRUE(src.is_open()); + std::stringstream signed_dest(std::ios::in | std::ios::out | std::ios::binary); + std::vector manifest_bytes = builder.sign("image/jpeg", src, signed_dest, signer); + ASSERT_FALSE(manifest_bytes.empty()); + // Use those re-read bytes directly + std::vector jumbf(manifest_bytes.begin(), manifest_bytes.end()); + + const std::string asset = fixture_bytes("A.jpg"); + + std::string named_json; + { + std::istringstream stream(asset, std::ios::binary); + c2pa::Reader reader(ctx, "image/jpeg", stream, jumbf); + named_json = reader.json(); + EXPECT_FALSE(named_json.empty()); + } + + std::string blank_json; + { + std::istringstream stream(asset, std::ios::binary); + c2pa::Reader reader(ctx, "", stream, jumbf); // blank format accepted + blank_json = reader.json(); + EXPECT_FALSE(blank_json.empty()); + } + + // Same manifest and asset: naming the format or not is indistinguishable. + EXPECT_EQ(json::parse(named_json)["active_manifest"], + json::parse(blank_json)["active_manifest"]); +} + +TEST_F(ReaderTest, ReaderBmffSidecarBlankFormatMatchesNamed) { + // BMFF hash verification is self-describing. + auto signer = c2pa_test::create_test_signer(); + auto manifest = c2pa_test::read_text_file(c2pa_test::get_fixture_path("training.json")); + auto ctx = std::make_shared(); + + c2pa::Builder builder(manifest); + builder.set_no_embed(); + std::ifstream src(c2pa_test::get_fixture_path("video1.mp4"), std::ios::binary); + ASSERT_TRUE(src.is_open()); + std::stringstream signed_dest(std::ios::in | std::ios::out | std::ios::binary); + std::vector manifest_bytes = builder.sign("video/mp4", src, signed_dest, signer); + ASSERT_FALSE(manifest_bytes.empty()); + std::vector jumbf(manifest_bytes.begin(), manifest_bytes.end()); + + const std::string asset = fixture_bytes("video1.mp4"); + + auto read_state = [&](const std::string& fmt) { + std::istringstream stream(asset, std::ios::binary); + c2pa::Reader reader(ctx, fmt, stream, jumbf); + auto j = json::parse(reader.json()); + return std::make_pair(j.value("validation_state", std::string("")), + j.value("active_manifest", std::string(""))); + }; + + std::pair blank; + // Verify we can read both, with and without format + EXPECT_NO_THROW({ blank = read_state(""); }); + EXPECT_EQ(blank, read_state("video/mp4")); +} + +TEST_F(ReaderTest, WithFragmentEmptyFormatThrows) { + // with_fragment rejects a blank format. The core would reject it too, but only + // after c2pa_reader_with_fragment() has consumed (freed) the reader handle, which + // would leave this Reader unusable. The binding validates the format first, so a + // rejected append leaves the reader intact for later calls (asserted below). + 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); + + for (const std::string& blank : {std::string(""), std::string(" ")}) { + 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()); + EXPECT_THROW({ reader.with_fragment(blank, main_seg, fragment); }, + c2pa::C2paException); + } + + // The reader handle is untouched by the rejected calls. + EXPECT_FALSE(reader.json().empty()); +} + +TEST_F(ReaderTest, DngReadsWithExplicitAndDetectedFormat) { + auto by_path = c2pa::Reader(c2pa_test::get_fixture_path("C2.DNG")); + + std::string bytes = fixture_bytes("C2.DNG"); + std::istringstream stream(bytes, std::ios::binary); + auto detected = c2pa::Reader("", stream); + + auto a = json::parse(by_path.json()); + auto b = json::parse(detected.json()); + EXPECT_EQ(a["active_manifest"], b["active_manifest"]); + EXPECT_EQ(a["manifests"].size(), b["manifests"].size()); +}