Skip to main content

oxo_license/
lib.rs

1//! Generic dual-license runtime verification library for Traitome Rust projects.
2//!
3//! # Overview
4//!
5//! `oxo-license` provides an Ed25519-based license verification system that
6//! can be embedded into any Rust application. It supports:
7//!
8//! - **Any number of license types** (e.g., `"academic"`, `"commercial"`, `"enterprise"`)
9//! - **Shared key infrastructure** — all Traitome products use the same signing key pair
10//! - **Offline verification** — no network calls required at runtime
11//! - **Configurable discovery** — customize env var names and platform config dirs
12//!
13//! # Architecture
14//!
15//! ```text
16//!  Issuer (offline tool)              Application (runtime)
17//!  ──────────────────────             ─────────────────────
18//!  LicensePayload ──┐                 license.json ──▶ LicenseFile
19//!  SigningKey (Ed25519)               │                     │
20//!      │                             │                     ▼
21//!      └──▶ serde_json::to_vec ──▶  sign ──▶   verify_license_with_key
22//!                                             (EMBEDDED_PUBLIC_KEY_BASE64)
23//! ```
24//!
25//! # Quick Start
26//!
27//! ```rust,no_run
28//! use oxo_license::{LicenseConfig, load_and_verify};
29//!
30//! // Define your project's license configuration
31//! static MY_CONFIG: LicenseConfig = LicenseConfig {
32//!     schema_version: "my-app-license-v1",
33//!     public_key_base64: "BASE64_ENCODED_32_BYTE_PUBLIC_KEY",
34//!     license_env_var: "MY_APP_LICENSE",
35//!     app_qualifier: "io",
36//!     app_org: "myorg",
37//!     app_name: "my-app",
38//!     license_filename: "license.json",
39//! };
40//!
41//! // Verify a license at runtime
42//! fn check_license(path: Option<&std::path::Path>) -> Result<(), oxo_license::LicenseError> {
43//!     load_and_verify(path, &MY_CONFIG)?;
44//!     Ok(())
45//! }
46//! ```
47//!
48//! # License Schema
49//!
50//! A signed license file is a JSON object with these fields:
51//!
52//! ```json
53//! {
54//!   "schema": "my-app-license-v1",
55//!   "license_id": "550e8400-e29b-41d4-a716-446655440000",
56//!   "issued_to_org": "Acme University",
57//!   "contact_email": "research@acme.edu",
58//!   "license_type": "academic",
59//!   "scope": "org",
60//!   "perpetual": true,
61//!   "issued_at": "2025-01-01",
62//!   "signature": "BASE64_ENCODED_64_BYTE_ED25519_SIGNATURE"
63//! }
64//! ```
65//!
66//! **Field order in the JSON object is the canonical wire format** — the
67//! signature covers `serde_json::to_vec(&payload)` with fields in the order
68//! they are declared in [`LicensePayload`].
69
70use base64::{Engine as _, engine::general_purpose::STANDARD};
71use ed25519_dalek::{Signature, Verifier, VerifyingKey};
72use serde::{Deserialize, Serialize};
73use std::path::{Path, PathBuf};
74
75// ── Configuration ─────────────────────────────────────────────────────────────
76
77/// Project-specific configuration for the license system.
78///
79/// Create a `static` instance of this struct in your application to define
80/// the schema version, embedded public key, and file discovery parameters.
81///
82/// # Example
83///
84/// ```rust
85/// use oxo_license::LicenseConfig;
86///
87/// pub static MY_LICENSE_CONFIG: LicenseConfig = LicenseConfig {
88///     schema_version: "my-app-license-v1",
89///     public_key_base64: "BASE64_32_BYTES",
90///     license_env_var: "MY_APP_LICENSE",
91///     app_qualifier: "io",
92///     app_org: "myorg",
93///     app_name: "my-app",
94///     license_filename: "license.json",
95/// };
96/// ```
97#[derive(Debug, Clone)]
98pub struct LicenseConfig {
99    /// Schema version string embedded in every license payload.
100    /// Must match exactly between issuer and verifier.
101    /// Example: `"my-app-license-v1"`
102    pub schema_version: &'static str,
103
104    /// Base64-encoded 32-byte Ed25519 public key (verifying key).
105    /// Generated by the `oxo-license-issuer generate-keypair` command.
106    pub public_key_base64: &'static str,
107
108    /// Environment variable name that users can set to point to their license file.
109    /// Example: `"MY_APP_LICENSE"`
110    pub license_env_var: &'static str,
111
112    /// Application qualifier for platform config directory discovery.
113    /// Example: `"io"` → `io.myorg.my-app`
114    pub app_qualifier: &'static str,
115
116    /// Application organization for platform config directory discovery.
117    /// Example: `"traitome"`
118    pub app_org: &'static str,
119
120    /// Application name for platform config directory discovery.
121    /// Example: `"my-app"`
122    pub app_name: &'static str,
123
124    /// License filename to look for in config directories.
125    /// Example: `"license.json"` or `"license.oxo.json"`
126    pub license_filename: &'static str,
127}
128
129// ── Data structures ───────────────────────────────────────────────────────────
130
131/// The payload that is signed by the license issuer.
132///
133/// **Field order is part of the wire format** — do not reorder fields.
134/// `serde_json::to_vec` is used for canonicalization; struct field order
135/// determines JSON key order, which must be identical between issuer and verifier.
136///
137/// The `license_type` field is a free-form string, which allows arbitrary
138/// license types to be defined (e.g., `"academic"`, `"commercial"`,
139/// `"enterprise"`, `"trial"`, `"internal"`).
140#[derive(Debug, Clone, Serialize, Deserialize)]
141pub struct LicensePayload {
142    /// License schema identifier — must match [`LicenseConfig::schema_version`].
143    pub schema: String,
144    /// Unique license ID (UUID v4 recommended).
145    pub license_id: String,
146    /// Full legal name of the licensed organization or individual.
147    pub issued_to_org: String,
148    /// Optional contact e-mail for the licensee.
149    #[serde(default, skip_serializing_if = "Option::is_none")]
150    pub contact_email: Option<String>,
151    /// License type — any string (e.g., `"academic"`, `"commercial"`, `"enterprise"`).
152    pub license_type: String,
153    /// Authorization scope — typically `"org"`.
154    pub scope: String,
155    /// Whether the license is perpetual.
156    pub perpetual: bool,
157    /// Issue date in `YYYY-MM-DD` format.
158    pub issued_at: String,
159}
160
161/// Full on-disk license file: payload fields flattened + Ed25519 `signature`.
162#[derive(Debug, Clone, Serialize, Deserialize)]
163pub struct LicenseFile {
164    /// All payload fields (flattened into the top-level JSON object).
165    #[serde(flatten)]
166    pub payload: LicensePayload,
167    /// Base64-encoded Ed25519 signature over `serde_json::to_vec(&payload)`.
168    pub signature: String,
169}
170
171// ── Error type ────────────────────────────────────────────────────────────────
172
173/// Errors returned by the license verification subsystem.
174#[derive(Debug, thiserror::Error)]
175pub enum LicenseError {
176    /// No license file was found in any of the standard search locations.
177    #[error(
178        "No license file found.\n\
179         \n\
180         Place your license file at one of:\n\
181         \t1. Pass the path via the CLI argument\n\
182         \t2. Set the license environment variable\n\
183         \t3. Platform config directory\n\
184         \t4. Legacy Unix path: ~/.config/<app>/license.json"
185    )]
186    NotFound,
187
188    /// The license file could not be read from disk.
189    #[error("Failed to read license file '{path}': {source}")]
190    ReadError {
191        path: PathBuf,
192        #[source]
193        source: std::io::Error,
194    },
195
196    /// The license file could not be parsed as JSON.
197    #[error(
198        "Failed to parse license file as JSON: {0}\n\
199         \n\
200         Common causes:\n\
201         • The file was modified or truncated\n\
202         • The file was saved with a UTF-8 BOM\n\
203         • The file was downloaded as HTML instead of raw JSON"
204    )]
205    ParseError(#[from] serde_json::Error),
206
207    /// The license schema does not match the expected value.
208    #[error(
209        "Invalid license schema: expected '{expected}', found '{found}'.\n\
210         This license was issued for a different version or application."
211    )]
212    InvalidSchema { expected: String, found: String },
213
214    /// The Ed25519 signature on the license is invalid.
215    #[error(
216        "License signature is invalid.\n\
217         The license file may have been tampered with or was not issued by Traitome."
218    )]
219    InvalidSignature,
220
221    /// The embedded public key is malformed.
222    #[error("Internal error — invalid embedded public key: {0}")]
223    InvalidPublicKey(String),
224
225    /// The signature field in the license file has invalid encoding.
226    #[error("Invalid signature encoding in license file: {0}")]
227    InvalidSignatureEncoding(String),
228}
229
230// ── Core verification ─────────────────────────────────────────────────────────
231
232/// Verify a [`LicenseFile`] against the public key embedded in `config`.
233///
234/// Returns `Ok(())` when the license is valid, or a descriptive [`LicenseError`].
235///
236/// # Example
237///
238/// ```rust,no_run
239/// use oxo_license::{LicenseConfig, LicenseFile, verify_license};
240///
241/// static CONFIG: LicenseConfig = LicenseConfig {
242///     schema_version: "my-app-license-v1",
243///     public_key_base64: "BASE64_32_BYTES",
244///     license_env_var: "MY_APP_LICENSE",
245///     app_qualifier: "io",
246///     app_org: "myorg",
247///     app_name: "my-app",
248///     license_filename: "license.json",
249/// };
250///
251/// fn check(license: &LicenseFile) -> Result<(), oxo_license::LicenseError> {
252///     verify_license(license, &CONFIG)
253/// }
254/// ```
255pub fn verify_license(license: &LicenseFile, config: &LicenseConfig) -> Result<(), LicenseError> {
256    verify_license_with_key(license, config.public_key_base64, config.schema_version)
257}
258
259/// Verify a [`LicenseFile`] against an arbitrary Base64-encoded public key.
260///
261/// This is the low-level function used by [`verify_license`] and by unit tests
262/// with ephemeral test keys.
263///
264/// # Arguments
265///
266/// * `license` — The license file to verify.
267/// * `pubkey_base64` — Base64-encoded 32-byte Ed25519 public key.
268/// * `schema_version` — Expected schema version string.
269pub fn verify_license_with_key(
270    license: &LicenseFile,
271    pubkey_base64: &str,
272    schema_version: &str,
273) -> Result<(), LicenseError> {
274    // 1. Schema check
275    if license.payload.schema != schema_version {
276        return Err(LicenseError::InvalidSchema {
277            expected: schema_version.to_string(),
278            found: license.payload.schema.clone(),
279        });
280    }
281
282    // 2. Decode the public key
283    let pubkey_bytes = STANDARD
284        .decode(pubkey_base64)
285        .map_err(|e| LicenseError::InvalidPublicKey(e.to_string()))?;
286    let pubkey_array: [u8; 32] = pubkey_bytes
287        .try_into()
288        .map_err(|_| LicenseError::InvalidPublicKey("expected exactly 32 bytes".to_string()))?;
289    let verifying_key = VerifyingKey::from_bytes(&pubkey_array)
290        .map_err(|e| LicenseError::InvalidPublicKey(e.to_string()))?;
291
292    // 3. Decode the signature
293    let sig_bytes = STANDARD
294        .decode(&license.signature)
295        .map_err(|e| LicenseError::InvalidSignatureEncoding(e.to_string()))?;
296    let sig_array: [u8; 64] = sig_bytes.try_into().map_err(|_| {
297        LicenseError::InvalidSignatureEncoding("expected exactly 64 bytes".to_string())
298    })?;
299    let signature = Signature::from_bytes(&sig_array);
300
301    // 4. Canonical payload bytes (field order defined by LicensePayload struct)
302    let payload_bytes = serde_json::to_vec(&license.payload).map_err(LicenseError::ParseError)?;
303
304    // 5. Verify
305    verifying_key
306        .verify(&payload_bytes, &signature)
307        .map_err(|_| LicenseError::InvalidSignature)?;
308
309    Ok(())
310}
311
312// ── Discovery ─────────────────────────────────────────────────────────────────
313
314/// Find the license file path using the priority chain:
315///
316/// 1. `cli_arg` (if provided and the file exists)
317/// 2. Environment variable named `config.license_env_var` (if set and file exists)
318/// 3. Platform config directory: `<config_dir>/<license_filename>`
319/// 4. Legacy Unix path: `~/.config/<app_name>/<license_filename>`
320///
321/// Returns the first path that **exists on disk**, or `None`.
322pub fn find_license_path(cli_arg: Option<&Path>, config: &LicenseConfig) -> Option<PathBuf> {
323    // 1. CLI argument takes highest priority
324    if let Some(p) = cli_arg {
325        return Some(p.to_path_buf());
326    }
327
328    // 2. Environment variable
329    if let Ok(val) = std::env::var(config.license_env_var) {
330        let p = PathBuf::from(val.trim());
331        return Some(p);
332    }
333
334    // 3 & 4. Default candidates
335    default_license_candidates(config)
336        .into_iter()
337        .find(|candidate| candidate.exists())
338}
339
340/// Load a license file from disk and verify its signature.
341///
342/// The path is resolved using [`find_license_path`] if not explicitly provided.
343///
344/// # Errors
345///
346/// Returns [`LicenseError::NotFound`] when no license file can be located.
347/// Returns [`LicenseError::ReadError`] when the file cannot be read.
348/// Returns [`LicenseError::ParseError`] when the JSON is malformed.
349/// Returns [`LicenseError::InvalidSignature`] when the signature is wrong.
350pub fn load_and_verify(
351    cli_arg: Option<&Path>,
352    config: &LicenseConfig,
353) -> Result<LicenseFile, LicenseError> {
354    let path = find_license_path(cli_arg, config).ok_or(LicenseError::NotFound)?;
355
356    let contents = std::fs::read_to_string(&path).map_err(|e| LicenseError::ReadError {
357        path: path.clone(),
358        source: e,
359    })?;
360
361    let license: LicenseFile = serde_json::from_str(&contents)?;
362
363    verify_license(&license, config)?;
364
365    Ok(license)
366}
367
368// ── Internal helpers ──────────────────────────────────────────────────────────
369
370fn legacy_unix_license_path(home_dir: Option<PathBuf>, config: &LicenseConfig) -> Option<PathBuf> {
371    home_dir.map(|home| {
372        home.join(".config")
373            .join(config.app_name)
374            .join(config.license_filename)
375    })
376}
377
378fn default_license_candidates(config: &LicenseConfig) -> Vec<PathBuf> {
379    let mut candidates: Vec<PathBuf> = Vec::new();
380
381    // Platform config directory (uses `directories` crate on native targets)
382    #[cfg(not(target_arch = "wasm32"))]
383    {
384        if let Some(dirs) =
385            directories::ProjectDirs::from(config.app_qualifier, config.app_org, config.app_name)
386        {
387            candidates.push(dirs.config_dir().join(config.license_filename));
388        }
389    }
390
391    // Legacy Unix fallback: ~/.config/<app_name>/<license_filename>
392    let home_dir = std::env::var_os("HOME").map(PathBuf::from);
393    if let Some(path) = legacy_unix_license_path(home_dir, config)
394        && !candidates.contains(&path)
395    {
396        candidates.push(path);
397    }
398
399    candidates
400}
401
402// ── Tests ─────────────────────────────────────────────────────────────────────
403
404#[cfg(test)]
405mod tests {
406    use super::*;
407    #[allow(unused_imports)]
408    use base64::{Engine as _, engine::general_purpose::STANDARD};
409    use ed25519_dalek::{Signer, SigningKey};
410    use rand::rngs::OsRng;
411    use std::sync::Mutex;
412
413    const TEST_SCHEMA: &str = "test-app-license-v1";
414
415    /// Serializes tests that read/write `TEST_APP_LICENSE` to prevent races.
416    static ENV_MUTEX: Mutex<()> = Mutex::new(());
417
418    fn make_test_keypair() -> (SigningKey, String) {
419        let key = SigningKey::generate(&mut OsRng);
420        let pubkey_b64 = STANDARD.encode(key.verifying_key().as_bytes());
421        (key, pubkey_b64)
422    }
423
424    fn make_config(pubkey_b64: &'static str) -> LicenseConfig {
425        LicenseConfig {
426            schema_version: TEST_SCHEMA,
427            public_key_base64: pubkey_b64,
428            license_env_var: "TEST_APP_LICENSE",
429            app_qualifier: "io",
430            app_org: "testorg",
431            app_name: "test-app",
432            license_filename: "license.json",
433        }
434    }
435
436    fn make_license(key: &SigningKey, license_type: &str) -> LicenseFile {
437        let payload = LicensePayload {
438            schema: TEST_SCHEMA.to_string(),
439            license_id: "00000000-0000-0000-0000-000000000001".to_string(),
440            issued_to_org: "Test Organization".to_string(),
441            contact_email: Some("test@example.com".to_string()),
442            license_type: license_type.to_string(),
443            scope: "org".to_string(),
444            perpetual: true,
445            issued_at: "2025-01-01".to_string(),
446        };
447        let payload_bytes = serde_json::to_vec(&payload).unwrap();
448        let sig = key.sign(&payload_bytes);
449        LicenseFile {
450            payload,
451            signature: STANDARD.encode(sig.to_bytes()),
452        }
453    }
454
455    #[test]
456    fn test_verify_academic_license_ok() {
457        let (key, pubkey_b64) = make_test_keypair();
458        let license = make_license(&key, "academic");
459        assert!(verify_license_with_key(&license, &pubkey_b64, TEST_SCHEMA).is_ok());
460    }
461
462    #[test]
463    fn test_verify_commercial_license_ok() {
464        let (key, pubkey_b64) = make_test_keypair();
465        let license = make_license(&key, "commercial");
466        assert!(verify_license_with_key(&license, &pubkey_b64, TEST_SCHEMA).is_ok());
467    }
468
469    #[test]
470    fn test_verify_enterprise_license_ok() {
471        let (key, pubkey_b64) = make_test_keypair();
472        let license = make_license(&key, "enterprise");
473        assert!(verify_license_with_key(&license, &pubkey_b64, TEST_SCHEMA).is_ok());
474    }
475
476    #[test]
477    fn test_wrong_pubkey_fails() {
478        let (key, _) = make_test_keypair();
479        let (_, other_pubkey_b64) = make_test_keypair();
480        let license = make_license(&key, "academic");
481        let result = verify_license_with_key(&license, &other_pubkey_b64, TEST_SCHEMA);
482        assert!(
483            matches!(result, Err(LicenseError::InvalidSignature)),
484            "expected InvalidSignature, got: {result:?}"
485        );
486    }
487
488    #[test]
489    fn test_tampered_org_fails() {
490        let (key, pubkey_b64) = make_test_keypair();
491        let mut license = make_license(&key, "academic");
492        license.payload.issued_to_org = "Tampered Org".to_string();
493        let result = verify_license_with_key(&license, &pubkey_b64, TEST_SCHEMA);
494        assert!(
495            matches!(result, Err(LicenseError::InvalidSignature)),
496            "expected InvalidSignature after tampering, got: {result:?}"
497        );
498    }
499
500    #[test]
501    fn test_wrong_schema_fails() {
502        let (key, pubkey_b64) = make_test_keypair();
503        let mut license = make_license(&key, "academic");
504        license.payload.schema = "wrong-schema-v99".to_string();
505        let payload_bytes = serde_json::to_vec(&license.payload).unwrap();
506        let sig = key.sign(&payload_bytes);
507        license.signature = STANDARD.encode(sig.to_bytes());
508
509        let result = verify_license_with_key(&license, &pubkey_b64, TEST_SCHEMA);
510        assert!(
511            matches!(result, Err(LicenseError::InvalidSchema { .. })),
512            "expected InvalidSchema, got: {result:?}"
513        );
514    }
515
516    #[test]
517    fn test_invalid_public_key_base64_fails() {
518        let (key, _) = make_test_keypair();
519        let license = make_license(&key, "academic");
520        let result = verify_license_with_key(&license, "!!!not-valid-base64!!!", TEST_SCHEMA);
521        assert!(
522            matches!(result, Err(LicenseError::InvalidPublicKey(_))),
523            "expected InvalidPublicKey, got: {result:?}"
524        );
525    }
526
527    #[test]
528    fn test_invalid_public_key_wrong_length_fails() {
529        let (key, _) = make_test_keypair();
530        let license = make_license(&key, "academic");
531        let short_key = STANDARD.encode([0u8; 16]);
532        let result = verify_license_with_key(&license, &short_key, TEST_SCHEMA);
533        assert!(
534            matches!(result, Err(LicenseError::InvalidPublicKey(_))),
535            "expected InvalidPublicKey, got: {result:?}"
536        );
537    }
538
539    #[test]
540    fn test_invalid_signature_encoding_fails() {
541        let (key, pubkey_b64) = make_test_keypair();
542        let mut license = make_license(&key, "academic");
543        license.signature = "!!!not-valid-base64!!!".to_string();
544        let result = verify_license_with_key(&license, &pubkey_b64, TEST_SCHEMA);
545        assert!(
546            matches!(result, Err(LicenseError::InvalidSignatureEncoding(_))),
547            "expected InvalidSignatureEncoding, got: {result:?}"
548        );
549    }
550
551    #[test]
552    fn test_invalid_signature_wrong_length_fails() {
553        let (key, pubkey_b64) = make_test_keypair();
554        let mut license = make_license(&key, "academic");
555        license.signature = STANDARD.encode([0u8; 32]); // 32 bytes, not 64
556        let result = verify_license_with_key(&license, &pubkey_b64, TEST_SCHEMA);
557        assert!(
558            matches!(result, Err(LicenseError::InvalidSignatureEncoding(_))),
559            "expected InvalidSignatureEncoding, got: {result:?}"
560        );
561    }
562
563    #[test]
564    fn test_roundtrip_json_serialization() {
565        let (key, _) = make_test_keypair();
566        let license = make_license(&key, "commercial");
567        let json = serde_json::to_string_pretty(&license).unwrap();
568        let parsed: LicenseFile = serde_json::from_str(&json).unwrap();
569        assert_eq!(parsed.payload.license_id, license.payload.license_id);
570        assert_eq!(parsed.payload.license_type, license.payload.license_type);
571        assert_eq!(parsed.signature, license.signature);
572    }
573
574    #[test]
575    fn test_contact_email_optional_in_signing() {
576        let (key, pubkey_b64) = make_test_keypair();
577        let mut payload = LicensePayload {
578            schema: TEST_SCHEMA.to_string(),
579            license_id: "00000000-0000-0000-0000-000000000002".to_string(),
580            issued_to_org: "Acme Corp".to_string(),
581            contact_email: None,
582            license_type: "commercial".to_string(),
583            scope: "org".to_string(),
584            perpetual: true,
585            issued_at: "2025-06-01".to_string(),
586        };
587        let sig1 = {
588            let bytes = serde_json::to_vec(&payload).unwrap();
589            key.sign(&bytes)
590        };
591        let lic_no_email = LicenseFile {
592            payload: payload.clone(),
593            signature: STANDARD.encode(sig1.to_bytes()),
594        };
595        assert!(verify_license_with_key(&lic_no_email, &pubkey_b64, TEST_SCHEMA).is_ok());
596
597        payload.contact_email = Some("admin@acme.com".to_string());
598        let sig2 = {
599            let bytes = serde_json::to_vec(&payload).unwrap();
600            key.sign(&bytes)
601        };
602        let lic_with_email = LicenseFile {
603            payload,
604            signature: STANDARD.encode(sig2.to_bytes()),
605        };
606        assert!(verify_license_with_key(&lic_with_email, &pubkey_b64, TEST_SCHEMA).is_ok());
607
608        // Cross-verify: email license with no-email signature should fail
609        let lic_tampered = LicenseFile {
610            payload: lic_with_email.payload.clone(),
611            signature: lic_no_email.signature.clone(),
612        };
613        assert!(verify_license_with_key(&lic_tampered, &pubkey_b64, TEST_SCHEMA).is_err());
614    }
615
616    #[test]
617    fn test_find_license_path_from_env_var() {
618        let _guard = ENV_MUTEX.lock().unwrap();
619        let tmp = tempfile::NamedTempFile::new().unwrap();
620        let path = tmp.path().to_path_buf();
621        unsafe {
622            std::env::set_var("TEST_APP_LICENSE", path.to_str().unwrap());
623        }
624        let config = make_config("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=");
625        let found = find_license_path(None, &config);
626        assert_eq!(found.as_deref(), Some(path.as_path()));
627        unsafe {
628            std::env::remove_var("TEST_APP_LICENSE");
629        }
630    }
631
632    #[test]
633    fn test_find_license_path_cli_arg_takes_precedence() {
634        let _guard = ENV_MUTEX.lock().unwrap();
635        let cli_path = PathBuf::from("/nonexistent/cli-license.json");
636        let env_path = PathBuf::from("/nonexistent/env-license.json");
637        unsafe {
638            std::env::set_var("TEST_APP_LICENSE", env_path.to_str().unwrap());
639        }
640        let config = make_config("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=");
641        let found = find_license_path(Some(&cli_path), &config);
642        assert_eq!(found.as_deref(), Some(cli_path.as_path()));
643        unsafe {
644            std::env::remove_var("TEST_APP_LICENSE");
645        }
646    }
647
648    #[test]
649    fn test_load_and_verify_not_found() {
650        let config = make_config("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=");
651        let result = load_and_verify(
652            Some(Path::new("/nonexistent/oxo-license-test.json")),
653            &config,
654        );
655        // Path is provided but doesn't exist → ReadError
656        assert!(result.is_err());
657    }
658
659    #[test]
660    fn test_load_and_verify_invalid_json() {
661        use std::io::Write;
662        let mut f = tempfile::NamedTempFile::new().unwrap();
663        f.write_all(b"not valid json {{{").unwrap();
664        let config = make_config("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=");
665        let result = load_and_verify(Some(f.path()), &config);
666        assert!(
667            matches!(result, Err(LicenseError::ParseError(_))),
668            "expected ParseError, got: {result:?}"
669        );
670    }
671
672    #[test]
673    fn test_load_and_verify_valid_license() {
674        let (key, pubkey_b64) = make_test_keypair();
675        let license = make_license(&key, "academic");
676        let json = serde_json::to_string_pretty(&license).unwrap();
677
678        use std::io::Write;
679        let mut f = tempfile::NamedTempFile::new().unwrap();
680        f.write_all(json.as_bytes()).unwrap();
681
682        // We need a static pubkey for the config — use box leak for test
683        let pubkey_static: &'static str = Box::leak(pubkey_b64.into_boxed_str());
684        let config = make_config(pubkey_static);
685        let result = load_and_verify(Some(f.path()), &config);
686        assert!(result.is_ok(), "expected Ok, got: {result:?}");
687    }
688}