Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 95a9a7cdd5 | |||
| f1c362f17f | |||
| 6693fb5306 |
@@ -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
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 165 KiB |
@@ -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"
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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);
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user