crates/monetize-site-buy/src/rules.rs
//! **The shapes a site checks before it dials the shop** — one copy of each rule, shared by
//! every product site. Each is a FILTER in front of monetize, which judges again with its own
//! rule and whose sentence the buyer is shown.
use axum::http::StatusCode;
use monetize_proto::Market;
/// The username rule as a sentence, for the one refusal a site writes itself.
pub const RULE_IN_WORDS: &str = "2 to 32 characters of a-z, 0-9, '.', '_' or '-', starting with a letter or a digit";
/// What a visitor reads where monetize did not finish a call.
pub const SHOP_DID_NOT_ANSWER: &str = "The shop did not answer. Try again in a minute.";
/// **A username's shape** (`monetize_pay::signup::USERNAME_RULE`, mirrored).
pub fn username(name: &str) -> bool {
let n = name.chars().count();
if !(2..=32).contains(&n) {
return false;
}
let mut chars = name.chars();
let first = chars.next().unwrap_or(' ');
if !first.is_ascii_lowercase() && !first.is_ascii_digit() {
return false;
}
chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '.' || c == '_' || c == '-')
}
/// **An e-mail address's shape**: one `@`, a dotted domain, no whitespace, at most 254.
pub fn address(t: &str) -> bool {
t.chars().count() <= 254
&& !t.chars().any(|c| c.is_whitespace() || c.is_control())
&& match t.split_once('@') {
Some((local, domain)) => !local.is_empty() && !domain.contains('@') && domain.contains('.') && !domain.starts_with('.') && !domain.ends_with('.') && !domain.contains(".."),
None => false,
}
}
/// A company's name as monetize's rule has it: lowercase letters and digits, a letter first,
/// at most 16.
pub fn company(company: &str) -> bool {
!company.is_empty() && company.len() <= 16 && company.starts_with(|c: char| c.is_ascii_lowercase()) && company.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit())
}
/// **A market word to monetize's enum**: none named is monetize's default; anything but the
/// three is refused by name.
pub fn market(word: Option<&str>) -> Result<Market, String> {
match word.map(str::trim).filter(|w| !w.is_empty()).map(str::to_ascii_uppercase).as_deref() {
None => Ok(Market::Unspecified),
Some("SE") => Ok(Market::Se),
Some("FI") => Ok(Market::Fi),
Some("DK") => Ok(Market::Dk),
Some(other) => Err(format!("market {other:?} is not one sold in here (SE, FI, DK)")),
}
}
/// The market as a page spells it (`SE`, `FI`, `DK`; empty for none).
pub fn market_word(wire: i32) -> &'static str {
match Market::try_from(wire) {
Ok(Market::Se) => "SE",
Ok(Market::Fi) => "FI",
Ok(Market::Dk) => "DK",
_ => "",
}
}
/// **The market a browser language names**: Danish or Denmark → DK, Finnish or Finland → FI,
/// Swedish or Sweden → SE; anything else names none.
pub fn market_of_language(tag: &str) -> Option<Market> {
let tag = tag.trim().to_ascii_lowercase();
let mut parts = tag.split(['-', '_']);
let primary = parts.next().unwrap_or_default();
let region = parts.next().unwrap_or_default();
match (primary, region) {
(_, "dk") | ("da", _) => Some(Market::Dk),
(_, "fi") | ("fi", _) => Some(Market::Fi),
(_, "se") | ("sv", _) => Some(Market::Se),
_ => None,
}
}
/// **The buyer's market from `Accept-Language`**: the most preferred language that names one
/// (`q`, then the order sent). None: monetize's default.
pub fn market_of_languages(header: &str) -> Option<Market> {
let mut ranked: Vec<(u32, usize, Market)> = header
.split(',')
.enumerate()
.filter_map(|(i, item)| {
let mut bits = item.split(';');
let tag = bits.next()?.trim();
let q = bits.find_map(|b| b.trim().strip_prefix("q=").map(|v| v.trim().parse::<f32>().unwrap_or(0.0))).unwrap_or(1.0);
let q = (q.clamp(0.0, 1.0) * 1000.0) as u32;
(q > 0).then_some(())?;
market_of_language(tag).map(|m| (q, i, m))
})
.collect();
ranked.sort_by(|a, b| b.0.cmp(&a.0).then(a.1.cmp(&b.1)));
ranked.first().map(|r| r.2)
}
/// **The market of a request**: the one the page named, else the browser's languages, else
/// monetize's default.
pub fn market_for(named: Option<&str>, accept_language: Option<&str>) -> Result<Market, String> {
match market(named)? {
Market::Unspecified => Ok(accept_language.and_then(market_of_languages).unwrap_or(Market::Unspecified)),
m => Ok(m),
}
}
/// **The shape of a plan id `Signup.Plans` hands out** (`product/row[@minorCODE]`, or empty).
pub fn plan_id(id: &str) -> bool {
if id.is_empty() {
return true;
}
if id.len() > 64 {
return false;
}
let (plan, price) = match id.split_once('@') {
Some((plan, price)) => (plan, Some(price)),
None => (id, None),
};
let Some((product, row)) = plan.split_once('/') else { return false };
let product_ok = product.starts_with(|c: char| c.is_ascii_lowercase()) && product.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-');
let row_ok = row.starts_with(|c: char| c.is_ascii_lowercase() || c.is_ascii_digit()) && row.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '-' | '_' | '.')) && !row.contains("..");
let price_ok = price.is_none_or(|price| {
let digits = price.trim_end_matches(|c: char| c.is_ascii_uppercase());
let code = &price[digits.len()..];
(1..=20).contains(&digits.len()) && digits.chars().all(|c| c.is_ascii_digit()) && digits.parse::<u64>().is_ok() && matches!(code, "SEK" | "EUR" | "DKK")
});
product_ok && row_ok && price_ok
}
/// **The shape of a vendor checkout session** (`cs_[test_|live_]…`) or the shop's own account
/// session (`acct_` + 32 hex). A shape check, not an authentication.
pub fn session(session: &str) -> bool {
if let Some(hex) = session.strip_prefix("acct_") {
return hex.len() == 32 && hex.chars().all(|c| c.is_ascii_hexdigit());
}
let Some(rest) = session.strip_prefix("cs_") else { return false };
let rest = rest.strip_prefix("test_").or_else(|| rest.strip_prefix("live_")).unwrap_or(rest);
rest.len() >= 10 && rest.chars().all(|c| c.is_ascii_alphanumeric())
}
/// A contact address as a site refuses one before asking monetize (`None`: plausible).
pub fn email_refusal(email: &str, confirm: &str) -> Option<&'static str> {
if email.is_empty() {
return Some("an e-mail address is required: it is where your welcome mail and receipt go");
}
if !address(email) {
return Some("that does not look like an e-mail address (name@example.com)");
}
if !email.eq_ignore_ascii_case(confirm) {
return Some("the two e-mail addresses do not match");
}
None
}
/// What a buyer reads when the shop would not take the site's word for who they are (a ticket
/// refused: spent, expired, or a key the shop does not hold). The shop's own reason is logged.
pub const WHO_NOT_CONFIRMED: &str = "This page could not confirm who you are. Reload it and try again; if it happens again, sign in again.";
/// What a buyer reads for a refusal whose sentence is the operator's, not theirs.
pub const NOT_DONE_HERE: &str = "This cannot be done here right now. Try again in a while.";
/// **The operator's vocabulary** (BUYFLOW-4; owner: customer-never-sees-caps, one-monetize-only):
/// whole words a buyer page never says. A buyer reads instance, account, room, payment page.
pub const OPERATOR_WORDS: &[&str] = &["lay", "lays", "laid", "laying", "estate", "estates", "guest", "guests", "operator", "operators", "cap", "caps", "monetize", "tenant", "tenants", "appliance", "vendor", "vendors", "ignalina"];
/// Pure: **`words` names the operator's machinery**: one of [`OPERATOR_WORDS`] as a whole word
/// (any case), or the ONE box.
pub fn operator_words_in(words: &str) -> bool {
let w = words.to_lowercase();
w.contains("one box") || w.split(|c: char| !c.is_alphanumeric()).any(|t| OPERATOR_WORDS.contains(&t))
}
/// Pure: a sentence that names the shop's own machinery, never shown to a buyer.
fn internal(words: &str) -> bool {
let w = words.to_ascii_lowercase();
operator_words_in(words) || ["appliance key", "[products.", "[estates.", "[estate_orders", "[signups."].iter().any(|k| w.contains(k))
}
/// **What a buyer reads for a refused monetize call** (BUYFLOW-4): the customer's sentence the
/// shop sent beside its refusal ([`customer_words`]), when it sent one — the message is the
/// operator's — else [`words_for_a_visitor`]. A refused ticket is always [`WHO_NOT_CONFIRMED`].
pub fn buyer_words(status: &tonic::Status) -> String {
match customer_words(status) {
Some(words) if status.code() != tonic::Code::PermissionDenied => words,
_ => words_for_a_visitor(status).to_owned(),
}
}
/// **What of a failed monetize call a buyer may read**: a verdict is monetize's sentence,
/// passed whole — unless it names the shop's own machinery (the owner's rule: a buyer never sees
/// monetize), and a refused ticket is [`WHO_NOT_CONFIRMED`]; a call that did not finish is
/// [`SHOP_DID_NOT_ANSWER`].
pub fn words_for_a_visitor(status: &tonic::Status) -> &str {
use tonic::Code;
match status.code() {
Code::PermissionDenied => {
tracing::info!(target: "monetize_site_buy::audit", event = "buy.refused_ticket", "the shop refused the site's ticket: {}", status.message());
WHO_NOT_CONFIRMED
}
Code::InvalidArgument | Code::FailedPrecondition | Code::AlreadyExists | Code::NotFound | Code::OutOfRange | Code::Unauthenticated if internal(status.message()) => {
tracing::info!(target: "monetize_site_buy::audit", event = "buy.refused_internal", "the shop's refusal named its machinery: {}", status.message());
NOT_DONE_HERE
}
Code::InvalidArgument | Code::FailedPrecondition | Code::AlreadyExists | Code::NotFound | Code::OutOfRange | Code::Unauthenticated => status.message(),
_ => SHOP_DID_NOT_ANSWER,
}
}
/// **The HTTP status of a refused order call** (the private-instance doors' map).
pub fn http_status_of(code: tonic::Code) -> StatusCode {
match code {
tonic::Code::InvalidArgument => StatusCode::BAD_REQUEST,
tonic::Code::AlreadyExists | tonic::Code::FailedPrecondition => StatusCode::CONFLICT,
tonic::Code::ResourceExhausted => StatusCode::SERVICE_UNAVAILABLE,
tonic::Code::NotFound => StatusCode::NOT_FOUND,
// The shop refused the site's word for who asks (a ticket spent, expired, or under a
// key it does not hold): the buyer may not do this as they are — 403, never a 502
// that reads as an outage (BUYFLOW-4). The words are [`WHO_NOT_CONFIRMED`].
tonic::Code::Unauthenticated | tonic::Code::PermissionDenied => StatusCode::FORBIDDEN,
_ => StatusCode::BAD_GATEWAY,
}
}
/// **An address from upstream a browser may be sent to** (a checkout's): `https://` with a
/// host, no credentials in it, nothing that is not plain printable ASCII. `None`: refuse.
pub fn https_url(raw: &str) -> Option<&str> {
if raw.is_empty() || raw.len() > 4096 || !raw.bytes().all(|b| b.is_ascii_graphic()) {
return None;
}
let uri: axum::http::Uri = raw.parse().ok()?;
let authority = uri.authority()?;
let host_ok = !authority.host().is_empty() && !authority.as_str().contains('@');
(uri.scheme_str() == Some("https") && host_ok).then_some(raw)
}
/// **monetize's link to a vendor as a browser may be given it**: empty (no customer step yet)
/// or an [`https_url`]. `None` is refused, never passed on.
pub fn checked_pay_url(raw: String) -> Option<String> {
(raw.is_empty() || https_url(&raw).is_some()).then_some(raw)
}
/// What a browser is told when monetize's checkout link is not one it may be sent to.
pub const PAY_URL_REFUSED: &str = "the payment address is not one this console will send a browser to";
/// **Where a sign-in should return to, if it is safe to go there**: a single-slash-rooted local
/// path with no control character or space; credential-shaped query parameters are dropped.
pub fn safe_next(raw: &str) -> Option<String> {
const CREDENTIAL_ISH: &[&str] = &["token", "access_token", "bearer", "api_key", "apikey", "key", "secret", "password", "code"];
if !raw.starts_with('/') || raw.starts_with("//") || raw.starts_with("/\\") {
return None;
}
if raw.chars().any(|c| c.is_control() || c.is_whitespace() || c == '\u{7f}') {
return None;
}
let (path, query) = match raw.split_once('?') {
Some((path, query)) => (path, Some(query)),
None => (raw, None),
};
let kept: Vec<&str> = query
.into_iter()
.flat_map(|q| q.split('&'))
.filter(|pair| !pair.is_empty())
.filter(|pair| {
let name = pair.split_once('=').map_or(*pair, |(name, _)| name);
!CREDENTIAL_ISH.contains(&name.to_ascii_lowercase().as_str())
})
.collect();
Some(if kept.is_empty() { path.to_owned() } else { format!("{path}?{}", kept.join("&")) })
}
/// `s` percent-encoded for a query value: the RFC 3986 unreserved set passes, every other byte
/// is `%XX`.
pub fn query_value(s: &str) -> String {
let mut out = String::with_capacity(s.len() * 3);
for b in s.bytes() {
if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~') {
out.push(b as char);
} else {
out.push_str(&format!("%{b:02X}"));
}
}
out
}
/// The gRPC metadata key monetize sends a customer's sentence under, beside a refusal
/// (`monetize_server::services::purchase::CUSTOMER_WORDS_KEY`).
pub const CUSTOMER_WORDS_KEY: &str = "monetize-customer-words-bin";
/// The customer's sentence riding on `status`, when monetize sent one that is not blank.
pub fn customer_words(status: &tonic::Status) -> Option<String> {
status.metadata().get_bin(CUSTOMER_WORDS_KEY).and_then(|v| v.to_bytes().ok()).and_then(|b| String::from_utf8(b.to_vec()).ok()).filter(|s| !s.trim().is_empty())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_shapes_are_monetizes() {
assert!(username("x14") && username("a.b-c_d") && !username("A1") && !username("x") && !username("-ab"));
assert!(address("ops@mock.invalid") && !address("ops@mock") && !address("a b@mock.invalid") && !address("@mock.invalid"));
assert!(company("acme") && company("a1") && !company("1a") && !company("Acme") && !company("seventeenletters1"));
assert!(plan_id("") && plan_id("gunnar/10gb-1m") && plan_id("holger/5gb@10000SEK") && !plan_id("gunnar") && !plan_id("gunnar/a..b") && !plan_id("x/y@1USD"));
assert!(session("cs_test_abcdefghij") && session(&format!("acct_{}", "0".repeat(32))) && !session("cs_short") && !session("../x"));
assert_eq!(email_refusal("", ""), Some("an e-mail address is required: it is where your welcome mail and receipt go"));
assert_eq!(email_refusal("ops@mock.invalid", "OPS@mock.invalid"), None);
}
#[test]
fn the_market_is_the_pages_else_the_browsers_else_the_default() {
assert_eq!(market_for(Some("fi"), Some("da")), Ok(Market::Fi));
assert_eq!(market_for(None, Some("en-GB,da;q=0.8,sv;q=0.9")), Ok(Market::Se));
assert_eq!(market_for(None, Some("en")), Ok(Market::Unspecified));
assert!(market_for(Some("US"), None).is_err());
assert_eq!(market_word(Market::Dk as i32), "DK");
}
#[test]
fn a_verdict_is_monetizes_sentence_and_an_outage_is_ours() {
assert_eq!(words_for_a_visitor(&tonic::Status::failed_precondition("username \"x14\" is taken; choose another")), "username \"x14\" is taken; choose another");
assert_eq!(words_for_a_visitor(&tonic::Status::unavailable("tcp connect error 10.0.0.1")), SHOP_DID_NOT_ANSWER);
// ★ A16 (owner 2026-10-23: a buyer never sees monetize): a ticket refusal is the buyer's
// sentence, and no sentence naming monetize, the ONE box or an appliance key gets through.
let key = tonic::Status::permission_denied("this monetize holds no appliance key for holger, so it cannot check who you are");
assert_eq!(words_for_a_visitor(&key), WHO_NOT_CONFIRMED);
assert_eq!(words_for_a_visitor(&tonic::Status::failed_precondition("this monetize sells no private estates")), NOT_DONE_HERE);
for w in [WHO_NOT_CONFIRMED, NOT_DONE_HERE] {
assert!(!w.to_lowercase().contains("monetize") && !w.contains("ONE box") && !w.contains("appliance"), "{w}");
}
assert_eq!(http_status_of(tonic::Code::AlreadyExists), StatusCode::CONFLICT);
assert_eq!(http_status_of(tonic::Code::Internal), StatusCode::BAD_GATEWAY);
assert_eq!(http_status_of(tonic::Code::PermissionDenied), StatusCode::FORBIDDEN);
// BUYFLOW-4: the operator's vocabulary never reaches a buyer; the customer's sentence does.
assert_eq!(words_for_a_visitor(&tonic::Status::failed_precondition("the cap of acme is past its lay")), NOT_DONE_HERE);
assert_eq!(words_for_a_visitor(&tonic::Status::failed_precondition("a capacity of 2 GB is capped here")), "a capacity of 2 GB is capped here");
let mut both = tonic::Status::failed_precondition("nothing to sell x14-repo: the order raises no cap");
both.metadata_mut().insert_bin(CUSTOMER_WORDS_KEY, tonic::metadata::MetadataValue::from_bytes("You have this room already.".as_bytes()));
assert_eq!(buyer_words(&both), "You have this room already.");
assert_eq!(buyer_words(&tonic::Status::not_found("no order for \"acme\"")), "no order for \"acme\"");
}
#[test]
fn only_https_and_local_paths_are_followed() {
assert!(https_url("https://checkout.stripe.com/c/pay/cs_x").is_some());
assert!(https_url("http://checkout.stripe.com/").is_none() && https_url("https://a@b/").is_none() && https_url("https:///x").is_none());
assert_eq!(safe_next("/signup?plan=private").as_deref(), Some("/signup?plan=private"));
assert!(safe_next("//evil.invalid").is_none() && safe_next("https://evil.invalid").is_none() && safe_next("/\\x").is_none());
assert_eq!(safe_next("/x?token=1&a=2").as_deref(), Some("/x?a=2"));
assert_eq!(query_value("/signup?plan=private&company=acme"), "%2Fsignup%3Fplan%3Dprivate%26company%3Dacme");
}
}