use std::collections::{BTreeMap, HashMap};
use std::sync::{Arc, Mutex, PoisonError};
use std::time::{Duration, Instant};
use serde_json::Value;
use crate::device_id::DeviceIdResolver;
use crate::error::{ApiError, Error, Result};
use crate::http::{HttpRequest, HttpResponse, HttpTransport, Method};
use crate::release_access::{ReleaseAccess, ReleaseNotCovered};
use crate::sub_product::{SubProductCatalog, parse_catalog};
use crate::time::parse_api_timestamp;
use crate::types::{ActivationMethod, ActivationRequest, License, LicensingOptions, Platform};
use crate::url::{append_query, trim_trailing_slashes, url_encode};
use crate::validator::{LicenseValidator, decode_jwt_json, split_jwt};
use crate::version::parse_app_version;
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
pub(crate) fn sanitize_client_info(value: &str) -> String {
const MAX_LENGTH: usize = 256;
let mut result = String::new();
for character in value.chars() {
if character.is_ascii_control() || character == ' ' {
if !result.is_empty() && !result.ends_with(' ') {
result.push(' ');
}
} else {
result.push(character);
}
if result.len() >= MAX_LENGTH {
break;
}
}
while result.len() > MAX_LENGTH {
result.pop();
}
result.trim_end_matches(' ').to_owned()
}
pub(crate) fn endpoint_path(options: &LicensingOptions, path: &str) -> String {
format!("{}{path}", trim_trailing_slashes(&options.endpoint))
}
fn license_path(options: &LicensingOptions, action: &str) -> String {
endpoint_path(options, &format!("/api/client/licenses/{}/{action}", url_encode(&options.product_id)))
}
fn sub_products_path(options: &LicensingOptions) -> String {
license_path(options, "sub-products")
}
fn sub_product_path(options: &LicensingOptions, sub_product_id: &str, action: &str) -> String {
format!("{}/{}/{action}", sub_products_path(options), url_encode(sub_product_id))
}
fn client_query(options: &LicensingOptions) -> BTreeMap<String, String> {
let mut query = BTreeMap::from([("format".to_owned(), "JWT".to_owned())]);
if options.platform != Platform::Unknown {
query.insert("platform".into(), options.platform.as_str().into());
}
if let Some(version) = &options.application_version {
query.insert("appVersion".into(), version.clone());
}
for (key, value) in &options.metadata {
if !value.is_empty() {
query.insert(format!("meta[{key}]"), value.clone());
}
}
if !options.auto_activate_sub_products {
query.insert("autoActivateSubProducts".into(), "false".into());
}
query
}
pub(crate) fn default_headers(options: &LicensingOptions, content_type: Option<&str>) -> BTreeMap<String, String> {
let mut user_agent = format!("moonbase-rs/{VERSION}");
if let Some(client_info) = &options.client_info {
let client_info = sanitize_client_info(client_info);
if !client_info.is_empty() {
user_agent.push(' ');
user_agent.push_str(&client_info);
}
}
let mut headers = BTreeMap::from([
("Accept".to_owned(), "application/json, application/jwt, text/plain".to_owned()),
("User-Agent".to_owned(), user_agent),
("x-mb-client".to_owned(), "moonbase-rs".to_owned()),
]);
if let Some(content_type) = content_type {
headers.insert("Content-Type".into(), content_type.into());
}
headers
}
pub(crate) fn build_request(
options: &LicensingOptions,
method: Method,
url: String,
headers: BTreeMap<String, String>,
body: String,
) -> HttpRequest {
HttpRequest {
method,
url,
headers,
connect_timeout: options.http_connect_timeout,
request_timeout: options.http_request_timeout,
body,
}
}
#[derive(Debug, Default)]
pub(crate) struct ProblemDetails {
title: String,
detail: String,
error_type: String,
}
pub(crate) fn looks_like_markup(body: &str) -> bool {
body.trim_ascii_start().starts_with('<')
}
fn first_validation_error(problem: &Value) -> String {
let Some(errors) = problem.get("errors").and_then(Value::as_object) else {
return String::new();
};
for (field, messages) in errors {
if let Some(message) = messages.as_array().and_then(|list| list.first()).and_then(Value::as_str) {
return if field.is_empty() { message.to_owned() } else { format!("{field}: {message}") };
}
}
String::new()
}
pub(crate) fn parse_problem(body: &str) -> ProblemDetails {
const MAX_PLAIN_TEXT_LENGTH: usize = 200;
let mut result = ProblemDetails::default();
if body.is_empty() {
return result;
}
let text = |value: &Value, key: &str| value.get(key).and_then(Value::as_str).map(str::to_owned);
match serde_json::from_str::<Value>(body) {
Ok(Value::String(message)) => result.detail = message,
Ok(problem @ Value::Object(_)) => {
result.title = text(&problem, "title").unwrap_or_default();
result.detail = text(&problem, "detail").unwrap_or_default();
if result.detail.is_empty() {
result.detail = first_validation_error(&problem);
}
if result.detail.is_empty() && result.title.is_empty() {
result.detail = text(&problem, "message").unwrap_or_default();
}
result.error_type = text(&problem, "errorType").unwrap_or_default();
}
Ok(_) => {}
Err(_) => {
let text = body.trim_ascii();
if !text.is_empty()
&& text.len() <= MAX_PLAIN_TEXT_LENGTH
&& !looks_like_markup(text)
&& !text.contains(['\r', '\n'])
{
result.detail = text.to_owned();
}
}
}
result
}
fn problem_message(status: u16, problem: &ProblemDetails) -> String {
if !problem.detail.is_empty() {
problem.detail.clone()
} else if !problem.title.is_empty() {
problem.title.clone()
} else {
format!("Moonbase API request failed with HTTP {status}")
}
}
pub(crate) fn retry_after(response: &HttpResponse) -> Option<Duration> {
let text = response.header("retry-after")?.trim_ascii();
if text.is_empty() || text.len() > 9 || !text.bytes().all(|byte| byte.is_ascii_digit()) {
return None;
}
text.parse().ok().map(Duration::from_secs)
}
fn store_closed(problem: &ProblemDetails) -> Option<Error> {
(problem.error_type == "StoreClosed").then(|| {
Error::StoreClosed(if problem.detail.is_empty() {
"This store has closed.".to_owned()
} else {
problem.detail.clone()
})
})
}
fn api_error(response: &HttpResponse, problem: ProblemDetails) -> Error {
ApiError {
status_code: response.status,
message: problem_message(response.status, &problem),
title: problem.title,
detail: problem.detail,
retry_after: retry_after(response),
}
.into()
}
pub(crate) fn error_for_problem(response: &HttpResponse) -> Error {
let problem = parse_problem(&response.body);
if let Some(closed) = store_closed(&problem) {
return closed;
}
if problem.error_type == "LicenseExpired" {
return Error::LicenseExpired(problem_message(response.status, &problem));
}
if response.status == 400 {
let message = problem_message(response.status, &problem);
if problem.title.contains("expired") || problem.detail.contains("expired") {
return Error::LicenseExpired(message);
}
return Error::LicenseInvalid(message);
}
api_error(response, problem)
}
fn problem_object(body: &str) -> Option<Value> {
serde_json::from_str::<Value>(body).ok().filter(Value::is_object)
}
pub(crate) fn problem_string(problem: &Value, key: &str) -> Option<String> {
problem.get(key).and_then(Value::as_str).filter(|value| !value.trim_ascii().is_empty()).map(str::to_owned)
}
pub(crate) fn release_access_from(owner: &Value) -> Option<ReleaseAccess> {
let value = owner.get("releaseAccess").filter(|value| value.is_object())?;
let access = ReleaseAccess {
updates_until: problem_string(value, "updatesUntil").and_then(|until| parse_api_timestamp(&until).ok()),
up_to_major: value.get("upToMajor").and_then(Value::as_u64),
up_to_version: problem_string(value, "upToVersion"),
};
(access.updates_until.is_some() || access.up_to_major.is_some()).then_some(access)
}
fn app_version_text(options: &LicensingOptions) -> String {
let Some(version) = &options.application_version else {
return String::new();
};
parse_app_version(version).map_or_else(|| version.clone(), |parsed| parsed.to_string())
}
pub(crate) fn error_for_inventory_problem(response: &HttpResponse) -> Error {
let problem = parse_problem(&response.body);
if let Some(closed) = store_closed(&problem) {
return closed;
}
if problem.error_type == "ReleaseNotCovered" {
let body = problem_object(&response.body);
return Error::ReleaseNotCovered(Box::new(ReleaseNotCovered::new(
if problem.detail.is_empty() { "Your license doesn't cover this version".into() } else { problem.detail },
None,
String::new(),
body.as_ref().and_then(release_access_from),
body.as_ref().and_then(|body| problem_string(body, "upgradeUrl")),
)));
}
api_error(response, problem)
}
fn error_for_sub_product_problem(response: &HttpResponse) -> Error {
let problem = parse_problem(&response.body);
if let Some(closed) = store_closed(&problem) {
return closed;
}
let message = problem_message(response.status, &problem);
if problem.error_type == "LicenseExpired" {
return Error::LicenseExpired(message);
}
if response.status == 400 {
return match problem.error_type.as_str() {
"LicenseInvalid" | "LicenseRevoked" | "LicenseActivationRevoked" => Error::LicenseInvalid(message),
"NoEligibleLicense" => Error::NoEligibleLicense(message),
_ => Error::OperationNotSupported(message),
};
}
api_error(response, problem)
}
fn error_for_activation_poll_problem(response: &HttpResponse) -> Error {
if response.status != 400 {
return error_for_problem(response);
}
let problem = parse_problem(&response.body);
if let Some(closed) = store_closed(&problem) {
return closed;
}
let reason = if problem.detail.is_empty() { &problem.title } else { &problem.detail };
if problem.error_type == "LicenseExpired" {
return Error::LicenseExpired(if reason.is_empty() {
"The license has expired".to_owned()
} else {
reason.clone()
});
}
Error::ActivationRequestEnded(if reason.is_empty() {
"Activation request can no longer be completed (HTTP 400)".to_owned()
} else {
format!("Activation request can no longer be completed: {reason}")
})
}
fn ensure_token_response(response: &HttpResponse) -> Result<()> {
let is_token = split_jwt(response.body.trim_ascii())
.and_then(|[header, _, _]| decode_jwt_json(header, "header"))
.is_ok_and(|header| header.is_object());
if is_token {
return Ok(());
}
Err(ApiError::new(
response.status,
format!(
"Moonbase API answered HTTP {} without a license token; a proxy or captive portal may have intercepted \
the request",
response.status
),
)
.into())
}
pub(crate) const ACTIVATION_POLL_MIN_INTERVAL: Duration = Duration::from_secs(1);
pub(crate) const ACTIVATION_POLL_MAX_BACKOFF: Duration = Duration::from_secs(300);
#[derive(Debug, Default)]
pub(crate) struct ActivationPollGate {
state: Mutex<PollGateState>,
}
#[derive(Debug, Default)]
struct PollGateState {
last_poll: HashMap<String, Instant>,
hold_until: Option<Instant>,
}
impl ActivationPollGate {
pub(crate) fn try_claim(&self, request_url: &str, now: Instant) -> bool {
let mut state = self.state.lock().unwrap_or_else(PoisonError::into_inner);
if state.hold_until.is_some_and(|hold| now < hold) {
return false;
}
state.last_poll.retain(|_, polled| now.saturating_duration_since(*polled) < ACTIVATION_POLL_MIN_INTERVAL);
if state.last_poll.contains_key(request_url) {
return false;
}
state.last_poll.insert(request_url.to_owned(), now);
true
}
pub(crate) fn back_off(&self, retry_after: Duration, now: Instant) {
let mut state = self.state.lock().unwrap_or_else(PoisonError::into_inner);
let until = now + retry_after.min(ACTIVATION_POLL_MAX_BACKOFF);
state.hold_until = Some(state.hold_until.map_or(until, |hold| hold.max(until)));
}
}
#[derive(Clone)]
pub struct LicenseClient {
options: Arc<LicensingOptions>,
device_ids: Arc<dyn DeviceIdResolver>,
validator: Arc<LicenseValidator>,
transport: Arc<dyn HttpTransport>,
poll_gate: Arc<ActivationPollGate>,
}
impl LicenseClient {
pub fn new(
options: LicensingOptions,
device_ids: Arc<dyn DeviceIdResolver>,
validator: Arc<LicenseValidator>,
transport: Arc<dyn HttpTransport>,
) -> Self {
Self { options: Arc::new(options), device_ids, validator, transport, poll_gate: Arc::default() }
}
pub fn request_activation(&self, method: ActivationMethod) -> Result<ActivationRequest> {
let options = &*self.options;
let mut query = client_query(options);
query.insert("method".into(), method.as_str().into());
let url = append_query(
&endpoint_path(options, &format!("/api/client/activations/{}/request", url_encode(&options.product_id))),
&query,
);
let mut device_name = self.device_ids.device_name();
if device_name.trim().is_empty() {
device_name = "Unknown device".into();
}
let device_id = self.device_ids.device_id()?;
if device_id.trim().is_empty() {
return Err(Error::Configuration("The device id resolver returned an empty device id".into()));
}
let payload = serde_json::json!({
"deviceName": device_name,
"deviceSignature": device_id,
});
let request = build_request(
options,
Method::Post,
url,
default_headers(options, Some("application/json")),
payload.to_string(),
);
let response = self.transport.send(&request)?;
if !response.is_success() {
return Err(self.error_for_license_problem(&response));
}
let mut activation: ActivationRequest = serde_json::from_str(&response.body).map_err(|error| {
Error::from(ApiError::new(response.status, format!("Could not parse activation response: {error}")))
})?;
activation.method = method;
Ok(activation)
}
pub fn get_requested_activation(&self, activation: &ActivationRequest) -> Result<Option<License>> {
if !self.poll_gate.try_claim(&activation.request_url, Instant::now()) {
return Ok(None);
}
let request = build_request(
&self.options,
Method::Get,
activation.request_url.clone(),
default_headers(&self.options, None),
String::new(),
);
let response = self.transport.send(&request)?;
if response.status == 204 || response.status == 404 {
return Ok(None);
}
if !response.is_success() {
if let Some(wait) = retry_after(&response).filter(|_| matches!(response.status, 429 | 503)) {
self.poll_gate.back_off(wait, Instant::now());
}
return Err(error_for_activation_poll_problem(&response));
}
ensure_token_response(&response)?;
self.validator.validate_token_ignoring_release_access(&response.body).map(Some)
}
pub fn validate_token_online(&self, token: &str) -> Result<License> {
let response = self.post_token(&license_path(&self.options, "validate"), token)?;
if !response.is_success() {
return Err(self.error_for_license_problem(&response));
}
ensure_token_response(&response)?;
self.validator.validate_token(&response.body)
}
pub fn revoke_activation(&self, token: &str) -> Result<()> {
let response = self.post_token(&license_path(&self.options, "revoke"), token)?;
if !response.is_success() {
return Err(self.error_for_license_problem(&response));
}
if looks_like_markup(&response.body) {
return Err(ApiError::new(
response.status,
format!(
"Moonbase API answered HTTP {} with a web page; a proxy or captive portal may have intercepted \
the request",
response.status
),
)
.into());
}
Ok(())
}
pub fn activate_sub_product(&self, token: &str, sub_product_id: &str) -> Result<License> {
self.post_for_sub_product(token, sub_product_id, "activate")
}
pub fn request_sub_product_trial(&self, token: &str, sub_product_id: &str) -> Result<License> {
self.post_for_sub_product(token, sub_product_id, "trial")
}
pub fn list_sub_products(&self, token: &str) -> Result<SubProductCatalog> {
let request = build_request(
&self.options,
Method::Post,
sub_products_path(&self.options),
default_headers(&self.options, Some("text/plain")),
token.to_owned(),
);
let response = self.transport.send(&request)?;
if !response.is_success() {
return Err(error_for_sub_product_problem(&response));
}
serde_json::from_str::<Value>(&response.body)
.map_err(|error| error.to_string())
.and_then(|value| parse_catalog(&value))
.map_err(|error| ApiError::new(response.status, format!("Could not parse the add-on list: {error}")).into())
}
fn post_for_sub_product(&self, token: &str, sub_product_id: &str, action: &str) -> Result<License> {
if sub_product_id.trim().is_empty() {
return Err(Error::Configuration("An add-on id is required".into()));
}
let response = self.post_token(&sub_product_path(&self.options, sub_product_id, action), token)?;
if !response.is_success() {
return Err(error_for_sub_product_problem(&response));
}
ensure_token_response(&response)?;
self.validator.validate_token_ignoring_release_access(&response.body)
}
fn error_for_license_problem(&self, response: &HttpResponse) -> Error {
match problem_object(&response.body) {
Some(problem) if problem_string(&problem, "errorType").as_deref() == Some("ReleaseNotCovered") => {
self.release_not_covered(&problem)
}
_ => error_for_problem(response),
}
}
fn release_not_covered(&self, problem: &Value) -> Error {
let license = problem_string(problem, "license")
.and_then(|token| self.validator.validate_token_ignoring_release_access(&token).ok());
let app_version = problem_string(problem, "appVersion").unwrap_or_else(|| app_version_text(&self.options));
let message = problem_string(problem, "detail")
.unwrap_or_else(|| format!("The license doesn't cover version {app_version}"));
Error::ReleaseNotCovered(Box::new(ReleaseNotCovered::new(
message,
license,
app_version,
release_access_from(problem),
problem_string(problem, "upgradeUrl"),
)))
}
fn post_token(&self, path: &str, token: &str) -> Result<HttpResponse> {
let request = build_request(
&self.options,
Method::Post,
append_query(path, &client_query(&self.options)),
default_headers(&self.options, Some("text/plain")),
token.to_owned(),
);
self.transport.send(&request)
}
}
impl std::fmt::Debug for LicenseClient {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("LicenseClient")
.field("endpoint", &self.options.endpoint)
.field("product_id", &self.options.product_id)
.finish_non_exhaustive()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn client_info_is_sanitised_and_capped() {
assert_eq!(sanitize_client_info(" a/1\r\nX-Injected: yes\t b/2 "), "a/1 X-Injected: yes b/2");
assert_eq!(sanitize_client_info("\n\t "), "");
assert_eq!(sanitize_client_info(&"x".repeat(1000)).len(), 256);
assert!(sanitize_client_info(&"é".repeat(300)).len() <= 256);
}
#[test]
fn poll_gate_spaces_each_request_separately() {
let gate = ActivationPollGate::default();
let start = Instant::now();
assert!(gate.try_claim("a", start));
assert!(!gate.try_claim("a", start + Duration::from_millis(500)));
assert!(gate.try_claim("b", start + Duration::from_millis(500)));
assert!(gate.try_claim("a", start + Duration::from_millis(1000)));
}
#[test]
fn poll_gate_backs_off_everything_and_caps_the_wait() {
let gate = ActivationPollGate::default();
let start = Instant::now();
gate.back_off(Duration::from_secs(3600), start);
assert!(!gate.try_claim("a", start + Duration::from_secs(299)));
assert!(gate.try_claim("a", start + Duration::from_secs(300)));
}
}