diff --git a/CMakeLists.txt b/CMakeLists.txt index 742a96e1..2271b4bc 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -17,7 +17,7 @@ if(CCACHE_PROGRAM) endif() project(libsession-util - VERSION 1.5.4 + VERSION 1.5.5 DESCRIPTION "Session client utility library" LANGUAGES ${LANGS}) diff --git a/external/oxen-libquic b/external/oxen-libquic index 88fdfea1..793bf5be 160000 --- a/external/oxen-libquic +++ b/external/oxen-libquic @@ -1 +1 @@ -Subproject commit 88fdfea11aa1535de79f2e492e5f14897de4e689 +Subproject commit 793bf5be12dc26ae08a4585b7bff4e0b0d23e278 diff --git a/include/session/config/base.hpp b/include/session/config/base.hpp index a6a53e4e..f1be01f4 100644 --- a/include/session/config/base.hpp +++ b/include/session/config/base.hpp @@ -612,6 +612,36 @@ class ConfigBase : public ConfigSig { return fallback; } + /// API: base/ConfigBase::DictFieldProxy::sys_time + /// + /// Returns the integer value loaded into a seconds-since-epoch std::chrono::sys_seconds + /// value if an integer value exists at the given location, std::nullopt otherwise. + /// + /// Inputs: None + /// + /// Outputs: + /// - `std::optional` -- nullopt if the value doesn't exist (or isn't + /// an integer), otherwise the integer value loaded as a seconds-from-epoch. + std::optional sys_seconds() const { + if (const auto* i = integer()) + return std::make_optional(std::chrono::seconds{*i}); + return std::nullopt; + } + + /// API: base/ConfigBase::DictFieldProxy::sys_time_or + /// + /// Returns the value as a std::chrono::sys_time or a fallback if the value doesn't exist + /// (or isn't an integer). + /// + /// Inputs: + /// - `fallback` -- this value will be returned if it the requested value doesn't exist + /// + /// Outputs: + /// - `int64_t` -- Returned Integer + std::chrono::sys_seconds sys_seconds_or(std::chrono::sys_seconds fallback) const { + return sys_seconds().value_or(fallback); + } + /// API: base/ConfigBase::DictFieldProxy::set /// /// Returns a const pointer to the set if one exists at the given location, nullptr @@ -679,6 +709,19 @@ class ConfigBase : public ConfigSig { /// - `value` -- replaces current value with given integer void operator=(int64_t value) { assign_if_changed(value); } + /// API: base/ConfigBase::DictFieldProxy::operator=(std::chrono::sys_seconds) + /// + /// Replaces the current value with an integer containing the seconds-since-epoch of the + /// given system time point. This also auto-vivifies any intermediate dicts needed to reach + /// the given key, including replacing non-dict values if they currently exist along the + /// path. + /// + /// Inputs: + /// - `value` -- replaces current value with given sys_seconds's time_since_epoch() value. + void operator=(std::chrono::sys_seconds value) { + assign_if_changed(static_cast(value.time_since_epoch().count())); + } + /// API: base/ConfigBase::DictFieldProxy::operator=(config::set) /// /// Replaces the current value with the given set. This also auto-vivifies any diff --git a/include/session/config/user_profile.h b/include/session/config/user_profile.h index 87d2c0ce..94d3531c 100644 --- a/include/session/config/user_profile.h +++ b/include/session/config/user_profile.h @@ -92,7 +92,8 @@ LIBSESSION_EXPORT int user_profile_set_name(config_object* conf, const char* nam /// /// Obtains the current profile pic. The pointers in the returned struct will be NULL if a profile /// pic is not currently set, and otherwise should be copied right away (they will not be valid -/// beyond other API calls on this config object). +/// beyond other API calls on this config object). The returned value will be the latest profile +/// pic between when the user last set their profile and when it was last re-uploaded. /// /// Declaration: /// ```cpp @@ -110,7 +111,7 @@ LIBSESSION_EXPORT user_profile_pic user_profile_get_pic(const config_object* con /// API: user_profile/user_profile_set_pic /// -/// Sets a user profile +/// Sets a user profile pic /// /// Declaration: /// ```cpp @@ -128,6 +129,26 @@ LIBSESSION_EXPORT user_profile_pic user_profile_get_pic(const config_object* con /// - `int` -- Returns 0 on success, non-zero on error LIBSESSION_EXPORT int user_profile_set_pic(config_object* conf, user_profile_pic pic); +/// API: user_profile/user_profile_set_reupload_pic +/// +/// Sets a user profile pic when reuploading +/// +/// Declaration: +/// ```cpp +/// INT user_profile_set_reupload_pic( +/// [in] config_object* conf, +/// [in] user_profile_pic pic +/// ); +/// ``` +/// +/// Inputs: +/// - `conf` -- [in] Pointer to the config object +/// - `pic` -- [in] Pointer to the pic +/// +/// Outputs: +/// - `int` -- Returns 0 on success, non-zero on error +LIBSESSION_EXPORT int user_profile_set_reupload_pic(config_object* conf, user_profile_pic pic); + /// API: user_profile/user_profile_get_nts_priority /// /// Gets the current note-to-self priority level. Will be negative for hidden, 0 for unpinned, and > @@ -245,6 +266,19 @@ LIBSESSION_EXPORT int user_profile_get_blinded_msgreqs(const config_object* conf /// - `void` -- Returns Nothing LIBSESSION_EXPORT void user_profile_set_blinded_msgreqs(config_object* conf, int enabled); +/// API: user_profile/user_profile_get_profile_updated +/// +/// Returns the timestamp that the user last updated their profile information; or `0` if it's +/// never been updated. This value will return the latest timestamp between when the user last +/// set their profile and when it was last re-uploaded. +/// +/// Inputs: None +/// +/// Outputs: +/// - `int64_t` - timestamp (unix seconds) that the user last updated their public profile +/// information. Will be `0` if it's never been updated. +LIBSESSION_EXPORT int64_t user_profile_get_profile_updated(config_object* conf); + #ifdef __cplusplus } // extern "C" #endif diff --git a/include/session/config/user_profile.hpp b/include/session/config/user_profile.hpp index 6b9dfa8e..c9601086 100644 --- a/include/session/config/user_profile.hpp +++ b/include/session/config/user_profile.hpp @@ -24,10 +24,19 @@ using namespace std::literals; /// M - set to 1 if blinded message request retrieval is enabled, 0 if retrieval is *disabled*, and /// omitted if the setting has not been explicitly set (or has been explicitly cleared for some /// reason). +/// t - The unix timestamp (seconds) that the user last explicitly updated their profile information +/// (automatically updates when changing `name`, `profile_pic` or `set_blinded_msgreqs`). +/// P - user profile url after re-uploading (should take precedence over `p` when `T > t`). +/// Q - user profile decryption key (binary) after re-uploading (should take precedence over `q` +/// when `T > t`). +/// T - The unix timestamp (seconds) that the user last re-uploaded their profile information +/// (automatically updates when calling `set_reupload_profile_pic`). class UserProfile : public ConfigBase { public: + friend class UserProfileTester; + // No default constructor UserProfile() = delete; @@ -101,7 +110,8 @@ class UserProfile : public ConfigBase { /// API: user_profile/UserProfile::get_profile_pic /// /// Gets the user's current profile pic URL and decryption key. The returned object will - /// evaluate as false if the URL and/or key are not set. + /// evaluate as false if the URL and/or key are not set. The returned value will be the latest + /// profile pic between when the user last set their profile and when it was last re-uploaded. /// /// Inputs: None /// @@ -111,8 +121,8 @@ class UserProfile : public ConfigBase { /// API: user_profile/UserProfile::set_profile_pic /// - /// Sets the user's current profile pic to a new URL and decryption key. Clears both if either - /// one is empty. + /// Sets the user's current profile pic to a new URL and decryption key. Clears both as well as + /// the reupload values if either one is empty. /// /// Declaration: /// ```cpp @@ -129,6 +139,25 @@ class UserProfile : public ConfigBase { void set_profile_pic(std::string_view url, std::span key); void set_profile_pic(profile_pic pic); + /// API: user_profile/UserProfile::set_reupload_profile_pic + /// + /// Sets the user's profile pic to a new URL and decryption key after reuploading. + /// + /// Declaration: + /// ```cpp + /// void set_reupload_profile_pic(std::string_view url, std::span key); + /// void set_reupload_profile_pic(profile_pic pic); + /// ``` + /// + /// Inputs: + /// - First function: + /// - `url` -- URL pointing to the profile pic + /// - `key` -- Decryption key + /// - Second function: + /// - `pic` -- Profile pic object + void set_reupload_profile_pic(std::string_view url, std::span key); + void set_reupload_profile_pic(profile_pic pic); + /// API: user_profile/UserProfile::get_nts_priority /// /// Gets the Note-to-self conversation priority. Negative means hidden; 0 means unpinned; @@ -199,6 +228,19 @@ class UserProfile : public ConfigBase { /// default). void set_blinded_msgreqs(std::optional enabled); + /// API: user_profile/UserProfile::get_profile_updated + /// + /// Returns the timestamp that the user last updated their profile information; or `0` if it's + /// never been updated. This value will return the latest timestamp between when the user last + /// set their profile and when it was last re-uploaded. + /// + /// Inputs: None + /// + /// Outputs: + /// - `std::chrono::sys_seconds` - timestamp that the user last updated their profile + /// information. Will be `0` if it's never been updated. + std::chrono::sys_seconds get_profile_updated() const; + bool accepts_protobuf() const override { return true; } }; diff --git a/src/config.cpp b/src/config.cpp index ee232910..a8e2cf1f 100644 --- a/src/config.cpp +++ b/src/config.cpp @@ -679,6 +679,7 @@ ConfigMessage::ConfigMessage( for (const auto& [seqno_hash, ptrs] : replay) { const auto& [data, diff] = ptrs; apply_diff(data_, *diff, *data); + lagged_diffs_.emplace_hint(lagged_diffs_.end(), seqno_hash, *diff); } diff --git a/src/config/groups/keys.cpp b/src/config/groups/keys.cpp index 2fe6a156..534ecc35 100644 --- a/src/config/groups/keys.cpp +++ b/src/config/groups/keys.cpp @@ -1382,7 +1382,8 @@ std::pair> Keys::decrypt_message( // The value we verify is the raw data *followed by* the group Ed25519 pubkey. (See the comment // in encrypt_message). assert(_sign_pk); - std::vector to_verify(raw_data.size() + _sign_pk->size()); + std::vector to_verify; + to_verify.resize(raw_data.size() + _sign_pk->size()); std::memcpy(to_verify.data(), raw_data.data(), raw_data.size()); std::memcpy(to_verify.data() + raw_data.size(), _sign_pk->data(), _sign_pk->size()); if (0 != crypto_sign_ed25519_verify_detached( diff --git a/src/config/internal.hpp b/src/config/internal.hpp index 668d20b7..3466a37a 100644 --- a/src/config/internal.hpp +++ b/src/config/internal.hpp @@ -3,6 +3,7 @@ #include #include +#include #include #include #include @@ -180,6 +181,12 @@ std::optional maybe_int(const session::config::dict& d, const char* key // int. Equivalent to `maybe_int(d, key).value_or(0)`. int64_t int_or_0(const session::config::dict& d, const char* key); +// Returns std::chrono::system_clock::now(), with the given precision (seconds, if unspecified). +template +std::chrono::sys_time ts_now() { + return std::chrono::floor(std::chrono::system_clock::now()); +} + // Digs into a config `dict` to get out an int64_t containing unix timestamp seconds, returns it // wrapped in a std::chrono::sys_seconds. Returns nullopt if not there (or not int). std::optional maybe_ts(const session::config::dict& d, const char* key); diff --git a/src/config/user_profile.cpp b/src/config/user_profile.cpp index f14b9255..5adaf110 100644 --- a/src/config/user_profile.cpp +++ b/src/config/user_profile.cpp @@ -28,6 +28,9 @@ void UserProfile::set_name(std::string_view new_name) { if (new_name.size() > contact_info::MAX_NAME_LENGTH) throw std::invalid_argument{"Invalid profile name: exceeds maximum length"}; set_nonempty_str(data["n"], new_name); + + const auto target_timestamp = (data["t"].integer_or(0) >= data["T"].integer_or(0) ? "t" : "T"); + data[target_timestamp] = ts_now(); } void UserProfile::set_name_truncated(std::string new_name) { set_name(utf8_truncate(std::move(new_name), contact_info::MAX_NAME_LENGTH)); @@ -35,9 +38,14 @@ void UserProfile::set_name_truncated(std::string new_name) { profile_pic UserProfile::get_profile_pic() const { profile_pic pic{}; - if (auto* url = data["p"].string(); url && !url->empty()) + + const bool use_primary_keys = (data["t"].integer_or(0) >= data["T"].integer_or(0)); + const auto url_key = (use_primary_keys ? "p" : "P"); + const auto key_key = (use_primary_keys ? "q" : "Q"); + + if (auto* url = data[url_key].string(); url && !url->empty()) pic.url = *url; - if (auto* key = data["q"].string(); key && key->size() == 32) + if (auto* key = data[key_key].string(); key && key->size() == 32) pic.key.assign( reinterpret_cast(key->data()), reinterpret_cast(key->data()) + 32); @@ -46,12 +54,28 @@ profile_pic UserProfile::get_profile_pic() const { void UserProfile::set_profile_pic(std::string_view url, std::span key) { set_pair_if(!url.empty() && key.size() == 32, data["p"], url, data["q"], key); + + // If the profile was removed then we should remove the "reupload" version as well + if (url.empty() || key.size() != 32) + set_reupload_profile_pic({}); + + data["t"] = ts_now(); } void UserProfile::set_profile_pic(profile_pic pic) { set_profile_pic(pic.url, pic.key); } +void UserProfile::set_reupload_profile_pic( + std::string_view url, std::span key) { + set_pair_if(!url.empty() && key.size() == 32, data["P"], url, data["Q"], key); + data["T"] = ts_now(); +} + +void UserProfile::set_reupload_profile_pic(profile_pic pic) { + set_reupload_profile_pic(pic.url, pic.key); +} + void UserProfile::set_nts_priority(int priority) { set_nonzero_int(data["+"], priority); } @@ -75,6 +99,9 @@ void UserProfile::set_blinded_msgreqs(std::optional value) { data["M"].erase(); else data["M"] = static_cast(*value); + + const auto target_timestamp = (data["t"].integer_or(0) >= data["T"].integer_or(0) ? "t" : "T"); + data[target_timestamp] = ts_now(); } std::optional UserProfile::get_blinded_msgreqs() const { @@ -83,6 +110,15 @@ std::optional UserProfile::get_blinded_msgreqs() const { return std::nullopt; } +std::chrono::sys_seconds UserProfile::get_profile_updated() const { + if (auto t = data["t"].sys_seconds()) { + if (auto T = data["T"].sys_seconds(); T && *T > *t) + return *T; + return *t; + } + return std::chrono::sys_seconds{}; +} + extern "C" { using namespace session; @@ -141,6 +177,21 @@ LIBSESSION_C_API int user_profile_set_pic(config_object* conf, user_profile_pic static_cast(SESSION_ERR_BAD_VALUE)); } +LIBSESSION_C_API int user_profile_set_reupload_pic(config_object* conf, user_profile_pic pic) { + std::string_view url{pic.url}; + std::span key; + if (!url.empty()) + key = {pic.key, 32}; + + return wrap_exceptions( + conf, + [&] { + unbox(conf)->set_reupload_profile_pic(url, key); + return 0; + }, + static_cast(SESSION_ERR_BAD_VALUE)); +} + LIBSESSION_C_API int user_profile_get_nts_priority(const config_object* conf) { return unbox(conf)->get_nts_priority(); } @@ -170,4 +221,8 @@ LIBSESSION_C_API void user_profile_set_blinded_msgreqs(config_object* conf, int unbox(conf)->set_blinded_msgreqs(std::move(val)); } -} // extern "C" \ No newline at end of file +LIBSESSION_C_API int64_t user_profile_get_profile_updated(config_object* conf) { + return unbox(conf)->get_profile_updated().time_since_epoch().count(); +} + +} // extern "C" diff --git a/tests/test_config_userprofile.cpp b/tests/test_config_userprofile.cpp index ce7a625f..66a169b5 100644 --- a/tests/test_config_userprofile.cpp +++ b/tests/test_config_userprofile.cpp @@ -5,6 +5,7 @@ #include #include +#include #include #include #include @@ -13,6 +14,36 @@ using namespace std::literals; +namespace { +struct UserProfileTester { + static std::chrono::sys_seconds get_profile_updated_value(config_object* conf) { + return std::chrono::sys_seconds{std::chrono::seconds{ + session::config::unbox(conf)->data["t"].integer_or( + 0)}}; + } + + static void set_profile_updated(config_object* conf, std::chrono::sys_seconds value) { + session::config::unbox(conf)->data["t"] = + static_cast(value.time_since_epoch().count()); + } + + static std::chrono::sys_seconds get_reupload_profile_updated_value(config_object* conf) { + return std::chrono::sys_seconds{std::chrono::seconds{ + session::config::unbox(conf)->data["T"].integer_or( + 0)}}; + } + + static void set_reupload_profile_updated(config_object* conf, std::chrono::sys_seconds value) { + session::config::unbox(conf)->data["T"] = + static_cast(value.time_since_epoch().count()); + } + + static uint64_t get_raw_profile_updated_value(config_object* conf) { + return session::config::unbox(conf)->data["t"].integer_or(0); + } +}; +} // namespace + TEST_CASE("UserProfile", "[config][user_profile]") { const auto seed = "0123456789abcdef0123456789abcdef00000000000000000000000000000000"_hexbytes; @@ -130,6 +161,7 @@ TEST_CASE("user profile C API", "[config][user_profile][c]") { memcpy(p.key, "secret78901234567890123456789012", 32); CHECK(0 == user_profile_set_pic(conf, p)); user_profile_set_nts_priority(conf, 9); + UserProfileTester::set_profile_updated(conf, std::chrono::sys_seconds{123s}); // Retrieve them just to make sure they set properly: name = user_profile_get_name(conf); @@ -167,6 +199,7 @@ TEST_CASE("user profile C API", "[config][user_profile][c]") { "1:n" "6:Kallie" "1:p" "34:http://example.org/omg-pic-123.bmp" "1:q" "32:secret78901234567890123456789012" + "1:t" "i123e" "e" "1:<" "l" "l" "i0e" "32:" + session::to_string(exp_hash0) + "de" "e" @@ -176,6 +209,7 @@ TEST_CASE("user profile C API", "[config][user_profile][c]") { "1:n" "0:" "1:p" "0:" "1:q" "0:" + "1:t" "0:" "e" "e"); // clang-format on @@ -282,6 +316,8 @@ TEST_CASE("user profile C API", "[config][user_profile][c]") { // Change the name on both clients: user_profile_set_name(conf, "Nibbler"); + UserProfileTester::set_profile_updated(conf, std::chrono::sys_seconds{123s}); + user_profile_set_name(conf2, "Raz"); // And, on conf2, we're also going to change some other things: @@ -299,6 +335,7 @@ TEST_CASE("user profile C API", "[config][user_profile][c]") { CHECK(user_profile_get_blinded_msgreqs(conf2) == -1); user_profile_set_blinded_msgreqs(conf2, 1); CHECK(user_profile_get_blinded_msgreqs(conf2) == 1); + UserProfileTester::set_profile_updated(conf2, std::chrono::sys_seconds{124s}); // Both have changes, so push need a push CHECK(config_needs_push(conf)); @@ -414,4 +451,84 @@ TEST_CASE("user profile C API", "[config][user_profile][c]") { CHECK_FALSE(config_needs_dump(conf2)); CHECK_FALSE(config_needs_push(conf)); CHECK_FALSE(config_needs_push(conf2)); + + // Check the current pic + pic = user_profile_get_pic(conf); + REQUIRE(pic.url != ""s); + REQUIRE(pic.key != session::to_vector("").data()); + CHECK(pic.url == "http://new.example.com/pic"sv); + CHECK(session::to_vector(std::span{pic.key, 32}) == + "qwert\0yuio1234567890123456789012"_bytes); + + // Reupload the "current" pic and confirm it gets returned + strcpy(p.url, "testUrl"); + memcpy(p.key, "secret78901234567890123456789000", 32); + CHECK(0 == user_profile_set_reupload_pic(conf, p)); + + pic = user_profile_get_pic(conf); + REQUIRE(pic.url != ""s); + REQUIRE(pic.key != session::to_vector("").data()); + CHECK(pic.url == "testUrl"sv); + CHECK(session::to_vector(std::span{pic.key, 32}) == + "secret78901234567890123456789000"_bytes); + + // Upload a "new" pic and it now gets returned + strcpy(p.url, "testNewUrl"); + memcpy(p.key, "secret78901234567890123456789111", 32); + CHECK(0 == user_profile_set_pic(conf, p)); + pic = user_profile_get_pic(conf); + REQUIRE(pic.url != ""s); + REQUIRE(pic.key != session::to_vector("").data()); + CHECK(pic.url == "testNewUrl"sv); + CHECK(session::to_vector(std::span{pic.key, 32}) == + "secret78901234567890123456789111"_bytes); + + // Ensure the timestamp for the last modified pic gets updated correctly when the name gets set + UserProfileTester::set_profile_updated(conf, std::chrono::sys_seconds{0s}); + UserProfileTester::set_reupload_profile_updated(conf, std::chrono::sys_seconds{0s}); + + CHECK(0 == user_profile_set_pic(conf, p)); + UserProfileTester::set_profile_updated(conf, std::chrono::sys_seconds{123s}); + user_profile_set_name(conf, "test1"); + CHECK(UserProfileTester::get_profile_updated_value(conf).time_since_epoch().count() != 123); + UserProfileTester::set_profile_updated(conf, std::chrono::sys_seconds{0s}); + + UserProfileTester::set_reupload_profile_updated(conf, std::chrono::sys_seconds{124s}); + CHECK(0 == user_profile_set_reupload_pic(conf, p)); + user_profile_set_name(conf, "test2"); + CHECK(UserProfileTester::get_reupload_profile_updated_value(conf).time_since_epoch().count() != + 124); + + // Ensure the timestamp for the last modified pic gets updated correctly when the blinded msgreq + // is set + UserProfileTester::set_profile_updated(conf, std::chrono::sys_seconds{0s}); + UserProfileTester::set_reupload_profile_updated(conf, std::chrono::sys_seconds{0s}); + + CHECK(0 == user_profile_set_pic(conf, p)); + UserProfileTester::set_profile_updated(conf, std::chrono::sys_seconds{123s}); + user_profile_set_blinded_msgreqs(conf, 1); + CHECK(UserProfileTester::get_profile_updated_value(conf).time_since_epoch().count() != 123); + UserProfileTester::set_profile_updated(conf, std::chrono::sys_seconds{0s}); + + UserProfileTester::set_reupload_profile_updated(conf, std::chrono::sys_seconds{124s}); + CHECK(0 == user_profile_set_reupload_pic(conf, p)); + user_profile_set_blinded_msgreqs(conf, 2); + CHECK(UserProfileTester::get_reupload_profile_updated_value(conf).time_since_epoch().count() != + 124); + + // Ensure the timestamp is stored in seconds seconds (was incorrectly stored as microseconds) + auto time_before_call = std::chrono::system_clock::now(); + CHECK(0 == user_profile_set_pic(conf, p)); + auto time_after_call = std::chrono::system_clock::now(); + auto before_seconds = + std::chrono::duration_cast(time_before_call.time_since_epoch()) + .count(); + auto after_seconds = + std::chrono::duration_cast(time_before_call.time_since_epoch()) + .count(); + + auto raw_value = UserProfileTester::get_raw_profile_updated_value(conf); + INFO("Checking if raw_value " << raw_value << " is within the range [" << before_seconds << ", " + << after_seconds << "]"); + CHECK((raw_value >= before_seconds && raw_value <= after_seconds)); } diff --git a/tests/test_logging.cpp b/tests/test_logging.cpp index 5fdde5ed..d1a38518 100644 --- a/tests/test_logging.cpp +++ b/tests/test_logging.cpp @@ -77,14 +77,15 @@ TEST_CASE("Logging callbacks", "[logging]") { REQUIRE(simple_logs.size() == 2); REQUIRE(full_logs.size() == 2); CHECK(fixup_log(simple_logs[0]) == - "[] [] [test.a:critical|test_logging.cpp:{}] abc 42\n"_format(line0)); + "[] [] [test.a:critical|tests/test_logging.cpp:{}] abc 42\n"_format( + line0)); CHECK(fixup_log(simple_logs[1]) == - "[] [] [test.b:info|test_logging.cpp:{}] hi\n"_format(line1)); + "[] [] [test.b:info|tests/test_logging.cpp:{}] hi\n"_format(line1)); CHECK(fixup_log(full_logs[0]) == - "test.a|critical|[] [] [test.a:critical|test_logging.cpp:{}] abc 42\n"_format( + "test.a|critical|[] [] [test.a:critical|tests/test_logging.cpp:{}] abc 42\n"_format( line0)); CHECK(fixup_log(full_logs[1]) == - "test.b|info|[] [] [test.b:info|test_logging.cpp:{}] hi\n"_format( + "test.b|info|[] [] [test.b:info|tests/test_logging.cpp:{}] hi\n"_format( line1)); }