use bevy_dlc::{DLC_PACK_MAGIC, EncryptionKey, parse_encrypted_pack, prelude::*};
use clap::{Arg, ArgAction, Command};
use owo_colors::{AnsiColors, CssColors, OwoColorize};
use secure_gate::RevealSecret;
use std::io::{ErrorKind, Write, stdin, stdout};
use std::path::{Path, PathBuf};
use crate::{is_executable, print_error, resolve_keys};
macro_rules! safe_println {
($($arg:tt)*) => {{
let res = writeln!(stdout(), $($arg)*);
if let Err(e) = res {
if e.kind() == ErrorKind::BrokenPipe {
return Ok(Default::default());
}
}
}};
}
macro_rules! safe_print {
($($arg:tt)*) => {{
let res = write!(stdout(), $($arg)*);
if let Err(e) = res {
if e.kind() == ErrorKind::BrokenPipe {
return Ok(Default::default());
}
}
}};
}
fn encrypt_metadata_section(
metadata: &bevy_dlc::PackMetadata,
key: &EncryptionKey,
) -> Result<([u8; 12], Vec<u8>), Box<dyn std::error::Error>> {
if metadata.is_empty() {
return Ok(([0u8; 12], Vec::new()));
}
use aes_gcm::{Aes256Gcm, KeyInit, Nonce, aead::Aead};
let plaintext = serde_json::to_vec(metadata)?;
let nonce: [u8; 12] = rand::random();
let ciphertext = key.with_secret(|key_bytes| {
let cipher = Aes256Gcm::new_from_slice(key_bytes)
.map_err(|e| bevy_dlc::DlcError::CryptoError(e.to_string()))?;
cipher
.encrypt(Nonce::from_slice(&nonce), plaintext.as_slice())
.map_err(|_| bevy_dlc::DlcError::EncryptionFailed("metadata encryption failed".into()))
})?;
Ok((nonce, ciphertext))
}
fn read_raw_metadata_section(
path: &Path,
) -> Result<([u8; 12], Vec<u8>), Box<dyn std::error::Error>> {
use std::io::Read;
let mut file = std::fs::File::open(path)?;
let mut magic = [0u8; 4];
file.read_exact(&mut magic)?;
if magic != *DLC_PACK_MAGIC {
return Err("invalid dlcpack magic".into());
}
let mut version = [0u8; 1];
file.read_exact(&mut version)?;
let mut product_len = [0u8; 2];
file.read_exact(&mut product_len)?;
let product_len = u16::from_be_bytes(product_len) as usize;
let mut discard = vec![0u8; product_len];
file.read_exact(&mut discard)?;
let mut dlc_len = [0u8; 2];
file.read_exact(&mut dlc_len)?;
let dlc_len = u16::from_be_bytes(dlc_len) as usize;
discard.resize(dlc_len, 0);
file.read_exact(&mut discard)?;
let mut nonce = [0u8; 12];
file.read_exact(&mut nonce)?;
let mut metadata_len = [0u8; 4];
file.read_exact(&mut metadata_len)?;
let metadata_len = u32::from_be_bytes(metadata_len) as usize;
let mut ciphertext = vec![0u8; metadata_len];
file.read_exact(&mut ciphertext)?;
Ok((nonce, ciphertext))
}
pub fn run_edit_repl(
path: PathBuf,
mut encrypt_key: Option<EncryptionKey>,
initial_command: Option<Vec<String>>, dry_run: bool,
) -> Result<(), Box<dyn std::error::Error>> {
let file = std::fs::File::open(&path)?;
let mut reader = std::io::BufReader::new(file);
let parsed_pack = bevy_dlc::parse_encrypted_pack_info(&mut reader, encrypt_key.as_ref())?;
let product = parsed_pack.product;
let mut dlc_id = parsed_pack.dlc_id;
let version = parsed_pack.version;
let mut entries = parsed_pack.entries;
let block_metadatas = parsed_pack.block_metadatas;
let mut pack_metadata = parsed_pack.metadata;
let metadata_locked = parsed_pack.metadata_locked;
let mut metadata_dirty = false;
safe_println!(
"{} {} (v{}, {}: {}, dlc: {})",
"REPL".color(AnsiColors::Cyan).bold(),
path.display().to_string().color(AnsiColors::Cyan),
version,
"product".color(AnsiColors::Blue),
product.as_ref().color(AnsiColors::Magenta).bold(),
dlc_id.as_str().color(AnsiColors::Magenta).bold()
);
let adding_enabled = if encrypt_key.is_some() {
format!("{}", "Adding new files enabled.".green())
} else {
format!(
"{}",
"Adding new files disabled, no signed license.".yellow()
)
};
safe_println!("{}", adding_enabled);
safe_println!("Type 'help' for commands.");
let mut dirty = false;
let mut added_files: std::collections::HashMap<String, Vec<u8>> =
std::collections::HashMap::new();
let build_repl_cli = || {
Command::new("edit")
.no_binary_name(true)
.subcommand(Command::new("info").about("Show pack info"))
.subcommand(
Command::new("ls")
.about("List all entries and their types")
.visible_alias("list"),
)
.subcommand(
Command::new("type")
.about("Set type_path for entry")
.visible_alias("ty")
.arg(
Arg::new("id")
.help("Index or path of the entry")
.required(true),
)
.arg(Arg::new("type").help("New TypePath").required(true)),
)
.subcommand(
Command::new("rm")
.about("Remove entry from manifest")
.visible_alias("remove")
.arg(
Arg::new("id")
.help("Index or path of the entry")
.required(true),
),
)
.subcommand(
Command::new("add")
.about("Add new local file to the pack (requires encryption key)")
.visible_alias("new")
.arg(
Arg::new("file")
.help("Local filesystem path")
.required(true)
.num_args(1..),
)
.arg(
Arg::new("inner_path")
.short('p')
.long("path")
.help("Archive-internal path (defaults to filename)"),
)
.arg(
Arg::new("type")
.short('t')
.long("type")
.help("TypePath override"),
),
)
.subcommand(
Command::new("id")
.about("Set the DLC ID for this pack")
.visible_alias("set-id")
.arg(Arg::new("new_id").help("The new DLC ID").required(true)),
)
.subcommand(
Command::new("export")
.about("Export (decrypt and save) an entry to a local file")
.arg(
Arg::new("id")
.help("Index or path of the entry")
.required(true),
)
.arg(
Arg::new("out")
.help("Optional output destination path (defaults to entry name)"),
),
)
.subcommand(
Command::new("cls")
.about("Clear the console")
.visible_alias("clear"),
)
.subcommand(
Command::new("metadata")
.about("Manage pack metadata")
.visible_alias("meta")
.subcommand(
Command::new("list")
.visible_aliases(&["ls"])
.about("List pack metadata entries"),
)
.subcommand(
Command::new("add")
.about("Add or replace a pack metadata entry")
.arg(Arg::new("key").help("Metadata key").required(true))
.arg(
Arg::new("value")
.help("Metadata value as JSON or plain string")
.required(true)
.num_args(1..),
),
)
.subcommand(
Command::new("remove")
.about("Remove a pack metadata entry")
.visible_aliases(&["rm", "del"])
.arg(Arg::new("key").help("Metadata key").required(true)),
),
)
.subcommand(Command::new("save").about("Write changes back to disk"))
.subcommand(
Command::new("merge")
.about("Merge entries from another .dlcpack into the current one")
.arg(
Arg::new("file")
.help("Path to other dlcpack")
.required(true),
)
.arg(
Arg::new("signed_license")
.long("signed-license")
.help("SignedLicense token for the source pack")
.value_name("SIGNED_LICENSE"),
)
.arg(
Arg::new("pubkey")
.long("pubkey")
.help("Public key to verify the supplied license")
.value_name("PUBKEY"),
)
.arg(
Arg::new("delete_source")
.long("delete")
.short('d')
.help("Remove source pack file after successful merge")
.action(ArgAction::SetTrue),
),
)
.subcommand(
Command::new("exit")
.about("Exit the editor")
.visible_alias("quit")
.visible_alias("q"),
)
};
let mut execute = |parts: Vec<String>| -> Result<bool, Box<dyn std::error::Error>> {
let cmd = build_repl_cli();
match cmd.try_get_matches_from(parts) {
Ok(matches) => {
match matches.subcommand() {
Some(("info", _)) => {
safe_println!(
"Pack info: {}",
path.display().to_string().color(AnsiColors::Cyan)
);
safe_println!(" Product: {}", product.as_ref().color(AnsiColors::Blue));
safe_println!(" DLC ID: {}", dlc_id.color(AnsiColors::Magenta));
safe_println!(
" Version: {}",
version.to_string().color(AnsiColors::Yellow)
);
safe_println!(
" Metadata: {}",
if metadata_locked {
"encrypted (key required to inspect)"
.color(AnsiColors::Yellow)
.to_string()
} else {
format!("{} keys", pack_metadata.len())
.color(AnsiColors::Yellow)
.to_string()
}
);
let archive_size: u64 = if version >= 4 {
block_metadatas
.iter()
.map(|b| b.encrypted_size as u64)
.sum()
} else if version >= 2 {
entries
.get(0)
.map(|(_, e)| e.ciphertext.len() as u64)
.unwrap_or(0)
} else {
entries.iter().map(|(_, e)| e.ciphertext.len() as u64).sum()
};
safe_println!(
" Archive Size: {} ({} bytes)",
bevy_dlc::human_bytes!(archive_size).color(CssColors::SlateGray),
archive_size,
);
return Ok(false);
}
Some(("ls", _)) => {
safe_println!("Entries in {}:", dlc_id.as_str().color(AnsiColors::Magenta));
for (i, (p, enc)) in entries.iter().enumerate() {
safe_println!(
" [{}] {} (ext: {}, size: {}) type: {}",
i.color(AnsiColors::Cyan),
p.as_str().color(AnsiColors::Green),
enc.original_extension.as_str().color(AnsiColors::Yellow),
bevy_dlc::human_bytes!(enc.size as u64).color(CssColors::SlateGray),
enc.type_path
.as_deref()
.unwrap_or("None")
.color(AnsiColors::Yellow),
);
}
return Ok(false);
}
Some(("type", sub)) => {
let id = sub.get_one::<String>("id").unwrap();
let new_type = sub.get_one::<String>("type").unwrap();
let target = if let Ok(idx) = id.parse::<usize>() {
entries.get_mut(idx)
} else {
entries.iter_mut().find(|(p, _)| p == id)
};
if let Some((p, enc)) = target {
enc.type_path = Some(new_type.to_string());
safe_println!(
"Updated type for {} to {}",
p.color(AnsiColors::Green),
new_type.color(AnsiColors::Yellow),
);
dirty = true;
} else {
safe_println!("Entry not found: {}", id);
}
return Ok(false);
}
Some(("rm", sub)) => {
let id = sub.get_one::<String>("id").unwrap();
let idx = if let Ok(idx) = id.parse::<usize>() {
Some(idx)
} else {
entries.iter().position(|(p, _)| p == id)
};
if let Some(i) = idx {
if i < entries.len() {
let (p, _) = entries.remove(i);
added_files.remove(&p);
safe_println!(
"Removed entry from manifest: {}",
p.color(AnsiColors::Green),
);
dirty = true;
} else {
safe_println!("Index out of bounds: {}", i);
}
} else {
safe_println!("Entry not found: {}", id);
}
return Ok(false);
}
Some(("add", sub)) => {
if encrypt_key.is_none() {
safe_println!(
"{} Command 'add' requires an encryption key. Provide a --signed-license (and optionally --pubkey) when launching the editor.",
"error".red().bold(),
);
return Ok(false);
}
let files = sub.get_many::<String>("file").unwrap();
let type_override = sub.get_one::<String>("type");
let inner_path_arg = sub.get_one::<String>("inner_path");
for f in files {
let f_path = Path::new(f);
if !f_path.exists() {
safe_println!("{} Local file not found: {}", "error".red(), f);
continue;
}
match std::fs::metadata(f_path) {
Ok(meta) => {
if !meta.is_file() {
safe_println!(
"{} Path is not a file: {}",
"error".red(),
f,
);
continue;
}
}
Err(e) => {
safe_println!(
"{} Failed to read metadata for {}: {}",
"error".red(),
f,
e,
);
continue;
}
}
if is_executable(f_path) {
safe_println!(
"{} Refusing to pack a executable file: {}",
"error".red(),
f,
);
continue;
}
let filename =
f_path.file_name().unwrap().to_string_lossy().to_string();
let inner_path = inner_path_arg.cloned().unwrap_or(filename);
let data = std::fs::read(f_path)?;
let mut pack_item = match PackItem::new(inner_path.clone(), data) {
Ok(item) => item,
Err(e) => {
print_error(&e.to_string());
continue;
}
};
if let Some(tp) = type_override {
pack_item = pack_item.with_type_path(tp);
}
added_files.insert(inner_path.clone(), pack_item.plaintext().to_vec());
entries.push((
inner_path.clone(),
EncryptedAsset {
dlc_id: dlc_id.to_string(),
original_extension: pack_item.ext().unwrap_or_default(),
type_path: pack_item.type_path(),
nonce: [0u8; 12],
ciphertext: vec![].into(),
block_id: 0,
block_offset: 0,
size: 0,
},
));
safe_println!(
"Added local file to staging: {} -> {}",
f.color(AnsiColors::Cyan),
inner_path.color(AnsiColors::Green),
);
}
dirty = true;
}
Some(("id", sub)) => {
let new_id = sub.get_one::<String>("new_id").unwrap();
safe_println!(
"Renaming pack DLC ID: {} -> {}",
dlc_id.color(AnsiColors::Magenta),
new_id.color(AnsiColors::Magenta).bold(),
);
dlc_id = bevy_dlc::DlcId(new_id.clone());
dirty = true;
return Ok(false);
}
Some(("export", sub)) => {
let id = sub.get_one::<String>("id").unwrap();
let out_path_str = sub.get_one::<String>("out");
let target_path = if let Ok(idx) = id.parse::<usize>() {
entries.get(idx).map(|(p, _)| p.clone())
} else {
entries
.iter()
.find(|(p, _)| p == id)
.map(|(p, _)| p.clone())
};
if let Some(p) = target_path {
let mut out_dest = out_path_str
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from(&p));
if out_dest.is_dir()
|| out_path_str
.map(|s| s.ends_with('/') || s.ends_with('\\'))
.unwrap_or(false)
{
if !out_dest.exists() {
std::fs::create_dir_all(&out_dest)?;
}
out_dest.push(&p);
}
if let Some(parent) = out_dest.parent() {
std::fs::create_dir_all(parent)?;
}
if let Some(ek) = encrypt_key.as_ref() {
if let Some(data) = added_files.get(&p) {
std::fs::write(&out_dest, data)?;
safe_println!(
"Exported (staged) file to {}",
out_dest.display().to_string().color(AnsiColors::Cyan),
);
} else {
use aes_gcm::{Aes256Gcm, KeyInit, Nonce, aead::Aead};
use flate2::read::GzDecoder;
use secure_gate::RevealSecret;
use tar::Archive;
let cipher = ek
.with_secret(|s| Aes256Gcm::new_from_slice(s))
.map_err(|_| "Invalid key")?;
let mut found = false;
if let Some((_, first)) = entries.first() {
let nonce = Nonce::from_slice(&first.nonce);
let pt = cipher
.decrypt(nonce, first.ciphertext.as_ref())
.map_err(|_| "Decryption failed")?;
let decoder = GzDecoder::new(&pt[..]);
let mut archive = Archive::new(decoder);
for entry_res in archive.entries()? {
let mut entry = entry_res?;
if entry.path()?.to_string_lossy() == p {
let mut out_file =
std::fs::File::create(&out_dest)?;
std::io::copy(&mut entry, &mut out_file)?;
safe_println!(
"Exported {} to {}",
p.color(AnsiColors::Green),
out_dest
.display()
.to_string()
.color(AnsiColors::Cyan),
);
found = true;
break;
}
}
}
if !found {
safe_println!(
"{} Entry {} not found in archive.",
"error".red(),
p,
);
}
}
} else {
safe_println!(
"{} Exporting requires an encryption key.",
"error".red()
);
}
} else {
safe_println!("{} Entry not found: {}", "error".red(), id);
}
return Ok(false);
}
Some(("cls", _)) => {
safe_print!("\x1B[2J\x1B[1;1H");
if let Err(e) = stdout().flush() {
if e.kind() == ErrorKind::BrokenPipe {
return Ok(true);
} else {
return Err(e.into());
}
}
return Ok(false);
}
Some(("metadata", sub)) => {
match sub.subcommand() {
Some(("list", _)) | None => {
if metadata_locked {
safe_println!(
"Pack metadata is encrypted. Provide a signed license to inspect it."
);
} else if pack_metadata.is_empty() {
safe_println!("No pack metadata entries.");
} else {
safe_println!("Pack metadata:");
for (key, value) in &pack_metadata {
let rendered =
serde_json::to_string(value).unwrap_or_else(|_| {
"<unprintable metadata>".to_string()
});
safe_println!(
" - {} = {}",
key.color(AnsiColors::Green),
rendered.color(AnsiColors::Yellow),
);
}
}
}
Some(("add", metadata_add)) => {
if encrypt_key.is_none() {
safe_println!(
"{} Editing encrypted metadata requires a signed license. Provide --signed-license (and optionally --pubkey) when launching the editor, or use \"generate\" command.",
"error".red().bold(),
);
return Ok(false);
}
let key = metadata_add
.get_one::<String>("key")
.expect("metadata key is required");
let value_parts = metadata_add
.get_many::<String>("value")
.expect("metadata value is required");
let raw_value = value_parts
.map(String::as_str)
.collect::<Vec<_>>()
.join(" ");
let parsed_value = crate::parse_metadata_value(&raw_value);
let rendered = serde_json::to_string(&parsed_value)
.unwrap_or_else(|_| "<unprintable metadata>".to_string());
pack_metadata.insert(key.clone(), parsed_value);
safe_println!(
"Updated metadata {} = {}",
key.color(AnsiColors::Green),
rendered.color(AnsiColors::Yellow),
);
dirty = true;
metadata_dirty = true;
}
Some(("remove", metadata_remove)) => {
if encrypt_key.is_none() {
safe_println!(
"{} Editing encrypted metadata requires a signed license. Provide --signed-license (and optionally --pubkey) when launching the editor, or use \"generate\" command.",
"error".red().bold(),
);
return Ok(false);
}
let key = metadata_remove
.get_one::<String>("key")
.expect("metadata key is required");
if pack_metadata.remove(key).is_some() {
safe_println!(
"Removed metadata entry {}",
key.color(AnsiColors::Green),
);
dirty = true;
metadata_dirty = true;
} else {
safe_println!("Metadata entry not found: {}", key);
}
}
_ => {}
}
return Ok(false);
}
Some(("save", _)) => {
if !dirty {
safe_println!("No changes to save.");
} else if dry_run {
safe_println!(
"dry-run: would save changes to {}",
path.display().to_string().color(AnsiColors::Cyan)
);
} else {
save_pack_optimized(
&path,
version,
&product,
&dlc_id,
&pack_metadata,
metadata_dirty,
&entries,
&added_files,
encrypt_key.as_ref(),
)?;
safe_println!(
"Saved changes to {}",
path.display().to_string().color(AnsiColors::Cyan),
);
dirty = false;
metadata_dirty = false;
added_files.clear();
}
return Ok(false);
}
Some(("merge", sub)) => {
let file = sub.get_one::<String>("file").unwrap();
let other_path = Path::new(file);
let delete_source = sub.get_flag("delete_source");
if !other_path.exists() {
safe_println!("{} file not found: {}", "error".red(), file);
return Ok(false);
}
if encrypt_key.is_none() {
if let Ok(file) = std::fs::File::open(other_path) {
let mut reader = std::io::BufReader::new(file);
if let Ok((other_prod, _other_did, _ver, _ents, _blocks)) =
parse_encrypted_pack(&mut reader)
{
let (_resolved_pubkey, resolved_license): (
Option<crate::DlcKey>,
Option<crate::SignedLicense>,
) = resolve_keys(
sub.get_one::<String>("pubkey").cloned(),
sub.get_one::<String>("signed_license").cloned(),
Some(other_prod.clone()),
None,
);
if encrypt_key.is_none() {
if let Some(lic) = resolved_license.as_ref() {
if let Some(enc_key) =
bevy_dlc::extract_encrypt_key_from_license(lic)
{
encrypt_key = Some(
enc_key
.with_secret(|kb| EncryptionKey::new(*kb)),
);
}
}
}
}
}
}
if encrypt_key.is_none() {
safe_println!(
"{}: merging packs requires an encryption key",
"error".red(),
);
return Ok(false);
}
if let Err(e) = merge_pack_into(
other_path,
&mut entries,
&mut added_files,
encrypt_key.as_ref(),
&product,
&dlc_id,
) {
safe_println!("{}: failed to merge: {}", "error".red(), e);
} else {
dirty = true;
if delete_source {
if !dry_run {
let _ = std::fs::remove_file(other_path);
}
}
}
return Ok(false);
}
Some(("exit", _)) => {
if dirty {
safe_print!(
"You have unsaved changes. {}? ({}/{}) ",
"Save before exiting".yellow(),
'y'.green(),
'n'.red(),
);
if let Err(e) = stdout().flush() {
if e.kind() == ErrorKind::BrokenPipe {
save_pack_optimized(
&path,
version,
&product,
&dlc_id,
&pack_metadata,
metadata_dirty,
&entries,
&added_files,
encrypt_key.as_ref(),
)?;
safe_println!(
"Saved changes to {}",
path.display().to_string().color(AnsiColors::Cyan),
);
added_files.clear();
metadata_dirty = false;
return Ok(true);
} else {
return Err(e.into());
}
}
let mut confirm = String::new();
stdin().read_line(&mut confirm)?;
if confirm.trim().eq_ignore_ascii_case("y") {
save_pack_optimized(
&path,
version,
&product,
&dlc_id,
&pack_metadata,
metadata_dirty,
&entries,
&added_files,
encrypt_key.as_ref(),
)?;
safe_println!(
"Saved changes to {}",
path.display().to_string().color(AnsiColors::Cyan),
);
added_files.clear();
metadata_dirty = false;
}
}
return Ok(true);
}
_ => {}
}
}
Err(e) => {
safe_println!("{}", e);
}
}
Ok(false)
};
if let Some(cmd_parts) = initial_command {
let _ = execute(cmd_parts)?;
return Ok(());
}
loop {
safe_print!("{} ", ">".color(AnsiColors::Magenta).bold());
if let Err(e) = stdout().flush() {
if e.kind() == ErrorKind::BrokenPipe {
stdout().flush().ok();
return Ok(());
} else {
return Err(e.into());
}
}
let mut input = String::new();
stdin().read_line(&mut input)?;
let trimmed = input.trim();
if trimmed.trim().is_empty() {
continue;
}
let parts: Vec<String> = trimmed.split_whitespace().map(|s| s.to_string()).collect();
if parts.is_empty() {
continue;
}
if execute(parts)? {
break;
}
}
Ok(())
}
fn save_pack_optimized(
path: &Path,
_version: usize,
product: &Product,
dlc_id: &DlcId,
metadata: &bevy_dlc::PackMetadata,
metadata_changed: bool,
entries: &[(String, EncryptedAsset)],
added_files: &std::collections::HashMap<String, Vec<u8>>,
encrypt_key: Option<&EncryptionKey>,
) -> Result<(), Box<dyn std::error::Error>> {
save_pack_with_replacements(
path,
_version,
product,
dlc_id,
metadata,
metadata_changed,
entries,
added_files,
&std::collections::HashMap::new(),
encrypt_key,
)
}
pub(crate) fn save_pack_with_replacements(
path: &Path,
_version: usize,
product: &Product,
dlc_id: &DlcId,
metadata: &bevy_dlc::PackMetadata,
metadata_changed: bool,
entries: &[(String, EncryptedAsset)],
added_files: &std::collections::HashMap<String, Vec<u8>>,
replacement_files: &std::collections::HashMap<String, Vec<u8>>,
encrypt_key: Option<&EncryptionKey>,
) -> Result<(), Box<dyn std::error::Error>> {
use aes_gcm::{Aes256Gcm, KeyInit, Nonce, aead::Aead};
use flate2::read::GzDecoder;
use secure_gate::RevealSecret;
use std::io::{Read, Seek, SeekFrom};
use tar::Archive;
let (old_entries, blocks) = {
let mut file = std::fs::File::open(path)?;
let (_old_prod, _old_did, _old_v, old_entries, blocks) = parse_encrypted_pack(&mut file)?;
(old_entries, blocks)
};
let removed = old_entries.len() > entries.len();
if added_files.is_empty() && replacement_files.is_empty() && !removed {
return update_manifest(
path,
bevy_dlc::DLC_PACK_VERSION_LATEST as usize,
product,
dlc_id,
metadata,
metadata_changed,
entries,
encrypt_key,
);
}
let ek = encrypt_key.ok_or(
"Re-packing (adding or removing files) requires a signed license (--signed-license)",
)?;
let mut items = Vec::new();
let cipher = ek
.with_secret(|s| Aes256Gcm::new_from_slice(s))
.map_err(|_| "cipher init")?;
for replacement_path in replacement_files.keys() {
if !entries.iter().any(|(entry_path, _)| entry_path == replacement_path) {
return Err(format!("replacement entry '{}' is not present in the pack manifest", replacement_path).into());
}
}
{
let mut file = std::fs::File::open(path)?;
let block_data = if !blocks.is_empty() {
let mut configs = Vec::new();
for b in blocks {
file.seek(SeekFrom::Start(b.file_offset))?;
let mut ct = vec![0u8; b.encrypted_size as usize];
file.read_exact(&mut ct)?;
configs.push((b.nonce, ct));
}
configs
} else if let Some((_, first)) = old_entries.first() {
vec![(first.nonce, first.ciphertext.to_vec())]
} else {
vec![]
};
for (nonce_bytes, ct) in block_data {
let nonce = Nonce::from_slice(&nonce_bytes);
let pt = cipher
.decrypt(nonce, ct.as_slice())
.map_err(|_| "Decryption failed (key may be incorrect for this pack)")?;
let mut archive = Archive::new(GzDecoder::new(&pt[..]));
for entry in archive.entries()? {
let mut entry = entry?;
let path_str = entry.path()?.to_string_lossy().to_string();
if entries.iter().any(|(p, _)| p == &path_str)
&& !replacement_files.contains_key(&path_str)
{
let mut data = Vec::new();
std::io::copy(&mut entry, &mut data)?;
let mut item = PackItem::new(path_str.clone(), data)?;
if let Some((_, e)) = old_entries.iter().find(|(p, _)| p == &path_str) {
if let Some(tp) = &e.type_path {
item = item.with_type_path(tp);
}
}
items.push(item);
}
}
}
}
let mut push_staged_item = |entry_path: &String,
bytes: &Vec<u8>|
-> Result<(), Box<dyn std::error::Error>> {
let mut item = PackItem::new(entry_path.clone(), bytes.clone())?;
if let Some((_, entry)) = entries.iter().find(|(path, _)| path == entry_path) {
if let Some(type_path) = &entry.type_path {
item = item.with_type_path(type_path);
}
}
items.push(item);
Ok(())
};
for (entry_path, bytes) in replacement_files {
push_staged_item(entry_path, bytes)?;
}
for (entry_path, bytes) in added_files {
push_staged_item(entry_path, bytes)?;
}
let bytes = bevy_dlc::pack_encrypted_pack_with_metadata(
dlc_id,
&items,
product,
metadata,
ek,
bevy_dlc::DEFAULT_BLOCK_SIZE,
)
.map_err(|e: bevy_dlc::DlcError| e.to_string())?;
std::fs::write(path, bytes)?;
Ok(())
}
fn update_manifest(
path: &Path,
version: usize,
product: &Product,
dlc_id: &DlcId,
metadata: &bevy_dlc::PackMetadata,
metadata_changed: bool,
entries: &[(String, EncryptedAsset)],
encrypt_key: Option<&EncryptionKey>,
) -> Result<(), Box<dyn std::error::Error>> {
let mut out = Vec::new();
out.extend_from_slice(DLC_PACK_MAGIC);
out.push(version as u8);
if version == bevy_dlc::DLC_PACK_VERSION_LATEST as usize {
let prod_bytes = product.as_str().as_bytes();
out.extend_from_slice(&(prod_bytes.len() as u16).to_be_bytes());
out.extend_from_slice(prod_bytes);
let dlc_bytes = dlc_id.as_str().as_bytes();
out.extend_from_slice(&(dlc_bytes.len() as u16).to_be_bytes());
out.extend_from_slice(dlc_bytes);
let (metadata_nonce, metadata_ciphertext) = if metadata_changed {
let key = encrypt_key.ok_or(
"Updating encrypted metadata requires a signed license (--signed-license)",
)?;
encrypt_metadata_section(metadata, key)?
} else {
read_raw_metadata_section(path)?
};
out.extend_from_slice(&metadata_nonce);
out.extend_from_slice(&(metadata_ciphertext.len() as u32).to_be_bytes());
out.extend_from_slice(&metadata_ciphertext);
out.extend_from_slice(&(entries.len() as u32).to_be_bytes());
for (p, enc) in entries {
let p_bytes = p.as_bytes();
out.extend_from_slice(&(p_bytes.len() as u32).to_be_bytes());
out.extend_from_slice(p_bytes);
let ext_bytes = enc.original_extension.as_bytes();
out.push(ext_bytes.len() as u8);
out.extend_from_slice(ext_bytes);
if let Some(tp) = &enc.type_path {
let tp_bytes = tp.as_bytes();
out.extend_from_slice(&(tp_bytes.len() as u16).to_be_bytes());
out.extend_from_slice(tp_bytes);
} else {
out.extend_from_slice(&0u16.to_be_bytes());
}
out.extend_from_slice(&enc.block_id.to_be_bytes());
out.extend_from_slice(&enc.block_offset.to_be_bytes());
out.extend_from_slice(&enc.size.to_be_bytes());
}
let mut file = std::fs::File::open(path)?;
let mut reader = std::io::BufReader::new(&mut file);
let (_p, _id, _v, _e, blocks) = parse_encrypted_pack(&mut reader)?;
out.extend_from_slice(&(blocks.len() as u32).to_be_bytes());
let mut block_data_to_copy = Vec::new();
for b in blocks {
out.extend_from_slice(&b.block_id.to_be_bytes());
let offset_pos = out.len();
out.extend_from_slice(&0u64.to_be_bytes());
out.extend_from_slice(&b.encrypted_size.to_be_bytes());
out.extend_from_slice(&b.uncompressed_size.to_be_bytes());
out.extend_from_slice(&b.nonce);
out.extend_from_slice(&b.crc32.to_be_bytes());
use std::io::{Read, Seek, SeekFrom};
file.seek(SeekFrom::Start(b.file_offset))?;
let mut ct = vec![0u8; b.encrypted_size as usize];
file.read_exact(&mut ct)?;
block_data_to_copy.push((offset_pos, ct));
}
for (offset_pos, ct) in block_data_to_copy {
let current_pos = out.len() as u64;
out[offset_pos..offset_pos + 8].copy_from_slice(¤t_pos.to_be_bytes());
out.extend_from_slice(&ct);
}
} else {
return Err(format!("pack format v{} is unsupported", version).into());
}
std::fs::write(path, &out)?;
Ok(())
}
fn merge_pack_into(
other_pack: &Path,
entries: &mut Vec<(String, EncryptedAsset)>,
added_files: &mut std::collections::HashMap<String, Vec<u8>>,
encrypt_key: Option<&EncryptionKey>,
current_product: &Product,
current_dlc_id: &DlcId,
) -> Result<Vec<String>, Box<dyn std::error::Error>> {
use aes_gcm::{Aes256Gcm, KeyInit, Nonce, aead::Aead};
use flate2::read::GzDecoder;
use secure_gate::RevealSecret;
use tar::Archive;
let ek = encrypt_key.ok_or("encryption key required to merge")?;
let mut file = std::fs::File::open(other_pack)?;
let (other_prod, _other_did, _ver, other_entries, blocks) = parse_encrypted_pack(&mut file)?;
if other_prod != *current_product {
return Err(format!(
"cannot merge two different products '{}' into '{}'",
other_prod, current_product
)
.into());
}
if other_entries.is_empty() {
return Ok(Vec::new());
}
let cipher = ek
.with_secret(|s| Aes256Gcm::new_from_slice(s))
.map_err(|_| "cipher init")?;
let mut strays: Vec<String> = Vec::new();
if blocks.is_empty() {
return Err("cannot merge from pre-v5 pack".into());
} else {
use std::io::{Read, Seek, SeekFrom};
for block in blocks {
file.seek(SeekFrom::Start(block.file_offset))?;
let mut ciphertext = vec![0u8; block.encrypted_size as usize];
file.read_exact(&mut ciphertext)?;
let nonce = Nonce::from_slice(&block.nonce);
let pt = cipher
.decrypt(nonce, ciphertext.as_slice())
.map_err(|_| "decryption failed (key mismatch?)")?;
let decoder = GzDecoder::new(&pt[..]);
let mut archive = Archive::new(decoder);
for entry_res in archive.entries()? {
let mut entry = entry_res?;
let path = entry.path()?.to_string_lossy().to_string();
process_merge_entry(
&mut entry,
path,
&other_entries,
&mut strays,
entries,
added_files,
current_dlc_id,
)?;
}
}
}
if !strays.is_empty() {
safe_println!(
"{}: pack contains unmanifested files; they were skipped. Repack using 'bevy-dlc pack':\n{}",
"warning".yellow().bold(),
strays.join(", "),
);
}
Ok(strays)
}
fn process_merge_entry(
entry: &mut tar::Entry<impl std::io::Read>,
path: String,
other_entries: &[(String, EncryptedAsset)],
strays: &mut Vec<String>,
entries: &mut Vec<(String, EncryptedAsset)>,
added_files: &mut std::collections::HashMap<String, Vec<u8>>,
current_dlc_id: &DlcId,
) -> Result<(), Box<dyn std::error::Error>> {
if !other_entries.iter().any(|(p, _)| p == &path) {
strays.push(path.clone());
return Ok(());
}
if entries.iter().any(|(p, _)| p == &path) || added_files.contains_key(&path) {
return Ok(());
}
let mut data = Vec::new();
std::io::copy(entry, &mut data)?;
let mut pack_item = PackItem::new(path.clone(), data)?;
if let Some((_, enc)) = other_entries.iter().find(|(p, _)| p == &path) {
if let Some(tp) = &enc.type_path {
pack_item = pack_item.with_type_path(tp);
}
}
added_files.insert(path.clone(), pack_item.plaintext().to_vec());
entries.push((
path.clone(),
EncryptedAsset {
dlc_id: current_dlc_id.to_string(),
original_extension: pack_item.ext().unwrap_or_default(),
type_path: pack_item.type_path(),
nonce: [0u8; 12],
ciphertext: vec![].into(),
block_id: 0,
block_offset: 0,
size: 0,
},
));
safe_println!("Merged entry: {}", path.color(AnsiColors::Green));
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use assert_cmd::{Command, pkg_name};
use base64::Engine;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use bevy_dlc::{DlcId, DlcKey, EncryptionKey, PackItem, Product, pack_encrypted_pack};
use predicates::prelude::*;
use secure_gate::RevealSecret;
use tempfile::tempdir;
fn test_bin() -> Command {
Command::cargo_bin(pkg_name!()).expect("build current bevy-dlc binary")
}
#[test]
fn human_bytes_macro_formats_expected() {
assert_eq!(bevy_dlc::human_bytes!(0), "0 B");
assert_eq!(bevy_dlc::human_bytes!(1023), "1023 B");
assert_eq!(bevy_dlc::human_bytes!(1024), "1.00 KB");
assert_eq!(bevy_dlc::human_bytes!(10 * 1024 * 1024 + 512), "10.00 MB");
}
#[test]
fn merge_pack_into_adds_new_files() {
let _dlc_key = DlcKey::generate_random();
let enc_key = EncryptionKey::new(rand::random());
let product = Product::from("prod");
let item1 = PackItem::new("a.txt", b"foo".to_vec()).unwrap();
let bytes_a = pack_encrypted_pack(
&DlcId::from("a".to_string()),
&[item1],
&product,
&enc_key,
bevy_dlc::DEFAULT_BLOCK_SIZE,
)
.unwrap();
let item2 = PackItem::new("b.txt", b"bar".to_vec()).unwrap();
let bytes_b = pack_encrypted_pack(
&DlcId::from("b".to_string()),
&[item2],
&product,
&enc_key,
bevy_dlc::DEFAULT_BLOCK_SIZE,
)
.unwrap();
let dir = tempdir().unwrap();
let path_a = dir.path().join("a.dlcpack");
let path_b = dir.path().join("b.dlcpack");
std::fs::write(&path_a, &bytes_a).unwrap();
std::fs::write(&path_b, &bytes_b).unwrap();
let (_p, _id, _ver, mut entries, _b) = parse_encrypted_pack(&bytes_a[..]).unwrap();
let mut added_files = std::collections::HashMap::new();
let strays = merge_pack_into(
&path_b,
&mut entries,
&mut added_files,
Some(&enc_key),
&Product::from("prod"),
&DlcId::from("a"),
)
.unwrap();
assert!(strays.is_empty());
assert!(entries.iter().any(|(p, _)| p == "b.txt"));
assert!(added_files.contains_key("b.txt"));
assert!(entries.iter().any(|(p, _)| p == "a.txt"));
}
#[test]
fn merge_pack_into_ignores_unmanifested_archive_entries() {
fn add_stray_to_pack(
original: &[u8],
encrypt_key: &EncryptionKey,
stray_path: &str,
stray_data: &[u8],
) -> Vec<u8> {
use aes_gcm::{Aes256Gcm, KeyInit, Nonce, aead::Aead};
use flate2::{Compression, read::GzDecoder, write::GzEncoder};
use secure_gate::RevealSecret;
use std::io::{Read, Seek, SeekFrom};
use tar::Archive;
let mut reader = std::io::Cursor::new(original);
let (product, did, _ver, entries, blocks) =
parse_encrypted_pack(&mut reader).expect("parse original pack");
let cipher = encrypt_key
.with_secret(|s| Aes256Gcm::new_from_slice(s))
.expect("cipher init");
let (nonce_bytes, ciphertext) = if blocks.is_empty() {
let first = &entries[0].1;
(first.nonce, first.ciphertext.clone())
} else {
let first_block = &blocks[0];
let mut data = vec![0u8; first_block.encrypted_size as usize];
reader
.seek(SeekFrom::Start(first_block.file_offset))
.unwrap();
reader.read_exact(&mut data).unwrap();
(first_block.nonce, std::sync::Arc::from(data))
};
let nonce = Nonce::from_slice(&nonce_bytes);
let pt = cipher
.decrypt(nonce, ciphertext.as_ref())
.expect("decrypt tar");
let decoder = GzDecoder::new(&pt[..]);
let mut archive = Archive::new(decoder);
let mut existing: Vec<(String, Vec<u8>)> = Vec::new();
for entry_res in archive.entries().unwrap() {
let mut entry = entry_res.unwrap();
let path = entry.path().unwrap().to_string_lossy().to_string();
let mut data = Vec::new();
std::io::copy(&mut entry, &mut data).unwrap();
existing.push((path, data));
}
existing.push((stray_path.to_string(), stray_data.to_vec()));
let mut new_tar = Vec::new();
{
let mut builder = tar::Builder::new(&mut new_tar);
for (p, data) in &existing {
let mut header = tar::Header::new_gnu();
header.set_size(data.len() as u64);
header.set_cksum();
builder.append_data(&mut header, p, &data[..]).unwrap();
}
builder.finish().unwrap();
}
let mut gz = GzEncoder::new(Vec::new(), Compression::default());
gz.write_all(&new_tar).unwrap();
let compressed = gz.finish().unwrap();
let _items: Vec<PackItem> = existing
.into_iter()
.map(|(p, d)| PackItem::new(p, d).unwrap())
.collect();
let _encrypted_size = compressed.len() as u32;
let new_nonce: [u8; 12] = rand::random();
let cipher2 = encrypt_key
.with_secret(|s| Aes256Gcm::new_from_slice(s))
.expect("cipher init");
let new_ciphertext = cipher2
.encrypt(Nonce::from_slice(&new_nonce), compressed.as_ref())
.unwrap();
let mut out = Vec::new();
out.extend_from_slice(DLC_PACK_MAGIC);
out.push(bevy_dlc::DLC_PACK_VERSION_LATEST);
let p_bytes = product.as_str().as_bytes();
out.extend_from_slice(&(p_bytes.len() as u16).to_be_bytes());
out.extend_from_slice(p_bytes);
let d_bytes = did.as_str().as_bytes();
out.extend_from_slice(&(d_bytes.len() as u16).to_be_bytes());
out.extend_from_slice(d_bytes);
out.extend_from_slice(&[0u8; 12]);
out.extend_from_slice(&0u32.to_be_bytes());
out.extend_from_slice(&(entries.len() as u32).to_be_bytes());
for (path, enc) in entries {
let p_bytes = path.as_bytes();
out.extend_from_slice(&(p_bytes.len() as u32).to_be_bytes());
out.extend_from_slice(p_bytes);
let ext_bytes = enc.original_extension.as_bytes();
out.push(ext_bytes.len() as u8);
out.extend_from_slice(ext_bytes);
if let Some(tp) = enc.type_path {
let tp_bytes = tp.as_bytes();
out.extend_from_slice(&(tp_bytes.len() as u16).to_be_bytes());
out.extend_from_slice(tp_bytes);
} else {
out.extend_from_slice(&0u16.to_be_bytes());
}
out.extend_from_slice(&0u32.to_be_bytes()); out.extend_from_slice(&0u32.to_be_bytes()); out.extend_from_slice(&0u32.to_be_bytes()); }
out.extend_from_slice(&1u32.to_be_bytes());
out.extend_from_slice(&0u32.to_be_bytes());
let _header_size = out.len() as u64 + 8 + 4 + 4 + 12 + 4; out.extend_from_slice(&0u64.to_be_bytes()); let offset_pos = out.len() - 8;
out.extend_from_slice(&(new_ciphertext.len() as u32).to_be_bytes());
out.extend_from_slice(&0u32.to_be_bytes()); out.extend_from_slice(&new_nonce);
out.extend_from_slice(&0u32.to_be_bytes());
let final_offset = out.len() as u64;
let offset_bytes = final_offset.to_be_bytes();
out[offset_pos..offset_pos + 8].copy_from_slice(&offset_bytes);
out.extend_from_slice(&new_ciphertext);
out
}
let _dlc_key = DlcKey::generate_random();
let enc_key = EncryptionKey::new(rand::random());
let product = Product::from("example");
let item = PackItem::new("a.txt", b"hello".to_vec()).unwrap();
let base_pack = pack_encrypted_pack(
&DlcId::from("dlcA".to_string()),
&[item],
&product,
&enc_key,
bevy_dlc::DEFAULT_BLOCK_SIZE,
)
.unwrap();
let bytes = add_stray_to_pack(&base_pack, &enc_key, "test.lua", b"bad");
let dir = tempdir().unwrap();
let tmp_pack = dir.path().join("copy.dlcpack");
std::fs::write(&tmp_pack, &bytes).unwrap();
let (_prod, _did, _ver, mut entries, _b) = parse_encrypted_pack(&bytes[..]).unwrap();
let mut added_files = std::collections::HashMap::new();
let strays = merge_pack_into(
&tmp_pack,
&mut entries,
&mut added_files,
Some(&enc_key),
&Product::from("example"),
&DlcId::from("dlcA"),
)
.unwrap();
assert_eq!(strays, vec!["test.lua".to_string()]);
assert!(
!entries.iter().any(|(p, _)| p == "test.lua"),
"unexpected unmanifested entry added"
);
assert!(!added_files.contains_key("test.lua"));
let mut file = std::fs::File::open(&tmp_pack).unwrap();
let mut found_in_disk = false;
{
use aes_gcm::{Aes256Gcm, KeyInit, Nonce, aead::Aead};
use flate2::read::GzDecoder;
use secure_gate::RevealSecret;
use std::io::{Read, Seek, SeekFrom};
use tar::Archive;
let cipher = enc_key
.with_secret(|s| Aes256Gcm::new_from_slice(s))
.unwrap();
let (_, _, _, disk_entries, blocks) = parse_encrypted_pack(&mut file).unwrap();
let block_data = if blocks.is_empty() {
let first = &disk_entries[0].1;
(first.nonce, first.ciphertext.clone())
} else {
let b = &blocks[0];
file.seek(SeekFrom::Start(b.file_offset)).unwrap();
let mut data = vec![0u8; b.encrypted_size as usize];
file.read_exact(&mut data).unwrap();
(b.nonce, std::sync::Arc::from(data))
};
let nonce = Nonce::from_slice(&block_data.0);
let pt = cipher.decrypt(nonce, block_data.1.as_ref()).unwrap();
let decoder = GzDecoder::new(&pt[..]);
let mut archive = Archive::new(decoder);
for entry_res in archive.entries().unwrap() {
let entry = entry_res.unwrap();
if entry.path().unwrap().to_string_lossy() == "test.lua" {
found_in_disk = true;
break;
}
}
}
assert!(
found_in_disk,
"pack should still contain stray entry on disk since we don't rewrite"
);
}
#[test]
fn save_pack_removes_deleted_entries() {
let _dlc_key = DlcKey::generate_random();
let enc_key = EncryptionKey::new(rand::random());
let product = Product::from("prod");
let id = DlcId::from("removal".to_string());
let item1 = PackItem::new("a.txt", b"one".to_vec()).unwrap();
let item2 = PackItem::new("b.txt", b"two".to_vec()).unwrap();
let bytes = pack_encrypted_pack(
&id,
&[item1, item2],
&product,
&enc_key,
bevy_dlc::DEFAULT_BLOCK_SIZE,
)
.unwrap();
let dir = tempdir().unwrap();
let path = dir.path().join("pack.dlcpack");
std::fs::write(&path, &bytes).unwrap();
let (_prod, _did, ver, mut entries, _) = parse_encrypted_pack(&bytes[..]).unwrap();
entries.retain(|(p, _)| p != "b.txt");
let added_files = std::collections::HashMap::new();
save_pack_optimized(
&path,
ver,
&product,
&id,
&bevy_dlc::PackMetadata::new(),
false,
&entries,
&added_files,
Some(&enc_key),
)
.unwrap();
use aes_gcm::{Aes256Gcm, KeyInit, Nonce, aead::Aead};
use flate2::read::GzDecoder;
use secure_gate::RevealSecret;
use std::io::{Read, Seek, SeekFrom};
use tar::Archive;
let cipher = enc_key
.with_secret(|s| Aes256Gcm::new_from_slice(s))
.unwrap();
let mut file = std::fs::File::open(&path).unwrap();
match parse_encrypted_pack(&mut file) {
Ok((_, _, _, disk_entries, blocks)) => {
assert_eq!(disk_entries.len(), 1);
let block_data = if blocks.is_empty() {
let first = &disk_entries[0].1;
(first.nonce, first.ciphertext.clone())
} else {
let b = &blocks[0];
file.seek(SeekFrom::Start(b.file_offset)).unwrap();
let mut data = vec![0u8; b.encrypted_size as usize];
file.read_exact(&mut data).unwrap();
(b.nonce, std::sync::Arc::from(data))
};
let nonce = Nonce::from_slice(&block_data.0);
let pt = cipher.decrypt(nonce, block_data.1.as_ref()).unwrap();
let decoder = GzDecoder::new(&pt[..]);
let mut archive = Archive::new(decoder);
let paths: Vec<_> = archive
.entries()
.unwrap()
.map(|e| e.unwrap().path().unwrap().to_string_lossy().to_string())
.collect();
assert_eq!(paths, vec!["a.txt"]);
}
Err(e) => {
eprintln!("parse failed: {:?}", e);
panic!("save pack parse error");
}
}
}
#[test]
fn edit_one_shot_ls() {
let _dlc_key = DlcKey::generate_random();
let enc_key = EncryptionKey::new(rand::random());
let product = Product::from("prod");
let item = PackItem::new("foo.txt", b"hello".to_vec()).unwrap();
let bytes = pack_encrypted_pack(
&DlcId::from("p".to_string()),
&[item],
&product,
&enc_key,
bevy_dlc::DEFAULT_BLOCK_SIZE,
)
.unwrap();
let tmp = tempdir().unwrap();
let pack_path = tmp.path().join("p.dlcpack");
std::fs::write(&pack_path, &bytes).unwrap();
let mut cmd = test_bin();
cmd.current_dir(tmp.path());
cmd.arg("edit").arg("p.dlcpack").arg("--").arg("ls");
cmd.assert()
.success()
.stdout(
predicates::str::contains("Entries in ").and(predicates::str::contains("foo.txt")),
);
}
#[test]
fn edit_one_shot_info() {
let _dlc_key = DlcKey::generate_random();
let enc_key = EncryptionKey::new(rand::random());
let product = Product::from("example");
let item = PackItem::new("foo.txt", b"hello".to_vec()).unwrap();
let bytes = pack_encrypted_pack(
&DlcId::from("p".to_string()),
&[item],
&product,
&enc_key,
bevy_dlc::DEFAULT_BLOCK_SIZE,
)
.unwrap();
let tmp = tempdir().unwrap();
let pack_path = tmp.path().join("p.dlcpack");
std::fs::write(&pack_path, &bytes).unwrap();
let mut cmd = test_bin();
cmd.current_dir(tmp.path());
cmd.arg("edit").arg("p.dlcpack").arg("--").arg("info");
cmd.assert()
.success()
.stdout(predicate::str::contains("Pack info:"))
.stdout(predicate::str::contains("Product:"))
.stdout(predicate::str::contains("DLC ID:"));
}
#[test]
fn edit_type_command_updates_manifest() {
let tmp = tempdir().unwrap();
let pack_path = tmp.path().join("p.dlcpack");
let _dlc_key = DlcKey::generate_random();
let enc_key = EncryptionKey::new(rand::random());
let product = Product::from("prod");
let item = PackItem::new("foo.txt", b"hello".to_vec()).unwrap();
let bytes = pack_encrypted_pack(
&DlcId::from("p".to_string()),
&[item],
&product,
&enc_key,
bevy_dlc::DEFAULT_BLOCK_SIZE,
)
.unwrap();
std::fs::write(&pack_path, &bytes).unwrap();
let mut cmd = test_bin();
cmd.current_dir(tmp.path());
cmd.arg("edit").arg("p.dlcpack");
cmd.write_stdin("type foo.txt my::Type\nsave\nexit\n");
cmd.assert().success();
let file = std::fs::File::open(&pack_path).unwrap();
let (_prod, _did, _version, entries, _blocks) = parse_encrypted_pack(file).unwrap();
let entry = entries.iter().find(|(path, _)| path == "foo.txt").expect("entry exists");
assert_eq!(entry.1.type_path.as_deref(), Some("my::Type"));
}
#[test]
fn edit_id_command_updates_pack_id() {
let tmp = tempdir().unwrap();
let pack_path = tmp.path().join("p.dlcpack");
let _dlc_key = DlcKey::generate_random();
let enc_key = EncryptionKey::new(rand::random());
let product = Product::from("prod");
let item = PackItem::new("foo.txt", b"hello".to_vec()).unwrap();
let bytes = pack_encrypted_pack(
&DlcId::from("p".to_string()),
&[item],
&product,
&enc_key,
bevy_dlc::DEFAULT_BLOCK_SIZE,
)
.unwrap();
std::fs::write(&pack_path, &bytes).unwrap();
let mut cmd = test_bin();
cmd.current_dir(tmp.path());
cmd.arg("edit").arg("p.dlcpack");
cmd.write_stdin("id renamed\nsave\nexit\n");
cmd.assert().success();
let file = std::fs::File::open(&pack_path).unwrap();
let (_prod, did, _version, _entries, _blocks) = parse_encrypted_pack(file).unwrap();
assert_eq!(did.as_str(), "renamed");
}
#[test]
fn edit_add_and_export_commands_work_with_signed_license() {
let tmp = tempdir().unwrap();
let pack_path = tmp.path().join("p.dlcpack");
let dlc_key = DlcKey::generate_random();
let product = Product::from("prod");
let signed = dlc_key
.create_signed_license(&["p"], product.clone())
.unwrap();
let signed_str = signed.expose_secret().to_string();
std::fs::write(tmp.path().join("prod.slicense"), &signed_str).unwrap();
let enc_key = bevy_dlc::extract_encrypt_key_from_license(&signed).unwrap();
let item = PackItem::new("foo.txt", b"hello".to_vec()).unwrap();
let bytes = pack_encrypted_pack(
&DlcId::from("p".to_string()),
&[item],
&product,
&enc_key,
bevy_dlc::DEFAULT_BLOCK_SIZE,
)
.unwrap();
std::fs::write(&pack_path, &bytes).unwrap();
std::fs::write(tmp.path().join("bar.txt"), b"bar data").unwrap();
let mut cmd = test_bin();
cmd.current_dir(tmp.path());
cmd.arg("edit").arg("p.dlcpack");
cmd.write_stdin(
"add bar.txt --path nested/bar.txt --type my::Bar\nexport nested/bar.txt exported.txt\nsave\nexit\n",
);
cmd.assert().success();
let exported = std::fs::read(tmp.path().join("exported.txt")).unwrap();
assert_eq!(exported, b"bar data");
let file = std::fs::File::open(&pack_path).unwrap();
let (_prod, _did, _version, entries, _blocks) = parse_encrypted_pack(file).unwrap();
let entry = entries.iter().find(|(path, _)| path == "nested/bar.txt").expect("added entry exists");
assert_eq!(entry.1.type_path.as_deref(), Some("my::Bar"));
}
#[test]
fn edit_one_shot_cls_succeeds() {
let _dlc_key = DlcKey::generate_random();
let enc_key = EncryptionKey::new(rand::random());
let product = Product::from("example");
let item = PackItem::new("foo.txt", b"hello".to_vec()).unwrap();
let bytes = pack_encrypted_pack(
&DlcId::from("p".to_string()),
&[item],
&product,
&enc_key,
bevy_dlc::DEFAULT_BLOCK_SIZE,
)
.unwrap();
let tmp = tempdir().unwrap();
let pack_path = tmp.path().join("p.dlcpack");
std::fs::write(&pack_path, &bytes).unwrap();
let mut cmd = test_bin();
cmd.current_dir(tmp.path());
cmd.arg("edit").arg("p.dlcpack").arg("--").arg("cls");
cmd.assert()
.success()
.stdout(predicate::str::contains("\u{1b}[2J\u{1b}[1;1H"));
}
#[test]
fn edit_dry_run_save_does_not_modify() {
let tmp = tempdir().unwrap();
let pack_path = tmp.path().join("p.dlcpack");
let _dlc_key = DlcKey::generate_random();
let enc_key = EncryptionKey::new(rand::random());
let product = Product::from("prod");
let item = PackItem::new("foo.txt", b"hello".to_vec()).unwrap();
let bytes = pack_encrypted_pack(
&DlcId::from("p".to_string()),
&[item],
&product,
&enc_key,
bevy_dlc::DEFAULT_BLOCK_SIZE,
)
.unwrap();
std::fs::write(&pack_path, &bytes).unwrap();
let mut cmd = test_bin();
cmd.current_dir(tmp.path());
cmd.arg("--dry-run").arg("edit").arg("p.dlcpack");
cmd.write_stdin("rm foo.txt\nsave\nexit\n");
cmd.assert().success();
let file = std::fs::File::open(&pack_path).unwrap();
let (_p, _id, _v, entries, _) = parse_encrypted_pack(file).unwrap();
assert!(entries.iter().any(|(p, _)| p == "foo.txt"));
}
#[test]
fn edit_metadata_commands_persist_metadata_changes() {
let tmp = tempdir().expect("tempdir");
let pack_path = tmp.path().join("p.dlcpack");
let product = Product::from("prod");
let dlc_key = DlcKey::generate_random();
let signed_license = dlc_key
.create_signed_license(&[DlcId::from("p".to_string())], product.clone())
.expect("create signed license");
let enc_key = bevy_dlc::extract_encrypt_key_from_license(&signed_license)
.expect("extract encrypt key from signed license");
let item = PackItem::new("foo.txt", b"hello".to_vec()).expect("pack item");
let bytes = pack_encrypted_pack(
&DlcId::from("p".to_string()),
&[item],
&product,
&enc_key,
bevy_dlc::DEFAULT_BLOCK_SIZE,
)
.expect("pack encrypted pack");
std::fs::write(&pack_path, &bytes).expect("write pack");
let mut signed_license_text = String::new();
signed_license.with_secret(|text| {
signed_license_text = text.to_string();
});
std::fs::write(tmp.path().join("prod.slicense"), signed_license_text)
.expect("write signed license");
let mut cmd = test_bin();
cmd.current_dir(tmp.path());
cmd.arg("edit").arg("p.dlcpack");
cmd.write_stdin(
"metadata add chapter intro\nmetadata add flags {\"boss\":true,\"stage\":2}\nmetadata list\nmetadata remove chapter\nsave\nexit\n",
);
cmd.assert()
.success()
.stdout(predicate::str::contains("flags"));
let file = std::fs::File::open(&pack_path).expect("open modified pack");
let parsed = bevy_dlc::parse_encrypted_pack_info(file, Some(&enc_key))
.expect("parse modified pack");
assert!(!parsed.metadata.contains_key("chapter"));
assert_eq!(
parsed.metadata["flags"]["boss"],
serde_json::Value::Bool(true)
);
assert_eq!(
parsed.metadata["flags"]["stage"],
serde_json::Value::from(2)
);
}
#[test]
fn merge_with_delete_and_dry_run_behaviour() {
let dlc_key = DlcKey::generate_random();
let product = Product::from("prod");
let signed_b = dlc_key
.create_signed_license(&[DlcId::from("b".to_string())], product.clone())
.unwrap();
let enc_key: EncryptionKey = bevy_dlc::extract_encrypt_key_from_license(&signed_b)
.expect("license should contain encrypt_key");
let item_a = PackItem::new("a.txt", b"foo".to_vec()).unwrap();
let bytes_a = pack_encrypted_pack(
&DlcId::from("a".to_string()),
&[item_a],
&product,
&enc_key,
bevy_dlc::DEFAULT_BLOCK_SIZE,
)
.unwrap();
let item_b = PackItem::new("b.txt", b"bar".to_vec()).unwrap();
let bytes_b = pack_encrypted_pack(
&DlcId::from("b".to_string()),
&[item_b],
&product,
&enc_key,
bevy_dlc::DEFAULT_BLOCK_SIZE,
)
.unwrap();
let tmp = tempdir().unwrap();
let path_a = tmp.path().join("a.dlcpack");
let path_b = tmp.path().join("b.dlcpack");
std::fs::write(&path_a, &bytes_a).unwrap();
std::fs::write(&path_b, &bytes_b).unwrap();
let mut lic_str = String::new();
signed_b.with_secret(|s| lic_str = s.to_string());
let pub_b64 = URL_SAFE_NO_PAD.encode(dlc_key.get_public_key().0);
let mut cmd = test_bin();
cmd.current_dir(tmp.path());
cmd.arg("edit")
.arg("a.dlcpack")
.arg("--")
.arg("merge")
.arg("b.dlcpack")
.arg("--signed-license")
.arg(&lic_str)
.arg(format!("--pubkey={}", pub_b64))
.arg("-d");
let output = cmd.assert().success().get_output().stdout.clone();
eprintln!("stdout: {}", String::from_utf8_lossy(&output));
assert!(!path_b.exists());
std::fs::write(&path_b, &bytes_b).unwrap();
let mut cmd2 = test_bin();
cmd2.current_dir(tmp.path());
cmd2.arg("--dry-run")
.arg("edit")
.arg("a.dlcpack")
.arg("--")
.arg("merge")
.arg("b.dlcpack")
.arg("--signed-license")
.arg(&lic_str)
.arg("--pubkey")
.arg(pub_b64)
.arg("-d");
cmd2.assert().success();
assert!(path_b.exists());
}
#[test]
fn pack_and_generate_dry_run() {
let tmp = tempdir().unwrap();
let file_path = tmp.path().join("foo.txt");
std::fs::write(&file_path, b"data").unwrap();
let dlc_key = DlcKey::generate_random();
let signed_license = dlc_key
.create_signed_license(&[DlcId::from("mypack".to_string())], Product::from("prod"))
.unwrap();
let mut lic_str = String::new();
signed_license.with_secret(|s| lic_str = s.to_string());
let pub_b64 = URL_SAFE_NO_PAD.encode(dlc_key.get_public_key().0);
std::fs::write(tmp.path().join("prod.slicense"), &lic_str).unwrap();
std::fs::write(tmp.path().join("prod.pubkey"), &pub_b64).unwrap();
let mut cmd = test_bin();
cmd.current_dir(tmp.path());
cmd.arg("--dry-run")
.arg("pack")
.arg("prod")
.arg("mypack")
.arg("--signed-license")
.arg("prod.slicense")
.arg("--pubkey")
.arg("prod.pubkey")
.arg("--types")
.arg("txt=DummyType")
.arg("--")
.arg(file_path.to_str().unwrap());
cmd.assert().success();
assert!(!tmp.path().join("mypack.dlcpack").exists());
assert!(tmp.path().join("prod.slicense").exists());
assert!(tmp.path().join("prod.pubkey").exists());
let slicense_path = tmp.path().join("prod.slicense");
let pubkey_path = tmp.path().join("prod.pubkey");
let original_slicense = std::fs::read_to_string(&slicense_path).unwrap();
let original_pubkey = std::fs::read_to_string(&pubkey_path).unwrap();
let mut cmd2 = test_bin();
cmd2.current_dir(tmp.path());
cmd2.arg("--dry-run")
.arg("generate")
.arg("prod")
.arg("dlc1")
.arg("--force");
cmd2.assert().success();
assert!(slicense_path.exists());
assert!(pubkey_path.exists());
assert_eq!(
std::fs::read_to_string(&slicense_path).unwrap(),
original_slicense
);
assert_eq!(
std::fs::read_to_string(&pubkey_path).unwrap(),
original_pubkey
);
}
#[test]
fn generate_refuses_overwrite_with_invalid_files() {
let tmp = tempdir().unwrap();
let prod = "prod";
let sl = tmp.path().join("prod.slicense");
let pk = tmp.path().join("prod.pubkey");
std::fs::write(&sl, "not-a-license").unwrap();
std::fs::write(&pk, "not-a-pubkey").unwrap();
let mut cmd = test_bin();
cmd.current_dir(tmp.path());
cmd.arg("generate").arg(prod).arg("dlcA");
cmd.assert().failure();
let mut cmd2 = test_bin();
cmd2.current_dir(tmp.path());
cmd2.arg("generate").arg("--force").arg(prod).arg("dlcA");
cmd2.assert().success();
}
#[test]
fn generate_refuses_overwrite_with_valid_files() {
let tmp = tempdir().unwrap();
let prod = "prod";
let mut cmd_gen = test_bin();
cmd_gen.current_dir(tmp.path());
cmd_gen.arg("generate").arg(prod).arg("dlcA");
cmd_gen.assert().success();
let mut cmd = test_bin();
cmd.current_dir(tmp.path());
cmd.arg("generate").arg(prod).arg("dlcA");
cmd.assert().failure();
}
#[test]
fn generate_creates_expected_files() {
let tmp = tempdir().unwrap();
let out_dir = tmp.path().join("output/");
let prod = "prod";
let mut cmd = test_bin();
cmd.current_dir(tmp.path());
cmd.arg("generate")
.arg(prod)
.arg("dlcA")
.arg("-o")
.arg(&out_dir);
cmd.assert().success();
assert!(out_dir.join("prod.slicense").exists());
assert!(out_dir.join("prod.pubkey").exists());
}
#[test]
fn pack_directory_ignores_forbidden_extensions() {
let tmp = tempdir().unwrap();
let assets_dir = tmp.path().join("assets");
std::fs::create_dir_all(&assets_dir).unwrap();
std::fs::write(assets_dir.join("data.txt"), b"game data").unwrap();
std::fs::write(assets_dir.join("bundle.dlcpack"), b"dlc bytes").unwrap();
std::fs::write(assets_dir.join("game.slicense"), b"license").unwrap();
std::fs::write(assets_dir.join("game.pubkey"), b"pubkey").unwrap();
std::fs::write(assets_dir.join("win.exe"), b"MZ").unwrap();
std::fs::write(assets_dir.join("lib.dll"), b"MZ").unwrap();
let dlc_key = DlcKey::generate_random();
let signed_license = dlc_key
.create_signed_license(
&[DlcId::from("testpack".to_string())],
Product::from("prod"),
)
.unwrap();
let mut lic_str = String::new();
signed_license.with_secret(|s| lic_str = s.to_string());
let pub_b64 = URL_SAFE_NO_PAD.encode(dlc_key.get_public_key().0);
std::fs::write(tmp.path().join("prod.slicense"), &lic_str).unwrap();
std::fs::write(tmp.path().join("prod.pubkey"), &pub_b64).unwrap();
let mut cmd = test_bin();
cmd.current_dir(tmp.path());
cmd.arg("--dry-run")
.arg("pack")
.arg("prod")
.arg("testpack")
.arg("--signed-license")
.arg("prod.slicense")
.arg("--pubkey")
.arg("prod.pubkey")
.arg("--types")
.arg("txt=DummyType")
.arg("--")
.arg(assets_dir.to_str().unwrap());
cmd.assert().success();
}
#[test]
fn exit_prompt_saves_if_yes() {
let tmp = tempdir().unwrap();
let pack_path = tmp.path().join("p.dlcpack");
let dlc_key = DlcKey::generate_random();
let product = Product::from("prod");
let signed = dlc_key
.create_signed_license(&["p"], product.clone())
.unwrap();
let signed_str = signed.expose_secret().to_string();
let key_bytes = bevy_dlc::extract_encrypt_key_from_license(&signed).unwrap();
let enc_key = EncryptionKey::from(key_bytes);
let item = PackItem::new("foo.txt", b"hello".to_vec()).unwrap();
let bytes = pack_encrypted_pack(
&DlcId::from("p".to_string()),
&[item],
&product,
&enc_key,
bevy_dlc::DEFAULT_BLOCK_SIZE,
)
.unwrap();
std::fs::write(&pack_path, &bytes).unwrap();
let mut cmd = test_bin();
cmd.current_dir(tmp.path());
cmd.arg("edit")
.arg("p.dlcpack")
.arg("--signed-license")
.arg(&signed_str);
cmd.write_stdin("rm foo.txt\nexit\ny\n");
cmd.assert().success();
let file = std::fs::File::open(&pack_path).unwrap();
let (_p, _id, _v, entries, _) = parse_encrypted_pack(file).unwrap();
assert!(!entries.iter().any(|(p, _)| p == "foo.txt"));
}
}