Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/monetize

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");
    }
}