Compare commits

...

3 Commits

16 changed files with 2820 additions and 51 deletions
+6
View File
@@ -1,5 +1,11 @@
# Rust build artifacts
/target/
/dist/
/.env
/.env.*
# Locally supplied MoonWell branding and patch source content.
/crates/moonwell/assets/*.ico
/src/Data/
**/*.rs.bk
# Cargo.lock IS committed (this workspace produces binaries).
Generated
+812 -51
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 165 KiB

+25
View File
@@ -0,0 +1,25 @@
[package]
name = "moonwell-deploy"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "Build vanilla MPQ packages and publish MoonWell launcher manifests to S3"
[lints]
workspace = true
[dependencies]
anyhow.workspace = true
benilla-mpq = { path = "../benilla-mpq" }
clap.workspace = true
http = "1"
md5 = "0.8"
rust-s3 = { version = "0.37.2", default-features = false, features = ["sync-rustls-tls"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sha2 = "0.10"
walkdir = "2.5"
wow-mpq = "0.6.4"
[dev-dependencies]
tempfile = "3"
+134
View File
@@ -0,0 +1,134 @@
# MoonWell deployment
`moonwell-deploy` builds MPQ patches, stages the Rust player client, generates
the launcher manifest, and publishes it to S3. It uses the same `wow-mpq` Rust
builder as MoonWellClientSources, with MPQ v1 and 4 KiB sectors for 1.12.1.
Its small single-unit blocks are converted to stored/sectored blocks, preserving
their bytes, because the current client reader rejects single-unit entries.
The 3.3.5 source tree and its WarcraftXL DLLs are not vanilla client data.
The source project's `.env` is read directly with `--env-file`; credentials
are never copied into the package or printed. Process environment values take
precedence. Supported settings are `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`,
optional `AWS_SESSION_TOKEN`, `AWS_DEFAULT_REGION`, `AWS_BUCKET`, `AWS_ENDPOINT`,
and `AWS_USE_PATH_STYLE_ENDPOINT`. Unquoted Windows paths elsewhere in that file
are ignored. `WOW_HOME` is never used as the vanilla data source.
## Windows pipeline
From the repository root:
```powershell
# Build, stage and generate the local manifest; does not upload.
.\scripts\deploy-moonwell.ps1 -DataPath 'D:\WoW-1.12.1\Data' `
-IconPath 'D:\Branding\MoonWell.ico' -AuthHost 'login.example:3724'
# Check the S3 upload plan without changing any objects.
.\scripts\deploy-moonwell.ps1 -DataPath 'D:\WoW-1.12.1\Data' `
-IconPath 'D:\Branding\MoonWell.ico' -AuthHost 'login.example:3724' -DryRun
# Build and publish a complete vanilla distribution.
.\scripts\deploy-moonwell.ps1 -DataPath 'D:\WoW-1.12.1\Data' `
-IconPath 'D:\Branding\MoonWell.ico' -AuthHost 'login.example:3724' -Publish
```
The default `.env` location is the sibling `MoonWellClientSources/.env`.
Override it with `-EnvFile`. Every run creates a new `dist/moonwell-<timestamp>`
directory; `-PackagePath` overrides it. The local manifest is adjacent to that
directory, with the suffix `.manifest.json`. Staging never changes the source
install or deletes an existing directory. With `-SkipClientBuild -BinaryPath`,
provide an already built player executable. Debug instruments must be disabled
in the binary distributed to players.
Optional `-PatchSource` points to your own source tree containing
`Data/patch-M/`, `Data/patch-N/`, etc. The Rust builder produces `Data/patch-M.MPQ`
and mounts them through the client's vanilla patch chain. A supplied patch
source takes precedence over the same named patch in the input install.
Locale subdirectories such as `Data/ruRU/patch-ruRU-5` are rejected because
MoonWell currently has no locale selection or mounting support.
The Windows script includes `vcruntime140.dll` from Visual Studio's x64
redistributable directory beside the game executable. `-VcRuntimePath` can
select this directory explicitly. Windows 10/11 supplies the Universal CRT.
When staging directly through the Rust CLI, include the required runtime DLL
before generating or publishing the manifest.
Only vanilla archives that the client mounts are copied from the Data directory.
All ten vanilla base archives are required. Input archives may use MPQ v1/v2,
as supported by the client reader; the winning `Spell.dbc` must have 173 columns.
These checks reject a normal 3.3.5 installation; they do not prove that a custom
patch's models, Lua, or DBC rows are compatible with 1.12.1. Test custom content
in the client before publishing it. Install content remains outside Git.
## S3 and Web API
```powershell
cargo run -p moonwell-deploy -- inspect --env-file ..\MoonWellClientSources\.env
cargo run -p moonwell-deploy -- check-storage --env-file ..\MoonWellClientSources\.env
cargo run -p moonwell-deploy -- publish --dir dist\moonwell-package `
--env-file ..\MoonWellClientSources\.env --dry-run
```
`check-storage` uploads a temporary 10 MiB object under `moonwell-deploy-check/`,
checks that its bytes round-trip correctly, then deletes that object. It does
not change any release or active manifest.
The inventory is `{ "buildHash": "...", "files": [{ "path": "...",
"size": 123, "sha256": "...", "objectKey": "..." }] }`. `buildHash` uses
the existing Python/launcher contract: sort by path, join
`path:size:sha256` with newline separators, then SHA-256 the UTF-8 bytes.
State, `.env` files, launcher metadata and partial downloads are excluded.
Symlinks and case-insensitive duplicate paths are rejected.
Files are streamed (multipart for large objects) into
`World of Warcraft 1.12.1/releases/<buildHash>/<relative-path>`. The release's
`manifest.json` is written after all files, and `manifest-1.12.1.json` is switched
last. Upload failure leaves the active inventory unchanged. No old objects are
deleted, so a download link already issued for an older release keeps working.
Run one publisher at a time. To roll back, restore a previous release inventory
as the active manifest; keep that release's objects.
Large files use four concurrent 8 MiB upload parts. The publisher checks each
part's MD5 and the completed object's size and ETag before publishing the
inventory. These ETag checks target the configured unencrypted Yandex S3
objects; SHA-256 remains the launcher manifest's integrity check. Repeating
`publish --dir <existing-package> --env-file .env` reuses completed objects
only when their content ETag and size match. Interrupted multipart files are
uploaded again; completed files are not transmitted again.
Files unchanged from the active release retain their verified immutable
`objectKey`, so publishing an executable or runtime update does not upload the
game data again. Keep all releases whose objects are referenced by an inventory.
The Laravel web app resolves a requested path through the active manifest's
`objectKey` and signs that S3 object. Its settings must match:
```dotenv
MOONWELL_LAUNCHER_BASE_PATH="World of Warcraft 1.12.1"
MOONWELL_LAUNCHER_MANIFEST_KEY=manifest-1.12.1.json
```
The Flutter launcher keeps requesting the existing Web API endpoints. It has
no S3 credentials. Inventories without `objectKey` retain the legacy
`<base-path>/<relative-path>` resolution. The old 3.3.5 `manifest.json` and
`World of Warcraft/` keys are reserved and cannot be publication targets.
Native macOS binaries must be built on a Mac and packaged with
[`package_macos.py`](../moonwell/package_macos.py). `stage --binary MoonWell.app`
can stage such a bundle, but publish it with separate `--manifest-key` and
`--prefix` settings for a launcher distribution targeting macOS. The current
API has one active inventory per configured distribution, not automatic OS
selection. This Windows pipeline does not build or sign macOS applications.
The current Flutter file downloader also does not restore Unix executable bits;
install the `.app` as a bundle with its permissions preserved when testing macOS.
## Checks
```powershell
cargo fmt -p moonwell-deploy -- --check
cargo clippy -p moonwell-deploy --all-targets -- -D warnings
cargo test -p moonwell-deploy
```
Tests cover actual client MPQ readability, excluded state and secrets, the
manifest contract, path validation, upload failures, dry runs and publication
ordering. Live client login remains a separate check.
+139
View File
@@ -0,0 +1,139 @@
mod manifest;
mod package;
mod storage;
mod vanilla_mpq;
use std::path::PathBuf;
use anyhow::{Context, Result};
use clap::{Parser, Subcommand};
#[derive(Parser)]
#[command(about = "Build and publish MoonWell 1.12.1 client packages")]
struct Cli {
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand)]
enum Command {
/// Build vanilla MPQ archives from <source>/Data/patch-? directories.
Pack {
#[arg(long)]
source: PathBuf,
#[arg(long)]
output: PathBuf,
},
/// Stage a player binary, read-only vanilla Data and optional patch sources.
Stage {
#[arg(long)]
binary: PathBuf,
#[arg(long)]
data: PathBuf,
#[arg(long)]
output: PathBuf,
#[arg(long)]
source: Option<PathBuf>,
#[arg(long)]
auth_host: String,
},
/// Generate the launcher inventory of an existing distribution directory.
Manifest {
#[arg(long)]
dir: PathBuf,
#[arg(long)]
output: PathBuf,
},
/// Inspect the remote manifest without writing anything.
Inspect {
#[arg(long)]
env_file: PathBuf,
#[arg(long, default_value = "manifest-1.12.1.json")]
manifest_key: String,
},
/// Verify S3 multipart upload/read using a temporary object, then remove it.
CheckStorage {
#[arg(long)]
env_file: PathBuf,
},
/// Upload a complete package to an immutable release; switch manifest last.
Publish {
#[arg(long)]
dir: PathBuf,
#[arg(long)]
env_file: PathBuf,
#[arg(long, default_value = "World of Warcraft 1.12.1")]
prefix: String,
#[arg(long, default_value = "manifest-1.12.1.json")]
manifest_key: String,
/// Read and report the upload plan without writing any S3 objects.
#[arg(long)]
dry_run: bool,
},
}
fn run() -> Result<()> {
match Cli::parse().command {
Command::Pack { source, output } => package::pack(&source, &output),
Command::Stage {
binary,
data,
output,
source,
auth_host,
} => package::stage(&binary, &data, &output, source.as_deref(), &auth_host),
Command::Manifest { dir, output } => {
package::validate_distribution(&dir)?;
let m = manifest::scan(&dir)?;
manifest::write(&m, &output)?;
println!(
"Manifest: {} files, buildHash={}",
m.files.len(),
m.build_hash
);
Ok(())
}
Command::Inspect {
env_file,
manifest_key,
} => {
let store = storage::S3Store::from_env(&env_file)?;
match storage::Store::get(&store, &manifest_key)? {
None => println!("No remote manifest at {manifest_key}"),
Some(bytes) => {
let m: manifest::Manifest =
serde_json::from_slice(&bytes).context("invalid remote manifest")?;
manifest::validate(&m)?;
println!(
"Remote manifest: {} files, buildHash={}",
m.files.len(),
m.build_hash
);
}
}
Ok(())
}
Command::CheckStorage { env_file } => {
storage::S3Store::from_env(&env_file)?.check_storage()
}
Command::Publish {
dir,
env_file,
prefix,
manifest_key,
dry_run,
} => {
package::validate_distribution(&dir)?;
let m = manifest::scan(&dir)?;
let store = storage::S3Store::from_env(&env_file)?;
storage::publish(&store, &dir, m, &prefix, &manifest_key, dry_run)
}
}
}
fn main() {
if let Err(error) = run() {
eprintln!("Error: {error:#}");
std::process::exit(1);
}
}
+219
View File
@@ -0,0 +1,219 @@
use std::collections::HashSet;
use std::fs::{self, File};
use std::io::{Read, Write};
use std::path::Path;
use anyhow::{bail, ensure, Context, Result};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use walkdir::WalkDir;
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Manifest {
pub build_hash: String,
pub files: Vec<ClientFile>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ClientFile {
pub path: String,
pub size: u64,
pub sha256: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub object_key: Option<String>,
}
pub fn valid_path(path: &str) -> bool {
!path.is_empty()
&& !path.contains(['\\', ':', '\r', '\n', '\0'])
&& path.split('/').all(|p| !matches!(p, "" | "." | ".."))
}
fn ignored(path: &Path) -> bool {
path.components().any(|part| {
let name = part.as_os_str().to_string_lossy().to_ascii_lowercase();
matches!(
name.as_str(),
"cache"
| "errors"
| "logs"
| "screenshots"
| "wtf"
| "benilla-config"
| ".moonwell_launcher"
| ".git"
| ".vscode"
| ".ds_store"
| ".probe-identity"
| "manifest.json"
) || name.starts_with(".env")
|| name.ends_with(".moonwell.part")
})
}
pub fn hash_file(path: &Path) -> Result<(u64, String)> {
let mut file = File::open(path).with_context(|| format!("reading {}", path.display()))?;
let mut hash = Sha256::new();
let mut buffer = vec![0; 1024 * 1024];
let mut size = 0;
loop {
let count = file.read(&mut buffer)?;
if count == 0 {
break;
}
hash.update(&buffer[..count]);
size += count as u64;
}
Ok((size, format!("{:x}", hash.finalize())))
}
pub fn build_hash(files: &[ClientFile]) -> String {
let mut entries: Vec<_> = files.iter().collect();
entries.sort_by(|a, b| a.path.cmp(&b.path));
let lines: Vec<_> = entries
.iter()
.map(|f| format!("{}:{}:{}", f.path, f.size, f.sha256))
.collect();
format!("{:x}", Sha256::digest(lines.join("\n").as_bytes()))
}
pub fn scan(root: &Path) -> Result<Manifest> {
ensure!(root.is_dir(), "package directory does not exist");
let mut files = Vec::new();
let mut names = HashSet::new();
for entry in WalkDir::new(root)
.follow_links(false)
.into_iter()
.filter_entry(|e| {
e.depth() == 0 || !ignored(e.path().strip_prefix(root).expect("walk root"))
})
{
let entry = entry?;
ensure!(
!entry.path_is_symlink(),
"symlinks are not supported in packages: {}",
entry.path().display()
);
if !entry.file_type().is_file() {
continue;
}
let path = entry
.path()
.strip_prefix(root)?
.to_str()
.context("package path is not UTF-8")?
.replace('\\', "/");
ensure!(valid_path(&path), "invalid package path: {path}");
ensure!(
names.insert(path.to_ascii_lowercase()),
"case-insensitive duplicate path: {path}"
);
let (size, sha256) = hash_file(entry.path())?;
files.push(ClientFile {
path,
size,
sha256,
object_key: None,
});
}
files.sort_by(|a, b| a.path.cmp(&b.path));
ensure!(!files.is_empty(), "package is empty");
Ok(Manifest {
build_hash: build_hash(&files),
files,
})
}
pub fn validate(manifest: &Manifest) -> Result<()> {
ensure!(!manifest.files.is_empty(), "manifest is empty");
let mut paths = HashSet::new();
for f in &manifest.files {
ensure!(valid_path(&f.path), "invalid manifest path");
ensure!(
paths.insert(f.path.to_ascii_lowercase()),
"duplicate manifest path"
);
ensure!(
f.sha256.len() == 64
&& f.sha256
.bytes()
.all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase()),
"invalid file SHA-256"
);
}
ensure!(
manifest.build_hash == build_hash(&manifest.files),
"manifest buildHash mismatch"
);
Ok(())
}
pub fn write(manifest: &Manifest, output: &Path) -> Result<()> {
let mut data = serde_json::to_vec_pretty(manifest)?;
data.push(b'\n');
if let Some(parent) = output.parent().filter(|p| !p.as_os_str().is_empty()) {
fs::create_dir_all(parent)?;
}
// Do not truncate an existing release inventory if writing fails.
let temp = output.with_extension("json.part");
let mut file = fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&temp)?;
file.write_all(&data)?;
file.sync_all()?;
drop(file);
if let Err(error) = fs::rename(&temp, output) {
let _ = fs::remove_file(&temp);
bail!("cannot write manifest: {error}");
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hash_matches_the_existing_python_and_launcher_contract() {
let f = ClientFile {
path: "a".into(),
size: 3,
sha256: "abc".into(),
object_key: None,
};
assert_eq!(
build_hash(&[f]),
format!("{:x}", Sha256::digest(b"a:3:abc"))
);
}
#[test]
fn scan_excludes_state_secrets_and_its_own_inventory() {
let dir = tempfile::tempdir().unwrap();
for name in [
"Data/patch-M.MPQ",
"benilla-config/config.toml",
"WTF/Config.wtf",
".env",
"manifest.json",
] {
let p = dir.path().join(name);
fs::create_dir_all(p.parent().unwrap()).unwrap();
fs::write(p, b"test").unwrap();
}
let m = scan(dir.path()).unwrap();
assert_eq!(m.files.len(), 1);
assert_eq!(m.files[0].path, "Data/patch-M.MPQ");
validate(&m).unwrap();
}
#[test]
fn traversal_and_protocol_delimiters_are_rejected() {
for path in ["/a", "../a", "a/../b", "C:/a", "a\\b", "a\nb", "a//b"] {
assert!(!valid_path(path), "{path}");
}
}
}
+453
View File
@@ -0,0 +1,453 @@
use std::collections::HashSet;
use std::fs::{self, File};
use std::io::Read;
use std::path::Path;
use anyhow::{ensure, Context, Result};
use walkdir::WalkDir;
use wow_mpq::compression::flags;
use wow_mpq::{ArchiveBuilder, FormatVersion, ListfileOption};
const BASE_ARCHIVES: &[&str] = &[
"dbc.mpq",
"speech.mpq",
"fonts.mpq",
"interface.mpq",
"misc.mpq",
"sound.mpq",
"wmo.mpq",
"terrain.mpq",
"texture.mpq",
"model.mpq",
];
fn patch_name(name: &str) -> bool {
let name = name.to_ascii_lowercase();
name == "patch"
|| name
.strip_prefix("patch-")
.is_some_and(|s| s.len() == 1 && s.bytes().all(|b| b.is_ascii_alphanumeric()))
}
fn mounted_archive(name: &str) -> bool {
let lower = name.to_ascii_lowercase();
BASE_ARCHIVES.contains(&lower.as_str())
|| lower == "speech2.mpq"
|| lower.strip_suffix(".mpq").is_some_and(patch_name)
}
fn valid_auth_host(host: &str) -> bool {
let (name, port) = host
.split_once(':')
.map_or((host, None), |(name, port)| (name, Some(port)));
name.as_bytes()
.first()
.is_some_and(u8::is_ascii_alphanumeric)
&& name
.bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'-' | b'_'))
&& port.is_none_or(|p| {
p.len() <= 5
&& p.bytes().all(|b| b.is_ascii_digit())
&& p.parse::<u16>().is_ok_and(|p| p > 0)
})
}
fn validate_data(data: &Path) -> Result<Vec<std::path::PathBuf>> {
ensure!(data.is_dir(), "vanilla Data directory was not found");
let mut names = HashSet::new();
let mut files = Vec::new();
for entry in fs::read_dir(data)? {
let entry = entry?;
let name = entry
.file_name()
.into_string()
.map_err(|_| anyhow::anyhow!("non-UTF-8 archive name"))?;
if !mounted_archive(&name) {
continue;
}
ensure!(
entry.file_type()?.is_file(),
"archive must be a regular file: {name}"
);
ensure!(
names.insert(name.to_ascii_lowercase()),
"duplicate archive name: {name}"
);
let mut header = [0u8; 32];
File::open(entry.path())?.read_exact(&mut header)?;
ensure!(
&header[..4] == b"MPQ\x1a" && u16::from_le_bytes([header[12], header[13]]) <= 1,
"{name} is outside the client's MPQ v1/v2 envelope"
);
files.push(entry.path());
}
for base in BASE_ARCHIVES {
ensure!(
names.contains(*base),
"missing vanilla base archive: {base}"
);
}
files.sort();
// Modified installs can mimic vanilla archive names; check the winning 5875 Spell.dbc too.
let mut spell_archives: Vec<_> = files.iter().collect();
// Match benilla-formats' vanilla mount order, including speech2's final priority.
spell_archives.sort_by_key(|p| {
let name = p
.file_name()
.expect("archive name")
.to_string_lossy()
.to_ascii_lowercase();
let priority = BASE_ARCHIVES
.iter()
.position(|base| *base == name)
.unwrap_or_else(|| {
if name == "patch.mpq" {
10
} else if name == "speech2.mpq" {
12
} else {
11
}
});
(priority, name)
});
let mut found = false;
for path in spell_archives.into_iter().rev() {
let archive = benilla_mpq::Archive::open(path)?;
if !archive.contains(r"DBFilesClient\Spell.dbc") {
continue;
}
let mut stream = archive.open_file(r"DBFilesClient\Spell.dbc")?;
let mut header = [0u8; 20];
stream.read_exact(&mut header)?;
ensure!(
&header[..4] == b"WDBC"
&& u32::from_le_bytes(header[8..12].try_into().expect("DBC fields")) == 173,
"Spell.dbc is not the vanilla 5875 layout; 3.3.5 data cannot be deployed as 1.12.1"
);
found = true;
break;
}
ensure!(found, "vanilla Spell.dbc was not found");
Ok(files)
}
fn ensure_outside(input: &Path, output: &Path) -> Result<()> {
let input = input.canonicalize()?;
let absolute = if output.is_absolute() {
output.to_path_buf()
} else {
std::env::current_dir()?.join(output)
};
let parent = absolute
.parent()
.context("output needs a parent directory")?;
fs::create_dir_all(parent)?;
let output = if absolute.exists() {
absolute.canonicalize()?
} else {
parent
.canonicalize()?
.join(absolute.file_name().context("output needs a filename")?)
};
ensure!(
!output.starts_with(&input) && !input.starts_with(&output),
"source and output directories must be separate"
);
Ok(())
}
/// Same wow-mpq builder as MoonWellClientSources, using vanilla's v1 format.
pub fn pack(source: &Path, output: &Path) -> Result<()> {
ensure_outside(source, output)?;
let data = source.join("Data");
ensure!(
data.is_dir(),
"patch sources must contain Data/patch-? directories"
);
let mut patches: Vec<_> = fs::read_dir(&data)?.collect::<std::io::Result<_>>()?;
patches.sort_by_key(|e| e.file_name());
let mut count = 0;
for patch in patches {
let name = patch
.file_name()
.into_string()
.map_err(|_| anyhow::anyhow!("non-UTF-8 patch name"))?;
ensure!(patch.file_type()?.is_dir() && patch_name(&name),
"unsupported vanilla patch source: {name}; locale subdirectories are not supported by the client");
let target = output.join("Data").join(format!("{name}.MPQ"));
ensure!(
!target.exists(),
"patch output already exists: {}",
target.display()
);
let mut builder = ArchiveBuilder::new()
.version(FormatVersion::V1)
.block_size(3)
.default_compression(flags::ZLIB)
.listfile_option(ListfileOption::Generate);
let mut files = Vec::new();
let root = patch.path();
for entry in WalkDir::new(&root).follow_links(false) {
let entry = entry?;
ensure!(
!entry.path_is_symlink(),
"symlinks are not supported in patch sources"
);
ensure!(
entry.file_name() != "__mpqmeta__",
"unresolved MPQ blocks cannot be rebuilt safely"
);
if entry.file_type().is_file() {
let path = entry
.path()
.strip_prefix(&root)?
.to_str()
.context("non-UTF-8 patch path")?
.replace('\\', "/");
ensure!(crate::manifest::valid_path(&path), "invalid archive path");
ensure!(
!path.split('/').any(|p| p.starts_with(".env")),
"patch contains an environment file"
);
files.push((path, entry.into_path()));
}
}
ensure!(!files.is_empty(), "patch {name} is empty");
files.sort_by(|a, b| a.0.cmp(&b.0));
let mut names = HashSet::new();
for (path, source) in files {
ensure!(
names.insert(path.to_ascii_lowercase()),
"duplicate archive path: {path}"
);
builder = builder.add_file(source, &path);
}
fs::create_dir_all(target.parent().expect("Data parent"))?;
let temporary = target.with_extension("MPQ.part");
ensure!(
!temporary.exists(),
"unfinished patch output already exists"
);
builder.build(&temporary)?;
crate::vanilla_mpq::normalize(&temporary)?;
fs::rename(&temporary, &target)?;
println!("Built {}", target.display());
count += 1;
}
ensure!(count > 0, "no patch sources found");
Ok(())
}
pub fn validate_distribution(root: &Path) -> Result<()> {
let windows = root.join("MoonWell.exe");
let mac = root.join("MoonWell.app/Contents/MacOS/MoonWell");
let linux = root.join("MoonWell");
let binary = if windows.is_file() {
windows
} else if mac.is_file() {
ensure!(
root.join("MoonWell.app/Contents/MacOS/launch").is_file(),
"macOS app launcher is missing"
);
mac
} else {
linux
};
let mut magic = [0; 4];
File::open(&binary)
.context("MoonWell player binary is missing")?
.read_exact(&mut magic)?;
ensure!(
magic.starts_with(b"MZ")
|| magic == *b"\x7fELF"
|| [
0xfeedfaceu32,
0xfeedfacf,
0xcefaedfe,
0xcffaedfe,
0xcafebabe,
0xbebafeca
]
.contains(&u32::from_be_bytes(magic)),
"MoonWell binary is not a native executable"
);
validate_data(&root.join("Data"))?;
ensure!(
root.join("realmlist.wtf").is_file(),
"realmlist.wtf is missing"
);
Ok(())
}
pub fn stage(
binary: &Path,
data: &Path,
output: &Path,
source: Option<&Path>,
host: &str,
) -> Result<()> {
let host = host.trim();
ensure!(
valid_auth_host(host),
"auth host must be hostname or IPv4[:port]"
);
let archives = validate_data(data)?;
ensure_outside(data, output)?;
ensure_outside(binary, output)?;
if let Some(source) = source {
ensure_outside(source, output)?;
}
ensure!(
!output.exists(),
"staging output already exists; choose a new directory"
);
let name = binary.file_name().context("binary filename is missing")?;
ensure!(
matches!(
name.to_str(),
Some("MoonWell.exe" | "MoonWell" | "MoonWell.app")
),
"use a MoonWell player executable or .app"
);
fs::create_dir(output)?;
if binary.is_dir() {
for entry in WalkDir::new(binary).follow_links(false) {
let entry = entry?;
ensure!(
!entry.path_is_symlink(),
"bundle symlinks are not supported"
);
let dest = output.join(name).join(entry.path().strip_prefix(binary)?);
if entry.file_type().is_dir() {
fs::create_dir_all(dest)?;
} else {
fs::copy(entry.path(), dest)?;
}
}
} else {
fs::copy(binary, output.join(name))?;
}
let target_data = output.join("Data");
fs::create_dir(&target_data)?;
// Build custom patches first; an explicitly supplied source replaces that install's patch.
if let Some(source) = source {
pack(source, output)?;
}
let built_patches: HashSet<_> = fs::read_dir(&target_data)?
.map(|e| e.map(|e| e.file_name().to_string_lossy().to_ascii_lowercase()))
.collect::<std::io::Result<_>>()?;
for archive in archives {
let dest = target_data.join(archive.file_name().expect("archive name"));
if !built_patches.contains(
&archive
.file_name()
.expect("archive name")
.to_string_lossy()
.to_ascii_lowercase(),
) {
fs::copy(&archive, dest)?;
}
}
fs::write(
output.join("realmlist.wtf"),
format!("set realmlist {host}\n"),
)?;
validate_distribution(output)?;
println!("Staged {}", output.display());
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn packed_archive_is_readable_by_the_actual_client_reader() {
let temp = tempfile::tempdir().unwrap();
let source = temp.path().join("src");
let patch = source.join("Data/patch-M/Interface");
fs::create_dir_all(&patch).unwrap();
let bytes = "-- MoonWell test: русский текст\n".repeat(500).into_bytes();
fs::write(patch.join("Test.lua"), &bytes).unwrap();
let small = b"-- compressed small file\n".repeat(50);
fs::write(patch.join("Small.lua"), &small).unwrap();
fs::write(patch.join("Tiny.lua"), b"hi").unwrap();
fs::write(patch.join("Empty.lua"), b"").unwrap();
let output = temp.path().join("dist");
pack(&source, &output).unwrap();
let archive = benilla_mpq::Archive::open(output.join("Data/patch-M.MPQ")).unwrap();
assert_eq!(archive.read_file(r"Interface\Test.lua").unwrap(), bytes);
assert_eq!(archive.read_file(r"Interface\Small.lua").unwrap(), small);
assert_eq!(archive.read_file(r"Interface\Tiny.lua").unwrap(), b"hi");
assert!(archive
.read_file(r"Interface\Empty.lua")
.unwrap()
.is_empty());
assert!(pack(&source, &output).is_err());
}
#[test]
fn wrath_locale_source_is_rejected() {
let temp = tempfile::tempdir().unwrap();
let source = temp.path().join("src");
fs::create_dir_all(source.join("Data/ruRU/patch-ruRU-5")).unwrap();
assert!(pack(&source, &temp.path().join("out")).is_err());
}
#[test]
fn staging_preserves_the_install_and_custom_sources_take_precedence() {
let temp = tempfile::tempdir().unwrap();
let data = temp.path().join("install/Data");
fs::create_dir_all(&data).unwrap();
for name in BASE_ARCHIVES.iter().chain([&"patch-m.mpq"]) {
let mut spell = [0u8; 20];
spell[..4].copy_from_slice(b"WDBC");
spell[8..12].copy_from_slice(&173u32.to_le_bytes());
ArchiveBuilder::new()
.version(FormatVersion::V1)
.add_file_data(b"old".to_vec(), "Test.txt")
.add_file_data(spell.to_vec(), r"DBFilesClient\Spell.dbc")
.build(data.join(name))
.unwrap();
crate::vanilla_mpq::normalize(&data.join(name)).unwrap();
}
let original = fs::read(data.join("patch-m.mpq")).unwrap();
let source = temp.path().join("src");
fs::create_dir_all(source.join("Data/patch-M")).unwrap();
fs::write(source.join("Data/patch-M/Test.txt"), b"custom").unwrap();
let binary = temp.path().join("MoonWell.exe");
fs::write(&binary, b"MZ\x00\x00 synthetic test executable").unwrap();
let out = temp.path().join("out");
stage(&binary, &data, &out, Some(&source), "127.0.0.1:3724").unwrap();
assert_eq!(fs::read(data.join("patch-m.mpq")).unwrap(), original);
let archive = benilla_mpq::Archive::open(out.join("Data/patch-M.MPQ")).unwrap();
assert_eq!(archive.read_file("Test.txt").unwrap(), b"custom");
assert_eq!(
fs::read_to_string(out.join("realmlist.wtf")).unwrap(),
"set realmlist 127.0.0.1:3724\n"
);
assert!(stage(&binary, &data, &out, None, "127.0.0.1").is_err());
}
#[test]
fn invalid_auth_ports_and_wrath_archives_are_rejected() {
for host in [
"host:0",
"host:65536",
"host:abc",
"host/path",
"host\nset realm x",
] {
assert!(!valid_auth_host(host));
}
let temp = tempfile::tempdir().unwrap();
ArchiveBuilder::new()
.version(FormatVersion::V2)
.add_file_data(b"test".to_vec(), "Test.txt")
.build(temp.path().join("dbc.MPQ"))
.unwrap();
assert!(validate_data(temp.path()).is_err());
}
}
+609
View File
@@ -0,0 +1,609 @@
use std::collections::HashMap;
use std::fs::File;
use std::io::Read;
use std::path::Path;
use anyhow::{ensure, Context, Result};
use s3::creds::Credentials;
use s3::{Bucket, Region};
use crate::manifest::{self, Manifest};
pub trait Store {
fn get(&self, key: &str) -> Result<Option<Vec<u8>>>;
fn matches(&self, _key: &str, _file: &Path) -> Result<bool> {
Ok(false)
}
fn upload(&self, key: &str, file: &Path) -> Result<()>;
fn put_manifest(&self, key: &str, data: &[u8]) -> Result<()>;
}
pub struct S3Store {
bucket: Box<Bucket>,
}
const PART_BYTES: usize = s3::bucket::CHUNK_SIZE;
const UPLOAD_WORKERS: usize = 4;
fn read_part(reader: &mut impl Read) -> Result<Vec<u8>> {
let mut bytes = Vec::with_capacity(PART_BYTES);
reader.take(PART_BYTES as u64).read_to_end(&mut bytes)?;
Ok(bytes)
}
// Yandex's unencrypted S3 objects expose MD5 or MD5-of-parts ETags. SHA-256
// remains the manifest's integrity contract; ETags allow verified upload reuse.
fn file_etag(path: &Path) -> Result<(u64, String)> {
let mut reader = File::open(path)?;
let mut size = 0;
let mut part_hashes = Vec::new();
loop {
let bytes = read_part(&mut reader)?;
size += bytes.len() as u64;
part_hashes.push(md5::compute(&bytes));
if bytes.len() < PART_BYTES {
break;
}
}
let etag = if part_hashes.len() == 1 {
format!("{:x}", part_hashes[0])
} else {
let mut digest = md5::Context::new();
for part in &part_hashes {
digest.consume(part.0);
}
format!("{:x}-{}", digest.finalize(), part_hashes.len())
};
Ok((size, etag))
}
fn read_aws_env(path: &Path) -> Result<HashMap<String, String>> {
// The source project's .env permits unquoted Windows paths containing spaces.
// Read only S3 settings with its existing literal-value semantics; do not interpolate secrets.
let text = std::fs::read_to_string(path).context("cannot read deployment .env")?;
let mut values = HashMap::new();
for line in text.trim_start_matches('\u{feff}').lines() {
let line = line.trim().strip_prefix("export ").unwrap_or(line.trim());
let Some((name, value)) = line.split_once('=') else {
continue;
};
let name = name.trim();
if !name.starts_with("AWS_") {
continue;
}
ensure!(
name.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_'),
"invalid AWS environment variable name"
);
let value = value.trim();
let value = if value.len() >= 2
&& ((value.starts_with('"') && value.ends_with('"'))
|| (value.starts_with('\'') && value.ends_with('\'')))
{
&value[1..value.len() - 1]
} else {
value
};
values.insert(name.into(), value.into());
}
Ok(values)
}
impl S3Store {
fn object_matches(&self, key: &str, size: u64, etag: &str) -> Result<bool> {
let (head, status) = self
.bucket
.head_object(key)
.map_err(|_| anyhow::anyhow!("S3 HEAD failed for {key}"))?;
if status == 404 {
return Ok(false);
}
ensure!(status == 200, "S3 HEAD {key}: HTTP {status}");
Ok(head.content_length == i64::try_from(size).ok()
&& head.e_tag.as_deref().map(|s| s.trim_matches('"')) == Some(etag))
}
fn upload_parts(&self, key: &str, path: &Path, size: u64) -> Result<()> {
let mut reader = File::open(path)?;
if size < PART_BYTES as u64 {
let bytes = read_part(&mut reader)?;
let response = self
.bucket
.put_object(key, &bytes)
.map_err(|_| anyhow::anyhow!("S3 upload failed for {key}"))?;
ensure!(
(200..300).contains(&response.status_code()),
"S3 upload {key}: HTTP {}",
response.status_code()
);
return Ok(());
}
let content_type = "application/octet-stream";
let upload = self
.bucket
.initiate_multipart_upload(key, content_type)
.map_err(|_| anyhow::anyhow!("S3 multipart start failed for {key}"))?;
let result = (|| {
let mut parts = Vec::new();
let mut finished = false;
let mut sent = 0_u64;
while !finished {
let batch = std::thread::scope(|scope| -> Result<Vec<s3::serde_types::Part>> {
let mut handles = Vec::new();
for _ in 0..UPLOAD_WORKERS {
if finished {
break;
}
let bytes = read_part(&mut reader)?;
finished = bytes.len() < PART_BYTES;
sent += bytes.len() as u64;
let number = (parts.len() + handles.len() + 1) as u32;
let upload_id = &upload.upload_id;
handles.push(scope.spawn(move || {
let expected = format!("{:x}", md5::compute(&bytes));
let part = self
.bucket
.put_multipart_chunk(&bytes, key, number, upload_id, content_type)
.map_err(|_| {
anyhow::anyhow!(
"S3 multipart upload failed for {key}, part {number}"
)
})?;
ensure!(
part.etag.trim_matches('"') == expected,
"S3 part checksum mismatch for {key}, part {number}"
);
Ok(part)
}));
}
handles
.into_iter()
.map(|handle| {
handle
.join()
.map_err(|_| anyhow::anyhow!("S3 upload worker failed for {key}"))?
})
.collect()
})?;
parts.extend(batch);
println!("Uploading {key}: {sent}/{size} bytes");
}
let response = self
.bucket
.complete_multipart_upload(key, &upload.upload_id, parts)
.map_err(|_| anyhow::anyhow!("S3 multipart completion failed for {key}"))?;
ensure!(
(200..300).contains(&response.status_code()),
"S3 multipart completion {key}: HTTP {}",
response.status_code()
);
Ok(())
})();
if result.is_err() {
let _ = self.bucket.abort_upload(key, &upload.upload_id);
}
result
}
pub fn check_storage(&self) -> Result<()> {
let nonce = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)?
.as_nanos();
let key = format!("moonwell-deploy-check/{nonce}/multipart test.bin");
let manifest_key = format!("moonwell-deploy-check/{nonce}/manifest.json");
let path = std::env::temp_dir().join(format!("moonwell-s3-check-{nonce}.bin"));
let bytes: Vec<u8> = (0..10 * 1024 * 1024).map(|n| (n % 251) as u8).collect();
std::fs::write(&path, &bytes)?;
let result = (|| {
self.upload(&key, &path)?;
self.upload(&key, &path)?;
let downloaded = self.get(&key)?.context("S3 check object disappeared")?;
ensure!(downloaded == bytes, "S3 round-trip checksum mismatch");
let mut changed = bytes.clone();
changed[0] ^= 1;
std::fs::write(&path, &changed)?;
self.upload(&key, &path)?;
ensure!(
self.get(&key)?.as_deref() == Some(changed.as_slice()),
"S3 same-size replacement checksum mismatch"
);
let inventory = b"{\"probe\":true}\n";
self.put_manifest(&manifest_key, inventory)?;
ensure!(
self.get(&manifest_key)?.as_deref() == Some(inventory),
"S3 JSON manifest round-trip mismatch"
);
Ok(())
})();
let cleanup = self
.bucket
.delete_object(&key)
.map_err(|_| anyhow::anyhow!("cannot clean up S3 check object: {key}"))
.and_then(|r| {
ensure!(
(200..300).contains(&r.status_code()),
"cannot clean up S3 check object: {key}"
);
Ok(())
});
let _ = std::fs::remove_file(&path);
let manifest_cleanup = self
.bucket
.delete_object(&manifest_key)
.map_err(|_| anyhow::anyhow!("cannot clean up S3 check inventory: {manifest_key}"))
.and_then(|r| {
ensure!(
(200..300).contains(&r.status_code()),
"cannot clean up S3 check inventory: {manifest_key}"
);
Ok(())
});
result?;
cleanup?;
manifest_cleanup?;
println!("S3 check passed: 10 MiB multipart round-trip verified; temporary object removed");
Ok(())
}
pub fn from_env(path: &Path) -> Result<Self> {
let values = read_aws_env(path)?;
let get = |key: &str| {
std::env::var(key)
.ok()
.filter(|v| !v.is_empty())
.or_else(|| values.get(key).cloned().filter(|v| !v.is_empty()))
};
let required = |key: &str| get(key).with_context(|| format!("{key} is not set"));
let credentials = Credentials::new(
Some(&required("AWS_ACCESS_KEY_ID")?),
Some(&required("AWS_SECRET_ACCESS_KEY")?),
None,
get("AWS_SESSION_TOKEN").as_deref(),
None,
)
.map_err(|_| anyhow::anyhow!("cannot initialize S3 credentials"))?;
let endpoint = required("AWS_ENDPOINT")?;
ensure!(
endpoint.starts_with("https://"),
"AWS_ENDPOINT must use HTTPS"
);
let bucket = Bucket::new(
&required("AWS_BUCKET")?,
Region::Custom {
region: required("AWS_DEFAULT_REGION")?,
endpoint,
},
credentials,
)
.map_err(|_| anyhow::anyhow!("cannot initialize S3 client"))?;
let bucket = match get("AWS_USE_PATH_STYLE_ENDPOINT").as_deref() {
Some("true" | "1") => bucket.with_path_style(),
None | Some("false" | "0") => bucket,
_ => anyhow::bail!("AWS_USE_PATH_STYLE_ENDPOINT must be true or false"),
};
Ok(Self { bucket })
}
}
impl Store for S3Store {
fn matches(&self, key: &str, file: &Path) -> Result<bool> {
let (size, etag) = file_etag(file)?;
self.object_matches(key, size, &etag)
}
fn get(&self, key: &str) -> Result<Option<Vec<u8>>> {
ensure!(manifest::valid_path(key), "invalid S3 key");
let response = self
.bucket
.get_object(key)
.map_err(|_| anyhow::anyhow!("S3 GET failed for {key}"))?;
match response.status_code() {
404 => Ok(None),
200 => Ok(Some(response.to_vec())),
code => anyhow::bail!("S3 GET {key}: HTTP {code}"),
}
}
fn upload(&self, key: &str, file: &Path) -> Result<()> {
let (size, etag) = file_etag(file)?;
if self.object_matches(key, size, &etag)? {
println!("Reusing verified S3 object: {key}");
return Ok(());
}
self.upload_parts(key, file, size)?;
ensure!(
self.object_matches(key, size, &etag)?,
"S3 object checksum/size mismatch for {key}"
);
Ok(())
}
fn put_manifest(&self, key: &str, data: &[u8]) -> Result<()> {
let mut headers = http::HeaderMap::new();
headers.insert(
http::header::CACHE_CONTROL,
"no-cache, no-store, must-revalidate".parse()?,
);
let response = self
.bucket
.put_object_with_content_type_and_headers(key, data, "application/json", Some(headers))
.map_err(|_| anyhow::anyhow!("S3 manifest write failed for {key}"))?;
ensure!(
(200..300).contains(&response.status_code()),
"S3 manifest {key}: HTTP {}",
response.status_code()
);
Ok(())
}
}
pub fn publish(
store: &impl Store,
dir: &Path,
mut m: Manifest,
prefix: &str,
manifest_key: &str,
dry_run: bool,
) -> Result<()> {
manifest::validate(&m)?;
ensure!(
manifest::valid_path(prefix) && manifest::valid_path(manifest_key),
"invalid publication prefix or manifest key"
);
ensure!(
prefix != "World of Warcraft" && manifest_key != "manifest.json",
"the legacy 3.3.5 publication keys are reserved"
);
let release = format!("{prefix}/releases/{}", m.build_hash);
for file in &mut m.files {
file.object_key = Some(format!("{release}/{}", file.path));
}
println!(
"Release: {release}; {} files; active manifest: {manifest_key}",
m.files.len()
);
// Check credentials/read access before uploading anything, and refuse malformed active inventories.
let previous = if let Some(previous) = store.get(manifest_key)? {
let previous: Manifest = serde_json::from_slice(&previous)
.context("invalid active manifest; publication stopped")?;
manifest::validate(&previous)?;
Some(previous)
} else {
None
};
for file in &mut m.files {
let path = dir.join(&file.path);
let (size, hash) = manifest::hash_file(&path)?;
ensure!(
size == file.size && hash == file.sha256,
"package changed after hashing: {}",
file.path
);
let reusable = previous
.as_ref()
.and_then(|previous| {
previous.files.iter().find(|old| {
old.path == file.path && old.size == file.size && old.sha256 == file.sha256
})
})
.and_then(|old| old.object_key.as_deref())
.filter(|key| key.starts_with(&format!("{prefix}/releases/")));
let reused = if let Some(key) = reusable {
if store.matches(key, &path)? {
file.object_key = Some(key.into());
true
} else {
false
}
} else {
false
};
if !dry_run && !reused {
store.upload(file.object_key.as_deref().expect("release key"), &path)?;
}
println!(
"{} {} ({} bytes)",
match (dry_run, reused) {
(true, true) => "Would reuse",
(true, false) => "Would upload",
(false, true) => "Reused",
(false, false) => "Uploaded",
},
file.path,
size
);
}
if dry_run {
return Ok(());
}
// A concurrent editor or build must not produce a manifest describing different bytes.
for file in &m.files {
let (size, hash) = manifest::hash_file(&dir.join(&file.path))?;
ensure!(
size == file.size && hash == file.sha256,
"package changed during upload: {}",
file.path
);
}
let mut bytes = serde_json::to_vec_pretty(&m)?;
bytes.push(b'\n');
store.put_manifest(&format!("{release}/manifest.json"), &bytes)?;
store.put_manifest(manifest_key, &bytes)?;
println!("Published {}", m.build_hash);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn etags_match_small_and_multipart_s3_golden_vectors() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("object");
std::fs::write(&path, b"abc").unwrap();
assert_eq!(
file_etag(&path).unwrap(),
(3, "900150983cd24fb0d6963f7d28e17f72".into())
);
std::fs::write(&path, []).unwrap();
assert_eq!(
file_etag(&path).unwrap(),
(0, "d41d8cd98f00b204e9800998ecf8427e".into())
);
let mut bytes = vec![b'a'; PART_BYTES];
std::fs::write(&path, &bytes).unwrap();
assert_eq!(
file_etag(&path).unwrap(),
(8388608, "49bcf0ebc7b48b92488324fed7d003f9-2".into())
);
bytes.extend_from_slice(b"bc");
std::fs::write(&path, &bytes).unwrap();
assert_eq!(
file_etag(&path).unwrap(),
(8388610, "097020083a0e59701fcdfbc5edb191ba-2".into())
);
}
#[test]
fn source_dotenv_windows_paths_do_not_break_literal_credentials() {
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join(".env");
std::fs::write(&path, "\u{feff}WOW_HOME=C:\\Games\\World of Warcraft 3.3.5\nexport AWS_SECRET_ACCESS_KEY='literal$not-expanded'\nAWS_BUCKET=client\n").unwrap();
let values = read_aws_env(&path).unwrap();
assert_eq!(values.len(), 2);
assert_eq!(values["AWS_SECRET_ACCESS_KEY"], "literal$not-expanded");
}
use std::cell::RefCell;
struct FakeStore {
operations: RefCell<Vec<String>>,
fail_upload: bool,
}
impl Store for FakeStore {
fn get(&self, _: &str) -> Result<Option<Vec<u8>>> {
Ok(None)
}
fn upload(&self, key: &str, _: &Path) -> Result<()> {
ensure!(!self.fail_upload, "simulated upload failure");
self.operations.borrow_mut().push(key.into());
Ok(())
}
fn put_manifest(&self, key: &str, _: &[u8]) -> Result<()> {
self.operations.borrow_mut().push(key.into());
Ok(())
}
}
#[test]
fn failed_upload_does_not_switch_manifest_and_dry_run_never_writes() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("MoonWell.exe"), b"test").unwrap();
let m = manifest::scan(dir.path()).unwrap();
let store = FakeStore {
operations: RefCell::new(vec![]),
fail_upload: true,
};
assert!(publish(
&store,
dir.path(),
m.clone(),
"MoonWell",
"active.json",
false
)
.is_err());
assert!(store.operations.borrow().is_empty());
publish(&store, dir.path(), m, "MoonWell", "active.json", true).unwrap();
assert!(store.operations.borrow().is_empty());
}
#[test]
fn manifest_switch_follows_all_file_uploads_and_release_inventory() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("MoonWell.exe"), b"test").unwrap();
let m = manifest::scan(dir.path()).unwrap();
let store = FakeStore {
operations: RefCell::new(vec![]),
fail_upload: false,
};
publish(&store, dir.path(), m, "MoonWell", "active.json", false).unwrap();
let ops = store.operations.borrow();
assert!(ops[0].ends_with("/MoonWell.exe"));
assert!(ops[1].ends_with("/manifest.json"));
assert_eq!(ops[2], "active.json");
}
#[test]
fn unchanged_files_reuse_verified_objects_inside_the_release_prefix() {
struct ReuseStore {
previous: Vec<u8>,
uploads: RefCell<Vec<String>>,
published: RefCell<Option<Manifest>>,
}
impl Store for ReuseStore {
fn get(&self, _: &str) -> Result<Option<Vec<u8>>> {
Ok(Some(self.previous.clone()))
}
fn matches(&self, key: &str, _: &Path) -> Result<bool> {
ensure!(
key == "MoonWell/releases/old/MoonWell.exe",
"unexpected source prefix"
);
Ok(true)
}
fn upload(&self, key: &str, _: &Path) -> Result<()> {
self.uploads.borrow_mut().push(key.into());
Ok(())
}
fn put_manifest(&self, _: &str, bytes: &[u8]) -> Result<()> {
*self.published.borrow_mut() = Some(serde_json::from_slice(bytes)?);
Ok(())
}
}
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("MoonWell.exe"), b"game").unwrap();
let mut old = manifest::scan(dir.path()).unwrap();
old.files[0].object_key = Some("MoonWell/releases/old/MoonWell.exe".into());
std::fs::write(dir.path().join("vcruntime140.dll"), b"runtime").unwrap();
let new = manifest::scan(dir.path()).unwrap();
for source in [
"MoonWell/releases/old/MoonWell.exe",
"Other/releases/old/MoonWell.exe",
] {
old.files[0].object_key = Some(source.into());
let store = ReuseStore {
previous: serde_json::to_vec(&old).unwrap(),
uploads: RefCell::new(vec![]),
published: RefCell::new(None),
};
publish(
&store,
dir.path(),
new.clone(),
"MoonWell",
"active.json",
false,
)
.unwrap();
let result = store.published.borrow();
let result = result.as_ref().unwrap();
assert_eq!(result.build_hash, new.build_hash);
let game = result
.files
.iter()
.find(|file| file.path == "MoonWell.exe")
.unwrap();
if source.starts_with("MoonWell/") {
assert_eq!(store.uploads.borrow().len(), 1);
assert_eq!(game.object_key.as_deref(), Some(source));
} else {
assert_eq!(store.uploads.borrow().len(), 2);
assert!(game
.object_key
.as_deref()
.unwrap()
.starts_with(&format!("MoonWell/releases/{}/", new.build_hash)));
}
}
}
}
+79
View File
@@ -0,0 +1,79 @@
//! Normalize wow-mpq's small-file blocks to the stored/sectored envelope read by benilla-mpq.
use std::fs::OpenOptions;
use std::io::{Read, Seek, SeekFrom, Write};
use std::path::Path;
use anyhow::{ensure, Result};
use wow_mpq::crypto::{decrypt_block, encrypt_block, hash_string, hash_type};
pub fn normalize(path: &Path) -> Result<()> {
let mut file = OpenOptions::new().read(true).write(true).open(path)?;
let original_size = file.metadata()?.len();
let mut header = [0u8; 32];
file.read_exact(&mut header)?;
ensure!(
&header[..4] == b"MPQ\x1a" && header[12..14] == [0, 0],
"expected generated MPQ v1"
);
let word =
|offset| u32::from_le_bytes(header[offset..offset + 4].try_into().expect("header word"));
let table_pos = word(20) as u64;
let count = word(28) as usize;
ensure!(
table_pos <= original_size && count as u64 <= (original_size - table_pos) / 16,
"invalid generated block table"
);
let mut table_bytes = vec![0; count * 16];
file.seek(SeekFrom::Start(table_pos))?;
file.read_exact(&mut table_bytes)?;
let mut table: Vec<u32> = table_bytes
.as_chunks::<4>()
.0
.iter()
.map(|c| u32::from_le_bytes(*c))
.collect();
let key = hash_string("(block table)", hash_type::FILE_KEY);
decrypt_block(&mut table, key);
const SINGLE_UNIT: u32 = 0x0100_0000;
const COMPRESS: u32 = 0x0000_0200;
for block in table.as_chunks_mut::<4>().0 {
if block[3] & SINGLE_UNIT == 0 {
continue;
}
ensure!(
block[3] & (0x0001_0000 | 0x0400_0000) == 0,
"unexpected encrypted or CRC block"
);
if block[3] & COMPRESS != 0 {
// wow-mpq chooses single-unit only for <= one sector. Preserve compressed bytes,
// prepend the one-sector offset table, and redirect this block to the appended copy.
ensure!(
block[2] <= 4096 && u64::from(block[0]) + u64::from(block[1]) <= original_size,
"invalid small-file block"
);
let mut bytes = vec![0; block[1] as usize];
file.seek(SeekFrom::Start(block[0] as u64))?;
file.read_exact(&mut bytes)?;
let pos = file.seek(SeekFrom::End(0))?;
block[0] = u32::try_from(pos)?;
block[1] = block[1]
.checked_add(8)
.ok_or_else(|| anyhow::anyhow!("MPQ size overflow"))?;
file.write_all(&8u32.to_le_bytes())?;
file.write_all(&block[1].to_le_bytes())?;
file.write_all(&bytes)?;
}
block[3] &= !SINGLE_UNIT;
}
encrypt_block(&mut table, key);
file.seek(SeekFrom::Start(table_pos))?;
for word in table {
file.write_all(&word.to_le_bytes())?;
}
let size = u32::try_from(file.metadata()?.len())?;
file.seek(SeekFrom::Start(8))?;
file.write_all(&size.to_le_bytes())?;
file.sync_all()?;
Ok(())
}
+32
View File
@@ -0,0 +1,32 @@
[package]
name = "moonwell"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "The MoonWell launcher: platform branding on top of benilla"
[lints]
workspace = true
[features]
default = ["dev"]
dev = ["benilla-app/dev"]
tracy = ["benilla-app/tracy"]
trace_chrome = ["benilla-app/trace_chrome"]
[dependencies]
benilla-app = { path = "../benilla-app", default-features = false }
bevy.workspace = true
[target.'cfg(windows)'.dependencies]
image = { version = "0.25", default-features = false, features = ["ico"] }
winit = { version = "0.30", default-features = false }
[build-dependencies]
benilla-buildstamp = { path = "../benilla-buildstamp" }
winresource = "0.1.31"
[[bin]]
name = "MoonWell"
path = "src/main.rs"
test = false
+43
View File
@@ -0,0 +1,43 @@
# MoonWell
MoonWell starts benilla through `run_with` and adds its own window title and platform
branding. The client build banner identifies it as `extended`.
For player package staging, Rust MPQ builds, launcher manifests and S3 publication,
see [MoonWell deployment](../moonwell-deploy/README.md).
Build the player executable on Windows:
```powershell
cargo build -p moonwell --release --no-default-features
```
The executable is `target/release/MoonWell.exe`. Provide your own WoW 1.12.1
`Data/` or `WoW/Data/` beside it, or set `WOW_DATA` to your install's Data folder.
Local client state is in `benilla-config/` beside the executable.
For development, run `cargo run -p moonwell --profile play`. The `dev` feature
forwards benilla's instruments; disable it for a player build.
Replace `assets/moonwell.ico` with your own multi-resolution Windows icon and
rebuild to update both the executable's icon and the running window's icons.
The icon is embedded; the player does not need the source `.ico` file.
On macOS, build and package a player app from the repository root (Python 3.11+):
```sh
cargo build -p moonwell --release --no-default-features
python3 crates/moonwell/package_macos.py \
--binary target/release/MoonWell \
--icon /path/to/MoonWell.icns
```
This creates `target/release/bundle/MoonWell.app` with the MoonWell name and app
icon. Packaging requires a macOS executable and an ICNS icon; it does not convert a
Windows EXE into a Mac application. Build natively on each target Mac architecture.
Pass `--output` to choose a different destination and `--bundle-id` to set your
application identifier. An existing destination is never overwritten.
Launch the `.app` from Finder. Its launcher finds your own `Data/` or `WoW/Data/`
beside `MoonWell.app` and keeps `benilla-config/` there too, outside the bundle.
Explicit `WOW_DATA` and `BENILLA_HOME` values take priority.
+17
View File
@@ -0,0 +1,17 @@
//! Stamp the build and embed MoonWell's Windows icon and product metadata.
fn main() {
benilla_buildstamp::emit();
println!("cargo::rerun-if-changed=assets/moonwell.ico");
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows") {
winresource::WindowsResource::new()
.set_icon("assets/moonwell.ico")
.set("ProductName", "MoonWell")
.set("FileDescription", "MoonWell")
.set("InternalName", "MoonWell")
.set("OriginalFilename", "MoonWell.exe")
.compile()
.expect("compile MoonWell's Windows resources");
}
}
+89
View File
@@ -0,0 +1,89 @@
#!/usr/bin/env python3
"""Package a macOS player build and an ICNS icon as MoonWell.app."""
import argparse
import os
from pathlib import Path
import plistlib
import shutil
import struct
import tomllib
# Keep the install and writable client state beside the app, outside the bundle.
LAUNCHER = '''#!/bin/sh
set -eu
macos_dir=$(CDPATH= cd -- "$(dirname "$0")" && pwd)
player_dir=$(CDPATH= cd -- "$macos_dir/../../.." && pwd)
if [ "${BENILLA_HOME+x}" != x ]; then
export BENILLA_HOME="$player_dir/benilla-config"
fi
if [ "${WOW_DATA+x}" != x ]; then
for data_dir in "$player_dir/Data" "$player_dir/WoW/Data"; do
if [ -d "$data_dir" ]; then
export WOW_DATA="$data_dir"
break
fi
done
fi
exec "$macos_dir/MoonWell" "$@"
'''
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--binary", type=Path, required=True,
help="MoonWell binary built on macOS without default features")
parser.add_argument("--icon", type=Path, required=True, help="application icon in .icns format")
parser.add_argument("--output", type=Path, default=Path("target/release/bundle/MoonWell.app"))
parser.add_argument("--bundle-id", default="org.moonwell.client")
args = parser.parse_args()
with args.binary.open("rb") as binary:
magic = binary.read(4)
if magic not in (b"\xfe\xed\xfa\xce", b"\xce\xfa\xed\xfe",
b"\xfe\xed\xfa\xcf", b"\xcf\xfa\xed\xfe",
b"\xca\xfe\xba\xbe", b"\xbe\xba\xfe\xca",
b"\xca\xfe\xba\xbf", b"\xbf\xba\xfe\xca"):
parser.error("--binary must be a macOS Mach-O executable, not a Windows EXE")
with args.icon.open("rb") as icon:
header = icon.read(8)
if len(header) != 8 or header[:4] != b"icns" or struct.unpack(">I", header[4:])[0] != args.icon.stat().st_size:
parser.error("--icon must be an ICNS file with a valid container header")
if args.output.suffix != ".app":
parser.error("--output must end in .app")
if args.output.exists():
parser.error("--output already exists; choose a new path or remove the old bundle first")
root = Path(__file__).resolve().parents[2]
with (root / "Cargo.toml").open("rb") as manifest:
version = tomllib.load(manifest)["workspace"]["package"]["version"]
contents = args.output / "Contents"
macos = contents / "MacOS"
resources = contents / "Resources"
macos.mkdir(parents=True)
resources.mkdir()
shutil.copyfile(args.binary, macos / "MoonWell")
(macos / "MoonWell").chmod(0o755)
(macos / "launch").write_text(LAUNCHER, encoding="utf-8", newline="\n")
(macos / "launch").chmod(0o755)
shutil.copyfile(args.icon, resources / "MoonWell.icns")
with (contents / "Info.plist").open("wb") as plist:
plistlib.dump({
"CFBundleName": "MoonWell",
"CFBundleDisplayName": "MoonWell",
"CFBundleExecutable": "launch",
"CFBundleIconFile": "MoonWell.icns",
"CFBundleIdentifier": args.bundle_id,
"CFBundleInfoDictionaryVersion": "6.0",
"CFBundlePackageType": "APPL",
"CFBundleShortVersionString": version,
"CFBundleVersion": version,
"NSHighResolutionCapable": True,
}, plist)
print(os.fspath(args.output.resolve()))
if __name__ == "__main__":
main()
+69
View File
@@ -0,0 +1,69 @@
//! MoonWell's branding lives in this launcher, on top of the stock client.
use benilla_app::BuildId;
use bevy::prelude::*;
use bevy::window::PrimaryWindow;
struct MoonWellPlugin;
impl Plugin for MoonWellPlugin {
fn build(&self, app: &mut App) {
let world = app.world_mut();
for mut window in world
.query_filtered::<&mut Window, With<PrimaryWindow>>()
.iter_mut(world)
{
window.title = "MoonWell".into();
window.name = Some("MoonWell".into());
}
#[cfg(windows)]
app.add_systems(Startup, set_window_icon);
}
}
/// An exclusive system keeps winit's thread-local window access on the main thread.
#[cfg(windows)]
fn set_window_icon(world: &mut World) {
use winit::platform::windows::WindowExtWindows;
let image = image::load_from_memory_with_format(
include_bytes!("../assets/moonwell.ico"),
image::ImageFormat::Ico,
)
.expect("decode MoonWell's embedded icon")
.into_rgba8();
let (width, height) = image.dimensions();
let icon = winit::window::Icon::from_rgba(image.into_raw(), width, height)
.expect("create MoonWell's window icon");
let entity = world
.query_filtered::<Entity, With<PrimaryWindow>>()
.single(world)
.expect("the client has one primary window");
bevy::winit::WINIT_WINDOWS.with_borrow(|windows| {
let window = windows
.get_window(entity)
.expect("the primary window exists before Startup");
window.set_window_icon(Some(icon.clone()));
window.set_taskbar_icon(Some(icon));
});
}
fn main() -> benilla_app::AppExit {
benilla_app::run_with(
BuildId {
version: env!("CARGO_PKG_VERSION"),
describe: env!("BENILLA_GIT_DESCRIBE"),
sha: env!("BENILLA_GIT_SHA"),
short: env!("BENILLA_GIT_SHORT"),
date: env!("BENILLA_GIT_DATE"),
profile: env!("BENILLA_PROFILE"),
project_dir: env!("BENILLA_PROJECT_DIR"),
..Default::default()
},
|app| {
app.add_plugins(MoonWellPlugin);
},
)
}
+94
View File
@@ -0,0 +1,94 @@
[CmdletBinding()]
param(
[string]$DataPath = '',
[string]$IconPath = '',
[string]$PatchSource = '',
[string]$AuthHost = '',
[string]$EnvFile = '',
[string]$PackagePath = '',
[string]$BinaryPath = '',
[string]$VcRuntimePath = '',
[string]$Prefix = 'World of Warcraft 1.12.1',
[string]$ManifestKey = 'manifest-1.12.1.json',
[switch]$Publish,
[switch]$DryRun,
[switch]$SkipClientBuild
)
$ErrorActionPreference = 'Stop'
$repoRoot = Split-Path $PSScriptRoot -Parent
if (-not $EnvFile) {
$EnvFile = Join-Path (Split-Path $repoRoot -Parent) 'MoonWellClientSources\.env'
}
if (-not $PackagePath) {
$PackagePath = Join-Path $repoRoot ('dist\moonwell-' + (Get-Date -Format 'yyyyMMdd-HHmmss'))
}
if (-not $BinaryPath) { $BinaryPath = Join-Path $repoRoot 'target\release\MoonWell.exe' }
if ($BinaryPath.EndsWith('.exe', [StringComparison]::OrdinalIgnoreCase)) {
if (-not $VcRuntimePath) {
$vswherePath = Join-Path ${env:ProgramFiles(x86)} 'Microsoft Visual Studio\Installer\vswhere.exe'
$vsInstall = (& $vswherePath -latest -products '*' -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath).Trim()
if ($LASTEXITCODE -ne 0 -or -not $vsInstall) { throw 'Visual Studio C++ runtime was not found; pass -VcRuntimePath.' }
$redistVersion = Get-ChildItem -LiteralPath (Join-Path $vsInstall 'VC\Redist\MSVC') -Directory |
Where-Object { $_.Name -match '^\d+\.\d+\.\d+$' } |
Sort-Object { [version]$_.Name } -Descending | Select-Object -First 1
$VcRuntimePath = Join-Path $redistVersion.FullName 'x64\Microsoft.VC143.CRT'
}
if (-not (Test-Path -LiteralPath (Join-Path $VcRuntimePath 'vcruntime140.dll') -PathType Leaf)) {
throw 'The x64 Visual C++ redistributable vcruntime140.dll was not found.'
}
}
if (-not $DataPath -or -not $AuthHost) {
throw 'Pass -DataPath pointing to WoW 1.12.1 Data and -AuthHost host[:port]. WOW_HOME from the old .env is deliberately not used.'
}
if ($Publish -and $DryRun) { throw 'Choose -Publish or -DryRun.' }
if (Test-Path -LiteralPath $PackagePath) { throw 'PackagePath already exists; choose a new staging directory.' }
if (($Publish -or $DryRun) -and -not (Test-Path -LiteralPath $EnvFile -PathType Leaf)) {
throw 'Deployment .env was not found.'
}
Push-Location $repoRoot
try {
if (-not $SkipClientBuild) {
$embeddedIcon = Join-Path $repoRoot 'crates\moonwell\assets\moonwell.ico'
if ($IconPath) {
$sourceIcon = (Resolve-Path -LiteralPath $IconPath).Path
New-Item -ItemType Directory -Path (Split-Path $embeddedIcon -Parent) -Force | Out-Null
if ($sourceIcon -ne $embeddedIcon) { Copy-Item -LiteralPath $sourceIcon -Destination $embeddedIcon }
}
if (-not (Test-Path -LiteralPath $embeddedIcon -PathType Leaf)) {
throw 'Provide the MoonWell icon with -IconPath or place it in crates/moonwell/assets/moonwell.ico.'
}
& cargo build -p moonwell --release --no-default-features --locked
if ($LASTEXITCODE -ne 0) { throw 'MoonWell player build failed.' }
}
& cargo build -p moonwell-deploy --release --locked
if ($LASTEXITCODE -ne 0) { throw 'Rust deployment tool build failed.' }
$toolPath = Join-Path $repoRoot 'target\release\moonwell-deploy.exe'
$stageArguments = @('stage', '--binary', $BinaryPath, '--data', $DataPath,
'--output', $PackagePath, '--auth-host', $AuthHost)
if ($PatchSource) { $stageArguments += @('--source', $PatchSource) }
& $toolPath @stageArguments
if ($LASTEXITCODE -ne 0) { throw 'Package staging failed.' }
if ($BinaryPath.EndsWith('.exe', [StringComparison]::OrdinalIgnoreCase)) {
Copy-Item -LiteralPath (Join-Path $VcRuntimePath 'vcruntime140.dll') -Destination (Join-Path $PackagePath 'vcruntime140.dll')
}
# The local inventory lives outside the package, so it cannot list itself.
$inventoryPath = $PackagePath + '.manifest.json'
& $toolPath manifest --dir $PackagePath --output $inventoryPath
if ($LASTEXITCODE -ne 0) { throw 'Manifest generation failed.' }
if ($Publish -or $DryRun) {
$publishArguments = @('publish', '--dir', $PackagePath, '--env-file', $EnvFile,
'--prefix', $Prefix, '--manifest-key', $ManifestKey)
if ($DryRun) { $publishArguments += '--dry-run' }
& $toolPath @publishArguments
if ($LASTEXITCODE -ne 0) { throw 'S3 publication failed.' }
}
Write-Host "Package: $PackagePath"
Write-Host "Local manifest: $inventoryPath"
} finally {
Pop-Location
}