From 2720716bf0f02eb082ab8b7976fa57e8b83b8608 Mon Sep 17 00:00:00 2001 From: tmathern <60901087+tmathern@users.noreply.github.com> Date: Wed, 16 Sep 2026 20:30:09 -0700 Subject: [PATCH 1/2] fix: chars checks --- tests/builder.test.cpp | 193 +++++++++++++++++++++++++++++++++++++++++ tests/reader.test.cpp | 65 ++++++++++++++ 2 files changed, 258 insertions(+) diff --git a/tests/builder.test.cpp b/tests/builder.test.cpp index c92075d..fd4f31b 100644 --- a/tests/builder.test.cpp +++ b/tests/builder.test.cpp @@ -5799,6 +5799,199 @@ TEST_F(BuilderTest, NonAsciiPathForReaderGetResource) EXPECT_TRUE(fs::exists(output_resource_path)); } +// Sign to destinations whose names are not ASCII, each against an ASCII control. +// A control that fails means the case failed for some reason other than the name. +// The extension stays ASCII so this isolates the name from format inference, +// which Builder::sign derives from the destination extension. +// +// Names are built from raw bytes rather than written as literals so they do not +// depend on how a compiler reads this file. MSVC only receives /utf-8 through an +// if(MSVC) guard, and the windows-11-arm CI runner has no MSVC setup step, so a +// literal would test the toolchain's source decoding instead of the library. +TEST_F(BuilderTest, SignToNonAsciiDestName) +{ + struct Case { + const char* label; + std::string name; + }; + const Case cases[] = { + {"ascii control", std::string("ascii")}, + // U+00E4: inside the BMP, representable in several Windows code pages. + {"latin-1 (U+00E4)", std::string("\xC3\xA4")}, + // U+1F525: outside the BMP, so a surrogate pair in UTF-16 and absent + // from every Windows code page. The BMP case can pass while this one + // fails, so both are needed to tell those two failures apart. + {"astral (U+1F525)", std::string("\xF0\x9F\x94\xA5")}, + // U+4E2D U+6587: inside the BMP but outside Latin-1. + {"cjk (U+4E2D U+6587)", std::string("\xE4\xB8\xAD\xE6\x96\x87")}, + }; + + auto manifest = c2pa_test::read_text_file(c2pa_test::get_fixture_path("training.json")); + for (const auto& test_case : cases) { + SCOPED_TRACE(test_case.label); + + // u8path states the encoding: building fs::path from a narrow string + // decodes using the active code page on Windows, which cannot represent + // these names. Deprecated in C++20, but this project builds as C++17. + const std::string filename = test_case.name + ".jpg"; +#ifdef _WIN32 + auto dest = get_temp_path(fs::u8path(filename)); +#else + auto dest = get_temp_path(fs::path(filename)); +#endif + + auto signer = c2pa_test::create_test_signer(); + auto builder = c2pa::Builder(manifest); + + std::vector manifest_data; + ASSERT_NO_THROW(manifest_data = builder.sign( + c2pa_test::get_fixture_path("A.jpg"), dest, signer)) + << "sign should write to a destination named " << test_case.label; + EXPECT_FALSE(manifest_data.empty()); + + // Check the name on disk still carries the bytes it was given. A + // conversion that replaces characters it cannot encode still produces a + // file, and distinct names collapse onto the same replacement, so + // fs::exists alone would pass while the name was silently corrupted. + ASSERT_TRUE(fs::exists(dest)) << "file was not created"; +#ifdef _WIN32 + const std::string actual = dest.filename().u8string(); +#else + const std::string actual = dest.filename().string(); +#endif + EXPECT_NE(actual.find(test_case.name), std::string::npos) + << "name on disk lost the non-ASCII characters it was given"; + EXPECT_EQ(actual.find('?'), std::string::npos) + << "name on disk contains a replacement character, so the conversion was lossy"; + } +} + +// Read a source whose name is not ASCII, and use one as an ingredient. +TEST_F(BuilderTest, NonAsciiSourceAndIngredient) +{ + const std::string names[] = { + std::string("ascii"), + std::string("\xC3\xA4"), // U+00E4 + std::string("\xF0\x9F\x94\xA5"), // U+1F525, outside the BMP + }; + + auto manifest = c2pa_test::read_text_file(c2pa_test::get_fixture_path("training.json")); + for (const auto& name : names) { + SCOPED_TRACE(name.c_str()); + + const std::string source_name = name + "-source.jpg"; +#ifdef _WIN32 + auto source = get_temp_path(fs::u8path(source_name)); +#else + auto source = get_temp_path(fs::path(source_name)); +#endif + fs::copy_file(c2pa_test::get_fixture_path("A.jpg"), source, + fs::copy_options::overwrite_existing); + + ASSERT_TRUE(fs::exists(source)) << "source copy was not created"; +#ifdef _WIN32 + const std::string source_on_disk = source.filename().u8string(); +#else + const std::string source_on_disk = source.filename().string(); +#endif + EXPECT_NE(source_on_disk.find(name), std::string::npos) + << "source name on disk lost the non-ASCII characters it was given"; + EXPECT_EQ(source_on_disk.find('?'), std::string::npos) + << "source name on disk contains a replacement character"; + + auto signer = c2pa_test::create_test_signer(); + auto builder = c2pa::Builder(manifest); + ASSERT_NO_THROW(builder.add_ingredient("{\"title\":\"non-ascii ingredient\"}", source)) + << "add_ingredient should open a non-ASCII source path"; + + const std::string dest_name = name + "-ingredient-out.jpg"; +#ifdef _WIN32 + auto dest = get_temp_path(fs::u8path(dest_name)); +#else + auto dest = get_temp_path(fs::path(dest_name)); +#endif + std::vector manifest_data; + ASSERT_NO_THROW(manifest_data = builder.sign(source, dest, signer)) + << "sign should read a non-ASCII source path"; + EXPECT_FALSE(manifest_data.empty()); + } +} + +// A directory named with non-ASCII characters, holding an ASCII filename. +// Windows applies the same encoding to every component, and create_directories +// runs on this one. +TEST_F(BuilderTest, NonAsciiDirectoryComponent) +{ + const std::string dir_name("\xF0\x9F\x94\xA5"); // U+1F525, outside the BMP +#ifdef _WIN32 + const fs::path dir_leaf = fs::u8path("builder-unicode-dir-" + dir_name); +#else + const fs::path dir_leaf = fs::path("builder-unicode-dir-" + dir_name); +#endif + fs::path build_dir = fs::path(__FILE__).parent_path().parent_path() / "build"; + fs::path dir = build_dir / dir_leaf; + + std::error_code ec; + fs::create_directories(dir, ec); + ASSERT_FALSE(ec) << "create_directories failed for a non-ASCII directory name"; + temp_dirs.push_back(dir); + + ASSERT_TRUE(fs::exists(dir)) << "directory was not created"; +#ifdef _WIN32 + const std::string dir_on_disk = dir.filename().u8string(); +#else + const std::string dir_on_disk = dir.filename().string(); +#endif + EXPECT_NE(dir_on_disk.find(dir_name), std::string::npos) + << "directory name on disk lost the non-ASCII characters it was given"; + EXPECT_EQ(dir_on_disk.find('?'), std::string::npos) + << "directory name on disk contains a replacement character"; + + auto dest = dir / "inside.jpg"; + auto manifest = c2pa_test::read_text_file(c2pa_test::get_fixture_path("training.json")); + auto signer = c2pa_test::create_test_signer(); + auto builder = c2pa::Builder(manifest); + + std::vector manifest_data; + ASSERT_NO_THROW(manifest_data = builder.sign( + c2pa_test::get_fixture_path("A.jpg"), dest, signer)) + << "sign should write into a non-ASCII directory"; + EXPECT_FALSE(manifest_data.empty()); + EXPECT_TRUE(fs::exists(dest)); +} + +// A non-ASCII character in the extension is not a path-encoding case: the format +// is inferred from the destination extension, so the library reports an +// unsupported type. Pinned so a change to that behavior is deliberate, and so it +// is not mistaken for an encoding defect. +TEST_F(BuilderTest, NonAsciiExtensionIsRejectedAsFormat) +{ + const std::string astral("\xF0\x9F\x94\xA5"); // U+1F525 + auto manifest = c2pa_test::read_text_file(c2pa_test::get_fixture_path("training.json")); + auto source = c2pa_test::get_fixture_path("A.jpg"); + +#ifdef _WIN32 + auto emoji_ext = get_temp_path(fs::u8path("emoji-ext." + astral)); + auto no_ext = get_temp_path(fs::u8path("no-extension-" + astral)); +#else + auto emoji_ext = get_temp_path(fs::path("emoji-ext." + astral)); + auto no_ext = get_temp_path(fs::path("no-extension-" + astral)); +#endif + + { + auto signer = c2pa_test::create_test_signer(); + auto builder = c2pa::Builder(manifest); + EXPECT_THROW(builder.sign(source, emoji_ext, signer), c2pa::C2paException) + << "a non-ASCII extension should be rejected as a format"; + } + { + auto signer = c2pa_test::create_test_signer(); + auto builder = c2pa::Builder(manifest); + EXPECT_THROW(builder.sign(source, no_ext, signer), c2pa::C2paException) + << "a path with no extension should be rejected as a missing format"; + } +} + // Sign a file using a Signer from the Builder's Context, then read back TEST_F(BuilderTest, SignFileWithContextSigner) { auto image_path = c2pa_test::get_fixture_path("A.jpg"); diff --git a/tests/reader.test.cpp b/tests/reader.test.cpp index 26cc459..948c877 100644 --- a/tests/reader.test.cpp +++ b/tests/reader.test.cpp @@ -398,6 +398,71 @@ TEST_F(ReaderTest, HasManifestUtf8PathUsingContext) { EXPECT_TRUE(reader.is_embedded()); } +// Reopen an asset whose name is not ASCII. Signing alone does not prove the name +// survives, because a failure to reopen shows up only on read. +// +// Names are built from raw bytes rather than written as literals so they do not +// depend on how a compiler reads this file. MSVC only receives /utf-8 through an +// if(MSVC) guard, and the windows-11-arm CI runner has no MSVC setup step, so a +// literal would test the toolchain's source decoding instead of the library. +TEST_F(ReaderTest, ReadBackNonAsciiDestName) { + const std::string names[] = { + std::string("ascii"), + std::string("\xC3\xA4"), // U+00E4, inside the BMP + std::string("\xF0\x9F\x94\xA5"), // U+1F525, outside the BMP + std::string("\xE4\xB8\xAD\xE6\x96\x87"), // U+4E2D U+6587 + }; + + fs::path build_dir = fs::path(__FILE__).parent_path().parent_path() / "build"; + if (!fs::exists(build_dir)) { + fs::create_directories(build_dir); + } + + auto manifest = c2pa_test::read_text_file(c2pa_test::get_fixture_path("training.json")); + for (const auto& name : names) { + SCOPED_TRACE(name.c_str()); + + // Built here rather than through get_temp_path: that helper takes a + // std::string, and the implicit conversion to fs::path decodes using the + // active code page on Windows, which would corrupt the name before the + // library sees it. u8path states the encoding instead. It is deprecated + // in C++20, but this project builds as C++17. + const std::string filename = "reader-" + name + "-readback.jpg"; +#ifdef _WIN32 + fs::path dest = build_dir / fs::u8path(filename); +#else + fs::path dest = build_dir / fs::path(filename); +#endif + temp_files.push_back(dest); + + auto signer = c2pa_test::create_test_signer(); + auto builder = c2pa::Builder(manifest); + ASSERT_NO_THROW(builder.sign(c2pa_test::get_fixture_path("A.jpg"), dest, signer)); + + // Check the name on disk still carries the bytes it was given. A + // conversion that replaces characters it cannot encode still produces a + // file, and distinct names collapse onto the same replacement, so + // reopening it would succeed and hide the loss. + ASSERT_TRUE(fs::exists(dest)) << "file was not created"; +#ifdef _WIN32 + const std::string actual = dest.filename().u8string(); +#else + const std::string actual = dest.filename().string(); +#endif + EXPECT_NE(actual.find(name), std::string::npos) + << "name on disk lost the non-ASCII characters it was given"; + EXPECT_EQ(actual.find('?'), std::string::npos) + << "name on disk contains a replacement character, so the conversion was lossy"; + + std::string manifest_json; + ASSERT_NO_THROW({ + auto reader = c2pa::Reader(dest); + manifest_json = reader.json(); + }) << "Reader should reopen a file named with non-ASCII characters"; + EXPECT_TRUE(json::parse(manifest_json).contains("manifests")); + } +} + TEST_F(ReaderTest, FileNotFound) { try From 9d92f74b310c02cedf9921b0dc005144076daf8b Mon Sep 17 00:00:00 2001 From: tmathern <60901087+tmathern@users.noreply.github.com> Date: Wed, 16 Sep 2026 20:42:34 -0700 Subject: [PATCH 2/2] fix: chars checks 2 --- tests/builder.test.cpp | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/tests/builder.test.cpp b/tests/builder.test.cpp index fd4f31b..65f9663 100644 --- a/tests/builder.test.cpp +++ b/tests/builder.test.cpp @@ -5961,9 +5961,17 @@ TEST_F(BuilderTest, NonAsciiDirectoryComponent) } // A non-ASCII character in the extension is not a path-encoding case: the format -// is inferred from the destination extension, so the library reports an -// unsupported type. Pinned so a change to that behavior is deliberate, and so it -// is not mistaken for an encoding defect. +// is inferred from the destination extension, so the destination is rejected. +// Pinned so a change to that behavior is deliberate, and so it is not mistaken +// for an encoding defect. +// +// The expected type is std::exception rather than C2paException because +// Builder::sign throws two unrelated types: resolve_format throws +// C2paException, while a destination that cannot be opened throws +// std::runtime_error. Which one arrives depends on the platform — on Windows, +// path::string() on a name outside the active code page throws on its own. A +// narrower expectation lets the other type escape the test and terminate the +// process, taking the rest of the suite with it. TEST_F(BuilderTest, NonAsciiExtensionIsRejectedAsFormat) { const std::string astral("\xF0\x9F\x94\xA5"); // U+1F525 @@ -5981,13 +5989,13 @@ TEST_F(BuilderTest, NonAsciiExtensionIsRejectedAsFormat) { auto signer = c2pa_test::create_test_signer(); auto builder = c2pa::Builder(manifest); - EXPECT_THROW(builder.sign(source, emoji_ext, signer), c2pa::C2paException) - << "a non-ASCII extension should be rejected as a format"; + EXPECT_THROW(builder.sign(source, emoji_ext, signer), std::exception) + << "a non-ASCII extension should be rejected"; } { auto signer = c2pa_test::create_test_signer(); auto builder = c2pa::Builder(manifest); - EXPECT_THROW(builder.sign(source, no_ext, signer), c2pa::C2paException) + EXPECT_THROW(builder.sign(source, no_ext, signer), std::exception) << "a path with no extension should be rejected as a missing format"; } }