Phase 2a: read-only scan/classification and process observation (verified against live repack)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ДмитрийandClaude Sonnet 5.5 committed 2026-09-30 07:01:44 +05:00
1 parent c39ff556a3
commit e1b8267022
7 files changed
+865 -9

No files matched your search

+2
View File
@@ -0,0 +1,2 @@
* text=auto eol=lf
*.bat text eol=crlf
Generated
+84 -9
View File
@@ -82,6 +82,7 @@ dependencies = [
"tracing", "tracing",
"tracing-subscriber", "tracing-subscriber",
"uuid", "uuid",
"windows-sys 0.60.2",
] ]
[[package]] [[package]]
@@ -664,7 +665,16 @@ version = "0.59.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
dependencies = [ dependencies = [
"windows-targets", "windows-targets 0.52.6",
]
[[package]]
name = "windows-sys"
version = "0.60.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
dependencies = [
"windows-targets 0.53.5",
] ]
[[package]] [[package]]
@@ -682,14 +692,31 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
dependencies = [ dependencies = [
"windows_aarch64_gnullvm", "windows_aarch64_gnullvm 0.52.6",
"windows_aarch64_msvc", "windows_aarch64_msvc 0.52.6",
"windows_i686_gnu", "windows_i686_gnu 0.52.6",
"windows_i686_gnullvm", "windows_i686_gnullvm 0.52.6",
"windows_i686_msvc", "windows_i686_msvc 0.52.6",
"windows_x86_64_gnu", "windows_x86_64_gnu 0.52.6",
"windows_x86_64_gnullvm", "windows_x86_64_gnullvm 0.52.6",
"windows_x86_64_msvc", "windows_x86_64_msvc 0.52.6",
]
[[package]]
name = "windows-targets"
version = "0.53.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
dependencies = [
"windows-link",
"windows_aarch64_gnullvm 0.53.1",
"windows_aarch64_msvc 0.53.1",
"windows_i686_gnu 0.53.1",
"windows_i686_gnullvm 0.53.1",
"windows_i686_msvc 0.53.1",
"windows_x86_64_gnu 0.53.1",
"windows_x86_64_gnullvm 0.53.1",
"windows_x86_64_msvc 0.53.1",
] ]
[[package]] [[package]]
@@ -698,48 +725,96 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
[[package]] [[package]]
name = "windows_aarch64_msvc" name = "windows_aarch64_msvc"
version = "0.52.6" version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
name = "windows_aarch64_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
[[package]] [[package]]
name = "windows_i686_gnu" name = "windows_i686_gnu"
version = "0.52.6" version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
[[package]]
name = "windows_i686_gnu"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
[[package]] [[package]]
name = "windows_i686_gnullvm" name = "windows_i686_gnullvm"
version = "0.52.6" version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
name = "windows_i686_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
[[package]] [[package]]
name = "windows_i686_msvc" name = "windows_i686_msvc"
version = "0.52.6" version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]]
name = "windows_i686_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
[[package]] [[package]]
name = "windows_x86_64_gnu" name = "windows_x86_64_gnu"
version = "0.52.6" version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
[[package]]
name = "windows_x86_64_gnu"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
[[package]] [[package]]
name = "windows_x86_64_gnullvm" name = "windows_x86_64_gnullvm"
version = "0.52.6" version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
[[package]] [[package]]
name = "windows_x86_64_msvc" name = "windows_x86_64_msvc"
version = "0.52.6" version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "windows_x86_64_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
[[package]] [[package]]
name = "zmij" name = "zmij"
version = "1.0.23" version = "1.0.23"
+6
View File
@@ -18,3 +18,9 @@ tracing-subscriber = { version = "0.3", default-features = false, features = ["f
[dev-dependencies] [dev-dependencies]
tempfile = "3" tempfile = "3"
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.60", features = ["Win32_Foundation", "Win32_System_Threading", "Win32_System_SystemInformation", "Win32_System_Diagnostics_ToolHelp", "Win32_NetworkManagement_IpHelper", "Win32_Networking_WinSock"] }
[features]
testkit = []
+10
View File
@@ -0,0 +1,10 @@
use std::path::Path;
fn main() {
let path = std::env::args().nth(1).expect("usage: scan <server folder>");
let root = Path::new(&path);
let report = coa_core::layout::scan(root).expect("scan failed");
println!("{}", serde_json::to_string_pretty(&report).unwrap());
let observed = coa_core::process::observe(root, &report.ports);
println!("{}", serde_json::to_string_pretty(&observed).unwrap());
}
+443
View File
@@ -0,0 +1,443 @@
//! Read-only detection and classification of a CoA Repack-shaped server folder.
//!
//! Nothing here writes to the scanned folder. Files are opened for reading only, and only the few files whose
//! content matters (server executables) are hashed; everything else is looked at by name and size.
use std::fs::{self, File};
use std::io::{Read, Seek, SeekFrom};
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::error::Result;
use crate::fsx;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Classification {
Healthy,
Partial,
UnknownCustom,
Incompatible,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum Status {
Found,
Missing,
Attention,
}
#[derive(Debug, Clone, Serialize)]
pub struct Item {
pub key: &'static str,
pub label: &'static str,
pub status: Status,
pub detail: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct ExeInfo {
pub size: u64,
pub sha256: String,
/// Some(true/false) when RELEASE.json lists this binary, None when it does not.
pub matches_release: Option<bool>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ReleaseJson {
pub name: Option<String>,
pub release_date: Option<String>,
pub main_revision: Option<String>,
pub source_revision: Option<String>,
#[serde(default)]
pub binaries: Vec<ReleaseBinary>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "PascalCase")]
pub struct ReleaseBinary {
pub name: String,
#[serde(rename = "SHA256")]
pub sha256: String,
pub bytes: u64,
}
#[derive(Debug, Clone, Serialize)]
pub struct Ports {
pub mysql: u16,
pub auth: u16,
pub world: u16,
pub ra: u16,
}
impl Default for Ports {
fn default() -> Self {
Ports { mysql: 3307, auth: 3724, world: 8085, ra: 3443 }
}
}
#[derive(Debug, Clone, Serialize)]
pub struct ClientInfo {
pub path: String,
pub executable: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct ScanReport {
pub path: String,
pub classification: Classification,
pub items: Vec<Item>,
pub worldserver: Option<ExeInfo>,
pub authserver: Option<ExeInfo>,
pub release: Option<ReleaseJson>,
/// Core commit as printed by the worldserver banner (12 hex) when found in its log.
pub banner_revision: Option<String>,
pub module_configs: Vec<String>,
pub bot_config_keys: usize,
pub ports: Ports,
pub database_schemas: Vec<String>,
pub client: Option<ClientInfo>,
pub notes: Vec<String>,
/// Always false: a scan never modifies anything.
pub modifies_files: bool,
}
fn exists(root: &Path, rel: &str) -> bool {
root.join(rel).exists()
}
fn item(key: &'static str, label: &'static str, present: bool, detail: Option<String>) -> Item {
Item { key, label, status: if present { Status::Found } else { Status::Missing }, detail }
}
fn hash_exe(path: &Path, release: Option<&ReleaseJson>, name: &str) -> Option<ExeInfo> {
let meta = fs::metadata(path).ok()?;
if !meta.is_file() {
return None;
}
let sha256 = fsx::sha256_file(path).ok()?;
let matches_release = release.and_then(|r| {
r.binaries.iter().find(|b| b.name.eq_ignore_ascii_case(name)).map(|b| b.sha256.eq_ignore_ascii_case(&sha256))
});
Some(ExeInfo { size: meta.len(), sha256, matches_release })
}
fn read_ports(root: &Path) -> Ports {
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct P {
mysql_port: Option<u16>,
auth_port: Option<u16>,
world_port: Option<u16>,
ra_port: Option<u16>,
}
let d = Ports::default();
match fsx::read_json::<P>(&root.join("Settings/repack.json")) {
Ok(p) => Ports {
mysql: p.mysql_port.unwrap_or(d.mysql),
auth: p.auth_port.unwrap_or(d.auth),
world: p.world_port.unwrap_or(d.world),
ra: p.ra_port.unwrap_or(d.ra),
},
Err(_) => d,
}
}
/// Find the last worldserver banner `AzerothCore rev. <hex>` in a (possibly huge) log: the tail window first,
/// then the head window, since a long-running server's banner can be far from the end.
pub fn banner_revision_in_log(log: &Path) -> Option<String> {
const WINDOW: u64 = 4 * 1024 * 1024;
let len = File::open(log).ok()?.metadata().ok()?.len();
let read = |start: u64| -> Option<String> {
let mut f = File::open(log).ok()?;
f.seek(SeekFrom::Start(start)).ok()?;
let mut buf = Vec::new();
f.take(WINDOW).read_to_end(&mut buf).ok()?;
last_banner(&String::from_utf8_lossy(&buf))
};
read(len.saturating_sub(WINDOW)).or_else(|| if len > WINDOW { read(0) } else { None })
}
fn last_banner(text: &str) -> Option<String> {
let mut found = None;
for line in text.lines() {
if let Some(pos) = line.find("AzerothCore rev. ") {
let rest = &line[pos + "AzerothCore rev. ".len()..];
let rev: String = rest.chars().take_while(|c| c.is_ascii_hexdigit()).collect();
if rev.len() >= 7 {
found = Some(rev);
}
}
}
found
}
pub fn detect_client(dir: &Path) -> Option<ClientInfo> {
if !dir.join("Data").is_dir() {
return None;
}
for exe in ["Ascension.exe", "Wow.exe", "WoW.exe"] {
if dir.join(exe).is_file() {
return Some(ClientInfo { path: dir.to_string_lossy().into_owned(), executable: exe.into() });
}
}
None
}
fn count_bot_keys(conf: &Path) -> usize {
fs::read_to_string(conf)
.map(|t| t.lines().filter(|l| l.trim_start().starts_with("CoaBots.")).count())
.unwrap_or(0)
}
/// Scan `root` without modifying it.
pub fn scan(root: &Path) -> Result<ScanReport> {
let root = fsx::canonicalize_lenient(root)?;
let mut notes = Vec::new();
let release: Option<ReleaseJson> = fsx::read_json(&root.join("RELEASE.json")).ok();
let world = hash_exe(&root.join("Core/worldserver.exe"), release.as_ref(), "worldserver.exe");
let auth = hash_exe(&root.join("Core/authserver.exe"), release.as_ref(), "authserver.exe");
let modules_dir = root.join("Core/configs/modules");
let mut module_configs: Vec<String> = fs::read_dir(&modules_dir)
.map(|rd| {
rd.filter_map(|e| e.ok())
.filter_map(|e| e.file_name().into_string().ok())
.filter(|n| n.ends_with(".conf"))
.collect()
})
.unwrap_or_default();
module_configs.sort();
let mysql_data = root.join("mysql/data");
let mut database_schemas: Vec<String> = ["acore_auth", "acore_characters", "acore_world"]
.iter()
.filter(|s| mysql_data.join(s).is_dir())
.map(|s| s.to_string())
.collect();
database_schemas.sort();
let bot_conf = modules_dir.join("mod_coa_playerbots.conf");
let bot_config_keys = count_bot_keys(&bot_conf);
let banner_revision = banner_revision_in_log(&root.join("Core/Logs/Server.log"));
let has_repack_scaffold =
exists(&root, "Scripts/manage.py") && exists(&root, "Settings/repack.json") && exists(&root, "Runtime/python/python.exe");
let has_bundled_db = exists(&root, "mysql/bin/mysqld.exe") && database_schemas.len() == 3;
let has_data = exists(&root, "Data/dbc") && exists(&root, "Data/maps");
let items = vec![
item("worldserver", "World server", world.is_some(), None),
item("authserver", "Auth server", auth.is_some(), None),
item("worldserver_conf", "World server configuration", exists(&root, "Core/configs/worldserver.conf"), None),
item("authserver_conf", "Auth server configuration", exists(&root, "Core/configs/authserver.conf"), None),
item("modules_conf", "Module configuration", modules_dir.is_dir(), Some(format!("{} files", module_configs.len()))),
item("game_data", "Game data (maps, DBC)", has_data, None),
item("database_runtime", "Database runtime", exists(&root, "mysql/bin/mysqld.exe"), None),
item(
"database_content",
"Auth / Characters / World databases",
has_bundled_db,
Some(database_schemas.join(", ")),
),
item("launcher", "Repack launcher scripts", has_repack_scaffold, None),
item("release_info", "RELEASE.json", release.is_some(), release.as_ref().and_then(|r| r.main_revision.clone())),
item(
"companions",
"CoA Companions (bots)",
bot_conf.is_file(),
(bot_config_keys > 0).then(|| format!("{bot_config_keys} settings")),
),
];
let client = ["Client", "client"].iter().find_map(|d| detect_client(&root.join(d)));
if let (Some(w), Some(r)) = (&world, &release) {
if w.matches_release == Some(false) {
notes.push(format!(
"worldserver.exe differs from the one listed in RELEASE.json (release {}); this looks like a customised build.",
r.release_date.clone().unwrap_or_default()
));
}
}
if module_configs.iter().any(|c| c.ends_with(".bak")) {
notes.push("Backup copies of module configs were found.".into());
}
let has_exes = world.is_some() && auth.is_some();
let looks_like_client = root.join("Wow.exe").is_file() || root.join("Ascension.exe").is_file();
let nested_repack = exists(&root, "Core/Core");
let classification = if looks_like_client || nested_repack {
Classification::Incompatible
} else if has_exes && !has_repack_scaffold {
Classification::UnknownCustom
} else if has_exes && has_repack_scaffold && has_bundled_db && has_data
&& exists(&root, "Core/configs/worldserver.conf")
&& exists(&root, "Core/configs/authserver.conf")
{
Classification::Healthy
} else if has_repack_scaffold || exists(&root, "Core") {
Classification::Partial
} else {
Classification::Incompatible
};
if looks_like_client {
notes.push("This looks like a game client folder, not a server folder.".into());
}
Ok(ScanReport {
path: root.to_string_lossy().into_owned(),
classification,
items,
worldserver: world,
authserver: auth,
release,
banner_revision,
module_configs,
bot_config_keys,
ports: read_ports(&root),
database_schemas,
client,
notes,
modifies_files: false,
})
}
/// Paths of the executables used by an installation, if present.
pub fn executables(root: &Path) -> (PathBuf, PathBuf, PathBuf) {
(root.join("Core/worldserver.exe"), root.join("Core/authserver.exe"), root.join("mysql/bin/mysqld.exe"))
}
#[cfg(any(test, feature = "testkit"))]
pub mod testkit {
use std::fs;
use std::path::Path;
fn put(root: &Path, rel: &str, bytes: &[u8]) {
let p = root.join(rel);
fs::create_dir_all(p.parent().unwrap()).unwrap();
fs::write(p, bytes).unwrap();
}
/// Build a minimal repack-shaped tree with fake binaries.
pub fn fake_repack(root: &Path) {
put(root, "Core/worldserver.exe", b"fake-world");
put(root, "Core/authserver.exe", b"fake-auth");
put(root, "Core/configs/worldserver.conf", b"[worldserver]\nRealmID = 1\n");
put(root, "Core/configs/authserver.conf", b"[authserver]\n");
put(root, "Core/configs/modules/mod_coa_playerbots.conf", b"CoaBots.AutoLoginOnStartup = 0\nCoaBots.X = 1\n");
put(root, "Core/Logs/Server.log", b"x\nINFO AzerothCore rev. 3567e2f8e9d5 2026-09-18 ready...\n");
put(root, "Data/dbc/Spell.dbc", b"d");
put(root, "Data/maps/000.map", b"m");
put(root, "mysql/bin/mysqld.exe", b"fake-mysqld");
for db in ["acore_auth", "acore_characters", "acore_world"] {
fs::create_dir_all(root.join("mysql/data").join(db)).unwrap();
}
put(root, "Scripts/manage.py", b"# stub");
put(root, "Runtime/python/python.exe", b"stub");
put(root, "Settings/repack.json", br#"{"mysqlPort":3307,"authPort":3724,"worldPort":8085,"raPort":3443}"#);
let world_hash = crate::fsx::sha256_bytes(b"fake-world");
let release = format!(
r#"{{"name":"CoA Repack","releaseDate":"2026-09-11","mainRevision":"c3beca68","binaries":[{{"Name":"worldserver.exe","SHA256":"{world_hash}","Bytes":10}}]}}"#
);
put(root, "RELEASE.json", release.as_bytes());
put(root, "user-notes.txt", b"my own file");
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn healthy_repack_is_classified_and_untouched() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path().join("repack");
testkit::fake_repack(&root);
let before = tree(&root);
let r = scan(&root).unwrap();
assert_eq!(r.classification, Classification::Healthy);
assert_eq!(r.banner_revision.as_deref(), Some("3567e2f8e9d5"));
assert_eq!(r.bot_config_keys, 2);
assert_eq!(r.worldserver.as_ref().unwrap().matches_release, Some(true));
assert_eq!(r.authserver.as_ref().unwrap().matches_release, None);
assert!(!r.modifies_files);
assert_eq!(before, tree(&root), "scan must not modify anything");
}
#[test]
fn customised_binary_is_flagged_but_still_healthy() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path().join("repack");
testkit::fake_repack(&root);
fs::write(root.join("Core/worldserver.exe"), b"my-custom-build").unwrap();
let r = scan(&root).unwrap();
assert_eq!(r.classification, Classification::Healthy);
assert_eq!(r.worldserver.unwrap().matches_release, Some(false));
assert!(r.notes.iter().any(|n| n.contains("customised")));
}
#[test]
fn partial_when_database_content_missing() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path().join("repack");
testkit::fake_repack(&root);
fs::remove_dir_all(root.join("mysql/data/acore_world")).unwrap();
assert_eq!(scan(&root).unwrap().classification, Classification::Partial);
}
#[test]
fn unknown_custom_when_exes_without_repack_scaffold() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path().join("custom");
fs::create_dir_all(root.join("Core")).unwrap();
fs::write(root.join("Core/worldserver.exe"), b"w").unwrap();
fs::write(root.join("Core/authserver.exe"), b"a").unwrap();
assert_eq!(scan(&root).unwrap().classification, Classification::UnknownCustom);
}
#[test]
fn client_and_random_folders_are_incompatible() {
let dir = tempfile::tempdir().unwrap();
let client = dir.path().join("wow");
fs::create_dir_all(client.join("Data")).unwrap();
fs::write(client.join("Wow.exe"), b"x").unwrap();
assert_eq!(scan(&client).unwrap().classification, Classification::Incompatible);
let docs = dir.path().join("Documents");
fs::create_dir_all(&docs).unwrap();
fs::write(docs.join("cv.docx"), b"x").unwrap();
assert_eq!(scan(&docs).unwrap().classification, Classification::Incompatible);
}
#[test]
fn banner_takes_the_last_occurrence() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("Server.log");
fs::write(&p, "AzerothCore rev. aaaaaaaaaaaa x\nnoise\nAzerothCore rev. bbbbbbbbbbbb y\n").unwrap();
assert_eq!(banner_revision_in_log(&p).as_deref(), Some("bbbbbbbbbbbb"));
assert_eq!(banner_revision_in_log(&dir.path().join("none")), None);
}
fn tree(root: &Path) -> Vec<(String, u64)> {
fn walk(dir: &Path, root: &Path, out: &mut Vec<(String, u64)>) {
for e in fs::read_dir(dir).unwrap() {
let e = e.unwrap();
let md = e.metadata().unwrap();
out.push((e.path().strip_prefix(root).unwrap().to_string_lossy().into_owned(), md.len()));
if md.is_dir() {
walk(&e.path(), root, out);
}
}
}
let mut out = Vec::new();
walk(root, root, &mut out);
out.sort();
out
}
}
+2
View File
@@ -1,6 +1,8 @@
pub mod error; pub mod error;
pub mod fsx; pub mod fsx;
pub mod layout;
pub mod logging; pub mod logging;
pub mod process;
pub mod manifest; pub mod manifest;
pub mod registry; pub mod registry;
+318
View File
@@ -0,0 +1,318 @@
//! Process identity and observation. A process belongs to an installation only if its executable path and
//! creation time say so; a bare PID or an image name is never trusted.
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::fsx;
use crate::layout::Ports;
/// Same shape as the repack's own `.state/<name>.json`, so records are interchangeable with its scripts.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProcessIdentity {
pub pid: u32,
pub exe: String,
/// Windows FILETIME of process creation (100 ns ticks since 1601).
pub created: u64,
}
impl ProcessIdentity {
pub fn same_as(&self, other: &ProcessIdentity) -> bool {
self.pid == other.pid && self.created == other.created && self.exe.eq_ignore_ascii_case(&other.exe)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ServiceState {
Stopped,
Starting,
Running,
Stopping,
Crashed,
Updating,
Unknown,
}
#[derive(Debug, Clone, Serialize)]
pub struct PortConflict {
pub port: u16,
pub pid: u32,
pub exe: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct ServiceStatus {
pub name: &'static str,
pub state: ServiceState,
pub pid: Option<u32>,
pub port: u16,
/// Our process is the one listening on the port.
pub port_ready: bool,
/// Something else is listening on our port.
pub conflict: Option<PortConflict>,
/// Seconds since the process started.
pub uptime_secs: Option<u64>,
}
#[derive(Debug, Clone, Serialize)]
pub struct Observed {
pub mysql: ServiceStatus,
pub auth: ServiceStatus,
pub world: ServiceStatus,
}
pub fn read_state_record(root: &Path, name: &str) -> Option<ProcessIdentity> {
fsx::read_json(&root.join(".state").join(format!("{name}.json"))).ok()
}
#[cfg(windows)]
mod sys {
use super::ProcessIdentity;
use windows_sys::Win32::Foundation::{CloseHandle, FILETIME, INVALID_HANDLE_VALUE};
use windows_sys::Win32::NetworkManagement::IpHelper::GetExtendedTcpTable;
use windows_sys::Win32::System::Diagnostics::ToolHelp::{
CreateToolhelp32Snapshot, Process32FirstW, Process32NextW, PROCESSENTRY32W, TH32CS_SNAPPROCESS,
};
use windows_sys::Win32::System::SystemInformation::GetSystemTimeAsFileTime;
use windows_sys::Win32::System::Threading::{
GetProcessTimes, OpenProcess, QueryFullProcessImageNameW, PROCESS_QUERY_LIMITED_INFORMATION,
};
fn ft(f: FILETIME) -> u64 {
((f.dwHighDateTime as u64) << 32) | f.dwLowDateTime as u64
}
pub fn identity(pid: u32) -> Option<ProcessIdentity> {
unsafe {
let h = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid);
if h.is_null() {
return None;
}
let mut buf = vec![0u16; 32768];
let mut size = buf.len() as u32;
let ok_name = QueryFullProcessImageNameW(h, 0, buf.as_mut_ptr(), &mut size);
let (mut c, mut e, mut k, mut u) = (std::mem::zeroed(), std::mem::zeroed(), std::mem::zeroed(), std::mem::zeroed());
let ok_time = GetProcessTimes(h, &mut c, &mut e, &mut k, &mut u);
CloseHandle(h);
if ok_name == 0 || ok_time == 0 || ft(e) != 0 {
return None;
}
Some(ProcessIdentity { pid, exe: String::from_utf16_lossy(&buf[..size as usize]), created: ft(c) })
}
}
pub fn all_pids() -> Vec<u32> {
let mut out = Vec::new();
unsafe {
let snap = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
if snap == INVALID_HANDLE_VALUE {
return out;
}
let mut entry: PROCESSENTRY32W = std::mem::zeroed();
entry.dwSize = std::mem::size_of::<PROCESSENTRY32W>() as u32;
let mut more = Process32FirstW(snap, &mut entry);
while more != 0 {
out.push(entry.th32ProcessID);
more = Process32NextW(snap, &mut entry);
}
CloseHandle(snap);
}
out
}
pub fn now_filetime() -> u64 {
unsafe {
let mut f: FILETIME = std::mem::zeroed();
GetSystemTimeAsFileTime(&mut f);
ft(f)
}
}
fn table(af: u32) -> Vec<u8> {
const TCP_TABLE_OWNER_PID_LISTENER: i32 = 3;
let mut size = 0u32;
let mut buf: Vec<u8> = Vec::new();
for _ in 0..4 {
let code = unsafe {
GetExtendedTcpTable(
if buf.is_empty() { std::ptr::null_mut() } else { buf.as_mut_ptr().cast() },
&mut size,
0,
af,
TCP_TABLE_OWNER_PID_LISTENER,
0,
)
};
match code {
0 => return buf,
122 => buf = vec![0u8; size as usize + 1024],
_ => return Vec::new(),
}
}
Vec::new()
}
/// (port, owning pid) for every TCP listener, IPv4 and IPv6.
pub fn listeners() -> Vec<(u16, u32)> {
let mut out = Vec::new();
let u32_at = |b: &[u8], o: usize| u32::from_le_bytes(b[o..o + 4].try_into().unwrap());
let v4 = table(2);
if v4.len() >= 4 {
let n = u32_at(&v4, 0) as usize;
for i in 0..n {
let o = 4 + 24 * i;
if o + 24 > v4.len() {
break;
}
out.push((u16::from_be((u32_at(&v4, o + 8) & 0xFFFF) as u16), u32_at(&v4, o + 20)));
}
}
let v6 = table(23);
if v6.len() >= 4 {
let n = u32_at(&v6, 0) as usize;
for i in 0..n {
let o = 4 + 56 * i;
if o + 56 > v6.len() {
break;
}
out.push((u16::from_be((u32_at(&v6, o + 20) & 0xFFFF) as u16), u32_at(&v6, o + 52)));
}
}
out
}
}
#[cfg(not(windows))]
mod sys {
use super::ProcessIdentity;
pub fn identity(_: u32) -> Option<ProcessIdentity> {
None
}
pub fn all_pids() -> Vec<u32> {
Vec::new()
}
pub fn now_filetime() -> u64 {
0
}
pub fn listeners() -> Vec<(u16, u32)> {
Vec::new()
}
}
pub use sys::{identity, listeners};
/// True if the recorded identity still describes a live process (same exe and creation time).
pub fn is_alive(record: &ProcessIdentity) -> bool {
identity(record.pid).map(|cur| cur.same_as(record)).unwrap_or(false)
}
/// Every running process whose image is exactly `exe`.
pub fn find_by_exe(exe: &Path) -> Vec<ProcessIdentity> {
let want = exe.to_string_lossy().to_lowercase().replace('/', "\\");
sys::all_pids()
.into_iter()
.filter_map(identity)
.filter(|p| p.exe.to_lowercase() == want)
.collect()
}
fn uptime_secs(p: &ProcessIdentity) -> Option<u64> {
let now = sys::now_filetime();
(now >= p.created).then(|| (now - p.created) / 10_000_000)
}
fn service(root: &Path, name: &'static str, exe: PathBuf, port: u16, listen: &[(u16, u32)]) -> ServiceStatus {
// Prefer the repack's own record when it still matches a live process; otherwise discover by exact exe path.
let proc = read_state_record(root, name)
.filter(is_alive)
.filter(|r| r.exe.eq_ignore_ascii_case(&exe.to_string_lossy().replace('/', "\\")))
.or_else(|| find_by_exe(&exe).into_iter().next());
let owner = listen.iter().find(|(p, _)| *p == port).map(|(_, pid)| *pid);
let (state, port_ready, conflict) = match (&proc, owner) {
(Some(p), Some(o)) if p.pid == o => (ServiceState::Running, true, None),
(Some(_), _) => (ServiceState::Starting, false, None),
(None, Some(o)) => {
(ServiceState::Stopped, false, Some(PortConflict { port, pid: o, exe: identity(o).map(|i| i.exe) }))
}
(None, None) => (ServiceState::Stopped, false, None),
};
ServiceStatus {
name,
state,
pid: proc.as_ref().map(|p| p.pid),
port,
port_ready,
conflict,
uptime_secs: proc.as_ref().and_then(uptime_secs),
}
}
/// Snapshot the three services of an installation without touching anything.
pub fn observe(root: &Path, ports: &Ports) -> Observed {
let listen = listeners();
let (world, auth, mysql) = crate::layout::executables(root);
Observed {
mysql: service(root, "mysql", mysql, ports.mysql, &listen),
auth: service(root, "auth", auth, ports.auth, &listen),
world: service(root, "world", world, ports.world, &listen),
}
}
#[cfg(all(test, windows))]
mod tests {
use super::*;
use std::net::TcpListener;
#[test]
fn identity_of_self_is_stable_and_alive() {
let me = identity(std::process::id()).unwrap();
assert!(me.exe.to_lowercase().ends_with(".exe"));
assert!(is_alive(&me));
let mut forged = me.clone();
forged.created += 1;
assert!(!is_alive(&forged), "different creation time = different process (PID reuse)");
let mut wrong_exe = me.clone();
wrong_exe.exe = "C:\\other.exe".into();
assert!(!is_alive(&wrong_exe));
assert!(identity(0xFFFF_FF00).is_none());
}
#[test]
fn finds_own_listener_and_exe() {
let l = TcpListener::bind("127.0.0.1:0").unwrap();
let port = l.local_addr().unwrap().port();
let me = std::process::id();
assert!(listeners().contains(&(port, me)));
let exe = identity(me).unwrap().exe;
assert!(find_by_exe(Path::new(&exe)).iter().any(|p| p.pid == me));
}
#[test]
fn observe_reports_foreign_listener_as_conflict_not_as_our_server() {
let dir = tempfile::tempdir().unwrap();
let l = TcpListener::bind("127.0.0.1:0").unwrap();
let port = l.local_addr().unwrap().port();
let ports = Ports { mysql: port, auth: 1, world: 2, ra: 3 };
let o = observe(dir.path(), &ports);
assert_eq!(o.mysql.state, ServiceState::Stopped);
assert!(!o.mysql.port_ready);
assert_eq!(o.mysql.conflict.as_ref().unwrap().pid, std::process::id());
}
#[test]
fn stale_state_record_is_not_trusted() {
let dir = tempfile::tempdir().unwrap();
let state = dir.path().join(".state");
std::fs::create_dir_all(&state).unwrap();
let me = identity(std::process::id()).unwrap();
// Right PID, wrong creation time => stale record after PID reuse.
let stale = ProcessIdentity { created: me.created + 12345, ..me };
std::fs::write(state.join("world.json"), serde_json::to_vec(&stale).unwrap()).unwrap();
let o = observe(dir.path(), &Ports::default());
assert_eq!(o.world.state, ServiceState::Stopped);
assert!(o.world.pid.is_none());
}
}