0

FLEDGE: Provide browserSignals.utf8{Encode,Decode} to worklets

In place of TextEncoder/TextEncoder which we don't have at hand and
which are more complex than what's actually needed.

(Requested in https://github.com/WICG/turtledove/issues/961)

Bug: 397936915
Change-Id: Iaa9018e05119a4dc0ef1579134cb50347fb57c30
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/6269638
Reviewed-by: Russ Hamilton <behamilton@google.com>
Commit-Queue: Maks Orlovich <morlovich@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1425747}
This commit is contained in:
Maks Orlovich
2025-02-27 07:56:35 -08:00
committed by Chromium LUCI CQ
parent 30569131b6
commit e013281fdb
24 changed files with 882 additions and 21 deletions

@ -93,6 +93,7 @@
#include "content/public/test/test_frame_navigation_observer.h"
#include "content/public/test/test_navigation_observer.h"
#include "content/public/test/url_loader_monitor.h"
#include "content/services/auction_worklet/public/cpp/auction_worklet_features.h"
#include "content/services/auction_worklet/public/cpp/cbor_test_util.h"
#include "content/services/auction_worklet/public/mojom/bidder_worklet.mojom.h"
#include "content/shell/browser/shell.h"
@ -766,7 +767,8 @@ class InterestGroupBrowserTest : public ContentBrowserTest {
// TODO(crrev.com/c/6096602): Remove once implementation is removed.
{blink::features::kFledgeDirectFromSellerSignalsWebBundles, {}},
{blink::features::kFledgeTrustedSignalsKVv2Support, {}},
{blink::features::kFledgeTrustedSignalsKVv1CreativeScanning, {}}},
{blink::features::kFledgeTrustedSignalsKVv1CreativeScanning, {}},
{features::kFledgeTextConversionHelpers, {}}},
/*disabled_features=*/
{blink::features::kFencedFrames,
blink::features::kFledgeEnforceKAnonymity,

@ -90,6 +90,8 @@ source_set("auction_worklet") {
"set_priority_signals_override_bindings.h",
"shared_storage_bindings.cc",
"shared_storage_bindings.h",
"text_conversion_helpers.cc",
"text_conversion_helpers.h",
"trusted_kvv2_signals.cc",
"trusted_kvv2_signals.h",
"trusted_signals.cc",

@ -60,6 +60,7 @@
#include "content/services/auction_worklet/set_priority_bindings.h"
#include "content/services/auction_worklet/set_priority_signals_override_bindings.h"
#include "content/services/auction_worklet/shared_storage_bindings.h"
#include "content/services/auction_worklet/text_conversion_helpers.h"
#include "content/services/auction_worklet/trusted_signals.h"
#include "content/services/auction_worklet/trusted_signals_kvv2_manager.h"
#include "content/services/auction_worklet/trusted_signals_request_manager.h"
@ -861,6 +862,7 @@ BidderWorklet::V8State::SingleGenerateBidResult::operator=(
bool BidderWorklet::V8State::SetBrowserSignals(
ContextRecycler& context_recycler,
v8::Local<v8::Context>& context,
bool is_for_additional_bid,
const std::optional<std::string>& interest_group_name_reporting_id,
const std::optional<std::string>& buyer_reporting_id,
@ -938,6 +940,11 @@ bool BidderWorklet::V8State::SetBrowserSignals(
return false;
}
if (base::FeatureList::IsEnabled(features::kFledgeTextConversionHelpers)) {
context_recycler.text_conversion_helpers()->ReInitialize(context,
browser_signals);
}
if (!context_recycler.report_win_lazy_filler()->FillInObject(
browser_signal_modeling_signals, browser_signal_join_count,
!is_for_additional_bid
@ -1027,7 +1034,7 @@ bool BidderWorklet::V8State::SetReportAggregateWinArgs(
}
if (!SetBrowserSignals(
context_recycler, is_for_additional_bid,
context_recycler, context, is_for_additional_bid,
interest_group_name_reporting_id, buyer_reporting_id,
buyer_and_seller_reporting_id, selected_buyer_and_seller_reporting_id,
browser_signal_render_url,
@ -1165,6 +1172,7 @@ void BidderWorklet::V8State::ReportWin(
}
context_recycler.AddReportWinBrowserSignalsLazyFiller();
context_recycler.AddTextConversionHelpers();
DeprecatedUrlLazyFiller deprecated_render_url(
v8_helper_.get(), &v8_logger, &browser_signal_render_url,
@ -1175,11 +1183,11 @@ void BidderWorklet::V8State::ReportWin(
? *browser_signal_reporting_timeout
: AuctionV8Helper::kScriptTimeout;
const bool browser_signals_set = SetBrowserSignals(
context_recycler, is_for_additional_bid, interest_group_name_reporting_id,
buyer_reporting_id, buyer_and_seller_reporting_id,
selected_buyer_and_seller_reporting_id, browser_signal_render_url,
&deprecated_render_url, browser_signal_bid, browser_signal_bid_currency,
browser_signal_highest_scoring_other_bid,
context_recycler, context, is_for_additional_bid,
interest_group_name_reporting_id, buyer_reporting_id,
buyer_and_seller_reporting_id, selected_buyer_and_seller_reporting_id,
browser_signal_render_url, &deprecated_render_url, browser_signal_bid,
browser_signal_bid_currency, browser_signal_highest_scoring_other_bid,
browser_signal_highest_scoring_other_bid_currency,
browser_signal_made_highest_scoring_other_bid, browser_signal_ad_cost,
browser_signal_modeling_signals, browser_signal_join_count,
@ -1867,6 +1875,10 @@ BidderWorklet::V8State::RunGenerateBidOnce(
}
v8::Local<v8::Object> browser_signals = v8::Object::New(isolate);
if (base::FeatureList::IsEnabled(features::kFledgeTextConversionHelpers)) {
context_recycler->text_conversion_helpers()->ReInitialize(context,
browser_signals);
}
gin::Dictionary browser_signals_dict(isolate, browser_signals);
// TODO(crbug.com/336164429): Construct the fields of browser signals lazily.
if (!browser_signals_dict.Set("topWindowHostname",
@ -2137,6 +2149,7 @@ BidderWorklet::V8State::CreateContextRecyclerAndRunTopLevelForGenerateBid(
context_recycler->AddSetBidBindings();
context_recycler->AddSetPriorityBindings();
context_recycler->AddSetPrioritySignalsOverrideBindings();
context_recycler->AddTextConversionHelpers();
context_recycler->AddInterestGroupLazyFiller();
context_recycler->AddBiddingBrowserSignalsLazyFiller();

@ -498,6 +498,7 @@ class CONTENT_EXPORT BidderWorklet : public mojom::BidderWorklet,
bool SetBrowserSignals(
ContextRecycler& context_recycler,
v8::Local<v8::Context>& context,
bool is_for_additional_bid,
const std::optional<std::string>& interest_group_name_reporting_id,
const std::optional<std::string>& buyer_reporting_id,

@ -1191,6 +1191,16 @@ class BidderWorkletMultiBidDisabledTest : public BidderWorkletTest {
base::test::ScopedFeatureList feature_list_;
};
class BidderWorkletTextConversionsTest : public BidderWorkletTest {
public:
BidderWorkletTextConversionsTest() {
feature_list_.InitAndEnableFeature(features::kFledgeTextConversionHelpers);
}
protected:
base::test::ScopedFeatureList feature_list_;
};
// Test the case the BidderWorklet pipe is closed before invoking the
// GenerateBidCallback. The invocation of the GenerateBidCallback is not
// observed, since the callback is on the pipe that was just closed. There
@ -4652,6 +4662,23 @@ TEST_F(BidderWorkletTest, GenerateBidInterestGroupCreativeScanningMetadata) {
R"(!('creativeScanningMetadata' in interestGroup.adComponents[0]))");
}
TEST_F(BidderWorkletTest, GenerateBidTextConversions) {
RunGenerateBidExpectingExpressionIsTrue(
R"(!('encodeUtf8' in browserSignals))");
RunGenerateBidExpectingExpressionIsTrue(
R"(!('decodeUtf8' in browserSignals))");
}
TEST_F(BidderWorkletTextConversionsTest, GenerateBidTextConversions) {
RunGenerateBidExpectingExpressionIsTrue(R"('encodeUtf8' in browserSignals)");
RunGenerateBidExpectingExpressionIsTrue(R"('decodeUtf8' in browserSignals)");
RunGenerateBidExpectingExpressionIsTrue(
"browserSignals.encodeUtf8('A')[0] === 65");
RunGenerateBidExpectingExpressionIsTrue(
"browserSignals.decodeUtf8(new Uint8Array([65, 32, 68])) === 'A D'");
}
class BidderWorkletCreativeScanningTest : public BidderWorkletTest {
public:
BidderWorkletCreativeScanningTest() {
@ -7529,6 +7556,24 @@ TEST_F(BidderWorkletTest, ReportWinTopLevelTimeout) {
{"https://url.test/ top-level execution timed out."});
}
TEST_F(BidderWorkletTest, ReportWinTextConversions) {
RunReportWinWithFunctionBodyExpectingResult(
"sendReportTo('https://foo.test?' + ('encodeUtf8' in browserSignals))",
GURL("https://foo.test/?false"));
RunReportWinWithFunctionBodyExpectingResult(
"sendReportTo('https://foo.test?' + ('decodeUtf8' in browserSignals))",
GURL("https://foo.test/?false"));
}
TEST_F(BidderWorkletTextConversionsTest, ReportWinTextConversions) {
RunReportWinWithFunctionBodyExpectingResult(
"sendReportTo('https://foo.test?' + ('encodeUtf8' in browserSignals))",
GURL("https://foo.test/?true"));
RunReportWinWithFunctionBodyExpectingResult(
"sendReportTo('https://foo.test?' + ('decodeUtf8' in browserSignals))",
GURL("https://foo.test/?true"));
}
TEST_F(BidderWorkletTest, SendReportToLongUrl) {
// Copying large URLs can cause flaky generateBid() timeouts with the default
// value, even on the standard debug bots.

@ -23,6 +23,7 @@
#include "content/services/auction_worklet/set_priority_bindings.h"
#include "content/services/auction_worklet/set_priority_signals_override_bindings.h"
#include "content/services/auction_worklet/shared_storage_bindings.h"
#include "content/services/auction_worklet/text_conversion_helpers.h"
#include "v8/include/v8-context.h"
namespace auction_worklet {
@ -116,6 +117,13 @@ void ContextRecycler::AddSharedStorageBindings(
AddBindings(shared_storage_bindings_.get());
}
void ContextRecycler::AddTextConversionHelpers() {
DCHECK(!text_conversion_helpers_);
text_conversion_helpers_ =
std::make_unique<TextConversionHelpers>(v8_helper_);
AddBindings(text_conversion_helpers_.get());
}
void ContextRecycler::AddInterestGroupLazyFiller() {
DCHECK(!interest_group_lazy_filler_);
interest_group_lazy_filler_ =

@ -35,6 +35,7 @@ class SetBidBindings;
class SetPriorityBindings;
class SetPrioritySignalsOverrideBindings;
class SharedStorageBindings;
class TextConversionHelpers;
class AuctionConfigLazyFiller;
class BiddingBrowserSignalsLazyFiller;
class InterestGroupLazyFiller;
@ -145,6 +146,11 @@ class CONTENT_EXPORT ContextRecycler {
return shared_storage_bindings_.get();
}
void AddTextConversionHelpers();
TextConversionHelpers* text_conversion_helpers() {
return text_conversion_helpers_.get();
}
void AddInterestGroupLazyFiller();
InterestGroupLazyFiller* interest_group_lazy_filler() {
return interest_group_lazy_filler_.get();
@ -204,6 +210,7 @@ class CONTENT_EXPORT ContextRecycler {
std::unique_ptr<SetPrioritySignalsOverrideBindings>
set_priority_signals_override_bindings_;
std::unique_ptr<SharedStorageBindings> shared_storage_bindings_;
std::unique_ptr<TextConversionHelpers> text_conversion_helpers_;
// everything here is owned by one of the unique_ptr's above.
std::vector<raw_ptr<Bindings, VectorExperimental>> bindings_list_;

@ -33,6 +33,7 @@
#include "content/services/auction_worklet/seller_lazy_filler.h"
#include "content/services/auction_worklet/set_bid_bindings.h"
#include "content/services/auction_worklet/set_priority_bindings.h"
#include "content/services/auction_worklet/text_conversion_helpers.h"
#include "content/services/auction_worklet/worklet_test_util.h"
#include "gin/converter.h"
#include "gin/dictionary.h"
@ -6141,6 +6142,271 @@ TEST_F(ContextRecyclerTest, RegisterAdMacroBindings) {
}
}
TEST_F(ContextRecyclerTest, EncodeUtf8) {
const char kScript[] = R"(
function assertEq(l, r, label) {
if (l !== r)
throw 'Mismatch ' + label;
}
function assertByteArray(result, expect) {
if (!(result instanceof Uint8Array)) {
throw 'Not a Uint8Array!';
}
assertEq(result.length, expect.length, 'length');
for (var i = 0; i < result.length; ++i) {
assertEq(result[i], expect[i], i);
}
}
function test1() {
assertByteArray(encoderObj.encodeUtf8('ABC'),
[65, 66, 67]);
}
function test2() {
assertByteArray(encoderObj.encodeUtf8('A \u0490'),
[65, 32, 0xD2, 0x90]);
}
// Unmatched surrogate.
function test3() {
assertByteArray(encoderObj.encodeUtf8('A\uD800C'),
[65, 0xEF, 0xBF, 0xBD, 67]);
}
// Matched surrogate.
function test4() {
assertByteArray(encoderObj.encodeUtf8('A\uD83D\uDE02C'),
[65, 0xF0, 0x9F, 0x98, 0x82, 67]);
}
// Custom conversion.
function test5() {
let obj = {
toString: () => "ABC"
};
assertByteArray(encoderObj.encodeUtf8(obj),
[65, 66, 67]);
}
)";
v8::Local<v8::UnboundScript> script = Compile(kScript);
ASSERT_FALSE(script.IsEmpty());
ContextRecycler context_recycler(helper_.get());
{
ContextRecyclerScope scope(context_recycler); // Initialize context
context_recycler.AddTextConversionHelpers();
}
for (const char* test : {"test1", "test2", "test3", "test4", "test5"}) {
SCOPED_TRACE(test);
ContextRecyclerScope scope(context_recycler);
v8::Local<v8::Context> context = scope.GetContext();
v8::Local<v8::Object> obj = v8::Object::New(helper_->isolate());
context_recycler.text_conversion_helpers()->ReInitialize(context, obj);
context->Global()
->Set(context, helper_->CreateStringFromLiteral("encoderObj"), obj)
.Check();
std::vector<std::string> error_msgs;
Run(scope, script, test, error_msgs);
EXPECT_THAT(error_msgs, ElementsAre());
}
}
TEST_F(ContextRecyclerTest, EncodeUtf8Failure) {
const char kScript[] = R"(
// Not enough arguments.
function test1() {
encoderObj.encodeUtf8();
}
// String conversion failure.
function test2() {
let obj = {
toString: () => { throw 'ouch' }
};
encoderObj.encodeUtf8(obj);
}
)";
v8::Local<v8::UnboundScript> script = Compile(kScript);
ASSERT_FALSE(script.IsEmpty());
ContextRecycler context_recycler(helper_.get());
{
ContextRecyclerScope scope(context_recycler); // Initialize context
context_recycler.AddTextConversionHelpers();
}
const struct TestCase {
const char* functionName;
const char* error;
} kTests[] = {
{"test1",
"https://example.test/script.js:4 Uncaught TypeError: encodeUtf8 at "
"least 1 argument(s) are required."},
{"test2", "https://example.test/script.js:12 Uncaught ouch."}};
for (const auto& test : kTests) {
SCOPED_TRACE(test.functionName);
ContextRecyclerScope scope(context_recycler);
v8::Local<v8::Context> context = scope.GetContext();
v8::Local<v8::Object> obj = v8::Object::New(helper_->isolate());
context_recycler.text_conversion_helpers()->ReInitialize(context, obj);
context->Global()
->Set(context, helper_->CreateStringFromLiteral("encoderObj"), obj)
.Check();
std::vector<std::string> error_msgs;
Run(scope, script, test.functionName, error_msgs);
EXPECT_THAT(error_msgs, ElementsAre(test.error));
}
}
TEST_F(ContextRecyclerTest, DecodeUtf8) {
const char kScript[] = R"(
function assertEq(l, r, label) {
if (l !== r)
throw 'Mismatch ' + label + ' ' + l + ' vs ' + r;
}
function assertString(result, expect) {
if (typeof result !== 'string') {
throw 'Not a string';
}
assertEq(result.length, expect.length, 'length');
for (var i = 0; i < result.length; ++i) {
assertEq(result.charCodeAt(i), expect.charCodeAt(i), i);
}
}
function test1() {
assertString(encoderObj.decodeUtf8(new Uint8Array([65, 66, 67])),
'ABC');
}
function test2() {
assertString(encoderObj.decodeUtf8(new Uint8Array([65, 32, 0xD2, 0x90])),
'A \u0490');
}
// Broken utf-8 --- gets a replacement character.
function test3() {
assertString(encoderObj.decodeUtf8(new Uint8Array([65, 32, 0xD2])),
'A \uFFFD');
}
// Utf-8 for just a single surrogate. Every byte ended up replaced with a
// replacement character.
function test4() {
assertString(encoderObj.decodeUtf8(new Uint8Array(
[65, 32, 0xED, 0xA0, 0x80, 66])),
'A \uFFFD\uFFFD\uFFFDB');
}
// Utf-8 for something that requires two Utf-16 characters.
function test5() {
assertString(encoderObj.decodeUtf8(new Uint8Array(
[65, 0xF0, 0x9F, 0x98, 0x82, 67])),
'A\uD83D\uDE02C');
}
// Partial view into an ArrayBuffer.
function test6() {
let buffer = new ArrayBuffer(8);
let fullView = new Uint8Array(buffer);
for (let i = 0; i < fullView.length; ++i)
fullView[i] = 65 + i;
let partialView = new Uint8Array(buffer, 2, 3);
assertString(encoderObj.decodeUtf8(fullView),
'ABCDEFGH');
assertString(encoderObj.decodeUtf8(partialView),
'CDE');
}
)";
v8::Local<v8::UnboundScript> script = Compile(kScript);
ASSERT_FALSE(script.IsEmpty());
ContextRecycler context_recycler(helper_.get());
{
ContextRecyclerScope scope(context_recycler); // Initialize context
context_recycler.AddTextConversionHelpers();
}
for (const char* test :
{"test1", "test2", "test3", "test4", "test5", "test6"}) {
SCOPED_TRACE(test);
ContextRecyclerScope scope(context_recycler);
v8::Local<v8::Context> context = scope.GetContext();
v8::Local<v8::Object> obj = v8::Object::New(helper_->isolate());
context_recycler.text_conversion_helpers()->ReInitialize(context, obj);
context->Global()
->Set(context, helper_->CreateStringFromLiteral("encoderObj"), obj)
.Check();
std::vector<std::string> error_msgs;
Run(scope, script, test, error_msgs);
EXPECT_THAT(error_msgs, ElementsAre());
}
}
TEST_F(ContextRecyclerTest, DecodeUtf8Failure) {
const char kScript[] = R"(
// Not enough arguments.
function test1() {
encoderObj.decodeUtf8();
}
// Wrong type.
function test2() {
encoderObj.decodeUtf8([65,66]);
}
)";
v8::Local<v8::UnboundScript> script = Compile(kScript);
ASSERT_FALSE(script.IsEmpty());
ContextRecycler context_recycler(helper_.get());
{
ContextRecyclerScope scope(context_recycler); // Initialize context
context_recycler.AddTextConversionHelpers();
}
const struct TestCase {
const char* functionName;
const char* error;
} kTests[] = {
{"test1",
"https://example.test/script.js:4 Uncaught TypeError: decodeUtf8 "
"expects a Uint8Array argument."},
{"test2",
"https://example.test/script.js:9 Uncaught TypeError: decodeUtf8 "
"expects a Uint8Array argument."}};
for (const auto& test : kTests) {
SCOPED_TRACE(test.functionName);
ContextRecyclerScope scope(context_recycler);
v8::Local<v8::Context> context = scope.GetContext();
v8::Local<v8::Object> obj = v8::Object::New(helper_->isolate());
context_recycler.text_conversion_helpers()->ReInitialize(context, obj);
context->Global()
->Set(context, helper_->CreateStringFromLiteral("encoderObj"), obj)
.Check();
std::vector<std::string> error_msgs;
Run(scope, script, test.functionName, error_msgs);
EXPECT_THAT(error_msgs, ElementsAre(test.error));
}
}
class ContextRecyclerRealTimeReportingEnabledTest : public ContextRecyclerTest {
public:
ContextRecyclerRealTimeReportingEnabledTest() {

@ -4,8 +4,6 @@ source_set("cpp") {
"auction_downloader.h",
"auction_network_events_delegate.cc",
"auction_network_events_delegate.h",
"auction_worklet_features.cc",
"auction_worklet_features.h",
"private_aggregation_reporting.cc",
"private_aggregation_reporting.h",
"real_time_reporting.h",
@ -26,6 +24,24 @@ source_set("cpp") {
"//services/network/public/cpp",
"//url",
]
public_deps = [ "//content/services/auction_worklet/public/cpp:features" ]
}
source_set("features") {
sources = [
"auction_worklet_features.cc",
"auction_worklet_features.h",
]
configs += [
"//build/config/compiler:wexit_time_destructors",
"//content:content_implementation",
]
deps = [
"//base",
"//content:export",
]
}
static_library("test_support") {

@ -84,4 +84,8 @@ BASE_FEATURE(kFledgeSplitTrustedSignalsFetchingURL,
"FledgeSplitTrustedSignalsFetchingURL",
base::FEATURE_ENABLED_BY_DEFAULT);
BASE_FEATURE(kFledgeTextConversionHelpers,
"FledgeTextConversionHelpers",
base::FEATURE_DISABLED_BY_DEFAULT);
} // namespace features

@ -63,6 +63,9 @@ CONTENT_EXPORT BASE_DECLARE_FEATURE_PARAM(
CONTENT_EXPORT BASE_DECLARE_FEATURE(kFledgeSplitTrustedSignalsFetchingURL);
// Provide encodeUtf8/decodeUtf8 helpers.
CONTENT_EXPORT BASE_DECLARE_FEATURE(kFledgeTextConversionHelpers);
} // namespace features
#endif // CONTENT_SERVICES_AUCTION_WORKLET_PUBLIC_CPP_AUCTION_WORKLET_FEATURES_H_

@ -49,6 +49,7 @@
#include "content/services/auction_worklet/report_bindings.h"
#include "content/services/auction_worklet/seller_lazy_filler.h"
#include "content/services/auction_worklet/shared_storage_bindings.h"
#include "content/services/auction_worklet/text_conversion_helpers.h"
#include "content/services/auction_worklet/trusted_signals.h"
#include "content/services/auction_worklet/trusted_signals_kvv2_manager.h"
#include "content/services/auction_worklet/webidl_compat.h"
@ -921,6 +922,7 @@ SellerWorklet::V8State::CreateContextRecyclerAndRunTopLevel(
permissions_policy_state_->private_aggregation_allowed,
/*reserved_once_allowed=*/true);
context_recycler->AddRealTimeReportingBindings();
context_recycler->AddTextConversionHelpers();
if (base::FeatureList::IsEnabled(blink::features::kSharedStorageAPI)) {
context_recycler->AddSharedStorageBindings(
shared_storage_host_remote_.is_bound()
@ -1139,6 +1141,10 @@ void SellerWorklet::V8State::ScoreAd(
}
context_recycler->seller_browser_signals_lazy_filler()->FillInObject(
browser_signal_render_url, &ad_components, browser_signals);
if (base::FeatureList::IsEnabled(features::kFledgeTextConversionHelpers)) {
context_recycler->text_conversion_helpers()->ReInitialize(context,
browser_signals);
}
// TODO(crbug.com/336164429): Construct the fields of browser signals lazily.
if (!browser_signals_dict.Set("topWindowHostname",
top_window_origin_.host()) ||
@ -1697,6 +1703,11 @@ void SellerWorklet::V8State::ReportResult(
v8::Local<v8::Object> browser_signals = v8::Object::New(isolate);
gin::Dictionary browser_signals_dict(isolate, browser_signals);
context_recycler.AddTextConversionHelpers();
if (base::FeatureList::IsEnabled(features::kFledgeTextConversionHelpers)) {
context_recycler.text_conversion_helpers()->ReInitialize(context,
browser_signals);
}
context_recycler.AddSellerBrowserSignalsLazyFiller();
// Passing null for ad_components here since we do not want creative scanning

@ -1007,6 +1007,16 @@ class SellerWorkletTwoThreadsTest : public SellerWorkletTest {
size_t NumThreads() override { return 2u; }
};
class SellerWorkletTextConversionsTest : public SellerWorkletTest {
public:
SellerWorkletTextConversionsTest() {
feature_list_.InitAndEnableFeature(features::kFledgeTextConversionHelpers);
}
protected:
base::test::ScopedFeatureList feature_list_;
};
class SellerWorkletMultiThreadingTest
: public SellerWorkletTest,
public testing::WithParamInterface<std::tuple<size_t, bool>> {
@ -1938,6 +1948,26 @@ TEST_F(SellerWorkletTest, ScoreAdAdComponentsCreativeScanningMetadata) {
3);
}
TEST_F(SellerWorkletTest, ScoreAdTextConversions) {
RunScoreAdWithReturnValueExpectingResult(
R"('encodeUtf8' in browserSignals? 3 : 2)", 2);
RunScoreAdWithReturnValueExpectingResult(
R"('decodeUtf8' in browserSignals? 3 : 2)", 2);
}
TEST_F(SellerWorkletTextConversionsTest, ScoreAdTextConversions) {
RunScoreAdWithReturnValueExpectingResult(
R"('encodeUtf8' in browserSignals? 3 : 2)", 3);
RunScoreAdWithReturnValueExpectingResult(
R"('decodeUtf8' in browserSignals? 3 : 2)", 3);
RunScoreAdWithReturnValueExpectingResult("browserSignals.encodeUtf8('A')[0]",
65);
RunScoreAdWithReturnValueExpectingResult(
"browserSignals.decodeUtf8(new Uint8Array([65, 68])) === 'AD' ? 3 : 2",
3);
}
TEST_F(SellerWorkletTest, ScoreAdBid) {
bid_ = 5;
RunScoreAdWithReturnValueExpectingResult("bid", 5);
@ -3427,6 +3457,28 @@ TEST_F(SellerWorkletTest, ReportResultNoAdComponentsCreativeScanningMetadata) {
/*expected_signals_for_winner=*/"1", GURL("https://foo.test/?2"));
}
TEST_F(SellerWorkletTest, ReportResultTextConversions) {
RunReportResultCreatedScriptExpectingResult(
"('encodeUtf8' in browserSignals) ? 2 : 1",
/*extra_code=*/std::string(), "1",
/*expected_report_url=*/std::nullopt);
RunReportResultCreatedScriptExpectingResult(
"('decodeUtf8' in browserSignals) ? 2 : 1",
/*extra_code=*/std::string(), "1",
/*expected_report_url=*/std::nullopt);
}
TEST_F(SellerWorkletTextConversionsTest, ReportResultTextConversions) {
RunReportResultCreatedScriptExpectingResult(
"('encodeUtf8' in browserSignals) ? 2 : 1",
/*extra_code=*/std::string(), "2",
/*expected_report_url=*/std::nullopt);
RunReportResultCreatedScriptExpectingResult(
"('decodeUtf8' in browserSignals) ? 2 : 1",
/*extra_code=*/std::string(), "2",
/*expected_report_url=*/std::nullopt);
}
TEST_F(SellerWorkletTest, ReportResultTopWindowOrigin) {
top_window_origin_ = url::Origin::Create(GURL("https://foo.test/"));
RunReportResultCreatedScriptExpectingResult(

@ -0,0 +1,140 @@
// Copyright 2025 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
#include "content/services/auction_worklet/text_conversion_helpers.h"
#include "base/compiler_specific.h"
#include "base/memory/ref_counted.h"
#include "content/services/auction_worklet/auction_v8_helper.h"
#include "content/services/auction_worklet/webidl_compat.h"
#include "v8/include/v8-array-buffer.h"
#include "v8/include/v8-exception.h"
#include "v8/include/v8-function-callback.h"
#include "v8/include/v8-function.h"
#include "v8/include/v8-primitive.h"
#include "v8/include/v8-typed-array.h"
namespace auction_worklet {
TextConversionHelpers::TextConversionHelpers(AuctionV8Helper* v8_helper)
: v8_helper_(v8_helper) {}
void TextConversionHelpers::AttachToContext(v8::Local<v8::Context> context) {
// We do everything in ReInitialize, since we attach to an object and not
// globally.
}
void TextConversionHelpers::Reset() {}
void TextConversionHelpers::ReInitialize(v8::Local<v8::Context> context,
v8::Local<v8::Object> object) {
v8::Local<v8::External> v8_this =
v8::External::New(v8_helper_->isolate(), this);
v8::Local<v8::Function> encode =
v8::Function::New(context, &TextConversionHelpers::EncodeUtf8, v8_this)
.ToLocalChecked();
v8::Local<v8::Function> decode =
v8::Function::New(context, &TextConversionHelpers::DecodeUtf8, v8_this)
.ToLocalChecked();
object
->Set(context, v8_helper_->CreateStringFromLiteral("encodeUtf8"), encode)
.Check();
object
->Set(context, v8_helper_->CreateStringFromLiteral("decodeUtf8"), decode)
.Check();
}
void TextConversionHelpers::EncodeUtf8(
const v8::FunctionCallbackInfo<v8::Value>& args) {
TextConversionHelpers* bindings = static_cast<TextConversionHelpers*>(
v8::External::Cast(*args.Data())->Value());
AuctionV8Helper* v8_helper = bindings->v8_helper_;
v8::Isolate* isolate = v8_helper->isolate();
AuctionV8Helper::TimeLimitScope time_limit_scope(v8_helper->GetTimeLimit());
ArgsConverter args_converter(v8_helper, time_limit_scope, "encodeUtf8 ",
&args,
/*min_required_args=*/1);
if (!args_converter.is_success()) {
args_converter.TakeStatus().PropagateErrorsToV8(v8_helper);
return;
}
// We don't use webidl_compat to do the string conversion here since we don't
// want the output as a std::string, we want to stick it into a UInt8Array.
// (Also it would get DOMString and not USVString semantics, but that's
// secondary).
v8::Local<v8::String> v8_string;
IdlConvert::Status conversion_status;
{
if (args[0]->IsString()) {
v8_string = args[0].As<v8::String>();
} else {
v8::TryCatch try_catch(isolate);
if (!args[0]
->ToString(isolate->GetCurrentContext())
.ToLocal(&v8_string)) {
conversion_status = IdlConvert::MakeConversionFailure(
try_catch, "encodeUtf8 ", {"argument 0"}, "String");
}
}
}
if (!conversion_status.is_success()) {
conversion_status.PropagateErrorsToV8(v8_helper);
return;
}
size_t required_len = v8_string->Utf8LengthV2(isolate);
v8::Local<v8::ArrayBuffer> buffer;
if (!v8::ArrayBuffer::MaybeNew(
isolate, required_len,
v8::BackingStoreInitializationMode::kZeroInitialized)
.ToLocal(&buffer)) {
isolate->ThrowException(
v8::Exception::TypeError(v8_helper->CreateStringFromLiteral(
"Unable to allocate buffer for result")));
return;
}
size_t used_len = v8_string->WriteUtf8V2(
isolate, reinterpret_cast<char*>(buffer->Data()), buffer->ByteLength(),
v8::String::WriteFlags::kReplaceInvalidUtf8);
// The length should be as expected (despite Utf8LengthV2 not knowing about
// kReplaceInvalidUtf8) since a mismatched surrogate and the unicode
// replacement character both end up the same length (3 bytes).
DCHECK_EQ(used_len, required_len);
args.GetReturnValue().Set(v8::Uint8Array::New(buffer,
/*byte_offset=*/0,
/*length=*/used_len));
}
void TextConversionHelpers::DecodeUtf8(
const v8::FunctionCallbackInfo<v8::Value>& args) {
TextConversionHelpers* bindings = static_cast<TextConversionHelpers*>(
v8::External::Cast(*args.Data())->Value());
AuctionV8Helper* v8_helper = bindings->v8_helper_;
v8::Isolate* isolate = v8_helper->isolate();
if (!args[0]->IsUint8Array()) {
isolate->ThrowException(
v8::Exception::TypeError(v8_helper->CreateStringFromLiteral(
"decodeUtf8 expects a Uint8Array argument")));
return;
}
v8::Local<v8::Uint8Array> array = args[0].As<v8::Uint8Array>();
// SAFETY: Uint8Array data is from ByteOffset and of length ByteLength inside
// the ArrayBuffer it wraps.
args.GetReturnValue().Set(
v8::String::NewFromUtf8(
isolate,
UNSAFE_BUFFERS(reinterpret_cast<char*>(array->Buffer()->Data()) +
array->ByteOffset()),
v8::NewStringType::kNormal, array->ByteLength())
.ToLocalChecked());
}
} // namespace auction_worklet

@ -0,0 +1,37 @@
// Copyright 2025 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
#ifndef CONTENT_SERVICES_AUCTION_WORKLET_TEXT_CONVERSION_HELPERS_H_
#define CONTENT_SERVICES_AUCTION_WORKLET_TEXT_CONVERSION_HELPERS_H_
#include "content/common/content_export.h"
#include "content/services/auction_worklet/context_recycler.h"
#include "v8/include/v8-forward.h"
namespace auction_worklet {
class AuctionV8Helper;
// Utilities to add JS functions to help convert between JS Strings and utf-8
// arrays.
class CONTENT_EXPORT TextConversionHelpers : public Bindings {
public:
explicit TextConversionHelpers(AuctionV8Helper* v8_helper);
void AttachToContext(v8::Local<v8::Context> context) override;
void Reset() override;
void ReInitialize(v8::Local<v8::Context> context,
v8::Local<v8::Object> object);
private:
static void EncodeUtf8(const v8::FunctionCallbackInfo<v8::Value>& args);
static void DecodeUtf8(const v8::FunctionCallbackInfo<v8::Value>& args);
const raw_ptr<AuctionV8Helper> v8_helper_;
};
} // namespace auction_worklet
#endif // CONTENT_SERVICES_AUCTION_WORKLET_TEXT_CONVERSION_HELPERS_H_

@ -1851,6 +1851,7 @@ test("content_browsertests") {
"//content/public/gpu",
"//content/public/renderer",
"//content/renderer:for_content_tests",
"//content/services/auction_worklet/public/cpp:features",
"//content/services/auction_worklet/public/cpp:test_support",
"//content/services/auction_worklet/public/mojom:for_content_tests",
"//content/shell:content_browsertests_mojom",

@ -212,9 +212,13 @@ function validateBrowserSignals(browserSignals, isGenerateBid) {
throw 'Wrong seller ' + browserSignals.seller;
if ('topLevelSeller' in browserSignals)
throw 'Wrong topLevelSeller ' + browserSignals.topLevelSeller;
if (!(browserSignals.decodeUtf8 instanceof Function))
throw 'Wrong decodeUtf8';
if (!(browserSignals.encodeUtf8 instanceof Function))
throw 'Wrong encodeUtf8';
if (isGenerateBid) {
if (Object.keys(browserSignals).length !== 11) {
if (Object.keys(browserSignals).length !== 13) {
throw 'Wrong number of browser signals fields ' +
JSON.stringify(browserSignals);
}
@ -237,7 +241,7 @@ function validateBrowserSignals(browserSignals, isGenerateBid) {
if (browserSignals.multiBidLimit !== 1)
throw 'Wrong multiBidLimit ' + browserSignals.multiBidLimit;
} else {
if (Object.keys(browserSignals).length !== 16) {
if (Object.keys(browserSignals).length !== 18) {
throw 'Wrong number of browser signals fields ' +
JSON.stringify(browserSignals);
}

@ -192,9 +192,13 @@ function validateBrowserSignals(browserSignals, isGenerateBid) {
throw 'Wrong seller ' + browserSignals.seller;
if (!browserSignals.topLevelSeller.startsWith('https://b.test'))
throw 'Wrong topLevelSeller ' + browserSignals.topLevelSeller;
if (!(browserSignals.decodeUtf8 instanceof Function))
throw 'Wrong decodeUtf8';
if (!(browserSignals.encodeUtf8 instanceof Function))
throw 'Wrong encodeUtf8';
if (isGenerateBid) {
if (Object.keys(browserSignals).length !== 12) {
if (Object.keys(browserSignals).length !== 14) {
throw 'Wrong number of browser signals fields ' +
JSON.stringify(browserSignals);
}
@ -217,7 +221,7 @@ function validateBrowserSignals(browserSignals, isGenerateBid) {
if (browserSignals.multiBidLimit !== 1)
throw 'Wrong multiBidLimit ' + browserSignals.multiBidLimit;
} else {
if (Object.keys(browserSignals).length !== 17) {
if (Object.keys(browserSignals).length !== 19) {
throw 'Wrong number of browser signals fields ' +
JSON.stringify(browserSignals);
}

@ -171,10 +171,14 @@ function validateBrowserSignals(browserSignals, isScoreAd) {
throw 'Wrong renderUrl ' + browserSignals.renderUrl;
if (browserSignals.bidCurrency != 'USD')
throw 'Wrong bidCurrency ' + browserSignals.bidCurrency;
if (!(browserSignals.decodeUtf8 instanceof Function))
throw 'Wrong decodeUtf8';
if (!(browserSignals.encodeUtf8 instanceof Function))
throw 'Wrong encodeUtf8';
// Fields that vary by method.
if (isScoreAd) {
if (Object.keys(browserSignals).length !== 12) {
if (Object.keys(browserSignals).length !== 14) {
throw 'Wrong number of browser signals fields ' +
JSON.stringify(browserSignals);
}
@ -197,7 +201,7 @@ function validateBrowserSignals(browserSignals, isScoreAd) {
throw 'Wrong forDebuggingOnlySampling ' +
browserSignals.forDebuggingOnlySampling;
} else {
if (Object.keys(browserSignals).length !== 13) {
if (Object.keys(browserSignals).length !== 15) {
throw 'Wrong number of browser signals fields ' +
JSON.stringify(browserSignals);
}

@ -185,12 +185,16 @@ function validateBrowserSignals(browserSignals, isScoreAd) {
throw 'Wrong renderURL ' + browserSignals.renderURL;
if (browserSignals.renderUrl !== "https://example.com/render")
throw 'Wrong renderUrl ' + browserSignals.renderUrl;
if (browserSignals.bidCurrency !== 'CAD')
throw 'Wrong bidCurrency ' + browserSignals.bidCurrency;
if (browserSignals.bidCurrency !== 'CAD')
throw 'Wrong bidCurrency ' + browserSignals.bidCurrency;
if (!(browserSignals.decodeUtf8 instanceof Function))
throw 'Wrong decodeUtf8';
if (!(browserSignals.encodeUtf8 instanceof Function))
throw 'Wrong encodeUtf8';
// Fields that vary by method.
if (isScoreAd) {
if (Object.keys(browserSignals).length !== 12) {
if (Object.keys(browserSignals).length !== 14) {
throw 'Wrong number of browser signals fields ' +
JSON.stringify(browserSignals);
}
@ -213,7 +217,7 @@ function validateBrowserSignals(browserSignals, isScoreAd) {
throw 'Wrong forDebuggingOnlySampling ' +
browserSignals.forDebuggingOnlySampling;
} else {
if (Object.keys(browserSignals).length !== 11) {
if (Object.keys(browserSignals).length !== 13) {
throw 'Wrong number of browser signals fields ' +
JSON.stringify(browserSignals);
}

@ -193,10 +193,14 @@ function validateBrowserSignals(browserSignals, isScoreAd) {
throw 'Wrong dataVersion ' + browserSignals.dataVersion;
if (browserSignals.bidCurrency !== 'USD')
throw 'Wrong bidCurrency ' + browserSignals.bidCurrency;
if (!(browserSignals.decodeUtf8 instanceof Function))
throw 'Wrong decodeUtf8';
if (!(browserSignals.encodeUtf8 instanceof Function))
throw 'Wrong encodeUtf8';
// Fields that vary by method.
if (isScoreAd) {
if (Object.keys(browserSignals).length !== 11) {
if (Object.keys(browserSignals).length !== 13) {
throw 'Wrong number of browser signals fields ' +
JSON.stringify(browserSignals);
}
@ -217,7 +221,7 @@ function validateBrowserSignals(browserSignals, isScoreAd) {
throw 'Wrong forDebuggingOnlySampling ' +
browserSignals.forDebuggingOnlySampling;
} else {
if (Object.keys(browserSignals).length !== 10) {
if (Object.keys(browserSignals).length !== 12) {
throw 'Wrong number of browser signals fields ' +
JSON.stringify(browserSignals);
}

@ -18378,6 +18378,25 @@
]
}
],
"ProtectedAudienceAuctionTextConversionHelpers": [
{
"platforms": [
"android",
"chromeos",
"linux",
"mac",
"windows"
],
"experiments": [
{
"name": "Enabled",
"enable_features": [
"FledgeTextConversionHelpers"
]
}
]
}
],
"ProtectedAudienceBAndAServerAPIMultiSeller": [
{
"platforms": [

@ -51,6 +51,11 @@ subsetTest(promise_test, async test => {
// Remove deprecated field, if present.
delete browserSignals.prevWins;
// encode/decode utf-8 are tested separately, and aren't
// suitable to equality testing.
delete browserSignals.encodeUtf8;
delete browserSignals.decodeUtf8;
if (!deepEquals(browserSignals, expectedBrowserSignals))
throw "Unexpected browserSignals: " + JSON.stringify(browserSignals);`
});

@ -0,0 +1,209 @@
// META: script=/resources/testdriver.js
// META: script=/resources/testdriver-vendor.js
// META: script=/common/utils.js
// META: script=resources/fledge-util.sub.js
// META: script=/common/subset-tests.js
// META: timeout=long
// META: variant=?1-5
// META: variant=?6-10
// META: variant=?11-15
'use strict;'
// These tests cover encodeUtf8 and decodeUtf8.
const helpers = `
function assertEq(l, r, label) {
if (l !== r)
throw 'Mismatch ' + label;
}
function assertByteArray(result, expect) {
if (!(result instanceof Uint8Array)) {
throw 'Not a Uint8Array!';
}
assertEq(result.length, expect.length, 'length');
for (var i = 0; i < result.length; ++i) {
assertEq(result[i], expect[i], i);
}
}
function assertString(result, expect) {
if (typeof result !== 'string') {
throw 'Not a string';
}
assertEq(result.length, expect.length, 'length');
for (var i = 0; i < result.length; ++i) {
assertEq(result.charCodeAt(i), expect.charCodeAt(i), i);
}
}
`
async function testConversion(test, conversionBody) {
const uuid = generateUuid(test);
let sellerReportURL = createSellerReportURL(uuid);
let bidderReportURL = createBidderReportURL(uuid);
let fullBody = `
${helpers}
${conversionBody}
`;
let biddingLogicURL = createBiddingScriptURL({
generateBid: fullBody,
reportWin: fullBody + `sendReportTo('${bidderReportURL}')`
});
let decisionLogicURL = createDecisionScriptURL(uuid, {
scoreAd: fullBody,
reportResult: fullBody + `sendReportTo('${sellerReportURL}')`
});
await joinInterestGroup(test, uuid, {biddingLogicURL: biddingLogicURL});
await runBasicFledgeAuctionAndNavigate(
test, uuid, {decisionLogicURL: decisionLogicURL});
await waitForObservedRequests(uuid, [sellerReportURL, bidderReportURL]);
}
async function testConversionException(test, conversionBody) {
const uuid = generateUuid(test);
let sellerReportURL = createSellerReportURL(uuid);
let bidderReportURL = createBidderReportURL(uuid);
let fullBody = `
${helpers}
try {
${conversionBody};
return -1;
} catch (e) {
}
`;
let biddingLogicURL = createBiddingScriptURL({
generateBid: fullBody,
reportWin: fullBody + `sendReportTo('${bidderReportURL}')`
});
let decisionLogicURL = createDecisionScriptURL(uuid, {
scoreAd: fullBody,
reportResult: fullBody + `sendReportTo('${sellerReportURL}')`
});
await joinInterestGroup(test, uuid, {biddingLogicURL: biddingLogicURL});
await runBasicFledgeAuctionAndNavigate(
test, uuid, {decisionLogicURL: decisionLogicURL});
await waitForObservedRequests(uuid, [sellerReportURL, bidderReportURL]);
}
subsetTest(promise_test, async test => {
await testConversion(
test, `let result = browserSignals.encodeUtf8('ABC\u0490');
assertByteArray(result, [65, 66, 67, 0xD2, 0x90])`);
}, 'encodeUtf8 - basic');
subsetTest(promise_test, async test => {
await testConversion(
test, `let result = browserSignals.encodeUtf8('A\uD800C');
assertByteArray(result, [65, 0xEF, 0xBF, 0xBD, 67])`);
}, 'encodeUtf8 - mismatched surrogate gets replaced');
subsetTest(promise_test, async test => {
await testConversion(
test, `let result = browserSignals.encodeUtf8('A\uD83D\uDE02C');
assertByteArray(result, [65, 0xF0, 0x9F, 0x98, 0x82, 67])`);
}, 'encodeUtf8 - surrogate pair combined');
subsetTest(promise_test, async test => {
const conversionBody = `
let obj = {
toString: () => "ABC"
};
let result = browserSignals.encodeUtf8(obj);
assertByteArray(result, [65, 66, 67])
`;
await testConversion(test, conversionBody);
}, 'encodeUtf8 - custom string conversion');
subsetTest(promise_test, async test => {
const conversionBody = `
let result = browserSignals.encodeUtf8();
`;
await testConversionException(test, conversionBody);
}, 'encodeUtf8 - not enough arguments');
subsetTest(promise_test, async test => {
const conversionBody = `
let obj = {
toString: () => { throw 'no go' }
};
let result = browserSignals.encodeUtf8(obj);
`;
await testConversionException(test, conversionBody);
}, 'encodeUtf8 - custom string conversion failure');
subsetTest(promise_test, async test => {
const conversionBody = `
let input = new Uint8Array([65, 66, 0xD2, 0x90, 67]);
let result = browserSignals.decodeUtf8(input);
assertString(result, 'AB\u0490C');
`;
await testConversion(test, conversionBody);
}, 'decodeUtf8 - basic');
subsetTest(promise_test, async test => {
const conversionBody = `
let input = new Uint8Array([65, 32, 0xD2]);
let result = browserSignals.decodeUtf8(input);
if (result.indexOf('\uFFFD') === -1)
throw 'Should have replacement character';
`;
await testConversion(test, conversionBody);
}, 'decodeUtf8 - broken utf-8');
subsetTest(promise_test, async test => {
const conversionBody = `
let input = new Uint8Array([65, 32, 0xED, 0xA0, 0x80, 66]);
let result = browserSignals.decodeUtf8(input);
if (result.indexOf('\uFFFD') === -1)
throw 'Should have replacement character';
`;
await testConversion(test, conversionBody);
}, 'decodeUtf8 - mismatched surrogate');
subsetTest(promise_test, async test => {
const conversionBody = `
let input = new Uint8Array([65, 0xF0, 0x9F, 0x98, 0x82, 67]);
let result = browserSignals.decodeUtf8(input);
assertString(result, 'A\uD83D\uDE02C');
`;
await testConversion(test, conversionBody);
}, 'decodeUtf8 - non-BMP character');
subsetTest(promise_test, async test => {
const conversionBody = `
let buffer = new ArrayBuffer(8);
let fullView = new Uint8Array(buffer);
for (let i = 0; i < fullView.length; ++i)
fullView[i] = 65 + i;
let partialView = new Uint8Array(buffer, 2, 3);
assertString(browserSignals.decodeUtf8(fullView),
'ABCDEFGH');
assertString(browserSignals.decodeUtf8(partialView),
'CDE');
`;
await testConversion(test, conversionBody);
}, 'decodeUtf8 - proper Uint8Array handling');
subsetTest(promise_test, async test => {
const conversionBody = `
let result = browserSignals.decodeUtf8();
`;
await testConversionException(test, conversionBody);
}, 'decodeUtf8 - not enough arguments');
subsetTest(promise_test, async test => {
const conversionBody = `
let result = browserSignals.decodeUtf8([65, 32, 66]);
`;
await testConversionException(test, conversionBody);
}, 'decodeUtf8 - wrong type');