From 71bf12926409f706f9740b56ad188e552222af5e Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Wed, 14 Oct 2026 23:22:11 +0100 Subject: [PATCH 1/9] The consent decoders live in the crates named for their standards Core decoded the TCF, GPP and US Privacy strings itself, in its consent module, and the permission signal crates named for those standards read what core had decoded. A standard is a vendor's thing as much as a product is, so each decoder moves to the crate for its standard: the TC String decoder, record and purpose mapping to permission-signal/tcf, the GPP string decoder with the iab_gpp dependency to permission-signal/gpp, and the US Privacy string decoder to permission-signal/us-privacy. Each module reads its own string from the request's cookies and headers, once, through a new `read` method on the permission signal trait, and reports a `SignalRecord`: the string as received, whether it decoded, could not be read, has expired or was left as received, where it came from, whether the module says its regulation applies, the sections a sectioned signal carries, and the decoded form in the module's own type, opaque to core. Core keeps the neutral types only. `ConsentContext` is the records the configured modules reported and the request's jurisdiction, `ConsentSource` says where a signal came from, and `SignalStatus` what became of it. The TCF module settles a standalone string against the EU section of a GPP string under the `[consent]` conflict rule, expires a stale record and says the regulation applies from a section hint, asking the GPP crate for the section and the identifiers. The US Privacy module constructs a string from a Global Privacy Control header in a US state under the declared defaults, asking the GPC crate whether the header was sent. Every reader reads the resolved state or asks the crate for the standard. The server-side auction gate reads the resolved permissions, running the auction where basic ad selection is set, where it read the decoded TCF record for one purpose under a jurisdiction rule of its own. The identity graph record writes every vouched signal under its scheme, in the shape it wrote before for the two it stored. Telemetry reads whether any module says its regulation applies. The OpenRTB request builder still in core reads the strings by scheme name through the neutral interface until it leaves core. The mock ad server and the demand crates ask the crates for the standards. `[consent]` keeps its keys. Core reads `mode` and holds the rest as written for the modules to read into types of their own, so a key no linked module reads is kept rather than refused. The policy reads `signals..authoritative` for any scheme and the opt-out sources by the names the modules give them, where it parsed three fixed names. Removed with the pipeline they served: the consent cookie constants, the consent cookie list and cookie forwarder in core, the `GdprConsent` error that nothing raised, the never-filled Additional Consent field and the demand policy flag that sent it, the core consent decoding bench, and the consent forwarding mode enum, which is now the Prebid Server crate's own. --- CHANGELOG.md | 1 + Cargo.lock | 34 +- crates/ad-server/mock/Cargo.toml | 3 + crates/ad-server/mock/src/lib.rs | 48 +- crates/auction/aps/Cargo.toml | 4 + crates/auction/aps/src/lib.rs | 66 +- crates/auction/prebid-server/Cargo.toml | 4 + crates/auction/prebid-server/src/lib.rs | 298 +++-- crates/permission-signal/gpc/Cargo.toml | 3 + crates/permission-signal/gpc/src/lib.rs | 106 +- crates/permission-signal/gpp/Cargo.toml | 9 +- crates/permission-signal/gpp/src/decode.rs | 376 ++++++ crates/permission-signal/gpp/src/lib.rs | 266 +++- crates/permission-signal/tcf/Cargo.toml | 13 +- .../tcf/src/decode.rs} | 400 +++--- crates/permission-signal/tcf/src/lib.rs | 733 +++++++++-- .../permission-signal/us-privacy/Cargo.toml | 11 +- .../us-privacy/src/decode.rs | 219 ++++ .../permission-signal/us-privacy/src/lib.rs | 474 ++++++- .../tests/permission_signals.rs | 299 +++-- .../src/platform.rs | 5 +- crates/trusted-server-core/Cargo.toml | 5 - .../benches/consent_decode.rs | 236 ---- .../trusted-server-core/src/auction/demand.rs | 2 - .../src/auction/endpoints.rs | 40 +- .../src/auction/openrtb.rs | 63 +- .../src/auction/openrtb/tests.rs | 45 +- .../src/auction/telemetry.rs | 5 +- .../src/auction/test_support.rs | 28 +- .../src/consent/extraction.rs | 194 --- crates/trusted-server-core/src/consent/gpp.rs | 464 ------- .../src/consent/jurisdiction.rs | 9 +- crates/trusted-server-core/src/consent/mod.rs | 1105 ++++------------- .../trusted-server-core/src/consent/types.rs | 857 +++++-------- .../src/consent/us_privacy.rs | 164 --- .../trusted-server-core/src/consent_config.rs | 436 ++----- crates/trusted-server-core/src/constants.rs | 9 - crates/trusted-server-core/src/cookies.rs | 196 +-- crates/trusted-server-core/src/ec/consent.rs | 62 +- crates/trusted-server-core/src/ec/finalize.rs | 115 +- crates/trusted-server-core/src/ec/identify.rs | 12 +- crates/trusted-server-core/src/ec/kv_types.rs | 107 +- crates/trusted-server-core/src/ec/mod.rs | 108 +- crates/trusted-server-core/src/ec/module.rs | 10 +- crates/trusted-server-core/src/error.rs | 30 - .../trusted-server-core/src/inspect/data.rs | 15 +- .../src/inspect/permissions.rs | 19 +- crates/trusted-server-core/src/lib.rs | 2 +- .../src/permission_signal/README.md | 74 +- .../src/permission_signal/mod.rs | 16 +- crates/trusted-server-core/src/permissions.rs | 179 +-- crates/trusted-server-core/src/publisher.rs | 20 +- crates/trusted-server-core/src/settings.rs | 5 +- .../trusted-server-core/src/test_support.rs | 40 +- docs/guide/configuration.md | 43 +- docs/guide/gdpr-compliance.md | 12 +- docs/guide/permission-model.md | 2 +- docs/guide/permission-signals.md | 77 +- .../2026-10-14-consent-decoders-design.md | 45 + 59 files changed, 3901 insertions(+), 4322 deletions(-) create mode 100644 crates/permission-signal/gpp/src/decode.rs rename crates/{trusted-server-core/src/consent/tcf.rs => permission-signal/tcf/src/decode.rs} (61%) create mode 100644 crates/permission-signal/us-privacy/src/decode.rs delete mode 100644 crates/trusted-server-core/benches/consent_decode.rs delete mode 100644 crates/trusted-server-core/src/consent/extraction.rs delete mode 100644 crates/trusted-server-core/src/consent/gpp.rs delete mode 100644 crates/trusted-server-core/src/consent/us_privacy.rs create mode 100644 docs/superpowers/specs/2026-10-14-consent-decoders-design.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 6373a277bd..d8e02358ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- **Breaking for crates outside core:** the consent decoders live in the crates named for their standards. The TCF string decoder is in `trusted-server-permission-signal-tcf`, the GPP string decoder in `trusted-server-permission-signal-gpp`, and the US Privacy string decoder in `trusted-server-permission-signal-us-privacy`, and each module reads its own string from the request through `PermissionSignalModule::read`, once, and reports a `SignalRecord`. `ConsentContext` is the records the configured modules reported and the request's jurisdiction, read by scheme name, with nothing of any standard in it, and a reader that needs a string in its standard's own form asks that standard's crate (`tc_string`, `applies`, `gpp_string`, `section_ids`, `us_privacy_string`, `signaled`). The server-side auction gate reads the resolved permissions, running the auction where `advertising_marketing.first_party.contextual` is set. `[consent]` keeps its keys, with `mode` read by core and the rest read by the modules, and `signals..authoritative` in `permissions.yaml` is read for any scheme. The identity graph record writes every vouched signal under its scheme, in the shape it wrote before for the two it stored. `CONSENT_COOKIE_NAMES`, `forward_cookie_header`, the consent cookie constants, `ConsentForwardingMode` (now the Prebid Server crate's), the `GdprConsent` error and the never-filled Additional Consent field and `additional_consent` policy flag are removed. - **Breaking:** Where content comes from is a source module. `[source]` selects the publisher's, and each `[[proxy.asset_routes]]` entry selects its own in a `source` table of the same shape, with `module` naming the source and its settings in the table beneath. Core supplies `origin`, an HTTP origin, and a module a build links can offer another through `IntegrationRegistrationBuilder::with_source_module`. A source answers for each request where that request's content comes from, and core fetches, caches, rewrites the origin host and follows the redirect rule from that answer. `[source]` is required. The origin settings move into the `origin` table, and a document that still writes one where it was is refused at startup naming the new place. An environment variable that set the address is now `TRUSTED_SERVER__SOURCE__ORIGIN__URL`. The old name is refused where the build reads variables, and `ts config push` ignores it, as it ignores any variable for a key the document does not hold. A path in an asset route's `url` names a folder the target path is added under, where a path was refused, a target path with a `.` or `..` segment under a folder is answered `400`, and a route applies `directory_index` as the publisher does. An existing deployment's stored pages and assets keep their keys. **For crates outside core**, `handle_asset_proxy_request` takes the route's source, which `IntegrationRegistry::route_source` gives, and `Publisher::origin_host`, `origin_host_header` and `origin_query_is_stripped` are replaced by `Settings::origin_source` and the `SourceLocation` a source answers. | Previous | Now | diff --git a/Cargo.lock b/Cargo.lock index 5c87f2354f..0314cdb8a3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6562,6 +6562,9 @@ dependencies = [ "serde", "serde_json", "trusted-server-core", + "trusted-server-permission-signal-gpp", + "trusted-server-permission-signal-tcf", + "trusted-server-permission-signal-us-privacy", "url", "validator", ] @@ -6725,6 +6728,10 @@ dependencies = [ "serde_json", "tokio", "trusted-server-core", + "trusted-server-permission-signal-gpc", + "trusted-server-permission-signal-gpp", + "trusted-server-permission-signal-tcf", + "trusted-server-permission-signal-us-privacy", "url", "validator", ] @@ -6766,6 +6773,10 @@ dependencies = [ "serde_json", "tokio", "trusted-server-core", + "trusted-server-permission-signal-gpc", + "trusted-server-permission-signal-gpp", + "trusted-server-permission-signal-tcf", + "trusted-server-permission-signal-us-privacy", "url", "validator", "web-time", @@ -6960,7 +6971,6 @@ dependencies = [ "hmac", "http", "httpdate", - "iab_gpp", "jose-jwk", "log", "lol_html", @@ -7160,6 +7170,7 @@ dependencies = [ name = "trusted-server-permission-signal-gpc" version = "0.1.0" dependencies = [ + "http", "trusted-server-core", ] @@ -7167,6 +7178,11 @@ dependencies = [ name = "trusted-server-permission-signal-gpp" version = "0.1.0" dependencies = [ + "derive_more", + "error-stack", + "http", + "iab_gpp", + "log", "trusted-server-core", ] @@ -7182,14 +7198,30 @@ dependencies = [ name = "trusted-server-permission-signal-tcf" version = "0.1.0" dependencies = [ + "base64", + "derive_more", + "error-stack", + "http", + "log", + "serde", + "serde_json", "trusted-server-core", + "trusted-server-permission-signal-gpp", + "web-time", ] [[package]] name = "trusted-server-permission-signal-us-privacy" version = "0.1.0" dependencies = [ + "derive_more", + "error-stack", + "http", + "log", + "serde", + "serde_json", "trusted-server-core", + "trusted-server-permission-signal-gpc", ] [[package]] diff --git a/crates/ad-server/mock/Cargo.toml b/crates/ad-server/mock/Cargo.toml index 34a995eb62..aa122197ce 100644 --- a/crates/ad-server/mock/Cargo.toml +++ b/crates/ad-server/mock/Cargo.toml @@ -22,6 +22,9 @@ log = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } trusted-server-core = { workspace = true } +trusted-server-permission-signal-gpp = { workspace = true } +trusted-server-permission-signal-tcf = { workspace = true } +trusted-server-permission-signal-us-privacy = { workspace = true } url = { workspace = true } validator = { workspace = true } diff --git a/crates/ad-server/mock/src/lib.rs b/crates/ad-server/mock/src/lib.rs index 07bd08e92f..f32ff7e7ad 100644 --- a/crates/ad-server/mock/src/lib.rs +++ b/crates/ad-server/mock/src/lib.rs @@ -270,14 +270,14 @@ impl AdServerMockProvider { json!(null) }; - // Build consent summary from ConsentContext + // The consent summary, each string asked of the crate for its scheme. let consent_json = request.user.consent.as_ref().map(|ctx| { json!({ - "gdpr": if ctx.gdpr_applies { 1 } else { 0 }, - "consent": ctx.raw_tc_string, - "us_privacy": ctx.raw_us_privacy, - "gpp": ctx.raw_gpp_string, - "gpp_sid": ctx.gpp_section_ids, + "gdpr": u8::from(trusted_server_permission_signal_tcf::applies(ctx)), + "consent": trusted_server_permission_signal_tcf::tc_string(ctx), + "us_privacy": trusted_server_permission_signal_us_privacy::us_privacy_string(ctx), + "gpp": trusted_server_permission_signal_gpp::gpp_string(ctx), + "gpp_sid": trusted_server_permission_signal_gpp::section_ids(ctx), }) }); @@ -1269,7 +1269,7 @@ mod tests { #[test] fn test_adserver_request_includes_consent() { - use trusted_server_core::consent::ConsentContext; + use trusted_server_core::consent::{ConsentContext, SignalRecord, SignalStatus}; let config = AdServerMockSettings { endpoint: "http://localhost:6767/mediate".to_string(), @@ -1281,14 +1281,32 @@ mod tests { let provider = AdServerMockProvider::new("adserver_mock", config); let mut request = create_test_auction_request(); - request.user.consent = Some(ConsentContext { - raw_tc_string: Some("BOEFEAyO".to_string()), - gdpr_applies: true, - raw_us_privacy: Some("1YNN".to_string()), - raw_gpp_string: Some("DBACNYA~CPXxRfAPXxRfA".to_string()), - gpp_section_ids: Some(vec![2, 6]), - ..Default::default() - }); + request.user.consent = Some( + ConsentContext::default() + .with_record( + SignalRecord::new( + trusted_server_permission_signal_tcf::SCHEME, + SignalStatus::Decoded, + ) + .with_value("BOEFEAyO") + .with_applies(true), + ) + .with_record( + SignalRecord::new( + trusted_server_permission_signal_us_privacy::SCHEME, + SignalStatus::Decoded, + ) + .with_value("1YNN"), + ) + .with_record( + SignalRecord::new( + trusted_server_permission_signal_gpp::SCHEME, + SignalStatus::Decoded, + ) + .with_value("DBACNYA~CPXxRfAPXxRfA") + .with_sections(vec![2, 6]), + ), + ); let adserver_req = provider .build_adserver_request(&request, &[]) diff --git a/crates/auction/aps/Cargo.toml b/crates/auction/aps/Cargo.toml index 7156f62a3e..d86a4c7c9f 100644 --- a/crates/auction/aps/Cargo.toml +++ b/crates/auction/aps/Cargo.toml @@ -23,6 +23,9 @@ log = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } trusted-server-core = { workspace = true } +trusted-server-permission-signal-gpp = { workspace = true } +trusted-server-permission-signal-tcf = { workspace = true } +trusted-server-permission-signal-us-privacy = { workspace = true } url = { workspace = true } validator = { workspace = true } @@ -30,6 +33,7 @@ validator = { workspace = true } futures = { workspace = true } tokio = { workspace = true } trusted-server-core = { workspace = true, features = ["test-utils"] } +trusted-server-permission-signal-gpc = { workspace = true } # The visible owner of this vendor crate, the way Prebid.js requires a named # maintainer of every adapter. Amazon has not yet adopted this crate, so the diff --git a/crates/auction/aps/src/lib.rs b/crates/auction/aps/src/lib.rs index 3d7b8b11f1..f5aba43b74 100644 --- a/crates/auction/aps/src/lib.rs +++ b/crates/auction/aps/src/lib.rs @@ -1205,15 +1205,18 @@ impl ApsAuctionProvider { fn build_regs(consent: Option<&trusted_server_core::consent::ConsentContext>) -> Option { let consent = consent?; + let applies = trusted_server_permission_signal_tcf::applies(consent); let ext = RegsExt { - gdpr: Some(u8::from(consent.gdpr_applies)), - us_privacy: consent.raw_us_privacy.clone(), - gpp: consent.raw_gpp_string.clone(), - gpp_sid: consent.gpp_section_ids.clone(), + gdpr: Some(u8::from(applies)), + us_privacy: trusted_server_permission_signal_us_privacy::us_privacy_string(consent) + .map(str::to_owned), + gpp: trusted_server_permission_signal_gpp::gpp_string(consent).map(str::to_owned), + gpp_sid: trusted_server_permission_signal_gpp::section_ids(consent) + .map(<[u16]>::to_vec), }; Some(Regs { coppa: None, - gdpr: Some(consent.gdpr_applies), + gdpr: Some(applies), us_privacy: ext.us_privacy.clone(), gpp: ext.gpp.clone(), gpp_sid: ext @@ -1328,7 +1331,9 @@ impl ApsAuctionProvider { .collect(); let consent = request.user.consent.as_ref(); - let raw_tc = consent.and_then(|value| value.raw_tc_string.clone()); + let raw_tc = consent + .and_then(trusted_server_permission_signal_tcf::tc_string) + .map(str::to_owned); let user = Some(User { id: request.user.id.clone(), consent: raw_tc.clone(), @@ -3852,45 +3857,42 @@ mod engine_tests { #[test] fn consent_fields_keep_an_empty_admitted_context() { - use trusted_server_core::consent::ConsentContext; use trusted_server_core::consent::jurisdiction::Jurisdiction; - + use trusted_server_core::consent::{ConsentContext, SignalRecord, SignalStatus}; + use trusted_server_permission_signal_gpc as gpc; + use trusted_server_permission_signal_gpp as gpp; + use trusted_server_permission_signal_tcf as tcf; + use trusted_server_permission_signal_us_privacy as us_privacy; + + let applying = SignalRecord::new(tcf::SCHEME, SignalStatus::Decoded) + .with_value("tc-string") + .with_applies(true); let cases = [ ("empty", ConsentContext::default()), ( "gdpr", - ConsentContext { - gdpr_applies: true, - raw_tc_string: Some("tc-string".to_string()), - jurisdiction: Jurisdiction::Gdpr, - ..Default::default() - }, + ConsentContext::new(Jurisdiction::Gdpr).with_record(applying), ), ( "unknown-gpc", - ConsentContext { - gpc: true, - jurisdiction: Jurisdiction::Unknown, - ..Default::default() - }, + ConsentContext::new(Jurisdiction::Unknown).with_record(gpc::record()), ), ( "nonregulated-gpc", - ConsentContext { - gpc: true, - jurisdiction: Jurisdiction::NonRegulated, - ..Default::default() - }, + ConsentContext::new(Jurisdiction::NonRegulated).with_record(gpc::record()), ), ( "usp-gpp", - ConsentContext { - raw_us_privacy: Some("1YNN".to_string()), - raw_gpp_string: Some("gpp-string".to_string()), - gpp_section_ids: Some(vec![7, 8]), - jurisdiction: Jurisdiction::NonRegulated, - ..Default::default() - }, + ConsentContext::new(Jurisdiction::NonRegulated) + .with_record( + SignalRecord::new(us_privacy::SCHEME, SignalStatus::Decoded) + .with_value("1YNN"), + ) + .with_record( + SignalRecord::new(gpp::SCHEME, SignalStatus::Decoded) + .with_value("gpp-string") + .with_sections(vec![7, 8]), + ), ), ]; for (name, consent) in cases { @@ -3907,7 +3909,7 @@ mod engine_tests { .expect("APS should preserve empty admitted context"); assert_eq!( regs["gdpr"], - serde_json::json!(u8::from(consent.gdpr_applies)), + serde_json::json!(u8::from(tcf::applies(&consent))), "{name}" ); assert!( diff --git a/crates/auction/prebid-server/Cargo.toml b/crates/auction/prebid-server/Cargo.toml index c1bfa1cefd..e40bd8cba5 100644 --- a/crates/auction/prebid-server/Cargo.toml +++ b/crates/auction/prebid-server/Cargo.toml @@ -21,6 +21,10 @@ log = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } trusted-server-core = { workspace = true } +trusted-server-permission-signal-gpc = { workspace = true } +trusted-server-permission-signal-gpp = { workspace = true } +trusted-server-permission-signal-tcf = { workspace = true } +trusted-server-permission-signal-us-privacy = { workspace = true } url = { workspace = true } [dev-dependencies] diff --git a/crates/auction/prebid-server/src/lib.rs b/crates/auction/prebid-server/src/lib.rs index afedc6627a..f4b5080107 100644 --- a/crates/auction/prebid-server/src/lib.rs +++ b/crates/auction/prebid-server/src/lib.rs @@ -55,8 +55,7 @@ use trusted_server_core::auction::provider::{AuctionProvider, ProviderRequestOut use trusted_server_core::auction::types::{AuctionContext, AuctionRequest, MediaType}; use trusted_server_core::auction::types::{AuctionResponse, Bid as AuctionBid}; use trusted_server_core::consent::{ConsentContext, ConsentSource}; -use trusted_server_core::consent_config::ConsentForwardingMode; -use trusted_server_core::cookies::{CONSENT_COOKIE_NAMES, strip_cookies}; +use trusted_server_core::cookies::strip_cookies; use trusted_server_core::error::TrustedServerError; #[cfg(test)] use trusted_server_core::http_util::RequestInfo; @@ -69,8 +68,8 @@ use trusted_server_core::integrations::{ }; #[cfg(test)] use trusted_server_core::openrtb::{ - Banner, ConsentedProvidersSettings, Device, Format, Geo, Imp, OpenRtbRequest, Publisher, Regs, - RegsExt, Site, ToExt, TrustedServerExt, User, UserExt, to_openrtb_i32, + Banner, Device, Format, Geo, Imp, OpenRtbRequest, Publisher, Regs, RegsExt, Site, ToExt, + TrustedServerExt, User, UserExt, to_openrtb_i32, }; #[cfg(test)] use trusted_server_core::platform::PlatformHttpRequest; @@ -81,6 +80,11 @@ use trusted_server_core::platform::RuntimeServices; use trusted_server_core::request_signing::{RequestSigner, SIGNING_VERSION, SigningParams}; #[cfg(test)] use trusted_server_core::settings::{IntegrationConfig, Settings}; +#[cfg(test)] +use trusted_server_permission_signal_gpc as gpc; +use trusted_server_permission_signal_gpp as gpp; +use trusted_server_permission_signal_tcf as tcf; +use trusted_server_permission_signal_us_privacy as us_privacy; /// The name an `implementation` line gives this implementation, its module /// path, which also serves as its builder's id. @@ -177,6 +181,61 @@ fn canonicalize_endpoint(endpoint: &mut Url) -> Result<(), String> { Ok(()) } +/// The cookies the consent signals travel in, stripped from a forwarded +/// `Cookie` header when consent travels in the body alone. +const CONSENT_COOKIE_NAMES: &[&str] = &[ + tcf::COOKIE_EUCONSENT_V2, + gpp::COOKIE_GPP, + gpp::COOKIE_GPP_SID, + us_privacy::COOKIE_US_PRIVACY, +]; + +/// How consent signals are forwarded to Prebid Server. +/// +/// Controls whether consent travels through the `OpenRTB` body, raw `Cookie` +/// headers, or both. The default (`Both`) preserves backward compatibility. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, Default)] +#[serde(rename_all = "snake_case")] +pub enum ConsentForwardingMode { + /// Forward consent in the `OpenRTB` body only; strip consent cookies. + OpenrtbOnly, + /// Forward consent cookies only; omit consent fields from the body. + CookiesOnly, + /// Forward consent in both cookies and body (default). + #[default] + Both, +} + +impl ConsentForwardingMode { + /// Whether consent cookies should be stripped from forwarded requests. + /// + /// Returns `true` for [`OpenrtbOnly`](Self::OpenrtbOnly) since consent + /// travels exclusively through the request body in that mode. + #[must_use] + pub const fn strips_consent_cookies(self) -> bool { + matches!(self, Self::OpenrtbOnly) + } + + /// Whether consent fields should be included in the request body. + /// + /// Returns `true` for [`OpenrtbOnly`](Self::OpenrtbOnly) and + /// [`Both`](Self::Both); `false` for [`CookiesOnly`](Self::CookiesOnly). + #[must_use] + pub const fn includes_body_consent(self) -> bool { + !matches!(self, Self::CookiesOnly) + } +} + +/// Whether a consent context carries a signal the deployment constructed +/// from its declared defaults, which has no cookie to forward and so travels +/// in the body whatever the forwarding mode. +fn carries_policy_default(consent: &ConsentContext) -> bool { + consent + .records + .iter() + .any(|record| record.source() == ConsentSource::PolicyDefault) +} + #[async_trait(?Send)] impl CompiledDemand for PrebidServerDemand { fn field_policy(&self) -> DemandFieldPolicy { @@ -184,7 +243,6 @@ impl CompiledDemand for PrebidServerDemand { imp_tagid: true, site_ref: true, precise_geo: true, - additional_consent: true, test: self.test_mode, unsigned_request_identity: true, consumes_bidder_params: true, @@ -205,8 +263,7 @@ impl CompiledDemand for PrebidServerDemand { fn body_consent<'c>(&self, consent: Option<&'c ConsentContext>) -> Option<&'c ConsentContext> { consent.filter(|value| { - self.consent_forwarding.includes_body_consent() - || !matches!(value.source, ConsentSource::Cookie) + self.consent_forwarding.includes_body_consent() || carries_policy_default(value) }) } @@ -1812,13 +1869,9 @@ impl PrebidAuctionProvider { // forwarded Cookie header. Policy-sourced consent has no inbound cookie // to forward, so carry it in the OpenRTB body instead. let consent_ctx = request.user.consent.as_ref().filter(|ctx| { - self.config.consent_forwarding.includes_body_consent() - || !matches!( - ctx.source, - trusted_server_core::consent::ConsentSource::Cookie - ) + self.config.consent_forwarding.includes_body_consent() || carries_policy_default(ctx) }); - let raw_tc = consent_ctx.and_then(|c| c.raw_tc_string.clone()); + let raw_tc = consent_ctx.and_then(|c| tcf::tc_string(c).map(str::to_owned)); let user = Some(User { id: request.user.id.clone(), // OpenRTB 2.6 top-level consent field @@ -1826,11 +1879,7 @@ impl PrebidAuctionProvider { ext: UserExt { // Prebid ext-based consent field consent: raw_tc, - consented_providers_settings: consent_ctx - .and_then(|c| c.raw_ac_string.as_ref()) - .map(|ac| ConsentedProvidersSettings { - consented_providers: Some(ac.clone()), - }), + consented_providers_settings: None, // EIDs resolved from the KV identity graph and gated on the // resolved permission state in `handle_auction` via // `gate_eids_by_permissions`. @@ -2029,11 +2078,15 @@ impl PrebidAuctionProvider { ) -> Option { let ctx = consent_ctx?; - let has_data = ctx.gdpr_applies - || ctx.raw_us_privacy.is_some() - || ctx.raw_gpp_string.is_some() - || ctx.gpp_section_ids.is_some() - || ctx.gpc; + let applies = tcf::applies(ctx); + let us_privacy = us_privacy::us_privacy_string(ctx).map(str::to_owned); + let gpp_string = gpp::gpp_string(ctx).map(str::to_owned); + let gpp_sid = gpp::section_ids(ctx).map(<[u16]>::to_vec); + let has_data = applies + || us_privacy.is_some() + || gpp_string.is_some() + || gpp_sid.is_some() + || gpc::signaled(ctx); if !has_data { return None; @@ -2048,7 +2101,7 @@ impl PrebidAuctionProvider { ctx.jurisdiction, trusted_server_core::consent::jurisdiction::Jurisdiction::Gdpr ); - let gdpr = if ctx.gdpr_applies || in_gdpr_jurisdiction { + let gdpr = if applies || in_gdpr_jurisdiction { Some(true) } else if matches!( ctx.jurisdiction, @@ -2062,9 +2115,7 @@ impl PrebidAuctionProvider { // Dual-placement: OpenRTB 2.6 top-level fields AND `regs.ext` for // backward compatibility with older exchanges. Extract top-level // values first, then clone once into the ext to avoid extra copies. - let us_privacy = ctx.raw_us_privacy.clone(); - let gpp = ctx.raw_gpp_string.clone(); - let gpp_sid = ctx.gpp_section_ids.clone(); + let gpp = gpp_string; // RegsExt uses u8 for GDPR (Prebid convention) while the top-level // Regs uses bool (OpenRTB proto). Map accordingly. @@ -2702,7 +2753,27 @@ mod tests { AdFormat, AdSlot, AuctionContext, AuctionRequest, DeviceInfo, PublisherInfo, UserInfo, }; - use trusted_server_core::consent::{ConsentContext, ConsentSource}; + use trusted_server_core::consent::{ConsentContext, ConsentSource, SignalRecord, SignalStatus}; + + /// A TC String record, as the TCF module reports one it decoded. + fn tc_record(raw: &str) -> SignalRecord { + SignalRecord::new(tcf::SCHEME, SignalStatus::Decoded) + .with_value(raw) + .with_applies(true) + } + + /// A US Privacy string record, as the US Privacy module reports one it + /// decoded. + fn usp_record(raw: &str) -> SignalRecord { + SignalRecord::new(us_privacy::SCHEME, SignalStatus::Decoded).with_value(raw) + } + + /// A GPP string record, as the GPP module reports one it decoded. + fn gpp_record(raw: &str, sections: Vec) -> SignalRecord { + SignalRecord::new(gpp::SCHEME, SignalStatus::Decoded) + .with_value(raw) + .with_sections(sections) + } use trusted_server_core::geo::GeoInfo; use trusted_server_core::platform::test_support::{ @@ -3344,11 +3415,8 @@ assume_single_jurisdiction = true fn to_openrtb_includes_consent_and_gdpr_flag_from_geo() { let provider = PrebidAuctionProvider::new(base_config()); let mut auction_request = create_test_auction_request(); - auction_request.user.consent = Some(ConsentContext { - raw_tc_string: Some("BOtest-consent-string".to_string()), - gdpr_applies: true, - ..Default::default() - }); + auction_request.user.consent = + Some(ConsentContext::default().with_record(tc_record("BOtest-consent-string"))); // Set device with EU geo so GDPR applies from geo check auction_request.device = Some(DeviceInfo { user_agent: Some("TestAgent".to_string()), @@ -3395,13 +3463,11 @@ assume_single_jurisdiction = true config.consent_forwarding = ConsentForwardingMode::CookiesOnly; let provider = PrebidAuctionProvider::new(config); let mut auction_request = create_test_auction_request(); - auction_request.user.consent = Some(ConsentContext { - raw_tc_string: Some("BOpolicy-consent-string".to_string()), - raw_us_privacy: Some("1YNN".to_string()), - gdpr_applies: true, - source: ConsentSource::PolicyDefault, - ..Default::default() - }); + auction_request.user.consent = Some( + ConsentContext::default() + .with_record(tc_record("BOpolicy-consent-string")) + .with_record(usp_record("1YNN").with_source(ConsentSource::PolicyDefault)), + ); let settings = make_settings(); let request = build_test_request(); @@ -3439,11 +3505,8 @@ assume_single_jurisdiction = true // cause false negatives). let provider = PrebidAuctionProvider::new(base_config()); let mut auction_request = create_test_auction_request(); - auction_request.user.consent = Some(ConsentContext { - raw_tc_string: Some("BOtest-consent-string".to_string()), - gdpr_applies: true, - ..Default::default() - }); + auction_request.user.consent = + Some(ConsentContext::default().with_record(tc_record("BOtest-consent-string"))); // US geo — but consent string overrides auction_request.device = Some(DeviceInfo { user_agent: Some("TestAgent".to_string()), @@ -3522,11 +3585,8 @@ assume_single_jurisdiction = true fn to_openrtb_falls_back_to_consent_when_no_geo() { let provider = PrebidAuctionProvider::new(base_config()); let mut auction_request = create_test_auction_request(); - auction_request.user.consent = Some(ConsentContext { - raw_tc_string: Some("BOtest-consent-string".to_string()), - gdpr_applies: true, - ..Default::default() - }); + auction_request.user.consent = + Some(ConsentContext::default().with_record(tc_record("BOtest-consent-string"))); // No device/geo let settings = make_settings(); @@ -3570,13 +3630,13 @@ assume_single_jurisdiction = true fn to_openrtb_sets_gpc_us_privacy() { let provider = PrebidAuctionProvider::new(base_config()); let mut auction_request = create_test_auction_request(); - // GPC signal is carried via ConsentContext, populated by the - // consent extraction pipeline from the Sec-GPC header. - auction_request.user.consent = Some(ConsentContext { - gpc: true, - raw_us_privacy: Some(GPC_US_PRIVACY.to_string()), - ..Default::default() - }); + // The browser setting and the string the US Privacy module + // constructed from it, as the modules report them. + auction_request.user.consent = Some( + ConsentContext::default() + .with_record(gpc::record()) + .with_record(usp_record(GPC_US_PRIVACY).with_source(ConsentSource::PolicyDefault)), + ); let settings = make_settings(); let request = build_test_request(); @@ -3622,11 +3682,7 @@ assume_single_jurisdiction = true #[test] fn build_regs_populates_gpp_and_section_ids() { - let ctx = ConsentContext { - raw_gpp_string: Some("DBACNYA~CPXxRfA".to_string()), - gpp_section_ids: Some(vec![2, 6]), - ..Default::default() - }; + let ctx = ConsentContext::default().with_record(gpp_record("DBACNYA~CPXxRfA", vec![2, 6])); let regs = PrebidAuctionProvider::build_regs(Some(&ctx)) .expect("should produce regs for GPP data"); @@ -3663,12 +3719,10 @@ assume_single_jurisdiction = true fn build_regs_sets_gdpr_true_from_jurisdiction() { // EU user with GPC but no TCF string — GDPR should still be flagged // based on jurisdiction alone. - let ctx = ConsentContext { - gpc: true, - raw_us_privacy: Some("1YYN".to_string()), - jurisdiction: trusted_server_core::consent::jurisdiction::Jurisdiction::Gdpr, - ..Default::default() - }; + let ctx = + ConsentContext::new(trusted_server_core::consent::jurisdiction::Jurisdiction::Gdpr) + .with_record(gpc::record()) + .with_record(usp_record("1YYN")); let regs = PrebidAuctionProvider::build_regs(Some(&ctx)) .expect("should produce regs for GDPR jurisdiction"); @@ -3684,12 +3738,10 @@ assume_single_jurisdiction = true fn build_regs_omits_gdpr_for_unknown_jurisdiction_without_tcf() { // GPC-only request with no geo — jurisdiction is Unknown, no TCF // signal. GDPR field should be None (omitted) rather than false. - let ctx = ConsentContext { - gpc: true, - raw_us_privacy: Some("1YYN".to_string()), - jurisdiction: trusted_server_core::consent::jurisdiction::Jurisdiction::Unknown, - ..Default::default() - }; + let ctx = + ConsentContext::new(trusted_server_core::consent::jurisdiction::Jurisdiction::Unknown) + .with_record(gpc::record()) + .with_record(usp_record("1YYN")); let regs = PrebidAuctionProvider::build_regs(Some(&ctx)) .expect("should produce regs for GPC signal"); @@ -3703,11 +3755,10 @@ assume_single_jurisdiction = true #[test] fn build_regs_sets_gdpr_false_for_non_regulated_jurisdiction() { // Non-EU, non-US-state user with a US privacy string. - let ctx = ConsentContext { - raw_us_privacy: Some("1NNN".to_string()), - jurisdiction: trusted_server_core::consent::jurisdiction::Jurisdiction::NonRegulated, - ..Default::default() - }; + let ctx = ConsentContext::new( + trusted_server_core::consent::jurisdiction::Jurisdiction::NonRegulated, + ) + .with_record(usp_record("1NNN")); let regs = PrebidAuctionProvider::build_regs(Some(&ctx)) .expect("should produce regs for us_privacy"); @@ -3721,14 +3772,11 @@ assume_single_jurisdiction = true #[test] fn build_regs_dual_placement_mirrors_all_fields() { - let ctx = ConsentContext { - gdpr_applies: true, - raw_us_privacy: Some("1YNN".to_string()), - raw_gpp_string: Some("DBACNYA~CPXxRfA".to_string()), - gpp_section_ids: Some(vec![7]), - jurisdiction: trusted_server_core::consent::jurisdiction::Jurisdiction::Gdpr, - ..Default::default() - }; + let ctx = + ConsentContext::new(trusted_server_core::consent::jurisdiction::Jurisdiction::Gdpr) + .with_record(tc_record("tc-string")) + .with_record(usp_record("1YNN")) + .with_record(gpp_record("DBACNYA~CPXxRfA", vec![7])); let regs = PrebidAuctionProvider::build_regs(Some(&ctx)) .expect("should produce regs with full consent data"); @@ -3774,10 +3822,8 @@ assume_single_jurisdiction = true // gdpr_applies=true but no strings — only gdpr flag is set. // RegsExt should serialize to an object with only gdpr, which is // non-empty, so ext should still be present. - let ctx = ConsentContext { - gdpr_applies: true, - ..Default::default() - }; + let ctx = ConsentContext::default() + .with_record(SignalRecord::new(tcf::SCHEME, SignalStatus::Absent).with_applies(true)); let regs = PrebidAuctionProvider::build_regs(Some(&ctx)) .expect("should produce regs for gdpr_applies"); @@ -7236,14 +7282,15 @@ set = { networkId = 42 } serde_json::to_string(&disabled).expect("should serialize disabled PBS request"); let disabled_value: Json = serde_json::from_str(&disabled).expect("should parse disabled PBS golden request"); - assert_eq!( - disabled_value["user"]["ext"]["ConsentedProvidersSettings"]["consented_providers"], - json!("fictional-ac"), - "should retain Google Additional Consent only in PBS's existing extension" + assert!( + disabled_value["user"]["ext"] + .get("ConsentedProvidersSettings") + .is_none(), + "no module reads an additional consent string, so none is sent" ); assert_eq!( disabled, - r#"{"id":"fictional-auction","imp":[{"id":"fictional-slot","banner":{"format":[{"w":300,"h":250},{"w":728,"h":90}]},"tagid":"fictional-slot","bidfloor":1.0,"bidfloorcur":"USD","secure":1,"ext":{"prebid":{"bidder":{"exampleBidder":{"placement":"fictional-placement"}}}}}],"site":{"domain":"publisher.example","page":"https://publisher.example/article","ref":"https://referrer.example/story?fictional=1","publisher":{"domain":"publisher.example"}},"device":{"geo":{"lat":12.34,"lon":56.78,"type":2,"country":"US","region":"CA","metro":"501","city":"Example City"},"ua":"Fictional Browser","ip":"192.0.2.10","language":"en"},"user":{"id":"fictional-user","consent":"fictional-tcf","ext":{"ConsentedProvidersSettings":{"consented_providers":"fictional-ac"},"consent":"fictional-tcf","eids":[{"source":"identity.example","uids":[{"atype":1,"id":"fictional-uid"}]}]}},"tmax":321,"cur":["USD"],"regs":{"gdpr":1,"us_privacy":"1YNN","gpp":"fictional-gpp","gpp_sid":[2,6],"ext":{"gdpr":1,"gpp":"fictional-gpp","gpp_sid":[2,6],"us_privacy":"1YNN"}},"ext":{"prebid":{},"trusted_server":{"request_host":"publisher.example","request_scheme":"https"}}}"#, + r#"{"id":"fictional-auction","imp":[{"id":"fictional-slot","banner":{"format":[{"w":300,"h":250},{"w":728,"h":90}]},"tagid":"fictional-slot","bidfloor":1.0,"bidfloorcur":"USD","secure":1,"ext":{"prebid":{"bidder":{"exampleBidder":{"placement":"fictional-placement"}}}}}],"site":{"domain":"publisher.example","page":"https://publisher.example/article","ref":"https://referrer.example/story?fictional=1","publisher":{"domain":"publisher.example"}},"device":{"geo":{"lat":12.34,"lon":56.78,"type":2,"country":"US","region":"CA","metro":"501","city":"Example City"},"ua":"Fictional Browser","ip":"192.0.2.10","language":"en"},"user":{"id":"fictional-user","consent":"fictional-tcf","ext":{"consent":"fictional-tcf","eids":[{"source":"identity.example","uids":[{"atype":1,"id":"fictional-uid"}]}]}},"tmax":321,"cur":["USD"],"regs":{"gdpr":1,"us_privacy":"1YNN","gpp":"fictional-gpp","gpp_sid":[2,6],"ext":{"gdpr":1,"gpp":"fictional-gpp","gpp_sid":[2,6],"us_privacy":"1YNN"}},"ext":{"prebid":{},"trusted_server":{"request_host":"publisher.example","request_scheme":"https"}}}"#, "should preserve the complete disabled PBS wire shape" ); @@ -7282,7 +7329,7 @@ set = { networkId = 42 } serde_json::to_string(&enabled).expect("should serialize enabled PBS request"); assert_eq!( enabled, - r#"{"id":"fictional-auction","imp":[{"id":"fictional-slot","banner":{"format":[{"w":300,"h":250},{"w":728,"h":90}]},"tagid":"fictional-slot","bidfloor":1.0,"bidfloorcur":"USD","secure":1,"ext":{"prebid":{"bidder":{"exampleBidder":{"placement":"fictional-placement"}}}}}],"site":{"domain":"publisher.example","page":"https://publisher.example/article","ref":"https://referrer.example/story?fictional=1","publisher":{"domain":"publisher.example"}},"device":{"geo":{"lat":12.34,"lon":56.78,"type":2,"country":"US","region":"CA","metro":"501","city":"Example City"},"ua":"Fictional Browser","ip":"192.0.2.10","language":"en"},"user":{"id":"fictional-user","consent":"fictional-tcf","ext":{"ConsentedProvidersSettings":{"consented_providers":"fictional-ac"},"consent":"fictional-tcf","eids":[{"source":"identity.example","uids":[{"atype":1,"id":"fictional-uid"}]}]}},"tmax":321,"cur":["USD"],"regs":{"gdpr":1,"us_privacy":"1YNN","gpp":"fictional-gpp","gpp_sid":[2,6],"ext":{"gdpr":1,"gpp":"fictional-gpp","gpp_sid":[2,6],"us_privacy":"1YNN"}},"ext":{"prebid":{},"trusted_server":{"kid":"fictional-kid","request_host":"publisher.example","request_scheme":"https","signature":"LU_JUIA1BT80ShZNjSa4PIF5T-uMjEeodwKrV_6bXgh0hi1SYVtCKn9g_DTW62krmjCOFgoFYPHsu6L0nAcuDg","ts":1706900000,"version":"1.1"}}}"#, + r#"{"id":"fictional-auction","imp":[{"id":"fictional-slot","banner":{"format":[{"w":300,"h":250},{"w":728,"h":90}]},"tagid":"fictional-slot","bidfloor":1.0,"bidfloorcur":"USD","secure":1,"ext":{"prebid":{"bidder":{"exampleBidder":{"placement":"fictional-placement"}}}}}],"site":{"domain":"publisher.example","page":"https://publisher.example/article","ref":"https://referrer.example/story?fictional=1","publisher":{"domain":"publisher.example"}},"device":{"geo":{"lat":12.34,"lon":56.78,"type":2,"country":"US","region":"CA","metro":"501","city":"Example City"},"ua":"Fictional Browser","ip":"192.0.2.10","language":"en"},"user":{"id":"fictional-user","consent":"fictional-tcf","ext":{"consent":"fictional-tcf","eids":[{"source":"identity.example","uids":[{"atype":1,"id":"fictional-uid"}]}]}},"tmax":321,"cur":["USD"],"regs":{"gdpr":1,"us_privacy":"1YNN","gpp":"fictional-gpp","gpp_sid":[2,6],"ext":{"gdpr":1,"gpp":"fictional-gpp","gpp_sid":[2,6],"us_privacy":"1YNN"}},"ext":{"prebid":{},"trusted_server":{"kid":"fictional-kid","request_host":"publisher.example","request_scheme":"https","signature":"LU_JUIA1BT80ShZNjSa4PIF5T-uMjEeodwKrV_6bXgh0hi1SYVtCKn9g_DTW62krmjCOFgoFYPHsu6L0nAcuDg","ts":1706900000,"version":"1.1"}}}"#, "should preserve the complete enabled PBS wire shape" ); } @@ -7304,6 +7351,7 @@ mod engine_tests { use std::collections::{BTreeMap, HashMap}; use std::sync::Arc; + use super::{gpc, gpp, tcf, us_privacy}; use edgezero_core::body::Body as EdgeBody; use http::{Request, header}; use serde_json::{Value, json}; @@ -7318,7 +7366,7 @@ mod engine_tests { AdFormat, AdSlot, AuctionContext, AuctionRequest, MediaType, PublisherInfo, UserInfo, }; use trusted_server_core::consent::jurisdiction::Jurisdiction; - use trusted_server_core::consent::{ConsentContext, ConsentSource}; + use trusted_server_core::consent::{ConsentContext, ConsentSource, SignalRecord, SignalStatus}; use trusted_server_core::openrtb::OpenRtbRequest; use trusted_server_core::platform::BackendNamingPolicy; use trusted_server_core::platform::test_support::{ @@ -7371,42 +7419,35 @@ mod engine_tests { #[test] fn consent_matrix_holds_for_prebid_server() { + let applying = SignalRecord::new(tcf::SCHEME, SignalStatus::Decoded) + .with_value("tc-string") + .with_applies(true); let cases = [ ("empty", ConsentContext::default()), ( "gdpr", - ConsentContext { - gdpr_applies: true, - raw_tc_string: Some("tc-string".to_string()), - jurisdiction: Jurisdiction::Gdpr, - ..Default::default() - }, + ConsentContext::new(Jurisdiction::Gdpr).with_record(applying), ), ( "unknown-gpc", - ConsentContext { - gpc: true, - jurisdiction: Jurisdiction::Unknown, - ..Default::default() - }, + ConsentContext::new(Jurisdiction::Unknown).with_record(gpc::record()), ), ( "nonregulated-gpc", - ConsentContext { - gpc: true, - jurisdiction: Jurisdiction::NonRegulated, - ..Default::default() - }, + ConsentContext::new(Jurisdiction::NonRegulated).with_record(gpc::record()), ), ( "usp-gpp", - ConsentContext { - raw_us_privacy: Some("1YNN".to_string()), - raw_gpp_string: Some("gpp-string".to_string()), - gpp_section_ids: Some(vec![7, 8]), - jurisdiction: Jurisdiction::NonRegulated, - ..Default::default() - }, + ConsentContext::new(Jurisdiction::NonRegulated) + .with_record( + SignalRecord::new(us_privacy::SCHEME, SignalStatus::Decoded) + .with_value("1YNN"), + ) + .with_record( + SignalRecord::new(gpp::SCHEME, SignalStatus::Decoded) + .with_value("gpp-string") + .with_sections(vec![7, 8]), + ), ), ]; for (name, consent) in cases { @@ -7421,8 +7462,8 @@ mod engine_tests { let regs = regs.expect("should emit actionable regs"); let expected_gdpr = match consent.jurisdiction { Jurisdiction::Gdpr => Some(true), - Jurisdiction::Unknown if !consent.gdpr_applies => None, - _ => Some(consent.gdpr_applies), + Jurisdiction::Unknown if !tcf::applies(&consent) => None, + _ => Some(tcf::applies(&consent)), }; assert_eq!( regs.get("gdpr"), @@ -7456,12 +7497,15 @@ mod engine_tests { ("both", ConsentSource::Cookie, true), ] { let mut canonical = canonical_parity_auction_request(); - canonical + for record in &mut canonical .user .consent .as_mut() .expect("should have consent context") - .source = source; + .records + { + *record = record.clone().with_source(source); + } let value = serde_json::to_value(build_with_request( json!({"consent_forwarding": mode}), canonical, @@ -7728,7 +7772,7 @@ mod engine_tests { assert_eq!( serde_json::to_string(&request).expect("should serialize PBS driver request"), - r#"{"id":"fictional-auction","imp":[{"id":"fictional-slot","banner":{"format":[{"w":300,"h":250},{"w":728,"h":90}]},"tagid":"fictional-slot","bidfloor":1.0,"bidfloorcur":"USD","secure":1,"ext":{"prebid":{"bidder":{"exampleBidder":{"placement":"fictional-placement"}}}}}],"site":{"domain":"publisher.example","page":"https://publisher.example/article","ref":"https://referrer.example/story?fictional=1","publisher":{"domain":"publisher.example"}},"device":{"geo":{"lat":12.34,"lon":56.78,"type":2,"country":"US","region":"CA","metro":"501","city":"Example City"},"dnt":1,"ua":"Fictional Browser","ip":"192.0.2.10","language":"en"},"user":{"id":"fictional-user","consent":"fictional-tcf","ext":{"ConsentedProvidersSettings":{"consented_providers":"fictional-ac"},"consent":"fictional-tcf","eids":[{"source":"identity.example","uids":[{"atype":1,"id":"fictional-uid"}]}]}},"tmax":321,"cur":["USD"],"regs":{"gdpr":1,"us_privacy":"1YNN","gpp":"fictional-gpp","gpp_sid":[2,6],"ext":{"gdpr":1,"gpp":"fictional-gpp","gpp_sid":[2,6],"us_privacy":"1YNN"}},"ext":{"prebid":{},"trusted_server":{"request_host":"publisher.example","request_scheme":"https"}}}"#, + r#"{"id":"fictional-auction","imp":[{"id":"fictional-slot","banner":{"format":[{"w":300,"h":250},{"w":728,"h":90}]},"tagid":"fictional-slot","bidfloor":1.0,"bidfloorcur":"USD","secure":1,"ext":{"prebid":{"bidder":{"exampleBidder":{"placement":"fictional-placement"}}}}}],"site":{"domain":"publisher.example","page":"https://publisher.example/article","ref":"https://referrer.example/story?fictional=1","publisher":{"domain":"publisher.example"}},"device":{"geo":{"lat":12.34,"lon":56.78,"type":2,"country":"US","region":"CA","metro":"501","city":"Example City"},"dnt":1,"ua":"Fictional Browser","ip":"192.0.2.10","language":"en"},"user":{"id":"fictional-user","consent":"fictional-tcf","ext":{"consent":"fictional-tcf","eids":[{"source":"identity.example","uids":[{"atype":1,"id":"fictional-uid"}]}]}},"tmax":321,"cur":["USD"],"regs":{"gdpr":1,"us_privacy":"1YNN","gpp":"fictional-gpp","gpp_sid":[2,6],"ext":{"gdpr":1,"gpp":"fictional-gpp","gpp_sid":[2,6],"us_privacy":"1YNN"}},"ext":{"prebid":{},"trusted_server":{"request_host":"publisher.example","request_scheme":"https"}}}"#, "should preserve PBS parity differences" ); } @@ -7748,7 +7792,7 @@ mod engine_tests { assert_eq!( serde_json::to_string(&request).expect("should serialize signed request"), - r#"{"id":"fictional-auction","imp":[{"id":"fictional-slot","banner":{"format":[{"w":300,"h":250},{"w":728,"h":90}]},"tagid":"fictional-slot","bidfloor":1.0,"bidfloorcur":"USD","secure":1,"ext":{"prebid":{"bidder":{"exampleBidder":{"placement":"fictional-placement"}}}}}],"site":{"domain":"publisher.example","page":"https://publisher.example/article","ref":"https://referrer.example/story?fictional=1","publisher":{"domain":"publisher.example"}},"device":{"geo":{"lat":12.34,"lon":56.78,"type":2,"country":"US","region":"CA","metro":"501","city":"Example City"},"dnt":1,"ua":"Fictional Browser","ip":"192.0.2.10","language":"en"},"user":{"id":"fictional-user","consent":"fictional-tcf","ext":{"ConsentedProvidersSettings":{"consented_providers":"fictional-ac"},"consent":"fictional-tcf","eids":[{"source":"identity.example","uids":[{"atype":1,"id":"fictional-uid"}]}]}},"tmax":321,"cur":["USD"],"regs":{"gdpr":1,"us_privacy":"1YNN","gpp":"fictional-gpp","gpp_sid":[2,6],"ext":{"gdpr":1,"gpp":"fictional-gpp","gpp_sid":[2,6],"us_privacy":"1YNN"}},"ext":{"prebid":{},"trusted_server":{"kid":"fictional-kid","request_host":"publisher.example","request_scheme":"https","signature":"LU_JUIA1BT80ShZNjSa4PIF5T-uMjEeodwKrV_6bXgh0hi1SYVtCKn9g_DTW62krmjCOFgoFYPHsu6L0nAcuDg","ts":1706900000,"version":"1.1"}}}"#, + r#"{"id":"fictional-auction","imp":[{"id":"fictional-slot","banner":{"format":[{"w":300,"h":250},{"w":728,"h":90}]},"tagid":"fictional-slot","bidfloor":1.0,"bidfloorcur":"USD","secure":1,"ext":{"prebid":{"bidder":{"exampleBidder":{"placement":"fictional-placement"}}}}}],"site":{"domain":"publisher.example","page":"https://publisher.example/article","ref":"https://referrer.example/story?fictional=1","publisher":{"domain":"publisher.example"}},"device":{"geo":{"lat":12.34,"lon":56.78,"type":2,"country":"US","region":"CA","metro":"501","city":"Example City"},"dnt":1,"ua":"Fictional Browser","ip":"192.0.2.10","language":"en"},"user":{"id":"fictional-user","consent":"fictional-tcf","ext":{"consent":"fictional-tcf","eids":[{"source":"identity.example","uids":[{"atype":1,"id":"fictional-uid"}]}]}},"tmax":321,"cur":["USD"],"regs":{"gdpr":1,"us_privacy":"1YNN","gpp":"fictional-gpp","gpp_sid":[2,6],"ext":{"gdpr":1,"gpp":"fictional-gpp","gpp_sid":[2,6],"us_privacy":"1YNN"}},"ext":{"prebid":{},"trusted_server":{"kid":"fictional-kid","request_host":"publisher.example","request_scheme":"https","signature":"LU_JUIA1BT80ShZNjSa4PIF5T-uMjEeodwKrV_6bXgh0hi1SYVtCKn9g_DTW62krmjCOFgoFYPHsu6L0nAcuDg","ts":1706900000,"version":"1.1"}}}"#, "signed wire fixture should stay exact" ); } diff --git a/crates/permission-signal/gpc/Cargo.toml b/crates/permission-signal/gpc/Cargo.toml index 3bb4981e0c..841d83f63b 100644 --- a/crates/permission-signal/gpc/Cargo.toml +++ b/crates/permission-signal/gpc/Cargo.toml @@ -16,6 +16,9 @@ workspace = true [dependencies] trusted-server-core = { workspace = true } +[dev-dependencies] +http = { workspace = true } + # The visible owner of this crate, the way Prebid.js requires a named # maintainer of every adapter. The scheme's own body is the natural owner, and # until one adopts the crate the Trusted Server maintainers hold it. diff --git a/crates/permission-signal/gpc/src/lib.rs b/crates/permission-signal/gpc/src/lib.rs index f6d61dde6c..69719ab11a 100644 --- a/crates/permission-signal/gpc/src/lib.rs +++ b/crates/permission-signal/gpc/src/lib.rs @@ -1,18 +1,18 @@ //! Global Privacy Control as a permission signal module. //! -//! Answers from the `Sec-GPC` request header, which core reads into the -//! consent record's `gpc` flag. Separate from the GPP and US Privacy modules -//! so that a publisher who does not act on Global Privacy Control can leave -//! this one out of the configured list without also losing the other two -//! opt-outs. +//! Reads the `Sec-GPC` request header and records it. Separate from the GPP +//! and US Privacy modules so that a publisher who does not act on Global +//! Privacy Control can leave this one out of the configured list without also +//! losing the other two opt-outs. //! //! This module lives outside `trusted-server-core` deliberately, like every //! scheme. Why is set out once, in `permission_signal/README.md` in core. -use trusted_server_core::consent::ConsentContext; +use trusted_server_core::consent::{ConsentContext, SignalRecord, SignalStatus}; +use trusted_server_core::evidence::RequestInfo; use trusted_server_core::module_context::ModuleCall; use trusted_server_core::permission_signal::{PermissionSignalModule, SignalInput}; -use trusted_server_core::permissions::{ConsentSignal, OptOutSource, Permission, ValidSignal}; +use trusted_server_core::permissions::{ConsentSignal, Permission, ValidSignal}; /// The name `[permission-signal] modules` selects this module by, from its /// crate folder. @@ -30,6 +30,37 @@ fn short() -> &'static str { ) } +/// The scheme, as a [`ValidSignal`] and a [`SignalRecord`] name it. +pub const SCHEME: &str = "gpc"; + +/// The name this scheme is listed under in `signals.us_opt_out.sources` of +/// the permission policy. +pub const OPT_OUT_SOURCE: &str = "gpc"; + +/// The request header carrying the setting. +pub const HEADER_SEC_GPC: &str = "sec-gpc"; + +/// Whether the request carries `Sec-GPC: 1`. +#[must_use] +pub fn header_set(evidence: &dyn RequestInfo) -> bool { + evidence + .header(HEADER_SEC_GPC) + .is_some_and(|value| value.trim() == "1") +} + +/// Whether this module read the header on the request. +#[must_use] +pub fn signaled(consent: &ConsentContext) -> bool { + consent.record(SCHEME).is_some() +} + +/// The record this module reports for a sent header, for a caller that +/// builds a consent context without a request. +#[must_use] +pub fn record() -> SignalRecord { + SignalRecord::new(SCHEME, SignalStatus::Decoded).with_value("1") +} + /// The `Sec-GPC` request header, Global Privacy Control. #[derive(Debug, Default, Clone, Copy)] pub struct GpcModule; @@ -41,10 +72,16 @@ impl GpcModule { Self } - /// The signal this scheme reads from the request's consent record, when - /// it can use one. + /// The header's one value, when it was sent. There is nothing to + /// decode, so a sent header is always read. + fn read_record(&self, evidence: &dyn RequestInfo) -> Option { + header_set(evidence).then(record) + } + + /// The signal this scheme vouches for, when the header was sent. fn read_signal(&self, consent: &ConsentContext) -> Option { - consent.gpc.then(|| ValidSignal::new(short(), "gpc", "1")) + let value = consent.value(SCHEME)?; + Some(ValidSignal::new(short(), SCHEME, value)) } } @@ -53,14 +90,18 @@ impl PermissionSignalModule for GpcModule { name() } + fn read(&self, call: ModuleCall<'_>) -> Option { + call.inject(self, Self::read_record).ok().flatten() + } + fn signal(&self, permission: Permission, input: &SignalInput<'_>) -> ConsentSignal { // The policy decides whether this scheme counts at all and what an // opt-out takes away, so a module the policy does not list stays // silent even when configuration names it. - if !input.policy.opt_out_sources().contains(&OptOutSource::Gpc) { + if !input.policy.is_opt_out_source(OPT_OUT_SOURCE) { return ConsentSignal::Neutral; } - if input.consent.gpc && input.policy.opt_out_revokes(permission) { + if signaled(input.consent) && input.policy.opt_out_revokes(permission) { return ConsentSignal::Revoke; } // Silence rather than refusal. Reading an absent signal as a refusal @@ -69,8 +110,7 @@ impl PermissionSignalModule for GpcModule { ConsentSignal::Neutral } - /// The header's one value, when it was sent. There is nothing to - /// decode, so a sent header is always valid. + /// The header's one value, when it was sent. fn valid_signal(&self, call: ModuleCall<'_>) -> Option { call.inject(self, Self::read_signal).ok().flatten() } @@ -78,6 +118,8 @@ impl PermissionSignalModule for GpcModule { #[cfg(test)] mod tests { + use http::HeaderMap; + use super::*; use trusted_server_core::consent::ConsentContext; use trusted_server_core::evidence::OwnedRequestInfo; @@ -91,10 +133,19 @@ mod tests { } fn with_header(set: bool) -> ConsentContext { - ConsentContext { - gpc: set, - ..ConsentContext::default() + if set { + ConsentContext::default().with_record(record()) + } else { + ConsentContext::default() + } + } + + fn evidence(gpc: Option<&str>) -> OwnedRequestInfo { + let mut headers = HeaderMap::new(); + if let Some(value) = gpc { + headers.insert("sec-gpc", value.parse().expect("should build the header")); } + OwnedRequestInfo::new(String::new(), headers) } fn answer( @@ -107,11 +158,29 @@ mod tests { GpcModule::new().signal(permission, &input) } + #[test] + fn reads_the_header_when_it_is_one() { + let module = GpcModule::new(); + let record = module + .read_record(&evidence(Some("1"))) + .expect("a sent header is read"); + assert_eq!(record.value(), Some("1")); + assert_eq!(record.status(), SignalStatus::Decoded); + assert!( + module.read_record(&evidence(Some("0"))).is_none(), + "a header that is not 1 is not the setting" + ); + assert!( + module.read_record(&evidence(None)).is_none(), + "no header is no record" + ); + } + #[test] fn vouches_for_the_header_only_when_it_was_sent() { assert_eq!( GpcModule::new().read_signal(&with_header(true)), - Some(ValidSignal::new(short(), "gpc", "1")), + Some(ValidSignal::new(short(), SCHEME, "1")), "a sent header is the one value it can carry" ); assert_eq!( @@ -119,6 +188,7 @@ mod tests { None, "no header is no signal" ); + assert!(signaled(&with_header(true)) && !signaled(&with_header(false))); } #[test] diff --git a/crates/permission-signal/gpp/Cargo.toml b/crates/permission-signal/gpp/Cargo.toml index b557f1a793..fe360e641b 100644 --- a/crates/permission-signal/gpp/Cargo.toml +++ b/crates/permission-signal/gpp/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "trusted-server-permission-signal-gpp" -description = "The GPP US sale opt-out as a permission signal module." +description = "The GPP string decoder and its US sale opt-out as a permission signal module." authors = { workspace = true } edition = { workspace = true } license = { workspace = true } @@ -14,8 +14,15 @@ doctest = false workspace = true [dependencies] +derive_more = { workspace = true } +error-stack = { workspace = true } +iab_gpp = { workspace = true } +log = { workspace = true } trusted-server-core = { workspace = true } +[dev-dependencies] +http = { workspace = true } + # The visible owner of this crate, the way Prebid.js requires a named # maintainer of every adapter. The scheme's own body is the natural owner, and # until one adopts the crate the Trusted Server maintainers hold it. diff --git a/crates/permission-signal/gpp/src/decode.rs b/crates/permission-signal/gpp/src/decode.rs new file mode 100644 index 0000000000..4ebfcebfb2 --- /dev/null +++ b/crates/permission-signal/gpp/src/decode.rs @@ -0,0 +1,376 @@ +//! The GPP (Global Privacy Platform) string decoder. +//! +//! Wraps the [`iab_gpp`] crate, which parses the GPP header and decodes each +//! US section, and reads out the two things this module acts on: the section +//! identifiers the string carries, and whether any US section opts the +//! visitor out of sale. The EU section the string may also carry is another +//! scheme's, which decodes it from the raw section this module hands over +//! through [`section`]. +//! +//! # References +//! +//! - [IAB GPP specification](https://github.com/InteractiveAdvertisingBureau/Global-Privacy-Platform) +//! - [`iab_gpp` crate docs](https://docs.rs/iab_gpp) + +use error_stack::Report; + +/// Longest raw GPP string decoded, so a hostile cookie cannot make the +/// parser allocate without bound. +pub const MAX_GPP_STRING_LEN: usize = 8192; + +/// GPP section identifiers of the US state and national sections, 7 to 23 +/// in the GPP v1 specification. +const US_SECTION_ID_RANGE: core::ops::RangeInclusive = 7..=23; + +/// What this module reads from a decoded GPP string. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GppConsent { + /// The section identifiers the string carries. + pub section_ids: Vec, + /// Whether a US section opts the visitor out of sale. + /// + /// - `Some(true)` when a decodable US section says so + /// - `Some(false)` when at least one decodable US section says not and + /// none says so + /// - `None` when no US section is present or none decodes to an answer + pub us_sale_opt_out: Option, +} + +/// Why a GPP string could not be decoded. +#[derive(Debug, derive_more::Display)] +pub enum GppDecodeError { + /// The string is too long or the parser refused it. + #[display("invalid GPP string: {reason}")] + InvalidGppString { + /// What the parser said. + reason: String, + }, +} + +impl core::error::Error for GppDecodeError {} + +/// Decodes a GPP string into what this module reads from it. +/// +/// # Errors +/// +/// [`GppDecodeError::InvalidGppString`] when the string exceeds +/// [`MAX_GPP_STRING_LEN`] or the parser refuses it. +pub fn decode_gpp_string(gpp_string: &str) -> Result> { + let parsed = parse(gpp_string)?; + let section_ids: Vec = parsed.section_ids().map(|id| *id as u16).collect(); + let us_sale_opt_out = decode_us_sale_opt_out(&parsed); + Ok(GppConsent { + section_ids, + us_sale_opt_out, + }) +} + +/// The raw string of section `id` in `gpp_string`, when the string parses +/// and carries that section, for the scheme that decodes it. +#[must_use] +pub fn section(gpp_string: &str, id: u16) -> Option { + let parsed = parse(gpp_string).ok()?; + let section_id = parsed.section_ids().find(|held| **held as u16 == id)?; + parsed.section(*section_id).map(str::to_owned) +} + +/// The section identifiers of a request's GPP signal: the decoded string's +/// when it decodes, otherwise the `__gpp_sid` cookie's, which carries the +/// same list as a transport hint. +#[must_use] +pub fn section_ids_of(gpp_string: Option<&str>, gpp_sid: Option<&str>) -> Option> { + gpp_string + .and_then(|raw| parse(raw).ok()) + .map(|parsed| parsed.section_ids().map(|id| *id as u16).collect()) + .or_else(|| gpp_sid.and_then(parse_gpp_sid_cookie)) +} + +fn parse(gpp_string: &str) -> Result> { + if gpp_string.len() > MAX_GPP_STRING_LEN { + return Err(Report::new(GppDecodeError::InvalidGppString { + reason: format!( + "GPP string too long: {} bytes, max {MAX_GPP_STRING_LEN}", + gpp_string.len() + ), + })); + } + iab_gpp::v1::GPPString::parse_str(gpp_string).map_err(|error| { + Report::new(GppDecodeError::InvalidGppString { + reason: format!("{error}"), + }) + }) +} + +/// The sale opt-out across every US section, read conservatively: any +/// decodable section opting out wins, otherwise any decodable section not +/// opting out answers `Some(false)`, otherwise there is no answer. +fn decode_us_sale_opt_out(parsed: &iab_gpp::v1::GPPString) -> Option { + let mut result = None; + for us_section_id in parsed + .section_ids() + .filter(|id| US_SECTION_ID_RANGE.contains(&(**id as u16))) + { + match parsed.decode_section(*us_section_id) { + Ok(section) => match us_sale_opt_out_from_section(§ion) { + Some(true) => return Some(true), + Some(false) => result = Some(false), + None => {} + }, + Err(error) => { + log::warn!("Failed to decode US GPP section {us_section_id}: {error}"); + } + } + } + result +} + +fn us_sale_opt_out_from_section(section: &iab_gpp::sections::Section) -> Option { + use iab_gpp::sections::Section; + use iab_gpp::sections::us_common::OptOut; + + // Kept in step with the US state variants `iab_gpp` adds. + let sale_opt_out = match section { + Section::UsNat(s) => match &s.core { + iab_gpp::sections::usnat::Core::V1(c) => &c.sale_opt_out, + iab_gpp::sections::usnat::Core::V2(c) => &c.sale_opt_out, + _ => return None, + }, + Section::UsCa(s) => &s.core.sale_opt_out, + Section::UsVa(s) => &s.core.sale_opt_out, + Section::UsCo(s) => &s.core.sale_opt_out, + Section::UsUt(s) => &s.core.sale_opt_out, + Section::UsCt(s) => &s.core.sale_opt_out, + Section::UsFl(s) => &s.core.sale_opt_out, + Section::UsMt(s) => &s.core.sale_opt_out, + Section::UsOr(s) => &s.core.sale_opt_out, + Section::UsTx(s) => &s.core.sale_opt_out, + Section::UsDe(s) => &s.core.sale_opt_out, + Section::UsIa(s) => &s.core.sale_opt_out, + Section::UsNe(s) => &s.core.sale_opt_out, + Section::UsNh(s) => &s.core.sale_opt_out, + Section::UsNj(s) => &s.core.sale_opt_out, + Section::UsTn(s) => &s.core.sale_opt_out, + Section::UsMn(s) => &s.core.sale_opt_out, + _ => return None, + }; + + Some(*sale_opt_out == OptOut::OptedOut) +} + +/// Parses a `__gpp_sid` cookie value, a comma-separated list of section +/// identifiers such as `2,6`, skipping an entry that is not a number. +/// +/// `None` when the value is empty or carries no number. +#[must_use] +pub fn parse_gpp_sid_cookie(raw: &str) -> Option> { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return None; + } + + let ids: Vec = trimmed + .split(',') + .filter_map(|entry| { + let entry = entry.trim(); + match entry.parse::() { + Ok(id) => Some(id), + Err(_) => { + log::debug!("Ignoring invalid __gpp_sid entry: {entry:?}"); + None + } + } + }) + .collect(); + + if ids.is_empty() { None } else { Some(ids) } +} + +#[cfg(test)] +mod tests { + use super::*; + + // Header "DBABTA" encodes version 1 with section 6 (US Privacy), and the + // section string is a US Privacy string. + const GPP_USP_ONLY: &str = "DBABTA~1YNN"; + + // Header "DBACNY" encodes version 1 with sections 2 (EU TCF v2) and 6. + const GPP_TCF_AND_USP: &str = "DBACNY~CPXxRfAPXxRfAAfKABENB-CgAAAAAAAAAAYgAAAAAAAA~1YNN"; + + #[test] + fn decodes_a_string_with_one_section() { + let result = decode_gpp_string(GPP_USP_ONLY).expect("should decode"); + assert_eq!(result.section_ids, vec![6]); + assert_eq!( + result.us_sale_opt_out, None, + "section 6 is not a US section" + ); + } + + #[test] + fn reads_the_section_identifiers_of_a_string_with_two() { + let result = decode_gpp_string(GPP_TCF_AND_USP).expect("should decode"); + assert_eq!(result.section_ids, vec![2, 6]); + } + + #[test] + fn hands_over_the_raw_section_another_scheme_decodes() { + assert_eq!( + section(GPP_TCF_AND_USP, 2).as_deref(), + Some("CPXxRfAPXxRfAAfKABENB-CgAAAAAAAAAAYgAAAAAAAA"), + "section 2 is handed over as written" + ); + assert_eq!(section(GPP_TCF_AND_USP, 7), None, "a section not carried"); + assert_eq!( + section("not a GPP string", 2), + None, + "a string that does not parse" + ); + } + + #[test] + fn section_identifiers_come_from_the_string_or_the_hint() { + assert_eq!( + section_ids_of(Some(GPP_TCF_AND_USP), Some("7")), + Some(vec![2, 6]), + "a string that decodes is authoritative over the hint" + ); + assert_eq!( + section_ids_of(Some("not a GPP string"), Some("2,6")), + Some(vec![2, 6]), + "the hint stands in when the string does not decode" + ); + assert_eq!(section_ids_of(None, Some("7,8")), Some(vec![7, 8])); + assert_eq!(section_ids_of(None, None), None); + } + + #[test] + fn rejects_an_invalid_string() { + assert!(decode_gpp_string("totally-invalid").is_err()); + assert!(decode_gpp_string("").is_err()); + } + + #[test] + fn rejects_an_oversized_string() { + let oversized = "D".repeat(MAX_GPP_STRING_LEN + 1); + assert!(decode_gpp_string(&oversized).is_err()); + } + + #[test] + fn parses_the_section_hint() { + assert_eq!(parse_gpp_sid_cookie("2,6"), Some(vec![2, 6])); + assert_eq!(parse_gpp_sid_cookie("2"), Some(vec![2])); + assert_eq!(parse_gpp_sid_cookie(" 2 , 6 , 8 "), Some(vec![2, 6, 8])); + assert_eq!(parse_gpp_sid_cookie("2,abc,6"), Some(vec![2, 6])); + assert_eq!(parse_gpp_sid_cookie(""), None); + assert_eq!(parse_gpp_sid_cookie(" "), None); + assert_eq!(parse_gpp_sid_cookie("abc,def"), None); + } + + #[test] + fn decodes_a_us_section_that_does_not_opt_out() { + let result = decode_gpp_string("DBABLA~BVQqAAAAAgA.QA").expect("should decode"); + assert_eq!(result.us_sale_opt_out, Some(false)); + } + + fn encode_fibonacci_integer(mut value: u16) -> String { + let mut fibs = vec![1_u16]; + let mut next = 2_u16; + while next <= value { + fibs.push(next); + next = if fibs.len() == 1 { + 2 + } else { + fibs[fibs.len() - 1] + fibs[fibs.len() - 2] + }; + } + + let mut bits = vec![false; fibs.len()]; + for (idx, fib) in fibs.iter().enumerate().rev() { + if *fib <= value { + value -= *fib; + bits[idx] = true; + } + } + bits.push(true); + + bits.into_iter() + .map(|bit| if bit { '1' } else { '0' }) + .collect() + } + + fn encode_header(section_ids: &[u16]) -> String { + const BASE64_URL: &[u8; 64] = + b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; + + let mut bits = String::from("000011000001"); + bits.push_str(&format!("{:012b}", section_ids.len())); + + let mut previous = 0_u16; + for §ion_id in section_ids { + bits.push('0'); + bits.push_str(&encode_fibonacci_integer(section_id - previous)); + previous = section_id; + } + + while bits.len() % 6 != 0 { + bits.push('0'); + } + + bits.as_bytes() + .chunks(6) + .map(|chunk| { + let value = u8::from_str_radix( + core::str::from_utf8(chunk).expect("should encode header bits as utf8"), + 2, + ) + .expect("should parse 6-bit chunk"); + char::from(BASE64_URL[value as usize]) + }) + .collect() + } + + fn gpp_with_sections(sections: &[(u16, &str)]) -> String { + let ids = sections.iter().map(|(id, _)| *id).collect::>(); + let header = encode_header(&ids); + let section_payloads = sections.iter().map(|(_, raw)| *raw).collect::>(); + format!("{header}~{}", section_payloads.join("~")) + } + + #[test] + fn no_us_section_gives_no_opt_out_answer() { + let result = decode_gpp_string(GPP_TCF_AND_USP).expect("should decode"); + assert_eq!(result.us_sale_opt_out, None); + } + + #[test] + fn a_later_us_section_opting_out_wins_over_an_earlier_one_that_does_not() { + let gpp = gpp_with_sections(&[(7, "BVQqAAAAAgA.QA"), (9, "BVVVVVVVVWA.AA")]); + let result = decode_gpp_string(&gpp).expect("should decode"); + assert_eq!(result.us_sale_opt_out, Some(true)); + } + + #[test] + fn several_us_sections_none_opting_out_answer_false() { + let gpp = gpp_with_sections(&[(7, "BVQqAAAAAgA.QA"), (9, "BVgVVVVVVWA.AA")]); + let result = decode_gpp_string(&gpp).expect("should decode"); + assert_eq!(result.us_sale_opt_out, Some(false)); + } + + #[test] + fn a_decodable_answer_stands_beside_an_undecodable_section() { + let gpp = gpp_with_sections(&[(7, "BVQqAAAAAgA.QA"), (9, "not-a-valid-usva-section")]); + let result = decode_gpp_string(&gpp).expect("should decode the header"); + assert_eq!(result.us_sale_opt_out, Some(false)); + + let gpp = gpp_with_sections(&[(7, "not-a-valid-usnat-section"), (9, "BVVVVVVVVWA.AA")]); + let result = decode_gpp_string(&gpp).expect("should decode the header"); + assert_eq!(result.us_sale_opt_out, Some(true)); + } + + #[test] + fn only_undecodable_us_sections_give_no_answer() { + let gpp = gpp_with_sections(&[(7, "not-a-valid-usnat-section"), (9, "also-invalid")]); + let result = decode_gpp_string(&gpp).expect("should decode the header"); + assert_eq!(result.us_sale_opt_out, None); + } +} diff --git a/crates/permission-signal/gpp/src/lib.rs b/crates/permission-signal/gpp/src/lib.rs index a18cb33128..042f0f0144 100644 --- a/crates/permission-signal/gpp/src/lib.rs +++ b/crates/permission-signal/gpp/src/lib.rs @@ -1,16 +1,28 @@ //! The GPP US sale opt-out as a permission signal module. //! -//! Answers from the US sale opt-out carried in the `__gpp` string, which core -//! decodes into the consent record. It says nothing about the EU TCF section a -//! GPP string may also carry, because that is the TCF module's scheme. +//! Reads the `__gpp` string and the `__gpp_sid` hint from the request, decodes +//! the string, and answers from the US sale opt-out it carries. It says +//! nothing about the EU section a GPP string may also carry, because that is +//! the TCF module's scheme, which asks this crate for the raw section through +//! [`section`]. //! //! This module lives outside `trusted-server-core` deliberately, like every //! scheme. Why is set out once, in `permission_signal/README.md` in core. -use trusted_server_core::consent::ConsentContext; +mod decode; + +pub use decode::{ + GppConsent, GppDecodeError, MAX_GPP_STRING_LEN, decode_gpp_string, parse_gpp_sid_cookie, + section, section_ids_of, +}; + +use trusted_server_core::consent::{ConsentContext, SignalRecord, SignalStatus}; +use trusted_server_core::consent_config::ConsentMode; +use trusted_server_core::evidence::RequestInfo; use trusted_server_core::module_context::ModuleCall; use trusted_server_core::permission_signal::{PermissionSignalModule, SignalInput}; -use trusted_server_core::permissions::{ConsentSignal, OptOutSource, Permission, ValidSignal}; +use trusted_server_core::permissions::{ConsentSignal, Permission, ValidSignal}; +use trusted_server_core::settings::Settings; /// The name `[permission-signal] modules` selects this module by, from its /// crate folder. @@ -28,6 +40,51 @@ fn short() -> &'static str { ) } +/// The scheme, as a [`ValidSignal`] and a [`SignalRecord`] name it. +pub const SCHEME: &str = "gpp"; + +/// The name this scheme is listed under in `signals.us_opt_out.sources` of +/// the permission policy. +pub const OPT_OUT_SOURCE: &str = "gpp_sale_opt_out"; + +/// The cookie carrying the GPP string. +pub const COOKIE_GPP: &str = "__gpp"; + +/// The cookie carrying the string's section identifiers, as a hint. +pub const COOKIE_GPP_SID: &str = "__gpp_sid"; + +/// The GPP string and the section hint on a request, as received. +#[must_use] +pub fn strings(evidence: &dyn RequestInfo) -> (Option<&str>, Option<&str>) { + (evidence.cookie(COOKIE_GPP), evidence.cookie(COOKIE_GPP_SID)) +} + +/// The GPP string the request carried, as received, when this module read +/// one and vouched for it. +#[must_use] +pub fn gpp_string(consent: &ConsentContext) -> Option<&str> { + consent.value(SCHEME) +} + +/// The section identifiers of the request's GPP signal, when it carried any. +#[must_use] +pub fn section_ids(consent: &ConsentContext) -> Option<&[u16]> { + consent + .record(SCHEME) + .map(SignalRecord::sections) + .filter(|sections| !sections.is_empty()) +} + +/// The record this module reports for a decoded string, for a caller that +/// builds a consent context without a request. +#[must_use] +pub fn record(decoded: GppConsent, raw: &str) -> SignalRecord { + SignalRecord::new(SCHEME, SignalStatus::Decoded) + .with_value(raw) + .with_sections(decoded.section_ids.clone()) + .with_decoded(decoded) +} + /// A GPP US sale opt-out, read from the `__gpp` string. #[derive(Debug, Default, Clone, Copy)] pub struct GppSaleOptOutModule; @@ -39,12 +96,45 @@ impl GppSaleOptOutModule { Self } - /// The signal this scheme reads from the request's consent record, when - /// it can use one. + /// Reads the string and the hint from the request and reports what they + /// carry. + fn read_record(&self, evidence: &dyn RequestInfo, settings: &Settings) -> Option { + let (raw, hint) = strings(evidence); + let hinted = hint.and_then(parse_gpp_sid_cookie); + let Some(raw) = raw else { + // A hint without a string still says which sections the visitor + // answered for, which a bid request carries. + return hinted.map(|sections| { + SignalRecord::new(SCHEME, SignalStatus::Absent).with_sections(sections) + }); + }; + if settings.consent.mode == ConsentMode::Proxy { + return Some( + SignalRecord::new(SCHEME, SignalStatus::NotDecoded) + .with_value(raw) + .with_sections(hinted.unwrap_or_default()), + ); + } + match decode_gpp_string(raw) { + Ok(decoded) => Some(record(decoded, raw)), + Err(error) => { + log::warn!("Failed to decode GPP string: {error}"); + Some( + SignalRecord::new(SCHEME, SignalStatus::Unreadable) + .with_value(raw) + .with_sections(hinted.unwrap_or_default()), + ) + } + } + } + + /// The signal this scheme vouches for, when the string decoded. fn read_signal(&self, consent: &ConsentContext) -> Option { - consent.gpp.as_ref()?; - let raw = consent.raw_gpp_string.as_deref()?; - Some(ValidSignal::new(short(), "gpp", raw)) + let record = consent.record(SCHEME)?; + if record.status() != SignalStatus::Decoded { + return None; + } + Some(ValidSignal::new(short(), SCHEME, record.value()?)) } } @@ -53,33 +143,37 @@ impl PermissionSignalModule for GppSaleOptOutModule { name() } + fn read(&self, call: ModuleCall<'_>) -> Option { + call.inject(self, Self::read_record).ok().flatten() + } + fn signal(&self, permission: Permission, input: &SignalInput<'_>) -> ConsentSignal { // The policy decides whether this scheme counts at all and what an // opt-out takes away, so a module the policy does not list stays // silent even when configuration names it. - if !input - .policy - .opt_out_sources() - .contains(&OptOutSource::GppSaleOptOut) - { + if !input.policy.is_opt_out_source(OPT_OUT_SOURCE) { return ConsentSignal::Neutral; } - // A GPP string that arrived and could not be read is treated as the - // opt-out it may have carried, because a preference this module - // cannot see is not the same as no preference. - let unreadable = input.consent.raw_gpp_string.is_some() && input.consent.gpp.is_none(); - let opted_out = input - .consent - .gpp - .as_ref() - .and_then(|gpp| gpp.us_sale_opt_out) + let Some(record) = input.consent.record(SCHEME) else { + // Silence rather than refusal. Reading an absent signal as a + // refusal would revoke the permission on every request that did + // not carry this scheme, which is most of them. + return ConsentSignal::Neutral; + }; + // A string that arrived and could not be read, or was left as + // received, is treated as the opt-out it may have carried, because a + // preference this module cannot see is not the same as no preference. + let unreadable = matches!( + record.status(), + SignalStatus::Unreadable | SignalStatus::NotDecoded + ); + let opted_out = record + .decoded::() + .and_then(|decoded| decoded.us_sale_opt_out) == Some(true); if (opted_out || unreadable) && input.policy.opt_out_revokes(permission) { return ConsentSignal::Revoke; } - // Silence rather than refusal. Reading an absent signal as a refusal - // would revoke the permission on every request that did not carry this - // scheme, which is most of them. ConsentSignal::Neutral } @@ -91,9 +185,10 @@ impl PermissionSignalModule for GppSaleOptOutModule { #[cfg(test)] mod tests { + use http::HeaderMap; + use super::*; use trusted_server_core::consent::ConsentContext; - use trusted_server_core::consent::types::GppConsent; use trusted_server_core::evidence::OwnedRequestInfo; use trusted_server_core::permission_signal::SignalInput; use trusted_server_core::permissions::{Acquisition, PermissionMaps, SignalPolicy}; @@ -103,15 +198,19 @@ mod tests { } fn with_sale_opt_out(value: Option) -> ConsentContext { - ConsentContext { - gpp: Some(GppConsent { - version: 1, + ConsentContext::default().with_record(record( + GppConsent { section_ids: vec![7], - eu_tcf: None, us_sale_opt_out: value, - }), - ..ConsentContext::default() - } + }, + "DBABMA~CPreadable", + )) + } + + fn unreadable() -> ConsentContext { + ConsentContext::default().with_record( + SignalRecord::new(SCHEME, SignalStatus::Unreadable).with_value("not a GPP string"), + ) } fn answer( @@ -124,6 +223,19 @@ mod tests { GppSaleOptOutModule::new().signal(permission, &input) } + fn evidence(cookie: &str) -> OwnedRequestInfo { + let mut headers = HeaderMap::new(); + headers.insert( + "cookie", + cookie.parse().expect("should build a cookie header"), + ); + OwnedRequestInfo::new(String::new(), headers) + } + + fn read_with(cookie: &str, settings: &Settings) -> Option { + GppSaleOptOutModule::new().read_record(&evidence(cookie), settings) + } + #[test] fn answers_to_its_identifier() { assert_eq!( @@ -133,48 +245,98 @@ mod tests { ); } + #[test] + fn reads_and_decodes_the_string_from_the_request() { + let settings = Settings::default(); + let record = read_with("__gpp=DBABLA~BVQqAAAAAgA.QA", &settings).expect("should read"); + assert_eq!(record.status(), SignalStatus::Decoded); + assert_eq!(record.value(), Some("DBABLA~BVQqAAAAAgA.QA")); + assert_eq!(record.sections(), &[7], "the sections come from the string"); + assert_eq!( + record.decoded::().map(|d| d.us_sale_opt_out), + Some(Some(false)) + ); + assert!( + read_with("other=1", &settings).is_none(), + "no string, no record" + ); + } + + #[test] + fn an_unreadable_string_is_recorded_with_the_hinted_sections() { + let settings = Settings::default(); + let record = + read_with("__gpp=not-a-gpp-string; __gpp_sid=2,6", &settings).expect("should read"); + assert_eq!(record.status(), SignalStatus::Unreadable); + assert_eq!(record.sections(), &[2, 6], "the hint stands in"); + assert!(record.decoded::().is_none()); + } + + #[test] + fn a_hint_alone_is_recorded_as_absent_with_its_sections() { + let record = read_with("__gpp_sid=2,6", &Settings::default()).expect("should read"); + assert_eq!(record.status(), SignalStatus::Absent); + assert_eq!(record.value(), None); + assert_eq!(record.sections(), &[2, 6]); + } + + #[test] + fn proxy_mode_leaves_the_string_as_received() { + let mut settings = Settings::default(); + settings.consent.mode = ConsentMode::Proxy; + let record = + read_with("__gpp=DBABLA~BVQqAAAAAgA.QA; __gpp_sid=7", &settings).expect("should read"); + assert_eq!(record.status(), SignalStatus::NotDecoded); + assert_eq!(record.value(), Some("DBABLA~BVQqAAAAAgA.QA")); + assert_eq!(record.sections(), &[7], "only the hint is read"); + assert!(record.decoded::().is_none()); + } + #[test] fn an_unreadable_gpp_string_is_read_as_the_opt_out_it_may_have_carried() { - let unreadable = ConsentContext { - raw_gpp_string: Some("not a GPP string".to_owned()), - ..ConsentContext::default() - }; assert_eq!( - answer(&unreadable, shipped_policy(), Permission::StoreOnDevice), + answer(&unreadable(), shipped_policy(), Permission::StoreOnDevice), ConsentSignal::Revoke, "an unreadable string refuses what the policy lets an opt-out revoke" ); assert_eq!( answer( - &unreadable, + &unreadable(), shipped_policy(), Permission::MeasureAdPerformance ), ConsentSignal::Neutral, "and nothing the policy leaves alone" ); + let not_decoded = ConsentContext::default().with_record( + SignalRecord::new(SCHEME, SignalStatus::NotDecoded).with_value("DBABMA~x"), + ); + assert_eq!( + answer(¬_decoded, shipped_policy(), Permission::StoreOnDevice), + ConsentSignal::Revoke, + "a string left as received is a preference this module cannot see" + ); } #[test] fn vouches_for_the_gpp_string_only_when_it_decoded() { - let decoded = ConsentContext { - raw_gpp_string: Some("DBABMA~CPreadable".to_owned()), - ..with_sale_opt_out(Some(false)) - }; assert_eq!( - GppSaleOptOutModule::new().read_signal(&decoded), - Some(ValidSignal::new(short(), "gpp", "DBABMA~CPreadable")), + GppSaleOptOutModule::new().read_signal(&with_sale_opt_out(Some(false))), + Some(ValidSignal::new(short(), SCHEME, "DBABMA~CPreadable")), "a decoded string is vouched for as received, whatever it says" ); - let unreadable = ConsentContext { - raw_gpp_string: Some("not a GPP string".to_owned()), - ..ConsentContext::default() - }; assert_eq!( - GppSaleOptOutModule::new().read_signal(&unreadable), + GppSaleOptOutModule::new().read_signal(&unreadable()), None, "an unreadable string is not" ); + assert_eq!( + gpp_string(&with_sale_opt_out(None)), + Some("DBABMA~CPreadable"), + "and a reader asks this crate for the string" + ); + assert_eq!(section_ids(&with_sale_opt_out(None)), Some(&[7][..])); + assert_eq!(section_ids(&ConsentContext::default()), None); } #[test] diff --git a/crates/permission-signal/tcf/Cargo.toml b/crates/permission-signal/tcf/Cargo.toml index 91d8c4da67..11467a5536 100644 --- a/crates/permission-signal/tcf/Cargo.toml +++ b/crates/permission-signal/tcf/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "trusted-server-permission-signal-tcf" -description = "IAB TCF v2 as a permission signal module, with the purpose mapping." +description = "The IAB TCF v2 string decoder and the record as a permission signal module, with the purpose mapping." authors = { workspace = true } edition = { workspace = true } license = { workspace = true } @@ -14,7 +14,18 @@ doctest = false workspace = true [dependencies] +base64 = { workspace = true } +derive_more = { workspace = true } +error-stack = { workspace = true } +log = { workspace = true } +serde = { workspace = true } trusted-server-core = { workspace = true } +trusted-server-permission-signal-gpp = { workspace = true } +web-time = { workspace = true } + +[dev-dependencies] +http = { workspace = true } +serde_json = { workspace = true } # The visible owner of this crate, the way Prebid.js requires a named # maintainer of every adapter. The scheme's own body is the natural owner, and diff --git a/crates/trusted-server-core/src/consent/tcf.rs b/crates/permission-signal/tcf/src/decode.rs similarity index 61% rename from crates/trusted-server-core/src/consent/tcf.rs rename to crates/permission-signal/tcf/src/decode.rs index fb417ec2cb..9b4b4a9533 100644 --- a/crates/trusted-server-core/src/consent/tcf.rs +++ b/crates/permission-signal/tcf/src/decode.rs @@ -1,13 +1,12 @@ -//! TCF v2 consent string decoder (core segment only). +//! The TCF v2 consent string decoder, core segment only. //! -//! Decodes the IAB Transparency & Consent Framework v2 consent string from the -//! `euconsent-v2` cookie. Only the core segment (segment type 0) is decoded; -//! publisher restrictions, disclosed vendors, and allowed vendors segments are -//! not yet supported. +//! Decodes a TC String from the `euconsent-v2` cookie, or the EU section of a +//! GPP string. Only the core segment (segment type 0) is decoded, so publisher +//! restrictions, disclosed vendors and allowed vendors are not read. //! //! # Binary format //! -//! The TC String is a web-safe base64-encoded binary bitfield. The core segment +//! The TC String is a web-safe base64-encoded bitfield. The core segment //! layout (after base64 decoding) is: //! //! | Field | Bits | Offset | @@ -43,35 +42,136 @@ use base64::Engine as _; use base64::engine::general_purpose::URL_SAFE_NO_PAD; use error_stack::Report; -use super::types::{ConsentDecodeError, TcfConsent}; - -/// Maximum length of a raw TC String before decoding. -/// -/// Real TC Strings are typically under 1 KB. This limit prevents malicious -/// cookies from triggering large heap allocations during base64 decoding. +/// Longest raw TC String decoded. Real strings are under 1 KB, and the limit +/// keeps a hostile cookie from forcing a large allocation. const MAX_TC_STRING_LEN: usize = 4096; -/// Maximum vendor ID we will process from a TC String. -/// -/// The IAB vendor list currently has ~1 200 entries. This cap prevents -/// malicious range-encoded entries (e.g. `start=1, end=65535`) from -/// expanding into enormous `Vec`s. Vendors beyond this limit are silently -/// ignored. +/// Highest vendor identifier read. The vendor list has about 1200 entries, +/// and the cap keeps a hostile range entry from expanding into an enormous +/// list. Vendors beyond it are silently dropped. const MAX_VENDOR_ID: u16 = 10_000; -/// Decodes a TC String v2 into a [`TcfConsent`] struct. -/// -/// Only the core segment is decoded. Additional segments (separated by `.`) -/// are ignored. +/// The decoded core segment of a TC String. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TcfConsent { + /// TCF version (2). + pub version: u8, + /// CMP ID that collected this consent. + pub cmp_id: u16, + /// CMP version. + pub cmp_version: u16, + /// Consent screen number. + pub consent_screen: u8, + /// CMP language (ISO 639-1, two uppercase letters). + pub consent_language: String, + /// Vendor list version used. + pub vendor_list_version: u16, + /// TCF policy version. + pub tcf_policy_version: u8, + /// When consent was created, in deciseconds since the epoch. + pub created_ds: u64, + /// When consent was last updated, in deciseconds since the epoch. + pub last_updated_ds: u64, + + /// Purpose consents (24 bits, 1-indexed). + /// + /// `true` at index 0 means purpose 1 is consented, and so on. + pub purpose_consents: Vec, + /// Purpose legitimate interests (24 bits, 1-indexed). + pub purpose_legitimate_interests: Vec, + + /// Vendor IDs with consent granted. + pub vendor_consents: Vec, + /// Vendor IDs with legitimate interest established. + pub vendor_legitimate_interests: Vec, + + /// Special feature opt-ins (12 bits). + pub special_feature_opt_ins: Vec, +} + +impl TcfConsent { + /// Looks up a 1-indexed purpose in a bitfield, `false` for purpose 0 + /// and for an index past the end. + fn purpose_bit(bits: &[bool], purpose: usize) -> bool { + purpose + .checked_sub(1) + .and_then(|idx| bits.get(idx).copied()) + .unwrap_or(false) + } + + /// Whether consent was given for a purpose, 1-indexed as the + /// specification numbers them, `false` out of range. + #[must_use] + pub fn has_purpose_consent(&self, purpose: usize) -> bool { + Self::purpose_bit(&self.purpose_consents, purpose) + } + + /// Whether legitimate interest was established for a purpose, 1-indexed, + /// `false` out of range. + #[must_use] + pub fn has_purpose_li(&self, purpose: usize) -> bool { + Self::purpose_bit(&self.purpose_legitimate_interests, purpose) + } + + /// Whether a vendor has consent. + #[must_use] + pub fn has_vendor_consent(&self, vendor_id: u16) -> bool { + self.vendor_consents.contains(&vendor_id) + } + + /// Whether a vendor has established legitimate interest. + #[must_use] + pub fn has_vendor_li(&self, vendor_id: u16) -> bool { + self.vendor_legitimate_interests.contains(&vendor_id) + } + + /// Whether Purpose 1 (store and/or access information on a device) is + /// consented. + #[must_use] + pub fn has_storage_consent(&self) -> bool { + self.has_purpose_consent(1) + } + + /// Whether Purpose 2 (use limited data to select advertising) is + /// consented. + #[must_use] + pub fn has_basic_ads_consent(&self) -> bool { + self.has_purpose_consent(2) + } + + /// Whether Purpose 4 (use profiles to select personalised advertising) is + /// consented. + #[must_use] + pub fn has_personalized_ads_consent(&self) -> bool { + self.has_purpose_consent(4) + } +} + +/// Why a TC String could not be decoded. +#[derive(Debug, derive_more::Display)] +pub enum TcfDecodeError { + /// The string is too long, is not base64, is not version 2, or is too + /// short to hold the core segment. + #[display("invalid TC String: {reason}")] + InvalidTcString { + /// What was wrong with it. + reason: String, + }, +} + +impl core::error::Error for TcfDecodeError {} + +/// Decodes the core segment of a TC String v2. Additional segments, after a +/// `.`, are ignored. /// /// # Errors /// -/// - [`ConsentDecodeError::InvalidTcString`] if the string exceeds -/// `MAX_TC_STRING_LEN`, base64 decoding fails, the version is not 2, or -/// the bitfield is too short. -pub fn decode_tc_string(tc_string: &str) -> Result> { +/// [`TcfDecodeError::InvalidTcString`] when the string exceeds +/// `MAX_TC_STRING_LEN`, base64 decoding fails, the version is not 2, or the +/// bitfield is too short. +pub fn decode_tc_string(tc_string: &str) -> Result> { if tc_string.len() > MAX_TC_STRING_LEN { - return Err(Report::new(ConsentDecodeError::InvalidTcString { + return Err(Report::new(TcfDecodeError::InvalidTcString { reason: format!( "TC string too long: {} bytes, max {MAX_TC_STRING_LEN}", tc_string.len() @@ -79,28 +179,27 @@ pub fn decode_tc_string(tc_string: &str) -> Result Result Result 25 || lang_b > 25 { - return Err(Report::new(ConsentDecodeError::InvalidTcString { + return Err(Report::new(TcfDecodeError::InvalidTcString { reason: format!("invalid consent language values: ({lang_a}, {lang_b}), expected 0-25"), })); } @@ -134,20 +232,19 @@ pub fn decode_tc_string(tc_string: &str) -> Result Result, - offset: usize, -) -> Result, Report> { +/// Decodes a vendor section, consents or legitimate interests, which starts +/// with `MaxVendorId` (16 bits) and `IsRangeEncoding` (1 bit) and is then a +/// bitfield of one bit per vendor, or `NumEntries` (12 bits) of entries. +fn decode_vendor_section(reader: &BitReader<'_>, offset: usize) -> Vec { if offset + 17 > reader.bit_len() { - return Ok(Vec::new()); + return Vec::new(); } let raw_max_vendor_id = reader.read_u16(offset, 16); @@ -191,10 +280,9 @@ fn decode_vendor_section( let is_range = reader.read_bool(offset + 16); if is_range { - // Range encoding let num_entries_offset = offset + 17; if num_entries_offset + 12 > reader.bit_len() { - return Ok(Vec::new()); + return Vec::new(); } let num_entries = reader.read_u16(num_entries_offset, 12); let mut vendors = Vec::new(); @@ -208,7 +296,7 @@ fn decode_vendor_section( pos += 1; if is_range_entry { - // Range: StartVendorId (16) + EndVendorId (16) + // StartVendorId (16) and EndVendorId (16). if pos + 32 > reader.bit_len() { break; } @@ -216,8 +304,7 @@ fn decode_vendor_section( let end = reader.read_u16(pos + 16, 16); pos += 32; - // Silently clamp to MAX_VENDOR_ID to prevent amplification - // from malicious range entries. + // Clamped to MAX_VENDOR_ID so a hostile range cannot amplify. if start == 0 || start > end { continue; } @@ -225,7 +312,7 @@ fn decode_vendor_section( vendors.push(id); } } else { - // Single vendor: VendorId (16) + // A single VendorId (16). if pos + 16 > reader.bit_len() { break; } @@ -236,9 +323,9 @@ fn decode_vendor_section( } } } - Ok(vendors) + vendors } else { - // Bitfield: one bit per vendor, 1..=max_vendor_id + // One bit per vendor, 1..=max_vendor_id. let mut vendors = Vec::new(); let bitfield_start = offset + 17; for i in 0..usize::from(max_vendor_id) { @@ -247,33 +334,29 @@ fn decode_vendor_section( break; } if reader.read_bool(bit_pos) { - // Vendor IDs are 1-indexed vendors.push((i + 1) as u16); } } - Ok(vendors) + vendors } } -/// Calculates the bit offset after a vendor section ends. -fn vendor_section_end_offset( - reader: &BitReader<'_>, - offset: usize, -) -> Result> { +/// The bit offset after a vendor section ends. +/// +/// Reads the raw `MaxVendorId` rather than the capped one, because the +/// section's true end in the bitstream depends on it. +fn vendor_section_end_offset(reader: &BitReader<'_>, offset: usize) -> usize { if offset + 17 > reader.bit_len() { - return Ok(offset); + return offset; } - // Intentionally uncapped: we need the raw max_vendor_id to compute the - // true end-of-section offset in the bitstream, even though - // `decode_vendor_section` caps at MAX_VENDOR_ID for allocation safety. let max_vendor_id = reader.read_u16(offset, 16); let is_range = reader.read_bool(offset + 16); if is_range { let num_entries_offset = offset + 17; if num_entries_offset + 12 > reader.bit_len() { - return Ok(num_entries_offset); + return num_entries_offset; } let num_entries = reader.read_u16(num_entries_offset, 12); let mut pos = num_entries_offset + 12; @@ -286,25 +369,18 @@ fn vendor_section_end_offset( pos += 1; if is_range_entry { - pos += 32; // StartVendorId (16) + EndVendorId (16) + pos += 32; } else { - pos += 16; // Single VendorId + pos += 16; } } - Ok(pos) + pos } else { - Ok(offset + 17 + usize::from(max_vendor_id)) + offset + 17 + usize::from(max_vendor_id) } } -// --------------------------------------------------------------------------- -// Bit reader utility -// --------------------------------------------------------------------------- - -/// A simple bit-level reader over a byte slice. -/// -/// All reads are specified as (`bit_offset`, `num_bits`) from the start of the -/// buffer. No internal cursor is maintained — callers manage offsets explicitly. +/// A bit-level reader over a byte slice, addressed by bit offset. struct BitReader<'a> { bytes: &'a [u8], } @@ -318,7 +394,6 @@ impl<'a> BitReader<'a> { self.bytes.len() * 8 } - /// Reads a single bit as a boolean. fn read_bool(&self, bit_offset: usize) -> bool { let byte_idx = bit_offset / 8; let bit_idx = 7 - (bit_offset % 8); @@ -328,19 +403,16 @@ impl<'a> BitReader<'a> { (self.bytes[byte_idx] >> bit_idx) & 1 == 1 } - /// Reads up to 8 bits as a [`u8`]. fn read_u8(&self, bit_offset: usize, num_bits: usize) -> u8 { debug_assert!(num_bits <= 8); self.read_u64(bit_offset, num_bits) as u8 } - /// Reads up to 16 bits as a [`u16`]. fn read_u16(&self, bit_offset: usize, num_bits: usize) -> u16 { debug_assert!(num_bits <= 16); self.read_u64(bit_offset, num_bits) as u16 } - /// Reads up to 64 bits as a [`u64`]. fn read_u64(&self, bit_offset: usize, num_bits: usize) -> u64 { debug_assert!(num_bits <= 64); let mut value: u64 = 0; @@ -352,7 +424,6 @@ impl<'a> BitReader<'a> { value } - /// Reads a sequence of bits as a [`Vec`]. fn read_bool_vec(&self, bit_offset: usize, num_bits: usize) -> Vec { (0..num_bits) .map(|i| self.read_bool(bit_offset + i)) @@ -361,33 +432,11 @@ impl<'a> BitReader<'a> { } #[cfg(test)] -mod tests { +pub(crate) mod tests { use super::*; - // A known-good TC String v2 generated by the IAB reference implementation. - // This encodes: version=2, cmpId=7, cmpVersion=1, consent for purposes 1-4, - // vendor consents for vendors 1-10 (bitfield encoding). - // - // To generate test strings: https://iabeurope.github.io/TCF-v2-consent-string-editor/ - // Or use the IAB reference implementation in JavaScript. - #[test] fn decodes_minimal_tc_string() { - // This is a minimal valid TC String v2 core segment. - // Generated with: version=2, created=1970-01-01, lastUpdated=1970-01-01, - // cmpId=1, cmpVersion=1, consentScreen=0, language=EN, - // vendorListVersion=1, policyVersion=2, purposes=none, vendors=none (max=0) - // - // Bitfield construction: - // version(6)=2, created(36)=0, lastUpdated(36)=0, cmpId(12)=1, - // cmpVersion(12)=1, consentScreen(6)=0, language(12)=EN, - // vendorListVersion(12)=1, policyVersion(6)=2, - // isServiceSpecific(1)=0, useNonStandard(1)=0, - // specialFeatures(12)=0, purposeConsents(24)=0, - // purposeLI(24)=0, purposeOneTreatment(1)=0, publisherCC(12)=EN, - // maxVendorId(16)=0, isRange(1)=0 - // - // We'll build this manually. let bytes = build_minimal_tc_bytes(1, 1, b"EN", 1, &[], &[]); let encoded = URL_SAFE_NO_PAD.encode(&bytes); @@ -402,7 +451,7 @@ mod tests { #[test] fn decodes_purpose_consents() { - let purposes = vec![true, true, false, true]; // purposes 1,2,4 + let purposes = vec![true, true, false, true]; let bytes = build_minimal_tc_bytes(1, 1, b"EN", 1, &purposes, &[]); let encoded = URL_SAFE_NO_PAD.encode(&bytes); @@ -414,11 +463,14 @@ mod tests { "purpose 3 should not be consented" ); assert!(result.purpose_consents[3], "purpose 4 should be consented"); + assert!(result.has_storage_consent() && result.has_basic_ads_consent()); + assert!(result.has_personalized_ads_consent()); + assert!(!result.has_purpose_consent(0) && !result.has_purpose_consent(99)); + assert!(!result.has_purpose_li(1)); } #[test] fn decodes_vendor_consents_bitfield() { - // Vendors 1, 3, 5 consented (bitfield encoding, max=5) let vendor_bits = vec![true, false, true, false, true]; let bytes = build_minimal_tc_bytes(1, 1, b"EN", 1, &[], &vendor_bits); let encoded = URL_SAFE_NO_PAD.encode(&bytes); @@ -429,14 +481,14 @@ mod tests { vec![1, 3, 5], "should have vendors 1, 3, 5" ); + assert!(result.has_vendor_consent(3) && !result.has_vendor_consent(2)); + assert!(!result.has_vendor_li(3)); } #[test] fn rejects_version_1() { - // Build bytes with version=1 let mut bytes = build_minimal_tc_bytes(1, 1, b"EN", 1, &[], &[]); - // Clear version bits (first 6 bits) and set to 1 - bytes[0] = (bytes[0] & 0x03) | (1 << 2); // version=1 in first 6 bits + bytes[0] = (bytes[0] & 0x03) | (1 << 2); let encoded = URL_SAFE_NO_PAD.encode(&bytes); let result = decode_tc_string(&encoded); @@ -445,7 +497,7 @@ mod tests { #[test] fn rejects_too_short() { - let encoded = URL_SAFE_NO_PAD.encode([0_u8; 10]); // only 80 bits + let encoded = URL_SAFE_NO_PAD.encode([0_u8; 10]); let result = decode_tc_string(&encoded); assert!(result.is_err(), "should reject short bitfield"); } @@ -456,9 +508,14 @@ mod tests { assert!(result.is_err(), "should reject invalid base64"); } + #[test] + fn rejects_an_oversized_string() { + let oversized = "A".repeat(MAX_TC_STRING_LEN + 1); + assert!(decode_tc_string(&oversized).is_err()); + } + #[test] fn handles_segmented_tc_string() { - // TC Strings can have multiple segments separated by '.' let bytes = build_minimal_tc_bytes(1, 1, b"EN", 1, &[], &[]); let encoded = format!("{}.extra-segment", URL_SAFE_NO_PAD.encode(&bytes)); @@ -477,42 +534,35 @@ mod tests { #[test] fn clamps_range_encoded_vendors_to_max() { - // Build a TC String with range encoding where end > MAX_VENDOR_ID. - // The decoder should silently clamp to MAX_VENDOR_ID. let core_bits: usize = 213; - // Vendor section: maxVendorId(16) + isRange(1) + numEntries(12) - // + entry: isRange(1) + start(16) + end(16) = 262 bits let total_bits = core_bits + 16 + 1 + 12 + 1 + 16 + 16; let total_bytes = total_bits.div_ceil(8); let mut buf = vec![0_u8; total_bytes]; let mut writer = BitWriter::new(&mut buf); - // Write minimal core fields (version=2, rest zeroed/defaults) - writer.write(0, 6, 2); // version - writer.write(6, 36, 0); // created - writer.write(42, 36, 0); // lastUpdated - writer.write(78, 12, 1); // cmpId - writer.write(90, 12, 1); // cmpVersion - writer.write(102, 6, 0); // consentScreen - writer.write(108, 6, 4); // language E - writer.write(114, 6, 13); // language N - writer.write(120, 12, 1); // vendorListVersion - writer.write(132, 6, 2); // policyVersion - writer.write(201, 6, 4); // publisherCC E - writer.write(207, 6, 13); // publisherCC N - - // Vendor consent section: range encoding with start=9990, end=65535 - writer.write(213, 16, 65535); // maxVendorId = 65535 - writer.write_bool(229, true); // isRangeEncoding = true - writer.write(230, 12, 1); // numEntries = 1 - writer.write_bool(242, true); // entry is a range - writer.write(243, 16, 9990); // startVendorId - writer.write(259, 16, 65535); // endVendorId + writer.write(0, 6, 2); + writer.write(6, 36, 0); + writer.write(42, 36, 0); + writer.write(78, 12, 1); + writer.write(90, 12, 1); + writer.write(102, 6, 0); + writer.write(108, 6, 4); + writer.write(114, 6, 13); + writer.write(120, 12, 1); + writer.write(132, 6, 2); + writer.write(201, 6, 4); + writer.write(207, 6, 13); + + writer.write(213, 16, 65535); + writer.write_bool(229, true); + writer.write(230, 12, 1); + writer.write_bool(242, true); + writer.write(243, 16, 9990); + writer.write(259, 16, 65535); let encoded = URL_SAFE_NO_PAD.encode(&buf); let result = decode_tc_string(&encoded).expect("should decode with clamped vendors"); - // Should contain vendors 9990..=10000 (clamped from 9990..=65535) assert_eq!(result.vendor_consents.len(), 11); assert_eq!( *result.vendor_consents.first().expect("should have first"), @@ -526,10 +576,7 @@ mod tests { #[test] fn rejects_invalid_consent_language() { - // Language values above 25 are invalid (only A-Z / 0-25 are valid). let mut bytes = build_minimal_tc_bytes(1, 1, b"EN", 1, &[], &[]); - // Set lang_a to 30 (invalid: exceeds 25) at bit offset 108, 6 bits. - // We need to overwrite bits 108..114 with value 30. let mut writer = BitWriter::new(&mut bytes); writer.write(108, 6, 30); @@ -540,7 +587,6 @@ mod tests { #[test] fn skips_invalid_range_start_gt_end() { - // Range entry where start > end should be silently skipped. let core_bits: usize = 213; let total_bits = core_bits + 16 + 1 + 12 + 1 + 16 + 16; let total_bytes = total_bits.div_ceil(8); @@ -557,12 +603,12 @@ mod tests { writer.write(201, 6, 4); writer.write(207, 6, 13); - writer.write(213, 16, 100); // maxVendorId - writer.write_bool(229, true); // isRangeEncoding - writer.write(230, 12, 1); // numEntries = 1 - writer.write_bool(242, true); // entry is a range - writer.write(243, 16, 50); // startVendorId = 50 - writer.write(259, 16, 10); // endVendorId = 10 (invalid: start > end) + writer.write(213, 16, 100); + writer.write_bool(229, true); + writer.write(230, 12, 1); + writer.write_bool(242, true); + writer.write(243, 16, 50); + writer.write(259, 16, 10); let encoded = URL_SAFE_NO_PAD.encode(&buf); let result = decode_tc_string(&encoded).expect("should decode with skipped range"); @@ -572,10 +618,17 @@ mod tests { ); } - // ----------------------------------------------------------------------- - // Test helper: builds a minimal TC String v2 byte buffer - // ----------------------------------------------------------------------- + /// A TC String consenting to `purpose_consents` (1-indexed from index 0), + /// last updated at `last_updated_ds`, encoded as a cookie carries it. + pub(crate) fn encode_tc_string(purpose_consents: &[bool], last_updated_ds: u64) -> String { + let mut bytes = build_minimal_tc_bytes(1, 1, b"EN", 1, purpose_consents, &[]); + let mut writer = BitWriter::new(&mut bytes); + writer.write(42, 36, last_updated_ds); + URL_SAFE_NO_PAD.encode(&bytes) + } + /// Builds a minimal TC String v2 byte buffer: version 2, the given CMP, + /// language, vendor list version and purposes, and a vendor bitfield. fn build_minimal_tc_bytes( cmp_id: u16, cmp_version: u16, @@ -585,52 +638,31 @@ mod tests { vendor_consent_bits: &[bool], ) -> Vec { let max_vendor_id = vendor_consent_bits.len() as u16; - // Calculate total bits needed - // Core fields: 213 bits + 16 (maxVendorId) + 1 (isRange) + max_vendor_id (bitfield) let total_bits = 213 + 17 + usize::from(max_vendor_id); let total_bytes = total_bits.div_ceil(8); let mut buf = vec![0_u8; total_bytes]; let mut writer = BitWriter::new(&mut buf); - // Version (6 bits) = 2 writer.write(0, 6, 2); - // Created (36 bits) = 0 writer.write(6, 36, 0); - // LastUpdated (36 bits) = 0 writer.write(42, 36, 0); - // CmpId (12 bits) writer.write(78, 12, u64::from(cmp_id)); - // CmpVersion (12 bits) writer.write(90, 12, u64::from(cmp_version)); - // ConsentScreen (6 bits) = 0 writer.write(102, 6, 0); - // ConsentLanguage (12 bits) - two 6-bit chars offset by 'A' writer.write(108, 6, u64::from(language[0] - b'A')); writer.write(114, 6, u64::from(language[1] - b'A')); - // VendorListVersion (12 bits) writer.write(120, 12, u64::from(vendor_list_version)); - // TcfPolicyVersion (6 bits) = 2 writer.write(132, 6, 2); - // IsServiceSpecific (1 bit) = 0 - // UseNonStandardTexts (1 bit) = 0 - // SpecialFeatureOptIns (12 bits) = 0 - // PurposesConsent (24 bits) for (i, &consented) in purpose_consents.iter().enumerate() { if consented && i < 24 { writer.write_bool(152 + i, true); } } - // PurposesLITransparency (24 bits) = 0 - // PurposeOneTreatment (1 bit) = 0 - // PublisherCC (12 bits) - same as language writer.write(201, 6, u64::from(language[0] - b'A')); writer.write(207, 6, u64::from(language[1] - b'A')); - // MaxVendorConsentId (16 bits) writer.write(213, 16, u64::from(max_vendor_id)); - // IsRangeEncoding (1 bit) = 0 (bitfield) writer.write_bool(229, false); - // Vendor consent bits for (i, &consented) in vendor_consent_bits.iter().enumerate() { if consented { writer.write_bool(230 + i, true); @@ -640,7 +672,7 @@ mod tests { buf } - /// Simple bit writer for test data construction. + /// A bit writer for building test strings. struct BitWriter<'a> { bytes: &'a mut [u8], } diff --git a/crates/permission-signal/tcf/src/lib.rs b/crates/permission-signal/tcf/src/lib.rs index 1b64f37080..7814a69d53 100644 --- a/crates/permission-signal/tcf/src/lib.rs +++ b/crates/permission-signal/tcf/src/lib.rs @@ -1,31 +1,40 @@ //! IAB TCF v2 as a permission signal module. //! -//! Answers from the decoded TCF record for the purposes this crate maps to -//! each permission, and is the one place that knows what a TCF purpose is. -//! Core decodes the TC string, keeps the record against the Edge Cookie -//! identifier, expires it by age and resolves it against a GPP EU section, and -//! this module reads what that pipeline produced rather than decoding the -//! cookie a second time. Reading the wire directly would silently skip the -//! cached record on a returning visitor and the expiry rule, and answer -//! differently from every other reader of the same request. +//! Reads the TC String from the `euconsent-v2` cookie and the EU section of +//! the request's GPP string, decodes them, settles which record is in force +//! when both are present, expires a record older than the settings allow, and +//! answers from that record for the purposes this crate maps to each +//! permission. It is the one place that knows what a TCF purpose is. +//! +//! The record is read once, when the consent context is built, and every +//! reader of the request sees the same record, so the module answers the same +//! as the bid request builder and the identity graph about expiry and which +//! string is in force. //! //! This module lives outside `trusted-server-core` deliberately, like every //! scheme. Why is set out once, in `permission_signal/README.md` in core. +mod decode; mod mapping; use std::sync::OnceLock; +use std::sync::atomic::{AtomicBool, Ordering}; +pub use decode::{TcfConsent, TcfDecodeError, decode_tc_string}; pub use mapping::purpose_for; -#[cfg(test)] -use trusted_server_core::consent::types::TcfConsent; -use trusted_server_core::consent::{ConsentContext, effective_tcf}; +use serde::Deserialize; +use trusted_server_core::consent::{ConsentContext, SignalRecord, SignalStatus}; +use trusted_server_core::consent_config::ConsentMode; +use trusted_server_core::evidence::RequestInfo; use trusted_server_core::module_context::ModuleCall; use trusted_server_core::permission_signal::{PermissionSignalModule, SignalInput}; use trusted_server_core::permissions::{ ConsentSignal, Permission, PermissionSet, SignalPolicy, ValidSignal, }; +use trusted_server_core::settings::Settings; +use trusted_server_permission_signal_gpp as gpp; +use web_time::{SystemTime, UNIX_EPOCH}; /// The name `[permission-signal] modules` selects this module by, from its /// crate folder. @@ -43,6 +52,132 @@ fn short() -> &'static str { ) } +/// The scheme, as a [`ValidSignal`] and a [`SignalRecord`] name it, and as +/// `signals.tcf` in the permission policy names it. +pub const SCHEME: &str = "tcf"; + +/// The cookie carrying the TC String. +pub const COOKIE_EUCONSENT_V2: &str = "euconsent-v2"; + +/// The identifier of the EU TCF v2 section in a GPP string. +pub const GPP_SECTION_ID: u16 = 2; + +/// Deciseconds in one day, the unit a TC String keeps its timestamps in. +const DECISECONDS_PER_DAY: u64 = 86_400 * 10; + +static SETTINGS_WARNING_LOGGED: AtomicBool = AtomicBool::new(false); + +/// What this module reads from the `[consent]` table. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(default)] +pub struct TcfSettings { + /// Whether a record older than `max_consent_age_days` is treated as + /// expired. + pub check_expiration: bool, + /// The oldest a record may be, in days. The specification recommends + /// 13 months, and a value outside `1..=3650` is clamped. + pub max_consent_age_days: u32, + /// How a standalone TC String and the EU section of a GPP string are + /// settled when both are present and disagree. + pub conflict_resolution: ConflictResolution, +} + +impl Default for TcfSettings { + fn default() -> Self { + Self { + check_expiration: true, + max_consent_age_days: 395, + conflict_resolution: ConflictResolution::default(), + } + } +} + +impl TcfSettings { + /// The longest a record may live, in deciseconds, with the days clamped + /// to the range the setting accepts. + fn max_age_deciseconds(&self) -> u64 { + u64::from(self.max_consent_age_days.clamp(1, 3650)) * DECISECONDS_PER_DAY + } +} + +/// The `conflict_resolution` table of the `[consent]` settings. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(default)] +pub struct ConflictResolution { + /// Which record wins when the two disagree. + pub mode: ConflictMode, + /// How many days newer one record must be to win under `newest`. + pub freshness_threshold_days: u32, +} + +impl Default for ConflictResolution { + fn default() -> Self { + Self { + mode: ConflictMode::Restrictive, + freshness_threshold_days: 30, + } + } +} + +/// How two disagreeing records are settled. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum ConflictMode { + /// The record that denies wins. + Restrictive, + /// The newer record wins, when it is newer by the freshness threshold, + /// otherwise the record that denies. + Newest, + /// The record that grants wins. + Permissive, +} + +/// What this module stores in the consent context for a decoded request. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TcfRecord { + /// The record in force: the standalone TC String's, or the EU section's + /// of a GPP string, after the settings' conflict rule. + pub effective: TcfConsent, +} + +/// The TC String the request carried, as received, when this module read +/// one and vouched for it. +#[must_use] +pub fn tc_string(consent: &ConsentContext) -> Option<&str> { + consent.value(SCHEME) +} + +/// Whether the request carries an EU TCF signal, as a standalone TC String +/// or as the EU section of a GPP string, which is what a bid request carries +/// as the regulation's applicability flag. +#[must_use] +pub fn applies(consent: &ConsentContext) -> bool { + consent.record(SCHEME).is_some_and(SignalRecord::applies) +} + +/// The record in force for the request, when one decoded and has not +/// expired. +#[must_use] +pub fn effective(consent: &ConsentContext) -> Option<&TcfConsent> { + consent + .decoded::(SCHEME) + .map(|record| &record.effective) +} + +/// The record this module reports for a decoded TC String, for a caller that +/// builds a consent context without a request. `raw` is the string as +/// received, or `None` for a record carried in a GPP string. +#[must_use] +pub fn record(effective: TcfConsent, raw: Option<&str>) -> SignalRecord { + let record = SignalRecord::new(SCHEME, SignalStatus::Decoded) + .with_applies(true) + .with_decoded(TcfRecord { effective }); + match raw { + Some(raw) => record.with_value(raw), + None => record, + } +} + /// TCF v2, when the policy says TCF answers for this deployment. /// /// The mapping from permission to purpose is this crate's, in @@ -58,12 +193,104 @@ impl TcfModule { Self } - /// The signal this scheme reads from the request's consent record, when - /// it can use one. + /// The module's settings from the `[consent]` table, or the defaults + /// when the table cannot be read as them, which is said once in the log. + fn settings(settings: &Settings) -> TcfSettings { + match settings.consent.read::() { + Ok(read) => read, + Err(error) => { + if !SETTINGS_WARNING_LOGGED.swap(true, Ordering::Relaxed) { + log::warn!( + "The TCF module cannot read its settings and is using its defaults: {error:?}" + ); + } + TcfSettings::default() + } + } + } + + /// Reads the TC String and the EU section of the GPP string from the + /// request and reports the record in force. + fn read_record(&self, evidence: &dyn RequestInfo, settings: &Settings) -> Option { + let raw_tc = evidence.cookie(COOKIE_EUCONSENT_V2); + let (raw_gpp, raw_sid) = gpp::strings(evidence); + let section_ids = gpp::section_ids_of(raw_gpp, raw_sid); + let applies = + raw_tc.is_some() || section_ids.is_some_and(|ids| ids.contains(&GPP_SECTION_ID)); + let with_value = |record: SignalRecord| match raw_tc { + Some(raw) => record.with_value(raw), + None => record, + }; + + if settings.consent.mode == ConsentMode::Proxy { + return match raw_tc { + Some(_) => Some(with_value( + SignalRecord::new(SCHEME, SignalStatus::NotDecoded).with_applies(applies), + )), + None => applies + .then(|| SignalRecord::new(SCHEME, SignalStatus::Absent).with_applies(true)), + }; + } + + let tcf_settings = Self::settings(settings); + let embedded = raw_gpp + .and_then(|raw| gpp::section(raw, GPP_SECTION_ID)) + .and_then(|section| match decode_tc_string(§ion) { + Ok(decoded) => Some(decoded), + Err(error) => { + log::warn!("GPP contains an EU TCF section that could not be decoded: {error}"); + None + } + }); + let standalone = match raw_tc.map(decode_tc_string) { + None => None, + Some(Ok(decoded)) => Some(decoded), + Some(Err(error)) => { + log::warn!("Failed to decode TC String: {error}"); + return Some(with_value( + SignalRecord::new(SCHEME, SignalStatus::Unreadable).with_applies(applies), + )); + } + }; + let effective = match (standalone, embedded) { + (Some(standalone), Some(embedded)) => { + resolve_conflict(standalone, embedded, &tcf_settings.conflict_resolution) + } + (Some(standalone), None) => standalone, + (None, Some(embedded)) => embedded, + (None, None) => { + return applies + .then(|| SignalRecord::new(SCHEME, SignalStatus::Absent).with_applies(true)); + } + }; + if tcf_settings.check_expiration { + let age = now_deciseconds().saturating_sub(effective.last_updated_ds); + if age > tcf_settings.max_age_deciseconds() { + log::warn!( + "TCF consent expired: age_days={}", + age / DECISECONDS_PER_DAY + ); + return Some(with_value( + SignalRecord::new(SCHEME, SignalStatus::Expired).with_applies(applies), + )); + } + } + Some(with_value( + SignalRecord::new(SCHEME, SignalStatus::Decoded) + .with_applies(applies) + .with_decoded(TcfRecord { effective }), + )) + } + + /// The signal this scheme vouches for: the standalone TC String, when it + /// decoded and has not expired. A record carried inside a GPP string is + /// the GPP string's, which the GPP module vouches for. fn read_signal(&self, consent: &ConsentContext) -> Option { - consent.tcf.as_ref()?; - let raw = consent.raw_tc_string.as_deref()?; - Some(ValidSignal::new(short(), "tcf", raw)) + let record = consent.record(SCHEME)?; + if record.status() != SignalStatus::Decoded { + return None; + } + Some(ValidSignal::new(short(), SCHEME, record.value()?)) } } @@ -72,32 +299,46 @@ impl PermissionSignalModule for TcfModule { name() } + fn read(&self, call: ModuleCall<'_>) -> Option { + call.inject(self, Self::read_record).ok().flatten() + } + fn signal(&self, permission: Permission, input: &SignalInput<'_>) -> ConsentSignal { // The policy still says whether a TCF record answers for this - // deployment at all. What it no longer says is which purpose grants + // deployment at all. What it does not say is which purpose grants // which permission, because that is this scheme's own knowledge. - if !input.policy.tcf_authoritative() { + if !input.policy.authoritative(SCHEME) { return ConsentSignal::Neutral; } let Some(purpose) = mapping::purpose_for(permission) else { // TCF has nothing to say about this Data Use, so it says nothing. return ConsentSignal::Neutral; }; - if unreadable(input.consent) { - // A TC string arrived and could not be read. The visitor expressed - // a preference this module cannot see, which is not the same as - // no preference, so it fails closed on everything it maps rather - // than leaving the place baseline standing. An expired record is - // not this case, expiry being its own explicit state. - return ConsentSignal::Revoke; - } - let Some(record) = effective_tcf(input.consent) else { + let Some(record) = input.consent.record(SCHEME) else { // No TCF record on the request. Silence, not refusal, because // reading an absent scheme as a refusal would revoke on every // request that did not carry it. return ConsentSignal::Neutral; }; - if record.has_purpose_consent(usize::from(purpose)) { + if matches!( + record.status(), + SignalStatus::Unreadable | SignalStatus::NotDecoded + ) { + // A TC String arrived and could not be read, or was left as + // received. The visitor expressed a preference this module cannot + // see, which is not the same as no preference, so it fails closed + // on everything it maps rather than leaving the place baseline + // standing. An expired record is not this case, expiry being its + // own explicit state. + return ConsentSignal::Revoke; + } + let Some(effective) = record.decoded::() else { + return ConsentSignal::Neutral; + }; + if effective + .effective + .has_purpose_consent(usize::from(purpose)) + { ConsentSignal::Grant } else { // A purpose the visitor did not consent to is a refusal. Reading it @@ -111,15 +352,12 @@ impl PermissionSignalModule for TcfModule { /// and nothing when it does not, because a silenced record grants nothing /// and a page must not wait for it. fn grants(&self, policy: &SignalPolicy) -> PermissionSet { - if !policy.tcf_authoritative() { + if !policy.authoritative(SCHEME) { return PermissionSet::none(); } mapped() } - /// The standalone TC string, when it decoded and has not expired. A record - /// carried inside a GPP string is the GPP string's, which the GPP - /// module vouches for. fn valid_signal(&self, call: ModuleCall<'_>) -> Option { call.inject(self, Self::read_signal).ok().flatten() } @@ -131,14 +369,14 @@ impl PermissionSignalModule for TcfModule { /// /// Whether the refusal is destructive at all is core's to decide from the /// jurisdiction's storage baseline, which is why this answers the narrow - /// question only. It does not consult `tcf_authoritative`, matching the - /// rule as it stood before the seam, where a record refusing storage - /// withdrew whether or not the policy let the record grant anything. + /// question only. It does not consult the policy's authoritative flag, so + /// a record refusing storage withdraws whether or not the policy lets the + /// record grant anything. fn withdraws(&self, permission: Permission, input: &SignalInput<'_>) -> bool { if permission != Permission::StoreOnDevice { return false; } - effective_tcf(input.consent).is_some_and(|record| !record.has_storage_consent()) + effective(input.consent).is_some_and(|record| !record.has_storage_consent()) } } @@ -153,16 +391,85 @@ fn mapped() -> PermissionSet { }) } -/// Whether a TC string arrived that could not be decoded, expiry aside. -fn unreadable(consent: &ConsentContext) -> bool { - consent.raw_tc_string.is_some() && consent.tcf.is_none() && !consent.expired +/// Whether a record allows identifiers to travel in the bidstream, which is +/// what the two records are compared on when they disagree. +fn allows_eid_transmission(record: &TcfConsent) -> bool { + record.has_storage_consent() && record.has_personalized_ads_consent() +} + +/// Settles a standalone TC String against the EU section of a GPP string. +fn resolve_conflict( + standalone: TcfConsent, + embedded: TcfConsent, + rule: &ConflictResolution, +) -> TcfConsent { + let standalone_allows = allows_eid_transmission(&standalone); + let embedded_allows = allows_eid_transmission(&embedded); + if standalone_allows == embedded_allows { + return standalone; + } + let select_embedded = match rule.mode { + ConflictMode::Restrictive => !embedded_allows, + ConflictMode::Permissive => embedded_allows, + // When neither is newer by the threshold, the record that denies + // wins. + ConflictMode::Newest => select_newer(&standalone, &embedded, rule.freshness_threshold_days) + .unwrap_or(!embedded_allows), + }; + log::info!( + "TCF conflict detected; mode={:?}, selected={}", + rule.mode, + if select_embedded { "gpp" } else { "standalone" } + ); + if select_embedded { + embedded + } else { + standalone + } +} + +/// Whether the embedded record wins under `newest`: `Some(true)` when it is +/// newer than the standalone by the threshold, `Some(false)` when the +/// standalone is, and `None` when neither is. +fn select_newer( + standalone: &TcfConsent, + embedded: &TcfConsent, + threshold_days: u32, +) -> Option { + let threshold_ds = u64::from(threshold_days) * DECISECONDS_PER_DAY; + if standalone + .last_updated_ds + .saturating_sub(embedded.last_updated_ds) + > threshold_ds + { + return Some(false); + } + if embedded + .last_updated_ds + .saturating_sub(standalone.last_updated_ds) + > threshold_ds + { + return Some(true); + } + None +} + +/// Now, in deciseconds since the Unix epoch, the unit a TC String keeps its +/// timestamps in. +fn now_deciseconds() -> u64 { + let duration = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default(); + duration.as_secs() * 10 + u64::from(duration.subsec_millis()) / 100 } #[cfg(test)] mod tests { + use http::HeaderMap; + use super::*; + use crate::decode::tests::encode_tc_string; use trusted_server_core::consent::ConsentContext; - use trusted_server_core::consent::types::GppConsent; use trusted_server_core::evidence::OwnedRequestInfo; use trusted_server_core::permission_signal::SignalInput; use trusted_server_core::permissions::{Acquisition, PermissionMaps, SignalPolicy}; @@ -172,8 +479,8 @@ mod tests { PermissionMaps::standard().signals() } - /// Builds a minimal decoded TCF record consenting to the given 1-indexed - /// purposes, with everything else refused. + /// A decoded record consenting to the given 1-indexed purposes, with + /// everything else refused. fn tcf_with_purposes(consented: &[usize]) -> TcfConsent { let mut purpose_consents = vec![false; 24]; for &purpose in consented { @@ -198,10 +505,12 @@ mod tests { } fn with_record(consented: &[usize]) -> ConsentContext { - ConsentContext { - tcf: Some(tcf_with_purposes(consented)), - ..ConsentContext::default() - } + ConsentContext::default().with_record(record(tcf_with_purposes(consented), None)) + } + + fn with_status(status: SignalStatus) -> ConsentContext { + ConsentContext::default() + .with_record(SignalRecord::new(SCHEME, status).with_value("CPsomething")) } fn answer( @@ -220,6 +529,30 @@ mod tests { TcfModule::new().withdraws(permission, &input) } + fn evidence(cookie: &str) -> OwnedRequestInfo { + let mut headers = HeaderMap::new(); + headers.insert( + "cookie", + cookie.parse().expect("should build a cookie header"), + ); + OwnedRequestInfo::new(String::new(), headers) + } + + fn read_with(cookie: &str, settings: &Settings) -> Option { + TcfModule::new().read_record(&evidence(cookie), settings) + } + + /// A TC String consenting to purposes 1 and 4, last updated now. + fn fresh_tc_string() -> String { + encode_tc_string(&[true, false, false, true], now_deciseconds()) + } + + fn with_consent_settings(json: serde_json::Value) -> Settings { + let mut settings = Settings::default(); + settings.consent = serde_json::from_value(json).expect("should build the consent table"); + settings + } + #[test] fn answers_to_its_identifier() { assert_eq!( @@ -229,6 +562,214 @@ mod tests { ); } + #[test] + fn reads_and_decodes_the_tc_string_from_the_request() { + let tc = fresh_tc_string(); + let record = + read_with(&format!("euconsent-v2={tc}"), &Settings::default()).expect("should read"); + assert_eq!(record.status(), SignalStatus::Decoded); + assert_eq!(record.value(), Some(tc.as_str())); + assert!(record.applies(), "a TC String says the regulation applies"); + let decoded = record + .decoded::() + .expect("should carry the record"); + assert!(decoded.effective.has_purpose_consent(1)); + assert!(decoded.effective.has_purpose_consent(4)); + assert!(!decoded.effective.has_purpose_consent(2)); + assert!( + read_with("other=1", &Settings::default()).is_none(), + "no string and no section, no record" + ); + } + + #[test] + fn an_unreadable_tc_string_is_recorded_as_such() { + let record = + read_with("euconsent-v2=not-a-tc-string", &Settings::default()).expect("should read"); + assert_eq!(record.status(), SignalStatus::Unreadable); + assert_eq!(record.value(), Some("not-a-tc-string")); + assert!(record.applies()); + assert!(record.decoded::().is_none()); + } + + #[test] + fn a_stale_tc_string_is_recorded_as_expired() { + let old = encode_tc_string(&[true], 0); + let record = + read_with(&format!("euconsent-v2={old}"), &Settings::default()).expect("should read"); + assert_eq!(record.status(), SignalStatus::Expired); + assert_eq!( + record.value(), + Some(old.as_str()), + "the string stays on record" + ); + assert!( + record.decoded::().is_none(), + "and the record is cleared" + ); + + let unchecked = with_consent_settings(serde_json::json!({ "check_expiration": false })); + let record = read_with(&format!("euconsent-v2={old}"), &unchecked).expect("should read"); + assert_eq!( + record.status(), + SignalStatus::Decoded, + "with expiry unchecked the record stands" + ); + } + + #[test] + fn the_eu_section_of_a_gpp_string_is_read_when_no_tc_string_arrived() { + // Header "DBACNY" carries sections 2 and 6, and section 2 is a TC + // String consenting to purposes 1 and 4. + let tc = fresh_tc_string(); + let record = read_with(&format!("__gpp=DBACNY~{tc}~1YNN"), &Settings::default()) + .expect("should read"); + assert_eq!(record.status(), SignalStatus::Decoded); + assert_eq!(record.value(), None, "the string is the GPP module's"); + assert!(record.applies(), "section 2 says the regulation applies"); + let decoded = record + .decoded::() + .expect("should carry the record"); + assert!(decoded.effective.has_purpose_consent(4)); + + // With a standalone string beside it, the restrictive default keeps + // the record that denies. + let denying = encode_tc_string(&[true], now_deciseconds()); + let record = read_with( + &format!("euconsent-v2={denying}; __gpp=DBACNY~{tc}~1YNN"), + &Settings::default(), + ) + .expect("should read"); + assert_eq!(record.value(), Some(denying.as_str())); + let decoded = record + .decoded::() + .expect("should carry the record"); + assert!( + !decoded.effective.has_purpose_consent(4), + "the standalone record denies and the restrictive rule keeps it" + ); + } + + #[test] + fn a_section_hint_naming_the_eu_section_says_the_regulation_applies() { + let record = read_with("__gpp_sid=2,6", &Settings::default()).expect("should read"); + assert_eq!(record.status(), SignalStatus::Absent); + assert!(record.applies()); + assert!( + read_with("__gpp_sid=7", &Settings::default()).is_none(), + "a hint naming no EU section is nothing for this module" + ); + } + + #[test] + fn proxy_mode_leaves_the_string_as_received() { + let tc = fresh_tc_string(); + let settings = with_consent_settings(serde_json::json!({ "mode": "proxy" })); + let record = read_with(&format!("euconsent-v2={tc}"), &settings).expect("should read"); + assert_eq!(record.status(), SignalStatus::NotDecoded); + assert_eq!(record.value(), Some(tc.as_str())); + assert!(record.applies()); + let record = read_with("__gpp_sid=2", &settings).expect("should read"); + assert_eq!(record.status(), SignalStatus::Absent); + assert!(record.applies()); + } + + #[test] + fn unreadable_settings_fall_back_to_the_defaults() { + let settings = with_consent_settings(serde_json::json!({ "max_consent_age_days": "x" })); + let old = encode_tc_string(&[true], 0); + let record = read_with(&format!("euconsent-v2={old}"), &settings).expect("should read"); + assert_eq!( + record.status(), + SignalStatus::Expired, + "the default expiry check still runs" + ); + } + + #[test] + fn settings_read_the_table_and_clamp_the_age() { + let settings = with_consent_settings(serde_json::json!({ + "check_expiration": false, + "max_consent_age_days": 9999, + "conflict_resolution": { "mode": "newest", "freshness_threshold_days": 15 }, + "us_privacy_defaults": { "notice_given": false } + })); + let read = TcfModule::settings(&settings); + assert!(!read.check_expiration); + assert_eq!(read.max_consent_age_days, 9999); + assert_eq!(read.max_age_deciseconds(), 3650 * DECISECONDS_PER_DAY); + assert_eq!(read.conflict_resolution.mode, ConflictMode::Newest); + assert_eq!(read.conflict_resolution.freshness_threshold_days, 15); + assert_eq!( + TcfModule::settings(&Settings::default()), + TcfSettings::default() + ); + } + + fn allowing(last_updated_ds: u64, allows_eids: bool) -> TcfConsent { + let mut record = tcf_with_purposes(if allows_eids { &[1, 4] } else { &[1] }); + record.last_updated_ds = last_updated_ds; + record + } + + #[test] + fn conflict_resolution_restrictive_prefers_denial() { + let settled = resolve_conflict( + allowing(10, true), + allowing(20, false), + &ConflictResolution::default(), + ); + assert!( + !settled.has_personalized_ads_consent(), + "restrictive mode should choose the denying record" + ); + } + + #[test] + fn conflict_resolution_permissive_prefers_grant() { + let rule = ConflictResolution { + mode: ConflictMode::Permissive, + ..ConflictResolution::default() + }; + let settled = resolve_conflict(allowing(10, true), allowing(20, false), &rule); + assert!( + settled.has_personalized_ads_consent(), + "permissive mode should choose the granting record" + ); + } + + #[test] + fn conflict_resolution_newest_prefers_fresher_record() { + let rule = ConflictResolution { + mode: ConflictMode::Newest, + freshness_threshold_days: 1, + }; + let settled = resolve_conflict( + allowing(2 * DECISECONDS_PER_DAY, true), + allowing(0, false), + &rule, + ); + assert!( + settled.has_personalized_ads_consent(), + "newest mode should pick the record that is newer than the threshold" + ); + let settled = resolve_conflict(allowing(0, true), allowing(0, false), &rule); + assert!( + !settled.has_personalized_ads_consent(), + "neither newer by the threshold, so the denying record wins" + ); + } + + #[test] + fn records_that_agree_keep_the_standalone() { + let settled = resolve_conflict( + allowing(10, true), + allowing(20, true), + &ConflictResolution::default(), + ); + assert_eq!(settled.last_updated_ds, 10); + } + #[test] fn grants_a_permission_whose_purpose_the_record_consents_to() { assert_eq!( @@ -281,13 +822,7 @@ mod tests { #[test] fn an_unreadable_tc_string_is_this_modules_refusal_and_an_expired_one_is_not() { - // A string arrived that could not be decoded. That is a preference - // this module cannot see, so it refuses what it maps, silently, - // and says nothing about a Data Use no purpose covers. - let unreadable = ConsentContext { - raw_tc_string: Some("not a TC string".to_owned()), - ..ConsentContext::default() - }; + let unreadable = with_status(SignalStatus::Unreadable); assert_eq!( answer(&unreadable, shipped_policy(), Permission::StoreOnDevice), ConsentSignal::Revoke, @@ -310,13 +845,21 @@ mod tests { ConsentSignal::Neutral, "a silenced scheme says nothing, readable or not" ); - let expired = ConsentContext { - raw_tc_string: Some("CPold".to_owned()), - expired: true, - ..ConsentContext::default() - }; assert_eq!( - answer(&expired, shipped_policy(), Permission::StoreOnDevice), + answer( + &with_status(SignalStatus::NotDecoded), + shipped_policy(), + Permission::StoreOnDevice + ), + ConsentSignal::Revoke, + "a string left as received is a preference this module cannot see" + ); + assert_eq!( + answer( + &with_status(SignalStatus::Expired), + shipped_policy(), + Permission::StoreOnDevice + ), ConsentSignal::Neutral, "expiry is its own explicit state and not an unreadable record" ); @@ -324,47 +867,40 @@ mod tests { #[test] fn vouches_for_the_standalone_tc_string_only_when_it_decoded() { - let decoded = ConsentContext { - raw_tc_string: Some("CPreadable".to_owned()), - ..with_record(&[1]) - }; + let decoded = ConsentContext::default() + .with_record(record(tcf_with_purposes(&[1]), Some("CPreadable"))); assert_eq!( TcfModule::new().read_signal(&decoded), - Some(ValidSignal::new(short(), "tcf", "CPreadable")), + Some(ValidSignal::new(short(), SCHEME, "CPreadable")), "a decoded record vouches for the string as received" ); - let unreadable = ConsentContext { - raw_tc_string: Some("not a TC string".to_owned()), - ..ConsentContext::default() - }; + assert_eq!(tc_string(&decoded), Some("CPreadable")); + assert!(applies(&decoded)); assert_eq!( - TcfModule::new().read_signal(&unreadable), + TcfModule::new().read_signal(&with_status(SignalStatus::Unreadable)), None, "an unreadable string is not vouched for" ); - let expired = ConsentContext { - raw_tc_string: Some("CPold".to_owned()), - expired: true, - ..ConsentContext::default() - }; assert_eq!( - TcfModule::new().read_signal(&expired), + TcfModule::new().read_signal(&with_status(SignalStatus::Expired)), None, "an expired record is not one this module uses, so it is not vouched for" ); + assert_eq!( + TcfModule::new().read_signal(&with_record(&[1])), + None, + "a record carried in a GPP string is the GPP string's to vouch for" + ); assert_eq!( TcfModule::new().read_signal(&ConsentContext::default()), None, "and no record is no signal" ); + assert!(!applies(&ConsentContext::default())); } #[test] fn declares_exactly_the_data_uses_a_purpose_maps_to() { - // Under the shipped policy a record answers, so every mapped Data Use - // is declared and an unmapped one is not. Under a policy that silences - // the record nothing is declared, because a record that cannot answer - // is not one a page should wait for. let declared = TcfModule::new().grants(shipped_policy()); assert!( declared.contains(Permission::StoreOnDevice), @@ -392,13 +928,9 @@ mod tests { #[test] fn a_non_authoritative_policy_silences_the_record_but_not_the_withdrawal() { - // The default policy declares no TCF block, so the record does not - // answer for the deployment. Withdrawal is the narrower, destructive - // question and keeps the rule it had before the seam, which did not - // consult the flag. let silenced = SignalPolicy::default(); assert!( - !silenced.tcf_authoritative(), + !silenced.authoritative(SCHEME), "the fixture must not be authoritative" ); assert_eq!( @@ -412,7 +944,7 @@ mod tests { ); assert!( withdraws(&with_record(&[4]), &silenced, Permission::StoreOnDevice), - "but a record refusing storage still withdraws, as it did before the seam" + "but a record refusing storage still withdraws" ); } @@ -450,34 +982,13 @@ mod tests { ), "and no record is never a withdrawal" ); - } - - #[test] - fn reads_the_eu_section_of_a_gpp_string_when_there_is_no_standalone_record() { - // Core resolves a GPP string's EU TCF section as the effective record - // when no TC string arrived, and this module reads what core resolved - // rather than the wire, so it sees that section too. - let consent = ConsentContext { - gpp: Some(GppConsent { - version: 1, - section_ids: vec![2], - eu_tcf: Some(tcf_with_purposes(&[4])), - us_sale_opt_out: None, - }), - ..ConsentContext::default() - }; - assert_eq!( - answer( - &consent, + assert!( + !withdraws( + &with_status(SignalStatus::Expired), shipped_policy(), - Permission::SelectPersonalisedAds + Permission::StoreOnDevice ), - ConsentSignal::Grant, - "the EU section's consent to Purpose 4 grants targeted advertising" - ); - assert!( - withdraws(&consent, shipped_policy(), Permission::StoreOnDevice), - "and its refusal of Purpose 1 withdraws storage" + "an expired record is no record" ); } } diff --git a/crates/permission-signal/us-privacy/Cargo.toml b/crates/permission-signal/us-privacy/Cargo.toml index 349f5bd4e2..a779f53e9c 100644 --- a/crates/permission-signal/us-privacy/Cargo.toml +++ b/crates/permission-signal/us-privacy/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "trusted-server-permission-signal-us-privacy" -description = "The US Privacy string sale opt-out as a permission signal module." +description = "The US Privacy string decoder and its sale opt-out as a permission signal module." authors = { workspace = true } edition = { workspace = true } license = { workspace = true } @@ -14,7 +14,16 @@ doctest = false workspace = true [dependencies] +derive_more = { workspace = true } +error-stack = { workspace = true } +log = { workspace = true } +serde = { workspace = true } trusted-server-core = { workspace = true } +trusted-server-permission-signal-gpc = { workspace = true } + +[dev-dependencies] +http = { workspace = true } +serde_json = { workspace = true } # The visible owner of this crate, the way Prebid.js requires a named # maintainer of every adapter. The scheme's own body is the natural owner, and diff --git a/crates/permission-signal/us-privacy/src/decode.rs b/crates/permission-signal/us-privacy/src/decode.rs new file mode 100644 index 0000000000..7fc912bc7e --- /dev/null +++ b/crates/permission-signal/us-privacy/src/decode.rs @@ -0,0 +1,219 @@ +//! The US Privacy string decoder, the four character format. +//! +//! The string is `VNOL`: +//! - **V** (version): always `1` +//! - **N** (notice): `Y` given, `N` not given, `-` not applicable +//! - **O** (opt-out of sale): `Y` opted out, `N` not, `-` not applicable +//! - **L** (LSPA covered): `Y`, `N`, `-` +//! +//! # References +//! +//! - [IAB US Privacy String specification](https://github.com/InteractiveAdvertisingBureau/USPrivacy/blob/master/CCPA/US%20Privacy%20String.md) + +use core::fmt; + +use error_stack::{Report, ResultExt as _}; + +/// A decoded US Privacy string. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct UsPrivacy { + /// Specification version, always 1. + pub version: u8, + /// Whether explicit notice has been given to the consumer. + pub notice_given: PrivacyFlag, + /// Whether the consumer has opted out of the sale of personal information. + pub opt_out_sale: PrivacyFlag, + /// Whether the transaction is covered by the Limited Service Provider + /// Agreement. + pub lspa_covered: PrivacyFlag, +} + +/// One flag of the string. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PrivacyFlag { + /// `Y`. + Yes, + /// `N`. + No, + /// `-`, not applicable or unknown. + NotApplicable, +} + +impl fmt::Display for PrivacyFlag { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Yes => write!(f, "Y"), + Self::No => write!(f, "N"), + Self::NotApplicable => write!(f, "-"), + } + } +} + +impl From for PrivacyFlag { + fn from(value: bool) -> Self { + if value { Self::Yes } else { Self::No } + } +} + +impl fmt::Display for UsPrivacy { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "{}{}{}{}", + self.version, self.notice_given, self.opt_out_sale, self.lspa_covered, + ) + } +} + +/// Why a US Privacy string could not be decoded. +#[derive(Debug, derive_more::Display)] +pub enum UsPrivacyDecodeError { + /// The string is not four characters, is not version 1, or carries a + /// flag that is not `Y`, `N` or `-`. + #[display("invalid US Privacy string: {reason}")] + InvalidUsPrivacy { + /// What was wrong with it. + reason: String, + }, +} + +impl core::error::Error for UsPrivacyDecodeError {} + +/// Decodes a US Privacy string. +/// +/// # Errors +/// +/// [`UsPrivacyDecodeError::InvalidUsPrivacy`] when the string is not exactly +/// four characters, is not version 1, or carries an invalid flag. +pub fn decode_us_privacy(s: &str) -> Result> { + let chars: Vec = s.chars().collect(); + + if chars.len() != 4 { + return Err(Report::new(UsPrivacyDecodeError::InvalidUsPrivacy { + reason: format!("expected 4 characters, got {}", chars.len()), + })); + } + + let version = match chars[0] { + '1' => 1_u8, + other => { + return Err(Report::new(UsPrivacyDecodeError::InvalidUsPrivacy { + reason: format!("unsupported version '{other}', expected '1'"), + })); + } + }; + + let notice_given = + parse_flag(chars[1]).change_context(UsPrivacyDecodeError::InvalidUsPrivacy { + reason: format!("invalid notice flag '{}'", chars[1]), + })?; + + let opt_out_sale = + parse_flag(chars[2]).change_context(UsPrivacyDecodeError::InvalidUsPrivacy { + reason: format!("invalid opt-out flag '{}'", chars[2]), + })?; + + let lspa_covered = + parse_flag(chars[3]).change_context(UsPrivacyDecodeError::InvalidUsPrivacy { + reason: format!("invalid LSPA flag '{}'", chars[3]), + })?; + + Ok(UsPrivacy { + version, + notice_given, + opt_out_sale, + lspa_covered, + }) +} + +fn parse_flag(c: char) -> Result> { + match c { + 'Y' | 'y' => Ok(PrivacyFlag::Yes), + 'N' | 'n' => Ok(PrivacyFlag::No), + '-' => Ok(PrivacyFlag::NotApplicable), + other => Err(Report::new(UsPrivacyDecodeError::InvalidUsPrivacy { + reason: format!("invalid flag character '{other}'"), + })), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn decodes_standard_string() { + let result = decode_us_privacy("1YNN").expect("should decode 1YNN"); + assert_eq!(result.version, 1); + assert_eq!(result.notice_given, PrivacyFlag::Yes); + assert_eq!(result.opt_out_sale, PrivacyFlag::No); + assert_eq!(result.lspa_covered, PrivacyFlag::No); + } + + #[test] + fn decodes_all_yes() { + let result = decode_us_privacy("1YYY").expect("should decode 1YYY"); + assert_eq!(result.notice_given, PrivacyFlag::Yes); + assert_eq!(result.opt_out_sale, PrivacyFlag::Yes); + assert_eq!(result.lspa_covered, PrivacyFlag::Yes); + } + + #[test] + fn decodes_all_not_applicable() { + let result = decode_us_privacy("1---").expect("should decode 1---"); + assert_eq!(result.notice_given, PrivacyFlag::NotApplicable); + assert_eq!(result.opt_out_sale, PrivacyFlag::NotApplicable); + assert_eq!(result.lspa_covered, PrivacyFlag::NotApplicable); + } + + #[test] + fn decodes_mixed_flags() { + let result = decode_us_privacy("1NYN").expect("should decode 1NYN"); + assert_eq!(result.notice_given, PrivacyFlag::No); + assert_eq!(result.opt_out_sale, PrivacyFlag::Yes); + assert_eq!(result.lspa_covered, PrivacyFlag::No); + } + + #[test] + fn roundtrips_through_display() { + let result = decode_us_privacy("1YNN").expect("should decode"); + assert_eq!(result.to_string(), "1YNN"); + assert_eq!(PrivacyFlag::NotApplicable.to_string(), "-"); + assert_eq!(PrivacyFlag::from(true), PrivacyFlag::Yes); + assert_eq!(PrivacyFlag::from(false), PrivacyFlag::No); + } + + #[test] + fn rejects_wrong_lengths() { + assert!( + decode_us_privacy("1YN").is_err(), + "should reject 3 characters" + ); + assert!( + decode_us_privacy("1YNNN").is_err(), + "should reject 5 characters" + ); + assert!(decode_us_privacy("").is_err(), "should reject empty"); + } + + #[test] + fn rejects_bad_version() { + assert!( + decode_us_privacy("2YNN").is_err(), + "should reject version 2" + ); + } + + #[test] + fn rejects_invalid_flag() { + assert!(decode_us_privacy("1XNN").is_err(), "should reject flag 'X'"); + } + + #[test] + fn accepts_lowercase_flags() { + let result = decode_us_privacy("1ynn").expect("should accept lowercase"); + assert_eq!(result.notice_given, PrivacyFlag::Yes); + assert_eq!(result.opt_out_sale, PrivacyFlag::No); + assert_eq!(result.lspa_covered, PrivacyFlag::No); + } +} diff --git a/crates/permission-signal/us-privacy/src/lib.rs b/crates/permission-signal/us-privacy/src/lib.rs index b08b884fda..44a868457f 100644 --- a/crates/permission-signal/us-privacy/src/lib.rs +++ b/crates/permission-signal/us-privacy/src/lib.rs @@ -1,18 +1,31 @@ //! The US Privacy string sale opt-out as a permission signal module. //! -//! Answers from the sale opt-out carried in the four character `us_privacy` -//! string, which core decodes into the consent record. Core also constructs -//! that string from a Global Privacy Control header in a US state when the -//! deployment's consent settings say to, and this module sees the result -//! the same way, because it reads the record and not the wire. +//! Reads the four character `us_privacy` cookie, decodes it and answers from +//! the sale opt-out it carries. Where no string arrived and the request +//! carries a Global Privacy Control header in a US state, this module +//! constructs the string from the deployment's declared defaults, when the +//! settings say to, and records it as a policy default rather than something +//! the visitor sent. //! //! This module lives outside `trusted-server-core` deliberately, like every //! scheme. Why is set out once, in `permission_signal/README.md` in core. -use trusted_server_core::consent::{ConsentContext, PrivacyFlag}; +mod decode; + +use std::sync::atomic::{AtomicBool, Ordering}; + +pub use decode::{PrivacyFlag, UsPrivacy, UsPrivacyDecodeError, decode_us_privacy}; + +use serde::Deserialize; +use trusted_server_core::consent::jurisdiction::Jurisdiction; +use trusted_server_core::consent::{ConsentContext, ConsentSource, SignalRecord, SignalStatus}; +use trusted_server_core::consent_config::ConsentMode; +use trusted_server_core::evidence::RequestInfo; use trusted_server_core::module_context::ModuleCall; use trusted_server_core::permission_signal::{PermissionSignalModule, SignalInput}; -use trusted_server_core::permissions::{ConsentSignal, OptOutSource, Permission, ValidSignal}; +use trusted_server_core::permissions::{ConsentSignal, Permission, ValidSignal}; +use trusted_server_core::settings::Settings; +use trusted_server_permission_signal_gpc as gpc; /// The name `[permission-signal] modules` selects this module by, from its /// crate folder. @@ -30,6 +43,74 @@ fn short() -> &'static str { ) } +/// The scheme, as a [`ValidSignal`] and a [`SignalRecord`] name it. +pub const SCHEME: &str = "us_privacy"; + +/// The name this scheme is listed under in `signals.us_opt_out.sources` of +/// the permission policy. +pub const OPT_OUT_SOURCE: &str = "us_privacy_opt_out"; + +/// The cookie carrying the string. +pub const COOKIE_US_PRIVACY: &str = "us_privacy"; + +static SETTINGS_WARNING_LOGGED: AtomicBool = AtomicBool::new(false); + +/// What this module reads from the `[consent]` table. +#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)] +#[serde(default)] +pub struct UsPrivacySettings { + /// The deployment's declared posture, used to construct a string from a + /// Global Privacy Control header. + pub us_privacy_defaults: UsPrivacyDefaults, +} + +/// The `us_privacy_defaults` table of the `[consent]` settings: the +/// deployment's own compliance posture, which is policy and not a protocol +/// requirement. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(default)] +pub struct UsPrivacyDefaults { + /// Whether the deployment has shown the visitor a notice. + pub notice_given: bool, + /// Whether the deployment is subject to the Limited Service Provider + /// Agreement. + pub lspa_covered: bool, + /// Whether a `Sec-GPC: 1` header is read as an opt-out of sale. + pub gpc_implies_optout: bool, +} + +impl Default for UsPrivacyDefaults { + fn default() -> Self { + Self { + notice_given: true, + lspa_covered: false, + gpc_implies_optout: true, + } + } +} + +/// The US Privacy string the request carried, or this module constructed, +/// as it reads, when this module vouched for it. +#[must_use] +pub fn us_privacy_string(consent: &ConsentContext) -> Option<&str> { + consent.value(SCHEME) +} + +/// The decoded string, when one decoded or was constructed. +#[must_use] +pub fn decoded(consent: &ConsentContext) -> Option<&UsPrivacy> { + consent.decoded::(SCHEME) +} + +/// The record this module reports for a decoded string, for a caller that +/// builds a consent context without a request. +#[must_use] +pub fn record(decoded: UsPrivacy, raw: &str) -> SignalRecord { + SignalRecord::new(SCHEME, SignalStatus::Decoded) + .with_value(raw) + .with_decoded(decoded) +} + /// A US Privacy string sale opt-out, read from `us_privacy`. #[derive(Debug, Default, Clone, Copy)] pub struct UsPrivacyModule; @@ -41,12 +122,84 @@ impl UsPrivacyModule { Self } - /// The signal this scheme reads from the request's consent record, when - /// it can use one. + /// The module's settings from the `[consent]` table, or the defaults + /// when the table cannot be read as them, which is said once in the log. + fn settings(settings: &Settings) -> UsPrivacySettings { + match settings.consent.read::() { + Ok(read) => read, + Err(error) => { + if !SETTINGS_WARNING_LOGGED.swap(true, Ordering::Relaxed) { + log::warn!( + "The US Privacy module cannot read its settings and is using its defaults: {error:?}" + ); + } + UsPrivacySettings::default() + } + } + } + + /// Reads the string from the request, or constructs one from a Global + /// Privacy Control header in a US state when the settings say to. + fn read_record( + &self, + evidence: &dyn RequestInfo, + settings: &Settings, + consent: &ConsentContext, + ) -> Option { + let raw = evidence.cookie(COOKIE_US_PRIVACY); + if settings.consent.mode == ConsentMode::Proxy { + return raw + .map(|raw| SignalRecord::new(SCHEME, SignalStatus::NotDecoded).with_value(raw)); + } + let read = raw.map(|raw| match decode_us_privacy(raw) { + Ok(decoded) => record(decoded, raw), + Err(error) => { + log::warn!("Failed to decode US Privacy string: {error}"); + SignalRecord::new(SCHEME, SignalStatus::Unreadable).with_value(raw) + } + }); + if read + .as_ref() + .is_some_and(|record| record.status() == SignalStatus::Decoded) + { + return read; + } + // No readable string. A Global Privacy Control header in a US state + // is read as the opt-out the deployment's defaults declare. + if !gpc::header_set(evidence) || !matches!(consent.jurisdiction, Jurisdiction::UsState(_)) { + return read; + } + let defaults = Self::settings(settings).us_privacy_defaults; + if !defaults.gpc_implies_optout { + return read; + } + if defaults.notice_given { + log::warn!( + "GPC-constructed US Privacy string asserts notice_given=true; \ + verify that the publisher's privacy notice satisfies this claim" + ); + } + let constructed = UsPrivacy { + version: 1, + notice_given: PrivacyFlag::from(defaults.notice_given), + opt_out_sale: PrivacyFlag::Yes, + lspa_covered: PrivacyFlag::from(defaults.lspa_covered), + }; + log::info!("Constructed US Privacy string from GPC: {constructed}"); + Some( + record(constructed.clone(), &constructed.to_string()) + .with_source(ConsentSource::PolicyDefault), + ) + } + + /// The signal this scheme vouches for, when the string decoded or was + /// constructed. fn read_signal(&self, consent: &ConsentContext) -> Option { - consent.us_privacy.as_ref()?; - let raw = consent.raw_us_privacy.as_deref()?; - Some(ValidSignal::new(short(), "us_privacy", raw)) + let record = consent.record(SCHEME)?; + if record.status() != SignalStatus::Decoded { + return None; + } + Some(ValidSignal::new(short(), SCHEME, record.value()?)) } } @@ -55,33 +208,36 @@ impl PermissionSignalModule for UsPrivacyModule { name() } + fn read(&self, call: ModuleCall<'_>) -> Option { + call.inject(self, Self::read_record).ok().flatten() + } + fn signal(&self, permission: Permission, input: &SignalInput<'_>) -> ConsentSignal { // The policy decides whether this scheme counts at all and what an // opt-out takes away, so a module the policy does not list stays // silent even when configuration names it. - if !input - .policy - .opt_out_sources() - .contains(&OptOutSource::UsPrivacyOptOut) - { + if !input.policy.is_opt_out_source(OPT_OUT_SOURCE) { return ConsentSignal::Neutral; } - // A US Privacy string that arrived and could not be read is treated - // as the opt-out it may have carried, because a preference this - // module cannot see is not the same as no preference. - let unreadable = - input.consent.raw_us_privacy.is_some() && input.consent.us_privacy.is_none(); - let opted_out = input - .consent - .us_privacy - .as_ref() - .is_some_and(|usp| usp.opt_out_sale == PrivacyFlag::Yes); + let Some(record) = input.consent.record(SCHEME) else { + // Silence rather than refusal. Reading an absent signal as a + // refusal would revoke the permission on every request that did + // not carry this scheme, which is most of them. + return ConsentSignal::Neutral; + }; + // A string that arrived and could not be read, or was left as + // received, is treated as the opt-out it may have carried, because a + // preference this module cannot see is not the same as no preference. + let unreadable = matches!( + record.status(), + SignalStatus::Unreadable | SignalStatus::NotDecoded + ); + let opted_out = record + .decoded::() + .is_some_and(|decoded| decoded.opt_out_sale == PrivacyFlag::Yes); if (opted_out || unreadable) && input.policy.opt_out_revokes(permission) { return ConsentSignal::Revoke; } - // Silence rather than refusal. Reading an absent signal as a refusal - // would revoke the permission on every request that did not carry this - // scheme, which is most of them. ConsentSignal::Neutral } @@ -93,9 +249,10 @@ impl PermissionSignalModule for UsPrivacyModule { #[cfg(test)] mod tests { + use http::HeaderMap; + use super::*; use trusted_server_core::consent::ConsentContext; - use trusted_server_core::consent::types::UsPrivacy; use trusted_server_core::evidence::OwnedRequestInfo; use trusted_server_core::permission_signal::SignalInput; use trusted_server_core::permissions::{Acquisition, PermissionMaps, SignalPolicy}; @@ -105,15 +262,19 @@ mod tests { } fn with_sale_flag(opt_out_sale: PrivacyFlag) -> ConsentContext { - ConsentContext { - us_privacy: Some(UsPrivacy { - version: 1, - notice_given: PrivacyFlag::Yes, - opt_out_sale, - lspa_covered: PrivacyFlag::NotApplicable, - }), - ..ConsentContext::default() - } + let decoded = UsPrivacy { + version: 1, + notice_given: PrivacyFlag::Yes, + opt_out_sale, + lspa_covered: PrivacyFlag::NotApplicable, + }; + let raw = decoded.to_string(); + ConsentContext::default().with_record(record(decoded, &raw)) + } + + fn unreadable() -> ConsentContext { + ConsentContext::default() + .with_record(SignalRecord::new(SCHEME, SignalStatus::Unreadable).with_value("bogus")) } fn answer( @@ -126,6 +287,43 @@ mod tests { UsPrivacyModule::new().signal(permission, &input) } + fn evidence(cookie: Option<&str>, gpc: bool) -> OwnedRequestInfo { + let mut headers = HeaderMap::new(); + if let Some(cookie) = cookie { + headers.insert( + "cookie", + cookie.parse().expect("should build a cookie header"), + ); + } + if gpc { + headers.insert("sec-gpc", "1".parse().expect("should build the header")); + } + OwnedRequestInfo::new(String::new(), headers) + } + + fn in_jurisdiction(jurisdiction: Jurisdiction) -> ConsentContext { + ConsentContext::new(jurisdiction) + } + + fn with_consent_settings(json: serde_json::Value) -> Settings { + let mut settings = Settings::default(); + settings.consent = serde_json::from_value(json).expect("should build the consent table"); + settings + } + + fn read_with( + cookie: Option<&str>, + gpc: bool, + settings: &Settings, + jurisdiction: Jurisdiction, + ) -> Option { + UsPrivacyModule::new().read_record( + &evidence(cookie, gpc), + settings, + &in_jurisdiction(jurisdiction), + ) + } + #[test] fn answers_to_its_identifier() { assert_eq!( @@ -135,20 +333,182 @@ mod tests { ); } + #[test] + fn reads_and_decodes_the_string_from_the_request() { + let record = read_with( + Some("us_privacy=1YNN"), + false, + &Settings::default(), + Jurisdiction::NonRegulated, + ) + .expect("should read"); + assert_eq!(record.status(), SignalStatus::Decoded); + assert_eq!(record.value(), Some("1YNN")); + assert_eq!(record.source(), ConsentSource::Cookie); + assert_eq!( + record.decoded::().map(|d| d.opt_out_sale), + Some(PrivacyFlag::No) + ); + assert!( + read_with( + None, + false, + &Settings::default(), + Jurisdiction::NonRegulated + ) + .is_none(), + "no string, no record" + ); + } + + #[test] + fn an_unreadable_string_is_recorded_as_such() { + let record = read_with( + Some("us_privacy=bogus"), + false, + &Settings::default(), + Jurisdiction::NonRegulated, + ) + .expect("should read"); + assert_eq!(record.status(), SignalStatus::Unreadable); + assert!(record.decoded::().is_none()); + } + + #[test] + fn proxy_mode_leaves_the_string_as_received_and_constructs_nothing() { + let settings = with_consent_settings(serde_json::json!({ "mode": "proxy" })); + let record = read_with( + Some("us_privacy=1YNN"), + true, + &settings, + Jurisdiction::UsState("CA".to_owned()), + ) + .expect("should read"); + assert_eq!(record.status(), SignalStatus::NotDecoded); + assert!( + read_with( + None, + true, + &settings, + Jurisdiction::UsState("CA".to_owned()) + ) + .is_none(), + "nothing is constructed in proxy mode" + ); + } + + #[test] + fn a_gpc_header_in_a_us_state_constructs_the_declared_opt_out() { + let record = read_with( + None, + true, + &Settings::default(), + Jurisdiction::UsState("CA".to_owned()), + ) + .expect("should construct"); + assert_eq!(record.status(), SignalStatus::Decoded); + assert_eq!( + record.value(), + Some("1YYN"), + "notice given, opted out, no LSPA" + ); + assert_eq!( + record.source(), + ConsentSource::PolicyDefault, + "a constructed string is the deployment's declaration, not the visitor's" + ); + assert_eq!( + UsPrivacyModule::new().read_signal(&ConsentContext::default().with_record(record)), + Some(ValidSignal::new(short(), SCHEME, "1YYN")), + "and it is vouched for" + ); + } + + #[test] + fn the_constructed_string_follows_the_declared_defaults() { + let settings = with_consent_settings(serde_json::json!({ + "us_privacy_defaults": { "notice_given": false, "lspa_covered": true }, + "check_expiration": false + })); + let record = read_with( + None, + true, + &settings, + Jurisdiction::UsState("CA".to_owned()), + ) + .expect("should construct"); + assert_eq!(record.value(), Some("1NYY")); + let off = with_consent_settings(serde_json::json!({ + "us_privacy_defaults": { "gpc_implies_optout": false } + })); + assert!( + read_with(None, true, &off, Jurisdiction::UsState("CA".to_owned())).is_none(), + "a deployment that does not read the header as an opt-out constructs nothing" + ); + } + + #[test] + fn nothing_is_constructed_outside_a_us_state_or_without_the_header() { + assert!( + read_with(None, true, &Settings::default(), Jurisdiction::Gdpr).is_none(), + "the header means something else outside a US state" + ); + assert!(read_with(None, true, &Settings::default(), Jurisdiction::NonRegulated).is_none()); + assert!( + read_with( + None, + false, + &Settings::default(), + Jurisdiction::UsState("CA".to_owned()) + ) + .is_none(), + "no header, nothing to read" + ); + } + + #[test] + fn a_received_string_stands_over_the_header() { + let record = read_with( + Some("us_privacy=1YNN"), + true, + &Settings::default(), + Jurisdiction::UsState("CA".to_owned()), + ) + .expect("should read"); + assert_eq!(record.value(), Some("1YNN"), "the visitor's string is kept"); + assert_eq!(record.source(), ConsentSource::Cookie); + } + + #[test] + fn settings_read_the_table_and_leave_the_rest() { + let settings = with_consent_settings(serde_json::json!({ + "us_privacy_defaults": { "notice_given": false, "lspa_covered": true }, + "max_consent_age_days": 180 + })); + let read = UsPrivacyModule::settings(&settings); + assert!(!read.us_privacy_defaults.notice_given); + assert!(read.us_privacy_defaults.lspa_covered); + assert!(read.us_privacy_defaults.gpc_implies_optout); + let unreadable = with_consent_settings(serde_json::json!({ + "us_privacy_defaults": { "notice_given": "yes" } + })); + assert_eq!( + UsPrivacyModule::settings(&unreadable), + UsPrivacySettings::default(), + "a table that cannot be read gives the defaults" + ); + } + #[test] fn an_unreadable_us_privacy_string_is_read_as_the_opt_out_it_may_have_carried() { - let unreadable = ConsentContext { - raw_us_privacy: Some("bogus".to_owned()), - ..ConsentContext::default() - }; assert_eq!( - answer(&unreadable, shipped_policy(), Permission::StoreOnDevice), + answer(&unreadable(), shipped_policy(), Permission::StoreOnDevice), ConsentSignal::Revoke, "an unreadable string refuses what the policy lets an opt-out revoke" ); assert_eq!( answer( - &unreadable, + &unreadable(), shipped_policy(), Permission::MeasureAdPerformance ), @@ -159,21 +519,21 @@ mod tests { #[test] fn vouches_for_the_us_privacy_string_only_when_it_decoded() { - let decoded = ConsentContext { - raw_us_privacy: Some("1YNN".to_owned()), - ..with_sale_flag(PrivacyFlag::No) - }; assert_eq!( - UsPrivacyModule::new().read_signal(&decoded), - Some(ValidSignal::new(short(), "us_privacy", "1YNN")), + UsPrivacyModule::new().read_signal(&with_sale_flag(PrivacyFlag::No)), + Some(ValidSignal::new(short(), SCHEME, "1YN-")), "a decoded string is vouched for as received, whatever it says" ); - let unreadable = ConsentContext { - raw_us_privacy: Some("bogus".to_owned()), - ..ConsentContext::default() - }; assert_eq!( - UsPrivacyModule::new().read_signal(&unreadable), + us_privacy_string(&with_sale_flag(PrivacyFlag::No)), + Some("1YN-") + ); + assert_eq!( + decoded(&with_sale_flag(PrivacyFlag::No)).map(|d| d.opt_out_sale), + Some(PrivacyFlag::No) + ); + assert_eq!( + UsPrivacyModule::new().read_signal(&unreadable()), None, "an unreadable string is not" ); diff --git a/crates/trusted-server-adapter-axum/tests/permission_signals.rs b/crates/trusted-server-adapter-axum/tests/permission_signals.rs index 8d77cc204c..583dfb6322 100644 --- a/crates/trusted-server-adapter-axum/tests/permission_signals.rs +++ b/crates/trusted-server-adapter-axum/tests/permission_signals.rs @@ -11,16 +11,15 @@ //! none. The names a deployment writes in configuration are checked here for //! the same reason, against the identifiers the real crates answer to. //! -//! The consent records here are built by hand, so nothing in core's consent -//! pipeline runs. In a deployment that pipeline also synthesizes a US Privacy -//! opt-out from a Global Privacy Control header in a US state when the consent -//! settings say to, and the `us-privacy` module then acts on it, which is -//! why removing `gpc` from the list alone does not make that header inert. +//! Most consent records here are built by hand, through the constructors +//! each crate offers, so nothing reads a request. The tests at the end read +//! real cookies and headers through the modules, as a deployment does. use std::sync::Arc; -use trusted_server_core::consent::types::{GppConsent, TcfConsent, UsPrivacy}; -use trusted_server_core::consent::{ConsentContext, PrivacyFlag}; +use trusted_server_core::consent::jurisdiction::Jurisdiction; +use trusted_server_core::consent::read_consent; +use trusted_server_core::consent::{ConsentContext, ConsentSource, SignalRecord, SignalStatus}; use trusted_server_core::ec::consent::{GeoStatus, assemble_permissions}; use trusted_server_core::evidence::OwnedRequestInfo; use trusted_server_core::module_context::{ModuleContext, test_support}; @@ -30,11 +29,13 @@ use trusted_server_core::permission_signal::{ use trusted_server_core::permissions::{Permission, PermissionState}; use trusted_server_core::platform::GeoInfo; use trusted_server_core::settings::Settings; -use trusted_server_permission_signal_gpc::GpcModule; -use trusted_server_permission_signal_gpp::GppSaleOptOutModule; +use trusted_server_permission_signal_gpc::{self as gpc, GpcModule}; +use trusted_server_permission_signal_gpp::{self as gpp, GppConsent, GppSaleOptOutModule}; use trusted_server_permission_signal_mtm::MtmModule; -use trusted_server_permission_signal_tcf::TcfModule; -use trusted_server_permission_signal_us_privacy::UsPrivacyModule; +use trusted_server_permission_signal_tcf::{self as tcf, TcfConsent, TcfModule}; +use trusted_server_permission_signal_us_privacy::{ + self as us_privacy, PrivacyFlag, UsPrivacy, UsPrivacyModule, +}; /// The four IAB modules an adapter offers, in the default order. MTM is /// offered after them and is tested with them below. @@ -107,6 +108,32 @@ fn assembled_with( assemble_permissions(&context, geo, modules) } +/// Reads the consent of a request carrying `evidence` through `modules`, as +/// core does before assembling the permissions, under `jurisdiction`. +fn read( + evidence: &OwnedRequestInfo, + modules: &[Arc], + jurisdiction: Jurisdiction, +) -> ConsentContext { + let settings = Settings::default(); + let context = ModuleContext::new(test_support::request("/")) + .with_evidence(evidence) + .with_settings(&settings); + read_consent(&context, modules, jurisdiction) +} + +/// Evidence carrying these request headers. +fn evidence(headers: &[(&str, &str)]) -> OwnedRequestInfo { + let mut map = axum::http::HeaderMap::new(); + for (name, value) in headers { + map.insert( + axum::http::HeaderName::from_bytes(name.as_bytes()).expect("should name a header"), + value.parse().expect("should build a header value"), + ); + } + OwnedRequestInfo::new(String::new(), map) +} + /// Builds a minimal decoded TCF record consenting to the given 1-indexed /// purposes, with everything else refused. fn tcf_with_purposes(consented: &[usize]) -> TcfConsent { @@ -132,22 +159,35 @@ fn tcf_with_purposes(consented: &[usize]) -> TcfConsent { } } -fn us_privacy_opted_out() -> UsPrivacy { - UsPrivacy { +/// A TCF record consenting to `consented`, as the TCF module reports one it +/// decoded from the `euconsent-v2` cookie. +fn tcf_record(consented: &[usize]) -> SignalRecord { + tcf::record(tcf_with_purposes(consented), Some("CPreadable")) +} + +fn us_privacy_opted_out() -> SignalRecord { + us_privacy_with(PrivacyFlag::Yes) +} + +fn us_privacy_with(opt_out_sale: PrivacyFlag) -> SignalRecord { + let decoded = UsPrivacy { version: 1, notice_given: PrivacyFlag::Yes, - opt_out_sale: PrivacyFlag::Yes, + opt_out_sale, lspa_covered: PrivacyFlag::NotApplicable, - } + }; + let raw = decoded.to_string(); + us_privacy::record(decoded, &raw) } -fn gpp_sale_opted_out() -> GppConsent { - GppConsent { - version: 1, - section_ids: vec![7], - eu_tcf: None, - us_sale_opt_out: Some(true), - } +fn gpp_sale_opted_out() -> SignalRecord { + gpp::record( + GppConsent { + section_ids: vec![7], + us_sale_opt_out: Some(true), + }, + "DBABMA~opted-out", + ) } /// A US opt-out state, where the baseline grants storage without a signal, so @@ -165,6 +205,12 @@ fn us_ca_geo() -> GeoInfo { } } +fn with(records: impl IntoIterator) -> ConsentContext { + records + .into_iter() + .fold(ConsentContext::default(), ConsentContext::with_record) +} + // ---------------------------------------------------------------------- // Which modules run. // ---------------------------------------------------------------------- @@ -204,10 +250,7 @@ fn an_old_name_is_refused_naming_the_names_available() { fn gpc_revokes_the_granted_baseline_in_a_us_opt_out_state() { // A US-style opt-out drops a granted baseline, because the map granted // these purposes and Global Privacy Control revokes them. - let consent = ConsentContext { - gpc: true, - ..ConsentContext::default() - }; + let consent = with([gpc::record()]); let geo = us_ca_geo(); let state = assembled(&consent, GeoStatus::Located(&geo), &all_four()); assert!( @@ -219,10 +262,7 @@ fn gpc_revokes_the_granted_baseline_in_a_us_opt_out_state() { #[test] fn a_module_left_off_the_list_does_not_run() { - let consent = ConsentContext { - gpc: true, - ..ConsentContext::default() - }; + let consent = with([gpc::record()]); let geo = us_ca_geo(); let everything = assembled(&consent, GeoStatus::Located(&geo), &all_four()); @@ -242,10 +282,7 @@ fn a_module_left_off_the_list_does_not_run() { #[test] fn removing_one_opt_out_leaves_the_others_working() { // The reason the three opt-outs are separate modules rather than one. - let consent = ConsentContext { - us_privacy: Some(us_privacy_opted_out()), - ..ConsentContext::default() - }; + let consent = with([us_privacy_opted_out()]); let geo = us_ca_geo(); let state = assembled(&consent, GeoStatus::Located(&geo), &all_but("gpc")); assert!( @@ -256,14 +293,7 @@ fn removing_one_opt_out_leaves_the_others_working() { #[test] fn gpc_suppresses_storage_even_when_us_privacy_reports_no_opt_out() { - let consent = ConsentContext { - gpc: true, - us_privacy: Some(UsPrivacy { - opt_out_sale: PrivacyFlag::No, - ..us_privacy_opted_out() - }), - ..ConsentContext::default() - }; + let consent = with([gpc::record(), us_privacy_with(PrivacyFlag::No)]); let geo = us_ca_geo(); let state = assembled(&consent, GeoStatus::Located(&geo), &all_four()); assert!( @@ -286,11 +316,7 @@ fn gpc_suppresses_storage_even_when_us_privacy_reports_no_opt_out() { #[test] fn a_prompt_answer_applies_over_a_gpc_signal() { - let consent = ConsentContext { - tcf: Some(tcf_with_purposes(&[1, 4])), - gpc: true, - ..ConsentContext::default() - }; + let consent = with([tcf_record(&[1, 4]), gpc::record()]); let geo = us_ca_geo(); let state = assembled(&consent, GeoStatus::Located(&geo), &all_four()); assert!( @@ -302,11 +328,7 @@ fn a_prompt_answer_applies_over_a_gpc_signal() { #[test] fn a_prompt_answer_applies_over_a_us_privacy_opt_out_signal() { - let consent = ConsentContext { - tcf: Some(tcf_with_purposes(&[1, 4])), - us_privacy: Some(us_privacy_opted_out()), - ..ConsentContext::default() - }; + let consent = with([tcf_record(&[1, 4]), us_privacy_opted_out()]); let geo = us_ca_geo(); let state = assembled(&consent, GeoStatus::Located(&geo), &all_four()); assert!( @@ -318,11 +340,7 @@ fn a_prompt_answer_applies_over_a_us_privacy_opt_out_signal() { #[test] fn a_prompt_answer_applies_over_a_gpp_sale_opt_out_signal() { - let consent = ConsentContext { - tcf: Some(tcf_with_purposes(&[1, 4])), - gpp: Some(gpp_sale_opted_out()), - ..ConsentContext::default() - }; + let consent = with([tcf_record(&[1, 4]), gpp_sale_opted_out()]); let geo = us_ca_geo(); let state = assembled(&consent, GeoStatus::Located(&geo), &all_four()); assert!( @@ -334,11 +352,7 @@ fn a_prompt_answer_applies_over_a_gpp_sale_opt_out_signal() { #[test] fn the_opt_out_wins_when_a_deployment_puts_it_last() { - let consent = ConsentContext { - tcf: Some(tcf_with_purposes(&[1, 4])), - gpc: true, - ..ConsentContext::default() - }; + let consent = with([tcf_record(&[1, 4]), gpc::record()]); let geo = us_ca_geo(); let modules = configured(&["tcf", "gpc"]); let state = assembled(&consent, GeoStatus::Located(&geo), &modules); @@ -349,7 +363,7 @@ fn the_opt_out_wins_when_a_deployment_puts_it_last() { } // ---------------------------------------------------------------------- -// The TCF mapping, now the TCF crate's, still reaches every purpose. +// The TCF mapping, the TCF crate's, still reaches every purpose. // ---------------------------------------------------------------------- #[test] @@ -358,10 +372,7 @@ fn tcf_resolves_every_mapped_purpose_not_just_storage_and_ads() { // US opt-out state where the baseline granted them all, so a revoke is // observable as a drop. let consented: Vec = (1..=11).filter(|&purpose| purpose != 7).collect(); - let consent = ConsentContext { - tcf: Some(tcf_with_purposes(&consented)), - ..ConsentContext::default() - }; + let consent = with([tcf_record(&consented)]); let geo = us_ca_geo(); let state = assembled(&consent, GeoStatus::Located(&geo), &all_four()); @@ -390,10 +401,7 @@ fn tcf_resolves_every_mapped_purpose_not_just_storage_and_ads() { #[test] fn tcf_storage_refusal_withdraws_under_a_requires_signal_baseline() { - let consent = ConsentContext { - tcf: Some(tcf_with_purposes(&[4])), - ..ConsentContext::default() - }; + let consent = with([tcf_record(&[4])]); let state = assembled(&consent, GeoStatus::NoLocation, &all_four()); assert!( state.storage_withdrawn(), @@ -403,10 +411,7 @@ fn tcf_storage_refusal_withdraws_under_a_requires_signal_baseline() { #[test] fn tcf_storage_refusal_does_not_withdraw_under_a_granted_baseline() { - let consent = ConsentContext { - tcf: Some(tcf_with_purposes(&[4])), - ..ConsentContext::default() - }; + let consent = with([tcf_record(&[4])]); let geo = us_ca_geo(); let state = assembled(&consent, GeoStatus::Located(&geo), &all_four()); assert!( @@ -417,10 +422,7 @@ fn tcf_storage_refusal_does_not_withdraw_under_a_granted_baseline() { #[test] fn tcf_storage_consent_is_not_a_withdrawal() { - let consent = ConsentContext { - tcf: Some(tcf_with_purposes(&[1])), - ..ConsentContext::default() - }; + let consent = with([tcf_record(&[1])]); let state = assembled(&consent, GeoStatus::NoLocation, &all_four()); assert!( !state.storage_withdrawn(), @@ -430,10 +432,7 @@ fn tcf_storage_consent_is_not_a_withdrawal() { #[test] fn gpc_alone_never_withdraws() { - let consent = ConsentContext { - gpc: true, - ..ConsentContext::default() - }; + let consent = with([gpc::record()]); let geo = us_ca_geo(); assert!( !assembled(&consent, GeoStatus::Located(&geo), &all_four()).storage_withdrawn() @@ -444,11 +443,7 @@ fn gpc_alone_never_withdraws() { #[test] fn us_style_opt_outs_never_withdraw() { - let consent = ConsentContext { - us_privacy: Some(us_privacy_opted_out()), - gpp: Some(gpp_sale_opted_out()), - ..ConsentContext::default() - }; + let consent = with([us_privacy_opted_out(), gpp_sale_opted_out()]); let state = assembled(&consent, GeoStatus::NoLocation, &all_four()); assert!( !state.storage_withdrawn(), @@ -473,10 +468,7 @@ fn no_signal_is_not_a_withdrawal() { fn a_withdrawal_needs_the_tcf_module_to_be_running() { // The withdrawal is TCF's answer, so a deployment that removed the TCF // module from the list has no scheme left that can withdraw. - let consent = ConsentContext { - tcf: Some(tcf_with_purposes(&[4])), - ..ConsentContext::default() - }; + let consent = with([tcf_record(&[4])]); let state = assembled(&consent, GeoStatus::NoLocation, &all_but("tcf")); assert!( !state.storage_withdrawn(), @@ -490,10 +482,9 @@ fn a_withdrawal_needs_the_tcf_module_to_be_running() { #[test] fn an_unreadable_tcf_record_is_the_tcf_modules_refusal() { - let consent = ConsentContext { - raw_tc_string: Some("not-a-tc-string".to_owned()), - ..ConsentContext::default() - }; + let consent = with([ + SignalRecord::new(tcf::SCHEME, SignalStatus::Unreadable).with_value("not-a-tc-string") + ]); let geo = us_ca_geo(); let state = assembled(&consent, GeoStatus::Located(&geo), &all_four()); assert!( @@ -521,12 +512,11 @@ fn an_unreadable_gpp_string_is_the_gpp_modules_opt_out_and_the_order_decides() { // may have carried. Asked after it, a readable TCF record consenting to // storage amends that, because the order is the policy. Nothing in // core answers ahead of the modules. - let consent = ConsentContext { - tcf: Some(tcf_with_purposes(&[1, 4])), - raw_tc_string: Some("CPreadable".to_owned()), - raw_gpp_string: Some("this is not a GPP string".to_owned()), - ..ConsentContext::default() - }; + let consent = with([ + tcf_record(&[1, 4]), + SignalRecord::new(gpp::SCHEME, SignalStatus::Unreadable) + .with_value("this is not a GPP string"), + ]); let geo = us_ca_geo(); assert!( !assembled(&consent, GeoStatus::Located(&geo), &all_but("tcf")) @@ -548,11 +538,8 @@ fn an_unreadable_gpp_string_is_the_gpp_modules_opt_out_and_the_order_decides() { #[test] fn an_expired_tcf_record_is_not_treated_as_malformed() { - let consent = ConsentContext { - raw_tc_string: Some("CPc-old-string".to_owned()), - expired: true, - ..ConsentContext::default() - }; + let consent = + with([SignalRecord::new(tcf::SCHEME, SignalStatus::Expired).with_value("CPc-old-string")]); let geo = us_ca_geo(); let state = assembled(&consent, GeoStatus::Located(&geo), &all_four()); assert!( @@ -591,10 +578,7 @@ fn a_visitor_in_the_eu_with_no_record_is_awaiting_what_tcf_could_grant() { "a channel no module can grant is not awaited" ); let state = assembled( - &ConsentContext { - tcf: Some(tcf_with_purposes(&[1])), - ..ConsentContext::default() - }, + &with([tcf_record(&[1])]), GeoStatus::Located(&geo), &all_four(), ); @@ -623,19 +607,10 @@ fn a_pmp_answer_in_the_eu_settles_what_the_model_terms_cover_and_declares_them() region: None, asn: None, }; - let evidence = |word: &str| { - let mut headers = axum::http::HeaderMap::new(); - headers.insert( - "cookie", - format!("{COOKIE_MTM_PREF}={word}") - .parse() - .expect("should build a cookie header"), - ); - OwnedRequestInfo::new(String::new(), headers) - }; + let answered = |word: &str| evidence(&[("cookie", &format!("{COOKIE_MTM_PREF}={word}"))]); let standard = assembled_with( &ConsentContext::default(), - &evidence("standard"), + &answered("standard"), GeoStatus::Located(&geo), &all_five(), ); @@ -664,7 +639,7 @@ fn a_pmp_answer_in_the_eu_settles_what_the_model_terms_cover_and_declares_them() ); let personalized = assembled_with( &ConsentContext::default(), - &evidence("personalized"), + &answered("personalized"), GeoStatus::Located(&geo), &all_five(), ); @@ -683,3 +658,81 @@ fn a_pmp_answer_in_the_eu_settles_what_the_model_terms_cover_and_declares_them() "with no answer the question is still open and no terms are declared" ); } + +// ---------------------------------------------------------------------- +// The modules read the request. Core decodes nothing, so what a request +// carries reaches the permissions only through the module for its scheme. +// ---------------------------------------------------------------------- + +#[test] +fn each_module_reads_its_own_signal_from_the_request() { + let request = evidence(&[("cookie", "us_privacy=1YYN; other=1"), ("sec-gpc", "1")]); + let consent = read( + &request, + &all_five(), + Jurisdiction::UsState("CA".to_owned()), + ); + let schemes: Vec<&str> = consent.records.iter().map(SignalRecord::scheme).collect(); + assert_eq!( + schemes, + vec![gpc::SCHEME, us_privacy::SCHEME], + "the modules that found a signal report one, in the configured order" + ); + assert_eq!(us_privacy::us_privacy_string(&consent), Some("1YYN")); + assert!(gpc::signaled(&consent)); + assert!(!tcf::applies(&consent), "no EU signal arrived"); + + let geo = us_ca_geo(); + let state = assembled_with(&consent, &request, GeoStatus::Located(&geo), &all_five()); + assert!( + !state.is_set(Permission::StoreOnDevice), + "the opt-outs the modules read revoke the granted baseline" + ); + let schemes: Vec<&str> = state.signals().iter().map(|s| s.scheme).collect(); + assert_eq!( + schemes, + vec![gpc::SCHEME, us_privacy::SCHEME], + "and both are vouched for" + ); +} + +#[test] +fn a_gpc_header_in_a_us_state_becomes_the_us_privacy_modules_record() { + // The US Privacy module constructs a string from the header when the + // settings say to, which they do by default, so removing `gpc` from the + // list alone does not make the header inert. A publisher who wants that + // turns the setting off as well. + let request = evidence(&[("sec-gpc", "1")]); + let modules = all_but("gpc"); + let consent = read(&request, &modules, Jurisdiction::UsState("CA".to_owned())); + let record = consent + .record(us_privacy::SCHEME) + .expect("the US Privacy module should construct a record from the header"); + assert_eq!(record.value(), Some("1YYN")); + assert_eq!(record.source(), ConsentSource::PolicyDefault); + assert!( + consent.record(gpc::SCHEME).is_none(), + "the module that reads the header itself did not run" + ); + + let geo = us_ca_geo(); + let state = assembled_with(&consent, &request, GeoStatus::Located(&geo), &modules); + assert!( + !state.is_set(Permission::StoreOnDevice), + "the constructed opt-out is acted on by the module that constructed it" + ); + + let consent = read(&request, &modules, Jurisdiction::Gdpr); + assert!( + consent.record(us_privacy::SCHEME).is_none(), + "outside a US state the header constructs nothing" + ); +} + +#[test] +fn a_request_carrying_nothing_reads_as_nothing() { + let consent = read(&no_evidence(), &all_five(), Jurisdiction::NonRegulated); + assert!(consent.records.is_empty(), "no module found a signal"); + assert!(consent.is_empty()); + assert_eq!(consent.jurisdiction, Jurisdiction::NonRegulated); +} diff --git a/crates/trusted-server-adapter-cloudflare/src/platform.rs b/crates/trusted-server-adapter-cloudflare/src/platform.rs index e83ce1c584..941beb869f 100644 --- a/crates/trusted-server-adapter-cloudflare/src/platform.rs +++ b/crates/trusted-server-adapter-cloudflare/src/platform.rs @@ -862,8 +862,9 @@ mod tests { // `detect_jurisdiction` reaches a US state node of the policy tree // only when the country is `US` and a region is present. With no // region a US visitor falls through to the country node's - // `NonRegulated`, where `allows_ec_creation` returns true without - // reading `ctx.gpc`, so a Sec-GPC opt-out would be ignored. + // `NonRegulated`, which no US state scheme reads as its own, so the + // string a module constructs from a browser setting in a US state is + // never constructed. let ctx = make_ctx_with_headers(&[("cf-ipcountry", "US"), ("cf-region-code", "CA")]); let geo = build_geo(&ctx) .lookup(None, &test_services()) diff --git a/crates/trusted-server-core/Cargo.toml b/crates/trusted-server-core/Cargo.toml index e68b1eca70..607cb0ff17 100644 --- a/crates/trusted-server-core/Cargo.toml +++ b/crates/trusted-server-core/Cargo.toml @@ -31,7 +31,6 @@ hex = { workspace = true } hmac = { workspace = true } http = { workspace = true } httpdate = { workspace = true } -iab_gpp = { workspace = true } jose-jwk = { workspace = true } log = { workspace = true } lol_html = { workspace = true } @@ -93,10 +92,6 @@ edgezero-core = { workspace = true } hex = { workspace = true } sha2 = { workspace = true } -[[bench]] -name = "consent_decode" -harness = false - [[bench]] name = "html_processor_bench" harness = false diff --git a/crates/trusted-server-core/benches/consent_decode.rs b/crates/trusted-server-core/benches/consent_decode.rs deleted file mode 100644 index 8323f8407e..0000000000 --- a/crates/trusted-server-core/benches/consent_decode.rs +++ /dev/null @@ -1,236 +0,0 @@ -//! Benchmarks for the consent decoding pipeline. -//! -//! Measures the computational cost of decoding consent signals (TCF v2, GPP, -//! US Privacy) to determine whether wiring decoding into the auction hot path -//! introduces unacceptable latency. -//! -//! Run with: `cargo bench -p trusted-server-core` - -use base64::Engine as _; -use base64::engine::general_purpose::URL_SAFE_NO_PAD; -use criterion::{BenchmarkId, Criterion, black_box, criterion_group, criterion_main}; - -use trusted_server_core::consent::tcf::decode_tc_string; -use trusted_server_core::consent::types::RawConsentSignals; -use trusted_server_core::consent::us_privacy::decode_us_privacy; -use trusted_server_core::consent::{build_context_from_signals, gpp}; - -// --------------------------------------------------------------------------- -// Test data -// --------------------------------------------------------------------------- - -/// Known-good GPP string with US Privacy section only (section ID 6). -const GPP_USP_ONLY: &str = "DBABTA~1YNN"; - -/// GPP string with both TCF EU v2 and US Privacy sections. -const GPP_TCF_AND_USP: &str = "DBACNY~CPXxRfAPXxRfAAfKABENB-CgAAAAAAAAAAYgAAAAAAAA~1YNN"; - -/// Builds a minimal TC String v2 byte buffer for benchmarking. -/// -/// This duplicates the test helper from `tcf.rs` since `#[cfg(test)]` helpers -/// are not available in bench targets. -fn build_tc_bytes(vendor_count: u16, use_range_encoding: bool) -> Vec { - let total_bits = if use_range_encoding { - // Core fields (213) + maxVendorId (16) + isRange (1) + numEntries (12) - // + one range entry per vendor group: isRange(1) + start(16) + end(16) - // We'll encode as one big range: vendors 1..=vendor_count - 213 + 17 + 12 + 1 + 32 - } else { - // Bitfield: core fields + maxVendorId + isRange + one bit per vendor - 213 + 17 + usize::from(vendor_count) - }; - let total_bytes = total_bits.div_ceil(8); - let mut buf = vec![0_u8; total_bytes]; - - // Version (6 bits) = 2 - write_bits(&mut buf, 0, 6, 2); - // Created (36 bits) = 100000 (arbitrary) - write_bits(&mut buf, 6, 36, 100_000); - // LastUpdated (36 bits) = 200000 - write_bits(&mut buf, 42, 36, 200_000); - // CmpId (12 bits) = 7 - write_bits(&mut buf, 78, 12, 7); - // CmpVersion (12 bits) = 1 - write_bits(&mut buf, 90, 12, 1); - // ConsentScreen (6 bits) = 1 - write_bits(&mut buf, 102, 6, 1); - // ConsentLanguage (12 bits) = EN - write_bits(&mut buf, 108, 6, u64::from(b'E' - b'A')); - write_bits(&mut buf, 114, 6, u64::from(b'N' - b'A')); - // VendorListVersion (12 bits) = 42 - write_bits(&mut buf, 120, 12, 42); - // TcfPolicyVersion (6 bits) = 2 - write_bits(&mut buf, 132, 6, 2); - // IsServiceSpecific (1) = 0, UseNonStandardTexts (1) = 0 - // SpecialFeatureOptIns (12) = 0b000000000011 (features 11, 12) - write_bits(&mut buf, 140, 12, 0b0000_0000_0011); - // PurposesConsent (24) = purposes 1-4 consented - write_bits(&mut buf, 152, 24, 0b1111_0000_0000_0000_0000_0000); - // PurposesLITransparency (24) = purposes 1-2 - write_bits(&mut buf, 176, 24, 0b1100_0000_0000_0000_0000_0000); - // PurposeOneTreatment (1) = 0 - // PublisherCC (12) = EN - write_bits(&mut buf, 201, 6, u64::from(b'E' - b'A')); - write_bits(&mut buf, 207, 6, u64::from(b'N' - b'A')); - - // MaxVendorConsentId (16) - write_bits(&mut buf, 213, 16, u64::from(vendor_count)); - - if use_range_encoding { - // IsRangeEncoding (1) = 1 - write_bit(&mut buf, 229, true); - // NumEntries (12) = 1 (one range covering all vendors) - write_bits(&mut buf, 230, 12, 1); - // Entry: IsRangeEntry (1) = 1 - write_bit(&mut buf, 242, true); - // StartVendorId (16) = 1 - write_bits(&mut buf, 243, 16, 1); - // EndVendorId (16) = vendor_count - write_bits(&mut buf, 259, 16, u64::from(vendor_count)); - } else { - // IsRangeEncoding (1) = 0 (bitfield) - write_bit(&mut buf, 229, false); - // Set every other vendor as consented (realistic pattern) - for i in 0..usize::from(vendor_count) { - if i % 2 == 0 { - write_bit(&mut buf, 230 + i, true); - } - } - } - - buf -} - -fn write_bit(buf: &mut [u8], bit_offset: usize, value: bool) { - if value { - let byte_idx = bit_offset / 8; - let bit_idx = 7 - (bit_offset % 8); - if byte_idx < buf.len() { - buf[byte_idx] |= 1 << bit_idx; - } - } -} - -fn write_bits(buf: &mut [u8], bit_offset: usize, num_bits: usize, value: u64) { - for i in 0..num_bits { - let bit = (value >> (num_bits - 1 - i)) & 1 == 1; - write_bit(buf, bit_offset + i, bit); - } -} - -fn encode_tc_string(vendor_count: u16, use_range: bool) -> String { - let bytes = build_tc_bytes(vendor_count, use_range); - URL_SAFE_NO_PAD.encode(&bytes) -} - -// --------------------------------------------------------------------------- -// Benchmarks -// --------------------------------------------------------------------------- - -fn bench_us_privacy(c: &mut Criterion) { - c.bench_function("us_privacy_decode", |b| { - b.iter(|| decode_us_privacy(black_box("1YNN"))); - }); -} - -fn bench_tcf_decode(c: &mut Criterion) { - let small_tc = encode_tc_string(10, false); - let medium_tc = encode_tc_string(100, false); - let large_tc_bitfield = encode_tc_string(500, false); - let large_tc_range = encode_tc_string(500, true); - - let mut group = c.benchmark_group("tcf_decode"); - - group.bench_with_input( - BenchmarkId::new("bitfield", "10_vendors"), - &small_tc, - |b, tc| { - b.iter(|| decode_tc_string(black_box(tc))); - }, - ); - - group.bench_with_input( - BenchmarkId::new("bitfield", "100_vendors"), - &medium_tc, - |b, tc| { - b.iter(|| decode_tc_string(black_box(tc))); - }, - ); - - group.bench_with_input( - BenchmarkId::new("bitfield", "500_vendors"), - &large_tc_bitfield, - |b, tc| { - b.iter(|| decode_tc_string(black_box(tc))); - }, - ); - - group.bench_with_input( - BenchmarkId::new("range", "500_vendors"), - &large_tc_range, - |b, tc| { - b.iter(|| decode_tc_string(black_box(tc))); - }, - ); - - group.finish(); -} - -fn bench_gpp_decode(c: &mut Criterion) { - let mut group = c.benchmark_group("gpp_decode"); - - group.bench_function("usp_only", |b| { - b.iter(|| gpp::decode_gpp_string(black_box(GPP_USP_ONLY))); - }); - - group.bench_function("with_tcf", |b| { - b.iter(|| gpp::decode_gpp_string(black_box(GPP_TCF_AND_USP))); - }); - - group.finish(); -} - -fn bench_full_pipeline(c: &mut Criterion) { - // Build a realistic TC string (500 vendors, range encoding) - let tc_string = encode_tc_string(500, true); - - let all_signals = RawConsentSignals { - raw_tc_string: Some(tc_string), - raw_gpp_string: Some(GPP_USP_ONLY.to_owned()), - raw_gpp_sid: Some("6".to_owned()), - raw_us_privacy: Some("1YNN".to_owned()), - gpc: true, - }; - - let empty_signals = RawConsentSignals::default(); - - let tc_only = RawConsentSignals { - raw_tc_string: Some(encode_tc_string(500, true)), - ..Default::default() - }; - - let mut group = c.benchmark_group("full_pipeline"); - - group.bench_function("all_signals", |b| { - b.iter(|| build_context_from_signals(black_box(&all_signals))); - }); - - group.bench_function("empty_signals", |b| { - b.iter(|| build_context_from_signals(black_box(&empty_signals))); - }); - - group.bench_function("tcf_only", |b| { - b.iter(|| build_context_from_signals(black_box(&tc_only))); - }); - - group.finish(); -} - -criterion_group!( - benches, - bench_us_privacy, - bench_tcf_decode, - bench_gpp_decode, - bench_full_pipeline, -); -criterion_main!(benches); diff --git a/crates/trusted-server-core/src/auction/demand.rs b/crates/trusted-server-core/src/auction/demand.rs index c9800bb36c..8a7b2c67cf 100644 --- a/crates/trusted-server-core/src/auction/demand.rs +++ b/crates/trusted-server-core/src/auction/demand.rs @@ -69,8 +69,6 @@ pub struct DemandFieldPolicy { pub site_ref: bool, /// Send precise latitude and longitude with the coarse geo. pub precise_geo: bool, - /// Send Google Additional Consent as `user.ext.ConsentedProvidersSettings`. - pub additional_consent: bool, /// The longest primary `Accept-Language` tag sent, or `None` for no limit. pub language_max_bytes: Option, /// How `regs` is derived from consent. diff --git a/crates/trusted-server-core/src/auction/endpoints.rs b/crates/trusted-server-core/src/auction/endpoints.rs index bd265b4249..823d9834e7 100644 --- a/crates/trusted-server-core/src/auction/endpoints.rs +++ b/crates/trusted-server-core/src/auction/endpoints.rs @@ -9,7 +9,7 @@ use serde_json::Value as JsonValue; use crate::auction::formats::AdRequest; use crate::auction::orchestrator::OrchestrationResult; -use crate::consent::{consent_allows_server_side_auction, gate_eids_by_permissions}; +use crate::consent::{gate_eids_by_permissions, permissions_allow_server_side_auction}; use crate::constants::COOKIE_EIDS; use crate::cookies::extract_cookie_value; use crate::ec::EcContext; @@ -225,14 +225,14 @@ pub async fn handle_auction( ); } - // Server-side auction consent gate. The publisher-navigation and - // `/_ts/page-bids` paths fail closed for GDPR/unknown jurisdictions that - // lack effective TCF Purpose 1. `/auction` is the programmatic entry point + // Server-side auction permission gate. The publisher-navigation and + // `/_ts/page-bids` paths fail closed where the resolved permissions do + // not set basic ad selection. `/auction` is the programmatic entry point // for the same server-side auction, so it must gate identically: returning - // a no-bid response here prevents outbound PBS/APS calls and the forwarding - // of request-derived signals (UA/IP/geo, and cookies under some Prebid + // a no-bid response here prevents outbound demand calls and the forwarding + // of request-derived signals (UA/IP/geo, and cookies under some // consent-forwarding modes) for traffic that must not run an auction. - if !consent_allows_server_side_auction(&consent_context) { + if !permissions_allow_server_side_auction(ec_context.permissions()) { log::info!( "/auction: server-side auction consent gate denied; returning no-bid response without contacting providers" ); @@ -286,10 +286,9 @@ pub async fn handle_auction( // Gate this on the same identity condition as the EC ID // (`ec_id.is_some()`, which is already filtered by the sharing pair via // `ec_context.ec_sharing_allowed()`). - // Otherwise a US/GPC or US-Privacy opt-out context — where EC identity use is - // denied but a non-personalized auction may still run — could forward - // persistent client EIDs from the body/cookie, since `gate_eids_by_consent` - // only strips on TCF/GDPR signals. This matches the publisher and + // Otherwise a US-style opt-out context, where EC identity use is denied + // but a non-personalized auction may still run, could forward persistent + // client EIDs from the body/cookie. This matches the publisher and // `/_ts/page-bids` paths, which also resolve client EIDs only when // `ec_id.is_some()`. let client_eids = if ec_id.is_some() { @@ -1313,8 +1312,8 @@ mod tests { #[tokio::test] async fn auction_endpoint_consent_gate_returns_no_bid_without_contacting_providers() { - // GDPR/unknown jurisdiction lacking effective TCF Purpose 1 must not run - // a server-side auction. The /auction endpoint must short-circuit to a + // A request whose resolved permissions do not set basic ad selection + // must not run a server-side auction. The /auction endpoint must short-circuit to a // no-bid response before dispatching to any provider — matching the // publisher-navigation and /_ts/page-bids paths. let settings = create_test_settings(); @@ -1441,12 +1440,12 @@ mod tests { #[tokio::test] async fn auction_strips_client_eids_when_ec_identity_denied() { - // US-state opt-out via GPC: the server-side auction consent gate still - // allows a non-personalized auction, but EC identity use is denied - // (`ec_allowed()` is false) and `gate_eids_by_consent` does not strip - // because no TCF signal is present and GDPR does not apply. Client EIDs - // supplied in the request body/cookie must NOT be forwarded — the - // outgoing auction request must have `user.eids == None`. + // A US-state opt-out: the server-side auction permission gate still + // allows a non-personalized auction, because the opt-out leaves basic + // ad selection set, but EC identity use is denied (`ec_allowed()` is + // false). Client EIDs supplied in the request body/cookie must NOT be + // forwarded, so the outgoing auction request must have + // `user.eids == None`. let settings = create_test_settings(); let config = AuctionConfig { enabled: true, @@ -1462,13 +1461,12 @@ mod tests { })); let services = noop_services(); - // US-state jurisdiction with an explicit GPC opt-out: auction allowed, + // US-state jurisdiction with an explicit opt-out: auction allowed, // EC identity denied. let mut ec_context = EcContext::new_for_test( None, ConsentContext { jurisdiction: Jurisdiction::UsState("CA".to_owned()), - gpc: true, ..ConsentContext::default() }, ); diff --git a/crates/trusted-server-core/src/auction/openrtb.rs b/crates/trusted-server-core/src/auction/openrtb.rs index fdea467979..1445871532 100644 --- a/crates/trusted-server-core/src/auction/openrtb.rs +++ b/crates/trusted-server-core/src/auction/openrtb.rs @@ -21,8 +21,8 @@ use super::routing::{ProviderAuctionInput, ProviderSlotInput, RoutedAuction, Tra use super::types::{AdFormat, AdSlot, AuctionResponse, Bid}; use crate::error::TrustedServerError; use crate::openrtb::{ - Banner, ConsentedProvidersSettings, Device, Format, Geo, Imp, OpenRtbRequest, Publisher, Regs, - RegsExt, Site, ToExt as _, TrustedServerExt, User, UserExt, to_openrtb_i32, + Banner, Device, Format, Geo, Imp, OpenRtbRequest, Publisher, Regs, RegsExt, Site, ToExt as _, + TrustedServerExt, User, UserExt, to_openrtb_i32, }; use crate::platform::PlatformResponse; use crate::request_signing::{RequestSigner, SIGNING_VERSION, SigningParams}; @@ -365,22 +365,13 @@ fn build_common_request( let site_domain = demand.site_domain(&common.publisher.domain); let page = demand.site_page(common.publisher.page_url.as_deref(), &site_domain); let body_consent = demand.body_consent(common.user.consent.as_ref()); - let raw_tc = body_consent.and_then(|value| value.raw_tc_string.clone()); + let raw_tc = body_consent.and_then(|value| value.value(SCHEME_TCF).map(str::to_owned)); let user = Some(User { id: common.user.id.clone(), consent: raw_tc.clone(), ext: UserExt { consent: raw_tc, - consented_providers_settings: policy - .additional_consent - .then(|| { - body_consent - .and_then(|value| value.raw_ac_string.clone()) - .map(|consented_providers| ConsentedProvidersSettings { - consented_providers: Some(consented_providers), - }) - }) - .flatten(), + consented_providers_settings: None, eids: common.user.eids.clone(), } .to_ext(), @@ -544,29 +535,42 @@ fn finalize_request( Ok(()) } +/// The schemes whose signals the request carries in its own fields, by the +/// names the modules for them record. The strings are read through the +/// consent context's neutral interface here, and the crate for each scheme +/// answers the same question for a reader that depends on it. +const SCHEME_TCF: &str = "tcf"; +const SCHEME_GPP: &str = "gpp"; +const SCHEME_US_PRIVACY: &str = "us_privacy"; + fn build_regs( consent: Option<&crate::consent::ConsentContext>, policy: RegsPolicy, ) -> Option { let consent = consent?; + let applies = consent.applies(); + let us_privacy = consent.value(SCHEME_US_PRIVACY).map(str::to_owned); + let gpp = consent.value(SCHEME_GPP).map(str::to_owned); + let gpp_sid = consent + .record(SCHEME_GPP) + .map(|record| record.sections().to_vec()) + .filter(|sections| !sections.is_empty()); if policy == RegsPolicy::ApplicabilityBit { // Any admitted context produces regs and GDPR comes from the // applicability bit alone, without jurisdiction rules. let ext = RegsExt { - gdpr: Some(u8::from(consent.gdpr_applies)), - us_privacy: consent.raw_us_privacy.clone(), - gpp: consent.raw_gpp_string.clone(), - gpp_sid: consent.gpp_section_ids.clone(), + gdpr: Some(u8::from(applies)), + us_privacy: us_privacy.clone(), + gpp: gpp.clone(), + gpp_sid: gpp_sid.clone(), }; return Some(Regs { coppa: None, - gdpr: Some(consent.gdpr_applies), - us_privacy: ext.us_privacy.clone(), - gpp: ext.gpp.clone(), - gpp_sid: ext - .gpp_sid - .as_ref() - .map(|ids| ids.iter().copied().map(i32::from).collect()) + gdpr: Some(applies), + us_privacy, + gpp, + gpp_sid: gpp_sid + .map(|ids| ids.into_iter().map(i32::from).collect()) .unwrap_or_default(), ext: ext.to_ext(), }); @@ -574,15 +578,11 @@ fn build_regs( // The jurisdiction policy sends no regs for an empty context and reads // GDPR from the applicability bit or from a GDPR jurisdiction. - let has_data = consent.gdpr_applies - || consent.raw_us_privacy.is_some() - || consent.raw_gpp_string.is_some() - || consent.gpp_section_ids.is_some() - || consent.gpc; + let has_data = applies || !consent.is_empty() || gpp_sid.is_some(); if !has_data { return None; } - let gdpr = if consent.gdpr_applies + let gdpr = if applies || matches!( consent.jurisdiction, crate::consent::jurisdiction::Jurisdiction::Gdpr @@ -596,9 +596,6 @@ fn build_regs( } else { Some(false) }; - let us_privacy = consent.raw_us_privacy.clone(); - let gpp = consent.raw_gpp_string.clone(); - let gpp_sid = consent.gpp_section_ids.clone(); let ext = RegsExt { gdpr: gdpr.map(u8::from), us_privacy: us_privacy.clone(), diff --git a/crates/trusted-server-core/src/auction/openrtb/tests.rs b/crates/trusted-server-core/src/auction/openrtb/tests.rs index af47ffad9c..eec93bbb71 100644 --- a/crates/trusted-server-core/src/auction/openrtb/tests.rs +++ b/crates/trusted-server-core/src/auction/openrtb/tests.rs @@ -17,8 +17,8 @@ use crate::auction::provider::{GenericOpenRtbProvider, ProviderRequestOutcome}; use crate::auction::routing::route_auction; use crate::auction::test_support::{canonical_parity_auction_request, demand_table, plan_config}; use crate::auction::types::{AdFormat, AdSlot, BidStatus, MediaType}; -use crate::consent::ConsentContext; use crate::consent::jurisdiction::Jurisdiction; +use crate::consent::{ConsentContext, SignalRecord, SignalStatus}; use crate::platform::test_support::{ HashMapConfigStore, HashMapSecretStore, NoopHttpClient, StubBackend, StubHttpClient, build_services_with_backend_and_http_client, build_services_with_config_secret_and_http_client, @@ -166,42 +166,35 @@ fn deterministic_signer() -> RequestSigner { #[test] fn consent_matrix_holds_under_each_implementation() { + let applying = SignalRecord::new("tcf", SignalStatus::Decoded) + .with_value("tc-string") + .with_applies(true); + let browser_setting = SignalRecord::new("gpc", SignalStatus::Decoded).with_value("1"); let cases = [ ("empty", ConsentContext::default()), ( "gdpr", - ConsentContext { - gdpr_applies: true, - raw_tc_string: Some("tc-string".to_string()), - jurisdiction: Jurisdiction::Gdpr, - ..Default::default() - }, + ConsentContext::new(Jurisdiction::Gdpr).with_record(applying), ), ( "unknown-gpc", - ConsentContext { - gpc: true, - jurisdiction: Jurisdiction::Unknown, - ..Default::default() - }, + ConsentContext::new(Jurisdiction::Unknown).with_record(browser_setting.clone()), ), ( "nonregulated-gpc", - ConsentContext { - gpc: true, - jurisdiction: Jurisdiction::NonRegulated, - ..Default::default() - }, + ConsentContext::new(Jurisdiction::NonRegulated).with_record(browser_setting), ), ( "usp-gpp", - ConsentContext { - raw_us_privacy: Some("1YNN".to_string()), - raw_gpp_string: Some("gpp-string".to_string()), - gpp_section_ids: Some(vec![7, 8]), - jurisdiction: Jurisdiction::NonRegulated, - ..Default::default() - }, + ConsentContext::new(Jurisdiction::NonRegulated) + .with_record( + SignalRecord::new("us_privacy", SignalStatus::Decoded).with_value("1YNN"), + ) + .with_record( + SignalRecord::new("gpp", SignalStatus::Decoded) + .with_value("gpp-string") + .with_sections(vec![7, 8]), + ), ), ]; for (name, consent) in cases { @@ -222,8 +215,8 @@ fn consent_matrix_holds_under_each_implementation() { let regs = regs.expect("should emit actionable regs"); let expected_gdpr = match consent.jurisdiction { Jurisdiction::Gdpr => Some(true), - Jurisdiction::Unknown if !consent.gdpr_applies => None, - _ => Some(consent.gdpr_applies), + Jurisdiction::Unknown if !consent.applies() => None, + _ => Some(consent.applies()), }; assert_eq!( regs.get("gdpr"), diff --git a/crates/trusted-server-core/src/auction/telemetry.rs b/crates/trusted-server-core/src/auction/telemetry.rs index cebd063102..8fa1cb2ef8 100644 --- a/crates/trusted-server-core/src/auction/telemetry.rs +++ b/crates/trusted-server-core/src/auction/telemetry.rs @@ -115,7 +115,8 @@ pub struct AuctionObservationContext { pub is_known_browser: u8, /// Complete User-Agent supplied by the auction request, when present. pub user_agent: Option, - /// Whether GDPR applies. + /// Whether a signal module says the regulation its scheme answers for + /// applies. pub gdpr_applies: bool, /// Whether any consent signal was present. pub consent_present: bool, @@ -194,7 +195,7 @@ impl AuctionObservationContext { None => 2, }, user_agent: user_agent.map(str::to_owned), - gdpr_applies: consent.gdpr_applies, + gdpr_applies: consent.applies(), consent_present: !consent.is_empty(), slot_count, origin_cache_shareable: None, diff --git a/crates/trusted-server-core/src/auction/test_support.rs b/crates/trusted-server-core/src/auction/test_support.rs index ee6f77ea7b..edd5aed744 100644 --- a/crates/trusted-server-core/src/auction/test_support.rs +++ b/crates/trusted-server-core/src/auction/test_support.rs @@ -12,7 +12,7 @@ use super::AuctionContext; use crate::auction::types::{ AdFormat, AdSlot, AuctionRequest, DeviceInfo, MediaType, PublisherInfo, UserInfo, }; -use crate::consent::ConsentContext; +use crate::consent::{ConsentContext, SignalRecord, SignalStatus}; use crate::geo::GeoInfo; use crate::openrtb::{Eid, Uid}; use crate::platform::{RuntimeServices, test_support::noop_services}; @@ -87,15 +87,22 @@ pub fn canonical_parity_auction_request() -> AuctionRequest { }, user: UserInfo { id: Some("fictional-user".to_string()), - consent: Some(ConsentContext { - gdpr_applies: true, - raw_tc_string: Some("fictional-tcf".to_string()), - raw_us_privacy: Some("1YNN".to_string()), - raw_gpp_string: Some("fictional-gpp".to_string()), - gpp_section_ids: Some(vec![2, 6]), - raw_ac_string: Some("fictional-ac".to_string()), - ..Default::default() - }), + consent: Some( + ConsentContext::default() + .with_record( + SignalRecord::new("tcf", SignalStatus::Decoded) + .with_value("fictional-tcf") + .with_applies(true), + ) + .with_record( + SignalRecord::new("us_privacy", SignalStatus::Decoded).with_value("1YNN"), + ) + .with_record( + SignalRecord::new("gpp", SignalStatus::Decoded) + .with_value("fictional-gpp") + .with_sections(vec![2, 6]), + ), + ), eids: Some(vec![Eid { source: "identity.example".to_string(), uids: vec![Uid { @@ -596,7 +603,6 @@ pub(crate) mod demand_fixture { imp_tagid: true, site_ref: true, precise_geo: true, - additional_consent: true, unsigned_request_identity: true, consumes_bidder_params: true, regs: RegsPolicy::Jurisdiction, diff --git a/crates/trusted-server-core/src/consent/extraction.rs b/crates/trusted-server-core/src/consent/extraction.rs deleted file mode 100644 index 110ac622af..0000000000 --- a/crates/trusted-server-core/src/consent/extraction.rs +++ /dev/null @@ -1,194 +0,0 @@ -//! Consent signal extraction from cookies and headers. -//! -//! Reads raw consent strings from the [`CookieJar`] and HTTP headers without -//! performing any decoding or validation. This is the first step in the consent -//! pipeline described in the [Consent Forwarding Architecture Design]. - -use cookie::CookieJar; -use edgezero_core::body::Body as EdgeBody; -use http::Request; - -use crate::constants::{ - COOKIE_EUCONSENT_V2, COOKIE_GPP, COOKIE_GPP_SID, COOKIE_US_PRIVACY, HEADER_SEC_GPC, -}; - -use super::types::RawConsentSignals; - -/// Extracts raw consent signals from a [`CookieJar`] and a [`Request`]. -/// -/// Reads the following consent cookies (if present): -/// - `euconsent-v2` — IAB TCF v2 consent string -/// - `__gpp` — IAB Global Privacy Platform string -/// - `__gpp_sid` — GPP section IDs (comma-separated) -/// - `us_privacy` — IAB US Privacy / CCPA string -/// -/// Also reads the `Sec-GPC` header for Global Privacy Control. -/// -/// No decoding or validation is performed — values are captured as-is. -pub fn extract_consent_signals( - jar: Option<&CookieJar>, - req: &Request, -) -> RawConsentSignals { - let raw_tc_string = jar - .and_then(|j| j.get(COOKIE_EUCONSENT_V2)) - .map(|c| c.value().to_owned()); - - let raw_gpp_string = jar - .and_then(|j| j.get(COOKIE_GPP)) - .map(|c| c.value().to_owned()); - - let raw_gpp_sid = jar - .and_then(|j| j.get(COOKIE_GPP_SID)) - .map(|c| c.value().to_owned()); - - let raw_us_privacy = jar - .and_then(|j| j.get(COOKIE_US_PRIVACY)) - .map(|c| c.value().to_owned()); - - let gpc = req - .headers() - .get(HEADER_SEC_GPC) - .and_then(|v| v.to_str().ok()) - .is_some_and(|v| v.trim() == "1"); - - RawConsentSignals { - raw_tc_string, - raw_gpp_string, - raw_gpp_sid, - raw_us_privacy, - gpc, - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::cookies::parse_cookies_to_jar; - - fn build_request(gpc: Option<&str>) -> Request { - let mut builder = Request::builder().method("GET").uri("https://example.com"); - if let Some(value) = gpc { - builder = builder.header("sec-gpc", value); - } - builder - .body(EdgeBody::empty()) - .expect("should build request") - } - - #[test] - fn no_cookies_no_headers() { - let req = build_request(None); - let signals = extract_consent_signals(None, &req); - assert!(signals.is_empty(), "should produce empty signals"); - } - - #[test] - fn extracts_euconsent_v2() { - let jar = parse_cookies_to_jar("euconsent-v2=CPXxGfAPXxGfAAHABBENBCCsAP_AAH_AAAAAHftf"); - let req = build_request(None); - let signals = extract_consent_signals(Some(&jar), &req); - - assert_eq!( - signals.raw_tc_string.as_deref(), - Some("CPXxGfAPXxGfAAHABBENBCCsAP_AAH_AAAAAHftf"), - "should extract euconsent-v2 cookie value" - ); - } - - #[test] - fn extracts_gpp_cookies() { - let jar = parse_cookies_to_jar("__gpp=DBACNYA~CPXxGfA; __gpp_sid=2,6"); - let req = build_request(None); - let signals = extract_consent_signals(Some(&jar), &req); - - assert_eq!( - signals.raw_gpp_string.as_deref(), - Some("DBACNYA~CPXxGfA"), - "should extract __gpp cookie value" - ); - assert_eq!( - signals.raw_gpp_sid.as_deref(), - Some("2,6"), - "should extract __gpp_sid cookie value" - ); - } - - #[test] - fn extracts_us_privacy() { - let jar = parse_cookies_to_jar("us_privacy=1YNN"); - let req = build_request(None); - let signals = extract_consent_signals(Some(&jar), &req); - - assert_eq!( - signals.raw_us_privacy.as_deref(), - Some("1YNN"), - "should extract us_privacy cookie value" - ); - } - - #[test] - fn extracts_sec_gpc_header() { - let req = build_request(Some("1")); - let signals = extract_consent_signals(None, &req); - - assert!(signals.gpc, "should detect Sec-GPC: 1 header"); - } - - #[test] - fn sec_gpc_absent_when_not_set() { - let req = build_request(None); - let signals = extract_consent_signals(None, &req); - - assert!(!signals.gpc, "should default gpc to false"); - } - - #[test] - fn sec_gpc_absent_when_not_one() { - let req = build_request(Some("0")); - let signals = extract_consent_signals(None, &req); - - assert!(!signals.gpc, "should not treat Sec-GPC: 0 as opt-out"); - } - - #[test] - fn extracts_all_signals() { - let jar = - parse_cookies_to_jar("euconsent-v2=CPXxGf; __gpp=DBAC; __gpp_sid=2,6; us_privacy=1YNN"); - let req = build_request(Some("1")); - let signals = extract_consent_signals(Some(&jar), &req); - - assert!(signals.raw_tc_string.is_some(), "should have tc_string"); - assert!(signals.raw_gpp_string.is_some(), "should have gpp_string"); - assert!(signals.raw_gpp_sid.is_some(), "should have gpp_sid"); - assert!(signals.raw_us_privacy.is_some(), "should have us_privacy"); - assert!(signals.gpc, "should have gpc"); - } - - #[test] - fn empty_jar_produces_no_cookie_signals() { - let jar = parse_cookies_to_jar(""); - let req = build_request(None); - let signals = extract_consent_signals(Some(&jar), &req); - - assert!( - signals.raw_tc_string.is_none(), - "should have no tc_string from empty jar" - ); - assert!( - signals.raw_gpp_string.is_none(), - "should have no gpp_string from empty jar" - ); - } - - #[test] - fn unrelated_cookies_ignored() { - let jar = parse_cookies_to_jar("session_id=abc123; theme=dark"); - let req = build_request(None); - let signals = extract_consent_signals(Some(&jar), &req); - - assert!( - signals.is_empty(), - "should produce empty signals for unrelated cookies" - ); - } -} diff --git a/crates/trusted-server-core/src/consent/gpp.rs b/crates/trusted-server-core/src/consent/gpp.rs deleted file mode 100644 index 59e39613ab..0000000000 --- a/crates/trusted-server-core/src/consent/gpp.rs +++ /dev/null @@ -1,464 +0,0 @@ -//! GPP (Global Privacy Platform) string decoder. -//! -//! Thin wrapper around the [`iab_gpp`] crate that maps decoded GPP data into -//! our [`GppConsent`] domain type. The `iab_gpp` crate handles the heavy -//! lifting of GPP v1 string parsing and section decoding; this module: -//! -//! 1. Parses the raw `__gpp` cookie value via [`iab_gpp::v1::GPPString`]. -//! 2. Extracts the header-level section IDs. -//! 3. If the EU TCF v2.2 section is present, decodes it via our own -//! [`super::tcf::decode_tc_string`] (for consistency with standalone -//! `euconsent-v2` decoding). -//! -//! # Why wrap `iab_gpp`? -//! -//! - Isolates external dependency behind our own types. -//! - Allows fallback/replacement without touching callers. -//! - Maps `iab_gpp` errors into our [`ConsentDecodeError`] hierarchy. -//! -//! # References -//! -//! - [IAB GPP specification](https://github.com/InteractiveAdvertisingBureau/Global-Privacy-Platform) -//! - [`iab_gpp` crate docs](https://docs.rs/iab_gpp) - -use error_stack::Report; - -use super::types::{ConsentDecodeError, GppConsent, TcfConsent}; - -/// Maximum length of a raw GPP string before parsing. -/// -/// GPP strings are typically larger than standalone TC strings because they -/// encode multiple sections. This limit prevents malicious cookies from -/// triggering large allocations in the `iab_gpp` parser. -const MAX_GPP_STRING_LEN: usize = 8192; - -/// Decodes a GPP string into a [`GppConsent`] struct. -/// -/// Parses the raw `__gpp` cookie value, extracts section IDs, and optionally -/// decodes the EU TCF v2.2 section if present. -/// -/// # Arguments -/// -/// * `gpp_string` — the raw GPP string from the `__gpp` cookie. -/// -/// # Errors -/// -/// - [`ConsentDecodeError::InvalidGppString`] if the string exceeds -/// `MAX_GPP_STRING_LEN` or the `iab_gpp` parser fails. -pub fn decode_gpp_string(gpp_string: &str) -> Result> { - if gpp_string.len() > MAX_GPP_STRING_LEN { - return Err(Report::new(ConsentDecodeError::InvalidGppString { - reason: format!( - "GPP string too long: {} bytes, max {MAX_GPP_STRING_LEN}", - gpp_string.len() - ), - })); - } - - let parsed = iab_gpp::v1::GPPString::parse_str(gpp_string).map_err(|e| { - Report::new(ConsentDecodeError::InvalidGppString { - reason: format!("{e}"), - }) - })?; - - // Extract section IDs as u16 values. - // Safety: `SectionId` is a C-like enum with discriminants 1–23 (GPP spec v1). - // The `iab_gpp` crate stores them internally as `BTreeSet`, so every - // variant is guaranteed to fit in u16. - let section_ids: Vec = parsed.section_ids().map(|id| *id as u16).collect(); - - // Attempt to extract and decode the EU TCF v2.2 section. - // Section ID 2 = TcfEuV2 in the GPP spec. - let eu_tcf = decode_tcf_from_gpp(&parsed); - - let us_sale_opt_out = decode_us_sale_opt_out(&parsed); - - Ok(GppConsent { - // The GPP header version is always 1 for current spec. - version: 1, - section_ids, - eu_tcf, - us_sale_opt_out, - }) -} - -/// Attempts to decode the EU TCF v2.2 section from a parsed GPP string. -/// -/// Uses our own TCF decoder on the raw section string (rather than -/// `iab_gpp`'s TCF decoder) to ensure consistency with standalone -/// `euconsent-v2` decoding. -/// -/// Returns `None` if the TCF section is not present or cannot be decoded. -fn decode_tcf_from_gpp(parsed: &iab_gpp::v1::GPPString) -> Option { - // iab_gpp::sections::SectionId::TcfEuV2 corresponds to section ID 2. - let tcf_section_str = parsed.section(iab_gpp::sections::SectionId::TcfEuV2)?; - - // Delegate to our own TCF decoder for consistency. - match super::tcf::decode_tc_string(tcf_section_str) { - Ok(tcf) => Some(tcf), - Err(e) => { - log::warn!("GPP contains TCF EU v2 section but decoding failed: {e}"); - None - } - } -} - -/// GPP section IDs that represent US state/national privacy sections. -/// -/// Range 7–23 per the GPP v1 specification: -/// 7=UsNat, 8=UsCa, 9=UsVa, 10=UsCo, 11=UsUt, 12=UsCt, 13=UsFl, -/// 14=UsMt, 15=UsOr, 16=UsTx, 17=UsDe, 18=UsIa, 19=UsNe, 20=UsNh, -/// 21=UsNj, 22=UsTn, 23=UsMn. -const US_SECTION_ID_RANGE: std::ops::RangeInclusive = 7..=23; - -/// Extracts the `sale_opt_out` signal across all US sections in a parsed GPP -/// string. -/// -/// Iterates through section IDs looking for any in the US range (7–23), -/// decodes each US section, and aggregates the result conservatively: -/// -/// - `Some(true)` if any decodable US section says the user opted out of sale -/// - `Some(false)` if at least one decodable US section says they did not opt -/// out and none say they opted out -/// - `None` if no US section is present or no decodable US section yields a -/// usable `sale_opt_out` signal -fn decode_us_sale_opt_out(parsed: &iab_gpp::v1::GPPString) -> Option { - let mut result = None; - - for us_section_id in parsed - .section_ids() - .filter(|id| US_SECTION_ID_RANGE.contains(&(**id as u16))) - { - match parsed.decode_section(*us_section_id) { - Ok(section) => match us_sale_opt_out_from_section(§ion) { - Some(true) => return Some(true), - Some(false) => result = Some(false), - None => {} - }, - Err(e) => { - log::warn!("Failed to decode US GPP section {us_section_id}: {e}"); - } - } - } - - result -} - -fn us_sale_opt_out_from_section(section: &iab_gpp::sections::Section) -> Option { - use iab_gpp::sections::Section; - use iab_gpp::sections::us_common::OptOut; - - // Keep this match in sync with new US-state variants added by `iab_gpp`. - let sale_opt_out = match section { - Section::UsNat(s) => match &s.core { - iab_gpp::sections::usnat::Core::V1(c) => &c.sale_opt_out, - iab_gpp::sections::usnat::Core::V2(c) => &c.sale_opt_out, - _ => return None, - }, - Section::UsCa(s) => &s.core.sale_opt_out, - Section::UsVa(s) => &s.core.sale_opt_out, - Section::UsCo(s) => &s.core.sale_opt_out, - Section::UsUt(s) => &s.core.sale_opt_out, - Section::UsCt(s) => &s.core.sale_opt_out, - Section::UsFl(s) => &s.core.sale_opt_out, - Section::UsMt(s) => &s.core.sale_opt_out, - Section::UsOr(s) => &s.core.sale_opt_out, - Section::UsTx(s) => &s.core.sale_opt_out, - Section::UsDe(s) => &s.core.sale_opt_out, - Section::UsIa(s) => &s.core.sale_opt_out, - Section::UsNe(s) => &s.core.sale_opt_out, - Section::UsNh(s) => &s.core.sale_opt_out, - Section::UsNj(s) => &s.core.sale_opt_out, - Section::UsTn(s) => &s.core.sale_opt_out, - Section::UsMn(s) => &s.core.sale_opt_out, - _ => return None, - }; - - Some(*sale_opt_out == OptOut::OptedOut) -} - -/// Parses a `__gpp_sid` cookie value into a vector of section IDs. -/// -/// The cookie is a comma-separated list of integer section IDs, e.g. `"2,6"`. -/// Invalid entries are silently skipped (logged at debug level) since the -/// cookie is treated as a transport hint. -/// -/// Returns `None` if the input is empty or contains no valid IDs. -#[must_use] -pub fn parse_gpp_sid_cookie(raw: &str) -> Option> { - let trimmed = raw.trim(); - if trimmed.is_empty() { - return None; - } - - let ids: Vec = trimmed - .split(',') - .filter_map(|s| { - let s = s.trim(); - if let Ok(id) = s.parse::() { - Some(id) - } else { - log::debug!("Ignoring invalid __gpp_sid entry: {s:?}"); - None - } - }) - .collect(); - - if ids.is_empty() { None } else { Some(ids) } -} - -#[cfg(test)] -mod tests { - use super::*; - - // A known-good GPP string with US Privacy section (section ID 6). - // Header "DBABTA" encodes: version=1, section IDs=[6] (UspV1). - // Section string: "1YNN" (US Privacy). - const GPP_USP_ONLY: &str = "DBABTA~1YNN"; - - // A GPP string with both TCF EU v2 and US Privacy sections. - // Header "DBACNY" encodes: version=1, section IDs=[2, 6]. - // First section: TCF EU v2 consent string. - // Second section: US Privacy string. - const GPP_TCF_AND_USP: &str = "DBACNY~CPXxRfAPXxRfAAfKABENB-CgAAAAAAAAAAYgAAAAAAAA~1YNN"; - - #[test] - fn decodes_usp_only_gpp_string() { - let result = decode_gpp_string(GPP_USP_ONLY).expect("should decode USP-only GPP"); - assert_eq!(result.version, 1); - assert!(!result.section_ids.is_empty(), "should have section IDs"); - assert!( - result.eu_tcf.is_none(), - "should not have TCF section in USP-only string" - ); - } - - #[test] - fn decodes_gpp_with_tcf_section() { - let result = decode_gpp_string(GPP_TCF_AND_USP).expect("should decode GPP with TCF"); - assert_eq!(result.version, 1); - // Section IDs should include 2 (TCF EU v2) and 6 (USP v1). - assert!( - result.section_ids.contains(&2), - "should contain TCF EU v2 section ID (2)" - ); - // TCF section should be decoded (may or may not succeed depending - // on whether the section string is a valid base64-encoded TC String). - // The GPP TCF section format differs from standalone euconsent-v2, - // so eu_tcf might be None if our decoder can't parse the GPP-encoded - // TCF format. That's acceptable — we log and continue. - } - - #[test] - fn rejects_invalid_gpp_string() { - let result = decode_gpp_string("totally-invalid"); - assert!(result.is_err(), "should reject invalid GPP string"); - } - - #[test] - fn rejects_empty_string() { - let result = decode_gpp_string(""); - assert!(result.is_err(), "should reject empty GPP string"); - } - - #[test] - fn rejects_oversized_gpp_string() { - let oversized = "D".repeat(MAX_GPP_STRING_LEN + 1); - let result = decode_gpp_string(&oversized); - assert!( - result.is_err(), - "should reject GPP string exceeding max length" - ); - } - - #[test] - fn parse_gpp_sid_simple() { - let ids = parse_gpp_sid_cookie("2,6").expect("should parse 2,6"); - assert_eq!(ids, vec![2, 6]); - } - - #[test] - fn parse_gpp_sid_single() { - let ids = parse_gpp_sid_cookie("2").expect("should parse single ID"); - assert_eq!(ids, vec![2]); - } - - #[test] - fn parse_gpp_sid_with_whitespace() { - let ids = parse_gpp_sid_cookie(" 2 , 6 , 8 ").expect("should handle whitespace"); - assert_eq!(ids, vec![2, 6, 8]); - } - - #[test] - fn parse_gpp_sid_empty_returns_none() { - assert!(parse_gpp_sid_cookie("").is_none(), "empty should be None"); - assert!( - parse_gpp_sid_cookie(" ").is_none(), - "whitespace should be None" - ); - } - - #[test] - fn parse_gpp_sid_skips_invalid_entries() { - let ids = parse_gpp_sid_cookie("2,abc,6").expect("should skip invalid"); - assert_eq!(ids, vec![2, 6]); - } - - #[test] - fn parse_gpp_sid_all_invalid_returns_none() { - assert!( - parse_gpp_sid_cookie("abc,def").is_none(), - "all-invalid should be None" - ); - } - - #[test] - fn decodes_us_sale_opt_out_not_opted_out() { - let result = decode_gpp_string("DBABLA~BVQqAAAAAgA.QA"); - match &result { - Ok(gpp) => { - assert_eq!( - gpp.us_sale_opt_out, - Some(false), - "should extract sale_opt_out=false from UsNat section" - ); - } - Err(e) => { - panic!("GPP decode failed: {e}"); - } - } - } - - fn encode_fibonacci_integer(mut value: u16) -> String { - let mut fibs = vec![1_u16]; - let mut next = 2_u16; - while next <= value { - fibs.push(next); - next = if fibs.len() == 1 { - 2 - } else { - fibs[fibs.len() - 1] + fibs[fibs.len() - 2] - }; - } - - let mut bits = vec![false; fibs.len()]; - for (idx, fib) in fibs.iter().enumerate().rev() { - if *fib <= value { - value -= *fib; - bits[idx] = true; - } - } - bits.push(true); - - bits.into_iter() - .map(|bit| if bit { '1' } else { '0' }) - .collect() - } - - fn encode_header(section_ids: &[u16]) -> String { - const BASE64_URL: &[u8; 64] = - b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; - - let mut bits = String::from("000011000001"); - bits.push_str(&format!("{:012b}", section_ids.len())); - - let mut previous = 0_u16; - for §ion_id in section_ids { - bits.push('0'); - bits.push_str(&encode_fibonacci_integer(section_id - previous)); - previous = section_id; - } - - while bits.len() % 6 != 0 { - bits.push('0'); - } - - bits.as_bytes() - .chunks(6) - .map(|chunk| { - let value = u8::from_str_radix( - core::str::from_utf8(chunk).expect("should encode header bits as utf8"), - 2, - ) - .expect("should parse 6-bit chunk"); - char::from(BASE64_URL[value as usize]) - }) - .collect() - } - - fn gpp_with_sections(sections: &[(u16, &str)]) -> String { - let ids = sections.iter().map(|(id, _)| *id).collect::>(); - let header = encode_header(&ids); - let section_payloads = sections.iter().map(|(_, raw)| *raw).collect::>(); - format!("{header}~{}", section_payloads.join("~")) - } - - #[test] - fn no_us_section_returns_none() { - let result = decode_gpp_string(GPP_TCF_AND_USP).expect("should decode GPP"); - assert_eq!( - result.us_sale_opt_out, None, - "should return None when no US section (7-23) is present" - ); - } - - #[test] - fn later_us_section_opt_out_overrides_earlier_non_opt_out() { - let gpp = gpp_with_sections(&[(7, "BVQqAAAAAgA.QA"), (9, "BVVVVVVVVWA.AA")]); - - let result = decode_gpp_string(&gpp).expect("should decode multi-section US GPP"); - - assert_eq!( - result.us_sale_opt_out, - Some(true), - "should treat any later decodable opt-out as authoritative" - ); - } - - #[test] - fn multiple_us_sections_without_opt_out_return_false() { - let gpp = gpp_with_sections(&[(7, "BVQqAAAAAgA.QA"), (9, "BVgVVVVVVWA.AA")]); - - let result = decode_gpp_string(&gpp).expect("should decode multi-section US GPP"); - - assert_eq!( - result.us_sale_opt_out, - Some(false), - "should return false when decodable US sections consistently do not opt out" - ); - } - - #[test] - fn valid_opt_out_wins_even_if_another_us_section_is_undecodable() { - let gpp = gpp_with_sections(&[(7, "BVQqAAAAAgA.QA"), (9, "not-a-valid-usva-section")]); - - let result = decode_gpp_string(&gpp).expect("should decode GPP header with raw sections"); - - assert_eq!( - result.us_sale_opt_out, - Some(false), - "should keep a valid non-opt-out signal even when another US section fails to decode" - ); - - let gpp = gpp_with_sections(&[(7, "not-a-valid-usnat-section"), (9, "BVVVVVVVVWA.AA")]); - let result = decode_gpp_string(&gpp).expect("should decode GPP header with raw sections"); - - assert_eq!( - result.us_sale_opt_out, - Some(true), - "should let a valid opt-out win even when another US section fails to decode" - ); - } - - #[test] - fn only_undecodable_us_sections_return_none() { - let gpp = gpp_with_sections(&[(7, "not-a-valid-usnat-section"), (9, "also-invalid")]); - - let result = decode_gpp_string(&gpp).expect("should decode GPP header with raw sections"); - - assert_eq!( - result.us_sale_opt_out, None, - "should return None when no decodable US section yields sale_opt_out" - ); - } -} diff --git a/crates/trusted-server-core/src/consent/jurisdiction.rs b/crates/trusted-server-core/src/consent/jurisdiction.rs index 0c3258f1a2..7f4df97521 100644 --- a/crates/trusted-server-core/src/consent/jurisdiction.rs +++ b/crates/trusted-server-core/src/consent/jurisdiction.rs @@ -3,11 +3,10 @@ //! The regime comes from the request's location, resolved through the same //! place tree in `permissions.yaml` that decides the permission baseline, so //! one file states the policy for both. The detected jurisdiction never causes -//! consent to be synthesized (see proposal Key Decision #3), but it is not -//! only observability either: the server-side auction gate -//! (`consent_allows_server_side_auction`) fails closed on a GDPR or unknown -//! jurisdiction, and a US state jurisdiction is what lets a GPC header be -//! turned into a US Privacy string. +//! consent to be synthesized by core, and it is not only observability +//! either, because the permission signal modules read it, and a module for a +//! US state scheme reads a US state jurisdiction before it acts on a browser +//! setting. use core::fmt; diff --git a/crates/trusted-server-core/src/consent/mod.rs b/crates/trusted-server-core/src/consent/mod.rs index 939cbde5d3..6068fba761 100644 --- a/crates/trusted-server-core/src/consent/mod.rs +++ b/crates/trusted-server-core/src/consent/mod.rs @@ -1,481 +1,104 @@ -//! Consent signal extraction, decoding, and normalization. +//! The request's consent, read by the permission signal modules. //! -//! This module implements the consent forwarding pipeline: -//! -//! 1. **Extract** raw consent strings from cookies and HTTP headers. -//! 2. **Decode** each signal into structured data (TCF v2, GPP, US Privacy). -//! 3. **Build** a request-local [`ConsentContext`] that flows through the -//! auction pipeline and populates `OpenRTB` bid requests. -//! -//! Consent is interpreted from request cookies, headers, geolocation, and -//! publisher policy defaults. EC identity lifecycle and withdrawal state are -//! managed separately by the EC identity graph. -//! -//! # Supported signals -//! -//! - **TCF v2** — `euconsent-v2` cookie (IAB Transparency & Consent Framework) -//! - **GPP** — `__gpp` and `__gpp_sid` cookies (IAB Global Privacy Platform) -//! - **US Privacy** — `us_privacy` cookie (IAB US Privacy / CCPA) -//! - **GPC** — `Sec-GPC` header (Global Privacy Control) -//! -//! # Usage -//! -//! ```ignore -//! let consent = consent::build_consent_context(&consent::ConsentPipelineInput { -//! jar: cookie_jar.as_ref(), -//! req: &req, -//! config: &settings.consent, -//! geo: geo.as_ref(), -//! }); -//! ``` +//! Core decodes no scheme. Each configured module reads its own signal from +//! the request, once, through [`PermissionSignalModule::read`], and reports a +//! [`SignalRecord`]. The records, with the request's jurisdiction, are the +//! [`ConsentContext`] the permission assembly, the bid request builders and the +//! identity graph read. What a record means for a permission is the module's +//! to say when the permissions are assembled. -mod extraction; -pub mod gpp; pub mod jurisdiction; -pub mod tcf; pub mod types; -pub mod us_privacy; -pub use extraction::extract_consent_signals; -pub use types::{ - ConsentContext, ConsentSource, PrivacyFlag, RawConsentSignals, TcfConsent, UsPrivacy, -}; +pub use types::{ConsentContext, ConsentSource, SignalRecord, SignalStatus}; +use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; -use web_time::{SystemTime, UNIX_EPOCH}; - -use cookie::CookieJar; -use edgezero_core::body::Body as EdgeBody; -use http::Request; - -use crate::consent_config::{ConflictMode, ConsentConfig, ConsentMode}; use crate::geo::GeoInfo; +use crate::module_context::ModuleContext; +use crate::permission_signal::PermissionSignalModule; use crate::permissions::{Permission, PermissionState}; -/// Number of deciseconds in one day (86 400 seconds × 10). -const DECISECONDS_PER_DAY: u64 = 86_400 * 10; - -/// GPP section ID for EU TCF v2. -const GPP_SECTION_ID_TCF_EU_V2: u16 = 2; - static MISSING_GEO_WARNING_LOGGED: AtomicBool = AtomicBool::new(false); -/// Inputs to the consent processing pipeline. -/// -/// Bundles all data needed to extract, decode, classify, and validate -/// consent signals from a single request. -pub struct ConsentPipelineInput<'a> { - /// Parsed cookie jar from the incoming request. - pub jar: Option<&'a CookieJar>, - /// The incoming HTTP request (for header access). - pub req: &'a Request, - /// Publisher consent configuration. - pub config: &'a ConsentConfig, - /// Geolocation data from the request (for jurisdiction detection). - pub geo: Option<&'a GeoInfo>, - /// The jurisdiction to apply when `geo` resolved no location. - /// - /// Jurisdiction is detected from geolocation, so with no location every - /// request would be [`Jurisdiction::Unknown`] and the consent gates would - /// fail closed even where the permission policy declares what to do. This - /// carries that declaration (see - /// [`PermissionMaps::default_jurisdiction`](crate::permissions::PermissionMaps::default_jurisdiction)). - /// Pass [`Jurisdiction::Unknown`] where no declaration applies, for example - /// after a failed geo lookup. - pub default_jurisdiction: jurisdiction::Jurisdiction, -} - -/// Extracts, decodes, and normalizes consent signals from a request. -/// -/// This is the primary entry point for the consent pipeline. It: -/// -/// 1. Reads raw consent strings from cookies and headers. -/// 2. Decodes each signal (TCF v2, GPP, US Privacy). -/// 3. Detects the privacy jurisdiction from geolocation. -/// 4. Checks consent expiration (if enabled). -/// 5. Constructs a US Privacy string from GPC when appropriate. -/// 6. Builds a [`ConsentContext`] with both raw and decoded data. -/// 7. Logs a summary for observability. -/// -/// The returned context reflects request-local consent signals plus policy -/// defaults only. +/// Reads the request's consent, asking each of `modules` in order for the +/// signal of its scheme. /// -/// Decoding failures are logged and the corresponding decoded field is set to -/// `None` — the raw string is still preserved for proxy-mode forwarding. -pub fn build_consent_context(input: &ConsentPipelineInput<'_>) -> ConsentContext { - let signals = extract_consent_signals(input.jar, input.req); - log_consent_signals(&signals); - - if input.geo.is_none() { - log_missing_geo_warning_once(); - } - - // In proxy mode, skip decoding entirely. - if input.config.mode == ConsentMode::Proxy { - let jur = request_jurisdiction(input); - let gpp_section_ids = signals - .raw_gpp_sid - .as_deref() - .and_then(gpp::parse_gpp_sid_cookie); - let gdpr_applies = - has_eu_tcf_signal(signals.raw_tc_string.is_some(), gpp_section_ids.as_deref()); - log::debug!("Consent proxy mode: jurisdiction={jur}, skipping decode"); - return ConsentContext { - raw_tc_string: signals.raw_tc_string, - raw_gpp_string: signals.raw_gpp_string, - gpp_section_ids, - raw_us_privacy: signals.raw_us_privacy, - raw_ac_string: None, - gdpr_applies, - tcf: None, - gpp: None, - us_privacy: None, - expired: false, - gpc: signals.gpc, - jurisdiction: jur, - source: ConsentSource::Cookie, - }; +/// `context` carries the request's evidence and the deployment's settings, +/// which is what a module reads. It is offered to the modules carrying a +/// consent context with no records under `jurisdiction`, so a module whose +/// reading depends on the jurisdiction can see it. The records every module +/// reported, in module order, make the returned context. +#[must_use] +pub fn read_consent( + context: &ModuleContext<'_>, + modules: &[Arc], + jurisdiction: jurisdiction::Jurisdiction, +) -> ConsentContext { + let empty = ConsentContext::new(jurisdiction.clone()); + let context = (*context).with_consent(&empty); + let mut consent = ConsentContext::new(jurisdiction); + for module in modules { + let call = context.call(module.id(), module.required_permissions()); + if let Some(record) = module.read(call) { + consent = consent.with_record(record); + } } - - let mut ctx = build_context_from_signals(&signals); - ctx.jurisdiction = request_jurisdiction(input); - apply_tcf_conflict_resolution(&mut ctx, input.config); - apply_expiration_check(&mut ctx, input.config); - apply_gpc_us_privacy(&mut ctx, input.config); - - log_consent_context(&ctx); - ctx + log_consent_context(&consent); + consent } /// The jurisdiction for a request: detected from its location when one /// resolved, otherwise the declaration the caller supplied. /// -/// Keeping the two in one place means every path through the pipeline (proxy -/// mode, the KV read fallback, and the ordinary decode) answers the question -/// the same way. -fn request_jurisdiction(input: &ConsentPipelineInput<'_>) -> jurisdiction::Jurisdiction { - match input.geo { - Some(_) => jurisdiction::detect_jurisdiction(input.geo), - None => input.default_jurisdiction.clone(), - } -} - -/// Marks TCF consent as expired when it exceeds the configured maximum age. -/// -/// Clears whichever decoded TCF source is active (`tcf` or `gpp.eu_tcf`) but -/// preserves raw strings for proxy-mode forwarding. -fn apply_expiration_check(ctx: &mut ConsentContext, config: &ConsentConfig) { - if !config.check_expiration { - return; - } - - let (is_expired, age_days) = { - let Some(tcf) = effective_tcf(ctx) else { - return; - }; - ( - is_consent_expired(tcf, config.max_consent_age_days), - consent_age_days(tcf), - ) - }; - - if !is_expired { - return; - } - - log::warn!("TCF consent expired: age_days={age_days}"); - ctx.expired = true; - - ctx.tcf = None; - if let Some(gpp) = &mut ctx.gpp { - gpp.eu_tcf = None; - } - - ctx.gdpr_applies = - has_eu_tcf_signal(ctx.raw_tc_string.is_some(), ctx.gpp_section_ids.as_deref()); -} - -/// Constructs a US Privacy string from GPC when no explicit cookie exists -/// and the user is in a US state with a privacy law. -fn apply_gpc_us_privacy(ctx: &mut ConsentContext, config: &ConsentConfig) { - if !ctx.gpc || ctx.us_privacy.is_some() { - return; - } - if !matches!(&ctx.jurisdiction, jurisdiction::Jurisdiction::UsState(_)) { - return; - } - - if let Some(usp) = build_us_privacy_from_gpc(config) { - log::info!("Constructed US Privacy string from GPC: {usp}"); - ctx.raw_us_privacy = Some(usp.to_string()); - ctx.us_privacy = Some(usp); - ctx.source = ConsentSource::PolicyDefault; - } -} - -/// Decodes a raw consent string, logging a warning on failure. -/// -/// Returns [`None`] and logs at `warn` level if decoding fails, preserving -/// the raw string for proxy-mode forwarding. -fn decode_or_warn( - raw: Option<&str>, - label: &str, - decode: fn(&str) -> Result, -) -> Option { - let s = raw?; - match decode(s) { - Ok(value) => Some(value), - Err(e) => { - log::warn!("Failed to decode {label}: {e}"); - None - } - } -} - -/// Builds a [`ConsentContext`] from previously extracted raw signals. -/// -/// This is the decode + normalize stage of the pipeline. Each signal is -/// decoded independently; failures are logged at `warn` level and the -/// corresponding decoded field is left as `None`. +/// Jurisdiction is detected from geolocation, so with no location every +/// request would be [`Jurisdiction::Unknown`](jurisdiction::Jurisdiction::Unknown) +/// and the consent gates would fail closed even where the permission policy +/// declares what to do. `default` carries that declaration (see +/// [`PermissionMaps::default_jurisdiction`](crate::permissions::PermissionMaps::default_jurisdiction)), +/// and is `Unknown` where no declaration applies, for example after a failed +/// geo lookup. #[must_use] -pub fn build_context_from_signals(signals: &RawConsentSignals) -> ConsentContext { - let decoded_us_privacy = decode_or_warn( - signals.raw_us_privacy.as_deref(), - "US Privacy string", - us_privacy::decode_us_privacy, - ); - let decoded_tcf = decode_or_warn( - signals.raw_tc_string.as_deref(), - "TC String", - tcf::decode_tc_string, - ); - let decoded_gpp = decode_or_warn( - signals.raw_gpp_string.as_deref(), - "GPP string", - gpp::decode_gpp_string, - ); - - // Resolve GPP section IDs: - // - Prefer decoded GPP section IDs (authoritative). - // - Fall back to __gpp_sid cookie (transport hint). - let gpp_section_ids = decoded_gpp - .as_ref() - .map(|g| g.section_ids.clone()) - .or_else(|| { - signals - .raw_gpp_sid - .as_deref() - .and_then(gpp::parse_gpp_sid_cookie) - }); - - // GDPR applies when an EU TCF signal is present via standalone TC string - // or via GPP section ID 2. - let gdpr_applies = - has_eu_tcf_signal(signals.raw_tc_string.is_some(), gpp_section_ids.as_deref()); - - ConsentContext { - raw_tc_string: signals.raw_tc_string.clone(), - raw_gpp_string: signals.raw_gpp_string.clone(), - gpp_section_ids, - raw_us_privacy: signals.raw_us_privacy.clone(), - // AC string extraction not yet implemented — will be added when - // the CMP-specific cookie source is determined (Phase 1a). - raw_ac_string: None, - - gdpr_applies, - tcf: decoded_tcf, - gpp: decoded_gpp, - us_privacy: decoded_us_privacy, - - expired: false, - - gpc: signals.gpc, - jurisdiction: jurisdiction::Jurisdiction::default(), - source: ConsentSource::Cookie, - } -} - -/// Resolves whether an EU TCF signal is present from raw signal hints. -#[must_use] -fn has_eu_tcf_signal(raw_tc_present: bool, gpp_section_ids: Option<&[u16]>) -> bool { - raw_tc_present || gpp_section_ids.is_some_and(|ids| ids.contains(&GPP_SECTION_ID_TCF_EU_V2)) -} - -/// Returns the effective decoded TCF consent for enforcement decisions. -/// -/// A standalone TC string wins, and the EU TCF section of a GPP string stands -/// in when there is none. Public because the TCF permission signal module -/// lives outside core and reads the record this pipeline produced, rather than -/// decoding the cookie a second time and disagreeing with every other reader -/// of the same request about expiry and the cached record. -#[must_use] -pub fn effective_tcf(ctx: &ConsentContext) -> Option<&types::TcfConsent> { - ctx.tcf.as_ref().or_else(|| { - let g = ctx.gpp.as_ref()?; - g.eu_tcf.as_ref() - }) -} - -/// Returns whether a server-side auction may be dispatched for this request. -/// -/// Fails closed for GDPR-relevant traffic: when an EU TCF signal is present -/// (`gdpr_applies`) **or** the request's geo jurisdiction is GDPR or unknown, -/// the effective TCF consent (standalone TC string or GPP EU TCF section) -/// must grant Purpose 1 (storage/access). Only requests from a known -/// non-GDPR jurisdiction with no EU TCF signal are freely allowed. -#[must_use] -pub fn consent_allows_server_side_auction(ctx: &ConsentContext) -> bool { - let requires_tcf_purpose1 = ctx.gdpr_applies - || matches!( - ctx.jurisdiction, - jurisdiction::Jurisdiction::Gdpr | jurisdiction::Jurisdiction::Unknown - ); - if !requires_tcf_purpose1 { - return true; - } - effective_tcf(ctx).is_some_and(|tcf| tcf.has_purpose_consent(1)) -} - -/// Returns whether TCF consent allows EID transmission. -#[must_use] -fn allows_eid_transmission(tcf: &types::TcfConsent) -> bool { - tcf.has_storage_consent() && tcf.has_personalized_ads_consent() -} - -/// Resolves conflicts between standalone TC and GPP EU TCF consents. -fn apply_tcf_conflict_resolution(ctx: &mut ConsentContext, config: &ConsentConfig) { - let Some(standalone_tcf) = ctx.tcf.as_ref() else { - return; - }; - let Some(gpp_tcf) = ctx.gpp.as_ref().and_then(|g| g.eu_tcf.as_ref()) else { - return; - }; - - let standalone_allows = allows_eid_transmission(standalone_tcf); - let gpp_allows = allows_eid_transmission(gpp_tcf); - - if standalone_allows == gpp_allows { - return; - } - - let select_gpp = match config.conflict_resolution.mode { - ConflictMode::Restrictive => !gpp_allows, - ConflictMode::Permissive => gpp_allows, - ConflictMode::Newest => select_newest_signal( - standalone_tcf, - gpp_tcf, - config.conflict_resolution.freshness_threshold_days, - ) - // When both signals are within the freshness threshold, fall back - // to the restrictive choice (prefer whichever denies consent). - .unwrap_or(!gpp_allows), - }; - - let source = if select_gpp { "gpp" } else { "standalone" }; - log::info!( - "TCF conflict detected; mode={:?}, selected={source}", - config.conflict_resolution.mode - ); - - // Overwrite only when GPP wins; standalone is already in ctx.tcf. - if select_gpp { - ctx.tcf = Some(gpp_tcf.clone()); - } -} - -/// Returns whether GPP should win under the `newest` strategy. -#[must_use] -fn select_newest_signal( - standalone_tcf: &types::TcfConsent, - gpp_tcf: &types::TcfConsent, - freshness_threshold_days: u32, -) -> Option { - let threshold_ds = u64::from(freshness_threshold_days) * DECISECONDS_PER_DAY; - - let standalone_delta = standalone_tcf - .last_updated_ds - .saturating_sub(gpp_tcf.last_updated_ds); - if standalone_delta > threshold_ds { - return Some(false); - } - - let gpp_delta = gpp_tcf - .last_updated_ds - .saturating_sub(standalone_tcf.last_updated_ds); - if gpp_delta > threshold_ds { - return Some(true); +pub fn request_jurisdiction( + geo: Option<&GeoInfo>, + default: jurisdiction::Jurisdiction, +) -> jurisdiction::Jurisdiction { + match geo { + Some(_) => jurisdiction::detect_jurisdiction(geo), + None => { + log_missing_geo_warning_once(); + default + } } - - None -} - -/// Returns the current time in deciseconds since the Unix epoch. -pub(crate) fn now_deciseconds() -> u64 { - let dur = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("should have system time after Unix epoch"); - dur.as_secs() * 10 + u64::from(dur.subsec_millis()) / 100 } -/// Returns the age of a TCF consent string in days. -fn consent_age_days(tcf: &types::TcfConsent) -> u64 { - now_deciseconds().saturating_sub(tcf.last_updated_ds) / DECISECONDS_PER_DAY -} - -/// Checks whether a TCF consent string has expired. +/// Whether a server-side auction may be dispatched for this request. /// -/// Compares `last_updated_ds` (deciseconds since epoch) against the current -/// time and the configured maximum age. Returns `true` if the consent is -/// older than `max_age_days`. +/// Reads the resolved permission state rather than any scheme's record, so +/// the place baseline and every configured module have already had their +/// say. The auction selects advertising from the request, so it runs when +/// [`Permission::SelectBasicAds`] is set, which under a baseline that requires +/// a signal means a module granted it, and under a baseline that grants it +/// outright means no module revoked it. #[must_use] -pub fn is_consent_expired(tcf: &types::TcfConsent, max_age_days: u32) -> bool { - let max_age_ds = u64::from(max_age_days) * DECISECONDS_PER_DAY; - now_deciseconds().saturating_sub(tcf.last_updated_ds) > max_age_ds -} - -/// Constructs a US Privacy string from `Sec-GPC` and publisher config defaults. -/// -/// Called when `gpc = true` but no explicit `us_privacy` cookie exists and the -/// user is in a US state with a privacy law. The resulting string reflects the -/// publisher's configured compliance posture, not a protocol assertion. -/// -/// Returns [`None`] if the config says GPC should not imply opt-out. -#[must_use] -pub fn build_us_privacy_from_gpc(config: &ConsentConfig) -> Option { - let defaults = &config.us_privacy_defaults; - if !defaults.gpc_implies_optout { - return None; - } - - if defaults.notice_given { - log::warn!( - "GPC-constructed US Privacy string asserts notice_given=true; \ - verify that the publisher's privacy notice satisfies this claim" - ); - } - - Some(types::UsPrivacy { - version: 1, - notice_given: PrivacyFlag::from(defaults.notice_given), - opt_out_sale: PrivacyFlag::Yes, - lspa_covered: PrivacyFlag::from(defaults.lspa_covered), - }) +pub fn permissions_allow_server_side_auction(permissions: &PermissionState) -> bool { + permissions.is_set(Permission::SelectBasicAds) } /// Filters Extended User IDs through the resolved permission state. /// /// EIDs are partner identifiers carried in the bidstream, so they ride on two -/// permissions: [`Permission::StoreOnDevice`] (the identifier must be readable, -/// TCF Purpose 1) and [`Permission::SelectPersonalisedAds`] (it may be shared to -/// select ads, TCF Purpose 4). They are transmitted only when both are set in -/// the resolved [`PermissionState`]; otherwise every EID is stripped. +/// permissions: [`Permission::StoreOnDevice`] (the identifier must be readable) +/// and [`Permission::SelectPersonalisedAds`] (it may be shared to select ads). +/// They are transmitted only when both are set in the resolved +/// [`PermissionState`]; otherwise every EID is stripped. /// -/// The country/region map and any opt-out signal already decided those bits when -/// the permission state was assembled, so this reads one source of truth rather -/// than re-inspecting consent. Called by `handle_auction` after resolving -/// partner EIDs from the KV identity graph, before attaching them to the -/// outbound `OpenRTB` request. +/// The country/region map and the signal modules already decided those bits +/// when the permission state was assembled, so this reads one source of truth +/// rather than re-inspecting consent. Called by `handle_auction` after +/// resolving partner EIDs from the KV identity graph, before attaching them to +/// the outbound bid request. #[must_use] pub fn gate_eids_by_permissions( eids: Option>, @@ -498,52 +121,6 @@ pub fn gate_eids_by_permissions( } } -// --------------------------------------------------------------------------- -// EC consent gating -// --------------------------------------------------------------------------- - -/// Returns `true` when the request carries a US-style storage/sale opt-out -/// signal (GPC, a GPP sale opt-out, or a US Privacy opt-out), independent of -/// jurisdiction. -/// -/// This reports the signal only. Whether the opt-out changes a permission is -/// decided by the country/region map when the permission state is assembled: it -/// drops a `granted` baseline (for example a US opt-out state) and has nothing to -/// drop where the permission is `requires_signal`. Honoring it everywhere is -/// intentionally conservative. -/// -/// TCF consent or refusal is handled separately by -/// [`crate::ec::consent::permission_signal`], which treats a present TCF record -/// as authoritative, so this helper does not consider TCF. -#[must_use] -pub fn has_storage_optout_signal(ctx: &ConsentContext) -> bool { - if ctx.gpc { - return true; - } - if ctx.gpp.as_ref().and_then(|gpp| gpp.us_sale_opt_out) == Some(true) { - return true; - } - ctx.us_privacy - .as_ref() - .is_some_and(|usp| usp.opt_out_sale == PrivacyFlag::Yes) -} - -// --------------------------------------------------------------------------- -// Logging helpers -// --------------------------------------------------------------------------- - -/// Logs a summary of the extracted consent signals. -/// -/// Emits an `info`-level log line when at least one consent signal is present, -/// or a `debug`-level line when no signals were found. -fn log_consent_signals(signals: &RawConsentSignals) { - if signals.is_empty() { - log::debug!("No consent signals found on request"); - } else { - log::info!("Consent signals: {signals}"); - } -} - /// Logs a one-time warning when request geolocation is unavailable. /// /// It says what happens next rather than only that geo is missing, because an @@ -561,421 +138,221 @@ fn log_missing_geo_warning_once() { ); } -/// Derives a human-readable status label for a decoded signal. -/// -/// Returns `"present"` when decoded data exists, `"decode-failed"` when only -/// the raw string exists, or `"absent"` when neither is available. -fn signal_status(decoded: bool, raw: bool) -> &'static str { - if decoded { - "present" - } else if raw { - "decode-failed" - } else { - "absent" - } -} - -/// Logs a structured summary of the fully-processed consent context. +/// Logs a summary of what the modules read, one entry per scheme. fn log_consent_context(ctx: &ConsentContext) { - if ctx.is_empty() { + if ctx.records.is_empty() { + log::debug!("No consent signals read: jurisdiction={}", ctx.jurisdiction); return; } - - let tcf_status = match (&ctx.tcf, ctx.expired) { - (Some(_), _) => "present", - (None, true) => "expired", - (None, false) if ctx.raw_tc_string.is_some() => "decode-failed", - _ => "absent", - }; - - let gpp_status = signal_status(ctx.gpp.is_some(), ctx.raw_gpp_string.is_some()); - let usp_status = signal_status(ctx.us_privacy.is_some(), ctx.raw_us_privacy.is_some()); - + let records: Vec = ctx + .records + .iter() + .map(|record| { + format!( + "{}={}{}", + record.scheme(), + record.status(), + if record.source() == ConsentSource::PolicyDefault { + " (policy default)" + } else { + "" + } + ) + }) + .collect(); log::info!( - "Consent context: jurisdiction={}, tcf={tcf_status}, gpp={gpp_status}, \ - us_privacy={usp_status}, gpc={}, gdpr_applies={}, source={:?}", + "Consent context: jurisdiction={}, {}, applies={}", ctx.jurisdiction, - ctx.gpc, - ctx.gdpr_applies, - ctx.source, + records.join(", "), + ctx.applies(), ); } #[cfg(test)] mod tests { - use edgezero_core::body::Body as EdgeBody; - use http::Request; - - use super::{ - ConsentPipelineInput, apply_expiration_check, apply_tcf_conflict_resolution, - build_consent_context, build_context_from_signals, consent_allows_server_side_auction, - gate_eids_by_permissions, has_storage_optout_signal, jurisdiction::Jurisdiction, - }; - use crate::consent::types::{ - ConsentContext, GppConsent, PrivacyFlag, RawConsentSignals, TcfConsent, UsPrivacy, - }; - use crate::consent_config::{ConflictMode, ConsentConfig, ConsentMode}; - use crate::cookies::parse_cookies_to_jar; - - fn build_request() -> Request { - Request::builder() - .method("GET") - .uri("https://example.com") - .body(EdgeBody::empty()) - .expect("should build consent test request") - } - - /// Builder for [`TcfConsent`] test fixtures with sensible defaults. - /// - /// All purposes default to `false`, timestamps to `0`, and vendor lists - /// to empty. Callers set only the fields relevant to their test. - struct TcfBuilder { - last_updated_ds: u64, - purpose_consents: [bool; 24], + use http::HeaderMap; + + use super::*; + use crate::evidence::{OwnedRequestInfo, RequestInfo}; + use crate::module_context::{ModuleCall, test_support}; + use crate::permission_signal::SignalInput; + use crate::permissions::{ConsentSignal, PermissionMaps}; + + /// A module that reads one cookie and reports it decoded, standing in for + /// a scheme that decodes a cookie. + struct Reading(&'static str, &'static str); + + impl Reading { + fn record(&self, evidence: &dyn RequestInfo) -> Option { + let value = evidence.cookie(self.1)?; + Some(SignalRecord::new(self.0, SignalStatus::Decoded).with_value(value)) + } } - impl TcfBuilder { - fn new() -> Self { - Self { - last_updated_ds: 0, - purpose_consents: [false; 24], - } + impl PermissionSignalModule for Reading { + fn id(&self) -> &'static str { + self.0 } - /// Sets Purpose 1 (store/access information on a device). - fn with_storage(mut self, consented: bool) -> Self { - self.purpose_consents[0] = consented; - self + fn signal(&self, _permission: Permission, _input: &SignalInput<'_>) -> ConsentSignal { + ConsentSignal::Neutral } - /// Sets Purpose 4 (personalized ads / EID transmission). - fn with_personalized_ads(mut self, consented: bool) -> Self { - self.purpose_consents[3] = consented; - self + fn read(&self, call: ModuleCall<'_>) -> Option { + call.inject(self, Self::record).ok().flatten() } + } - fn with_last_updated(mut self, ds: u64) -> Self { - self.last_updated_ds = ds; - self - } + /// A module that reports the jurisdiction it was offered, standing in + /// for a scheme whose reading depends on it. + struct SeeingJurisdiction; - fn build(self) -> TcfConsent { - TcfConsent { - version: 2, - cmp_id: 1, - cmp_version: 1, - consent_screen: 0, - consent_language: "EN".to_owned(), - vendor_list_version: 1, - tcf_policy_version: 4, - created_ds: self.last_updated_ds, - last_updated_ds: self.last_updated_ds, - purpose_consents: self.purpose_consents.to_vec(), - purpose_legitimate_interests: vec![false; 24], - vendor_consents: Vec::new(), - vendor_legitimate_interests: Vec::new(), - special_feature_opt_ins: vec![false; 12], - } + impl SeeingJurisdiction { + fn record(&self, consent: &ConsentContext) -> Option { + Some( + SignalRecord::new("seeing", SignalStatus::Absent) + .with_applies(consent.jurisdiction == jurisdiction::Jurisdiction::Gdpr), + ) } } - fn make_tcf(last_updated_ds: u64, allows_eids: bool) -> TcfConsent { - TcfBuilder::new() - .with_storage(true) - .with_personalized_ads(allows_eids) - .with_last_updated(last_updated_ds) - .build() - } - - fn make_conflicting_context( - standalone_last_updated_ds: u64, - standalone_allows_eids: bool, - gpp_last_updated_ds: u64, - gpp_allows_eids: bool, - ) -> ConsentContext { - ConsentContext { - raw_tc_string: Some("standalone".to_owned()), - raw_gpp_string: Some("gpp".to_owned()), - gpp_section_ids: Some(vec![2]), - gdpr_applies: true, - tcf: Some(make_tcf(standalone_last_updated_ds, standalone_allows_eids)), - gpp: Some(GppConsent { - version: 1, - section_ids: vec![2], - eu_tcf: Some(make_tcf(gpp_last_updated_ds, gpp_allows_eids)), - us_sale_opt_out: None, - }), - ..ConsentContext::default() + impl PermissionSignalModule for SeeingJurisdiction { + fn id(&self) -> &'static str { + "seeing" } - } - #[test] - fn auction_allowed_for_known_non_gdpr_jurisdiction_without_tcf_signal() { - let ctx = ConsentContext { - jurisdiction: Jurisdiction::UsState("CA".to_owned()), - ..ConsentContext::default() - }; + fn signal(&self, _permission: Permission, _input: &SignalInput<'_>) -> ConsentSignal { + ConsentSignal::Neutral + } - assert!( - consent_allows_server_side_auction(&ctx), - "known non-GDPR jurisdiction with no EU TCF signal should allow auction" - ); + fn read(&self, call: ModuleCall<'_>) -> Option { + call.inject(self, Self::record).ok().flatten() + } } - #[test] - fn auction_fails_closed_for_gdpr_jurisdiction_without_consent() { - let ctx = ConsentContext { - jurisdiction: Jurisdiction::Gdpr, - ..ConsentContext::default() - }; - - assert!( - !consent_allows_server_side_auction(&ctx), - "GDPR jurisdiction without a TCF signal should fail closed" + fn evidence(cookie: &str) -> OwnedRequestInfo { + let mut headers = HeaderMap::new(); + headers.insert( + "cookie", + cookie.parse().expect("should build a cookie header"), ); + OwnedRequestInfo::new(String::new(), headers) } #[test] - fn auction_fails_closed_for_unknown_jurisdiction_without_consent() { - let ctx = ConsentContext { - jurisdiction: Jurisdiction::Unknown, - ..ConsentContext::default() - }; - - assert!( - !consent_allows_server_side_auction(&ctx), - "unknown jurisdiction without a TCF signal should fail closed" + fn each_module_reads_its_own_signal_in_order() { + let evidence = evidence("b=2; a=1"); + let context = ModuleContext::new(test_support::request("/")).with_evidence(&evidence); + let modules: Vec> = vec![ + Arc::new(Reading("first", "a")), + Arc::new(Reading("second", "b")), + Arc::new(Reading("third", "c")), + ]; + let consent = read_consent(&context, &modules, jurisdiction::Jurisdiction::NonRegulated); + let schemes: Vec<&str> = consent.records.iter().map(SignalRecord::scheme).collect(); + assert_eq!( + schemes, + vec!["first", "second"], + "a module with nothing to read reports no record, and the rest come in order" ); - } - - #[test] - fn auction_fails_closed_when_tcf_signal_present_without_purpose1() { - let ctx = ConsentContext { - jurisdiction: Jurisdiction::UsState("CA".to_owned()), - gdpr_applies: true, - tcf: Some(TcfBuilder::new().with_storage(false).build()), - ..ConsentContext::default() - }; - - assert!( - !consent_allows_server_side_auction(&ctx), - "EU TCF signal without Purpose 1 should block auction even outside GDPR geo" + assert_eq!(consent.value("first"), Some("1")); + assert_eq!(consent.value("second"), Some("2")); + assert_eq!( + consent.jurisdiction, + jurisdiction::Jurisdiction::NonRegulated, + "the jurisdiction given is the context's" ); } #[test] - fn auction_allowed_for_gdpr_jurisdiction_with_purpose1_consent() { - let ctx = ConsentContext { - jurisdiction: Jurisdiction::Gdpr, - gdpr_applies: true, - tcf: Some(TcfBuilder::new().with_storage(true).build()), - ..ConsentContext::default() - }; - + fn a_module_reading_sees_the_jurisdiction() { + let context = ModuleContext::new(test_support::request("/")); + let modules: Vec> = vec![Arc::new(SeeingJurisdiction)]; + let consent = read_consent(&context, &modules, jurisdiction::Jurisdiction::Gdpr); assert!( - consent_allows_server_side_auction(&ctx), - "GDPR jurisdiction with Purpose 1 consent should allow auction" + consent.record("seeing").is_some_and(SignalRecord::applies), + "the module read the jurisdiction the context will carry" ); } #[test] - fn auction_allowed_with_purpose1_via_gpp_eu_tcf_section() { - let ctx = ConsentContext { - jurisdiction: Jurisdiction::Gdpr, - gdpr_applies: true, - gpp: Some(GppConsent { - version: 1, - section_ids: vec![2], - eu_tcf: Some(TcfBuilder::new().with_storage(true).build()), - us_sale_opt_out: None, - }), - ..ConsentContext::default() - }; - - assert!( - consent_allows_server_side_auction(&ctx), - "Purpose 1 granted via GPP EU TCF section should allow auction" - ); + fn no_modules_read_nothing() { + let context = ModuleContext::new(test_support::request("/")); + let consent = read_consent(&context, &[], jurisdiction::Jurisdiction::Unknown); + assert!(consent.records.is_empty()); + assert!(consent.is_empty()); } #[test] - fn missing_geo_keeps_unknown_jurisdiction() { - let req = build_request(); - let config = ConsentConfig::default(); - - let ctx = build_consent_context(&ConsentPipelineInput { - jar: None, - req: &req, - config: &config, - geo: None, - default_jurisdiction: Jurisdiction::Unknown, - }); - - assert_eq!( - ctx.jurisdiction, - Jurisdiction::Unknown, - "missing geo should keep jurisdiction unknown" - ); - } - - #[test] - fn proxy_mode_marks_gdpr_when_raw_tc_exists() { - let jar = parse_cookies_to_jar("euconsent-v2=CPXxGfAPXxGfA"); - let req = build_request(); - let config = ConsentConfig { - mode: ConsentMode::Proxy, - ..ConsentConfig::default() + fn a_located_request_takes_the_jurisdiction_of_its_place() { + let geo = GeoInfo { + city: String::new(), + country: "DE".to_owned(), + continent: String::new(), + latitude: 0.0, + longitude: 0.0, + metro_code: 0, + region: None, + asn: None, }; - - let ctx = build_consent_context(&ConsentPipelineInput { - jar: Some(&jar), - req: &req, - config: &config, - geo: None, - default_jurisdiction: Jurisdiction::Unknown, - }); - - assert!( - ctx.gdpr_applies, - "should set gdpr_applies when raw TC string is present in proxy mode" - ); assert_eq!( - ctx.raw_tc_string.as_deref(), - Some("CPXxGfAPXxGfA"), - "should preserve raw TC string in proxy mode" - ); - assert!(ctx.tcf.is_none(), "should skip TCF decoding in proxy mode"); - } - - #[test] - fn proxy_mode_marks_gdpr_when_gpp_sid_contains_tcf_section() { - let jar = parse_cookies_to_jar("__gpp_sid=2,6"); - let req = build_request(); - let config = ConsentConfig { - mode: ConsentMode::Proxy, - ..ConsentConfig::default() - }; - - let ctx = build_consent_context(&ConsentPipelineInput { - jar: Some(&jar), - req: &req, - config: &config, - geo: None, - default_jurisdiction: Jurisdiction::Unknown, - }); - - assert!( - ctx.gdpr_applies, - "should set gdpr_applies when __gpp_sid includes section 2" + request_jurisdiction(Some(&geo), jurisdiction::Jurisdiction::Unknown), + jurisdiction::Jurisdiction::Gdpr, + "the place decides, not the default" ); } #[test] - fn marks_gdpr_when_gpp_sid_contains_tcf_section_even_if_gpp_decode_fails() { - let signals = RawConsentSignals { - raw_gpp_string: Some("invalid-gpp".to_owned()), - raw_gpp_sid: Some("2,6".to_owned()), - ..RawConsentSignals::default() - }; - - let ctx = build_context_from_signals(&signals); - - assert!( - ctx.gdpr_applies, - "should set gdpr_applies when section 2 exists even if __gpp decode fails" + fn an_unlocated_request_takes_the_default_jurisdiction() { + assert_eq!( + request_jurisdiction(None, jurisdiction::Jurisdiction::Unknown), + jurisdiction::Jurisdiction::Unknown, + "missing geo should keep the default the caller supplied" ); - } - - #[test] - fn conflict_resolution_restrictive_prefers_denial() { - let mut ctx = make_conflicting_context(10, true, 20, false); - let config = ConsentConfig::default(); - - apply_tcf_conflict_resolution(&mut ctx, &config); - - let tcf = ctx - .tcf - .expect("should keep an effective TCF after resolution"); - assert!( - !tcf.has_personalized_ads_consent(), - "restrictive mode should choose the denying signal" + assert_eq!( + request_jurisdiction(None, jurisdiction::Jurisdiction::Gdpr), + jurisdiction::Jurisdiction::Gdpr ); } #[test] - fn conflict_resolution_permissive_prefers_grant() { - let mut ctx = make_conflicting_context(10, true, 20, false); - let mut config = ConsentConfig::default(); - config.conflict_resolution.mode = ConflictMode::Permissive; - - apply_tcf_conflict_resolution(&mut ctx, &config); - - let tcf = ctx - .tcf - .expect("should keep an effective TCF after resolution"); + fn the_auction_runs_where_basic_ad_selection_is_set() { + // US/CA maps to us-opt-out, where advertising_marketing.first_party.contextual + // is granted with no signal. + let permissions = PermissionMaps::standard().resolve(Some("US"), |_| false); assert!( - tcf.has_personalized_ads_consent(), - "permissive mode should choose the granting signal" + permissions_allow_server_side_auction(&permissions), + "a granted baseline with no signal allows the auction" ); - } - - #[test] - fn conflict_resolution_newest_prefers_fresher_signal() { - let mut ctx = make_conflicting_context(2 * super::DECISECONDS_PER_DAY, true, 0, false); - let mut config = ConsentConfig::default(); - config.conflict_resolution.mode = ConflictMode::Newest; - config.conflict_resolution.freshness_threshold_days = 1; - - apply_tcf_conflict_resolution(&mut ctx, &config); - - let tcf = ctx - .tcf - .expect("should keep an effective TCF after resolution"); + // FR maps to gdpr-eu, where every purpose requires a signal, so with + // none the auction fails closed. + let permissions = PermissionMaps::standard().resolve(Some("FR"), |_| false); assert!( - tcf.has_personalized_ads_consent(), - "newest mode should pick the signal that is newer than threshold" + !permissions_allow_server_side_auction(&permissions), + "a requires-signal baseline with no signal fails closed" ); - } - - #[test] - fn expiration_clears_gpp_embedded_tcf() { - let mut ctx = ConsentContext { - raw_gpp_string: Some("DBACNY".to_owned()), - gpp_section_ids: Some(vec![2]), - gdpr_applies: true, - gpp: Some(GppConsent { - version: 1, - section_ids: vec![2], - eu_tcf: Some(make_tcf(0, true)), - us_sale_opt_out: None, - }), - ..ConsentContext::default() - }; - let config = ConsentConfig { - max_consent_age_days: 0, - ..ConsentConfig::default() - }; - - apply_expiration_check(&mut ctx, &config); - - assert!(ctx.expired, "should mark embedded TCF as expired"); + // A signal granting basic ad selection opens it. + let permissions = PermissionMaps::standard().resolve(Some("FR"), |permission| { + permission == Permission::SelectBasicAds + }); assert!( - ctx.gpp.as_ref().is_some_and(|g| g.eu_tcf.is_none()), - "should clear decoded GPP embedded TCF when expired" + permissions_allow_server_side_auction(&permissions), + "a module granting basic ad selection allows the auction" ); + // An unresolved place takes the top node, which requires a signal. + let permissions = PermissionMaps::standard().resolve(None, |_| false); assert!( - ctx.gdpr_applies, - "should keep gdpr_applies true when raw EU TCF signal is still present" + !permissions_allow_server_side_auction(&permissions), + "an unknown place without a signal fails closed" ); } - // ----------------------------------------------------------------------- #[test] fn gate_eids_keeps_eids_when_required_permissions_are_set() { - // US maps to us-opt-out, where necessary.operations.storage and advertising_marketing.first_party.targeted - // are granted with no signal, so bidstream EIDs are transmitted. - let permissions = - crate::permissions::PermissionMaps::standard().resolve(Some("US"), |_| false); + // US maps to us-opt-out, where necessary.operations.storage and + // advertising_marketing.first_party.targeted are granted with no + // signal, so bidstream EIDs are transmitted. + let permissions = PermissionMaps::standard().resolve(Some("US"), |_| false); let eids = Some(vec!["eid-1".to_owned()]); assert!( gate_eids_by_permissions(eids, &permissions).is_some(), @@ -987,8 +364,7 @@ mod tests { fn gate_eids_strips_eids_when_a_required_permission_is_unset() { // FR maps to gdpr-eu, where every purpose is requires_signal, so with no // signal neither required permission is set and EIDs are stripped. - let permissions = - crate::permissions::PermissionMaps::standard().resolve(Some("FR"), |_| false); + let permissions = PermissionMaps::standard().resolve(Some("FR"), |_| false); let eids = Some(vec!["eid-1".to_owned()]); assert!( gate_eids_by_permissions(eids, &permissions).is_none(), @@ -998,8 +374,7 @@ mod tests { #[test] fn gate_eids_returns_none_for_empty_input() { - let permissions = - crate::permissions::PermissionMaps::standard().resolve(Some("US"), |_| false); + let permissions = PermissionMaps::standard().resolve(Some("US"), |_| false); assert!( gate_eids_by_permissions::(None, &permissions).is_none(), "no EIDs should resolve to None" @@ -1009,46 +384,4 @@ mod tests { "an empty EID list should resolve to None" ); } - - #[test] - fn has_storage_optout_signal_detects_us_style_opt_outs() { - let gpc = ConsentContext { - gpc: true, - ..ConsentContext::default() - }; - assert!(has_storage_optout_signal(&gpc), "GPC is a storage opt-out"); - - let gpp_sale_opt_out = ConsentContext { - gpp: Some(GppConsent { - version: 1, - section_ids: vec![8], - eu_tcf: None, - us_sale_opt_out: Some(true), - }), - ..ConsentContext::default() - }; - assert!( - has_storage_optout_signal(&gpp_sale_opt_out), - "a GPP US sale opt-out is a storage opt-out" - ); - - let usp_opt_out = ConsentContext { - us_privacy: Some(UsPrivacy { - version: 1, - notice_given: PrivacyFlag::Yes, - opt_out_sale: PrivacyFlag::Yes, - lspa_covered: PrivacyFlag::No, - }), - ..ConsentContext::default() - }; - assert!( - has_storage_optout_signal(&usp_opt_out), - "a US Privacy sale opt-out is a storage opt-out" - ); - - assert!( - !has_storage_optout_signal(&ConsentContext::default()), - "no signal is not a storage opt-out" - ); - } } diff --git a/crates/trusted-server-core/src/consent/types.rs b/crates/trusted-server-core/src/consent/types.rs index 60d5dd4d5e..0140b916bf 100644 --- a/crates/trusted-server-core/src/consent/types.rs +++ b/crates/trusted-server-core/src/consent/types.rs @@ -1,683 +1,408 @@ -//! Consent signal types. +//! The request's consent context: what each permission signal module read +//! from the request, in neutral terms. //! -//! This module defines the full consent type hierarchy: -//! -//! - [`RawConsentSignals`] — raw (undecoded) strings extracted from cookies/headers -//! - [`ConsentContext`] — the normalized output carrying both raw and decoded data -//! - [`UsPrivacy`] / [`PrivacyFlag`] — decoded US Privacy (CCPA) 4-char string -//! - [`TcfConsent`] — decoded TCF v2 core consent data -//! - [`GppConsent`] — decoded GPP consent data -//! - [`ConsentSource`] — how consent was sourced (cookie or policy default) +//! A module reads its own scheme's signal from the request and reports it as +//! a [`SignalRecord`], being the string as received, whether it decoded, where +//! it came from, and the decoded form, which is the module's own type and is +//! opaque here. Core carries the records, the jurisdiction, and nothing of how +//! any scheme spells its signal. +use core::any::Any; use core::fmt; +use std::sync::Arc; -// --------------------------------------------------------------------------- -// Raw extraction layer -// --------------------------------------------------------------------------- +use super::jurisdiction::Jurisdiction; +use crate::permissions::ValidSignal; -/// Raw consent signals extracted from cookies and HTTP headers. -/// -/// All fields are optional because any combination of consent mechanisms may be -/// present (or absent) on a given request. No decoding or validation is -/// performed at this stage — the values are preserved exactly as received. -/// -/// # Consent sources +/// The consent context for a request, as the permission signal modules +/// filled it. /// -/// | Field | Source | Standard | -/// |---|---|---| -/// | [`raw_tc_string`](Self::raw_tc_string) | `euconsent-v2` cookie | IAB TCF v2 | -/// | [`raw_gpp_string`](Self::raw_gpp_string) | `__gpp` cookie | IAB GPP | -/// | [`raw_gpp_sid`](Self::raw_gpp_sid) | `__gpp_sid` cookie | IAB GPP | -/// | [`raw_us_privacy`](Self::raw_us_privacy) | `us_privacy` cookie | IAB US Privacy (CCPA) | -/// | [`gpc`](Self::gpc) | `Sec-GPC` header | Global Privacy Control | +/// One [`SignalRecord`] per scheme a configured module read, in the order the +/// modules were asked, and the request's privacy jurisdiction. Built once by +/// the consent pipeline and read by the permission assembly, the bid request +/// builders and the identity graph. #[derive(Debug, Clone, Default)] -pub struct RawConsentSignals { - /// TCF v2 consent string from the `euconsent-v2` cookie. - pub raw_tc_string: Option, - /// GPP consent string from the `__gpp` cookie. - pub raw_gpp_string: Option, - /// GPP section IDs from the `__gpp_sid` cookie (raw comma-separated string). - pub raw_gpp_sid: Option, - /// US Privacy string from the `us_privacy` cookie (4-character format). - pub raw_us_privacy: Option, - /// Global Privacy Control signal from the `Sec-GPC` header. - /// - /// When `true`, the browser has signaled the user's opt-out preference. - pub gpc: bool, +pub struct ConsentContext { + /// What each configured module read, one record per scheme, in module + /// order. + pub records: Vec, + /// The privacy jurisdiction the request falls under. + pub jurisdiction: Jurisdiction, } -impl RawConsentSignals { - /// Returns `true` when at least one consent cookie signal is present. +impl ConsentContext { + /// A context holding no records under `jurisdiction`. #[must_use] - pub fn has_cookie_signals(&self) -> bool { - self.raw_tc_string.is_some() - || self.raw_gpp_string.is_some() - || self.raw_gpp_sid.is_some() - || self.raw_us_privacy.is_some() + pub fn new(jurisdiction: Jurisdiction) -> Self { + Self { + records: Vec::new(), + jurisdiction, + } } - /// Returns `true` when no consent signals were found on the request. + /// The same context with `record` added, replacing any record of the + /// same scheme, so one scheme is reported once. #[must_use] - pub fn is_empty(&self) -> bool { - !self.has_cookie_signals() && !self.gpc + pub fn with_record(mut self, record: SignalRecord) -> Self { + self.records.retain(|held| held.scheme != record.scheme); + self.records.push(record); + self } -} -impl fmt::Display for RawConsentSignals { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "euconsent-v2=")?; - match &self.raw_tc_string { - Some(s) => write!(f, "present ({} chars)", s.len())?, - None => write!(f, "absent")?, - } - - write!(f, ", __gpp=")?; - match &self.raw_gpp_string { - Some(s) => write!(f, "present ({} chars)", s.len())?, - None => write!(f, "absent")?, - } - - write!(f, ", __gpp_sid=")?; - match &self.raw_gpp_sid { - Some(s) => write!(f, "\"{s}\"")?, - None => write!(f, "absent")?, - } + /// The record of `scheme`, when a module read one. + #[must_use] + pub fn record(&self, scheme: &str) -> Option<&SignalRecord> { + self.records.iter().find(|record| record.scheme == scheme) + } - write!(f, ", us_privacy=")?; - match &self.raw_us_privacy { - Some(s) => write!(f, "\"{s}\"")?, - None => write!(f, "absent")?, - } + /// The decoded form of `scheme`, when a module read one and it is of + /// type `T`. + #[must_use] + pub fn decoded(&self, scheme: &str) -> Option<&T> { + self.record(scheme).and_then(SignalRecord::decoded) + } - write!(f, ", Sec-GPC={}", if self.gpc { "1" } else { "absent" }) + /// The signal of `scheme` as it was received, when a module read one. + #[must_use] + pub fn value(&self, scheme: &str) -> Option<&str> { + self.record(scheme).and_then(SignalRecord::value) } -} -// --------------------------------------------------------------------------- -// Decoded consent types -// --------------------------------------------------------------------------- + /// Whether any module says the regulation its scheme answers for applies + /// to this request. + #[must_use] + pub fn applies(&self) -> bool { + self.records.iter().any(|record| record.applies) + } -/// Normalized consent context extracted from cookies and headers. -/// -/// Carries both raw consent strings (for `OpenRTB` forwarding) and decoded -/// structured data (for TS-level enforcement and observability). This is the -/// central type that flows through the entire request lifecycle. -/// -/// Built from [`RawConsentSignals`] by the decoding pipeline in -/// [`super::build_consent_context`]. -#[derive(Debug, Clone, Default)] -pub struct ConsentContext { - /// Raw TC String from `euconsent-v2` cookie, passed as-is in `user.consent`. - pub raw_tc_string: Option, - /// Raw GPP string from `__gpp` cookie, passed as-is in `regs.gpp`. - pub raw_gpp_string: Option, - /// GPP section IDs derived from decoded `__gpp` data. - /// - /// The `__gpp_sid` cookie is treated as a transport hint and validated - /// against decoded section IDs when both are present. - pub gpp_section_ids: Option>, - /// Raw US Privacy string from `us_privacy` cookie. - pub raw_us_privacy: Option, - /// Raw Google Additional Consent (AC) string. - /// - /// Covers ad tech providers not in the IAB Global Vendor List but - /// participating in the Google ecosystem. Format: `{version}~{ids}~dv.` - pub raw_ac_string: Option, - - /// Whether GDPR applies to this request (derived from TCF presence). - pub gdpr_applies: bool, - /// Decoded TCF v2 consent data. - pub tcf: Option, - /// Decoded GPP consent data. - pub gpp: Option, - /// Decoded US Privacy signal. - pub us_privacy: Option, - - /// Whether the TCF consent string has expired (age exceeds configured max). - /// - /// When `true` and `check_expiration` is enabled, the decoded `tcf` field - /// is cleared (treated as no consent) but the raw string is preserved for - /// proxy-mode forwarding. - pub expired: bool, - - /// Global Privacy Control signal from `Sec-GPC` header. - pub gpc: bool, - /// Detected privacy jurisdiction for this request. - pub jurisdiction: super::jurisdiction::Jurisdiction, - /// Source of the consent data (for debugging). - pub source: ConsentSource, -} + /// Whether no record carries a signal. + #[must_use] + pub fn is_empty(&self) -> bool { + self.records.iter().all(|record| record.value.is_none()) + } -impl ConsentContext { - /// Keeps only the raw strings a configured module vouched for, so a - /// signal that was absent, unreadable, expired or not acted on is never - /// forwarded in a bid request or anywhere else this context is read. + /// Keeps only the signals a configured module vouched for, so a signal + /// that was absent, unreadable, expired or not acted on is never forwarded + /// in a bid request or anywhere else this context is read. /// /// `signals` are the [`ValidSignal`]s on the assembled permission state, - /// matched by scheme. The decoded records are left alone, because a - /// decoded record only exists where the raw string was readable and the - /// modules have already read it. - pub fn keep_only(&mut self, signals: &[crate::permissions::ValidSignal]) { - let vouched = |scheme: &str| signals.iter().any(|signal| signal.scheme == scheme); - if !vouched("tcf") { - self.raw_tc_string = None; - } - if !vouched("gpp") { - self.raw_gpp_string = None; - self.gpp_section_ids = None; - } - if !vouched("us_privacy") { - self.raw_us_privacy = None; + /// matched by scheme. A record nobody vouched for keeps its status and + /// what it decoded, because the modules have already read those, and loses + /// the string and the sections a reader would forward. + pub fn keep_only(&mut self, signals: &[ValidSignal]) { + for record in &mut self.records { + if !signals.iter().any(|signal| signal.scheme == record.scheme) { + record.value = None; + record.sections = Vec::new(); + } } } - /// Returns `true` when no consent signals are present. - #[must_use] - pub fn is_empty(&self) -> bool { - self.raw_tc_string.is_none() - && self.raw_gpp_string.is_none() - && self.gpp_section_ids.is_none() - && self.raw_us_privacy.is_none() - && self.raw_ac_string.is_none() - && self.tcf.is_none() - && self.gpp.is_none() - && self.us_privacy.is_none() - && !self.gpc + /// Drops every record `keep` answers `false` for. + pub fn retain(&mut self, keep: impl FnMut(&SignalRecord) -> bool) { + self.records.retain(keep); } } -// --------------------------------------------------------------------------- -// TCF v2 -// --------------------------------------------------------------------------- - -/// Decoded TCF v2.x consent data. -/// -/// Extracted from either a standalone TC String (`euconsent-v2` cookie) -/// or from the EU TCF v2.2 section within a GPP string. -/// -/// Only the core segment (segment type 0) is decoded. Publisher restrictions, -/// disclosed vendors, and allowed vendors segments are not yet supported. -#[derive(Debug, Clone)] -pub struct TcfConsent { - /// TCF version (2). - pub version: u8, - /// CMP ID that collected this consent. - pub cmp_id: u16, - /// CMP version. - pub cmp_version: u16, - /// Consent screen number. - pub consent_screen: u8, - /// CMP language (ISO 639-1, two uppercase letters). - pub consent_language: String, - /// Vendor list version used. - pub vendor_list_version: u16, - /// TCF policy version. - pub tcf_policy_version: u8, - /// Timestamp when consent was created (deciseconds since epoch). - pub created_ds: u64, - /// Timestamp when consent was last updated (deciseconds since epoch). - pub last_updated_ds: u64, - - /// Purpose consents (24 bits, 1-indexed). - /// - /// `true` at index 0 means purpose 1 is consented, etc. - pub purpose_consents: Vec, - /// Purpose legitimate interests (24 bits, 1-indexed). - pub purpose_legitimate_interests: Vec, - - /// Vendor IDs with consent granted. - pub vendor_consents: Vec, - /// Vendor IDs with legitimate interest established. - pub vendor_legitimate_interests: Vec, - - /// Special feature opt-ins (12 bits). - pub special_feature_opt_ins: Vec, +/// What a permission signal module read from the request for its scheme. +#[derive(Clone)] +pub struct SignalRecord { + scheme: &'static str, + value: Option, + status: SignalStatus, + source: ConsentSource, + applies: bool, + sections: Vec, + decoded: Option>, } -impl TcfConsent { - /// Looks up a 1-indexed purpose in a TCF bitfield. - /// - /// Returns `false` for purpose 0 (invalid) and out-of-range indices. - fn purpose_bit(bits: &[bool], purpose: usize) -> bool { - purpose - .checked_sub(1) - .and_then(|idx| bits.get(idx).copied()) - .unwrap_or(false) +impl SignalRecord { + /// A record of `scheme` in `status`, carrying no string, no decoded form + /// and no source yet. + #[must_use] + pub fn new(scheme: &'static str, status: SignalStatus) -> Self { + Self { + scheme, + value: None, + status, + source: ConsentSource::None, + applies: false, + sections: Vec::new(), + decoded: None, + } } - /// Checks whether consent was granted for a specific TCF purpose. - /// - /// Purposes are 1-indexed per the TCF specification (Purpose 1 = index 0). - /// Returns `false` if the purpose is out of range. + /// The same record carrying the signal as it was received, from a cookie + /// or a header. #[must_use] - pub fn has_purpose_consent(&self, purpose: usize) -> bool { - Self::purpose_bit(&self.purpose_consents, purpose) + pub fn with_value(mut self, value: impl Into) -> Self { + self.value = Some(value.into()); + self.source = ConsentSource::Cookie; + self } - /// Checks whether legitimate interest was established for a specific TCF purpose. - /// - /// Purposes are 1-indexed per the TCF specification. - /// Returns `false` if the purpose is out of range. + /// The same record carrying `decoded`, the module's own form of the + /// signal, read back with [`decoded`](Self::decoded). #[must_use] - pub fn has_purpose_li(&self, purpose: usize) -> bool { - Self::purpose_bit(&self.purpose_legitimate_interests, purpose) + pub fn with_decoded(mut self, decoded: T) -> Self { + self.decoded = Some(Arc::new(decoded)); + self } - /// Checks whether a specific vendor has been granted consent. - /// - /// Uses linear search over the vendor ID list, which is adequate for - /// current usage patterns. Consider switching to a `HashSet` or - /// sorted vec with binary search if this lands on a hot path (e.g. - /// per-EID vendor gating). + /// The same record with where the signal came from. #[must_use] - pub fn has_vendor_consent(&self, vendor_id: u16) -> bool { - self.vendor_consents.contains(&vendor_id) + pub fn with_source(mut self, source: ConsentSource) -> Self { + self.source = source; + self } - /// Checks whether a specific vendor has established legitimate interest. - /// - /// See [`has_vendor_consent`](Self::has_vendor_consent) for performance - /// notes on the linear search. + /// The same record saying whether the regulation the scheme answers for + /// applies to this request. #[must_use] - pub fn has_vendor_li(&self, vendor_id: u16) -> bool { - self.vendor_legitimate_interests.contains(&vendor_id) + pub fn with_applies(mut self, applies: bool) -> Self { + self.applies = applies; + self } - /// Whether Purpose 1 (Store/access information on a device) is consented. - /// - /// Required for any EID or cookie-based identifier to be set. + /// The same record carrying the identifiers of the sections the signal + /// is made of, for a scheme whose string carries sections. #[must_use] - pub fn has_storage_consent(&self) -> bool { - self.has_purpose_consent(1) + pub fn with_sections(mut self, sections: Vec) -> Self { + self.sections = sections; + self } - /// Whether Purpose 2 (Basic ads) is consented. - /// - /// Required for bid adapters to participate in the auction. + /// The scheme, as the module names it and a reader asks for it. #[must_use] - pub fn has_basic_ads_consent(&self) -> bool { - self.has_purpose_consent(2) + pub fn scheme(&self) -> &'static str { + self.scheme } - /// Whether Purpose 4 (Personalized ads) is consented. - /// - /// Controls whether user first-party data and EIDs are transmitted. + /// The signal as it was received, when one arrived. #[must_use] - pub fn has_personalized_ads_consent(&self) -> bool { - self.has_purpose_consent(4) + pub fn value(&self) -> Option<&str> { + self.value.as_deref() } -} -// --------------------------------------------------------------------------- -// GPP -// --------------------------------------------------------------------------- + /// Whether the signal decoded, could not be read, expired, or was left + /// as received. + #[must_use] + pub fn status(&self) -> SignalStatus { + self.status + } -/// Decoded GPP (Global Privacy Platform) consent data. -/// -/// Wraps the `iab_gpp` crate's decoded output with our domain types. -#[derive(Debug, Clone)] -pub struct GppConsent { - /// GPP header version. - pub version: u8, - /// Active section IDs present in the GPP string. - pub section_ids: Vec, - /// Decoded EU TCF v2.2 section (if present in GPP, section ID 2). - pub eu_tcf: Option, - /// Whether the user opted out of sale of personal information via a US GPP - /// section (IDs 7–23). - /// - /// - `Some(true)` — a US section is present and `sale_opt_out == OptedOut` - /// - `Some(false)` — a US section is present and user did not opt out - /// - `None` — no US section exists in the GPP string - pub us_sale_opt_out: Option, -} + /// Where the signal came from. + #[must_use] + pub fn source(&self) -> ConsentSource { + self.source + } -// --------------------------------------------------------------------------- -// US Privacy (CCPA) -// --------------------------------------------------------------------------- + /// Whether the module says the regulation its scheme answers for applies + /// to this request. A scheme that does not say leaves it `false`. + #[must_use] + pub fn applies(&self) -> bool { + self.applies + } -/// Decoded US Privacy string (legacy 4-character format). -/// -/// Format: `1YNN` where: -/// - Char 1: Version (always `1`) -/// - Char 2: Notice given (`Y`/`N`/`-`) -/// - Char 3: Opt-out of sale (`Y`/`N`/`-`) -/// - Char 4: LSPA covered (`Y`/`N`/`-`) -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct UsPrivacy { - /// Specification version (currently always 1). - pub version: u8, - /// Whether explicit notice has been given to the consumer. - pub notice_given: PrivacyFlag, - /// Whether the consumer has opted out of the sale of personal information. - pub opt_out_sale: PrivacyFlag, - /// Whether the transaction is covered by the Limited Service Provider Agreement. - pub lspa_covered: PrivacyFlag, -} + /// The identifiers of the sections the signal is made of, in the scheme's + /// own numbering, and empty for a scheme without sections. + #[must_use] + pub fn sections(&self) -> &[u16] { + &self.sections + } -/// A tri-state flag used in the US Privacy string. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum PrivacyFlag { - /// `Y` — yes / affirmative. - Yes, - /// `N` — no / negative. - No, - /// `-` — not applicable or unknown. - NotApplicable, + /// The decoded form, when the module stored one of type `T`. + #[must_use] + pub fn decoded(&self) -> Option<&T> { + self.decoded + .as_deref() + .and_then(|decoded| decoded.downcast_ref::()) + } } -impl fmt::Display for PrivacyFlag { +impl fmt::Debug for SignalRecord { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::Yes => write!(f, "Y"), - Self::No => write!(f, "N"), - Self::NotApplicable => write!(f, "-"), - } + f.debug_struct("SignalRecord") + .field("scheme", &self.scheme) + .field("value", &self.value.as_ref().map(String::len)) + .field("status", &self.status) + .field("source", &self.source) + .field("applies", &self.applies) + .field("sections", &self.sections) + .field("decoded", &self.decoded.is_some()) + .finish() } } -impl From for PrivacyFlag { - fn from(value: bool) -> Self { - if value { Self::Yes } else { Self::No } - } +/// What became of the signal a module read. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SignalStatus { + /// No signal of the scheme arrived. A record in this state carries what + /// the module can still say without one. + Absent, + /// The signal decoded, and the record carries the decoded form. + Decoded, + /// The signal arrived and was left as received, because the settings + /// say not to decode. + NotDecoded, + /// The signal arrived and could not be read. + Unreadable, + /// The signal decoded and is older than the settings allow. + Expired, } -impl fmt::Display for UsPrivacy { +impl fmt::Display for SignalStatus { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!( - f, - "{}{}{}{}", - self.version, self.notice_given, self.opt_out_sale, self.lspa_covered, - ) + f.write_str(match self { + Self::Absent => "absent", + Self::Decoded => "present", + Self::NotDecoded => "not-decoded", + Self::Unreadable => "decode-failed", + Self::Expired => "expired", + }) } } -// --------------------------------------------------------------------------- -// Metadata types -// --------------------------------------------------------------------------- - -/// How consent was sourced for this request. +/// Where a signal came from. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] pub enum ConsentSource { - /// Read from cookies on the incoming request. + /// Read from the request, a cookie or a header. Cookie, - /// Applied from explicit publisher policy defaults. + /// Constructed from the deployment's declared policy defaults. PolicyDefault, - /// No consent data available. + /// No signal. #[default] None, } -// --------------------------------------------------------------------------- -// Consent error -// --------------------------------------------------------------------------- - -/// Errors that can occur during consent string decoding. -#[derive(Debug, derive_more::Display)] -pub enum ConsentDecodeError { - /// The US Privacy string has an invalid format. - #[display("invalid US Privacy string: {reason}")] - InvalidUsPrivacy { reason: String }, - /// The TC String could not be decoded. - #[display("invalid TC String: {reason}")] - InvalidTcString { reason: String }, - /// The GPP string could not be decoded. - #[display("invalid GPP string: {reason}")] - InvalidGppString { reason: String }, -} - -impl core::error::Error for ConsentDecodeError {} - -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - #[cfg(test)] mod tests { use super::*; #[test] - fn empty_signals() { - let signals = RawConsentSignals::default(); - assert!(signals.is_empty(), "default signals should be empty"); - } - - #[test] - fn not_empty_with_tc_string() { - let signals = RawConsentSignals { - raw_tc_string: Some("CPXxGfAPXxGfA".to_owned()), - ..Default::default() - }; - assert!(!signals.is_empty(), "should not be empty with tc_string"); - } - - #[test] - fn not_empty_with_gpc() { - let signals = RawConsentSignals { - gpc: true, - ..Default::default() - }; - assert!(!signals.is_empty(), "should not be empty with gpc=true"); - } - - #[test] - fn has_no_cookie_signals_with_only_gpc() { - let signals = RawConsentSignals { - gpc: true, - ..Default::default() - }; - - assert!( - !signals.has_cookie_signals(), - "should not report cookie signals when only gpc is present" + fn a_context_holds_one_record_per_scheme() { + let context = ConsentContext::default() + .with_record(SignalRecord::new("first", SignalStatus::Decoded).with_value("one")) + .with_record(SignalRecord::new("first", SignalStatus::Decoded).with_value("two")); + assert_eq!(context.records.len(), 1, "a scheme is reported once"); + assert_eq!( + context.value("first"), + Some("two"), + "the later record replaces the earlier one" ); - } - - #[test] - fn has_cookie_signals_with_tc_string() { - let signals = RawConsentSignals { - raw_tc_string: Some("CPXxGfAPXxGfA".to_owned()), - ..Default::default() - }; - - assert!( - signals.has_cookie_signals(), - "should report cookie signals when tc string is present" + assert_eq!( + context.value("other"), + None, + "an unread scheme has no value" ); } #[test] - fn display_all_absent() { - let signals = RawConsentSignals::default(); - let output = signals.to_string(); - assert!( - output.contains("euconsent-v2=absent"), - "should show euconsent-v2 absent" - ); - assert!(output.contains("__gpp=absent"), "should show __gpp absent"); - assert!( - output.contains("us_privacy=absent"), - "should show us_privacy absent" - ); - assert!( - output.contains("Sec-GPC=absent"), - "should show Sec-GPC absent" - ); - } + fn a_record_carries_its_decoded_form_by_type() { + #[derive(Debug, PartialEq)] + struct Decoded(u8); - #[test] - fn display_with_values() { - let signals = RawConsentSignals { - raw_tc_string: Some("CPXxGfAPXxGfA".to_owned()), - raw_gpp_string: Some("DBACNYA~CPXxGfA".to_owned()), - raw_gpp_sid: Some("2,6".to_owned()), - raw_us_privacy: Some("1YNN".to_owned()), - gpc: true, - }; - let output = signals.to_string(); - assert!( - output.contains("euconsent-v2=present (13 chars)"), - "should show tc_string length" - ); - assert!( - output.contains("__gpp=present (15 chars)"), - "should show gpp length" - ); - assert!( - output.contains("__gpp_sid=\"2,6\""), - "should show gpp_sid value" - ); - assert!( - output.contains("us_privacy=\"1YNN\""), - "should show us_privacy value" + let record = SignalRecord::new("example", SignalStatus::Decoded).with_decoded(Decoded(7)); + assert_eq!(record.decoded::(), Some(&Decoded(7))); + assert_eq!( + record.decoded::(), + None, + "asking for another type answers nothing rather than something wrong" ); - assert!(output.contains("Sec-GPC=1"), "should show Sec-GPC as 1"); + let context = ConsentContext::default().with_record(record); + assert_eq!(context.decoded::("example"), Some(&Decoded(7))); } #[test] - fn consent_context_empty_by_default() { - let ctx = ConsentContext::default(); - assert!(ctx.is_empty(), "default ConsentContext should be empty"); - } - - #[test] - fn consent_context_not_empty_with_tc_string() { - let ctx = ConsentContext { - raw_tc_string: Some("CPXx".to_owned()), - ..Default::default() - }; - assert!( - !ctx.is_empty(), - "should not be empty with raw_tc_string present" + fn a_value_marks_the_record_as_read_from_the_request() { + let read = SignalRecord::new("example", SignalStatus::Decoded).with_value("v"); + assert_eq!(read.source(), ConsentSource::Cookie); + let declared = read.with_source(ConsentSource::PolicyDefault); + assert_eq!( + declared.source(), + ConsentSource::PolicyDefault, + "a source set afterwards stands" ); - } - - #[test] - fn consent_context_not_empty_with_gpc() { - let ctx = ConsentContext { - gpc: true, - ..Default::default() - }; - assert!(!ctx.is_empty(), "should not be empty with gpc=true"); - } - - #[test] - fn us_privacy_display() { - let usp = UsPrivacy { - version: 1, - notice_given: PrivacyFlag::Yes, - opt_out_sale: PrivacyFlag::No, - lspa_covered: PrivacyFlag::NotApplicable, - }; - assert_eq!(usp.to_string(), "1YN-", "should format as 1YN-"); - } - - #[test] - fn privacy_flag_display() { - assert_eq!(PrivacyFlag::Yes.to_string(), "Y"); - assert_eq!(PrivacyFlag::No.to_string(), "N"); - assert_eq!(PrivacyFlag::NotApplicable.to_string(), "-"); - } - - #[test] - fn consent_source_default_is_none() { assert_eq!( - ConsentSource::default(), - ConsentSource::None, - "default source should be None" + SignalRecord::new("example", SignalStatus::Absent).source(), + ConsentSource::None ); } - fn make_tcf_consent() -> TcfConsent { - TcfConsent { - version: 2, - cmp_id: 1, - cmp_version: 1, - consent_screen: 1, - consent_language: "EN".to_owned(), - vendor_list_version: 42, - tcf_policy_version: 4, - created_ds: 0, - last_updated_ds: 0, - // Purposes 1, 2, 4 consented (indices 0, 1, 3) - purpose_consents: vec![ - true, true, false, true, false, false, false, false, false, false, false, false, - ], - // Purpose 7 LI (index 6) - purpose_legitimate_interests: vec![ - false, false, false, false, false, false, true, false, false, false, false, false, - ], - vendor_consents: vec![10, 32, 755], - vendor_legitimate_interests: vec![32], - special_feature_opt_ins: vec![false; 12], - } - } - #[test] - fn tcf_has_purpose_consent() { - let tcf = make_tcf_consent(); - assert!(tcf.has_purpose_consent(1), "should have Purpose 1 consent"); - assert!(tcf.has_purpose_consent(2), "should have Purpose 2 consent"); + fn emptiness_and_applicability_read_across_the_records() { + assert!(ConsentContext::default().is_empty()); + let context = ConsentContext::default() + .with_record(SignalRecord::new("a", SignalStatus::Absent).with_applies(true)) + .with_record(SignalRecord::new("b", SignalStatus::Decoded).with_value("x")); + assert!(!context.is_empty(), "a record with a value is a signal"); + assert!(context.applies(), "one record applying is enough"); assert!( - !tcf.has_purpose_consent(3), - "should not have Purpose 3 consent" + !ConsentContext::default() + .with_record(SignalRecord::new("a", SignalStatus::Absent)) + .applies() ); - assert!(tcf.has_purpose_consent(4), "should have Purpose 4 consent"); } #[test] - fn tcf_purpose_consent_out_of_range() { - let tcf = make_tcf_consent(); - assert!( - !tcf.has_purpose_consent(0), - "purpose 0 should return false (1-indexed)" + fn keep_only_drops_the_strings_nobody_vouched_for() { + let mut context = ConsentContext::default() + .with_record( + SignalRecord::new("kept", SignalStatus::Decoded) + .with_value("k") + .with_sections(vec![1]), + ) + .with_record( + SignalRecord::new("dropped", SignalStatus::Unreadable) + .with_value("d") + .with_sections(vec![2]) + .with_applies(true), + ); + context.keep_only(&[ValidSignal::new("module", "kept", "k")]); + assert_eq!(context.value("kept"), Some("k")); + assert_eq!( + context.record("kept").map(SignalRecord::sections), + Some(&[1][..]) ); - assert!( - !tcf.has_purpose_consent(99), - "out-of-range purpose should return false" + let dropped = context.record("dropped").expect("the record stays"); + assert_eq!(dropped.value(), None, "the string goes no further"); + assert!(dropped.sections().is_empty(), "nor do its sections"); + assert_eq!( + dropped.status(), + SignalStatus::Unreadable, + "what the module read stays on record" ); + assert!(dropped.applies(), "and so does what it said applies"); } #[test] - fn tcf_has_purpose_li() { - let tcf = make_tcf_consent(); - assert!( - tcf.has_purpose_li(7), - "should have Purpose 7 legitimate interest" - ); - assert!( - !tcf.has_purpose_li(1), - "should not have Purpose 1 legitimate interest" - ); + fn retain_drops_whole_records() { + let mut context = ConsentContext::default() + .with_record(SignalRecord::new("a", SignalStatus::Decoded).with_value("1")) + .with_record( + SignalRecord::new("b", SignalStatus::Decoded) + .with_value("2") + .with_source(ConsentSource::PolicyDefault), + ); + context.retain(|record| record.source() == ConsentSource::PolicyDefault); + assert!(context.record("a").is_none()); + assert_eq!(context.value("b"), Some("2")); } #[test] - fn tcf_has_vendor_consent() { - let tcf = make_tcf_consent(); - assert!( - tcf.has_vendor_consent(755), - "should have consent for vendor 755" - ); - assert!( - !tcf.has_vendor_consent(999), - "should not have consent for vendor 999" - ); + fn status_reads_as_a_word() { + assert_eq!(SignalStatus::Decoded.to_string(), "present"); + assert_eq!(SignalStatus::Unreadable.to_string(), "decode-failed"); + assert_eq!(SignalStatus::Expired.to_string(), "expired"); + assert_eq!(SignalStatus::NotDecoded.to_string(), "not-decoded"); + assert_eq!(SignalStatus::Absent.to_string(), "absent"); } #[test] - fn tcf_convenience_methods() { - let tcf = make_tcf_consent(); - assert!( - tcf.has_storage_consent(), - "should have storage consent (P1)" - ); - assert!( - tcf.has_basic_ads_consent(), - "should have basic ads consent (P2)" - ); - assert!( - tcf.has_personalized_ads_consent(), - "should have personalized ads consent (P4)" - ); + fn debug_shows_the_length_of_a_signal_and_never_its_text() { + let record = SignalRecord::new("example", SignalStatus::Decoded).with_value("secret"); + let shown = format!("{record:?}"); + assert!(shown.contains("value: Some(6)"), "{shown}"); + assert!(!shown.contains("secret"), "{shown}"); } } diff --git a/crates/trusted-server-core/src/consent/us_privacy.rs b/crates/trusted-server-core/src/consent/us_privacy.rs deleted file mode 100644 index 5537ab24c2..0000000000 --- a/crates/trusted-server-core/src/consent/us_privacy.rs +++ /dev/null @@ -1,164 +0,0 @@ -//! US Privacy string decoder. -//! -//! Parses the legacy 4-character IAB US Privacy string (CCPA format). -//! -//! # Format -//! -//! The string is exactly 4 characters: `VNOL` where: -//! - **V** (version): always `1` -//! - **N** (notice): `Y` = given, `N` = not given, `-` = N/A -//! - **O** (opt-out of sale): `Y` = opted out, `N` = not opted out, `-` = N/A -//! - **L** (LSPA covered): `Y` = yes, `N` = no, `-` = N/A -//! -//! # References -//! -//! - [IAB US Privacy String specification](https://github.com/InteractiveAdvertisingBureau/USPrivacy/blob/master/CCPA/US%20Privacy%20String.md) - -use error_stack::{Report, ResultExt as _}; - -use super::types::{ConsentDecodeError, PrivacyFlag, UsPrivacy}; - -/// Decodes a US Privacy string into a [`UsPrivacy`] struct. -/// -/// # Errors -/// -/// - [`ConsentDecodeError::InvalidUsPrivacy`] if the string is not exactly -/// 4 characters, has an unsupported version, or contains invalid flag values. -pub fn decode_us_privacy(s: &str) -> Result> { - let chars: Vec = s.chars().collect(); - - if chars.len() != 4 { - return Err(Report::new(ConsentDecodeError::InvalidUsPrivacy { - reason: format!("expected 4 characters, got {}", chars.len()), - })); - } - - let version = match chars[0] { - '1' => 1_u8, - other => { - return Err(Report::new(ConsentDecodeError::InvalidUsPrivacy { - reason: format!("unsupported version '{other}', expected '1'"), - })); - } - }; - - let notice_given = - parse_flag(chars[1]).change_context(ConsentDecodeError::InvalidUsPrivacy { - reason: format!("invalid notice flag '{}'", chars[1]), - })?; - - let opt_out_sale = - parse_flag(chars[2]).change_context(ConsentDecodeError::InvalidUsPrivacy { - reason: format!("invalid opt-out flag '{}'", chars[2]), - })?; - - let lspa_covered = - parse_flag(chars[3]).change_context(ConsentDecodeError::InvalidUsPrivacy { - reason: format!("invalid LSPA flag '{}'", chars[3]), - })?; - - Ok(UsPrivacy { - version, - notice_given, - opt_out_sale, - lspa_covered, - }) -} - -/// Parses a single US Privacy flag character. -fn parse_flag(c: char) -> Result> { - match c { - 'Y' | 'y' => Ok(PrivacyFlag::Yes), - 'N' | 'n' => Ok(PrivacyFlag::No), - '-' => Ok(PrivacyFlag::NotApplicable), - other => Err(Report::new(ConsentDecodeError::InvalidUsPrivacy { - reason: format!("invalid flag character '{other}'"), - })), - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn decodes_standard_string() { - let result = decode_us_privacy("1YNN").expect("should decode 1YNN"); - assert_eq!(result.version, 1); - assert_eq!(result.notice_given, PrivacyFlag::Yes); - assert_eq!(result.opt_out_sale, PrivacyFlag::No); - assert_eq!(result.lspa_covered, PrivacyFlag::No); - } - - #[test] - fn decodes_all_yes() { - let result = decode_us_privacy("1YYY").expect("should decode 1YYY"); - assert_eq!(result.notice_given, PrivacyFlag::Yes); - assert_eq!(result.opt_out_sale, PrivacyFlag::Yes); - assert_eq!(result.lspa_covered, PrivacyFlag::Yes); - } - - #[test] - fn decodes_all_not_applicable() { - let result = decode_us_privacy("1---").expect("should decode 1---"); - assert_eq!(result.notice_given, PrivacyFlag::NotApplicable); - assert_eq!(result.opt_out_sale, PrivacyFlag::NotApplicable); - assert_eq!(result.lspa_covered, PrivacyFlag::NotApplicable); - } - - #[test] - fn decodes_mixed_flags() { - let result = decode_us_privacy("1NYN").expect("should decode 1NYN"); - assert_eq!(result.notice_given, PrivacyFlag::No); - assert_eq!(result.opt_out_sale, PrivacyFlag::Yes); - assert_eq!(result.lspa_covered, PrivacyFlag::No); - } - - #[test] - fn roundtrips_through_display() { - let result = decode_us_privacy("1YNN").expect("should decode"); - assert_eq!( - result.to_string(), - "1YNN", - "should roundtrip through Display" - ); - } - - #[test] - fn rejects_too_short() { - let result = decode_us_privacy("1YN"); - assert!(result.is_err(), "should reject 3-char string"); - } - - #[test] - fn rejects_too_long() { - let result = decode_us_privacy("1YNNN"); - assert!(result.is_err(), "should reject 5-char string"); - } - - #[test] - fn rejects_empty() { - let result = decode_us_privacy(""); - assert!(result.is_err(), "should reject empty string"); - } - - #[test] - fn rejects_bad_version() { - let result = decode_us_privacy("2YNN"); - assert!(result.is_err(), "should reject version 2"); - } - - #[test] - fn rejects_invalid_flag() { - let result = decode_us_privacy("1XNN"); - assert!(result.is_err(), "should reject invalid flag 'X'"); - } - - #[test] - fn accepts_lowercase_flags() { - let result = decode_us_privacy("1ynn").expect("should accept lowercase"); - assert_eq!(result.notice_given, PrivacyFlag::Yes); - assert_eq!(result.opt_out_sale, PrivacyFlag::No); - assert_eq!(result.lspa_covered, PrivacyFlag::No); - } -} diff --git a/crates/trusted-server-core/src/consent_config.rs b/crates/trusted-server-core/src/consent_config.rs index 530aa62b43..b217974bd5 100644 --- a/crates/trusted-server-core/src/consent_config.rs +++ b/crates/trusted-server-core/src/consent_config.rs @@ -1,368 +1,174 @@ -//! Consent forwarding configuration types. +//! The `[consent]` settings table, read by the permission signal modules. //! -//! Defines the `[consent]` TOML section and its nested sub-sections for -//! controlling how Trusted Server interprets, validates, and forwards -//! privacy consent signals to advertising partners. +//! Core reads one key, `mode`, which says whether the modules decode the +//! signals they read or leave them as received. Every other key is the +//! business of the module for the scheme it concerns, which reads the table +//! into a type of its own through [`ConsentConfig::read`]. -use serde::{Deserialize, Serialize}; +use error_stack::Report; +use serde::de::{DeserializeOwned, Error as _}; +use serde::{Deserialize, Deserializer, Serialize}; +use serde_json::{Map, Value}; -/// TCF spec recommends 13 months (≈395 days). -const MAX_CONSENT_AGE_DAYS: u32 = 395; +use crate::error::TrustedServerError; -/// How many days newer one string must be to win under the `newest` strategy. -const FRESHNESS_THRESHOLD_DAYS: u32 = 30; +/// A key the table once held and refuses now, so a document still carrying +/// it is told rather than silently kept. +const REMOVED_KEYS: &[&str] = &["consent_store"]; -/// Top-level consent configuration (`[consent]` in TOML). -#[derive(Debug, Clone, Deserialize, Serialize)] -#[serde(deny_unknown_fields)] +/// The `[consent]` table. +#[derive(Debug, Clone, Default, Serialize)] pub struct ConsentConfig { - /// Operating mode for consent handling. - /// - /// - `"interpreter"` — decode consent strings and forward structured data - /// (recommended; enables observability and enforcement). - /// - `"proxy"` — forward raw strings without decoding. - #[serde(default = "default_consent_mode")] - pub mode: ConsentMode, - - /// Whether to check consent expiration based on TCF timestamps. - #[serde(default = "default_true")] - pub check_expiration: bool, - - /// Maximum consent age in days before it is considered expired. - /// - /// TCF spec recommends 13 months (≈395 days). Valid range: 1–3650. - /// Values outside this range are clamped during [`validate`](Self::validate). - #[serde(default = "default_max_consent_age_days")] - pub max_consent_age_days: u32, - - /// Defaults for constructing a US Privacy string when only `Sec-GPC` - /// is present and no explicit `us_privacy` cookie exists. - #[serde(default)] - pub us_privacy_defaults: UsPrivacyDefaultsConfig, - - /// How to resolve conflicts when both TCF and GPP strings are present - /// but disagree on consent status. + /// Whether the signal modules decode what they read. #[serde(default)] - pub conflict_resolution: ConflictResolutionConfig, + pub mode: ConsentMode, + /// Every other key, as written, for the modules to read. + #[serde(flatten)] + settings: Map, } -impl Default for ConsentConfig { - fn default() -> Self { - Self { - mode: ConsentMode::Interpreter, - check_expiration: true, - max_consent_age_days: MAX_CONSENT_AGE_DAYS, - us_privacy_defaults: UsPrivacyDefaultsConfig::default(), - conflict_resolution: ConflictResolutionConfig::default(), +/// The table as written, before the removed keys are refused. +#[derive(Deserialize)] +struct WrittenConsentConfig { + #[serde(default)] + mode: ConsentMode, + #[serde(flatten)] + settings: Map, +} + +impl<'de> Deserialize<'de> for ConsentConfig { + fn deserialize>(deserializer: D) -> Result { + let written = WrittenConsentConfig::deserialize(deserializer)?; + if let Some(removed) = REMOVED_KEYS + .iter() + .find(|key| written.settings.contains_key(**key)) + { + return Err(D::Error::custom(format!( + "[consent] {removed} was removed and is no longer accepted. Remove the line, because consent is read from the request on every request and kept with the identity graph, never in a store of its own" + ))); } + Ok(Self { + mode: written.mode, + settings: written.settings, + }) } } impl ConsentConfig { - /// Clamps configuration values to valid ranges and logs warnings. + /// The table read as `T`, `mode` included. /// - /// Call after deserialization to catch out-of-range values early. - pub fn validate(&mut self) { - let clamped = self.max_consent_age_days.clamp(1, 3650); - if clamped != self.max_consent_age_days { - log::warn!( - "max_consent_age_days={} is outside the valid range 1\u{2013}3650; clamped to {clamped}", - self.max_consent_age_days - ); - self.max_consent_age_days = clamped; - } - } -} - -// --------------------------------------------------------------------------- -// Consent mode -// --------------------------------------------------------------------------- - -/// Operating mode for the consent pipeline. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] -#[serde(rename_all = "lowercase")] -pub enum ConsentMode { - /// Decode consent strings and forward structured data. - Interpreter, - /// Forward raw strings without decoding. - Proxy, -} - -// --------------------------------------------------------------------------- -// Consent forwarding mode (per-partner) -// --------------------------------------------------------------------------- - -/// How consent signals are forwarded to a specific partner integration. -/// -/// Controls whether consent travels through the `OpenRTB` body, raw `Cookie` -/// headers, or both. The default (`Both`) preserves backward compatibility. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, Default)] -#[serde(rename_all = "snake_case")] -pub enum ConsentForwardingMode { - /// Forward consent in the `OpenRTB` body only; strip consent cookies. - OpenrtbOnly, - /// Forward consent cookies only; omit consent fields from the body. - CookiesOnly, - /// Forward consent in both cookies and body (default). - #[default] - Both, -} - -impl ConsentForwardingMode { - /// Whether consent cookies should be stripped from forwarded requests. + /// A module names the keys it reads in `T` and leaves the rest to the + /// other modules, so `T` must accept keys it does not name. /// - /// Returns `true` for [`OpenrtbOnly`](Self::OpenrtbOnly) since consent - /// travels exclusively through the request body in that mode. - #[must_use] - pub const fn strips_consent_cookies(self) -> bool { - matches!(self, Self::OpenrtbOnly) - } - - /// Whether consent fields should be included in the request body. + /// # Errors /// - /// Returns `true` for [`OpenrtbOnly`](Self::OpenrtbOnly) and - /// [`Both`](Self::Both); `false` for [`CookiesOnly`](Self::CookiesOnly). - #[must_use] - pub const fn includes_body_consent(self) -> bool { - !matches!(self, Self::CookiesOnly) - } -} - -// --------------------------------------------------------------------------- -// US Privacy defaults (GPC handling) -// --------------------------------------------------------------------------- - -/// Publisher-configurable defaults for constructing a US Privacy string -/// when only the `Sec-GPC` header is present (`[consent.us_privacy_defaults]`). -/// -/// These reflect the publisher's actual compliance posture — they are -/// **publisher policy**, not protocol requirements. -#[derive(Debug, Clone, Deserialize, Serialize)] -#[serde(deny_unknown_fields)] -pub struct UsPrivacyDefaultsConfig { - /// Whether the publisher has actually shown a CCPA notice to the user. - #[serde(default = "default_true")] - pub notice_given: bool, - - /// Whether the publisher is subject to the Limited Service Provider - /// Agreement. - #[serde(default)] - pub lspa_covered: bool, - - /// Whether a `Sec-GPC: 1` header should be interpreted as an opt-out - /// of sale. - #[serde(default = "default_true")] - pub gpc_implies_optout: bool, -} - -impl Default for UsPrivacyDefaultsConfig { - fn default() -> Self { - Self { - notice_given: true, - lspa_covered: false, - gpc_implies_optout: true, - } + /// A key `T` names whose value is not of the type `T` expects. + pub fn read(&self) -> Result> { + let mut table = self.settings.clone(); + table.insert( + "mode".to_owned(), + serde_json::to_value(self.mode).unwrap_or_default(), + ); + serde_json::from_value(Value::Object(table)).map_err(|error| { + Report::new(TrustedServerError::Configuration { + message: format!("[consent] could not be read: {error}"), + }) + }) } -} - -// --------------------------------------------------------------------------- -// Conflict resolution -// --------------------------------------------------------------------------- -/// How to resolve disagreements between GPP and TC String when both are -/// present (`[consent.conflict_resolution]`). -#[derive(Debug, Clone, Deserialize, Serialize)] -#[serde(deny_unknown_fields)] -pub struct ConflictResolutionConfig { - /// Resolution strategy. - #[serde(default = "default_conflict_mode")] - pub mode: ConflictMode, - - /// How many days newer one string must be to win under the `newest` - /// strategy. - #[serde(default = "default_freshness_threshold_days")] - pub freshness_threshold_days: u32, -} - -impl Default for ConflictResolutionConfig { - fn default() -> Self { - Self { - mode: ConflictMode::Restrictive, - freshness_threshold_days: FRESHNESS_THRESHOLD_DAYS, - } + /// Whether the table carries `key`. + #[must_use] + pub fn has(&self, key: &str) -> bool { + self.settings.contains_key(key) } } -/// Conflict resolution strategy. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +/// Whether the signal modules decode what they read. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)] #[serde(rename_all = "lowercase")] -pub enum ConflictMode { - /// Deny consent when signals disagree (most restrictive). - Restrictive, - /// Use the newer signal based on timestamps. - Newest, - /// Grant consent when signals disagree (least restrictive). - Permissive, -} - -// --------------------------------------------------------------------------- -// Serde default value functions -// --------------------------------------------------------------------------- - -const fn default_consent_mode() -> ConsentMode { - ConsentMode::Interpreter -} - -const fn default_true() -> bool { - true -} - -const fn default_max_consent_age_days() -> u32 { - MAX_CONSENT_AGE_DAYS -} - -const fn default_conflict_mode() -> ConflictMode { - ConflictMode::Restrictive -} - -const fn default_freshness_threshold_days() -> u32 { - FRESHNESS_THRESHOLD_DAYS +pub enum ConsentMode { + /// Decode each signal and act on what it says. + #[default] + Interpreter, + /// Leave each signal as received and forward it without reading it. + Proxy, } -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - #[cfg(test)] mod tests { - use super::{ConflictMode, ConsentConfig, ConsentMode}; + use super::*; - #[test] - fn default_config_uses_interpreter_mode() { - let config = ConsentConfig::default(); - assert_eq!( - config.mode, - ConsentMode::Interpreter, - "default mode should be interpreter" - ); + #[derive(Debug, Default, Deserialize, PartialEq)] + #[serde(default)] + struct ExampleSettings { + mode: ConsentMode, + example_days: u32, } #[test] - fn default_config_enables_expiration_checking() { - let config = ConsentConfig::default(); - assert!( - config.check_expiration, - "expiration checking should be enabled by default" - ); + fn an_empty_table_decodes_and_reads_as_defaults() { + let config: ConsentConfig = + serde_json::from_str("{}").expect("should deserialize an empty table"); + assert_eq!(config.mode, ConsentMode::Interpreter); assert_eq!( - config.max_consent_age_days, 395, - "default max age should be 395 days" + config.read::().expect("should read"), + ExampleSettings::default() ); } #[test] - fn default_us_privacy_defaults_reflect_common_posture() { - let config = ConsentConfig::default(); - let defaults = &config.us_privacy_defaults; - assert!(defaults.notice_given, "notice_given should default to true"); - assert!( - !defaults.lspa_covered, - "lspa_covered should default to false" - ); - assert!( - defaults.gpc_implies_optout, - "gpc_implies_optout should default to true" - ); - } - - #[test] - fn default_conflict_resolution_is_restrictive() { - let config = ConsentConfig::default(); - assert_eq!( - config.conflict_resolution.mode, - ConflictMode::Restrictive, - "default conflict mode should be restrictive" - ); + fn a_module_reads_its_keys_and_leaves_the_others() { + let config: ConsentConfig = serde_json::from_value(serde_json::json!({ + "mode": "proxy", + "example_days": 7, + "another": { "key": true } + })) + .expect("should deserialize the table"); + assert_eq!(config.mode, ConsentMode::Proxy, "should parse proxy mode"); + assert!(config.has("another") && !config.has("missing")); assert_eq!( - config.conflict_resolution.freshness_threshold_days, 30, - "default freshness threshold should be 30 days" + config.read::().expect("should read"), + ExampleSettings { + mode: ConsentMode::Proxy, + example_days: 7, + } ); } #[test] - fn deserializes_from_empty_json() { + fn a_key_of_the_wrong_type_is_refused_naming_the_table() { let config: ConsentConfig = - serde_json::from_str("{}").expect("should deserialize empty JSON with defaults"); - assert_eq!(config.mode, ConsentMode::Interpreter); - assert!(config.check_expiration); + serde_json::from_value(serde_json::json!({ "example_days": "seven" })) + .expect("should deserialize the table"); + let error = config + .read::() + .expect_err("should refuse a string where a number is expected"); + assert!(format!("{error:?}").contains("[consent] could not be read")); } #[test] - fn deserializes_proxy_mode() { - let config: ConsentConfig = - serde_json::from_str(r#"{"mode": "proxy"}"#).expect("should deserialize proxy mode"); - assert_eq!(config.mode, ConsentMode::Proxy, "should parse proxy mode"); - } - - #[test] - fn consent_forwarding_mode_strips_cookies_only_for_openrtb() { - use super::ConsentForwardingMode; - - assert!( - ConsentForwardingMode::OpenrtbOnly.strips_consent_cookies(), - "openrtb_only should strip consent cookies" - ); - assert!( - !ConsentForwardingMode::CookiesOnly.strips_consent_cookies(), - "cookies_only should not strip consent cookies" - ); + fn a_removed_key_is_refused_by_name() { + let error = serde_json::from_value::(serde_json::json!({ + "consent_store": "legacy-consent-store" + })) + .expect_err("should refuse the removed key"); assert!( - !ConsentForwardingMode::Both.strips_consent_cookies(), - "both should not strip consent cookies" + error.to_string().contains("consent_store"), + "should name the removed key: {error}" ); } #[test] - fn consent_forwarding_mode_includes_body_consent_except_cookies_only() { - use super::ConsentForwardingMode; - - assert!( - ConsentForwardingMode::OpenrtbOnly.includes_body_consent(), - "openrtb_only should include body consent" - ); - assert!( - !ConsentForwardingMode::CookiesOnly.includes_body_consent(), - "cookies_only should not include body consent" - ); - assert!( - ConsentForwardingMode::Both.includes_body_consent(), - "both should include body consent" + fn the_table_serializes_as_written() { + let config: ConsentConfig = serde_json::from_value(serde_json::json!({ + "mode": "proxy", + "example_days": 7 + })) + .expect("should deserialize the table"); + let written = serde_json::to_value(&config).expect("should serialize"); + assert_eq!( + written, + serde_json::json!({ "mode": "proxy", "example_days": 7 }) ); } - - #[test] - fn deserializes_full_config() { - let json = serde_json::json!({ - "mode": "interpreter", - "check_expiration": false, - "max_consent_age_days": 180, - "us_privacy_defaults": { - "notice_given": false, - "lspa_covered": true, - "gpc_implies_optout": false - }, - "conflict_resolution": { - "mode": "newest", - "freshness_threshold_days": 15 - } - }); - let config: ConsentConfig = - serde_json::from_value(json).expect("should deserialize full config"); - assert!(!config.check_expiration); - assert_eq!(config.max_consent_age_days, 180); - assert!(!config.us_privacy_defaults.notice_given); - assert!(config.us_privacy_defaults.lspa_covered); - assert_eq!(config.conflict_resolution.mode, ConflictMode::Newest); - assert_eq!(config.conflict_resolution.freshness_threshold_days, 15); - } } diff --git a/crates/trusted-server-core/src/constants.rs b/crates/trusted-server-core/src/constants.rs index 34921d3db8..588b4bdd73 100644 --- a/crates/trusted-server-core/src/constants.rs +++ b/crates/trusted-server-core/src/constants.rs @@ -151,15 +151,6 @@ const fn join_internal_headers() -> [&'static str; INTERNAL_HEADER_COUNT] { /// in `const` context. pub const INTERNAL_HEADERS: &[&str] = &join_internal_headers(); -// Consent-related cookie names -pub const COOKIE_EUCONSENT_V2: &str = "euconsent-v2"; -pub const COOKIE_GPP: &str = "__gpp"; -pub const COOKIE_GPP_SID: &str = "__gpp_sid"; -pub const COOKIE_US_PRIVACY: &str = "us_privacy"; - -// Consent-related header names -pub const HEADER_SEC_GPC: HeaderName = HeaderName::from_static("sec-gpc"); - #[cfg(test)] mod tests { use super::*; diff --git a/crates/trusted-server-core/src/cookies.rs b/crates/trusted-server-core/src/cookies.rs index 3fe6719ef7..e778defebf 100644 --- a/crates/trusted-server-core/src/cookies.rs +++ b/crates/trusted-server-core/src/cookies.rs @@ -11,21 +11,8 @@ use error_stack::{Report, ResultExt as _}; use http::Request; use http::header; -use crate::constants::{COOKIE_EUCONSENT_V2, COOKIE_GPP, COOKIE_GPP_SID, COOKIE_US_PRIVACY}; use crate::error::TrustedServerError; -/// Cookie names carrying privacy consent signals. -/// -/// Used by [`strip_cookies`] to remove consent signals from a `Cookie` header -/// before forwarding requests to partners that receive consent through the -/// `OpenRTB` body instead. -pub const CONSENT_COOKIE_NAMES: &[&str] = &[ - COOKIE_EUCONSENT_V2, - COOKIE_GPP, - COOKIE_GPP_SID, - COOKIE_US_PRIVACY, -]; - /// Parses a cookie string into a [`CookieJar`]. /// /// Returns an empty jar if the cookie string is unparseable. @@ -104,44 +91,6 @@ pub fn strip_cookies(cookie_header: &str, cookie_names: &[&str]) -> String { .join("; ") } -/// Copies the `Cookie` header from one request to another, optionally -/// stripping consent cookies. -/// -/// When `strip_consent` is `true`, cookies listed in [`CONSENT_COOKIE_NAMES`] -/// are removed before forwarding. If stripping leaves no cookies or yields an -/// invalid header value, the stripped header is omitted. Non-UTF-8 cookie -/// headers are forwarded unchanged. Existing `Cookie` headers on `to` are -/// preserved; source values are appended after them. -pub fn forward_cookie_header( - from: &Request, - to: &mut Request, - strip_consent: bool, -) { - for cookie_value in from.headers().get_all(header::COOKIE) { - if !strip_consent { - to.headers_mut() - .append(header::COOKIE, cookie_value.clone()); - continue; - } - - match cookie_value.to_str() { - Ok(s) => { - let stripped = strip_cookies(s, CONSENT_COOKIE_NAMES); - if !stripped.is_empty() - && let Ok(value) = http::HeaderValue::from_str(&stripped) - { - to.headers_mut().append(header::COOKIE, value); - } - } - Err(_) => { - // Non-UTF-8 Cookie header — forward as-is. - to.headers_mut() - .append(header::COOKIE, cookie_value.clone()); - } - } - } -} - #[cfg(test)] mod tests { use http::HeaderValue; @@ -288,167 +237,46 @@ mod tests { assert_eq!(extract_cookie_value(&req, "session"), None); } - // --------------------------------------------------------------- - // forward_cookie_header tests - // --------------------------------------------------------------- - - #[test] - fn test_forward_cookie_header_strips_consent() { - let from = build_request(Some("euconsent-v2=BOE; session=abc123; us_privacy=1YNN")); - let mut to = build_request(None); - - forward_cookie_header(&from, &mut to, true); - - let forwarded = to - .headers() - .get(header::COOKIE) - .and_then(|v| v.to_str().ok()) - .unwrap_or(""); - assert!( - !forwarded.contains("euconsent-v2"), - "should strip consent cookie" - ); - assert!( - forwarded.contains("session=abc123"), - "should keep non-consent cookie" - ); - } - - #[test] - fn test_forward_cookie_header_strip_all_leaves_header_absent() { - let from = build_request(Some("euconsent-v2=BOE; __gpp=DBAC")); - let mut to = build_request(None); - - forward_cookie_header(&from, &mut to, true); - - assert!( - to.headers().get(header::COOKIE).is_none(), - "should omit Cookie header when all cookies are stripped" - ); - } - - #[test] - fn test_forward_cookie_header_no_strip_passes_all() { - let from = build_request(Some("euconsent-v2=BOE; session=abc123")); - let mut to = build_request(None); - - forward_cookie_header(&from, &mut to, false); - - let forwarded = to - .headers() - .get(header::COOKIE) - .and_then(|v| v.to_str().ok()) - .unwrap_or(""); - assert!( - forwarded.contains("euconsent-v2"), - "should forward consent cookie when not stripping" - ); - assert!( - forwarded.contains("session=abc123"), - "should forward non-consent cookie" - ); - } - - #[test] - fn test_forward_cookie_header_non_utf8_forwarded_unchanged() { - let non_utf8 = http::HeaderValue::from_bytes(b"\xff\xfe=value") - .expect("should build non-UTF-8 header value"); - let mut from = build_request(None); - from.headers_mut().append(header::COOKIE, non_utf8); - let mut to = build_request(None); - - forward_cookie_header(&from, &mut to, true); - - let forwarded = to.headers().get(header::COOKIE); - assert!( - forwarded.is_some(), - "should forward non-UTF-8 Cookie header unchanged" - ); - assert_eq!( - forwarded.expect("should have cookie header").as_bytes(), - b"\xff\xfe=value", - "should preserve raw bytes for non-UTF-8 cookie" - ); - } - - #[test] - fn test_forward_cookie_header_multiple_cookie_headers_appended() { - let mut from = build_request(Some("session=abc123")); - from.headers_mut().append( - header::COOKIE, - "theme=dark".parse().expect("should parse header value"), - ); - let mut to = build_request(None); - - forward_cookie_header(&from, &mut to, false); - - let all_cookies: Vec<_> = to - .headers() - .get_all(header::COOKIE) - .iter() - .filter_map(|v| v.to_str().ok()) - .collect(); - assert_eq!(all_cookies.len(), 2, "should append all Cookie headers"); - assert!(all_cookies.iter().any(|v| v.contains("session=abc123"))); - assert!(all_cookies.iter().any(|v| v.contains("theme=dark"))); - } - - #[test] - fn test_forward_cookie_header_appends_to_existing_target_cookies() { - let from = build_request(Some("session=abc123")); - let mut to = build_request(Some("template=existing")); - - forward_cookie_header(&from, &mut to, false); - - let all_cookies: Vec<_> = to - .headers() - .get_all(header::COOKIE) - .iter() - .filter_map(|v| v.to_str().ok()) - .collect(); - assert_eq!( - all_cookies, - vec!["template=existing", "session=abc123"], - "should preserve existing target cookies and append source cookies after them" - ); - } - // --------------------------------------------------------------- // strip_cookies tests // --------------------------------------------------------------- + /// The names a forwarder strips, standing in for the cookies a + /// signal scheme travels in. + const STRIPPED: &[&str] = &["pref-a", "pref-b", "pref-b-sid", "pref-c"]; + #[test] fn test_strip_cookies_removes_consent() { - let header = "euconsent-v2=BOE; __gpp=DBAC; session=abc123; us_privacy=1YNN"; - let stripped = strip_cookies(header, CONSENT_COOKIE_NAMES); + let header = "pref-a=BOE; pref-b=DBAC; session=abc123; pref-c=1YNN"; + let stripped = strip_cookies(header, STRIPPED); assert_eq!(stripped, "session=abc123"); } #[test] fn test_strip_cookies_preserves_non_consent() { let header = "session=abc123; theme=dark"; - let stripped = strip_cookies(header, CONSENT_COOKIE_NAMES); + let stripped = strip_cookies(header, STRIPPED); assert_eq!(stripped, "session=abc123; theme=dark"); } #[test] fn test_strip_cookies_empty_input() { - let stripped = strip_cookies("", CONSENT_COOKIE_NAMES); + let stripped = strip_cookies("", STRIPPED); assert_eq!(stripped, ""); } #[test] fn test_strip_cookies_all_stripped() { - let header = "euconsent-v2=BOE; __gpp=DBAC; __gpp_sid=2,6; us_privacy=1YNN"; - let stripped = strip_cookies(header, CONSENT_COOKIE_NAMES); + let header = "pref-a=BOE; pref-b=DBAC; pref-b-sid=2,6; pref-c=1YNN"; + let stripped = strip_cookies(header, STRIPPED); assert_eq!(stripped, ""); } #[test] fn test_strip_cookies_with_complex_values() { // Cookie values can contain '=' characters. - let header = "euconsent-v2=BOE=xyz; session=abc=123=def"; - let stripped = strip_cookies(header, CONSENT_COOKIE_NAMES); + let header = "pref-a=BOE=xyz; session=abc=123=def"; + let stripped = strip_cookies(header, STRIPPED); assert_eq!(stripped, "session=abc=123=def"); } } diff --git a/crates/trusted-server-core/src/ec/consent.rs b/crates/trusted-server-core/src/ec/consent.rs index 430a210713..20164c01b9 100644 --- a/crates/trusted-server-core/src/ec/consent.rs +++ b/crates/trusted-server-core/src/ec/consent.rs @@ -18,6 +18,8 @@ use std::sync::Arc; use crate::consent::ConsentContext; use crate::consent::jurisdiction::Jurisdiction; +#[cfg(test)] +use crate::consent::{SignalRecord, SignalStatus}; use crate::evidence::{OwnedRequestInfo, RequestInfo}; use crate::module_context::ModuleContext; use crate::permission_signal::{self, PermissionSignalModule}; @@ -420,17 +422,19 @@ mod tests { // baseline stands whatever the request carries. // ------------------------------------------------------------------ + /// A context carrying a record of `scheme` in `status` with `value`. + fn carrying(scheme: &'static str, status: SignalStatus, value: &str) -> ConsentContext { + ConsentContext::default().with_record(SignalRecord::new(scheme, status).with_value(value)) + } + #[test] fn core_answers_nothing_ahead_of_the_modules() { - let consent = ConsentContext { - raw_tc_string: Some("this is not a TC string".to_owned()), - ..ConsentContext::default() - }; + let consent = carrying("example", SignalStatus::Unreadable, "this is not a record"); let geo = us_ca_geo(); let state = assembled(&consent, GeoStatus::Located(&geo), &[]); assert!( state.is_set(Permission::StoreOnDevice), - "with no module configured, what a TC string says or fails to say is nobody's \ + "with no module configured, what a record says or fails to say is nobody's \ to answer, so the baseline stands" ); assert!( @@ -439,16 +443,15 @@ mod tests { ); } - /// A module that vouches for whatever TC string the request carries, - /// standing in for a scheme that decoded it. + /// A module that vouches for whatever string of its scheme the request + /// carries, standing in for a scheme that decoded it. struct VouchingForTcf; impl VouchingForTcf { fn vouch(&self, consent: &ConsentContext) -> Option { consent - .raw_tc_string - .as_deref() - .map(|raw| ValidSignal::new("vouching", "tcf", raw)) + .value("example") + .map(|raw| ValidSignal::new("vouching", "example", raw)) } } @@ -516,46 +519,41 @@ mod tests { #[test] fn the_assembled_state_carries_the_signals_the_modules_vouched_for() { - let consent = ConsentContext { - raw_tc_string: Some("CPxyz".to_owned()), - ..ConsentContext::default() - }; + let consent = carrying("example", SignalStatus::Decoded, "CPxyz"); let modules: Vec> = vec![Arc::new(VouchingForTcf)]; let state = assembled(&consent, GeoStatus::NoLocation, &modules); assert_eq!( state.signals(), - &[ValidSignal::new("vouching", "tcf", "CPxyz")], + &[ValidSignal::new("vouching", "example", "CPxyz")], "should carry the signal as received, attributed to the module that read it" ); } #[test] fn the_context_keeps_only_what_was_vouched_for() { - let mut consent = ConsentContext { - raw_tc_string: Some("CPxyz".to_owned()), - raw_gpp_string: Some("not a GPP string".to_owned()), - gpp_section_ids: Some(vec![7]), - raw_us_privacy: Some("1YNN".to_owned()), - ..ConsentContext::default() - }; - consent.keep_only(&[ValidSignal::new("vouching", "tcf", "CPxyz")]); - assert_eq!(consent.raw_tc_string.as_deref(), Some("CPxyz")); + let mut consent = carrying("example", SignalStatus::Decoded, "CPxyz") + .with_record( + SignalRecord::new("sectioned", SignalStatus::Unreadable) + .with_value("not a readable string") + .with_sections(vec![7]), + ) + .with_record(SignalRecord::new("other", SignalStatus::Decoded).with_value("1YNN")); + consent.keep_only(&[ValidSignal::new("vouching", "example", "CPxyz")]); + assert_eq!(consent.value("example"), Some("CPxyz")); + let sectioned = consent.record("sectioned").expect("the record stays"); assert!( - consent.raw_gpp_string.is_none() && consent.gpp_section_ids.is_none(), - "a GPP string nobody vouched for goes no further, with its section ids" + sectioned.value().is_none() && sectioned.sections().is_empty(), + "a string nobody vouched for goes no further, with its sections" ); assert!( - consent.raw_us_privacy.is_none(), - "a US Privacy string nobody vouched for goes no further" + consent.value("other").is_none(), + "nor does any other string nobody vouched for" ); } #[test] fn an_unreadable_record_is_not_a_withdrawal() { - let consent = ConsentContext { - raw_tc_string: Some("not-a-tc-string".to_owned()), - ..ConsentContext::default() - }; + let consent = carrying("example", SignalStatus::Unreadable, "not-a-record"); let state = assembled(&consent, GeoStatus::NoLocation, &granting()); assert!( !state.storage_withdrawn(), diff --git a/crates/trusted-server-core/src/ec/finalize.rs b/crates/trusted-server-core/src/ec/finalize.rs index 46bf43290e..f446afa4c6 100644 --- a/crates/trusted-server-core/src/ec/finalize.rs +++ b/crates/trusted-server-core/src/ec/finalize.rs @@ -30,7 +30,7 @@ use super::{EcKvSnapshot, EidSyncSource, current_timestamp, log_id}; /// ingestion, and cookie writes for new EC generation. /// /// When the request carries an explicit withdrawal signal (a storage opt-out or -/// a TCF record refusing storage) and the client presented a cookie, the browser +/// a record refusing storage) and the client presented a cookie, the browser /// response clears the EC cookie immediately and the EC identity-graph KV /// tombstone is the authoritative revocation marker. A request that is merely /// not permitted (pre-consent or fail-closed) strips EC response headers but @@ -133,7 +133,7 @@ pub async fn ec_finalize_response( // was never anything to write (an unconfigured registry, no sync // source, or no EID cookies/body this request — the case // `finalize_not_read_snapshot_does_not_rotate` covers). But when - // consent gating (e.g. TCF Purpose 4 denial) is what emptied + // permission gating (the personalised-ad permission unset) is what emptied // `updates`, the request *did* have EID data, and orphan recovery // below still needs an actual `Missing` read to detect an // orphaned cookie. An unread `NotRead` snapshot proves nothing, so @@ -621,7 +621,7 @@ mod tests { use super::*; use crate::consent::jurisdiction::Jurisdiction; - use crate::consent::types::{ConsentContext, ConsentSource}; + use crate::consent::types::ConsentContext; use crate::ec::tests::{CANONICAL_COOKIE_VALUE, CANONICAL_KV_KEY}; use crate::openrtb::{Eid, Uid}; use crate::redacted::Redacted; @@ -661,7 +661,6 @@ mod tests { ) -> EcContext { let consent = ConsentContext { jurisdiction, - source: ConsentSource::Cookie, ..Default::default() }; @@ -856,13 +855,11 @@ mod tests { // identity this deployment never issued must not put a row in the // identity graph. let ec_id = sample_ec_id("zz9999"); - // A TCF record refusing storage under the requires-signal floor is - // the withdrawal trigger. The TCF module answers that at assembly, - // and core links no module, so the answer is stated here. + // A record refusing storage under the requires-signal floor is the + // withdrawal trigger. The module for its scheme answers that at + // assembly, and core links no module, so the answer is stated here. let consent = ConsentContext { jurisdiction: Jurisdiction::Gdpr, - tcf: Some(refusing_tcf()), - source: ConsentSource::Cookie, ..Default::default() }; let mut ec_context = @@ -903,13 +900,11 @@ mod tests { async fn finalize_withdrawal_tombstones_a_held_identity() { let settings = create_test_settings(); let ec_id = sample_ec_id("held01"); - // A TCF record refusing storage under the requires-signal floor is - // the withdrawal trigger. The TCF module answers that at assembly, - // and core links no module, so the answer is stated here. + // A record refusing storage under the requires-signal floor is the + // withdrawal trigger. The module for its scheme answers that at + // assembly, and core links no module, so the answer is stated here. let consent = ConsentContext { jurisdiction: Jurisdiction::Gdpr, - tcf: Some(refusing_tcf()), - source: ConsentSource::Cookie, ..Default::default() }; let mut ec_context = @@ -965,13 +960,11 @@ mod tests { // marker is exactly what goes missing. let settings = create_test_settings(); let ec_id = sample_ec_id("dead01"); - // A TCF record refusing storage under the requires-signal floor is - // the withdrawal trigger. The TCF module answers that at assembly, - // and core links no module, so the answer is stated here. + // A record refusing storage under the requires-signal floor is the + // withdrawal trigger. The module for its scheme answers that at + // assembly, and core links no module, so the answer is stated here. let consent = ConsentContext { jurisdiction: Jurisdiction::Gdpr, - tcf: Some(refusing_tcf()), - source: ConsentSource::Cookie, ..Default::default() }; let mut ec_context = @@ -1018,15 +1011,13 @@ mod tests { async fn finalize_withdrawal_clears_cookie_and_headers() { let settings = create_test_settings(); let ec_id = sample_ec_id("aBc123"); - // A TCF record refusing storage is the withdrawal trigger, under a + // A record refusing storage is the withdrawal trigger, under a // storage baseline at the requires-signal floor, where refusing the - // signal storage depends on is destructive. The TCF module answers + // signal storage depends on is destructive. The signal module answers // that at assembly, and core links no module, so the answer is // stated here and what finalization does with it is what is tested. let consent = ConsentContext { jurisdiction: Jurisdiction::Gdpr, - tcf: Some(refusing_tcf()), - source: ConsentSource::Cookie, ..Default::default() }; let mut ec_context = @@ -1079,26 +1070,6 @@ mod tests { ); } - /// A decoded TCF record refusing every purpose, storage included. - fn refusing_tcf() -> crate::consent::TcfConsent { - crate::consent::TcfConsent { - version: 2, - cmp_id: 0, - cmp_version: 0, - consent_screen: 0, - consent_language: "EN".to_owned(), - vendor_list_version: 0, - tcf_policy_version: 2, - created_ds: 0, - last_updated_ds: 0, - purpose_consents: vec![false; 24], - purpose_legitimate_interests: vec![false; 24], - vendor_consents: Vec::new(), - vendor_legitimate_interests: Vec::new(), - special_feature_opt_ins: vec![false; 12], - } - } - #[tokio::test] async fn finalize_gpc_suppresses_headers_but_keeps_the_cookie() { // A US-style opt-out suppresses use (headers cleared, nothing egressed) @@ -1108,8 +1079,6 @@ mod tests { let ec_id = sample_ec_id("aBc123"); let consent = ConsentContext { jurisdiction: Jurisdiction::UsState("CA".to_owned()), - gpc: true, - source: ConsentSource::Cookie, ..Default::default() }; let mut ec_context = @@ -1258,7 +1227,6 @@ mod tests { let orphaned_ec = sample_ec_id("orphn1"); let consent = ConsentContext { jurisdiction: Jurisdiction::NonRegulated, - source: ConsentSource::Cookie, ..Default::default() }; let mut ec_context = EcContext::new_for_test_with_ip( @@ -1304,7 +1272,7 @@ mod tests { #[tokio::test] async fn finalize_recovers_orphaned_ec_when_purpose_four_denial_empties_updates() { // Regression test: this request has real EID data (a configured - // partner and a captured client EID), but TCF Purpose 4 denial gates + // partner and a captured client EID), but the personalised-ad permission gates // it away, emptying the update list. That must not also suppress the // snapshot refresh that orphan recovery depends on. Without a // preloaded snapshot (a non-GET publisher navigation never calls @@ -1375,7 +1343,6 @@ mod tests { let orphaned_ec = sample_ec_id("orphn2"); let consent = ConsentContext { jurisdiction: Jurisdiction::NonRegulated, - source: ConsentSource::Cookie, ..Default::default() }; let mut ec_context = EcContext::new_for_test_with_ip( @@ -1797,7 +1764,7 @@ mod tests { #[tokio::test] async fn finalize_withholds_eid_kv_writes_when_purpose_four_is_denied() { - // A GDPR user can grant TCF Purpose 1 (storage/EC) while denying + // A reader under a requires-signal baseline can grant storage while denying // Purpose 4 (personalized ads). `gate_eids_by_consent` already strips // EIDs from the outbound /auction bid request in that case; the KV // write path must apply the same Purpose 4 check, or a user's opt-out @@ -1858,7 +1825,7 @@ mod tests { .expect("row should remain"); assert!( !stored.ids.contains_key("id5-sync.com"), - "denying TCF Purpose 4 must keep the EID out of KV even though Purpose 1 \ + "withholding the personalised-ad permission must keep the EID out of KV even though storage \ (EC) consent is granted" ); } @@ -2513,20 +2480,18 @@ mod tests { // value it creates a second row nothing reads, and the revocation // never takes effect for a module whose canonical form differs. // - // Destructive withdrawal is narrow, so the trigger here is a TCF record + // Destructive withdrawal is narrow, so the trigger here is a record // refusing storage, the same one // `finalize_withdrawal_clears_cookie_and_headers` uses. An opt-out such - // as GPC suppresses use without destroying an issued identifier, so it + // as a browser setting suppresses use without destroying an issued identifier, so it // would write no tombstone for this test to place. let settings = create_test_settings(); let graph = graph_with_live_canonical_row(); let consent = ConsentContext { jurisdiction: Jurisdiction::Gdpr, - tcf: Some(refusing_tcf()), - source: ConsentSource::Cookie, ..Default::default() }; - // The TCF module answers the withdrawal at assembly, and core links + // The signal module answers the withdrawal at assembly, and core links // no module, so the answer is stated here. let mut ec_context = canonicalizing_context(true, false, consent, false) .with_storage_withdrawn_for_test(true); @@ -2572,7 +2537,6 @@ mod tests { let registry = PartnerRegistry::from_config(&partners).expect("should build registry"); let consent = ConsentContext { jurisdiction: Jurisdiction::NonRegulated, - source: ConsentSource::Cookie, ..Default::default() }; let mut ec_context = canonicalizing_context(true, false, consent, true); @@ -2664,7 +2628,6 @@ mod tests { let graph = KvIdentityGraph::in_memory("finalize-module-headers-store"); let consent = ConsentContext { jurisdiction: Jurisdiction::NonRegulated, - source: ConsentSource::Cookie, ..Default::default() }; let mut ec_context = make_context_with_consent(None, None, false, false, consent, true) @@ -2777,36 +2740,19 @@ mod tests { // tombstone the row, and the identifier is never adopted either. let settings = create_test_settings(); let graph = graph_with_live_canonical_row(); - // A TCF record consenting to nothing, under GDPR, so the request + // A record consenting to nothing, under a requires-signal baseline, so the request // carries an explicit refusal of storage. That is the narrow, // destructive kind of withdrawal, the one that tombstones rather than // merely suppressing, which is the behavior under test. let consent = ConsentContext { jurisdiction: Jurisdiction::Gdpr, - tcf: Some(crate::consent::TcfConsent { - version: 2, - cmp_id: 0, - cmp_version: 0, - consent_screen: 0, - consent_language: "EN".to_owned(), - vendor_list_version: 0, - tcf_policy_version: 2, - created_ds: 0, - last_updated_ds: 0, - purpose_consents: vec![false; 24], - purpose_legitimate_interests: vec![false; 24], - vendor_consents: Vec::new(), - vendor_legitimate_interests: Vec::new(), - special_feature_opt_ins: vec![false; 12], - }), - source: ConsentSource::Cookie, ..Default::default() }; // The browser still carries the identifier the previous module // created, but the deployment now runs a module with a different // code, so read-back treats the cookie as absent and the active // identifier is empty. - // The TCF module answers the withdrawal at assembly, and core links + // The signal module answers the withdrawal at assembly, and core links // no module, so the answer is stated here. let mut ec_context = make_context_with_consent( None, @@ -2943,7 +2889,6 @@ mod tests { fn granting_consent() -> ConsentContext { ConsentContext { jurisdiction: Jurisdiction::NonRegulated, - source: ConsentSource::Cookie, ..Default::default() } } @@ -3096,13 +3041,11 @@ mod tests { let settings = create_test_settings(); let active_ec = sample_ec_id("activ2"); let cookie_ec = sample_ec_id("cook2e"); - // A TCF record refusing storage under the requires-signal floor is - // the withdrawal trigger. The TCF module answers that at assembly, - // and core links no module, so the answer is stated here. + // A record refusing storage under the requires-signal floor is the + // withdrawal trigger. The module for its scheme answers that at + // assembly, and core links no module, so the answer is stated here. let consent = ConsentContext { jurisdiction: Jurisdiction::Gdpr, - tcf: Some(refusing_tcf()), - source: ConsentSource::Cookie, ..Default::default() }; let mut ec_context = make_context_with_consent( @@ -3156,8 +3099,6 @@ mod tests { let cookie_ec = sample_ec_id("cook3e"); let consent = ConsentContext { jurisdiction: Jurisdiction::UsState("CA".to_owned()), - gpc: true, - source: ConsentSource::Cookie, ..Default::default() }; let mut ec_context = make_context_with_consent( @@ -3261,8 +3202,6 @@ mod tests { let ec_id = sample_ec_id("failw1"); let consent = ConsentContext { jurisdiction: Jurisdiction::UsState("CA".to_owned()), - gpc: true, - source: ConsentSource::Cookie, ..Default::default() }; let mut ec_context = @@ -3374,8 +3313,6 @@ mod tests { let settings = create_test_settings(); let consent = ConsentContext { jurisdiction: Jurisdiction::UsState("CA".to_owned()), - gpc: true, - source: ConsentSource::Cookie, ..Default::default() }; let mut ec_context = make_context_with_consent(None, None, false, false, consent, false); @@ -3404,8 +3341,6 @@ mod tests { let settings = create_test_settings(); let consent = ConsentContext { jurisdiction: Jurisdiction::UsState("CA".to_owned()), - gpc: true, - source: ConsentSource::Cookie, ..Default::default() }; let mut ec_context = make_context_with_consent(None, None, false, false, consent, false) diff --git a/crates/trusted-server-core/src/ec/identify.rs b/crates/trusted-server-core/src/ec/identify.rs index 7e514e11de..5be875dd29 100644 --- a/crates/trusted-server-core/src/ec/identify.rs +++ b/crates/trusted-server-core/src/ec/identify.rs @@ -348,7 +348,7 @@ fn apply_cors_headers(response: &mut Response, origin: &str) { #[cfg(test)] mod tests { use super::*; - use crate::consent::types::{ConsentContext, ConsentSource}; + use crate::consent::types::ConsentContext; use crate::ec::registry::PartnerRegistry; use crate::ec::tests::{CANONICAL_COOKIE_VALUE, CANONICAL_KV_KEY}; use crate::redacted::Redacted; @@ -369,11 +369,11 @@ mod tests { } fn make_ec_context(ec_allowed: bool, ec_value: Option<&str>) -> EcContext { - let consent = ConsentContext { - source: ConsentSource::Cookie, - ..ConsentContext::default() - }; - EcContext::new_for_test_gated(ec_value.map(str::to_owned), consent, ec_allowed) + EcContext::new_for_test_gated( + ec_value.map(str::to_owned), + ConsentContext::default(), + ec_allowed, + ) } fn make_test_partner(source_domain: &str, api_token: &str) -> EcPartner { diff --git a/crates/trusted-server-core/src/ec/kv_types.rs b/crates/trusted-server-core/src/ec/kv_types.rs index 8eb1bcc2d8..52bf10b6a0 100644 --- a/crates/trusted-server-core/src/ec/kv_types.rs +++ b/crates/trusted-server-core/src/ec/kv_types.rs @@ -69,12 +69,11 @@ pub struct KvEntry { /// Consent state within a KV entry. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct KvConsent { - /// Raw TCF v2 consent string. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub tcf: Option, - /// Raw GPP consent string. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub gpp: Option, + /// The signals the identifier was created under, keyed by scheme, each as + /// it was received. Only a signal a configured module vouched for is + /// written. + #[serde(flatten)] + pub signals: BTreeMap, /// `true` for a live entry, `false` for a withdrawal tombstone. pub ok: bool, /// Unix timestamp (seconds) of last consent state change. @@ -290,8 +289,15 @@ impl KvEntry { v: SCHEMA_VERSION, created: now, consent: KvConsent { - tcf: consent.raw_tc_string.clone(), - gpp: consent.raw_gpp_string.clone(), + signals: consent + .records + .iter() + .filter_map(|record| { + record + .value() + .map(|value| (record.scheme().to_owned(), value.to_owned())) + }) + .collect(), ok: true, updated: now, }, @@ -320,8 +326,7 @@ impl KvEntry { v: SCHEMA_VERSION, created: now, consent: KvConsent { - tcf: None, - gpp: None, + signals: BTreeMap::new(), ok: true, updated: now, }, @@ -352,8 +357,7 @@ impl KvEntry { v: SCHEMA_VERSION, created: now, consent: KvConsent { - tcf: None, - gpp: None, + signals: BTreeMap::new(), ok: false, updated: now, }, @@ -472,11 +476,16 @@ mod tests { use super::*; fn sample_consent_context() -> ConsentContext { - ConsentContext { - raw_tc_string: Some("CP_test_tc_string".to_owned()), - raw_gpp_string: Some("DBA_test_gpp".to_owned()), - ..ConsentContext::default() - } + use crate::consent::{SignalRecord, SignalStatus}; + + ConsentContext::default() + .with_record( + SignalRecord::new("scheme-a", SignalStatus::Decoded).with_value("signal-a"), + ) + .with_record( + SignalRecord::new("scheme-b", SignalStatus::Decoded).with_value("signal-b"), + ) + .with_record(SignalRecord::new("unvouched", SignalStatus::Unreadable)) } fn sample_geo_info() -> GeoInfo { @@ -511,10 +520,25 @@ mod tests { assert_eq!(deserialized.v, SCHEMA_VERSION); assert_eq!(deserialized.created, 1_741_824_000); assert_eq!( - deserialized.consent.tcf.as_deref(), - Some("CP_test_tc_string") + deserialized + .consent + .signals + .get("scheme-a") + .map(String::as_str), + Some("signal-a") + ); + assert_eq!( + deserialized + .consent + .signals + .get("scheme-b") + .map(String::as_str), + Some("signal-b") + ); + assert!( + !deserialized.consent.signals.contains_key("unvouched"), + "a record carrying no string is not written" ); - assert_eq!(deserialized.consent.gpp.as_deref(), Some("DBA_test_gpp")); assert!(deserialized.consent.ok, "should be a live entry"); assert_eq!(deserialized.geo.country, "US"); assert_eq!(deserialized.geo.region.as_deref(), Some("CA")); @@ -549,7 +573,7 @@ mod tests { "pub_properties": { "origin_domain": "example.com", "seen_domains": { - "example.com": { "first": 1000, "last": 1200, "visits": 3 } + "example.com": { "scheme-a": 1000, "last": 1200, "visits": 3 } } }, "network": { @@ -589,7 +613,7 @@ mod tests { assert!( !reserialized.contains("last_seen") && !reserialized.contains("synced") - && !reserialized.contains("first") + && !reserialized.contains("scheme-a") && !reserialized.contains("last") && !reserialized.contains("cluster_checked") && !reserialized.contains("visits"), @@ -909,16 +933,39 @@ mod tests { } #[test] - fn none_consent_fields_omitted_from_json() { + fn a_record_with_no_signals_writes_only_its_state() { let entry = KvEntry::tombstone(1000); - let json = serde_json::to_string(&entry).expect("should serialize"); - assert!( - !json.contains("\"tcf\""), - "None tcf should be omitted from JSON" + let json: serde_json::Value = serde_json::to_value(&entry).expect("should serialize"); + assert_eq!( + json["consent"], + serde_json::json!({ "ok": false, "updated": 1000 }), + "no signal keys are written for a record carrying none" ); - assert!( - !json.contains("\"gpp\""), - "None gpp should be omitted from JSON" + } + + #[test] + fn signals_are_written_beside_the_state_under_their_scheme() { + let consent = sample_consent_context(); + let entry = KvEntry::new(&consent, None, 1000, "example.com"); + let json: serde_json::Value = serde_json::to_value(&entry).expect("should serialize"); + assert_eq!( + json["consent"], + serde_json::json!({ + "scheme-a": "signal-a", + "scheme-b": "signal-b", + "ok": true, + "updated": 1000 + }) + ); + let read: KvConsent = serde_json::from_value(serde_json::json!({ + "scheme-a": "signal-a", + "ok": true, + "updated": 1000 + })) + .expect("should read a record written by an earlier build"); + assert_eq!( + read.signals.get("scheme-a").map(String::as_str), + Some("signal-a") ); } diff --git a/crates/trusted-server-core/src/ec/mod.rs b/crates/trusted-server-core/src/ec/mod.rs index e3b1265497..6341a61c4c 100644 --- a/crates/trusted-server-core/src/ec/mod.rs +++ b/crates/trusted-server-core/src/ec/mod.rs @@ -73,13 +73,12 @@ pub fn log_id(ec_id: &str) -> String { use std::sync::Arc; -use cookie::CookieJar; use edgezero_core::body::Body as EdgeBody; use error_stack::Report; use http::Request; use crate::consent::jurisdiction::Jurisdiction; -use crate::consent::{self as consent_mod, ConsentContext, ConsentPipelineInput}; +use crate::consent::{self as consent_mod, ConsentContext}; use crate::constants::{COOKIE_EC, COOKIE_EC_PULL_COMPLETE}; use crate::cookies::handle_request_cookies; use crate::ec::cookies::ec_id_has_only_allowed_chars; @@ -196,8 +195,6 @@ struct RequestEc { cookie_ec: Option, /// Pull-sync completeness marker, if present. pull_sync_marker: Option, - /// The parsed cookie jar (retained for consent pipeline input). - jar: Option, } /// Parses EC identity from request cookies in a single pass. @@ -224,7 +221,6 @@ fn parse_ec_from_request( Ok(RequestEc { cookie_ec, pull_sync_marker, - jar, }) } @@ -480,28 +476,9 @@ impl EcContext { .client_ip .map(generation::normalize_ip); - // Build consent context from request-local cookies, headers, and geo. - // Jurisdiction detection follows the permission model's fallback: with - // no location resolved the policy's declared jurisdiction stands in, so - // a deployment that declared one is not treated as unknown, while a - // failed lookup stays unknown so the consent gates fail closed - // alongside the requires-signal floor. - let consent = consent_mod::build_consent_context(&ConsentPipelineInput { - jar: parsed.jar.as_ref(), - req, - config: &settings.consent, - geo: geo_info, - default_jurisdiction: consent::default_jurisdiction(geo_status), - }); - - // Assemble the permission state once, here, through the permission - // model, building the country/region baseline amended by what the - // signal modules the adapter selected say about the request. - // Downstream consumers read the stored result via - // [`EcContext::permissions`] and [`EcContext::ec_allowed`] rather than - // re-deriving it. The modules see the request as evidence, the same - // abstraction the Edge Cookie and device modules read, so a scheme - // core has never heard of can read its own signal from it. + // The modules see the request as evidence, the same abstraction the + // Edge Cookie and device modules read, so a scheme core has never + // heard of can read its own signal from it. let evidence = crate::evidence::BorrowedRequestInfo::new( client_ip.as_deref().unwrap_or_default(), Some(req.headers()), @@ -509,13 +486,32 @@ impl EcContext { .with_request_target(req.uri().path(), req.uri().query().unwrap_or_default()); let mut module_context = ModuleContext::new(ModuleRequest::of(req, services.client_info())) .with_evidence(&evidence) - .with_consent(&consent) .with_settings(settings) .with_services(services) .with_client(services.client_info()); if let Some(geo) = geo_info { module_context = module_context.with_geo(geo, services.geo().required_permissions()); } + + // Read the consent once, each signal module reading its own scheme. + // Jurisdiction detection follows the permission model's fallback: with + // no location resolved the policy's declared jurisdiction stands in, so + // a deployment that declared one is not treated as unknown, while a + // failed lookup stays unknown so the consent gates fail closed + // alongside the requires-signal floor. + let consent = consent_mod::read_consent( + &module_context, + services.permission_signal_modules(), + consent_mod::request_jurisdiction(geo_info, consent::default_jurisdiction(geo_status)), + ); + + // Assemble the permission state once, here, through the permission + // model, building the country/region baseline amended by what the + // signal modules the adapter selected say about the request. + // Downstream consumers read the stored result via + // [`EcContext::permissions`] and [`EcContext::ec_allowed`] rather than + // re-deriving it. + let module_context = module_context.with_consent(&consent); let permissions = consent::assemble_permissions( &module_context, geo_status, @@ -1103,10 +1099,9 @@ impl EcContext { /// storage, scoped to the jurisdiction's storage baseline. /// /// Answered by the signal modules at construction and recorded on the - /// permission state, see [`PermissionState::storage_withdrawn`]. Of the - /// schemes that ship, only a TCF record refusing storage withdraws, and - /// only where the storage baseline is not `granted`. Suppression (the - /// permission merely not set) is reported by + /// permission state, see [`PermissionState::storage_withdrawn`]. A module + /// withdraws only where the storage baseline is not `granted`. + /// Suppression (the permission merely not set) is reported by /// [`ec_allowed`](Self::ec_allowed) being `false` instead. #[must_use] pub fn storage_withdrawn(&self) -> bool { @@ -1194,7 +1189,7 @@ impl EcContext { consent: ConsentContext, ec_allowed: bool, ) -> Self { - let permissions = permissions_for_test(ec_allowed); + let permissions = permissions_for_test(ec_allowed, &consent.jurisdiction); Self { ec_was_present: ec_value.is_some(), cookie_ec_value: ec_value.clone(), @@ -1230,7 +1225,7 @@ impl EcContext { #[must_use] pub fn new_for_test_withdrawn(ec_value: Option, consent: ConsentContext) -> Self { let mut context = Self::new_for_test_gated(ec_value, consent, false); - context.permissions = PermissionState::default().with_storage_withdrawn(true); + context.permissions = context.permissions.clone().with_storage_withdrawn(true); context } @@ -1242,6 +1237,7 @@ impl EcContext { consent: ConsentContext, client_ip: Option, ) -> Self { + let permissions = permissions_for_test(true, &consent.jurisdiction); Self { ec_was_present: ec_value.is_some(), cookie_ec_value: ec_value.clone(), @@ -1249,7 +1245,7 @@ impl EcContext { ec_generated: false, consent, ec_allowed: true, - permissions: permissions_for_test(true), + permissions, client_ip, geo_info: None, device_signals: None, @@ -1278,6 +1274,7 @@ impl EcContext { consent: ConsentContext, ec_allowed: bool, ) -> Self { + let permissions = permissions_for_test(ec_allowed, &consent.jurisdiction); Self { ec_value, cookie_ec_value, @@ -1285,7 +1282,7 @@ impl EcContext { ec_generated, consent, ec_allowed, - permissions: permissions_for_test(ec_allowed), + permissions, client_ip: None, geo_info: None, device_signals: None, @@ -1315,10 +1312,11 @@ impl EcContext { /// storage, as assembly records it when a signal module answers so. /// /// Core links no module, so a core test cannot derive the withdrawal - /// from a consent record the way a deployment does through the TCF + /// from a consent record the way a deployment does through a signal /// module. It states the answer instead and tests what finalization does - /// with it. That a TCF refusal produces this answer is proved where the - /// modules are linked, in the Axum adapter's `permission_signals` test. + /// with it. That a record refusing storage produces this answer is proved + /// where the modules are linked, in the Axum adapter's + /// `permission_signals` test. #[cfg(test)] #[must_use] pub fn with_storage_withdrawn_for_test(mut self, withdrawn: bool) -> Self { @@ -1330,16 +1328,24 @@ impl EcContext { /// The permissions a test context stands in for: storage and personalised-ad /// selection when the gate is open, none when it is closed. #[cfg(test)] -fn permissions_for_test(ec_allowed: bool) -> PermissionState { +/// The permission state a test context stands on: the Edge Cookie pair when +/// the gate is open, and basic ad selection wherever the jurisdiction's +/// baseline grants it outright, which is what the server-side auction gate +/// reads. +fn permissions_for_test(ec_allowed: bool, jurisdiction: &Jurisdiction) -> PermissionState { + let mut set = crate::permissions::PermissionSet::none(); if ec_allowed { - PermissionState::new( - [Permission::StoreOnDevice, Permission::SelectPersonalisedAds] - .into_iter() - .collect(), - ) - } else { - PermissionState::default() + set = set + .with(Permission::StoreOnDevice) + .with(Permission::SelectPersonalisedAds); + } + if matches!( + jurisdiction, + Jurisdiction::NonRegulated | Jurisdiction::UsState(_) + ) { + set = set.with(Permission::SelectBasicAds); } + PermissionState::new(set) } /// Returns the current Unix timestamp in seconds, falling back to zero on clock failure. @@ -1385,7 +1391,7 @@ pub(crate) fn std_system_time_now() -> std::time::SystemTime { pub(crate) mod tests { use super::*; use crate::consent::jurisdiction::Jurisdiction; - use crate::consent::types::{ConsentContext, ConsentSource}; + use crate::consent::types::ConsentContext; use crate::ec::kv_backend::test_support::InMemoryEcKv; use crate::ec::kv_backend::{ EcKvLookup, EcKvStore, EcKvWrite, EcKvWriteMode, EcKvWriteOutcome, @@ -1456,11 +1462,7 @@ pub(crate) mod tests { } fn granting_consent() -> ConsentContext { - ConsentContext { - jurisdiction: Jurisdiction::NonRegulated, - source: ConsentSource::Cookie, - ..Default::default() - } + ConsentContext::new(Jurisdiction::NonRegulated) } #[tokio::test] diff --git a/crates/trusted-server-core/src/ec/module.rs b/crates/trusted-server-core/src/ec/module.rs index d3a794553a..5df3b6398e 100644 --- a/crates/trusted-server-core/src/ec/module.rs +++ b/crates/trusted-server-core/src/ec/module.rs @@ -788,8 +788,8 @@ impl EdgeCookieModule for HmacModule { fn required_permissions(&self) -> PermissionSet { // The HMAC module writes the Edge Cookie to the device, so it requires - // permission to store on the device (TCF Purpose 1). Whether that needs a - // signal is decided by the country rules, not by the module. + // permission to store on the device. Whether that needs a signal is + // decided by the country rules, not by the module. PermissionSet::none().with(Permission::StoreOnDevice) } } @@ -868,8 +868,8 @@ impl EdgeCookieModule for HostSignalModule { } fn required_permissions(&self) -> PermissionSet { - // Writes the Edge Cookie to the device, so it requires necessary.operations.storage - // (TCF Purpose 1), the same gate as the HMAC module. + // Writes the Edge Cookie to the device, so it requires + // necessary.operations.storage, the same gate as the HMAC module. PermissionSet::none().with(Permission::StoreOnDevice) } } @@ -954,7 +954,7 @@ impl EdgeCookieModule for ClientFixedModule { fn required_permissions(&self) -> PermissionSet { // The module writes the resolved value to the device as the Edge - // Cookie, so it requires necessary.operations.storage (TCF Purpose 1), the same gate + // Cookie, so it requires necessary.operations.storage, the same gate // as the HMAC module. PermissionSet::none().with(Permission::StoreOnDevice) } diff --git a/crates/trusted-server-core/src/error.rs b/crates/trusted-server-core/src/error.rs index 226dc681ca..535e08c695 100644 --- a/crates/trusted-server-core/src/error.rs +++ b/crates/trusted-server-core/src/error.rs @@ -30,14 +30,6 @@ pub enum TrustedServerError { #[display("Auction error: {message}")] Auction { message: String }, - /// GDPR consent handling error. - /// - /// **Note:** Unlike [`BadRequest`](Self::BadRequest), the detail `message` - /// is intentionally suppressed in client-facing responses because consent - /// strings may contain user data. Only the category name is returned. - #[display("GDPR consent error: {message}")] - GdprConsent { message: String }, - /// Invalid UTF-8 data encountered. #[display("Invalid UTF-8 data: {message}")] InvalidUtf8 { message: String }, @@ -111,7 +103,6 @@ impl IntoHttpResponse for TrustedServerError { Self::Auction { .. } => StatusCode::BAD_GATEWAY, Self::BadRequest { .. } => StatusCode::BAD_REQUEST, Self::Configuration { .. } | Self::Settings { .. } => StatusCode::INTERNAL_SERVER_ERROR, - Self::GdprConsent { .. } => StatusCode::BAD_REQUEST, Self::InvalidHeaderValue { .. } => StatusCode::BAD_REQUEST, Self::InvalidUtf8 { .. } => StatusCode::INTERNAL_SERVER_ERROR, Self::KvStore { .. } => StatusCode::SERVICE_UNAVAILABLE, @@ -130,8 +121,6 @@ impl IntoHttpResponse for TrustedServerError { match self { // Selected client errors with safe details to surface. Self::BadRequest { message } => format!("Bad request: {message}"), - // Consent strings may contain user data; return category only. - Self::GdprConsent { .. } => "GDPR consent error".to_string(), Self::InvalidHeaderValue { .. } => "Invalid header value".to_string(), // Server/integration errors (5xx/502/503) — generic message only. // Full details are already logged via log::error! in to_error_response. @@ -165,12 +154,6 @@ mod tests { }, StatusCode::BAD_GATEWAY, ), - ( - TrustedServerError::GdprConsent { - message: String::from("missing consent string"), - }, - StatusCode::BAD_REQUEST, - ), ( TrustedServerError::InvalidUtf8 { message: String::from("invalid byte sequence"), @@ -259,7 +242,6 @@ mod tests { StatusCode::INTERNAL_SERVER_ERROR } TrustedServerError::Auction { .. } => StatusCode::BAD_GATEWAY, - TrustedServerError::GdprConsent { .. } => StatusCode::BAD_REQUEST, TrustedServerError::InvalidUtf8 { .. } => StatusCode::INTERNAL_SERVER_ERROR, TrustedServerError::RequestTooLarge { .. } => StatusCode::PAYLOAD_TOO_LARGE, TrustedServerError::InvalidHeaderValue { .. } => StatusCode::BAD_REQUEST, @@ -360,11 +342,6 @@ mod tests { }; assert_eq!(error.user_message(), "Bad request: missing field"); - let error = TrustedServerError::GdprConsent { - message: "no consent string".into(), - }; - assert_eq!(error.user_message(), "GDPR consent error"); - let error = TrustedServerError::InvalidHeaderValue { message: "non-ascii".into(), }; @@ -379,7 +356,6 @@ mod tests { TrustedServerError::BadRequest { .. } | TrustedServerError::Configuration { .. } | TrustedServerError::Auction { .. } - | TrustedServerError::GdprConsent { .. } | TrustedServerError::InvalidUtf8 { .. } | TrustedServerError::InvalidHeaderValue { .. } | TrustedServerError::KvStore { .. } @@ -401,12 +377,6 @@ mod tests { }, StatusCode::BAD_REQUEST, ), - ( - TrustedServerError::GdprConsent { - message: "missing consent".to_string(), - }, - StatusCode::BAD_REQUEST, - ), ( TrustedServerError::InvalidHeaderValue { message: "invalid header".to_string(), diff --git a/crates/trusted-server-core/src/inspect/data.rs b/crates/trusted-server-core/src/inspect/data.rs index 1850c1ae7a..9dd685ae29 100644 --- a/crates/trusted-server-core/src/inspect/data.rs +++ b/crates/trusted-server-core/src/inspect/data.rs @@ -419,8 +419,10 @@ mod tests { v: 1, created: CREATED, consent: KvConsent { - tcf: Some("tcf-string".to_owned()), - gpp: Some("gpp-string".to_owned()), + signals: BTreeMap::from([ + ("first".to_owned(), "first-signal".to_owned()), + ("second".to_owned(), "second-signal".to_owned()), + ]), ok: true, updated: CREATED + 60, }, @@ -459,9 +461,9 @@ mod tests { assert_eq!( shown.iter().map(String::as_str).collect::>(), [ - "consent.gpp", + "consent.first", "consent.ok", - "consent.tcf", + "consent.second", "consent.updated", "created", "device.h2_fp_hash", @@ -503,8 +505,7 @@ mod tests { v: 1, created: made, consent: KvConsent { - tcf: Some("CP...".to_owned()), - gpp: None, + signals: BTreeMap::from([("example".to_owned(), "CP...".to_owned())]), ok: true, updated: made, }, @@ -536,7 +537,7 @@ mod tests { "held": true, "identifier": "XXXX", "record": { - "consent": { "ok": true, "tcf": "CP...", "updated": 1_773_480_600 }, + "consent": { "example": "CP...", "ok": true, "updated": 1_773_480_600 }, "created": 1_773_480_600, "geo": { "country": "GB", "region": "ENG" }, "ids": { "partner.example.com": { "uid": "XXXX" } }, diff --git a/crates/trusted-server-core/src/inspect/permissions.rs b/crates/trusted-server-core/src/inspect/permissions.rs index 9aaf4925ee..344a197e80 100644 --- a/crates/trusted-server-core/src/inspect/permissions.rs +++ b/crates/trusted-server-core/src/inspect/permissions.rs @@ -140,7 +140,7 @@ mod tests { fn state_with_signal() -> PermissionState { PermissionState::new(PermissionSet::none().with(Permission::StoreOnDevice)) - .with_signals(vec![ValidSignal::new("tcf", "tcf", "CPabc123")].into()) + .with_signals(vec![ValidSignal::new("example", "example", "CPabc123")].into()) .with_tdls( vec![ Tdl::new("https://terms.example.com/marketing/2.txt") @@ -167,10 +167,10 @@ mod tests { fn the_payload_names_the_signal_and_the_module_that_read_it() { let payload = permissions_payload(&state_with_signal(), &create_test_settings()); - assert_eq!(payload["signals"][0]["module"], "tcf"); - assert_eq!(payload["signals"][0]["scheme"], "tcf"); + assert_eq!(payload["signals"][0]["module"], "example"); + assert_eq!(payload["signals"][0]["scheme"], "example"); assert_eq!(payload["signals"][0]["value"], "CPabc123"); - assert_eq!(payload["modules"]["contributed"], json!(["tcf"])); + assert_eq!(payload["modules"]["contributed"], json!(["example"])); assert_eq!( payload["tdls"], json!(["https://terms.example.com/marketing/2.txt"]) @@ -202,12 +202,15 @@ mod tests { #[test] fn configured_and_contributed_modules_are_reported_separately() { let mut settings = create_test_settings(); - settings.permission_signal.modules = Some(vec!["tcf".to_owned(), "gpc".to_owned()]); + settings.permission_signal.modules = Some(vec!["example".to_owned(), "other".to_owned()]); let payload = permissions_payload(&state_with_signal(), &settings); - assert_eq!(payload["modules"]["contributed"], json!(["tcf"])); - assert_eq!(payload["modules"]["configured"], json!(["tcf", "gpc"])); + assert_eq!(payload["modules"]["contributed"], json!(["example"])); + assert_eq!( + payload["modules"]["configured"], + json!(["example", "other"]) + ); } /// With no modules named the selection is the adapter's, which core @@ -275,7 +278,7 @@ mod tests { #[test] fn the_page_escapes_a_hostile_signal_value() { let state = PermissionState::new(PermissionSet::none()) - .with_signals(vec![ValidSignal::new("tcf", "tcf", "