use std::collections::BTreeMap;
use std::sync::Arc;
use serde::Serialize;
use serde_json::Value;
use crate::client::{
build_request, default_headers, endpoint_path, error_for_inventory_problem, problem_string, release_access_from,
};
use crate::error::{ApiError, Error, Result};
use crate::http::{HttpTransport, Method};
use crate::release_access::ReleaseAccess;
use crate::types::{License, LicensingOptions};
use crate::url::{append_query, url_decode, url_encode};
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ReleaseInfo {
pub version: String,
pub description: String,
pub has_downloads: bool,
pub downloads_need_user: bool,
pub downloads_need_ownership: bool,
pub release_covered: bool,
pub release_access: Option<ReleaseAccess>,
pub upgrade_url: Option<String>,
}
impl Default for ReleaseInfo {
fn default() -> Self {
Self {
version: String::new(),
description: String::new(),
has_downloads: false,
downloads_need_user: false,
downloads_need_ownership: false,
release_covered: true,
release_access: None,
upgrade_url: None,
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DownloadTarget {
pub url: String,
pub filename: String,
}
const NIL_GUID: &str = "00000000-0000-0000-0000-000000000000";
pub fn can_download(license: &License, info: &ReleaseInfo) -> bool {
if !info.release_covered {
return false;
}
let owns = !license.trial;
let has_user = !license.issued_to.id.is_empty() && license.issued_to.id != NIL_GUID;
!(info.downloads_need_ownership && !owns) && !(info.downloads_need_user && !has_user)
}
pub(crate) fn filename_from_download_url(url: &str) -> String {
let decoded = url_decode(url);
let Some(at) = decoded.find("filename") else {
return String::new();
};
let mut rest = &decoded[at + "filename".len()..];
rest = rest.strip_prefix('*').unwrap_or(rest);
let Some(equals) = rest.find('=') else {
return String::new();
};
rest = &rest[equals + 1..];
if let Some(quote) = rest.find("''").filter(|"e| quote < 12) {
rest = &rest[quote + 2..];
}
let mut name = String::new();
let mut quoted = false;
for character in rest.chars() {
if character == '"' {
if quoted {
break;
}
quoted = true;
continue;
}
if !quoted && matches!(character, ';' | '&' | ' ') {
break;
}
name.push(character);
}
name
}
#[derive(Clone)]
pub struct InventoryClient {
options: Arc<LicensingOptions>,
transport: Arc<dyn HttpTransport>,
}
impl InventoryClient {
pub fn new(options: LicensingOptions, transport: Arc<dyn HttpTransport>) -> Self {
Self { options: Arc::new(options), transport }
}
fn product_path(&self) -> String {
endpoint_path(
&self.options,
&format!("/api/customer/inventory/products/{}", url_encode(&self.options.product_id)),
)
}
fn get(&self, url: String, license_token: &str) -> Result<Value> {
let mut headers = default_headers(&self.options, None);
if !license_token.is_empty() {
headers.insert("Authorization".into(), format!("LicenseToken {license_token}"));
}
let request = build_request(&self.options, Method::Get, url, headers, String::new());
let response = self.transport.send(&request)?;
if !response.is_success() {
return Err(error_for_inventory_problem(&response));
}
serde_json::from_str(&response.body).map_err(|error| {
Error::from(ApiError::new(response.status, format!("Could not parse inventory response: {error}")))
})
}
pub fn get_release(&self, version: &str, license_token: &str) -> Result<ReleaseInfo> {
let mut query = BTreeMap::from([("includeManifests".to_owned(), "false".to_owned())]);
if !version.is_empty() {
query.insert("version".into(), version.into());
}
let json = self.get(append_query(&self.product_path(), &query), license_token)?;
let text = |key: &str| json.get(key).and_then(Value::as_str).map(str::to_owned);
let flag = |key: &str| json.get(key).and_then(Value::as_bool).unwrap_or(false);
let access = json.get("releaseAccess").filter(|access| access.is_object());
Ok(ReleaseInfo {
version: text("version").or_else(|| text("currentVersion")).unwrap_or_default(),
description: text("releaseDescription").unwrap_or_default(),
has_downloads: json
.get("downloads")
.and_then(Value::as_array)
.is_some_and(|downloads| !downloads.is_empty()),
downloads_need_user: flag("downloadsNeedsUser"),
downloads_need_ownership: flag("downloadsNeedsOwnership"),
release_covered: access
.and_then(|access| access.get("coversRelease"))
.and_then(Value::as_bool)
.unwrap_or(true),
release_access: access.and_then(|_| release_access_from(&json)),
upgrade_url: access.and_then(|access| problem_string(access, "upgradeUrl")),
})
}
pub fn get_download_url(&self, platform_name: &str, license_token: &str) -> Result<DownloadTarget> {
let path = format!("{}/download/{}/latest", self.product_path(), url_encode(platform_name));
let query = BTreeMap::from([("redirect".to_owned(), "false".to_owned())]);
let json = self.get(append_query(&path, &query), license_token)?;
let url = json
.get("location")
.and_then(Value::as_str)
.ok_or_else(|| Error::from(ApiError::new(200, "Could not parse download response: missing location")))?
.to_owned();
Ok(DownloadTarget { filename: filename_from_download_url(&url), url })
}
}
impl std::fmt::Debug for InventoryClient {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("InventoryClient").field("endpoint", &self.options.endpoint).finish_non_exhaustive()
}
}
#[cfg(test)]
mod tests {
use super::filename_from_download_url;
#[test]
fn reads_the_filename_out_of_a_presigned_url() {
assert_eq!(
filename_from_download_url(
"https://s3/x?response-content-disposition=attachment%3B%20filename%3D%22Solstice%201.0.dmg%22&X-Amz=1"
),
"Solstice 1.0.dmg"
);
assert_eq!(
filename_from_download_url(
"https://s3/x?response-content-disposition=attachment%3B+filename*%3DUTF-8''Solstice.pkg&a=b"
),
"Solstice.pkg"
);
assert_eq!(filename_from_download_url("https://s3/x?a=b"), "");
}
}