Skip to main content

bevy_dlc/
lib.rs

1use base64::Engine as _;
2use base64::engine::general_purpose::URL_SAFE_NO_PAD;
3use bevy::prelude::*;
4use ring::signature::{ED25519, Ed25519KeyPair, KeyPair, UnparsedPublicKey};
5
6use secure_gate::{ExposeSecret, dynamic_alias, fixed_alias};
7
8mod asset_loader;
9pub mod encrypt_key_registry;
10mod ext;
11
12#[macro_use]
13mod macros;
14
15mod pack_format;
16
17pub use asset_loader::{
18    DlcLoader, DlcPack, DlcPackLoader, DlcPackLoaderSettings, EncryptedAsset, parse_encrypted,
19};
20
21pub use pack_format::{
22    BlockMetadata, CompressionLevel, DEFAULT_BLOCK_SIZE, DLC_PACK_MAGIC, DLC_PACK_VERSION_LATEST,
23    ManifestEntry, V4ManifestEntry, is_data_executable, is_forbidden_extension,
24    pack_encrypted_pack, parse_encrypted_pack,
25};
26
27use serde::{Deserialize, Serialize};
28
29use thiserror::Error;
30
31use crate::asset_loader::DlcPackLoaded;
32
33pub use crate::ext::AppExt;
34
35/// Register an encryption key for a DLC ID in the global registry.
36/// This is used internally by the plugin and can be called by tools/CLI for key management.
37///
38/// # Arguments
39/// * `dlc_id` - The DLC identifier to associate with the key
40/// * `key` - The encryption key to register
41pub fn register_encryption_key(dlc_id: &str, key: EncryptionKey) {
42    encrypt_key_registry::insert(dlc_id, key);
43}
44
45pub mod prelude {
46    pub use crate::ext::*;
47    pub use crate::{
48        DlcError, DlcId, DlcKey, DlcLoader, DlcPack, DlcPackLoader, DlcPackLoaderSettings,
49        DlcPlugin, EncryptedAsset, PackItem, Product, SignedLicense,
50        asset_loader::DlcPackEntry, asset_loader::DlcPackLoaded, is_dlc_entry_loaded,
51        is_dlc_loaded,
52    };
53}
54
55pub struct DlcPlugin {
56    dlc_key: DlcKey,
57
58    signed_license: SignedLicense,
59}
60
61impl DlcPlugin {
62    /// Create the plugin from a `DlcKey` and a `SignedLicense`.
63    ///
64    /// The plugin will extract the encryption key from the signed license
65    /// during `build` and register it in the global key registry.
66    pub fn new(dlc_key: DlcKey, signed_license: SignedLicense) -> Self {
67        Self {
68            dlc_key,
69            signed_license,
70        }
71    }
72}
73
74impl Plugin for DlcPlugin {
75    fn build(&self, app: &mut App) {
76        if let Some(encrypt_key) = extract_encrypt_key_from_license(&self.signed_license) {
77            let dlcs = extract_dlc_ids_from_license(&self.signed_license);
78            for dlc_id in dlcs {
79                // asset sources should already have been registered by PreDlcPlugin;
80                // only insert the encryption keys here so later runtime behaviour
81                // still works if user accidentally omitted the pre-plugin.
82                let key_for_dlc = encrypt_key.with_secret(|kb| EncryptionKey::new(*kb));
83                encrypt_key_registry::insert(&dlc_id, key_for_dlc);
84            }
85        }
86
87        app.init_resource::<asset_loader::DlcPackRegistrarFactories>();
88
89        app.insert_resource(self.dlc_key.clone())
90            .init_asset_loader::<asset_loader::DlcLoader<Image>>()
91            .init_asset_loader::<asset_loader::DlcLoader<Scene>>()
92            .init_asset_loader::<asset_loader::DlcLoader<Mesh>>()
93            .init_asset_loader::<asset_loader::DlcLoader<Font>>()
94            .init_asset_loader::<asset_loader::DlcLoader<AudioSource>>()
95            .init_asset_loader::<asset_loader::DlcLoader<ColorMaterial>>()
96            .init_asset_loader::<asset_loader::DlcLoader<StandardMaterial>>()
97            .init_asset_loader::<asset_loader::DlcLoader<Gltf>>()
98            .init_asset_loader::<asset_loader::DlcLoader<bevy::gltf::GltfMesh>>()
99            .init_asset_loader::<asset_loader::DlcLoader<Shader>>()
100            .init_asset_loader::<asset_loader::DlcLoader<DynamicScene>>()
101            .init_asset_loader::<asset_loader::DlcLoader<AnimationClip>>()
102            .init_asset_loader::<asset_loader::DlcLoader<AnimationGraph>>();
103
104        let factories = app
105            .world()
106            .get_resource::<asset_loader::DlcPackRegistrarFactories>()
107            .cloned();
108        let pack_loader = asset_loader::DlcPackLoader {
109            registrars: asset_loader::collect_pack_registrars(factories.as_ref()),
110            factories,
111        };
112
113        app.register_asset_loader(pack_loader);
114        app.init_asset::<asset_loader::DlcPack>();
115
116        app.add_systems(Update, trigger_dlc_events);
117    }
118}
119
120
121/// System that monitors `AssetEvent<DlcPack>` and triggers observer-friendly events.
122fn trigger_dlc_events(
123    mut events: MessageReader<AssetEvent<DlcPack>>,
124    packs: Res<Assets<DlcPack>>,
125    mut commands: Commands,
126) {
127    for event in events.read() {
128        match event {
129            AssetEvent::Added { id } => {
130                if let Some(pack) = packs.get(*id) {
131                    let dlc_id = pack.id().clone();
132                    commands.trigger(DlcPackLoaded::new(dlc_id.clone(), pack.clone()));
133                }
134            }
135            _ => {}
136        }
137    }
138}
139
140/// A Bevy system condition that returns `true` when a DLC pack has been loaded.
141///
142/// A DLC is considered loaded when `DlcPackLoader` has successfully loaded a `.dlcpack` file
143/// containing that `DlcId`.
144///
145/// This is useful for gating systems that should only run when specific DLC content is actually
146/// available.
147///
148/// # Example
149/// ```ignore
150/// use bevy::prelude::*;
151/// use bevy_dlc::is_dlc_loaded;
152///
153/// fn spawn_dlc_content() {}
154///
155/// let mut app = App::new();
156/// app.add_systems(Update, spawn_dlc_content.run_if(is_dlc_loaded("dlcA")));
157/// ```
158pub fn is_dlc_loaded(dlc_id: impl Into<DlcId>) -> impl Fn() -> bool + Send + Sync + 'static {
159    let id_string = dlc_id.into().0;
160    move || !encrypt_key_registry::asset_path_for(&id_string).is_none()
161}
162
163/// A Bevy system condition that returns `true` when a specific DLC pack entry is loaded.
164pub fn is_dlc_entry_loaded(
165    dlc_id: impl Into<DlcId>,
166    entry: impl Into<String>,
167) -> impl Fn(Res<Assets<DlcPack>>) -> bool + Send + Sync + 'static {
168    let id_string = dlc_id.into().0;
169    let entry_name = entry.into();
170    move |dlc_packs: Res<Assets<DlcPack>>| {
171        if !encrypt_key_registry::asset_path_for(&id_string).is_none() {
172            dlc_packs
173                .iter()
174                .filter(|p| p.1.id() == &DlcId::from(id_string.clone()))
175                .any(|pack| pack.1.find_entry(&entry_name).is_some())
176        } else {
177            false
178        }
179    }
180}
181
182/// Strongly-typed DLC identifier (string-backed).
183#[derive(Serialize, Deserialize, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
184#[serde(transparent)]
185pub struct DlcId(pub String);
186
187impl std::fmt::Display for DlcId {
188    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
189        self.0.fmt(f)
190    }
191}
192
193impl Clone for DlcId {
194    fn clone(&self) -> Self {
195        DlcId(self.0.clone())
196    }
197}
198
199impl DlcId {
200    /// Return the underlying string slice.
201    pub fn as_str(&self) -> &str {
202        self.0.as_str()
203    }
204}
205
206impl From<&str> for DlcId {
207    fn from(s: &str) -> Self {
208        DlcId(s.to_owned())
209    }
210}
211
212impl From<String> for DlcId {
213    fn from(s: String) -> Self {
214        DlcId(s)
215    }
216}
217
218impl AsRef<str> for DlcId {
219    fn as_ref(&self) -> &str {
220        &self.0
221    }
222}
223
224fixed_alias!(pub PrivateKey, 32, "A secure wrapper for a 32-byte Ed25519 signing seed (private key) used to create signed licenses. This should be protected and never exposed in logs or error messages.");
225
226/// PublicKey wrapper (32 bytes)
227#[derive(Clone, Copy, PartialEq, Eq)]
228pub struct PublicKey(pub [u8; 32]);
229
230impl AsRef<[u8]> for PublicKey {
231    fn as_ref(&self) -> &[u8] {
232        &self.0
233    }
234}
235
236impl std::fmt::Debug for PublicKey {
237    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
238        write!(f, "PublicKey({} bytes)", 32)
239    }
240}
241
242dynamic_alias!(pub SignedLicense, String, "A compact offline-signed token containing DLC ids and an optional embedded encrypt key. Treat as sensitive and do not leak raw secrets in logs.  This ");
243
244/// Extract the embedded encryption key from a signed license's payload (base64url-encoded).
245/// Returns `None` if the token is malformed or contains no encrypt_key.
246pub fn extract_encrypt_key_from_license(license: &SignedLicense) -> Option<EncryptionKey> {
247    license.with_secret(|token_str| {
248        let parts: Vec<&str> = token_str.split('.').collect();
249        if parts.len() != 2 {
250            return None;
251        }
252        let payload = URL_SAFE_NO_PAD.decode(parts[0].as_bytes()).ok()?;
253        let payload_json: serde_json::Value = serde_json::from_slice(&payload).ok()?;
254        let key_b64 = payload_json.get("encrypt_key").and_then(|v| v.as_str())?;
255        URL_SAFE_NO_PAD
256            .decode(key_b64.as_bytes())
257            .ok()
258            .and_then(|key_bytes| Some(EncryptionKey::new(key_bytes.try_into().ok()?)))
259    })
260}
261
262/// Extract the DLC IDs from a signed license's payload.
263/// Returns an empty vec if the token is malformed or contains no dlcs array.
264pub fn extract_dlc_ids_from_license(license: &SignedLicense) -> Vec<String> {
265    license.with_secret(|token_str| {
266        let parts: Vec<&str> = token_str.split('.').collect();
267        if parts.len() != 2 {
268            return Vec::new();
269        }
270        if let Ok(payload) = URL_SAFE_NO_PAD.decode(parts[0].as_bytes()) {
271            if let Ok(payload_json) = serde_json::from_slice::<serde_json::Value>(&payload) {
272                if let Some(dlcs_array) = payload_json.get("dlcs").and_then(|v| v.as_array()) {
273                    let mut dlcs = Vec::new();
274                    for dlc in dlcs_array {
275                        if let Some(dlc_id) = dlc.as_str() {
276                            dlcs.push(dlc_id.to_string());
277                        }
278                    }
279                    return dlcs;
280                }
281            }
282        }
283        Vec::new()
284    })
285}
286
287/// Extract the `product` field from a signed license's payload.
288/// Returns `None` if the token is malformed or the field is missing.
289pub fn extract_product_from_license(license: &SignedLicense) -> Option<String> {
290    license.with_secret(|token_str| {
291        let parts: Vec<&str> = token_str.split('.').collect();
292        if parts.len() != 2 {
293            return None;
294        }
295        if let Ok(payload) = URL_SAFE_NO_PAD.decode(parts[0].as_bytes()) {
296            if let Ok(payload_json) = serde_json::from_slice::<serde_json::Value>(&payload) {
297                if let Some(prod) = payload_json.get("product").and_then(|v| v.as_str()) {
298                    return Some(prod.to_string());
299                }
300            }
301        }
302        None
303    })
304}
305/// Product: non-secret identifier wrapper (keeps same API surface)
306#[derive(Resource, Clone, PartialEq, Eq, Debug)]
307pub struct Product(String);
308
309impl std::fmt::Display for Product {
310    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
311        self.0.fmt(f)
312    }
313}
314
315impl AsRef<str> for Product {
316    fn as_ref(&self) -> &str {
317        &self.0
318    }
319}
320
321impl Product {
322    /// Return the underlying string slice.
323    pub fn as_str(&self) -> &str {
324        self.0.as_str()
325    }
326}
327
328impl From<String> for Product {
329    fn from(s: String) -> Self {
330        Product(s)
331    }
332}
333impl From<&str> for Product {
334    fn from(s: &str) -> Self {
335        Product(s.to_owned())
336    }
337}
338
339fixed_alias!(pub EncryptionKey, 32, "A secure encrypt key (symmetric key for encrypting DLC pack entries). This should be protected and never exposed in logs or error messages.");
340
341/// Client-side wrapper for Ed25519 key operations: verify tokens and (when
342/// private) create compact signed tokens.
343#[derive(Resource)]
344pub enum DlcKey {
345    /// Private key (protected seed + public bytes + public key tag)
346    Private {
347        /// Protected signing seed (secure wrapper)
348        privkey: PrivateKey,
349        /// Public key bytes (wrapped)
350        pubkey: PublicKey,
351    },
352
353    /// Public-only key (public bytes)
354    Public {
355        /// Public key bytes (wrapped)
356        pubkey: PublicKey,
357    },
358}
359
360impl DlcKey {
361    pub fn new(pubkey: &str, privkey: &str) -> Result<Self, DlcError> {
362        let decoded_pub = URL_SAFE_NO_PAD
363            .decode(pubkey.as_bytes())
364            .map_err(|e| DlcError::CryptoError(format!("invalid pubkey base64: {}", e)))?;
365        if decoded_pub.len() != 32 {
366            return Err(DlcError::CryptoError("public key must be 32 bytes".into()));
367        }
368        let mut pub_bytes = [0u8; 32];
369        pub_bytes.copy_from_slice(&decoded_pub);
370
371        let decoded_priv = URL_SAFE_NO_PAD
372            .decode(privkey.as_bytes())
373            .map_err(|e| DlcError::CryptoError(format!("invalid privkey base64: {}", e)))?;
374        if decoded_priv.len() != 32 {
375            return Err(DlcError::CryptoError("private key must be 32 bytes".into()));
376        }
377        let mut priv_bytes = [0u8; 32];
378        priv_bytes.copy_from_slice(&decoded_priv);
379
380        Self::from_priv_and_pub(PrivateKey::from(priv_bytes), PublicKey(pub_bytes))
381    }
382
383    /// Construct a `DlcKey::Public` from a base64url-encoded public key string.
384    pub fn public(pubkey: &str) -> Result<Self, DlcError> {
385        let decoded_pub = URL_SAFE_NO_PAD
386            .decode(pubkey.as_bytes())
387            .map_err(|e| DlcError::CryptoError(format!("invalid pubkey base64: {}", e)))?;
388        if decoded_pub.len() != 32 {
389            return Err(DlcError::CryptoError("public key must be 32 bytes".into()));
390        }
391        let mut pub_bytes = [0u8; 32];
392        pub_bytes.copy_from_slice(&decoded_pub);
393
394        Ok(DlcKey::Public {
395            pubkey: PublicKey(pub_bytes),
396        })
397    }
398
399    /// Construct a `DlcKey::Private` from a private key and public key.
400    pub(crate) fn from_priv_and_pub(
401        privkey: PrivateKey,
402        publickey: PublicKey,
403    ) -> Result<Self, DlcError> {
404        let kp = privkey
405            .with_secret(|priv_bytes| {
406                Ed25519KeyPair::from_seed_and_public_key(priv_bytes, &publickey.0)
407            })
408            .map_err(|e| DlcError::CryptoError(format!("invalid seed: {:?}", e)))?;
409        let mut pub_bytes = [0u8; 32];
410        pub_bytes.copy_from_slice(kp.public_key().as_ref());
411
412        privkey
413            .with_secret(|priv_bytes| {
414                Ed25519KeyPair::from_seed_and_public_key(priv_bytes, &pub_bytes)
415            })
416            .map_err(|e| DlcError::CryptoError(format!("keypair validation failed: {:?}", e)))?;
417
418        Ok(DlcKey::Private {
419            privkey,
420            pubkey: PublicKey(pub_bytes),
421        })
422    }
423
424    /// Generate a new `DlcKey::Private` with a random seed and derived public key.
425    ///
426    /// The public key is derived from the generated seed so the keypair is valid.
427    pub fn generate_random() -> Self {
428        let privkey: PrivateKey = PrivateKey::from_random();
429
430        let pair = privkey
431            .with_secret(|priv_bytes| Ed25519KeyPair::from_seed_unchecked(priv_bytes))
432            .expect("derive public key from seed");
433        let mut pub_bytes = [0u8; 32];
434        pub_bytes.copy_from_slice(pair.public_key().as_ref());
435
436        Self::from_priv_and_pub(privkey, PublicKey(pub_bytes))
437            .unwrap_or_else(|e| panic!("generate_complete failed: {:?}", e))
438    }
439
440    pub fn get_public_key(&self) -> &PublicKey {
441        match self {
442            DlcKey::Private { pubkey, .. } => pubkey,
443            DlcKey::Public { pubkey: public } => public,
444        }
445    }
446
447    /// Sign data using the private key.
448    /// Returns a 64-byte signature.
449    pub fn sign(&self, data: &[u8]) -> Result<[u8; 64], DlcError> {
450        match self {
451            DlcKey::Private { privkey, pubkey } => privkey.with_secret(|seed| {
452                let pair = Ed25519KeyPair::from_seed_and_public_key(seed, pubkey.as_ref())
453                    .map_err(|e| DlcError::CryptoError(e.to_string()))?;
454                let sig = pair.sign(data);
455                let mut sig_bytes = [0u8; 64];
456                sig_bytes.copy_from_slice(sig.as_ref());
457                Ok(sig_bytes)
458            }),
459            DlcKey::Public { .. } => Err(DlcError::PrivateKeyRequired),
460        }
461    }
462
463    /// Verify a 64-byte Ed25519 signature against provided data.
464    pub fn verify(&self, data: &[u8], signature: &[u8; 64]) -> Result<(), DlcError> {
465        let pubkey = self.get_public_key();
466        ring::signature::UnparsedPublicKey::new(&ring::signature::ED25519, pubkey.as_ref())
467            .verify(data, signature.as_ref())
468            .map_err(|_| DlcError::SignatureInvalid)
469    }
470
471    /// Create a compact offline-signed token that can be verified by this key's public key.
472    ///
473    /// Returns a `SignedLicense` (zeroized on drop). The license payload includes
474    /// the provided DLC ids and product binding.
475    /// Create a compact offline-signed token (SignedLicense).
476    pub fn create_signed_license<D>(
477        &self,
478        dlcs: impl IntoIterator<Item = D>,
479        product: Product,
480    ) -> Result<SignedLicense, DlcError>
481    where
482        D: std::fmt::Display,
483    {
484        let mut payload = serde_json::Map::new();
485        payload.insert(
486            "dlcs".to_string(),
487            serde_json::Value::Array(
488                dlcs.into_iter()
489                    .map(|s| serde_json::Value::String(s.to_string()))
490                    .collect(),
491            ),
492        );
493
494        payload.insert(
495            "product".to_string(),
496            serde_json::Value::String(product.as_ref().to_string()),
497        );
498
499        match self {
500            DlcKey::Private { privkey, .. } => {
501                let sig_token = privkey.with_secret(
502                    |encrypt_key_bytes| -> Result<SignedLicense, DlcError> {
503                        payload.insert(
504                            "encrypt_key".to_string(),
505                            serde_json::Value::String(URL_SAFE_NO_PAD.encode(encrypt_key_bytes)),
506                        );
507
508                        let payload_value = serde_json::Value::Object(payload);
509                        let payload_bytes = serde_json::to_vec(&payload_value)
510                            .map_err(|e| DlcError::TokenCreationFailed(e.to_string()))?;
511
512                        let sig = self.sign(&payload_bytes)?;
513                        Ok(SignedLicense::from(format!(
514                            "{}.{}",
515                            URL_SAFE_NO_PAD.encode(&payload_bytes),
516                            URL_SAFE_NO_PAD.encode(sig.as_ref())
517                        )))
518                    },
519                )?;
520                Ok(sig_token)
521            }
522            DlcKey::Public { .. } => Err(DlcError::PrivateKeyRequired),
523        }
524    }
525
526    /// Extend an existing signed license by merging additional DLC ids while preserving backwards compatibility.
527    ///
528    /// Extracts the DLC ids from an existing (unsigned or unverified) license payload, merges them
529    /// with the provided new DLC ids (with deduplication), and creates a fresh signed license
530    /// with the combined list. The product must match this key's current product.
531    ///
532    /// **Important**: This creates a NEW signed token with a potentially different signature,
533    /// but the payload contains all previous DLC ids plus the new ones. Existing DLC packs
534    /// remain unlocked by the new license.
535    ///
536    /// # Example
537    /// ```ignore
538    /// use bevy_dlc::{DlcKey, SignedLicense, Product};
539    ///
540    /// // in a real program you would obtain a key and product from your build
541    /// // process or configuration.  we use simple constructors here so the
542    /// // example compiles without panicking.
543    /// let dlc_key: DlcKey = DlcKey::generate_random();
544    /// let product: Product = Product::from("my_game");
545    ///
546    /// let old_license = SignedLicense::from("...existing token...");
547    /// let _new_license = dlc_key
548    ///     .extend_signed_license(&old_license, &["new_expansion"], product)
549    ///     .unwrap();
550    /// ```
551    pub fn extend_signed_license<D>(
552        &self,
553        existing: &SignedLicense,
554        new_dlcs: impl IntoIterator<Item = D>,
555        product: Product,
556    ) -> Result<SignedLicense, DlcError>
557    where
558        D: std::fmt::Display,
559    {
560        let mut combined_dlcs: Vec<String> = existing.with_secret(|token_str| {
561            let parts: Vec<&str> = token_str.split('.').collect();
562            if parts.len() != 2 {
563                return Vec::new();
564            }
565            if let Ok(payload) = URL_SAFE_NO_PAD.decode(parts[0].as_bytes()) {
566                if let Ok(payload_json) = serde_json::from_slice::<serde_json::Value>(&payload) {
567                    if let Some(dlcs_array) = payload_json.get("dlcs").and_then(|v| v.as_array()) {
568                        let mut dlcs = Vec::new();
569                        for dlc in dlcs_array {
570                            if let Some(dlc_id) = dlc.as_str() {
571                                dlcs.push(dlc_id.to_string());
572                            }
573                        }
574                        return dlcs;
575                    }
576                }
577            }
578            Vec::new()
579        });
580
581        for new_dlc in new_dlcs {
582            let dlc_str = new_dlc.to_string();
583            if !combined_dlcs.contains(&dlc_str) {
584                combined_dlcs.push(dlc_str);
585            }
586        }
587
588        self.create_signed_license(combined_dlcs, product)
589    }
590
591    /// Verify a compact signed-license (signature + payload) using this key's public key
592    /// Verify a compact signed-license using this key's public key.
593    ///
594    /// Returns `true` if the signature is valid and the token is well-formed,
595    /// `false` otherwise.  No further payload validation is performed.
596    pub fn verify_signed_license(&self, license: &SignedLicense) -> bool {
597        license.with_secret(|full_token| {
598            let parts: Vec<&str> = full_token.split('.').collect();
599            if parts.len() != 2 {
600                return false;
601            }
602
603            let payload = URL_SAFE_NO_PAD.decode(parts[0]);
604            let sig_bytes = URL_SAFE_NO_PAD.decode(parts[1]);
605            if payload.is_err() || sig_bytes.is_err() {
606                return false;
607            }
608            let payload = payload.unwrap();
609            let sig_bytes = sig_bytes.unwrap();
610
611            if sig_bytes.len() != 64 {
612                return false;
613            }
614
615            let public = self.get_public_key().0;
616            let public_key = UnparsedPublicKey::new(&ED25519, public);
617            public_key.verify(&payload, &sig_bytes).is_ok()
618        })
619    }
620}
621
622impl Clone for DlcKey {
623    fn clone(&self) -> Self {
624        match self {
625            DlcKey::Private { privkey, pubkey } => privkey.with_secret(|s| DlcKey::Private {
626                privkey: PrivateKey::new(*s),
627                pubkey: *pubkey,
628            }),
629            DlcKey::Public { pubkey } => DlcKey::Public { pubkey: *pubkey },
630        }
631    }
632}
633
634impl std::fmt::Display for DlcKey {
635    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
636        write!(f, "{}", URL_SAFE_NO_PAD.encode(self.get_public_key().0))
637    }
638}
639
640impl From<&DlcKey> for String {
641    fn from(k: &DlcKey) -> Self {
642        k.to_string()
643    }
644}
645
646/// Helper struct for building DLC pack entries with optional metadata.
647/// Provides a builder pattern for creating entries to pack into a `.dlcpack` container.
648#[derive(Clone, Debug)]
649pub struct PackItem {
650    path: String,
651    original_extension: Option<String>,
652    type_path: Option<String>,
653    plaintext: Vec<u8>,
654}
655
656#[allow(dead_code)]
657impl PackItem {
658    pub fn new(path: impl Into<String>, plaintext: impl Into<Vec<u8>>) -> Result<Self, DlcError> {
659        let path = path.into();
660        let bytes = plaintext.into();
661
662        if bytes.len() >= 4 && bytes.starts_with(DLC_PACK_MAGIC) {
663            return Err(DlcError::Other(format!(
664                "cannot pack existing dlcpack container as an item: {}",
665                path
666            )));
667        }
668
669        if pack_format::is_data_executable(&bytes) {
670            return Err(DlcError::Other(format!(
671                "input data looks like an executable payload, which is not allowed: {}",
672                path
673            )));
674        }
675
676        let ext_str = std::path::Path::new(&path)
677            .extension()
678            .and_then(|e| e.to_str());
679
680        if let Some(ext) = ext_str {
681            if pack_format::is_forbidden_extension(ext) {
682                return Err(DlcError::Other(format!(
683                    "input path contains forbidden extension (.{}): {}",
684                    ext, path
685                )));
686            }
687        }
688
689        Ok(Self {
690            path: path.clone(),
691            original_extension: ext_str.map(|s| s.to_string()),
692            type_path: None,
693            plaintext: bytes,
694        })
695    }
696
697    pub fn with_extension(mut self, ext: impl Into<String>) -> Result<Self, DlcError> {
698        let ext_s = ext.into();
699        if pack_format::is_forbidden_extension(&ext_s) {
700            return Err(DlcError::Other(format!(
701                "forbidden extension (.{}): {}",
702                ext_s, self.path
703            )));
704        }
705        self.original_extension = Some(ext_s);
706        Ok(self)
707    }
708
709    pub fn with_type_path(mut self, type_path: impl Into<String>) -> Self {
710        self.type_path = Some(type_path.into());
711        self
712    }
713
714    pub fn with_type<T: Asset>(self) -> Self {
715        self.with_type_path(T::type_path())
716    }
717
718    /// Return the relative path for this item within the pack. This is used by the loader to determine how to register the decrypted asset.
719    pub fn path(&self) -> &str {
720        &self.path
721    }
722
723    /// Return the plaintext bytes for this item. This is the data that will be encrypted and stored in the pack.
724    pub fn plaintext(&self) -> &[u8] {
725        &self.plaintext
726    }
727
728    pub fn ext(&self) -> Option<String> {
729        if let Some(ext) = std::path::Path::new(&self.path)
730            .extension()
731            .and_then(|e| e.to_str())
732            .and_then(|s| {
733                if s.is_empty() {
734                    None
735                } else {
736                    Some(s.to_string())
737                }
738            })
739        {
740            Some(ext)
741        } else {
742            self.original_extension.clone()
743        }
744    }
745
746    pub fn type_path(&self) -> Option<String> {
747        self.type_path.clone()
748    }
749}
750
751impl From<PackItem> for (String, Option<String>, Option<String>, Vec<u8>) {
752    fn from(item: PackItem) -> Self {
753        (
754            item.path,
755            item.original_extension,
756            item.type_path,
757            item.plaintext,
758        )
759    }
760}
761
762/// Parse a `.dlcpack` container and return product, embedded dlc_id, and a list
763/// of `(path, EncryptedAsset)` pairs. For v3 format, also validates the signature
764/// against the authorized product public key.
765///
766/// Returns: (product, dlc_id, entries, signature_bytes_if_v3)
767
768#[derive(Error, Debug, Clone)]
769pub enum DlcError {
770    #[error("invalid public key: {0}")]
771    InvalidPublicKey(String),
772    #[error("malformed private key: {0}")]
773    MalformedLicense(String),
774    #[error("signature verification failed")]
775    SignatureInvalid,
776    #[error("payload parse failed: {0}")]
777    PayloadInvalid(String),
778
779    #[error("private key creation failed: {0}")]
780    TokenCreationFailed(String),
781    #[error("private key required for this operation")]
782    PrivateKeyRequired,
783    #[error("invalid public key")]
784    InvalidPassphrase,
785    #[error("crypto error: {0}")]
786    CryptoError(String),
787
788    #[error("encryption failed: {0}")]
789    EncryptionFailed(String),
790    #[error("{0}")]
791    DecryptionFailed(String),
792    #[error("invalid encrypt key: {0}")]
793    InvalidEncryptKey(String),
794    #[error("invalid nonce: {0}")]
795    InvalidNonce(String),
796
797    #[error("dlc locked: {0}")]
798    DlcLocked(String),
799    #[error("no encrypt key for dlc: {0}")]
800    NoEncryptKey(String),
801
802    /// The DLC pack being loaded is cryptographically valid but the product it is bound to does not match the expected product. This indicates a mismatch between the pack and the game's registered product key.
803    #[error("private key product does not match")]
804    TokenProductMismatch,
805
806    /// The version of the pack being loaded is older than the minimum supported version. The string contains the minimum supported version (e.g. "3").
807    #[error("deprecated version: v{0}")]
808    DeprecatedVersion(String),
809
810    #[error("{0}")]
811    Other(String),
812}
813
814// convenience conversions to reduce boilerplate
815impl From<std::io::Error> for DlcError {
816    fn from(e: std::io::Error) -> Self {
817        DlcError::Other(e.to_string())
818    }
819}
820
821impl From<ring::error::Unspecified> for DlcError {
822    fn from(e: ring::error::Unspecified) -> Self {
823        DlcError::CryptoError(format!("crypto error: {:?}", e))
824    }
825}
826
827#[cfg(test)]
828mod tests {
829    use crate::ext::*;
830
831    use super::*;
832
833    #[test]
834    fn pack_encrypted_pack_rejects_nested_dlc() {
835        let mut v = Vec::new();
836        v.extend_from_slice(DLC_PACK_MAGIC);
837        v.extend_from_slice(b"inner");
838        let err = PackItem::new("a.txt", v).unwrap_err();
839        assert!(err.to_string().contains("cannot pack existing dlcpack"));
840    }
841
842    #[test]
843    fn pack_encrypted_pack_rejects_nested_dlcpack() {
844        let mut v = Vec::new();
845        v.extend_from_slice(DLC_PACK_MAGIC);
846        v.extend_from_slice(b"innerpack");
847        let err = PackItem::new("b.dlcpack", v);
848        assert!(err.is_err());
849    }
850
851    #[test]
852    fn is_data_executable_detects_pe_header() {
853        assert!(is_data_executable(&[0x4D, 0x5A, 0, 0]));
854    }
855
856    #[test]
857    fn is_data_executable_detects_shebang() {
858        assert!(is_data_executable(b"#! /bin/sh"));
859    }
860
861    #[test]
862    fn is_data_executable_ignores_plain_text() {
863        assert!(!is_data_executable(b"hello"));
864    }
865
866    #[test]
867    fn packitem_rejects_binary_data() {
868        let mut v = Vec::new();
869        v.extend_from_slice(&[0x4D, 0x5A, 0, 0]);
870
871        let pack_item = PackItem::new("evil.dat", v);
872        assert!(pack_item.is_err());
873    }
874
875    #[test]
876    fn dlc_id_serde_roundtrip() {
877        let id = DlcId::from("expansion_serde");
878        let s = serde_json::to_string(&id).expect("serialize dlc id");
879        assert_eq!(s, "\"expansion_serde\"");
880        let decoded: DlcId = serde_json::from_str(&s).expect("deserialize dlc id");
881        assert_eq!(decoded.to_string(), "expansion_serde");
882    }
883
884    #[test]
885    fn extend_signed_license_merges_dlc_ids() {
886        let product = Product::from("test_product");
887        let dlc_key = DlcKey::generate_random();
888
889        let initial = dlc_key
890            .create_signed_license(&["expansion_a", "expansion_b"], product.clone())
891            .expect("create initial license");
892
893        let extended = dlc_key
894            .extend_signed_license(&initial, &["expansion_c"], product.clone())
895            .expect("extend license");
896
897        assert!(dlc_key.verify_signed_license(&extended));
898        let verified_dlcs = extract_dlc_ids_from_license(&extended);
899        assert_eq!(verified_dlcs.len(), 3);
900        assert!(verified_dlcs.contains(&"expansion_a".to_string()));
901        assert!(verified_dlcs.contains(&"expansion_b".to_string()));
902        assert!(verified_dlcs.contains(&"expansion_c".to_string()));
903    }
904
905    #[test]
906    fn extend_signed_license_deduplicates() {
907        let product = Product::from("test_product");
908        let dlc_key = DlcKey::generate_random();
909
910        let initial = dlc_key
911            .create_signed_license(&["expansion_a"], product.clone())
912            .expect("create initial license");
913
914        let extended = dlc_key
915            .extend_signed_license(&initial, &["expansion_a"], product.clone())
916            .expect("extend license");
917
918        assert!(dlc_key.verify_signed_license(&extended));
919        let verified_dlcs = extract_dlc_ids_from_license(&extended);
920        let count = verified_dlcs.iter().filter(|d| d == &"expansion_a").count();
921        assert_eq!(count, 1, "Should not duplicate dlc_ids");
922    }
923
924    #[test]
925    #[serial_test::serial]
926    fn register_dlc_type_adds_pack_registrar_factory() {
927        let mut app = App::new();
928        app.add_plugins(AssetPlugin::default());
929        #[derive(Asset, TypePath)]
930        struct TestAsset;
931        app.init_asset::<TestAsset>();
932        app.register_dlc_type::<TestAsset>();
933
934        let factories = app
935            .world()
936            .get_resource::<asset_loader::DlcPackRegistrarFactories>()
937            .expect("should have factories resource");
938        assert!(
939            factories
940                .0
941                .read()
942                .unwrap()
943                .iter()
944                .any(|f| asset_loader::fuzzy_type_path_match(
945                    f.type_name(),
946                    TestAsset::type_path()
947                ))
948        );
949    }
950
951    #[test]
952    #[serial_test::serial]
953    fn register_dlc_type_is_idempotent_for_pack_factories() {
954        let mut app = App::new();
955        app.add_plugins(AssetPlugin::default());
956        #[derive(Asset, TypePath)]
957        struct TestAsset2;
958        app.init_asset::<TestAsset2>();
959        app.register_dlc_type::<TestAsset2>();
960        app.register_dlc_type::<TestAsset2>();
961
962        let factories = app
963            .world()
964            .get_resource::<asset_loader::DlcPackRegistrarFactories>()
965            .expect("should have factories resource");
966        let count = factories
967            .0
968            .read()
969            .unwrap()
970            .iter()
971            .filter(|f| asset_loader::fuzzy_type_path_match(f.type_name(), TestAsset2::type_path()))
972            .count();
973        assert_eq!(count, 1);
974    }
975}
976
977/// Test helpers for integration tests. These provide controlled access to the
978/// internal registry to support test scenarios. Do not use in production code.
979///
980/// The registry should only be updated by `DlcPackLoader` when assets are loaded
981/// or by systems processing `SignedLicense` tokens in production. This module
982/// allows integration tests to bypass normal flow by directly registering keys.
983#[cfg(test)]
984#[allow(dead_code)]
985pub mod test_helpers {
986    use crate::{EncryptionKey, encrypt_key_registry};
987
988    /// Insert a test encryption key into the registry for a given DLC id.
989    ///
990    /// **Test-only**: This bypasses normal production flows and should only be used
991    /// in integration tests that need to load assets without processing signed licenses.
992    pub fn register_test_encryption_key(dlc_id: &str, key: EncryptionKey) {
993        encrypt_key_registry::insert(dlc_id, key);
994    }
995
996    /// Register an asset path in the test registry.
997    ///
998    /// **Test-only**: Used to simulate what `DlcPackLoader` does during asset loading.
999    pub fn register_test_asset_path(dlc_id: &str, path: &str) {
1000        encrypt_key_registry::register_asset_path(dlc_id, path);
1001    }
1002
1003    /// Clear the entire test registry.
1004    ///
1005    /// **Test-only**: Call this in test cleanup to reset state.
1006    pub fn clear_test_registry() {
1007        encrypt_key_registry::clear_all();
1008    }
1009
1010    pub fn is_malicious_data(data: &[u8]) -> bool {
1011        crate::pack_format::is_data_executable(data)
1012    }
1013}