feat(shared): implement core clipboard cleaning and license signature validation
This commit is contained in:
@@ -0,0 +1 @@
|
||||
tab_spaces = 2
|
||||
@@ -0,0 +1,14 @@
|
||||
[package]
|
||||
name = "shared"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
authors.workspace = true
|
||||
|
||||
[dependencies]
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
ed25519-dalek = { workspace = true }
|
||||
base64 = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
url = { workspace = true }
|
||||
log = { workspace = true }
|
||||
@@ -0,0 +1,218 @@
|
||||
use crate::types::CleanConfig;
|
||||
use url::Url;
|
||||
|
||||
pub fn clean_whitespace(input: &str) -> String {
|
||||
input
|
||||
.chars()
|
||||
.filter(|&c| c != '\u{200b}' && c != '\x00')
|
||||
.collect::<String>()
|
||||
.trim()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
pub fn fix_typography(input: &str) -> String {
|
||||
let mut result = String::with_capacity(input.len());
|
||||
|
||||
for c in input.chars() {
|
||||
match c {
|
||||
'\u{201c}' | '\u{201d}' => result.push('"'),
|
||||
'\u{2018}' | '\u{2019}' => result.push('\''),
|
||||
'\u{2014}' => result.push_str("--"),
|
||||
'\u{2013}' => result.push('-'),
|
||||
_ => result.push(c),
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
fn is_markdown_line(line: &str) -> bool {
|
||||
let trimmed = line.trim_start();
|
||||
|
||||
trimmed.starts_with("- ")
|
||||
|| trimmed.starts_with("* ")
|
||||
|| trimmed.starts_with("#")
|
||||
|| (trimmed.len() > 2
|
||||
&& trimmed.chars().next().unwrap().is_ascii_digit()
|
||||
&& trimmed.contains(". "))
|
||||
}
|
||||
|
||||
pub fn normalize_newlines(input: &str) -> String {
|
||||
let normalized = input.replace("\r\n", "\n");
|
||||
let without_hyphens = normalized.replace("-\n", "");
|
||||
let lines: Vec<&str> = without_hyphens.lines().collect();
|
||||
|
||||
let mut result = String::new();
|
||||
|
||||
for i in 0..lines.len() {
|
||||
let current = lines[i];
|
||||
result.push_str(current);
|
||||
|
||||
if i < lines.len() - 1 {
|
||||
let next = lines[i + 1];
|
||||
|
||||
if current.is_empty()
|
||||
|| next.is_empty()
|
||||
|| is_markdown_line(next)
|
||||
|| is_markdown_line(current)
|
||||
{
|
||||
result.push('\n');
|
||||
} else {
|
||||
result.push(' ');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
pub fn scrub_url_trackers(input: &str, custom_params: &[String]) -> String {
|
||||
let mut result = String::new();
|
||||
let mut current_idx = 0;
|
||||
let chars: Vec<char> = input.chars().collect();
|
||||
|
||||
while current_idx < chars.len() {
|
||||
if (current_idx + 7 <= chars.len()
|
||||
&& chars[current_idx..current_idx + 7] == ['h', 't', 't', 'p', ':', '/', '/'])
|
||||
|| (current_idx + 8 <= chars.len()
|
||||
&& chars[current_idx..current_idx + 8] == ['h', 't', 't', 'p', 's', ':', '/', '/'])
|
||||
{
|
||||
let start = current_idx;
|
||||
|
||||
while current_idx < chars.len() && !chars[current_idx].is_whitespace() {
|
||||
current_idx += 1;
|
||||
}
|
||||
|
||||
let url_str: String = chars[start..current_idx].iter().collect();
|
||||
|
||||
if let Ok(mut url) = Url::parse(&url_str) {
|
||||
if url.query().is_some() {
|
||||
let query_pairs: Vec<(String, String)> = url
|
||||
.query_pairs()
|
||||
.map(|(k, v)| (k.into_owned(), v.into_owned()))
|
||||
.filter(|(k, _)| {
|
||||
let kl = k.to_lowercase();
|
||||
!kl.starts_with("utm_")
|
||||
&& kl != "gclid"
|
||||
&& kl != "fbclid"
|
||||
&& kl != "si"
|
||||
&& !custom_params.iter().any(|p| p.to_lowercase() == kl)
|
||||
})
|
||||
.collect();
|
||||
|
||||
url.set_query(None);
|
||||
|
||||
if !query_pairs.is_empty() {
|
||||
let mut serializer = url.query_pairs_mut();
|
||||
|
||||
for (k, v) in query_pairs {
|
||||
serializer.append_pair(&k, &v);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result.push_str(url.as_str());
|
||||
} else {
|
||||
result.push_str(&url_str);
|
||||
}
|
||||
} else {
|
||||
result.push(chars[current_idx]);
|
||||
current_idx += 1;
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
pub fn sanitize_text(input: &str, config: &CleanConfig) -> String {
|
||||
let mut text = clean_whitespace(input);
|
||||
|
||||
if config.fix_quotes {
|
||||
text = fix_typography(&text);
|
||||
}
|
||||
|
||||
if config.fix_newlines {
|
||||
text = normalize_newlines(&text);
|
||||
}
|
||||
|
||||
if config.strip_utms {
|
||||
text = scrub_url_trackers(&text, &config.custom_params);
|
||||
}
|
||||
|
||||
text
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_whitespace() {
|
||||
let input = "\u{200b} hello\x00world\t \n ";
|
||||
let result = clean_whitespace(input);
|
||||
assert_eq!(result, "helloworld");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_typography() {
|
||||
let input = "\u{201c}hello\u{201d} \u{2018}world\u{2019}\u{2014}dash\u{2013}en";
|
||||
let result = fix_typography(input);
|
||||
assert_eq!(result, "\"hello\" 'world'--dash-en");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_newlines() {
|
||||
let input = "inno-\nvation is\ngreat.\n\nParagraph 2.\n- Item 1\n- Item 2";
|
||||
let result = normalize_newlines(input);
|
||||
let expected = "innovation is great.\n\nParagraph 2.\n- Item 1\n- Item 2";
|
||||
assert_eq!(result, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_url_scrubber() {
|
||||
let input =
|
||||
"Check https://example.com/path?utm_source=twitter&id=1024&gclid=123#frag and simple text.";
|
||||
let result = scrub_url_trackers(input, &[]);
|
||||
let expected = "Check https://example.com/path?id=1024#frag and simple text.";
|
||||
assert_eq!(result, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_url_scrubber_custom_params() {
|
||||
let input = "https://example.com/page?ref=home&tracking_id=abc&id=99";
|
||||
let result = scrub_url_trackers(input, &["ref".to_string(), "tracking_id".to_string()]);
|
||||
let expected = "https://example.com/page?id=99";
|
||||
assert_eq!(result, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_url_scrubber_custom_params_only() {
|
||||
let input = "https://example.com/page?campaign=summer&fbclid=xyz&token=secret";
|
||||
let result = scrub_url_trackers(input, &["campaign".to_string()]);
|
||||
let expected = "https://example.com/page?token=secret";
|
||||
assert_eq!(result, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_url_scrubber_custom_params_case_insensitive() {
|
||||
let input = "https://example.com/page?Ref=home&REF=other&id=1";
|
||||
let result = scrub_url_trackers(input, &["ref".to_string()]);
|
||||
let expected = "https://example.com/page?id=1";
|
||||
assert_eq!(result, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_master_pipeline() {
|
||||
let config = CleanConfig {
|
||||
strip_utms: true,
|
||||
fix_quotes: true,
|
||||
fix_newlines: true,
|
||||
..CleanConfig::default()
|
||||
};
|
||||
|
||||
let input = "\u{201c}inno-\nvation\u{201d} on https://example.com?utm_source=test&id=123";
|
||||
let result = sanitize_text(input, &config);
|
||||
let expected = "\"innovation\" on https://example.com/?id=123";
|
||||
assert_eq!(result, expected);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
pub mod cleanser;
|
||||
pub mod licensing;
|
||||
pub mod types;
|
||||
@@ -0,0 +1,306 @@
|
||||
use base64::{engine::general_purpose::STANDARD, Engine as _};
|
||||
use ed25519_dalek::{Signature, Verifier, VerifyingKey};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use crate::types::{LicensePayload, LicensingProfiles, Profile};
|
||||
|
||||
const PROFILES_JSON: &str = include_str!("../../licensing_profiles.json");
|
||||
|
||||
static ACTIVE_PROFILE: OnceLock<Profile> = OnceLock::new();
|
||||
|
||||
pub fn get_active_profile() -> &'static Profile {
|
||||
ACTIVE_PROFILE.get_or_init(|| {
|
||||
let profiles: LicensingProfiles = serde_json::from_str(PROFILES_JSON)
|
||||
.expect("Failed to parse compile-time licensing_profiles.json");
|
||||
|
||||
if cfg!(test) {
|
||||
profiles.testing
|
||||
} else if cfg!(debug_assertions) {
|
||||
profiles.development
|
||||
} else {
|
||||
profiles.production
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
static PUBLIC_KEY_CACHE: OnceLock<[u8; 32]> = OnceLock::new();
|
||||
|
||||
pub fn decode_public_key_hex(hex: &str) -> [u8; 32] {
|
||||
let hex = hex.trim();
|
||||
|
||||
if hex.len() != 64 {
|
||||
panic!("Hex public key must be exactly 64 characters");
|
||||
}
|
||||
|
||||
let mut bytes = [0u8; 32];
|
||||
|
||||
for i in 0..32 {
|
||||
let byte_str = &hex[i * 2..i * 2 + 2];
|
||||
bytes[i] = u8::from_str_radix(byte_str, 16).expect("Invalid hex in public key");
|
||||
}
|
||||
|
||||
bytes
|
||||
}
|
||||
|
||||
pub fn get_public_key_bytes() -> [u8; 32] {
|
||||
if let Some(cached) = PUBLIC_KEY_CACHE.get() {
|
||||
return *cached;
|
||||
}
|
||||
|
||||
let bytes = decode_public_key_hex(&get_active_profile().public_key_hex);
|
||||
let _ = PUBLIC_KEY_CACHE.set(bytes);
|
||||
|
||||
bytes
|
||||
}
|
||||
|
||||
pub fn get_all_valid_key_bytes() -> Vec<[u8; 32]> {
|
||||
let profile = get_active_profile();
|
||||
let mut keys = vec![decode_public_key_hex(&profile.public_key_hex)];
|
||||
|
||||
for hex in &profile.deprecated_public_keys {
|
||||
keys.push(decode_public_key_hex(hex));
|
||||
}
|
||||
|
||||
keys
|
||||
}
|
||||
|
||||
pub fn get_activation_url() -> &'static str {
|
||||
&get_active_profile().activation_url
|
||||
}
|
||||
|
||||
static LICENSE_ACTIVE: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
pub fn set_license_active(active: bool) {
|
||||
LICENSE_ACTIVE.store(active, Ordering::Relaxed);
|
||||
log::info!("License active cache set to {}", active);
|
||||
}
|
||||
|
||||
pub fn is_license_active() -> bool {
|
||||
LICENSE_ACTIVE.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
pub fn verify_license_with_key(
|
||||
license_token: &str,
|
||||
public_key_bytes: &[u8],
|
||||
current_hwid: &str,
|
||||
) -> Result<LicensePayload, String> {
|
||||
let parts: Vec<&str> = license_token.trim().split('.').collect();
|
||||
|
||||
if parts.len() != 2 {
|
||||
return Err("Invalid license token format. Expected PAYLOAD.SIGNATURE".to_string());
|
||||
}
|
||||
|
||||
let payload_b64 = parts[0];
|
||||
let signature_b64 = parts[1];
|
||||
|
||||
let payload_bytes = STANDARD
|
||||
.decode(payload_b64)
|
||||
.map_err(|e| format!("Failed to decode payload: {}", e))?;
|
||||
|
||||
let payload_str =
|
||||
String::from_utf8(payload_bytes).map_err(|e| format!("Invalid UTF-8 in payload: {}", e))?;
|
||||
|
||||
let payload: LicensePayload = serde_json::from_str(&payload_str)
|
||||
.map_err(|e| format!("Failed to parse payload JSON: {}", e))?;
|
||||
|
||||
let signature_bytes = STANDARD
|
||||
.decode(signature_b64)
|
||||
.map_err(|e| format!("Failed to decode signature: {}", e))?;
|
||||
|
||||
if signature_bytes.len() != 64 {
|
||||
return Err("Signature must be 64 bytes".to_string());
|
||||
}
|
||||
|
||||
let sig_arr: [u8; 64] = signature_bytes
|
||||
.try_into()
|
||||
.map_err(|_| "Signature convert failed".to_string())?;
|
||||
|
||||
let signature = Signature::from_bytes(&sig_arr);
|
||||
|
||||
let pub_key_arr: [u8; 32] = public_key_bytes
|
||||
.try_into()
|
||||
.map_err(|_| "Invalid public key length".to_string())?;
|
||||
|
||||
let verifying_key =
|
||||
VerifyingKey::from_bytes(&pub_key_arr).map_err(|e| format!("Invalid verifying key: {}", e))?;
|
||||
|
||||
verifying_key
|
||||
.verify(payload_b64.as_bytes(), &signature)
|
||||
.map_err(|e| format!("Signature verification failed: {}", e))?;
|
||||
|
||||
if payload.hwid != current_hwid {
|
||||
return Err("License does not match this machine's Hardware ID".to_string());
|
||||
}
|
||||
|
||||
if payload.product_id != "cleancopy" {
|
||||
return Err("License is not valid for this product".to_string());
|
||||
}
|
||||
|
||||
Ok(payload)
|
||||
}
|
||||
|
||||
pub fn verify_license_with_any_key(
|
||||
license_token: &str,
|
||||
public_keys: &[[u8; 32]],
|
||||
current_hwid: &str,
|
||||
) -> Result<LicensePayload, String> {
|
||||
let mut last_err = String::new();
|
||||
|
||||
for key in public_keys {
|
||||
match verify_license_with_key(license_token, key, current_hwid) {
|
||||
Ok(payload) => return Ok(payload),
|
||||
Err(e) => last_err = e,
|
||||
}
|
||||
}
|
||||
|
||||
Err(last_err)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use base64::{engine::general_purpose::STANDARD, Engine as _};
|
||||
use ed25519_dalek::Signer;
|
||||
|
||||
#[test]
|
||||
fn test_offline_validation() {
|
||||
let secret = [42u8; 32];
|
||||
let signing_key = ed25519_dalek::SigningKey::from_bytes(&secret);
|
||||
let verifying_key = signing_key.verifying_key();
|
||||
let pub_key_bytes = verifying_key.to_bytes();
|
||||
|
||||
let dummy_hwid = "test-machine-hwid-abc-123".to_string();
|
||||
let payload = LicensePayload {
|
||||
product_id: "cleancopy".to_string(),
|
||||
license_key: "pi_12345".to_string(),
|
||||
hwid: dummy_hwid.clone(),
|
||||
max_devices: None,
|
||||
expires_at: None,
|
||||
device_count: None,
|
||||
};
|
||||
|
||||
let payload_str = serde_json::to_string(&payload).unwrap();
|
||||
let payload_b64 = STANDARD.encode(payload_str.as_bytes());
|
||||
|
||||
let signature = signing_key.sign(payload_b64.as_bytes());
|
||||
let signature_b64 = STANDARD.encode(signature.to_bytes());
|
||||
|
||||
let license_token = format!("{}.{}", payload_b64, signature_b64);
|
||||
|
||||
let verify_res = verify_license_with_key(&license_token, &pub_key_bytes, &dummy_hwid);
|
||||
assert!(verify_res.is_ok());
|
||||
|
||||
let verify_res_wrong_hwid =
|
||||
verify_license_with_key(&license_token, &pub_key_bytes, "other-machine-hwid");
|
||||
assert!(verify_res_wrong_hwid.is_err());
|
||||
|
||||
let tampered_license = format!("{}.{}", payload_b64, STANDARD.encode([0u8; 64]));
|
||||
let verify_res_tampered =
|
||||
verify_license_with_key(&tampered_license, &pub_key_bytes, &dummy_hwid);
|
||||
assert!(verify_res_tampered.is_err());
|
||||
|
||||
let wrong_payload = LicensePayload {
|
||||
product_id: "other_app".to_string(),
|
||||
license_key: "pi_12345".to_string(),
|
||||
hwid: dummy_hwid.clone(),
|
||||
max_devices: None,
|
||||
expires_at: None,
|
||||
device_count: None,
|
||||
};
|
||||
let wrong_payload_str = serde_json::to_string(&wrong_payload).unwrap();
|
||||
let wrong_payload_b64 = STANDARD.encode(wrong_payload_str.as_bytes());
|
||||
let wrong_signature = signing_key.sign(wrong_payload_b64.as_bytes());
|
||||
let wrong_signature_b64 = STANDARD.encode(wrong_signature.to_bytes());
|
||||
let wrong_token = format!("{}.{}", wrong_payload_b64, wrong_signature_b64);
|
||||
|
||||
let verify_res_wrong_product =
|
||||
verify_license_with_key(&wrong_token, &pub_key_bytes, &dummy_hwid);
|
||||
assert!(verify_res_wrong_product.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_profile_configuration_loading() {
|
||||
let active = get_active_profile();
|
||||
assert!(!active.activation_url.is_empty());
|
||||
assert_eq!(active.public_key_hex.len(), 64);
|
||||
|
||||
let bytes = get_public_key_bytes();
|
||||
assert_eq!(bytes.len(), 32);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deprecated_key_verification() {
|
||||
let old_secret = [11u8; 32];
|
||||
let new_secret = [22u8; 32];
|
||||
let old_signing_key = ed25519_dalek::SigningKey::from_bytes(&old_secret);
|
||||
let new_signing_key = ed25519_dalek::SigningKey::from_bytes(&new_secret);
|
||||
let old_pub_key = old_signing_key.verifying_key().to_bytes();
|
||||
let new_pub_key = new_signing_key.verifying_key().to_bytes();
|
||||
|
||||
let dummy_hwid = "test-machine-hwid-deprecated".to_string();
|
||||
let payload = LicensePayload {
|
||||
product_id: "cleancopy".to_string(),
|
||||
license_key: "pi_99999".to_string(),
|
||||
hwid: dummy_hwid.clone(),
|
||||
max_devices: None,
|
||||
expires_at: None,
|
||||
device_count: None,
|
||||
};
|
||||
|
||||
let payload_str = serde_json::to_string(&payload).unwrap();
|
||||
let payload_b64 = STANDARD.encode(payload_str.as_bytes());
|
||||
|
||||
let signature = old_signing_key.sign(payload_b64.as_bytes());
|
||||
let signature_b64 = STANDARD.encode(signature.to_bytes());
|
||||
let token = format!("{}.{}", payload_b64, signature_b64);
|
||||
|
||||
let keys_only_new = vec![new_pub_key];
|
||||
assert!(
|
||||
verify_license_with_any_key(&token, &keys_only_new, &dummy_hwid).is_err(),
|
||||
"Should fail when only new key is available"
|
||||
);
|
||||
|
||||
let keys_with_old = vec![new_pub_key, old_pub_key];
|
||||
assert!(
|
||||
verify_license_with_any_key(&token, &keys_with_old, &dummy_hwid).is_ok(),
|
||||
"Should succeed when deprecated key is in the list"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_offline_token_format_verification() {
|
||||
let secret = [77u8; 32];
|
||||
let signing_key = ed25519_dalek::SigningKey::from_bytes(&secret);
|
||||
let verifying_key = signing_key.verifying_key();
|
||||
let pub_key_bytes = verifying_key.to_bytes();
|
||||
|
||||
let dummy_hwid = "test-offline-token-hwid".to_string();
|
||||
let payload = LicensePayload {
|
||||
product_id: "cleancopy".to_string(),
|
||||
license_key: "manual_activation".to_string(),
|
||||
hwid: dummy_hwid.clone(),
|
||||
max_devices: None,
|
||||
expires_at: None,
|
||||
device_count: None,
|
||||
};
|
||||
|
||||
let payload_str = serde_json::to_string(&payload).unwrap();
|
||||
let payload_b64 = STANDARD.encode(payload_str.as_bytes());
|
||||
let signature = signing_key.sign(payload_b64.as_bytes());
|
||||
let signature_b64 = STANDARD.encode(signature.to_bytes());
|
||||
let token = format!("{}.{}", payload_b64, signature_b64);
|
||||
|
||||
assert!(token.contains('.'), "Token must contain period separator");
|
||||
|
||||
let keys = vec![pub_key_bytes];
|
||||
let result = verify_license_with_any_key(&token, &keys, &dummy_hwid);
|
||||
assert!(result.is_ok(), "Offline token should verify successfully");
|
||||
|
||||
let result_wrong_hwid = verify_license_with_any_key(&token, &keys, "wrong-hwid");
|
||||
assert!(
|
||||
result_wrong_hwid.is_err(),
|
||||
"Offline token should reject wrong HWID"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CleanConfig {
|
||||
pub strip_utms: bool,
|
||||
pub fix_quotes: bool,
|
||||
pub fix_newlines: bool,
|
||||
pub play_sound: bool,
|
||||
pub autostart: bool,
|
||||
pub hotkey: String,
|
||||
#[serde(default)]
|
||||
pub custom_params: Vec<String>,
|
||||
}
|
||||
|
||||
impl Default for CleanConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
strip_utms: true,
|
||||
fix_quotes: true,
|
||||
fix_newlines: true,
|
||||
play_sound: true,
|
||||
autostart: false,
|
||||
hotkey: "Alt+Shift+C".to_string(),
|
||||
custom_params: vec![],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct Profile {
|
||||
pub activation_url: String,
|
||||
pub public_key_hex: String,
|
||||
#[serde(default)]
|
||||
pub deprecated_public_keys: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct LicensingProfiles {
|
||||
pub development: Profile,
|
||||
pub testing: Profile,
|
||||
pub production: Profile,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct LicensePayload {
|
||||
pub product_id: String,
|
||||
pub license_key: String,
|
||||
pub hwid: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub max_devices: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub expires_at: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub device_count: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct LicenseCapabilities {
|
||||
pub device_limit: Option<u32>,
|
||||
pub expires_at: Option<String>,
|
||||
pub is_active: bool,
|
||||
}
|
||||
Reference in New Issue
Block a user