mirror of
https://github.com/beerpiss/saekawa.git
synced 2024-11-23 23:00:58 +01:00
saekawa 0.4.0: rewrite boogaloo
This commit is contained in:
parent
3a9fd14c20
commit
d352266759
@ -1,2 +0,0 @@
|
||||
[build]
|
||||
target = "i686-pc-windows-msvc"
|
2
.gitignore
vendored
2
.gitignore
vendored
@ -133,3 +133,5 @@ fabric.properties
|
||||
|
||||
# End of https://www.toptal.com/developers/gitignore/api/intellij
|
||||
|
||||
*.pfx
|
||||
*.json
|
||||
|
661
Cargo.lock
generated
661
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
50
Cargo.toml
50
Cargo.toml
@ -1,42 +1,38 @@
|
||||
[package]
|
||||
name = "saekawa"
|
||||
version = "0.3.4"
|
||||
authors = ["beerpsi <lacvtg.a1.2023@gmail.com>"]
|
||||
version = "0.4.0"
|
||||
edition = "2021"
|
||||
license = "0BSD"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[profile.release]
|
||||
strip = true # Automatically strip symbols from the binary.
|
||||
opt-level = "z" # Optimize for size.
|
||||
lto = true
|
||||
codegen-units = 1
|
||||
panic = "abort"
|
||||
|
||||
[dependencies]
|
||||
aes = "0.8.3"
|
||||
anyhow = "1.0.75"
|
||||
binary-reader = "0.4.5"
|
||||
aes = "0.8.4"
|
||||
cbc = "0.1.2"
|
||||
chrono = "0.4.31"
|
||||
chrono = "0.4.38"
|
||||
confy = "0.6.1"
|
||||
crc32fast = "1.3.2"
|
||||
crochet = "0.2.3"
|
||||
env_logger = "0.10.2"
|
||||
env_logger = "0.11.3"
|
||||
faster-hex = "0.9.0"
|
||||
flate2 = "1.0.28"
|
||||
hex-literal = "0.4.1"
|
||||
lazy_static = "1.4.0"
|
||||
log = "0.4.20"
|
||||
num_enum = "0.7.1"
|
||||
flate2 = "1.0.30"
|
||||
lightningscanner = "1.0.2"
|
||||
log = "0.4.21"
|
||||
num_enum = "0.7.2"
|
||||
pbkdf2 = "0.12.2"
|
||||
serde = { version = "1.0.193", features = ["derive"] }
|
||||
serde-aux = "4.3.1"
|
||||
serde_json = "1.0.108"
|
||||
rand = "0.8.5"
|
||||
rust-ini = "0.21.0"
|
||||
serde = { version = "1.0.203", features = ["derive"] }
|
||||
serde-aux = "4.5.0"
|
||||
serde_json = "1.0.117"
|
||||
sha1 = "0.10.6"
|
||||
ureq = { version = "2.9.1", features = ["json"] }
|
||||
url = "2.5.0"
|
||||
widestring = "1.0.2"
|
||||
winapi = { version = "0.3.9", features = ["winhttp", "minwindef", "debugapi", "synchapi", "libloaderapi", "processthreadsapi"] }
|
||||
sha2 = "0.10.8"
|
||||
snafu = "0.8.3"
|
||||
ureq = { version = "2.9.7", features = ["json"] }
|
||||
url = { version = "2.5.2", features = ["serde"] }
|
||||
widestring = "1.1.0"
|
||||
winapi = { version = "0.3.9", features = ["minwindef", "winnt", "psapi", "processthreadsapi", "libloaderapi", "errhandlingapi", "winhttp", "synchapi", "debugapi", "wincon", "heapapi", "winbase", "wincrypt", "softpub", "wintrust"] }
|
||||
|
||||
[build-dependencies]
|
||||
snafu = "0.8.3"
|
||||
vergen = { version = "8.3.1", features = ["build", "git", "gitcl"] }
|
||||
|
12
build.rs
Normal file
12
build.rs
Normal file
@ -0,0 +1,12 @@
|
||||
use snafu::{prelude::*, Whatever};
|
||||
use vergen::EmitBuilder;
|
||||
|
||||
pub fn main() -> Result<(), Whatever> {
|
||||
EmitBuilder::builder()
|
||||
.build_timestamp()
|
||||
.git_sha(false)
|
||||
.git_branch()
|
||||
.emit()
|
||||
.with_whatever_context(|_| "Could not emit version information")?;
|
||||
Ok(())
|
||||
}
|
16
release.ps1
Normal file
16
release.ps1
Normal file
@ -0,0 +1,16 @@
|
||||
cargo build --target i686-pc-windows-msvc --release
|
||||
|
||||
if (!(Test-Path ./saekawa.pfx)) {
|
||||
$cert = New-SelfSignedCertificate -Type Custom `
|
||||
-Subject "CN=saekawa self-signed certificate" `
|
||||
-CertStoreLocation cert:\CurrentUser\My `
|
||||
-KeyUsage DigitalSignature
|
||||
|
||||
Export-PfxCertificate -Cert $cert `
|
||||
-FilePath saekawa.pfx `
|
||||
-Password (ConvertTo-SecureString -String "saekawa" -Force -AsPlainText)
|
||||
}
|
||||
|
||||
signtool sign -f saekawa.pfx -p "saekawa" -fd SHA256 -t http://timestamp.comodoca.com/authenticode -v target/i686-pc-windows-msvc/release/saekawa.dll
|
||||
|
||||
Write-Output "Remember to make the .rtext section writable for auto-updates! I have fuck all idea how to do it in Rust itself, so it's manual from here. The DLL has already been signed."
|
23
src/config/defaults.rs
Normal file
23
src/config/defaults.rs
Normal file
@ -0,0 +1,23 @@
|
||||
use std::{path::PathBuf, str::FromStr};
|
||||
|
||||
use url::Url;
|
||||
|
||||
pub(super) fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
pub(super) fn default_false() -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
pub(super) fn default_timeout() -> u64 {
|
||||
5000
|
||||
}
|
||||
|
||||
pub(super) fn default_tachi_url() -> Url {
|
||||
Url::parse("https://kamai.tachi.ac").unwrap()
|
||||
}
|
||||
|
||||
pub(super) fn default_failed_import_dir() -> Option<PathBuf> {
|
||||
PathBuf::from_str("failed_saekawa_imports").ok()
|
||||
}
|
42
src/config/migrate.rs
Normal file
42
src/config/migrate.rs
Normal file
@ -0,0 +1,42 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::defaults::*;
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Default)]
|
||||
pub struct OldSaekawaConfig {
|
||||
pub general: OldGeneralConfig,
|
||||
pub cards: OldCardsConfig,
|
||||
pub tachi: OldTachiConfig,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Default)]
|
||||
pub struct OldGeneralConfig {
|
||||
#[serde(default = "default_true")]
|
||||
pub enable: bool,
|
||||
|
||||
#[serde(default = "default_true")]
|
||||
pub export_class: bool,
|
||||
|
||||
#[serde(default = "default_false")]
|
||||
pub export_pbs: bool,
|
||||
|
||||
#[serde(default = "default_false")]
|
||||
pub fail_over_lamp: bool,
|
||||
|
||||
#[serde(default = "default_timeout")]
|
||||
pub timeout: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct OldCardsConfig {
|
||||
#[serde(default)]
|
||||
pub whitelist: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct OldTachiConfig {
|
||||
pub base_url: String,
|
||||
pub status: String,
|
||||
pub import: String,
|
||||
pub api_key: String,
|
||||
}
|
129
src/config/mod.rs
Normal file
129
src/config/mod.rs
Normal file
@ -0,0 +1,129 @@
|
||||
mod defaults;
|
||||
mod migrate;
|
||||
|
||||
use std::{collections::HashMap, path::PathBuf, str::FromStr};
|
||||
|
||||
use log::{info, warn};
|
||||
use migrate::OldSaekawaConfig;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use snafu::{ResultExt, Snafu};
|
||||
use url::Url;
|
||||
|
||||
use self::defaults::*;
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Default)]
|
||||
pub struct SaekawaConfig {
|
||||
pub general: GeneralConfig,
|
||||
pub cards: HashMap<String, String>,
|
||||
pub tachi: TachiConfig,
|
||||
}
|
||||
|
||||
#[derive(Snafu, Debug)]
|
||||
pub enum ConfigLoadError {
|
||||
#[snafu(display(
|
||||
"Could not load or save configuration. Is the configuration format correct?"
|
||||
))]
|
||||
ConfyError { source: confy::ConfyError },
|
||||
|
||||
#[snafu(display("Could not migrate to new configuration format: {source:#?}"))]
|
||||
MigrationError { source: MigrationError },
|
||||
}
|
||||
|
||||
#[derive(Snafu, Debug)]
|
||||
pub enum MigrationError {
|
||||
#[snafu(display("Invalid Tachi base URL."))]
|
||||
InvalidTachiUrl { source: url::ParseError },
|
||||
}
|
||||
|
||||
impl SaekawaConfig {
|
||||
pub fn load() -> Result<SaekawaConfig, ConfigLoadError> {
|
||||
let result = confy::load_path::<SaekawaConfig>("saekawa.toml");
|
||||
|
||||
match result {
|
||||
Ok(_) => result.context(ConfySnafu),
|
||||
Err(_) => {
|
||||
warn!("Could not parse configuration, attempting to parse as old configuration...");
|
||||
let old_config =
|
||||
confy::load_path::<OldSaekawaConfig>("saekawa.toml").context(ConfySnafu)?;
|
||||
|
||||
info!("Successfully loaded as old configuration, migrating to new format...");
|
||||
let tachi_base_url = Url::parse(&old_config.tachi.base_url)
|
||||
.context(InvalidTachiUrlSnafu)
|
||||
.context(MigrationSnafu)?;
|
||||
let new_tachi_config = TachiConfig {
|
||||
base_url: tachi_base_url,
|
||||
};
|
||||
|
||||
let mut new_cards_config: HashMap<String, String> = HashMap::new();
|
||||
if old_config.cards.whitelist.is_empty() {
|
||||
new_cards_config.insert("default".to_string(), old_config.tachi.api_key);
|
||||
} else {
|
||||
for card in old_config.cards.whitelist {
|
||||
new_cards_config.insert(card, old_config.tachi.api_key.clone());
|
||||
}
|
||||
}
|
||||
|
||||
let new_general_config = GeneralConfig {
|
||||
export_class: old_config.general.export_class,
|
||||
fail_over_lamp: old_config.general.fail_over_lamp,
|
||||
timeout: old_config.general.timeout,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let new_config = SaekawaConfig {
|
||||
general: new_general_config,
|
||||
cards: new_cards_config,
|
||||
tachi: new_tachi_config,
|
||||
};
|
||||
|
||||
confy::store_path("saekawa.toml", new_config.clone()).context(ConfySnafu)?;
|
||||
|
||||
Ok(new_config)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct GeneralConfig {
|
||||
#[serde(default = "default_true")]
|
||||
pub export_class: bool,
|
||||
|
||||
#[serde(default = "default_false")]
|
||||
pub fail_over_lamp: bool,
|
||||
|
||||
#[serde(default = "default_timeout")]
|
||||
pub timeout: u64,
|
||||
|
||||
#[serde(default = "default_false")]
|
||||
pub auto_update: bool,
|
||||
|
||||
#[serde(default = "default_failed_import_dir")]
|
||||
pub failed_import_dir: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl Default for GeneralConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
export_class: true,
|
||||
fail_over_lamp: false,
|
||||
timeout: 5000,
|
||||
auto_update: true,
|
||||
failed_import_dir: PathBuf::from_str("failed_saekawa_imports").ok(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct TachiConfig {
|
||||
#[serde(default = "default_tachi_url")]
|
||||
pub base_url: Url,
|
||||
}
|
||||
|
||||
impl Default for TachiConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
base_url: Url::parse("https://kamai.tachi.ac").unwrap(),
|
||||
}
|
||||
}
|
||||
}
|
@ -1,98 +0,0 @@
|
||||
use anyhow::Result;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs::File;
|
||||
use std::io::Write;
|
||||
use std::path::Path;
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct Configuration {
|
||||
pub general: GeneralConfiguration,
|
||||
|
||||
pub cards: CardsConfiguration,
|
||||
|
||||
#[serde(default)]
|
||||
pub crypto: CryptoConfiguration,
|
||||
|
||||
pub tachi: TachiConfiguration,
|
||||
}
|
||||
|
||||
impl Configuration {
|
||||
pub fn load() -> Result<Self> {
|
||||
if !Path::new("saekawa.toml").exists() {
|
||||
File::create("saekawa.toml")
|
||||
.and_then(|mut file| file.write_all(include_bytes!("../res/saekawa.toml")))
|
||||
.map_err(|err| anyhow::anyhow!("Could not create default config file: {}", err))?;
|
||||
}
|
||||
|
||||
confy::load_path("saekawa.toml")
|
||||
.map_err(|err| anyhow::anyhow!("Could not load config: {}", err))
|
||||
}
|
||||
|
||||
pub fn update(cfg: Configuration) -> Result<()> {
|
||||
confy::store_path("saekawa.toml", cfg)
|
||||
.map_err(|err| anyhow::anyhow!("Could not update config: {}", err))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct GeneralConfiguration {
|
||||
#[serde(default = "default_true")]
|
||||
pub enable: bool,
|
||||
|
||||
#[serde(default = "default_true")]
|
||||
pub export_class: bool,
|
||||
|
||||
#[serde(default = "default_false")]
|
||||
pub export_pbs: bool,
|
||||
|
||||
#[serde(default = "default_false")]
|
||||
pub fail_over_lamp: bool,
|
||||
|
||||
#[serde(default = "default_timeout")]
|
||||
pub timeout: u64,
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn default_false() -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn default_timeout() -> u64 {
|
||||
3000
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct CardsConfiguration {
|
||||
#[serde(default)]
|
||||
pub whitelist: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct CryptoConfiguration {
|
||||
#[serde(with = "faster_hex::nopfx_lowercase")]
|
||||
pub key: Vec<u8>,
|
||||
|
||||
#[serde(with = "faster_hex::nopfx_lowercase")]
|
||||
pub iv: Vec<u8>,
|
||||
|
||||
#[serde(with = "faster_hex::nopfx_lowercase")]
|
||||
pub salt: Vec<u8>,
|
||||
|
||||
#[serde(default = "default_iterations")]
|
||||
pub iterations: u32,
|
||||
}
|
||||
|
||||
fn default_iterations() -> u32 {
|
||||
70
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct TachiConfiguration {
|
||||
pub base_url: String,
|
||||
pub status: String,
|
||||
pub import: String,
|
||||
pub api_key: String,
|
||||
}
|
@ -1,59 +0,0 @@
|
||||
use std::{fmt::Debug, sync::atomic::Ordering};
|
||||
|
||||
use log::{debug, error, info};
|
||||
use serde::de::DeserializeOwned;
|
||||
|
||||
use crate::{
|
||||
helpers::execute_tachi_import, saekawa::GAME_MAJOR_VERSION, types::tachi::ToTachiImport,
|
||||
CONFIGURATION,
|
||||
};
|
||||
|
||||
pub fn score_handler<T>(body: String, guard: impl Fn(&T) -> bool)
|
||||
where
|
||||
T: Debug + DeserializeOwned + ToTachiImport,
|
||||
{
|
||||
let data = match serde_json::from_str::<T>(body.as_ref()) {
|
||||
Ok(req) => req,
|
||||
Err(err) => {
|
||||
error!("Could not parse request body: {:#}", err);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
debug!("parsed request body: {:#?}", data);
|
||||
|
||||
if !guard(&data) {
|
||||
return;
|
||||
}
|
||||
|
||||
let import = data.to_tachi_import(
|
||||
GAME_MAJOR_VERSION.load(Ordering::SeqCst),
|
||||
CONFIGURATION.general.export_class,
|
||||
CONFIGURATION.general.fail_over_lamp,
|
||||
);
|
||||
|
||||
if import.scores.is_empty() {
|
||||
if import.classes.is_none() {
|
||||
return;
|
||||
}
|
||||
|
||||
if import
|
||||
.classes
|
||||
.clone()
|
||||
.is_some_and(|v| v.dan.is_none() && v.emblem.is_none())
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
info!(
|
||||
"Submitting {} scores from {} {}",
|
||||
import.scores.len(),
|
||||
data.displayed_id_type(),
|
||||
data.displayed_id(),
|
||||
);
|
||||
|
||||
if let Err(err) = execute_tachi_import(import) {
|
||||
error!("{:#}", err);
|
||||
}
|
||||
}
|
66
src/helpers/chuni_encoding.rs
Normal file
66
src/helpers/chuni_encoding.rs
Normal file
@ -0,0 +1,66 @@
|
||||
use std::io::{self, Read};
|
||||
|
||||
use aes::cipher::{block_padding::Pkcs7, BlockDecryptMut, KeyIvInit};
|
||||
use flate2::read::ZlibDecoder;
|
||||
use pbkdf2::pbkdf2_hmac_array;
|
||||
use sha1::Sha1;
|
||||
use snafu::prelude::Snafu;
|
||||
|
||||
type Aes256CbcDec = cbc::Decryptor<aes::Aes256>;
|
||||
|
||||
#[derive(Snafu, Debug)]
|
||||
pub enum DecryptError {
|
||||
InvalidLength,
|
||||
UnpadError,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct MaybeDecompressError {
|
||||
pub zlib_error: io::Error,
|
||||
pub raw_error: io::Error,
|
||||
}
|
||||
|
||||
pub fn hash_endpoint(endpoint: impl AsRef<str>, salt: impl AsRef<[u8]>, rounds: u32) -> String {
|
||||
let key_bytes =
|
||||
pbkdf2_hmac_array::<Sha1, 16>(endpoint.as_ref().as_bytes(), salt.as_ref(), rounds);
|
||||
|
||||
faster_hex::hex_string(&key_bytes)
|
||||
}
|
||||
|
||||
pub fn decrypt_aes256_cbc(
|
||||
body: &mut [u8],
|
||||
key: impl AsRef<[u8]>,
|
||||
iv: impl AsRef<[u8]>,
|
||||
) -> Result<Vec<u8>, DecryptError> {
|
||||
let cipher = Aes256CbcDec::new_from_slices(key.as_ref(), iv.as_ref())
|
||||
.map_err(|_| DecryptError::InvalidLength)?;
|
||||
|
||||
Ok(cipher
|
||||
.decrypt_padded_mut::<Pkcs7>(body)
|
||||
.map_err(|_| DecryptError::UnpadError)?
|
||||
.to_owned())
|
||||
}
|
||||
|
||||
pub fn maybe_decompress_buffer(buf: impl AsRef<[u8]>) -> Result<String, MaybeDecompressError> {
|
||||
let mut ret = String::with_capacity(buf.as_ref().len() * 2);
|
||||
|
||||
let mut decoder = ZlibDecoder::new(buf.as_ref());
|
||||
let zlib_result = decoder.read_to_string(&mut ret);
|
||||
|
||||
if zlib_result.is_ok() {
|
||||
return Ok(ret);
|
||||
}
|
||||
|
||||
ret.clear();
|
||||
|
||||
let result = buf.as_ref().read_to_string(&mut ret);
|
||||
|
||||
if result.is_ok() {
|
||||
return Ok(ret);
|
||||
}
|
||||
|
||||
Err(MaybeDecompressError {
|
||||
zlib_error: zlib_result.expect_err("must be Err if reached here"),
|
||||
raw_error: result.expect_err("must be Err if reached here"),
|
||||
})
|
||||
}
|
@ -1,16 +0,0 @@
|
||||
use aes::cipher::{block_padding::Pkcs7, BlockDecryptMut, KeyIvInit};
|
||||
use anyhow::{anyhow, Result};
|
||||
|
||||
type Aes256CbcDec = cbc::Decryptor<aes::Aes256>;
|
||||
|
||||
pub fn decrypt_aes256_cbc(
|
||||
body: &mut [u8],
|
||||
key: impl AsRef<[u8]>,
|
||||
iv: impl AsRef<[u8]>,
|
||||
) -> Result<Vec<u8>> {
|
||||
let cipher = Aes256CbcDec::new_from_slices(key.as_ref(), iv.as_ref())?;
|
||||
Ok(cipher
|
||||
.decrypt_padded_mut::<Pkcs7>(body)
|
||||
.map_err(|err| anyhow!(err))?
|
||||
.to_owned())
|
||||
}
|
@ -1,58 +0,0 @@
|
||||
use log::debug;
|
||||
use pbkdf2::pbkdf2_hmac_array;
|
||||
use sha1::Sha1;
|
||||
|
||||
use crate::CONFIGURATION;
|
||||
|
||||
pub fn is_endpoint(
|
||||
endpoint: &str,
|
||||
unencrypted_variant: &str,
|
||||
encrypted_variant: &Option<String>,
|
||||
) -> bool {
|
||||
if endpoint == unencrypted_variant {
|
||||
return true;
|
||||
}
|
||||
|
||||
if encrypted_variant.as_ref().is_some_and(|v| v == endpoint) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Determine if it is an encrypted endpoint by checking if the endpoint
|
||||
/// is exactly 32 characters long, and consists of all hex characters.
|
||||
///
|
||||
/// While this may trigger false positives, this should not happen as long
|
||||
/// as CHUNITHM title APIs keep their `{method}{object}Api` endpoint
|
||||
/// convention.
|
||||
pub fn is_encrypted_endpoint(endpoint: &str) -> bool {
|
||||
if endpoint.len() != 32 {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Lazy way to check if all digits are hexadecimal
|
||||
if u128::from_str_radix(endpoint, 16).is_err() {
|
||||
return false;
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
pub fn hash_endpoint(endpoint: &str) -> Option<String> {
|
||||
if CONFIGURATION.crypto.salt.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let key_bytes = pbkdf2_hmac_array::<Sha1, 16>(
|
||||
endpoint.as_bytes(),
|
||||
&CONFIGURATION.crypto.salt,
|
||||
CONFIGURATION.crypto.iterations,
|
||||
);
|
||||
|
||||
let key = faster_hex::hex_string(&key_bytes);
|
||||
|
||||
debug!("Running with encryption support: {endpoint} maps to {key}");
|
||||
|
||||
Some(key)
|
||||
}
|
@ -1,114 +0,0 @@
|
||||
use std::ptr;
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use widestring::U16CString;
|
||||
use winapi::{
|
||||
ctypes::c_void,
|
||||
shared::{minwindef::TRUE, winerror::ERROR_INSUFFICIENT_BUFFER},
|
||||
um::{
|
||||
errhandlingapi::GetLastError,
|
||||
winhttp::{
|
||||
WinHttpQueryHeaders, WinHttpQueryOption, HINTERNET, WINHTTP_OPTION_URL,
|
||||
WINHTTP_QUERY_FLAG_REQUEST_HEADERS, WINHTTP_QUERY_USER_AGENT,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/// Queries a HINTERNET handle for its URL, then return the result.
|
||||
pub fn read_hinternet_url(handle: HINTERNET) -> Result<String> {
|
||||
let mut buf_length = 255;
|
||||
let mut buffer = [0u16; 255];
|
||||
let result = unsafe {
|
||||
WinHttpQueryOption(
|
||||
handle,
|
||||
WINHTTP_OPTION_URL,
|
||||
buffer.as_mut_ptr() as *mut c_void,
|
||||
&mut buf_length,
|
||||
)
|
||||
};
|
||||
|
||||
if result == TRUE {
|
||||
let url_str = U16CString::from_vec_truncate(&buffer[..buf_length as usize]);
|
||||
return url_str
|
||||
.to_string()
|
||||
.map_err(|err| anyhow!("Could not decode wide string: {:#}", err));
|
||||
}
|
||||
|
||||
let ec = unsafe { GetLastError() };
|
||||
if ec == ERROR_INSUFFICIENT_BUFFER {
|
||||
let mut buffer = vec![0u16; buf_length as usize];
|
||||
let result = unsafe {
|
||||
WinHttpQueryOption(
|
||||
handle,
|
||||
WINHTTP_OPTION_URL,
|
||||
buffer.as_mut_ptr() as *mut c_void,
|
||||
&mut buf_length,
|
||||
)
|
||||
};
|
||||
|
||||
if result != TRUE {
|
||||
let ec = unsafe { GetLastError() };
|
||||
return Err(anyhow!("Could not get URL from HINTERNET handle: {ec}"));
|
||||
}
|
||||
|
||||
let url_str = U16CString::from_vec_truncate(&buffer[..buf_length as usize]);
|
||||
return url_str
|
||||
.to_string()
|
||||
.map_err(|err| anyhow!("Could not decode wide string: {:#}", err));
|
||||
}
|
||||
|
||||
let ec = unsafe { GetLastError() };
|
||||
Err(anyhow!("Could not get URL from HINTERNET handle: {ec}"))
|
||||
}
|
||||
|
||||
pub fn read_hinternet_user_agent(handle: HINTERNET) -> Result<String> {
|
||||
let mut buf_length = 255;
|
||||
let mut buffer = [0u16; 255];
|
||||
let result = unsafe {
|
||||
WinHttpQueryHeaders(
|
||||
handle,
|
||||
WINHTTP_QUERY_USER_AGENT | WINHTTP_QUERY_FLAG_REQUEST_HEADERS,
|
||||
ptr::null(),
|
||||
buffer.as_mut_ptr() as *mut c_void,
|
||||
&mut buf_length,
|
||||
ptr::null_mut(),
|
||||
)
|
||||
};
|
||||
|
||||
if result == TRUE {
|
||||
let user_agent_str = U16CString::from_vec_truncate(&buffer[..buf_length as usize]);
|
||||
return user_agent_str
|
||||
.to_string()
|
||||
.map_err(|err| anyhow!("Could not decode wide string: {:#}", err));
|
||||
}
|
||||
|
||||
let ec = unsafe { GetLastError() };
|
||||
if ec == ERROR_INSUFFICIENT_BUFFER {
|
||||
let mut buffer = vec![0u16; buf_length as usize];
|
||||
let result = unsafe {
|
||||
WinHttpQueryHeaders(
|
||||
handle,
|
||||
WINHTTP_QUERY_USER_AGENT | WINHTTP_QUERY_FLAG_REQUEST_HEADERS,
|
||||
ptr::null(),
|
||||
buffer.as_mut_ptr() as *mut c_void,
|
||||
&mut buf_length,
|
||||
ptr::null_mut(),
|
||||
)
|
||||
};
|
||||
|
||||
if result != TRUE {
|
||||
let ec = unsafe { GetLastError() };
|
||||
return Err(anyhow!("Could not get URL from HINTERNET handle: {ec}"));
|
||||
}
|
||||
|
||||
let user_agent_str = U16CString::from_vec_truncate(&buffer[..buf_length as usize]);
|
||||
return user_agent_str
|
||||
.to_string()
|
||||
.map_err(|err| anyhow!("Could not decode wide string: {:#}", err));
|
||||
}
|
||||
|
||||
let ec = unsafe { GetLastError() };
|
||||
Err(anyhow!(
|
||||
"Could not get User-Agent from HINTERNET handle: {ec}"
|
||||
))
|
||||
}
|
@ -1,35 +0,0 @@
|
||||
use std::io::Read;
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use flate2::read::ZlibDecoder;
|
||||
|
||||
pub fn read_slice(buf: *const u8, len: usize) -> Result<Vec<u8>> {
|
||||
let mut slice = unsafe { std::slice::from_raw_parts(buf, len) };
|
||||
let mut ret = Vec::with_capacity(len);
|
||||
|
||||
slice.read_to_end(&mut ret)?;
|
||||
|
||||
Ok(ret)
|
||||
}
|
||||
|
||||
pub fn read_maybe_compressed_buffer(buf: impl AsRef<[u8]>) -> Result<String> {
|
||||
let mut ret = String::new();
|
||||
|
||||
let mut decoder = ZlibDecoder::new(buf.as_ref());
|
||||
let zlib_result = decoder.read_to_string(&mut ret);
|
||||
if zlib_result.is_ok() {
|
||||
return Ok(ret);
|
||||
}
|
||||
|
||||
ret.clear();
|
||||
let result = buf.as_ref().read_to_string(&mut ret);
|
||||
if result.is_ok() {
|
||||
return Ok(ret);
|
||||
}
|
||||
|
||||
Err(anyhow!(
|
||||
"Could not decode contents of buffer as both DEFLATE-compressed ({:#}) and plaintext ({:#}) UTF-8 string.",
|
||||
zlib_result.expect_err("This shouldn't happen, if Result was Ok the string should have been returned earlier."),
|
||||
result.expect_err("This shouldn't happen, if Result was Ok the string should have been returned earlier."),
|
||||
))
|
||||
}
|
@ -1,11 +1,2 @@
|
||||
mod crypto;
|
||||
mod endpoint;
|
||||
mod hinternet;
|
||||
mod io;
|
||||
mod net;
|
||||
|
||||
pub use crypto::*;
|
||||
pub use endpoint::*;
|
||||
pub use hinternet::*;
|
||||
pub use io::*;
|
||||
pub use net::*;
|
||||
pub mod chuni_encoding;
|
||||
pub mod winapi_ext;
|
||||
|
@ -1,140 +0,0 @@
|
||||
use std::fmt::Debug;
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use log::{debug, error, info};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{
|
||||
types::tachi::{Import, ImportDocument, ImportPollStatus, ImportResponse, TachiResponse},
|
||||
CONFIGURATION, TACHI_IMPORT_URL,
|
||||
};
|
||||
|
||||
pub fn request_agent() -> ureq::Agent {
|
||||
let timeout = CONFIGURATION.general.timeout;
|
||||
let timeout = if timeout > 10000 { 10000 } else { timeout };
|
||||
|
||||
ureq::builder()
|
||||
.timeout(std::time::Duration::from_millis(timeout))
|
||||
.build()
|
||||
}
|
||||
|
||||
fn request<T>(
|
||||
method: impl AsRef<str>,
|
||||
url: impl AsRef<str>,
|
||||
body: Option<T>,
|
||||
) -> Result<ureq::Response>
|
||||
where
|
||||
T: Serialize + Debug,
|
||||
{
|
||||
let agent = request_agent();
|
||||
|
||||
let method = method.as_ref();
|
||||
let url = url.as_ref();
|
||||
debug!("{} request to {} with body: {:#?}", method, url, body);
|
||||
|
||||
let authorization = format!("Bearer {}", CONFIGURATION.tachi.api_key);
|
||||
let request = agent
|
||||
.request(method, url)
|
||||
.set("Authorization", authorization.as_str());
|
||||
let response = match body {
|
||||
Some(body) => request.send_json(body),
|
||||
None => request.call(),
|
||||
}
|
||||
.map_err(|err| anyhow::anyhow!("Could not reach Tachi API: {:#}", err))?;
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
pub fn request_tachi<T, R>(
|
||||
method: impl AsRef<str>,
|
||||
url: impl AsRef<str>,
|
||||
body: Option<T>,
|
||||
) -> Result<R>
|
||||
where
|
||||
T: Serialize + Debug,
|
||||
R: for<'de> Deserialize<'de> + Debug,
|
||||
{
|
||||
let response = request(method, url, body)?;
|
||||
let response = response.into_json()?;
|
||||
debug!("Tachi API response: {:#?}", response);
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
fn log_import(description: &str, import: ImportDocument) {
|
||||
info!(
|
||||
"{description} {} scores, {} sessions, {} errors",
|
||||
import.score_ids.len(),
|
||||
import.created_sessions.len(),
|
||||
import.errors.len()
|
||||
);
|
||||
|
||||
for err in import.errors {
|
||||
error!("{}: {}", err.error_type, err.message);
|
||||
}
|
||||
}
|
||||
|
||||
/// Executes a DIRECT-MANUAL import and logs some information on success.
|
||||
///
|
||||
/// ## Important
|
||||
/// This function blocks until import has fully finished! It is best to call this in a separate thread.
|
||||
pub fn execute_tachi_import(import: Import) -> Result<()> {
|
||||
let resp: TachiResponse<ImportResponse> =
|
||||
match request_tachi("POST", TACHI_IMPORT_URL.as_str(), Some(import)) {
|
||||
Err(err) => {
|
||||
return Err(anyhow!("Could not send scores to Tachi: {:#}", err));
|
||||
}
|
||||
Ok(resp) => resp,
|
||||
};
|
||||
|
||||
let (body, description) = match resp {
|
||||
TachiResponse::Err(err) => {
|
||||
return Err(anyhow!(
|
||||
"Tachi API returned an error: {:#}",
|
||||
err.description
|
||||
));
|
||||
}
|
||||
TachiResponse::Ok(resp) => (resp.body, resp.description),
|
||||
};
|
||||
|
||||
let poll_url = match body {
|
||||
ImportResponse::Queued { url, import_id: _ } => {
|
||||
info!("Queued import for processing. Status URL: {}", url);
|
||||
url
|
||||
}
|
||||
ImportResponse::Finished(import) => {
|
||||
log_import(&description, import);
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
|
||||
loop {
|
||||
let resp: TachiResponse<ImportPollStatus> =
|
||||
match request_tachi("GET", &poll_url, None::<()>) {
|
||||
Ok(resp) => resp,
|
||||
Err(err) => {
|
||||
error!("Could not poll import status: {:#}", err);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
let (body, description) = match resp {
|
||||
TachiResponse::Ok(resp) => (resp.body, resp.description),
|
||||
TachiResponse::Err(err) => {
|
||||
return Err(anyhow!("Tachi API returned an error: {}", err.description));
|
||||
}
|
||||
};
|
||||
|
||||
match body {
|
||||
ImportPollStatus::Completed { import } => {
|
||||
log_import(&description, import);
|
||||
return Ok(());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
std::thread::sleep(std::time::Duration::from_secs(1));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
99
src/helpers/winapi_ext.rs
Normal file
99
src/helpers/winapi_ext.rs
Normal file
@ -0,0 +1,99 @@
|
||||
use snafu::prelude::Snafu;
|
||||
use widestring::U16CString;
|
||||
use winapi::{
|
||||
ctypes::c_void,
|
||||
shared::{
|
||||
minwindef::{HINSTANCE, HMODULE, TRUE},
|
||||
winerror::ERROR_INSUFFICIENT_BUFFER,
|
||||
},
|
||||
um::{
|
||||
errhandlingapi::GetLastError,
|
||||
libloaderapi::{FreeLibraryAndExitThread, GetModuleFileNameW},
|
||||
winhttp::{WinHttpQueryOption, HINTERNET},
|
||||
},
|
||||
};
|
||||
|
||||
pub struct LibraryHandle(HINSTANCE);
|
||||
|
||||
unsafe impl Send for LibraryHandle {}
|
||||
unsafe impl Sync for LibraryHandle {}
|
||||
impl LibraryHandle {
|
||||
pub unsafe fn new(handle: HINSTANCE) -> Self {
|
||||
Self(handle)
|
||||
}
|
||||
|
||||
pub fn handle(&self) -> HINSTANCE {
|
||||
self.0
|
||||
}
|
||||
|
||||
pub fn free_and_exit_thread(self, code: u32) -> ! {
|
||||
unsafe {
|
||||
FreeLibraryAndExitThread(self.0, code);
|
||||
}
|
||||
unreachable!()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Snafu)]
|
||||
pub enum ReadStringFnError {
|
||||
InvalidData,
|
||||
Other { errno: u32 },
|
||||
}
|
||||
|
||||
pub fn read_string_from_function_call(
|
||||
reader: impl Fn(&mut [u16], &mut u32) -> i32,
|
||||
is_success: impl Fn(i32) -> bool,
|
||||
) -> Result<String, ReadStringFnError> {
|
||||
let mut buffer = vec![0u16; 255];
|
||||
let mut buffer_length = 255;
|
||||
let result = reader(&mut buffer, &mut buffer_length);
|
||||
|
||||
if is_success(result) {
|
||||
let out = U16CString::from_vec_truncate(&buffer[..buffer_length as usize]);
|
||||
|
||||
return out.to_string().map_err(|_| ReadStringFnError::InvalidData);
|
||||
}
|
||||
|
||||
let errno = unsafe { GetLastError() };
|
||||
|
||||
if errno == ERROR_INSUFFICIENT_BUFFER {
|
||||
buffer.resize(buffer_length as usize, 0);
|
||||
let result = reader(&mut buffer, &mut buffer_length);
|
||||
|
||||
if result != TRUE {
|
||||
let errno = unsafe { GetLastError() };
|
||||
|
||||
return Err(ReadStringFnError::Other { errno });
|
||||
}
|
||||
|
||||
let out = U16CString::from_vec_truncate(&buffer[..buffer_length as usize]);
|
||||
|
||||
return out.to_string().map_err(|_| ReadStringFnError::InvalidData);
|
||||
}
|
||||
|
||||
Err(ReadStringFnError::Other { errno })
|
||||
}
|
||||
|
||||
pub fn winhttp_query_option(handle: HINTERNET, option: u32) -> Result<String, ReadStringFnError> {
|
||||
read_string_from_function_call(
|
||||
|buf, buflen| unsafe {
|
||||
WinHttpQueryOption(handle, option, buf.as_mut_ptr() as *mut c_void, buflen)
|
||||
},
|
||||
|ret| ret == TRUE,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn get_module_file_name(handle: HMODULE) -> Result<String, ReadStringFnError> {
|
||||
read_string_from_function_call(
|
||||
|buf, buflen| unsafe {
|
||||
let ret = GetModuleFileNameW(handle, buf.as_mut_ptr(), *buflen) as i32;
|
||||
|
||||
if GetLastError() == ERROR_INSUFFICIENT_BUFFER {
|
||||
*buflen = 32767;
|
||||
}
|
||||
|
||||
ret
|
||||
},
|
||||
|_| unsafe { GetLastError() != ERROR_INSUFFICIENT_BUFFER },
|
||||
)
|
||||
}
|
245
src/icf.rs
245
src/icf.rs
@ -1,245 +0,0 @@
|
||||
use std::fmt::Display;
|
||||
|
||||
use aes::cipher::{block_padding::NoPadding, BlockDecryptMut, KeyIvInit};
|
||||
use anyhow::{anyhow, Result};
|
||||
use binary_reader::{BinaryReader, Endian};
|
||||
use chrono::{NaiveDate, NaiveDateTime};
|
||||
|
||||
type Aes128CbcDec = cbc::Decryptor<aes::Aes128>;
|
||||
|
||||
const ICF_KEY: [u8; 16] = hex_literal::decode(&[env!("SAEKAWA_ICF_KEY").as_bytes()]);
|
||||
const ICF_IV: [u8; 16] = hex_literal::decode(&[env!("SAEKAWA_ICF_IV").as_bytes()]);
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub struct Version {
|
||||
pub major: u16,
|
||||
pub minor: u8,
|
||||
pub build: u8,
|
||||
}
|
||||
|
||||
impl Display for Version {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}.{:0>2}.{:0>2}", self.major, self.minor, self.build)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct IcfInnerData {
|
||||
pub id: String,
|
||||
pub version: Version,
|
||||
pub required_system_version: Version,
|
||||
pub datetime: NaiveDateTime,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct IcfPatchData {
|
||||
pub id: String,
|
||||
pub source_version: Version,
|
||||
pub target_version: Version,
|
||||
pub required_system_version: Version,
|
||||
pub datetime: NaiveDateTime,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum IcfData {
|
||||
System(IcfInnerData),
|
||||
App(IcfInnerData),
|
||||
Patch(IcfPatchData),
|
||||
Option(IcfInnerData),
|
||||
}
|
||||
|
||||
fn decrypt_icf(data: &mut [u8], key: impl AsRef<[u8]>, iv: impl AsRef<[u8]>) -> Result<Vec<u8>> {
|
||||
let size = data.len();
|
||||
|
||||
let mut decrypted = Vec::with_capacity(size);
|
||||
|
||||
for i in (0..size).step_by(4096) {
|
||||
let from_start = i;
|
||||
|
||||
let bufsz = std::cmp::min(4096, size - from_start);
|
||||
let buf = &data[i..i + bufsz];
|
||||
let mut decbuf = vec![0; bufsz];
|
||||
|
||||
let cipher = Aes128CbcDec::new_from_slices(key.as_ref(), iv.as_ref())?;
|
||||
cipher
|
||||
.decrypt_padded_b2b_mut::<NoPadding>(buf, &mut decbuf)
|
||||
.map_err(|err| anyhow!(err))?;
|
||||
|
||||
let xor1 = u64::from_le_bytes(decbuf[0..8].try_into()?) ^ (from_start as u64);
|
||||
let xor2 = u64::from_le_bytes(decbuf[8..16].try_into()?) ^ (from_start as u64);
|
||||
|
||||
decrypted.extend(xor1.to_le_bytes());
|
||||
decrypted.extend(xor2.to_le_bytes());
|
||||
decrypted.extend(&decbuf[16..]);
|
||||
}
|
||||
|
||||
Ok(decrypted)
|
||||
}
|
||||
|
||||
pub fn decode_icf_container_data(
|
||||
rd: &mut BinaryReader,
|
||||
) -> Result<(Version, NaiveDateTime, Version)> {
|
||||
let version = Version {
|
||||
build: rd.read_u8()?,
|
||||
minor: rd.read_u8()?,
|
||||
major: rd.read_u16()?,
|
||||
};
|
||||
|
||||
let datetime = NaiveDate::from_ymd_opt(
|
||||
rd.read_i16()? as i32,
|
||||
rd.read_u8()? as u32,
|
||||
rd.read_u8()? as u32,
|
||||
)
|
||||
.ok_or(anyhow!("Invalid date"))?
|
||||
.and_hms_milli_opt(
|
||||
rd.read_u8()? as u32,
|
||||
rd.read_u8()? as u32,
|
||||
rd.read_u8()? as u32,
|
||||
rd.read_u8()? as u32,
|
||||
)
|
||||
.ok_or(anyhow!("Invalid time"))?;
|
||||
|
||||
let required_system_version = Version {
|
||||
build: rd.read_u8()?,
|
||||
minor: rd.read_u8()?,
|
||||
major: rd.read_u16()?,
|
||||
};
|
||||
|
||||
Ok((version, datetime, required_system_version))
|
||||
}
|
||||
|
||||
pub fn decode_icf(data: &mut [u8]) -> Result<Vec<IcfData>> {
|
||||
let decrypted = decrypt_icf(data, ICF_KEY, ICF_IV)?;
|
||||
|
||||
let mut rd = BinaryReader::from_vec(&decrypted);
|
||||
rd.endian = Endian::Little;
|
||||
|
||||
let checksum = crc32fast::hash(&decrypted[4..]);
|
||||
let reported_crc = rd.read_u32()?;
|
||||
if reported_crc != checksum {
|
||||
return Err(anyhow!(
|
||||
"Reported CRC32 ({reported_crc:02X}) does not match actual checksum ({checksum:02X})"
|
||||
));
|
||||
}
|
||||
|
||||
let reported_size = rd.read_u32()? as usize;
|
||||
let actual_size = decrypted.len();
|
||||
if actual_size != reported_size {
|
||||
return Err(anyhow!(
|
||||
"Reported size {reported_size} does not match actual size {actual_size}"
|
||||
));
|
||||
}
|
||||
|
||||
let padding = rd.read_u64()?;
|
||||
if padding != 0 {
|
||||
return Err(anyhow!("Padding error. Expected 8 NULL bytes."));
|
||||
}
|
||||
|
||||
let entry_count: usize = rd.read_u64()?.try_into()?;
|
||||
let expected_size = 0x40 * (entry_count + 1);
|
||||
if actual_size != expected_size {
|
||||
return Err(anyhow!("Expected size {expected_size} ({entry_count} entries) does not match actual size {actual_size}"));
|
||||
}
|
||||
|
||||
let app_id = String::from_utf8(rd.read_bytes(4)?.to_vec())?;
|
||||
let platform_id = String::from_utf8(rd.read_bytes(3)?.to_vec())?;
|
||||
let _platform_generation = rd.read_u8()?;
|
||||
|
||||
let reported_crc = rd.read_u32()?;
|
||||
let mut checksum = 0;
|
||||
for i in 1..=entry_count {
|
||||
let container = &decrypted[0x40 * i..0x40 * (i + 1)];
|
||||
if container[0] == 2 && container[1] == 1 {
|
||||
checksum ^= crc32fast::hash(container);
|
||||
}
|
||||
}
|
||||
|
||||
if reported_crc != checksum {
|
||||
return Err(anyhow!("Reported container CRC32 ({reported_crc:02X}) does not match actual checksum ({checksum:02X})"));
|
||||
}
|
||||
|
||||
for _ in 0..7 {
|
||||
if rd.read_u32()? != 0 {
|
||||
return Err(anyhow!("Padding error. Expected 28 NULL bytes."));
|
||||
}
|
||||
}
|
||||
|
||||
let mut entries: Vec<IcfData> = Vec::with_capacity(entry_count);
|
||||
for _ in 0..entry_count {
|
||||
let sig = rd.read_u32()?;
|
||||
|
||||
if sig != 0x0102 && sig != 0x0201 {
|
||||
return Err(anyhow!("Container does not start with signature (0x0102)"));
|
||||
}
|
||||
|
||||
let container_type = rd.read_u32()?;
|
||||
for _ in 0..3 {
|
||||
if rd.read_u64()? != 0 {
|
||||
return Err(anyhow!("Padding error. Expected 24 NULL bytes."));
|
||||
}
|
||||
}
|
||||
|
||||
let data: IcfData = match container_type {
|
||||
0x0000 | 0x0001 | 0x0002 => {
|
||||
let (version, datetime, required_system_version) =
|
||||
decode_icf_container_data(&mut rd)?;
|
||||
|
||||
for _ in 0..2 {
|
||||
if rd.read_u64()? != 0 {
|
||||
return Err(anyhow!("Padding error. Expected 16 NULL bytes."));
|
||||
}
|
||||
}
|
||||
|
||||
match container_type {
|
||||
0x0000 => IcfData::System(IcfInnerData {
|
||||
id: platform_id.clone(),
|
||||
version,
|
||||
datetime,
|
||||
required_system_version,
|
||||
}),
|
||||
0x0001 => IcfData::App(IcfInnerData {
|
||||
id: app_id.clone(),
|
||||
version,
|
||||
datetime,
|
||||
required_system_version,
|
||||
}),
|
||||
0x0002 => IcfData::Option(IcfInnerData {
|
||||
id: app_id.clone(),
|
||||
version,
|
||||
datetime,
|
||||
required_system_version,
|
||||
}),
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
// PATCH container type also encode the patch's sequence number
|
||||
// in the higher 16 bits.
|
||||
// The lower 16 bits will always be 1.
|
||||
let sequence_number = (container_type >> 8) as u8;
|
||||
|
||||
if (container_type & 1) == 0 || sequence_number == 0 {
|
||||
println!("Unknown ICF container type {container_type:#06x} at byte {:#06x}, skipping", rd.pos);
|
||||
rd.read_bytes(32)?;
|
||||
continue;
|
||||
}
|
||||
|
||||
let (target_version, target_datetime, _) = decode_icf_container_data(&mut rd)?;
|
||||
let (source_version, _, source_required_system_version) =
|
||||
decode_icf_container_data(&mut rd)?;
|
||||
|
||||
IcfData::Patch(IcfPatchData {
|
||||
id: app_id.clone(),
|
||||
source_version,
|
||||
target_version,
|
||||
required_system_version: source_required_system_version,
|
||||
datetime: target_datetime,
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
entries.push(data);
|
||||
}
|
||||
|
||||
Ok(entries)
|
||||
}
|
165
src/lib.rs
165
src/lib.rs
@ -1,111 +1,26 @@
|
||||
mod configuration;
|
||||
mod handlers;
|
||||
mod config;
|
||||
mod helpers;
|
||||
mod icf;
|
||||
mod log;
|
||||
mod logging;
|
||||
mod saekawa;
|
||||
mod score_import;
|
||||
mod sigscan;
|
||||
mod types;
|
||||
mod updater;
|
||||
|
||||
use std::ffi::c_void;
|
||||
use std::{ptr, thread};
|
||||
use std::thread;
|
||||
|
||||
use ::log::{error, warn};
|
||||
use lazy_static::lazy_static;
|
||||
use url::Url;
|
||||
use winapi::shared::minwindef::{BOOL, DWORD, FALSE, HINSTANCE, LPVOID, TRUE};
|
||||
use winapi::um::errhandlingapi::GetLastError;
|
||||
use winapi::um::handleapi::{CloseHandle, DuplicateHandle};
|
||||
use winapi::um::processthreadsapi::{GetCurrentProcess, GetCurrentThread};
|
||||
use winapi::um::synchapi::WaitForSingleObject;
|
||||
use winapi::um::winnt::{DLL_PROCESS_ATTACH, DLL_PROCESS_DETACH, SYNCHRONIZE};
|
||||
|
||||
use crate::configuration::Configuration;
|
||||
use crate::helpers::hash_endpoint;
|
||||
use crate::log::Logger;
|
||||
use crate::saekawa::{hook_init, hook_release};
|
||||
|
||||
lazy_static! {
|
||||
pub static ref CONFIGURATION: Configuration = {
|
||||
let result = Configuration::load();
|
||||
if let Err(err) = result {
|
||||
error!("{:#}", err);
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
result.unwrap()
|
||||
use log::{error, info, warn};
|
||||
use winapi::{
|
||||
shared::minwindef::{BOOL, DWORD, HINSTANCE, LPVOID, TRUE},
|
||||
um::winnt::{DLL_PROCESS_ATTACH, DLL_PROCESS_DETACH},
|
||||
};
|
||||
pub static ref TACHI_STATUS_URL: String = {
|
||||
let result = Url::parse(&CONFIGURATION.tachi.base_url)
|
||||
.and_then(|url| url.join(&CONFIGURATION.tachi.status));
|
||||
if let Err(err) = result {
|
||||
error!("Could not parse Tachi status URL: {:#}", err);
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
result.unwrap().to_string()
|
||||
use crate::{
|
||||
helpers::winapi_ext::LibraryHandle,
|
||||
logging::init_logger,
|
||||
saekawa::{hook_init, hook_release},
|
||||
updater::self_update,
|
||||
};
|
||||
pub static ref TACHI_IMPORT_URL: String = {
|
||||
let result = Url::parse(&CONFIGURATION.tachi.base_url)
|
||||
.and_then(|url| url.join(&CONFIGURATION.tachi.import));
|
||||
if let Err(err) = result {
|
||||
error!("Could not parse Tachi import URL: {:#}", err);
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
result.unwrap().to_string()
|
||||
};
|
||||
pub static ref UPSERT_USER_ALL_API_ENCRYPTED: Option<String> =
|
||||
hash_endpoint("UpsertUserAllApi");
|
||||
pub static ref GET_USER_MUSIC_API_ENCRYPTED: Option<String> = hash_endpoint("GetUserMusicApi");
|
||||
}
|
||||
|
||||
fn init_logger() {
|
||||
env_logger::builder()
|
||||
.filter_level(::log::LevelFilter::Error)
|
||||
.filter_module(
|
||||
"saekawa",
|
||||
if cfg!(debug_assertions) {
|
||||
::log::LevelFilter::Debug
|
||||
} else {
|
||||
::log::LevelFilter::Info
|
||||
},
|
||||
)
|
||||
.parse_default_env()
|
||||
.target(env_logger::Target::Pipe(Box::new(Logger::new())))
|
||||
.format(|f, record| {
|
||||
use crate::log::{colored_level, max_target_width, Padded};
|
||||
use std::io::Write;
|
||||
|
||||
let target = record.target();
|
||||
let max_width = max_target_width(target);
|
||||
|
||||
let mut style = f.style();
|
||||
let level = colored_level(&mut style, record.level());
|
||||
|
||||
let mut style = f.style();
|
||||
let target = style.set_bold(true).value(Padded {
|
||||
value: target,
|
||||
width: max_width,
|
||||
});
|
||||
|
||||
let time = chrono::Local::now().format("%d/%m/%Y %H:%M:%S");
|
||||
|
||||
writeln!(f, "[{}] {} {} -> {}", time, level, target, record.args())
|
||||
})
|
||||
.init();
|
||||
}
|
||||
|
||||
struct ThreadHandle(*mut c_void);
|
||||
|
||||
impl ThreadHandle {
|
||||
pub unsafe fn wait_and_close(self, ms: u32) {
|
||||
WaitForSingleObject(self.0, ms);
|
||||
CloseHandle(self.0);
|
||||
}
|
||||
}
|
||||
|
||||
unsafe impl Send for ThreadHandle {}
|
||||
unsafe impl Sync for ThreadHandle {}
|
||||
|
||||
#[no_mangle]
|
||||
#[allow(non_snake_case, unused_variables)]
|
||||
@ -114,42 +29,36 @@ extern "system" fn DllMain(dll_module: HINSTANCE, call_reason: DWORD, reserved:
|
||||
DLL_PROCESS_ATTACH => {
|
||||
init_logger();
|
||||
|
||||
let (cur_thread, result) = unsafe {
|
||||
let mut cur_thread = ptr::null_mut();
|
||||
let result = DuplicateHandle(
|
||||
GetCurrentProcess(),
|
||||
GetCurrentThread(),
|
||||
GetCurrentProcess(),
|
||||
&mut cur_thread,
|
||||
SYNCHRONIZE,
|
||||
FALSE,
|
||||
0,
|
||||
);
|
||||
|
||||
if result == 0 {
|
||||
warn!(
|
||||
"Failed to get current thread handle, error code: {}",
|
||||
GetLastError()
|
||||
);
|
||||
}
|
||||
|
||||
(ThreadHandle(cur_thread), result)
|
||||
};
|
||||
let library_handle = unsafe { LibraryHandle::new(dll_module) };
|
||||
|
||||
thread::spawn(move || {
|
||||
if result != 0 {
|
||||
unsafe { cur_thread.wait_and_close(100) };
|
||||
info!(
|
||||
"saekawa {} ({}@{}) starting up...",
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
&env!("VERGEN_GIT_SHA")[0..7],
|
||||
env!("VERGEN_GIT_BRANCH"),
|
||||
);
|
||||
|
||||
match self_update(&library_handle) {
|
||||
Ok(should_reboot) => {
|
||||
if should_reboot {
|
||||
info!("Self-update successful. Reloading into new hook...");
|
||||
library_handle.free_and_exit_thread(1);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Self-update failed: {e:#}");
|
||||
}
|
||||
}
|
||||
|
||||
if let Err(err) = hook_init() {
|
||||
error!("Failed to initialize hook: {:#}", err);
|
||||
if let Err(e) = hook_init() {
|
||||
error!("Failed to initialize hook: {e:#}");
|
||||
}
|
||||
});
|
||||
}
|
||||
DLL_PROCESS_DETACH => {
|
||||
if let Err(err) = hook_release() {
|
||||
error!("{:#}", err);
|
||||
return FALSE;
|
||||
if let Err(e) = hook_release() {
|
||||
warn!("Failed to release hook: {e:#}")
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
|
81
src/log.rs
81
src/log.rs
@ -1,81 +0,0 @@
|
||||
use std::ffi::CString;
|
||||
use std::fmt;
|
||||
use std::fs::File;
|
||||
use std::io::Write;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
use winapi::um::debugapi::OutputDebugStringA;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Logger {
|
||||
file: File,
|
||||
}
|
||||
|
||||
impl Logger {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
file: File::create("saekawa.log").unwrap(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Write for Logger {
|
||||
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
|
||||
if let Ok(c_str) = CString::new(buf) {
|
||||
unsafe {
|
||||
OutputDebugStringA(c_str.as_ptr());
|
||||
}
|
||||
}
|
||||
|
||||
let _ = std::io::stdout().write(buf);
|
||||
self.file.write(buf)
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> std::io::Result<()> {
|
||||
// Ignore the result of the write to stdout, since it's not really important
|
||||
let _ = std::io::stdout().flush();
|
||||
self.file.flush()
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct Padded<T> {
|
||||
pub(crate) value: T,
|
||||
pub(crate) width: usize,
|
||||
}
|
||||
|
||||
impl<T: fmt::Display> fmt::Display for Padded<T> {
|
||||
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||
write!(f, "{: <width$}", self.value, width = self.width)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) static MAX_MODULE_WIDTH: AtomicUsize = AtomicUsize::new(0);
|
||||
|
||||
pub(crate) fn max_target_width(target: &str) -> usize {
|
||||
let max_width = MAX_MODULE_WIDTH.load(Ordering::Relaxed);
|
||||
if max_width < target.len() {
|
||||
MAX_MODULE_WIDTH.store(target.len(), Ordering::Relaxed);
|
||||
target.len()
|
||||
} else {
|
||||
max_width
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn colored_level(
|
||||
style: &mut env_logger::fmt::Style,
|
||||
level: log::Level,
|
||||
) -> env_logger::fmt::StyledValue<&'static str> {
|
||||
match level {
|
||||
log::Level::Trace => style
|
||||
.set_color(env_logger::fmt::Color::Magenta)
|
||||
.value("TRACE"),
|
||||
log::Level::Debug => style.set_color(env_logger::fmt::Color::Blue).value("DEBUG"),
|
||||
log::Level::Info => style
|
||||
.set_color(env_logger::fmt::Color::Green)
|
||||
.value(" INFO"),
|
||||
log::Level::Warn => style
|
||||
.set_color(env_logger::fmt::Color::Yellow)
|
||||
.value(" WARN"),
|
||||
log::Level::Error => style.set_color(env_logger::fmt::Color::Red).value("ERROR"),
|
||||
}
|
||||
}
|
61
src/logging.rs
Normal file
61
src/logging.rs
Normal file
@ -0,0 +1,61 @@
|
||||
use std::{ffi::CString, fs::File, io::Write};
|
||||
|
||||
use winapi::um::{debugapi::OutputDebugStringA, wincon::GetConsoleWindow};
|
||||
|
||||
#[derive(Debug)]
|
||||
struct Logger {
|
||||
file: File,
|
||||
has_console_output: bool,
|
||||
}
|
||||
|
||||
impl Logger {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
file: File::create("saekawa.log").unwrap(),
|
||||
has_console_output: unsafe { !GetConsoleWindow().is_null() },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Write for Logger {
|
||||
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
|
||||
if self.has_console_output {
|
||||
let _ = std::io::stdout().write(buf);
|
||||
} else if let Ok(c_str) = CString::new(buf) {
|
||||
unsafe {
|
||||
OutputDebugStringA(c_str.as_ptr());
|
||||
}
|
||||
}
|
||||
|
||||
self.file.write(buf)
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> std::io::Result<()> {
|
||||
// Ignore the result of the write to stdout, since it's not really important
|
||||
let _ = std::io::stdout().flush();
|
||||
self.file.flush()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn init_logger() {
|
||||
env_logger::builder()
|
||||
.filter_module(
|
||||
"saekawa",
|
||||
if cfg!(debug_assertions) {
|
||||
log::LevelFilter::Debug
|
||||
} else {
|
||||
log::LevelFilter::Info
|
||||
},
|
||||
)
|
||||
.parse_default_env()
|
||||
.target(env_logger::Target::Pipe(Box::new(Logger::new())))
|
||||
.format(|f, record| {
|
||||
let target = record.target();
|
||||
let level = record.level();
|
||||
let args = record.args();
|
||||
let time = chrono::Local::now().format("%Y-%m-%d %H:%M:%S");
|
||||
|
||||
writeln!(f, "{time} {level:<5} [{target}] {args}")
|
||||
})
|
||||
.init();
|
||||
}
|
611
src/saekawa.rs
611
src/saekawa.rs
@ -1,331 +1,228 @@
|
||||
use std::{
|
||||
fmt::Debug,
|
||||
fs::File,
|
||||
io::Read,
|
||||
path::Path,
|
||||
sync::atomic::{AtomicBool, AtomicU16, Ordering},
|
||||
io::{self, Read},
|
||||
mem::{self, MaybeUninit},
|
||||
num::ParseIntError,
|
||||
ptr,
|
||||
sync::OnceLock,
|
||||
thread,
|
||||
};
|
||||
|
||||
use ::log::{debug, error, info};
|
||||
use anyhow::{anyhow, Result};
|
||||
use log::warn;
|
||||
use serde::de::DeserializeOwned;
|
||||
use widestring::U16CString;
|
||||
use ini::Ini;
|
||||
use log::{debug, error, info};
|
||||
use snafu::{prelude::Snafu, ResultExt};
|
||||
use winapi::{
|
||||
ctypes::c_void,
|
||||
shared::minwindef::{BOOL, DWORD, FALSE, LPCVOID, LPDWORD, LPVOID, MAX_PATH},
|
||||
um::{errhandlingapi::GetLastError, winbase::GetPrivateProfileStringW, winhttp::HINTERNET},
|
||||
shared::minwindef::{BOOL, DWORD, LPCVOID, LPDWORD},
|
||||
um::{
|
||||
errhandlingapi::GetLastError,
|
||||
libloaderapi::GetModuleHandleW,
|
||||
processthreadsapi::GetCurrentProcess,
|
||||
psapi::{GetModuleInformation, MODULEINFO},
|
||||
winhttp::{HINTERNET, WINHTTP_OPTION_URL},
|
||||
},
|
||||
};
|
||||
|
||||
use crate::{
|
||||
configuration::{Configuration, GeneralConfiguration},
|
||||
handlers::score_handler,
|
||||
config::{ConfigLoadError, SaekawaConfig},
|
||||
helpers::{
|
||||
decrypt_aes256_cbc, is_encrypted_endpoint, is_endpoint, read_hinternet_url,
|
||||
read_hinternet_user_agent, read_maybe_compressed_buffer, read_slice, request_tachi,
|
||||
chuni_encoding::{decrypt_aes256_cbc, hash_endpoint, maybe_decompress_buffer},
|
||||
winapi_ext::{winhttp_query_option, ReadStringFnError},
|
||||
},
|
||||
icf::{decode_icf, IcfData},
|
||||
types::{
|
||||
game::{UpsertUserAllRequest, UserMusicResponse},
|
||||
tachi::{StatusCheck, TachiResponse, ToTachiImport},
|
||||
},
|
||||
CONFIGURATION, GET_USER_MUSIC_API_ENCRYPTED, TACHI_STATUS_URL, UPSERT_USER_ALL_API_ENCRYPTED,
|
||||
score_import::execute_score_import,
|
||||
sigscan::{self, CryptoKeys},
|
||||
types::{chuni::UpsertUserAllRequest, ToBatchManual},
|
||||
};
|
||||
|
||||
pub static GAME_MAJOR_VERSION: AtomicU16 = AtomicU16::new(0);
|
||||
pub static PB_IMPORTED: AtomicBool = AtomicBool::new(true);
|
||||
#[derive(Debug, Snafu)]
|
||||
pub enum HookError {
|
||||
#[snafu(display("Could not load configuration"))]
|
||||
ConfigError { source: ConfigLoadError },
|
||||
|
||||
pub fn hook_init() -> Result<()> {
|
||||
if !CONFIGURATION.general.enable {
|
||||
return Ok(());
|
||||
#[snafu(display("No cards were configured in the [cards] section. There is nothing to export to. Add tokens under the cards section with the format `\"access_code\" = \"tachi_api_key\"`. If you wish to export scores from all cards, use `default` in place of an access code."))]
|
||||
NoCardsError,
|
||||
|
||||
#[snafu(display("An error occured hooking the underlying functions"))]
|
||||
CrochetError { source: crochet::detour::Error },
|
||||
|
||||
#[snafu(display("The game version specified in project.conf is not a number."))]
|
||||
InvalidVersion { source: ParseIntError },
|
||||
|
||||
#[snafu(display("An error occured parsing project.conf"))]
|
||||
IniError { source: ini::Error },
|
||||
|
||||
#[snafu(display("An error occured calling a Win32 function: {errno}"))]
|
||||
Win32Error { errno: u32 },
|
||||
|
||||
#[snafu(display("Could not find a pattern in the game executable"))]
|
||||
CryptoScanError { source: sigscan::CryptoScanError },
|
||||
|
||||
#[snafu(display("The configured path for failed import exists and is not a directory."))]
|
||||
FailedImportNotDir,
|
||||
|
||||
#[snafu(display("Could not create the configured directory for failed imports."))]
|
||||
FailedCreatingFailedImportDir { source: io::Error },
|
||||
}
|
||||
|
||||
if CONFIGURATION.general.export_pbs {
|
||||
warn!("===============================================================================");
|
||||
warn!("Exporting PBs is enabled. This should only be used once to sync up your scores!");
|
||||
warn!("Leaving it on can make your profile messy! This will be automatically be turned off after exporting is finished.");
|
||||
warn!("You can check when it's done by searching for the message 'Submitting x scores from user ID xxxxx'.");
|
||||
warn!("===============================================================================");
|
||||
#[derive(Debug, Snafu)]
|
||||
pub enum ProcessRequestError {
|
||||
#[snafu(display("Could not read URL from HINTERNET handle"))]
|
||||
UrlReadError { source: ReadStringFnError },
|
||||
|
||||
PB_IMPORTED.store(false, Ordering::SeqCst);
|
||||
#[snafu(display("The URL does not have an endpoint"))]
|
||||
UrlMissingEndpointError,
|
||||
|
||||
#[snafu(display(
|
||||
"Hooked function was called before all necessary state has been initialized"
|
||||
))]
|
||||
UninitializedError,
|
||||
|
||||
#[snafu(display("Could not read request body"))]
|
||||
ReadBodyError { source: io::Error },
|
||||
}
|
||||
|
||||
debug!("Retrieving AMFS path from segatools.ini");
|
||||
#[derive(Debug, Clone)]
|
||||
struct GameInformation {
|
||||
pub game_id: String,
|
||||
pub major: u16,
|
||||
pub minor: u8,
|
||||
pub build: u8,
|
||||
}
|
||||
|
||||
let mut buf = [0u16; MAX_PATH];
|
||||
let amfs_cfg = unsafe {
|
||||
let sz = GetPrivateProfileStringW(
|
||||
U16CString::from_str_unchecked("vfs").as_ptr(),
|
||||
U16CString::from_str_unchecked("amfs").as_ptr(),
|
||||
U16CString::new().as_ptr(),
|
||||
buf.as_mut_ptr(),
|
||||
MAX_PATH as u32,
|
||||
U16CString::from_str(".\\segatools.ini").unwrap().as_ptr(),
|
||||
/// This is used by the Tachi <-> CHUNITHM conversion functions,
|
||||
/// because some enum indexes changed between CHUNITHM and CHUNITHM NEW,
|
||||
/// namely difficulty, and later on, clear lamps.
|
||||
static GAME_MAJOR_VERSION: OnceLock<u16> = OnceLock::new();
|
||||
static CRYPTO_KEYS: OnceLock<CryptoKeys> = OnceLock::new();
|
||||
static UPSERT_USER_ALL_API: OnceLock<String> = OnceLock::new();
|
||||
|
||||
static CONFIG: OnceLock<SaekawaConfig> = OnceLock::new();
|
||||
|
||||
pub fn hook_init() -> Result<(), HookError> {
|
||||
info!("Reading hook configuration");
|
||||
let config = SaekawaConfig::load().context(ConfigSnafu)?;
|
||||
|
||||
if config.cards.is_empty() {
|
||||
return Err(HookError::NoCardsError);
|
||||
}
|
||||
|
||||
info!("Loaded tokens for {} access codes", config.cards.len());
|
||||
|
||||
if let Some(d) = &config.general.failed_import_dir {
|
||||
if d.exists() && !d.is_dir() {
|
||||
return Err(HookError::FailedImportNotDir);
|
||||
}
|
||||
|
||||
if !d.exists() {
|
||||
std::fs::create_dir_all(d).context(FailedCreatingFailedImportDirSnafu)?;
|
||||
}
|
||||
}
|
||||
|
||||
CONFIG
|
||||
.set(config)
|
||||
.expect("OnceLock shouldn't be initialized.");
|
||||
|
||||
debug!("Reading version information from project.conf");
|
||||
let info = get_project_conf()?;
|
||||
|
||||
info!(
|
||||
"Running on {} {}.{}.{}",
|
||||
info.game_id, info.major, info.minor, info.build
|
||||
);
|
||||
|
||||
if sz == 0 {
|
||||
let ec = GetLastError();
|
||||
return Err(anyhow!(
|
||||
"AMFS path not specified in segatools.ini, error code {ec}"
|
||||
));
|
||||
}
|
||||
let ver = determine_major_version(&info);
|
||||
|
||||
match U16CString::from_ptr(buf.as_ptr(), sz as usize) {
|
||||
Ok(data) => data.to_string_lossy(),
|
||||
Err(err) => {
|
||||
return Err(anyhow!(
|
||||
"could not read AMFS path from segatools.ini: {:#}",
|
||||
err
|
||||
));
|
||||
}
|
||||
}
|
||||
};
|
||||
let amfs_path = Path::new(&amfs_cfg);
|
||||
let icf1_path = amfs_path.join("ICF1");
|
||||
debug!("Game's major version is {ver}");
|
||||
|
||||
if !icf1_path.exists() {
|
||||
return Err(anyhow!("Could not find ICF1 inside AMFS path. You will probably not be able to network without this file, so this hook will also be disabled."));
|
||||
}
|
||||
GAME_MAJOR_VERSION
|
||||
.set(ver)
|
||||
.expect("OnceLock shouldn't be initialized.");
|
||||
|
||||
debug!("Reading ICF1 located at {:?}", icf1_path);
|
||||
debug!("Checking if network requests are encrypted");
|
||||
setup_network_encryption(&info)?;
|
||||
|
||||
let mut icf1_buf = {
|
||||
let mut icf1_file = File::open(icf1_path)?;
|
||||
let mut icf1_buf = Vec::new();
|
||||
icf1_file.read_to_end(&mut icf1_buf)?;
|
||||
icf1_buf
|
||||
};
|
||||
let icf = decode_icf(&mut icf1_buf).map_err(|err| anyhow!("Reading ICF failed: {:#}", err))?;
|
||||
|
||||
for entry in icf {
|
||||
if let IcfData::App(app) = entry {
|
||||
info!("Running on {} {}", app.id, app.version);
|
||||
GAME_MAJOR_VERSION.store(app.version.major, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
debug!("Pinging Tachi API for status check and token verification");
|
||||
|
||||
let resp: TachiResponse<StatusCheck> =
|
||||
request_tachi("GET", TACHI_STATUS_URL.as_str(), None::<()>)?;
|
||||
let user_id = match resp {
|
||||
TachiResponse::Err(err) => {
|
||||
return Err(anyhow!("Tachi API returned an error: {}", err.description));
|
||||
}
|
||||
TachiResponse::Ok(resp) => {
|
||||
if !resp.body.permissions.iter().any(|v| v == "submit_score") {
|
||||
return Err(anyhow!(
|
||||
"API key has insufficient permissions. The permission submit_score must be set."
|
||||
));
|
||||
}
|
||||
|
||||
let Some(user_id) = resp.body.whoami else {
|
||||
return Err(anyhow!(
|
||||
"Status check was successful, yet API returned userID null?"
|
||||
));
|
||||
};
|
||||
|
||||
user_id
|
||||
}
|
||||
};
|
||||
|
||||
info!("Logged in to Tachi with userID {user_id}");
|
||||
|
||||
debug!("Initializing detours");
|
||||
|
||||
crochet::enable!(winhttpwritedata_hook_wrapper)?;
|
||||
|
||||
if CONFIGURATION.general.export_pbs || cfg!(debug_assertions) {
|
||||
crochet::enable!(winhttpreaddata_hook_wrapper)?;
|
||||
}
|
||||
|
||||
info!("Hook successfully initialized");
|
||||
info!("Enabling hooks");
|
||||
crochet::enable!(winhttpwritedata_hook).context(CrochetSnafu)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn hook_release() -> Result<()> {
|
||||
if !CONFIGURATION.general.enable {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if crochet::is_enabled!(winhttpreaddata_hook_wrapper) {
|
||||
crochet::disable!(winhttpreaddata_hook_wrapper)?;
|
||||
}
|
||||
|
||||
if crochet::is_enabled!(winhttpwritedata_hook_wrapper) {
|
||||
crochet::disable!(winhttpwritedata_hook_wrapper)?;
|
||||
pub fn hook_release() -> Result<(), HookError> {
|
||||
if crochet::is_enabled!(winhttpwritedata_hook) {
|
||||
info!("Disabling hooks");
|
||||
crochet::disable!(winhttpwritedata_hook).context(CrochetSnafu)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[crochet::hook(compile_check, "winhttp.dll", "WinHttpReadData")]
|
||||
fn winhttpreaddata_hook_wrapper(
|
||||
h_request: HINTERNET,
|
||||
lp_buffer: LPVOID,
|
||||
dw_number_of_bytes_to_read: DWORD,
|
||||
lpdw_number_of_bytes_read: LPDWORD,
|
||||
) -> BOOL {
|
||||
debug!("hit winhttpreaddata");
|
||||
|
||||
let result = call_original!(
|
||||
h_request,
|
||||
lp_buffer,
|
||||
dw_number_of_bytes_to_read,
|
||||
lpdw_number_of_bytes_read
|
||||
);
|
||||
|
||||
if result == FALSE {
|
||||
let ec = unsafe { GetLastError() };
|
||||
error!("Calling original WinHttpReadData function failed: {ec}");
|
||||
return result;
|
||||
}
|
||||
|
||||
let pb_imported = PB_IMPORTED.load(Ordering::SeqCst);
|
||||
if cfg!(not(debug_assertions)) && pb_imported {
|
||||
return result;
|
||||
}
|
||||
|
||||
if let Err(err) = winhttprwdata_hook::<UserMusicResponse>(
|
||||
h_request,
|
||||
lp_buffer,
|
||||
dw_number_of_bytes_to_read,
|
||||
"GetUserMusicApi",
|
||||
&GET_USER_MUSIC_API_ENCRYPTED,
|
||||
move |_| {
|
||||
if pb_imported {
|
||||
return false;
|
||||
}
|
||||
|
||||
PB_IMPORTED.store(true, Ordering::Relaxed);
|
||||
if let Err(err) = Configuration::update(Configuration {
|
||||
general: GeneralConfiguration {
|
||||
export_pbs: false,
|
||||
..CONFIGURATION.general
|
||||
},
|
||||
cards: CONFIGURATION.cards.clone(),
|
||||
crypto: CONFIGURATION.crypto.clone(),
|
||||
tachi: CONFIGURATION.tachi.clone(),
|
||||
}) {
|
||||
error!("Could not update configuration to disable exporting PBs: {err:?}");
|
||||
}
|
||||
|
||||
true
|
||||
},
|
||||
) {
|
||||
error!("{err:?}");
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
#[crochet::hook(compile_check, "winhttp.dll", "WinHttpWriteData")]
|
||||
fn winhttpwritedata_hook_wrapper(
|
||||
h_request: HINTERNET,
|
||||
fn winhttpwritedata_hook(
|
||||
hrequest: HINTERNET,
|
||||
lp_buffer: LPCVOID,
|
||||
dw_number_of_bytes_to_write: DWORD,
|
||||
lpdw_number_of_bytes_written: LPDWORD,
|
||||
dw_n_bytes_to_write: DWORD,
|
||||
lpdw_n_bytes_written: LPDWORD,
|
||||
) -> BOOL {
|
||||
debug!("hit winhttpwritedata");
|
||||
|
||||
if let Err(err) = winhttprwdata_hook::<UpsertUserAllRequest>(
|
||||
h_request,
|
||||
lp_buffer,
|
||||
dw_number_of_bytes_to_write,
|
||||
"UpsertUserAllApi",
|
||||
&UPSERT_USER_ALL_API_ENCRYPTED,
|
||||
|upsert_req| {
|
||||
let user_data = &upsert_req.upsert_user_all.user_data[0];
|
||||
let access_code = &user_data.access_code;
|
||||
if !CONFIGURATION.cards.whitelist.is_empty()
|
||||
&& !CONFIGURATION.cards.whitelist.contains(access_code)
|
||||
{
|
||||
info!("Card {access_code} is not whitelisted, skipping score submission");
|
||||
return false;
|
||||
}
|
||||
|
||||
true
|
||||
},
|
||||
) {
|
||||
error!("{err:?}");
|
||||
if let Err(e) = process_request(hrequest, lp_buffer, dw_n_bytes_to_write) {
|
||||
error!("{e:#?}");
|
||||
}
|
||||
|
||||
call_original!(
|
||||
h_request,
|
||||
hrequest,
|
||||
lp_buffer,
|
||||
dw_number_of_bytes_to_write,
|
||||
lpdw_number_of_bytes_written
|
||||
dw_n_bytes_to_write,
|
||||
lpdw_n_bytes_written
|
||||
)
|
||||
}
|
||||
|
||||
/// Common hook for WinHttpWriteData/WinHttpReadData. The flow is similar for both
|
||||
/// hooks:
|
||||
/// - Read URL and User-Agent from the handle
|
||||
/// - Extract the API method from the URL, and exit if it's not the method we're
|
||||
/// looking for
|
||||
/// - Determine if the API is encrypted, and exit if it is and we don't have keys
|
||||
/// - Parse the body and convert it to Tachi's BATCH-MANUAL
|
||||
/// - Submit it off to Tachi, if our guard function (which takes the parsed body) allows so.
|
||||
fn winhttprwdata_hook<'a, T: Debug + DeserializeOwned + ToTachiImport + 'static>(
|
||||
handle: HINTERNET,
|
||||
buffer: *const c_void,
|
||||
bufsz: DWORD,
|
||||
unencrypted_endpoint: &str,
|
||||
encrypted_endpoint: &Option<String>,
|
||||
guard_fn: impl Fn(&T) -> bool + Send + 'static,
|
||||
) -> Result<()> {
|
||||
let url = read_hinternet_url(handle)?;
|
||||
let user_agent = read_hinternet_user_agent(handle)?;
|
||||
debug!("user-agent {user_agent}, URL: {url}");
|
||||
fn process_request(
|
||||
hrequest: HINTERNET,
|
||||
buffer: LPCVOID,
|
||||
bufsiz: DWORD,
|
||||
) -> Result<(), ProcessRequestError> {
|
||||
let url = winhttp_query_option(hrequest, WINHTTP_OPTION_URL).context(UrlReadSnafu)?;
|
||||
|
||||
let maybe_endpoint = url
|
||||
debug!("Captured request to {url}");
|
||||
|
||||
let endpoint = url
|
||||
.split('/')
|
||||
.last()
|
||||
.ok_or(anyhow!("Could not extract last part of a split URL"))?;
|
||||
.ok_or(ProcessRequestError::UrlMissingEndpointError)?;
|
||||
let upsert_user_all_endpoint = UPSERT_USER_ALL_API
|
||||
.get()
|
||||
.ok_or(ProcessRequestError::UninitializedError)?;
|
||||
|
||||
let is_encrypted = is_encrypted_endpoint(maybe_endpoint);
|
||||
|
||||
let endpoint = if is_encrypted && user_agent.contains('#') {
|
||||
user_agent
|
||||
.split('#')
|
||||
.next()
|
||||
.ok_or(anyhow!("there should be at least one item in the split"))?
|
||||
} else {
|
||||
maybe_endpoint
|
||||
};
|
||||
|
||||
let is_correct_endpoint = is_endpoint(endpoint, unencrypted_endpoint, encrypted_endpoint);
|
||||
if cfg!(not(debug_assertions)) && !is_correct_endpoint {
|
||||
if endpoint != upsert_user_all_endpoint {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if is_encrypted && (CONFIGURATION.crypto.key.is_empty() || CONFIGURATION.crypto.iv.is_empty()) {
|
||||
return Err(anyhow!("Communications with the server is encrypted, but no keys were provided. Fill in the keys by editing 'saekawa.toml'."));
|
||||
let mut raw_body_slice =
|
||||
unsafe { std::slice::from_raw_parts(buffer as *const u8, bufsiz as usize) };
|
||||
let mut raw_body = Vec::with_capacity(bufsiz as usize);
|
||||
|
||||
raw_body_slice
|
||||
.read_to_end(&mut raw_body)
|
||||
.context(ReadBodySnafu)?;
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
{
|
||||
debug!("raw request: {}", faster_hex::hex_string(&raw_body));
|
||||
}
|
||||
|
||||
let mut raw_body = match read_slice(buffer as *const u8, bufsz as usize) {
|
||||
Ok(data) => data,
|
||||
Err(err) => {
|
||||
return Err(anyhow!(
|
||||
"There was an error reading the response body: {:#}",
|
||||
err
|
||||
));
|
||||
}
|
||||
thread::spawn(move || {
|
||||
let Some(config) = CONFIG.get() else {
|
||||
error!("Config has not been initialized?");
|
||||
return;
|
||||
};
|
||||
|
||||
debug!("raw body: {}", faster_hex::hex_string(&raw_body));
|
||||
let Some(major_version) = GAME_MAJOR_VERSION.get() else {
|
||||
error!("The game's major version is not known?");
|
||||
return;
|
||||
};
|
||||
|
||||
std::thread::spawn(move || {
|
||||
let compressed_body = if is_encrypted {
|
||||
match decrypt_aes256_cbc(
|
||||
&mut raw_body,
|
||||
&CONFIGURATION.crypto.key,
|
||||
&CONFIGURATION.crypto.iv,
|
||||
) {
|
||||
Ok(res) => res,
|
||||
Err(err) => {
|
||||
error!("Could not decrypt response: {:#}", err);
|
||||
let compressed_body = if let Some(keys) = CRYPTO_KEYS.get() {
|
||||
match decrypt_aes256_cbc(&mut raw_body, &keys.key, &keys.iv) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
error!("Could not decrypt request: {e:#?}");
|
||||
return;
|
||||
}
|
||||
}
|
||||
@ -333,23 +230,171 @@ fn winhttprwdata_hook<'a, T: Debug + DeserializeOwned + ToTachiImport + 'static>
|
||||
raw_body
|
||||
};
|
||||
|
||||
let body = match read_maybe_compressed_buffer(&compressed_body[..]) {
|
||||
Ok(data) => data,
|
||||
Err(err) => {
|
||||
error!("There was an error decoding the request body: {:#}", err);
|
||||
let body = match maybe_decompress_buffer(&compressed_body) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
error!("Could not read request as DEFLATE-compressed or plaintext: {e:#?}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
debug!("decoded response body: {body}");
|
||||
|
||||
// Hit in debug build
|
||||
if !is_correct_endpoint {
|
||||
return;
|
||||
#[cfg(debug_assertions)]
|
||||
{
|
||||
debug!("decoded request: {}", body.trim());
|
||||
}
|
||||
|
||||
score_handler::<T>(body, guard_fn)
|
||||
let data = match serde_json::from_str::<UpsertUserAllRequest>(&body) {
|
||||
Ok(d) => d,
|
||||
Err(e) => {
|
||||
error!("Could not parse request: {e:#?}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let user_data = &data.upsert_user_all.user_data[0];
|
||||
let access_code = &user_data.access_code;
|
||||
let Some(tachi_api_key) = config
|
||||
.cards
|
||||
.get(access_code)
|
||||
.or_else(|| config.cards.get("default"))
|
||||
else {
|
||||
info!("No API keys was assigned to {access_code}, and no default API key was set, skipping score submission.");
|
||||
return;
|
||||
};
|
||||
|
||||
let import = data.to_batch_manual(
|
||||
*major_version,
|
||||
config.general.export_class,
|
||||
config.general.fail_over_lamp,
|
||||
);
|
||||
|
||||
if let Err(e) = execute_score_import(import, access_code, &tachi_api_key, &config) {
|
||||
error!("{e}");
|
||||
}
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn get_project_conf() -> Result<GameInformation, HookError> {
|
||||
let project_conf = Ini::load_from_file("./project.conf").context(IniSnafu)?;
|
||||
let major_version = &project_conf["Version"]["VerMajor"];
|
||||
let minor_version = &project_conf["Version"]["VerMinor"];
|
||||
let build_version = &project_conf["Version"]["VerRelease"];
|
||||
let game_id = &project_conf["Project"]["GameID"];
|
||||
|
||||
Ok(GameInformation {
|
||||
game_id: game_id.to_string(),
|
||||
major: major_version.parse::<u16>().context(InvalidVersionSnafu)?,
|
||||
minor: minor_version.parse::<u8>().context(InvalidVersionSnafu)?,
|
||||
build: build_version.parse::<u8>().context(InvalidVersionSnafu)?,
|
||||
})
|
||||
}
|
||||
|
||||
fn determine_major_version(info: &GameInformation) -> u16 {
|
||||
if info.game_id == "SDGS" {
|
||||
if info.minor < 10 {
|
||||
1
|
||||
} else {
|
||||
2
|
||||
}
|
||||
} else {
|
||||
info.major
|
||||
}
|
||||
}
|
||||
|
||||
fn setup_network_encryption(info: &GameInformation) -> Result<(), HookError> {
|
||||
debug!("Getting module information of the game process");
|
||||
let mut modinfo: MaybeUninit<MODULEINFO> = MaybeUninit::uninit();
|
||||
let result = unsafe {
|
||||
GetModuleInformation(
|
||||
GetCurrentProcess(),
|
||||
GetModuleHandleW(ptr::null_mut()),
|
||||
modinfo.as_mut_ptr(),
|
||||
mem::size_of::<MODULEINFO>() as u32,
|
||||
)
|
||||
};
|
||||
|
||||
if result == 0 {
|
||||
let err = unsafe { GetLastError() };
|
||||
|
||||
error!("Could not get information about the game process, error code {err}");
|
||||
return Err(HookError::Win32Error { errno: err });
|
||||
}
|
||||
|
||||
let modinfo = unsafe { modinfo.assume_init() };
|
||||
debug!(
|
||||
"Base address: {:p}, image size: {:x}",
|
||||
modinfo.lpBaseOfDll, modinfo.SizeOfImage
|
||||
);
|
||||
|
||||
debug!("Scanning game for encryption status");
|
||||
let encryption_enabled = unsafe {
|
||||
sigscan::is_network_encrypted(modinfo.lpBaseOfDll as *const _, modinfo.SizeOfImage as _)
|
||||
.context(CryptoScanSnafu)?
|
||||
};
|
||||
|
||||
let endpoint = if info.game_id == "SDGS" {
|
||||
if info.minor < 10 {
|
||||
"UpsertUserAllApiExp"
|
||||
} else {
|
||||
"UpsertUserAllApiC3Exp"
|
||||
}
|
||||
} else {
|
||||
"UpsertUserAllApi"
|
||||
};
|
||||
|
||||
if encryption_enabled {
|
||||
info!("Network requests are encrypted.");
|
||||
|
||||
debug!("Searching for encryption keys. This might take a bit...");
|
||||
|
||||
let keys = unsafe {
|
||||
sigscan::get_crypto_keys(modinfo.lpBaseOfDll as *const _, modinfo.SizeOfImage as _)
|
||||
.context(CryptoScanSnafu)?
|
||||
};
|
||||
|
||||
info!("Search completed successfully.");
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
{
|
||||
debug!(
|
||||
"Key: {}, IV: {}, salt: {}, iterations: {}",
|
||||
faster_hex::hex_string(&keys.key),
|
||||
faster_hex::hex_string(&keys.iv),
|
||||
faster_hex::hex_string(&keys.salt),
|
||||
keys.iterations,
|
||||
)
|
||||
}
|
||||
|
||||
// For some reason, CHUNITHM SUPERSTAR/SUPERSTAR+ forgot to add "Exp" when
|
||||
// hashing the endpoint.
|
||||
let endpoint_password = if info.game_id == "SDGS" && info.minor < 10 {
|
||||
"UpsertUserAllApi"
|
||||
} else {
|
||||
endpoint
|
||||
};
|
||||
|
||||
let hashed_endpoint = hash_endpoint(endpoint_password, &keys.salt, keys.iterations);
|
||||
|
||||
debug!(
|
||||
"Hashed {endpoint_password} with {:#?} to {hashed_endpoint}",
|
||||
keys.salt
|
||||
);
|
||||
|
||||
UPSERT_USER_ALL_API
|
||||
.set(hashed_endpoint)
|
||||
.expect("OnceLock shouldn't be initialized.");
|
||||
CRYPTO_KEYS
|
||||
.set(keys)
|
||||
.expect("OnceLock shouldn't be initialized.");
|
||||
} else {
|
||||
info!("Network requests are not encrypted.");
|
||||
|
||||
UPSERT_USER_ALL_API
|
||||
.set(endpoint.to_string())
|
||||
.expect("OnceLock shouldn't be initialized.");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
257
src/score_import.rs
Normal file
257
src/score_import.rs
Normal file
@ -0,0 +1,257 @@
|
||||
use std::{fmt::Debug, fs::File, io, thread, time::Duration};
|
||||
|
||||
use log::{debug, error, info};
|
||||
use rand::{rngs::ThreadRng, Rng};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use snafu::{prelude::Snafu, ResultExt};
|
||||
|
||||
use crate::{
|
||||
config::SaekawaConfig,
|
||||
types::tachi::{
|
||||
api::{TachiFailureResponse, TachiResponse},
|
||||
api_returns::{ImportPollStatus, ImportResponse},
|
||||
batch_manual::BatchManualImport,
|
||||
documents::ImportDocument,
|
||||
},
|
||||
};
|
||||
|
||||
const MAX_RETRY_COUNT: u32 = 3;
|
||||
static SAEKAWA_USER_AGENT: &str = concat!("saekawa/", env!("CARGO_PKG_VERSION"));
|
||||
|
||||
#[derive(Snafu, Debug)]
|
||||
pub enum ScoreImportError {
|
||||
#[snafu(display("Could not create import URL"))]
|
||||
InvalidImportUrl { source: url::ParseError },
|
||||
|
||||
#[snafu(display("Tachi API returned an error: {}", response.description))]
|
||||
TachiError { response: TachiFailureResponse },
|
||||
|
||||
#[snafu(display("Could not communicate with Tachi {max_retries} times."))]
|
||||
MaxRetriesExhausted { max_retries: u32 },
|
||||
|
||||
#[snafu(display("Tachi returned an invalid response."))]
|
||||
InvalidTachiResponse { source: io::Error },
|
||||
|
||||
#[snafu(display("Could not create backup batch manual file."))]
|
||||
FailedCreatingBackup { source: io::Error },
|
||||
|
||||
#[snafu(display("Could not write backup batch manual file."))]
|
||||
FailedWritingBackup { source: serde_json::Error },
|
||||
}
|
||||
|
||||
/// This function blocks until it has completed, which may take a long time
|
||||
/// depending on the user's internet connection with Tachi. It's best to call
|
||||
/// this in a separate thread.
|
||||
pub fn execute_score_import(
|
||||
import: BatchManualImport,
|
||||
access_code: &str,
|
||||
api_key: &str,
|
||||
config: &SaekawaConfig,
|
||||
) -> Result<(), ScoreImportError> {
|
||||
// Checking if there's actually anything to import before continuing on
|
||||
if import.scores.is_empty()
|
||||
&& (import.classes.is_none()
|
||||
|| import
|
||||
.classes
|
||||
.as_ref()
|
||||
.is_some_and(|c| c.dan.is_none() && c.emblem.is_none()))
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let import_url = config
|
||||
.tachi
|
||||
.base_url
|
||||
.join("/ir/direct-manual/import")
|
||||
.context(InvalidImportUrlSnafu)?
|
||||
.to_string();
|
||||
let client = saekawa_client(config);
|
||||
let response = match request_tachi::<_, ImportResponse>(
|
||||
&client,
|
||||
"POST",
|
||||
&import_url,
|
||||
&api_key,
|
||||
Some(&import),
|
||||
) {
|
||||
Ok(r) => r,
|
||||
Err(ScoreImportError::MaxRetriesExhausted { max_retries }) => {
|
||||
error!("Could not reach Tachi after {max_retries} attempts.");
|
||||
|
||||
let Some(d) = &config.general.failed_import_dir else {
|
||||
return Err(ScoreImportError::MaxRetriesExhausted { max_retries });
|
||||
};
|
||||
|
||||
info!("Saving batch manual JSON to configured failed import directory for later import.");
|
||||
|
||||
let current_time = chrono::Local::now().format("%Y-%m-%d_%H-%M-%S");
|
||||
let failed_import_filename =
|
||||
d.join(format!("saekawa_{}_{}.json", access_code, current_time));
|
||||
|
||||
{
|
||||
let file = File::create(&failed_import_filename).context(FailedCreatingBackupSnafu)?;
|
||||
serde_json::to_writer(file, &import).context(FailedWritingBackupSnafu)?;
|
||||
}
|
||||
|
||||
info!("Saved batch manual JSON to {}", failed_import_filename.to_string_lossy());
|
||||
|
||||
return Ok(());
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
|
||||
let response = match response {
|
||||
TachiResponse::Ok(r) => r,
|
||||
TachiResponse::Err(e) => return Err(ScoreImportError::TachiError { response: e }),
|
||||
};
|
||||
|
||||
match response.body {
|
||||
ImportResponse::Deferred(d) => {
|
||||
info!("Import was queued for processing. Poll URL: {}", d.url);
|
||||
poll_deferred_import(&client, &api_key, &d.url);
|
||||
return Ok(());
|
||||
}
|
||||
ImportResponse::Completed(d) => {
|
||||
log_tachi_import(&response.description, &d);
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn saekawa_client(config: &SaekawaConfig) -> ureq::Agent {
|
||||
ureq::builder()
|
||||
.timeout(Duration::from_millis(config.general.timeout))
|
||||
.user_agent(SAEKAWA_USER_AGENT)
|
||||
.build()
|
||||
}
|
||||
|
||||
fn exponential_backoff(rand: &mut ThreadRng, attempt: u32) -> u64 {
|
||||
// attempt | backoff
|
||||
// 0 | 2-4 seconds
|
||||
// 1 | 8-16 seconds
|
||||
// 2 | 32-64 seconds
|
||||
return rand.gen_range(500..=1000) * 4_u64.pow(attempt + 1);
|
||||
}
|
||||
|
||||
fn request_tachi<T, R>(
|
||||
client: &ureq::Agent,
|
||||
method: &str,
|
||||
url: &str,
|
||||
api_key: &str,
|
||||
body: Option<T>,
|
||||
) -> Result<TachiResponse<R>, ScoreImportError>
|
||||
where
|
||||
T: Serialize + Debug,
|
||||
R: for<'de> Deserialize<'de> + Debug,
|
||||
{
|
||||
let auth_header = format!("Bearer {}", api_key);
|
||||
let mut rand = rand::thread_rng();
|
||||
|
||||
for attempt in 0..MAX_RETRY_COUNT {
|
||||
debug!(
|
||||
"Requesting Tachi, attempt {}/{MAX_RETRY_COUNT}",
|
||||
attempt + 1
|
||||
);
|
||||
|
||||
let request = client
|
||||
.request(method, url)
|
||||
.set("Authorization", &auth_header);
|
||||
let response = if let Some(ref body) = body {
|
||||
request.send_json(body)
|
||||
} else {
|
||||
request.call()
|
||||
};
|
||||
let response = match response {
|
||||
Ok(r) => r,
|
||||
Err(ureq::Error::Transport(e)) => {
|
||||
error!("Could not reach Tachi API. Is your network up or are they having issues?.");
|
||||
error!("Detailed error: {e:#?}");
|
||||
|
||||
if attempt != MAX_RETRY_COUNT - 1 {
|
||||
let wait_time = exponential_backoff(&mut rand, attempt);
|
||||
|
||||
info!("Waiting for {wait_time}ms before trying again...");
|
||||
thread::sleep(Duration::from_millis(wait_time));
|
||||
continue;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
Err(ureq::Error::Status(code, response)) => {
|
||||
if code >= 500 {
|
||||
error!("Tachi is having a server error. Response code was {code}.");
|
||||
|
||||
if let Ok(s) = response.into_string() {
|
||||
error!("Response from Tachi: {s}");
|
||||
} else {
|
||||
error!("No response could be read.");
|
||||
}
|
||||
|
||||
if attempt != MAX_RETRY_COUNT - 1 {
|
||||
let wait_time = exponential_backoff(&mut rand, attempt);
|
||||
|
||||
info!("Waiting for {wait_time}ms before trying again...");
|
||||
thread::sleep(Duration::from_millis(wait_time));
|
||||
continue;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
response
|
||||
}
|
||||
};
|
||||
|
||||
return response
|
||||
.into_json::<TachiResponse<R>>()
|
||||
.context(InvalidTachiResponseSnafu);
|
||||
}
|
||||
|
||||
return Err(ScoreImportError::MaxRetriesExhausted {
|
||||
max_retries: MAX_RETRY_COUNT,
|
||||
});
|
||||
}
|
||||
|
||||
fn log_tachi_import(description: &str, import: &ImportDocument) {
|
||||
info!(
|
||||
"{description} {} scores, {} sessions, {} errors",
|
||||
import.score_ids.len(),
|
||||
import.created_sessions.len(),
|
||||
import.errors.len()
|
||||
);
|
||||
|
||||
for err in &import.errors {
|
||||
error!("{}: {}", err.error_type, err.message);
|
||||
}
|
||||
}
|
||||
|
||||
fn poll_deferred_import(client: &ureq::Agent, api_key: &str, poll_url: &str) {
|
||||
loop {
|
||||
let resp = match request_tachi::<_, ImportPollStatus>(
|
||||
&client, "GET", &poll_url, &api_key, None::<()>,
|
||||
) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
error!("Could not poll import status. While Tachi has received the score, Saekawa cannot make any guarantees about its success. Detailed error: {e:#?}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let resp = match resp {
|
||||
TachiResponse::Ok(r) => r,
|
||||
TachiResponse::Err(e) => {
|
||||
error!("Tachi API returned an error: {}", e.description);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
match resp.body {
|
||||
ImportPollStatus::Completed { import } => {
|
||||
log_tachi_import(&resp.description, &import);
|
||||
return;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
thread::sleep(Duration::from_secs(1));
|
||||
}
|
||||
}
|
236
src/sigscan.rs
Normal file
236
src/sigscan.rs
Normal file
@ -0,0 +1,236 @@
|
||||
use std::{ffi::CStr, slice};
|
||||
|
||||
use lightningscanner::{ScanMode, Scanner};
|
||||
use log::{debug, error};
|
||||
use pbkdf2::pbkdf2_hmac;
|
||||
use sha1::Sha1;
|
||||
use snafu::prelude::Snafu;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct CryptoKeys {
|
||||
pub key: Vec<u8>,
|
||||
pub iv: Vec<u8>,
|
||||
pub salt: Vec<u8>,
|
||||
pub iterations: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Snafu)]
|
||||
pub enum CryptoScanError {
|
||||
MissingSignature,
|
||||
NotEncrypted,
|
||||
}
|
||||
|
||||
/// SAFETY: The caller ensures that `module_base` and `module_size` are valid.
|
||||
pub unsafe fn is_network_encrypted(
|
||||
module_base: *const u8,
|
||||
module_size: usize,
|
||||
) -> Result<bool, CryptoScanError> {
|
||||
let scan_mode = if is_x86_feature_detected!("avx2") {
|
||||
ScanMode::Avx2
|
||||
} else if is_x86_feature_detected!("sse4.2") {
|
||||
ScanMode::Sse42
|
||||
} else {
|
||||
ScanMode::Scalar
|
||||
};
|
||||
|
||||
debug!("Using {scan_mode:?} for signature scanning");
|
||||
|
||||
debug!("Scanning for the endpoint salt password");
|
||||
// b"?AVDeflate@projClient@@\x??\x??\x??\x??\x??\x??\x??\x??"
|
||||
// This is what the patchers are patching out when disabling encryption. This is
|
||||
// also where the endpoint salt password can be found.
|
||||
let scanner = Scanner::new("3F 41 56 44 65 66 6C 61 74 65 40 70 72 6F 6A 43 6C 69 65 6E 74 40 40 ?? ?? ?? ?? ?? ?? ?? ??");
|
||||
let result = scanner.find(Some(scan_mode), module_base, module_size);
|
||||
|
||||
if !result.is_valid() {
|
||||
error!("Could not find the endpoint salt password");
|
||||
return Err(CryptoScanError::MissingSignature);
|
||||
}
|
||||
|
||||
let crypto_config = *result.get_addr().wrapping_add(0x1B);
|
||||
|
||||
if crypto_config == 0 {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// SAFETY: The caller ensures that `module_base` and `module_size` are valid.
|
||||
pub unsafe fn get_crypto_keys(
|
||||
module_base: *const u8,
|
||||
module_size: usize,
|
||||
) -> Result<CryptoKeys, CryptoScanError> {
|
||||
let scan_mode = if is_x86_feature_detected!("avx2") {
|
||||
ScanMode::Avx2
|
||||
} else if is_x86_feature_detected!("sse4.2") {
|
||||
ScanMode::Sse42
|
||||
} else {
|
||||
ScanMode::Scalar
|
||||
};
|
||||
|
||||
debug!("Using {scan_mode:?} for signature scanning");
|
||||
|
||||
debug!("Scanning for the endpoint salt password");
|
||||
// b"?AVDeflate@projClient@@\x??\x??\x??\x??\x??\x??\x??\x??"
|
||||
// This is what the patchers are patching out when disabling encryption. This is
|
||||
// also where the endpoint salt password can be found.
|
||||
let scanner = Scanner::new("3F 41 56 44 65 66 6C 61 74 65 40 70 72 6F 6A 43 6C 69 65 6E 74 40 40 ?? ?? ?? ?? ?? ?? ?? ??");
|
||||
let result = scanner.find(Some(scan_mode), module_base, module_size);
|
||||
|
||||
if !result.is_valid() {
|
||||
error!("Could not find the endpoint salt password");
|
||||
return Err(CryptoScanError::MissingSignature);
|
||||
}
|
||||
|
||||
let crypto_config = *result.get_addr().wrapping_add(0x1B);
|
||||
|
||||
if crypto_config == 0 {
|
||||
return Err(CryptoScanError::NotEncrypted);
|
||||
}
|
||||
|
||||
let endpoint_salt_password_address = if crypto_config == *result.get_addr().wrapping_add(0x1F) {
|
||||
i32_from_ptr_le_bytes(result.get_addr().wrapping_add(0x23)) as *const i8
|
||||
} else {
|
||||
i32_from_ptr_le_bytes(result.get_addr().wrapping_add(0x1F)) as *const i8
|
||||
};
|
||||
|
||||
let endpoint_salt_password = CStr::from_ptr(endpoint_salt_password_address);
|
||||
|
||||
debug!(
|
||||
"Endpoint salt password: {} ({endpoint_salt_password_address:p})",
|
||||
endpoint_salt_password.to_string_lossy()
|
||||
);
|
||||
|
||||
// Scanning for the call to [`PKCS5_PBKDF2_HMAC_SHA1`](https://www.openssl.org/docs/man3.2/man3/PKCS5_PBKDF2_HMAC_SHA1.html)
|
||||
// with saltlen=16, iter=31, keylen=8 to find the endpoint salt's salt
|
||||
let scanner = Scanner::new("52 6A 08 6A ?? 6A 10 68 ?? ?? ?? ?? 51 53 E8 ?? ?? ?? ??");
|
||||
let result = scanner.find(Some(scan_mode), module_base, module_size);
|
||||
|
||||
if !result.is_valid() {
|
||||
return Err(CryptoScanError::MissingSignature);
|
||||
}
|
||||
|
||||
let endpoint_salt_rounds = *result.get_addr().wrapping_add(0x04) as u32;
|
||||
let endpoint_salt_salt_address =
|
||||
i32_from_ptr_le_bytes(result.get_addr().wrapping_add(0x08)) as *const u8;
|
||||
let endpoint_salt_salt = slice::from_raw_parts(endpoint_salt_salt_address, 16);
|
||||
|
||||
debug!(
|
||||
"Endpoint salt salt: {} ({endpoint_salt_salt_address:p})",
|
||||
faster_hex::hex_string(endpoint_salt_salt)
|
||||
);
|
||||
|
||||
let mut endpoint_salt = vec![0u8; 8];
|
||||
pbkdf2_hmac::<Sha1>(
|
||||
endpoint_salt_password.to_bytes(),
|
||||
endpoint_salt_salt,
|
||||
endpoint_salt_rounds,
|
||||
&mut endpoint_salt,
|
||||
);
|
||||
|
||||
debug!("Endpoint salt: {endpoint_salt:X?}");
|
||||
|
||||
// Scanning for the call to [`PKCS5_PBKDF2_HMAC_SHA1`](https://www.openssl.org/docs/man3.2/man3/PKCS5_PBKDF2_HMAC_SHA1.html)
|
||||
// with saltlen=16, iter=??, keylen=32 to find the encryption key's salt
|
||||
let scanner = Scanner::new("50 6A 20 6A ?? 6A 10 2B CA 68 ?? ?? ?? ?? 51 55 E8 ?? ?? ?? ??");
|
||||
let result = scanner.find(Some(scan_mode), module_base, module_size);
|
||||
|
||||
if !result.is_valid() {
|
||||
return Err(CryptoScanError::MissingSignature);
|
||||
}
|
||||
|
||||
let encryption_key_rounds = *result.get_addr().wrapping_add(0x04) as u32;
|
||||
let encryption_key_salt_address =
|
||||
i32_from_ptr_le_bytes(result.get_addr().wrapping_add(0x0A)) as *const u8;
|
||||
let encryption_key_salt = slice::from_raw_parts(encryption_key_salt_address, 16);
|
||||
|
||||
debug!(
|
||||
"Encryption key salt: {} ({encryption_key_salt_address:p})",
|
||||
faster_hex::hex_string(encryption_key_salt)
|
||||
);
|
||||
|
||||
// b"?AVSystemInterface@projClient@@\x??\x??\x??\x??\x??\x??\x??\x??\x??\x??\x??\x??"
|
||||
let scanner = Scanner::new("3F 41 56 53 79 73 74 65 6D 49 6E 74 65 72 66 61 63 65 40 70 72 6F 6A 43 6C 69 65 6E 74 40 40 ?? ?? ?? ?? ?? ?? ?? ?? ?? ?? ?? ??");
|
||||
let result = scanner.find(Some(scan_mode), module_base, module_size);
|
||||
|
||||
if !result.is_valid() {
|
||||
return Err(CryptoScanError::MissingSignature);
|
||||
}
|
||||
|
||||
let encryption_key_password_address =
|
||||
i32_from_ptr_le_bytes(result.get_addr().wrapping_add(0x23)) as *const i8;
|
||||
let encryption_key_password = CStr::from_ptr(encryption_key_password_address);
|
||||
|
||||
debug!(
|
||||
"Encryption key password: {} ({encryption_key_password_address:p})",
|
||||
encryption_key_password.to_string_lossy()
|
||||
);
|
||||
|
||||
let mut encryption_key = vec![0u8; 32];
|
||||
pbkdf2_hmac::<Sha1>(
|
||||
encryption_key_password.to_bytes(),
|
||||
encryption_key_salt,
|
||||
encryption_key_rounds,
|
||||
&mut encryption_key,
|
||||
);
|
||||
|
||||
for byte in encryption_key.iter_mut() {
|
||||
*byte = (*byte % 0x5E) + 0x21;
|
||||
}
|
||||
|
||||
debug!("Encryption key: {encryption_key:X?}");
|
||||
|
||||
// Encryption IV
|
||||
let scanner2 = Scanner::new("E8 ?? ?? ?? ?? F3 0F 7E 05 ?? ?? ?? ?? 6A 01");
|
||||
let result2 = scanner2.find(Some(scan_mode), module_base, module_size);
|
||||
|
||||
let iv_addr = if result2.is_valid() {
|
||||
i32_from_ptr_le_bytes(result2.get_addr().wrapping_add(0x09)) as *const i8
|
||||
} else {
|
||||
let scanner1 = Scanner::new("F3 0F 7E 05 ?? ?? ?? ?? 8B 74 24 24 6A 01");
|
||||
let result1 = scanner1.find(Some(scan_mode), module_base, module_size);
|
||||
|
||||
if !result1.is_valid() {
|
||||
return Err(CryptoScanError::MissingSignature);
|
||||
}
|
||||
|
||||
i32_from_ptr_le_bytes(result1.get_addr().wrapping_add(0x04)) as *const i8
|
||||
};
|
||||
|
||||
let iv = CStr::from_ptr(iv_addr).to_bytes().to_vec();
|
||||
|
||||
debug!(
|
||||
"Encryption IV: {} ({iv_addr:p})",
|
||||
faster_hex::hex_string(&iv)
|
||||
);
|
||||
|
||||
let scanner = Scanner::new("C7 86 ?? ?? ?? ?? ?? ?? ?? ?? 0F 8C ?? ?? ?? ?? 85 ED 0F 84 ?? ?? ?? ?? 85 DB 0F 84 ?? ?? ?? ??");
|
||||
let result = scanner.find(Some(scan_mode), module_base, module_size);
|
||||
|
||||
if !result.is_valid() {
|
||||
return Err(CryptoScanError::MissingSignature);
|
||||
}
|
||||
|
||||
let iterations = u32::from_le_bytes(from_raw_parts_const(result.get_addr().wrapping_add(0x06)));
|
||||
|
||||
debug!("Iterations: {iterations}");
|
||||
|
||||
Ok(CryptoKeys {
|
||||
key: encryption_key,
|
||||
iv,
|
||||
salt: endpoint_salt,
|
||||
iterations,
|
||||
})
|
||||
}
|
||||
|
||||
unsafe fn from_raw_parts_const<const N: usize>(ptr: *const u8) -> [u8; N] {
|
||||
slice::from_raw_parts(ptr, N)
|
||||
.try_into()
|
||||
.expect("slice::from_raw_parts with len=N should convert to [u8; N]")
|
||||
}
|
||||
|
||||
#[inline]
|
||||
unsafe fn i32_from_ptr_le_bytes(ptr: *const u8) -> i32 {
|
||||
i32::from_le_bytes(from_raw_parts_const(ptr))
|
||||
}
|
31
src/types/chuni/mod.rs
Normal file
31
src/types/chuni/mod.rs
Normal file
@ -0,0 +1,31 @@
|
||||
mod music;
|
||||
pub mod upsert;
|
||||
|
||||
use serde::{de, Deserialize};
|
||||
|
||||
pub use self::upsert::UpsertUserAllRequest;
|
||||
|
||||
fn deserialize_bool<'de, D>(deserializer: D) -> Result<bool, D::Error>
|
||||
where
|
||||
D: de::Deserializer<'de>,
|
||||
{
|
||||
#[derive(Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum BooleanishTypes {
|
||||
String(String),
|
||||
Bool(bool),
|
||||
Number(i32),
|
||||
}
|
||||
|
||||
let s: BooleanishTypes = de::Deserialize::deserialize(deserializer)?;
|
||||
|
||||
match s {
|
||||
BooleanishTypes::String(s) => match s.as_str() {
|
||||
"true" => Ok(true),
|
||||
"false" => Ok(false),
|
||||
_ => Err(de::Error::unknown_variant(&s, &["true", "false"])),
|
||||
},
|
||||
BooleanishTypes::Bool(b) => Ok(b),
|
||||
BooleanishTypes::Number(n) => Ok(n > 0),
|
||||
}
|
||||
}
|
41
src/types/chuni/music.rs
Normal file
41
src/types/chuni/music.rs
Normal file
@ -0,0 +1,41 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_aux::prelude::*;
|
||||
|
||||
use super::deserialize_bool;
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UserMusicResponse {
|
||||
pub user_id: String,
|
||||
pub length: u32,
|
||||
pub user_music_list: Vec<UserMusicItem>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UserMusicItem {
|
||||
pub length: u32,
|
||||
pub user_music_detail_list: Vec<UserMusicDetail>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UserMusicDetail {
|
||||
#[serde(deserialize_with = "deserialize_number_from_string")]
|
||||
pub music_id: u32,
|
||||
|
||||
#[serde(deserialize_with = "deserialize_number_from_string")]
|
||||
pub level: u32,
|
||||
|
||||
#[serde(deserialize_with = "deserialize_number_from_string")]
|
||||
pub score_max: u32,
|
||||
|
||||
#[serde(deserialize_with = "deserialize_bool")]
|
||||
pub is_all_justice: bool,
|
||||
|
||||
#[serde(deserialize_with = "deserialize_bool")]
|
||||
pub is_full_combo: bool,
|
||||
|
||||
#[serde(deserialize_with = "deserialize_bool")]
|
||||
pub is_success: bool,
|
||||
}
|
@ -1,29 +1,21 @@
|
||||
use serde::{de, Deserialize, Serialize};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_aux::prelude::*;
|
||||
|
||||
fn deserialize_bool<'de, D>(deserializer: D) -> Result<bool, D::Error>
|
||||
where
|
||||
D: de::Deserializer<'de>,
|
||||
{
|
||||
#[derive(Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum StringOrBoolean {
|
||||
String(String),
|
||||
Bool(bool),
|
||||
Number(i32),
|
||||
use super::deserialize_bool;
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UpsertUserAllRequest {
|
||||
pub user_id: String,
|
||||
pub upsert_user_all: UpsertUserAllBody,
|
||||
}
|
||||
|
||||
let s: StringOrBoolean = de::Deserialize::deserialize(deserializer)?;
|
||||
|
||||
match s {
|
||||
StringOrBoolean::String(s) => match s.as_str() {
|
||||
"true" => Ok(true),
|
||||
"false" => Ok(false),
|
||||
_ => Err(de::Error::unknown_variant(&s, &["true", "false"])),
|
||||
},
|
||||
StringOrBoolean::Bool(b) => Ok(b),
|
||||
StringOrBoolean::Number(n) => Ok(n > 0),
|
||||
}
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UpsertUserAllBody {
|
||||
pub user_data: Vec<UserData>,
|
||||
pub user_data_ex: Option<Vec<UserDataEx>>,
|
||||
pub user_playlog_list: Vec<UserPlaylog>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
@ -31,11 +23,18 @@ where
|
||||
pub struct UserData {
|
||||
pub access_code: String,
|
||||
|
||||
#[serde(deserialize_with = "deserialize_number_from_string")]
|
||||
pub class_emblem_base: u32,
|
||||
#[serde(deserialize_with = "deserialize_option_number_from_string")]
|
||||
pub class_emblem_base: Option<u32>,
|
||||
|
||||
#[serde(deserialize_with = "deserialize_option_number_from_string")]
|
||||
pub class_emblem_medal: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UserDataEx {
|
||||
#[serde(deserialize_with = "deserialize_number_from_string")]
|
||||
pub class_emblem_medal: u32,
|
||||
pub medal: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
@ -71,6 +70,7 @@ pub struct UserPlaylog {
|
||||
|
||||
#[serde(deserialize_with = "deserialize_number_from_string")]
|
||||
pub judge_critical: u32,
|
||||
|
||||
// Only introduced in CHUNITHM NEW, thus needing a default value.
|
||||
#[serde(
|
||||
default = "default_judge_heaven",
|
||||
@ -91,54 +91,3 @@ pub struct UserPlaylog {
|
||||
fn default_judge_heaven() -> u32 {
|
||||
0
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UserMusicDetail {
|
||||
#[serde(deserialize_with = "deserialize_number_from_string")]
|
||||
pub music_id: u32,
|
||||
|
||||
#[serde(deserialize_with = "deserialize_number_from_string")]
|
||||
pub level: u32,
|
||||
|
||||
#[serde(deserialize_with = "deserialize_number_from_string")]
|
||||
pub score_max: u32,
|
||||
|
||||
#[serde(deserialize_with = "deserialize_bool")]
|
||||
pub is_all_justice: bool,
|
||||
|
||||
#[serde(deserialize_with = "deserialize_bool")]
|
||||
pub is_full_combo: bool,
|
||||
|
||||
#[serde(deserialize_with = "deserialize_bool")]
|
||||
pub is_success: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UserMusicItem {
|
||||
pub length: u32,
|
||||
pub user_music_detail_list: Vec<UserMusicDetail>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UserMusicResponse {
|
||||
pub user_id: String,
|
||||
pub length: u32,
|
||||
pub user_music_list: Vec<UserMusicItem>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UpsertUserAllBody {
|
||||
pub user_data: Vec<UserData>,
|
||||
pub user_playlog_list: Vec<UserPlaylog>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UpsertUserAllRequest {
|
||||
pub user_id: String,
|
||||
pub upsert_user_all: UpsertUserAllBody,
|
||||
}
|
143
src/types/mod.rs
143
src/types/mod.rs
@ -1,2 +1,143 @@
|
||||
pub mod game;
|
||||
pub mod chuni;
|
||||
pub mod tachi;
|
||||
|
||||
use chrono::{FixedOffset, NaiveDateTime, TimeZone};
|
||||
use num_enum::TryFromPrimitiveError;
|
||||
use snafu::{ResultExt, Snafu};
|
||||
|
||||
use self::{
|
||||
chuni::{upsert::UserPlaylog, UpsertUserAllRequest},
|
||||
tachi::batch_manual::{
|
||||
class::ClassEmblem,
|
||||
score::{Difficulty, Judgements, Lamp, MatchType, OptionalMetrics},
|
||||
BatchManualClasses, BatchManualImport, BatchManualScore,
|
||||
},
|
||||
};
|
||||
|
||||
#[derive(Debug, Snafu)]
|
||||
pub enum ScoreConversionError {
|
||||
#[snafu(display("Unknown difficulty index."))]
|
||||
InvalidDifficulty {
|
||||
source: TryFromPrimitiveError<Difficulty>,
|
||||
},
|
||||
|
||||
#[snafu(display("Invalid play date."))]
|
||||
InvalidPlayDate { source: chrono::format::ParseError },
|
||||
}
|
||||
|
||||
impl UserPlaylog {
|
||||
pub fn to_batch_manual(
|
||||
&self,
|
||||
major_version: u16,
|
||||
fail_over_lamp: bool,
|
||||
) -> Result<BatchManualScore, ScoreConversionError> {
|
||||
let lamp = if !self.is_clear && fail_over_lamp {
|
||||
Lamp::Failed
|
||||
} else if self.is_all_justice {
|
||||
if self.judge_justice + self.judge_attack + self.judge_guilty == 0 {
|
||||
Lamp::AllJusticeCritical
|
||||
} else {
|
||||
Lamp::AllJustice
|
||||
}
|
||||
} else if self.is_full_combo {
|
||||
Lamp::FullCombo
|
||||
} else if self.is_clear {
|
||||
Lamp::Clear
|
||||
} else {
|
||||
Lamp::Failed
|
||||
};
|
||||
|
||||
let judgements = Judgements {
|
||||
jcrit: self.judge_heaven + self.judge_critical,
|
||||
justice: self.judge_justice,
|
||||
attack: self.judge_attack,
|
||||
miss: self.judge_guilty,
|
||||
};
|
||||
|
||||
let difficulty = if major_version == 1 && self.level == 4 {
|
||||
Difficulty::WorldsEnd
|
||||
} else {
|
||||
Difficulty::try_from(self.level).context(InvalidDifficultySnafu)?
|
||||
};
|
||||
|
||||
let datetime = NaiveDateTime::parse_from_str(&self.user_play_date, "%Y-%m-%d %H:%M:%S")
|
||||
.context(InvalidPlayDateSnafu)?;
|
||||
let jst_offset = FixedOffset::east_opt(9 * 3600).expect("chrono should parse JST timezone");
|
||||
let jst_time = jst_offset.from_local_datetime(&datetime).unwrap();
|
||||
|
||||
Ok(BatchManualScore {
|
||||
score: self.score,
|
||||
lamp,
|
||||
match_type: MatchType::InGameId,
|
||||
identifier: self.music_id.clone(),
|
||||
difficulty,
|
||||
time_achieved: Some(jst_time.timestamp_millis() as u128),
|
||||
judgements: Some(judgements),
|
||||
optional: Some(OptionalMetrics {
|
||||
max_combo: self.max_combo,
|
||||
}),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub trait ToBatchManual {
|
||||
fn to_batch_manual(
|
||||
&self,
|
||||
major_version: u16,
|
||||
export_class: bool,
|
||||
fail_over_lamp: bool,
|
||||
) -> BatchManualImport;
|
||||
}
|
||||
|
||||
impl ToBatchManual for UpsertUserAllRequest {
|
||||
fn to_batch_manual(
|
||||
&self,
|
||||
major_version: u16,
|
||||
export_class: bool,
|
||||
fail_over_lamp: bool,
|
||||
) -> BatchManualImport {
|
||||
let user_data = &self.upsert_user_all.user_data[0];
|
||||
|
||||
let classes = if export_class {
|
||||
let dan = if let Some(medal) = user_data.class_emblem_medal {
|
||||
ClassEmblem::try_from(medal).ok()
|
||||
} else if let Some(user_data_ex) = &self.upsert_user_all.user_data_ex {
|
||||
ClassEmblem::try_from(user_data_ex[0].medal).ok()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let emblem = user_data
|
||||
.class_emblem_base
|
||||
.map(|b| ClassEmblem::try_from(b).ok())
|
||||
.flatten();
|
||||
|
||||
Some(BatchManualClasses { dan, emblem })
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let scores = self
|
||||
.upsert_user_all
|
||||
.user_playlog_list
|
||||
.iter()
|
||||
.filter_map(|p| {
|
||||
let conv = p.to_batch_manual(major_version, fail_over_lamp);
|
||||
|
||||
if conv
|
||||
.as_ref()
|
||||
.is_ok_and(|s| s.difficulty != Difficulty::WorldsEnd)
|
||||
{
|
||||
conv.ok()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
BatchManualImport {
|
||||
classes,
|
||||
scores,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,435 +0,0 @@
|
||||
use anyhow::Result;
|
||||
use chrono::{FixedOffset, NaiveDateTime, TimeZone};
|
||||
use num_enum::TryFromPrimitive;
|
||||
use serde::{de, Deserialize, Deserializer, Serialize};
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
use super::game::{UpsertUserAllRequest, UserMusicDetail, UserMusicResponse, UserPlaylog};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum TachiResponse<T> {
|
||||
Ok(TachiSuccessResponse<T>),
|
||||
Err(TachiErrorResponse),
|
||||
}
|
||||
|
||||
impl<'de, T> Deserialize<'de> for TachiResponse<T>
|
||||
where
|
||||
T: Deserialize<'de>,
|
||||
{
|
||||
fn deserialize<D>(deserializer: D) -> Result<TachiResponse<T>, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let mut map = Map::deserialize(deserializer)?;
|
||||
|
||||
let success = map
|
||||
.remove("success")
|
||||
.ok_or_else(|| de::Error::missing_field("success"))
|
||||
.map(Deserialize::deserialize)?
|
||||
.map_err(de::Error::custom)?;
|
||||
let rest = Value::Object(map);
|
||||
|
||||
if success {
|
||||
TachiSuccessResponse::deserialize(rest)
|
||||
.map(TachiResponse::Ok)
|
||||
.map_err(de::Error::custom)
|
||||
} else {
|
||||
TachiErrorResponse::deserialize(rest)
|
||||
.map(TachiResponse::Err)
|
||||
.map_err(de::Error::custom)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct TachiSuccessResponse<T> {
|
||||
pub description: String,
|
||||
pub body: T,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct TachiErrorResponse {
|
||||
pub description: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct StatusCheck {
|
||||
pub permissions: Vec<String>,
|
||||
pub whoami: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum ImportResponse {
|
||||
Queued {
|
||||
url: String,
|
||||
|
||||
#[serde(rename = "importID")]
|
||||
import_id: String,
|
||||
},
|
||||
Finished(ImportDocument),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct ImportDocument {
|
||||
#[serde(rename = "scoreIDs")]
|
||||
pub score_ids: Vec<String>,
|
||||
|
||||
pub errors: Vec<ImportErrContent>,
|
||||
|
||||
#[serde(rename = "createdSessions")]
|
||||
pub created_sessions: Vec<SessionInfoReturn>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct ImportErrContent {
|
||||
#[serde(rename = "type")]
|
||||
pub error_type: String,
|
||||
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct SessionInfoReturn {
|
||||
#[serde(rename = "type")]
|
||||
pub session_type: String,
|
||||
|
||||
#[serde(rename = "sessionID")]
|
||||
pub session_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "importStatus")]
|
||||
pub enum ImportPollStatus {
|
||||
#[serde(rename = "ongoing")]
|
||||
Ongoing { progress: ImportProgress },
|
||||
|
||||
#[serde(rename = "completed")]
|
||||
Completed { import: ImportDocument },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct ImportProgress {
|
||||
pub description: String,
|
||||
pub value: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct Import {
|
||||
pub meta: ImportMeta,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub classes: Option<ImportClasses>,
|
||||
pub scores: Vec<ImportScore>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ImportMeta {
|
||||
pub game: String,
|
||||
pub playtype: String,
|
||||
pub service: String,
|
||||
}
|
||||
|
||||
impl Default for ImportMeta {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
game: "chunithm".to_string(),
|
||||
playtype: "Single".to_string(),
|
||||
service: "Saekawa".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct ImportClasses {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub dan: Option<ClassEmblem>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub emblem: Option<ClassEmblem>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize, TryFromPrimitive)]
|
||||
#[repr(u32)]
|
||||
pub enum ClassEmblem {
|
||||
#[serde(rename = "DAN_I")]
|
||||
First = 1,
|
||||
|
||||
#[serde(rename = "DAN_II")]
|
||||
Second = 2,
|
||||
|
||||
#[serde(rename = "DAN_III")]
|
||||
Third = 3,
|
||||
|
||||
#[serde(rename = "DAN_IV")]
|
||||
Fourth = 4,
|
||||
|
||||
#[serde(rename = "DAN_V")]
|
||||
Fifth = 5,
|
||||
|
||||
#[serde(rename = "DAN_INFINITE")]
|
||||
Infinite = 6,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ImportScore {
|
||||
pub score: u32,
|
||||
pub lamp: TachiLamp,
|
||||
pub match_type: String,
|
||||
pub identifier: String,
|
||||
pub difficulty: Difficulty,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub time_achieved: Option<u128>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub judgements: Option<Judgements>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub optional: Option<OptionalMetrics>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize, TryFromPrimitive)]
|
||||
#[repr(u32)]
|
||||
pub enum TachiLamp {
|
||||
#[serde(rename = "FAILED")]
|
||||
Failed = 0,
|
||||
|
||||
#[serde(rename = "CLEAR")]
|
||||
Clear = 1,
|
||||
|
||||
#[serde(rename = "FULL COMBO")]
|
||||
FullCombo = 2,
|
||||
|
||||
#[serde(rename = "ALL JUSTICE")]
|
||||
AllJustice = 3,
|
||||
|
||||
#[serde(rename = "ALL JUSTICE CRITICAL")]
|
||||
AllJusticeCritical = 4,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize, TryFromPrimitive)]
|
||||
#[repr(u32)]
|
||||
pub enum Difficulty {
|
||||
#[serde(rename = "BASIC")]
|
||||
Basic = 0,
|
||||
|
||||
#[serde(rename = "ADVANCED")]
|
||||
Advanced = 1,
|
||||
|
||||
#[serde(rename = "EXPERT")]
|
||||
Expert = 2,
|
||||
|
||||
#[serde(rename = "MASTER")]
|
||||
Master = 3,
|
||||
|
||||
#[serde(rename = "ULTIMA")]
|
||||
Ultima = 4,
|
||||
|
||||
#[serde(rename = "WORLD'S END")]
|
||||
WorldsEnd = 5,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct Judgements {
|
||||
pub jcrit: u32,
|
||||
pub justice: u32,
|
||||
pub attack: u32,
|
||||
pub miss: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct OptionalMetrics {
|
||||
pub max_combo: u32,
|
||||
}
|
||||
|
||||
impl ImportScore {
|
||||
pub fn try_from_playlog(
|
||||
p: &UserPlaylog,
|
||||
major_version: u16,
|
||||
fail_over_lamp: bool,
|
||||
) -> Result<ImportScore> {
|
||||
let lamp = if !p.is_clear && fail_over_lamp {
|
||||
TachiLamp::Failed
|
||||
} else if p.is_all_justice {
|
||||
if p.judge_justice + p.judge_attack + p.judge_guilty == 0 {
|
||||
TachiLamp::AllJusticeCritical
|
||||
} else {
|
||||
TachiLamp::AllJustice
|
||||
}
|
||||
} else if p.is_full_combo {
|
||||
TachiLamp::FullCombo
|
||||
} else if p.is_clear {
|
||||
TachiLamp::Clear
|
||||
} else {
|
||||
TachiLamp::Failed
|
||||
};
|
||||
|
||||
let judgements = Judgements {
|
||||
jcrit: p.judge_heaven + p.judge_critical,
|
||||
justice: p.judge_justice,
|
||||
attack: p.judge_attack,
|
||||
miss: p.judge_guilty,
|
||||
};
|
||||
|
||||
let difficulty = if major_version == 1 && p.level == 4 {
|
||||
Difficulty::WorldsEnd
|
||||
} else {
|
||||
Difficulty::try_from(p.level)?
|
||||
};
|
||||
|
||||
let datetime = NaiveDateTime::parse_from_str(&p.user_play_date, "%Y-%m-%d %H:%M:%S")?;
|
||||
let jst_offset =
|
||||
FixedOffset::east_opt(9 * 3600).expect("chrono should be able to parse JST timezone");
|
||||
let jst_time = jst_offset.from_local_datetime(&datetime).unwrap();
|
||||
|
||||
Ok(ImportScore {
|
||||
score: p.score,
|
||||
lamp,
|
||||
match_type: "inGameID".to_string(),
|
||||
identifier: p.music_id.clone(),
|
||||
difficulty,
|
||||
time_achieved: Some(jst_time.timestamp_millis() as u128),
|
||||
judgements: Some(judgements),
|
||||
optional: Some(OptionalMetrics {
|
||||
max_combo: p.max_combo,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
fn try_from_music_detail(
|
||||
d: &UserMusicDetail,
|
||||
major_version: u16,
|
||||
fail_over_lamp: bool,
|
||||
) -> Result<ImportScore> {
|
||||
let lamp = if !d.is_success && fail_over_lamp {
|
||||
TachiLamp::Failed
|
||||
} else if d.is_all_justice {
|
||||
TachiLamp::AllJustice
|
||||
} else if d.is_full_combo {
|
||||
TachiLamp::FullCombo
|
||||
} else if d.is_success {
|
||||
TachiLamp::Clear
|
||||
} else {
|
||||
TachiLamp::Failed
|
||||
};
|
||||
|
||||
let difficulty = if major_version == 1 && d.level == 4 {
|
||||
Difficulty::WorldsEnd
|
||||
} else {
|
||||
Difficulty::try_from(d.level)?
|
||||
};
|
||||
|
||||
Ok(ImportScore {
|
||||
score: d.score_max,
|
||||
lamp,
|
||||
match_type: "inGameID".to_string(),
|
||||
identifier: d.music_id.to_string(),
|
||||
difficulty,
|
||||
time_achieved: None,
|
||||
judgements: None,
|
||||
optional: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub trait ToTachiImport {
|
||||
fn displayed_id(&self) -> &str;
|
||||
fn displayed_id_type(&self) -> &str;
|
||||
fn to_tachi_import(
|
||||
&self,
|
||||
major_version: u16,
|
||||
export_class: bool,
|
||||
fail_over_lamp: bool,
|
||||
) -> Import;
|
||||
}
|
||||
|
||||
impl ToTachiImport for UserMusicResponse {
|
||||
fn displayed_id(&self) -> &str {
|
||||
&self.user_id
|
||||
}
|
||||
|
||||
fn displayed_id_type(&self) -> &str {
|
||||
"user ID"
|
||||
}
|
||||
|
||||
fn to_tachi_import(&self, major_version: u16, _: bool, fail_over_lamp: bool) -> Import {
|
||||
let scores = self
|
||||
.user_music_list
|
||||
.iter()
|
||||
.flat_map(|item| {
|
||||
item.user_music_detail_list.iter().filter_map(|d| {
|
||||
let result =
|
||||
ImportScore::try_from_music_detail(d, major_version, fail_over_lamp);
|
||||
if result
|
||||
.as_ref()
|
||||
.is_ok_and(|v| v.difficulty != Difficulty::WorldsEnd)
|
||||
{
|
||||
result.ok()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
Import {
|
||||
scores,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ToTachiImport for UpsertUserAllRequest {
|
||||
fn displayed_id(&self) -> &str {
|
||||
let user_data = &self.upsert_user_all.user_data[0];
|
||||
|
||||
&user_data.access_code
|
||||
}
|
||||
|
||||
fn displayed_id_type(&self) -> &str {
|
||||
"access code"
|
||||
}
|
||||
|
||||
fn to_tachi_import(
|
||||
&self,
|
||||
major_version: u16,
|
||||
export_class: bool,
|
||||
fail_over_lamp: bool,
|
||||
) -> Import {
|
||||
let user_data = &self.upsert_user_all.user_data[0];
|
||||
|
||||
let classes = if export_class {
|
||||
Some(ImportClasses {
|
||||
dan: ClassEmblem::try_from(user_data.class_emblem_medal).ok(),
|
||||
emblem: ClassEmblem::try_from(user_data.class_emblem_base).ok(),
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let scores = self
|
||||
.upsert_user_all
|
||||
.user_playlog_list
|
||||
.iter()
|
||||
.filter_map(|playlog| {
|
||||
let result = ImportScore::try_from_playlog(playlog, major_version, fail_over_lamp);
|
||||
if result
|
||||
.as_ref()
|
||||
.is_ok_and(|v| v.difficulty != Difficulty::WorldsEnd)
|
||||
{
|
||||
result.ok()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect::<Vec<ImportScore>>();
|
||||
|
||||
Import {
|
||||
classes,
|
||||
scores,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
}
|
74
src/types/tachi/api.rs
Normal file
74
src/types/tachi/api.rs
Normal file
@ -0,0 +1,74 @@
|
||||
use serde::{de, Deserialize, Serialize};
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum TachiResponse<T> {
|
||||
Ok(TachiSuccessResponse<T>),
|
||||
Err(TachiFailureResponse),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct TachiSuccessResponse<T> {
|
||||
pub description: String,
|
||||
pub body: T,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct TachiFailureResponse {
|
||||
pub description: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
|
||||
pub enum TachiApiPermission {
|
||||
#[serde(rename = "customise_profile")]
|
||||
CustomiseProfile,
|
||||
|
||||
#[serde(rename = "customise_score")]
|
||||
CustomiseScore,
|
||||
|
||||
#[serde(rename = "customise_session")]
|
||||
CustomiseSession,
|
||||
|
||||
#[serde(rename = "delete_score")]
|
||||
DeleteScore,
|
||||
|
||||
#[serde(rename = "manage_challenges")]
|
||||
ManageChallenges,
|
||||
|
||||
#[serde(rename = "manage_rivals")]
|
||||
ManageRivals,
|
||||
|
||||
#[serde(rename = "manage_targets")]
|
||||
ManageTargets,
|
||||
|
||||
#[serde(rename = "submit_score")]
|
||||
SubmitScore,
|
||||
}
|
||||
|
||||
impl<'de, T> Deserialize<'de> for TachiResponse<T>
|
||||
where
|
||||
T: Deserialize<'de>,
|
||||
{
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
let mut map = Map::deserialize(deserializer)?;
|
||||
let success = map
|
||||
.remove("success")
|
||||
.ok_or_else(|| de::Error::missing_field("success"))
|
||||
.map(Deserialize::deserialize)?
|
||||
.map_err(de::Error::custom)?;
|
||||
let rest = Value::Object(map);
|
||||
|
||||
if success {
|
||||
TachiSuccessResponse::deserialize(rest)
|
||||
.map(TachiResponse::Ok)
|
||||
.map_err(de::Error::custom)
|
||||
} else {
|
||||
TachiFailureResponse::deserialize(rest)
|
||||
.map(TachiResponse::Err)
|
||||
.map_err(de::Error::custom)
|
||||
}
|
||||
}
|
||||
}
|
40
src/types/tachi/api_returns.rs
Normal file
40
src/types/tachi/api_returns.rs
Normal file
@ -0,0 +1,40 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::{api::TachiApiPermission, documents::ImportDocument};
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct ServerStatus {
|
||||
pub whoami: Option<u32>,
|
||||
pub permissions: Vec<TachiApiPermission>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct ImportDeferred {
|
||||
pub url: String,
|
||||
|
||||
#[serde(rename = "importID")]
|
||||
pub import_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum ImportResponse {
|
||||
Deferred(ImportDeferred),
|
||||
Completed(ImportDocument),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct ImportProgress {
|
||||
description: String,
|
||||
value: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "importStatus")]
|
||||
pub enum ImportPollStatus {
|
||||
#[serde(rename = "ongoing")]
|
||||
Ongoing { progress: ImportProgress },
|
||||
|
||||
#[serde(rename = "completed")]
|
||||
Completed { import: ImportDocument },
|
||||
}
|
33
src/types/tachi/batch_manual/class.rs
Normal file
33
src/types/tachi/batch_manual/class.rs
Normal file
@ -0,0 +1,33 @@
|
||||
use num_enum::TryFromPrimitive;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct BatchManualClasses {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub dan: Option<ClassEmblem>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub emblem: Option<ClassEmblem>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize, TryFromPrimitive)]
|
||||
#[repr(u32)]
|
||||
pub enum ClassEmblem {
|
||||
#[serde(rename = "DAN_I")]
|
||||
First = 1,
|
||||
|
||||
#[serde(rename = "DAN_II")]
|
||||
Second = 2,
|
||||
|
||||
#[serde(rename = "DAN_III")]
|
||||
Third = 3,
|
||||
|
||||
#[serde(rename = "DAN_IV")]
|
||||
Fourth = 4,
|
||||
|
||||
#[serde(rename = "DAN_V")]
|
||||
Fifth = 5,
|
||||
|
||||
#[serde(rename = "DAN_INFINITE")]
|
||||
Infinite = 6,
|
||||
}
|
18
src/types/tachi/batch_manual/meta.rs
Normal file
18
src/types/tachi/batch_manual/meta.rs
Normal file
@ -0,0 +1,18 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct BatchManualMeta {
|
||||
pub game: String,
|
||||
pub playtype: String,
|
||||
pub service: String,
|
||||
}
|
||||
|
||||
impl Default for BatchManualMeta {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
game: "chunithm".to_string(),
|
||||
playtype: "Single".to_string(),
|
||||
service: "Saekawa".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
16
src/types/tachi/batch_manual/mod.rs
Normal file
16
src/types/tachi/batch_manual/mod.rs
Normal file
@ -0,0 +1,16 @@
|
||||
pub mod class;
|
||||
pub mod meta;
|
||||
pub mod score;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub use self::{class::BatchManualClasses, meta::BatchManualMeta, score::BatchManualScore};
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct BatchManualImport {
|
||||
pub meta: BatchManualMeta,
|
||||
pub scores: Vec<BatchManualScore>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub classes: Option<BatchManualClasses>,
|
||||
}
|
105
src/types/tachi/batch_manual/score.rs
Normal file
105
src/types/tachi/batch_manual/score.rs
Normal file
@ -0,0 +1,105 @@
|
||||
use num_enum::TryFromPrimitive;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct BatchManualScore {
|
||||
pub match_type: MatchType,
|
||||
pub identifier: String,
|
||||
pub difficulty: Difficulty,
|
||||
pub score: u32,
|
||||
pub lamp: Lamp,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub judgements: Option<Judgements>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub time_achieved: Option<u128>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub optional: Option<OptionalMetrics>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize, TryFromPrimitive)]
|
||||
#[repr(u32)]
|
||||
pub enum Lamp {
|
||||
#[serde(rename = "FAILED")]
|
||||
Failed = 0,
|
||||
|
||||
#[serde(rename = "CLEAR")]
|
||||
Clear = 1,
|
||||
|
||||
#[serde(rename = "FULL COMBO")]
|
||||
FullCombo = 2,
|
||||
|
||||
#[serde(rename = "ALL JUSTICE")]
|
||||
AllJustice = 3,
|
||||
|
||||
#[serde(rename = "ALL JUSTICE CRITICAL")]
|
||||
AllJusticeCritical = 4,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize, TryFromPrimitive)]
|
||||
#[repr(u32)]
|
||||
pub enum Difficulty {
|
||||
#[serde(rename = "BASIC")]
|
||||
Basic = 0,
|
||||
|
||||
#[serde(rename = "ADVANCED")]
|
||||
Advanced = 1,
|
||||
|
||||
#[serde(rename = "EXPERT")]
|
||||
Expert = 2,
|
||||
|
||||
#[serde(rename = "MASTER")]
|
||||
Master = 3,
|
||||
|
||||
#[serde(rename = "ULTIMA")]
|
||||
Ultima = 4,
|
||||
|
||||
#[serde(rename = "WORLD'S END")]
|
||||
WorldsEnd = 5,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
|
||||
pub enum MatchType {
|
||||
#[serde(rename = "bmsChartHash")]
|
||||
BmsChartHash,
|
||||
|
||||
#[serde(rename = "itgChartHash")]
|
||||
ItgChartHash,
|
||||
|
||||
#[serde(rename = "popnChartHash")]
|
||||
PopnChartHash,
|
||||
|
||||
#[serde(rename = "uscChartHash")]
|
||||
UscChartHash,
|
||||
|
||||
#[serde(rename = "inGameID")]
|
||||
InGameId,
|
||||
|
||||
#[serde(rename = "inGameStrID")]
|
||||
InGameStrId,
|
||||
|
||||
#[serde(rename = "sdvxInGameID")]
|
||||
SdvxInGameId,
|
||||
|
||||
#[serde(rename = "songTitle")]
|
||||
SongTitle,
|
||||
|
||||
#[serde(rename = "tachiSongID")]
|
||||
TachiSongId,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct Judgements {
|
||||
pub jcrit: u32,
|
||||
pub justice: u32,
|
||||
pub attack: u32,
|
||||
pub miss: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct OptionalMetrics {
|
||||
pub max_combo: u32,
|
||||
}
|
35
src/types/tachi/documents.rs
Normal file
35
src/types/tachi/documents.rs
Normal file
@ -0,0 +1,35 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
|
||||
pub enum SessionType {
|
||||
Appended,
|
||||
Created,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct ImportErrContent {
|
||||
#[serde(rename = "type")]
|
||||
pub error_type: String,
|
||||
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SessionInfoReturn {
|
||||
#[serde(rename = "type")]
|
||||
pub session_type: SessionType,
|
||||
|
||||
#[serde(rename = "sessionID")]
|
||||
pub session_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct ImportDocument {
|
||||
#[serde(rename = "scoreIDs")]
|
||||
pub score_ids: Vec<String>,
|
||||
|
||||
pub errors: Vec<ImportErrContent>,
|
||||
|
||||
#[serde(rename = "createdSessions")]
|
||||
pub created_sessions: Vec<SessionInfoReturn>,
|
||||
}
|
4
src/types/tachi/mod.rs
Normal file
4
src/types/tachi/mod.rs
Normal file
@ -0,0 +1,4 @@
|
||||
pub mod api;
|
||||
pub mod api_returns;
|
||||
pub mod batch_manual;
|
||||
pub mod documents;
|
94
src/updater/external.rs
Normal file
94
src/updater/external.rs
Normal file
@ -0,0 +1,94 @@
|
||||
use winapi::{
|
||||
ctypes::c_void,
|
||||
shared::{
|
||||
minwindef::{BOOL, DWORD, HMODULE, LPVOID, PROC},
|
||||
ntdef::{HANDLE, LPCWSTR, LPSTR},
|
||||
},
|
||||
};
|
||||
|
||||
#[link_section = ".rtext"]
|
||||
#[used]
|
||||
pub static mut GET_MODULE_FILE_NAME_A_PTR: PROC = 0 as PROC;
|
||||
|
||||
#[link_section = ".rtext"]
|
||||
#[used]
|
||||
pub static mut GET_PROCESS_HEAP_PTR: PROC = 0 as PROC;
|
||||
|
||||
#[link_section = ".rtext"]
|
||||
#[used]
|
||||
pub static mut REPLCE_FILE_W_PTR: PROC = 0 as PROC;
|
||||
|
||||
#[link_section = ".rtext"]
|
||||
#[used]
|
||||
pub static mut LOAD_LIBRARY_W_POINTER: PROC = 0 as PROC;
|
||||
|
||||
#[link_section = ".rtext"]
|
||||
#[used]
|
||||
pub static mut HEAP_FREE_PTR: PROC = 0 as PROC;
|
||||
|
||||
#[link_section = ".rtext"]
|
||||
#[used]
|
||||
pub static mut SLEEP_PTR: PROC = 0 as PROC;
|
||||
|
||||
#[link_section = ".rtext"]
|
||||
#[used]
|
||||
pub static mut GET_LAST_ERROR_PTR: PROC = 0 as PROC;
|
||||
|
||||
type GetModuleFileNameAFn = unsafe extern "system" fn(HMODULE, LPSTR, DWORD) -> DWORD;
|
||||
type GetProcessHeapFn = unsafe extern "system" fn() -> HANDLE;
|
||||
type ReplaceFileWFn =
|
||||
unsafe extern "system" fn(LPCWSTR, LPCWSTR, LPCWSTR, DWORD, LPVOID, LPVOID) -> BOOL;
|
||||
type LoadLibraryWFn = unsafe extern "system" fn(LPCWSTR) -> HMODULE;
|
||||
type HeapFreeFn = unsafe extern "system" fn(HANDLE, DWORD, LPVOID) -> BOOL;
|
||||
type SleepFn = unsafe extern "system" fn(DWORD);
|
||||
|
||||
#[repr(C)]
|
||||
pub struct ReplaceArgs {
|
||||
pub module: HMODULE,
|
||||
pub old: [u16; 32767],
|
||||
pub new: [u16; 32767],
|
||||
}
|
||||
|
||||
/// SAFETY: This function *must* only be called when the addresses for the function have been filled in.
|
||||
#[allow(non_snake_case)]
|
||||
#[link_section = ".rtext"]
|
||||
pub unsafe extern "system" fn replace_with_new_library(parameter: *const c_void) -> u32 {
|
||||
let args = parameter as *const ReplaceArgs;
|
||||
let GetModuleFileNameA =
|
||||
std::mem::transmute::<_, GetModuleFileNameAFn>(GET_MODULE_FILE_NAME_A_PTR);
|
||||
let GetProcessHeap = std::mem::transmute::<_, GetProcessHeapFn>(GET_PROCESS_HEAP_PTR);
|
||||
let ReplaceFileW = std::mem::transmute::<_, ReplaceFileWFn>(REPLCE_FILE_W_PTR);
|
||||
let LoadLibraryW = std::mem::transmute::<_, LoadLibraryWFn>(LOAD_LIBRARY_W_POINTER);
|
||||
let HeapFree = std::mem::transmute::<_, HeapFreeFn>(HEAP_FREE_PTR);
|
||||
let Sleep = std::mem::transmute::<_, SleepFn>(SLEEP_PTR);
|
||||
|
||||
// Wait for the old library to be freed
|
||||
let mut filename = 0;
|
||||
|
||||
loop {
|
||||
let result = GetModuleFileNameA((*args).module, &mut filename, 1);
|
||||
|
||||
if result == 0 {
|
||||
break;
|
||||
}
|
||||
|
||||
Sleep(1000);
|
||||
}
|
||||
|
||||
let result = ReplaceFileW(
|
||||
(*args).old.as_ptr(),
|
||||
(*args).new.as_ptr(),
|
||||
0 as *const _,
|
||||
2,
|
||||
0 as *mut c_void,
|
||||
0 as *mut c_void,
|
||||
);
|
||||
|
||||
if result > 0 {
|
||||
LoadLibraryW((*args).old.as_ptr());
|
||||
} else {
|
||||
LoadLibraryW((*args).new.as_ptr());
|
||||
}
|
||||
|
||||
HeapFree(GetProcessHeap(), 0, args as *mut _) as u32
|
||||
}
|
578
src/updater/mod.rs
Normal file
578
src/updater/mod.rs
Normal file
@ -0,0 +1,578 @@
|
||||
mod external;
|
||||
|
||||
use std::{
|
||||
ffi::CStr,
|
||||
io::{self, Read},
|
||||
mem::{self},
|
||||
path::Path,
|
||||
ptr,
|
||||
};
|
||||
|
||||
use log::{debug, error, info};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use snafu::{prelude::Snafu, ResultExt};
|
||||
use widestring::U16CString;
|
||||
use winapi::{
|
||||
shared::{
|
||||
minwindef::{BOOL, DWORD, HMODULE, LPVOID, PROC},
|
||||
ntdef::{LPCWSTR, LPSTR},
|
||||
winerror::{
|
||||
CERT_E_CHAINING, CERT_E_EXPIRED, CERT_E_UNTRUSTEDROOT, CRYPT_E_SECURITY_SETTINGS,
|
||||
TRUST_E_EXPLICIT_DISTRUST, TRUST_E_NOSIGNATURE,
|
||||
},
|
||||
},
|
||||
um::{
|
||||
heapapi::HeapAlloc,
|
||||
memoryapi::{VirtualAlloc, VirtualProtect},
|
||||
minwinbase::LMEM_ZEROINIT,
|
||||
processthreadsapi::CreateThread,
|
||||
softpub::WINTRUST_ACTION_GENERIC_VERIFY_V2,
|
||||
winbase::LocalAlloc,
|
||||
wincrypt::{
|
||||
CertCloseStore, CertFindCertificateInStore, CryptMsgClose, CryptMsgGetParam,
|
||||
CryptQueryObject, CERT_FIND_SUBJECT_CERT, CERT_INFO,
|
||||
CERT_QUERY_CONTENT_FLAG_PKCS7_SIGNED_EMBED, CERT_QUERY_FORMAT_FLAG_BINARY,
|
||||
CERT_QUERY_OBJECT_FILE, CMSG_SIGNER_INFO_PARAM, HCERTSTORE, HCRYPTMSG,
|
||||
PCMSG_SIGNER_INFO, PKCS_7_ASN_ENCODING, X509_ASN_ENCODING,
|
||||
},
|
||||
winnt::{
|
||||
HANDLE, HEAP_ZERO_MEMORY, IMAGE_DOS_HEADER, IMAGE_NT_HEADERS32, IMAGE_SECTION_HEADER,
|
||||
MEM_COMMIT, MEM_RESERVE, PAGE_EXECUTE_READ, PAGE_READWRITE,
|
||||
},
|
||||
wintrust::{
|
||||
WinVerifyTrust, WINTRUST_DATA, WINTRUST_FILE_INFO, WTD_CHOICE_FILE, WTD_REVOKE_NONE,
|
||||
WTD_STATEACTION_VERIFY, WTD_UI_NONE,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
use self::external::{replace_with_new_library, ReplaceArgs};
|
||||
use crate::helpers::winapi_ext::{get_module_file_name, LibraryHandle, ReadStringFnError};
|
||||
|
||||
const PUBLIC_KEY: [u8; 270] = [
|
||||
0x30, 0x82, 0x01, 0x0a, 0x02, 0x82, 0x01, 0x01, 0x00, 0xf5, 0xbd, 0x02, 0xb0, 0x81, 0xc6, 0x4d,
|
||||
0x4c, 0xa0, 0x40, 0xa8, 0x76, 0x78, 0xe2, 0x61, 0x39, 0x13, 0x1d, 0x2f, 0x0c, 0x70, 0x71, 0x96,
|
||||
0x56, 0x67, 0xf2, 0xbe, 0xc2, 0x5c, 0xc7, 0xd4, 0xa6, 0xb5, 0x07, 0xc5, 0x7a, 0x19, 0x58, 0x10,
|
||||
0x70, 0xb5, 0x87, 0x5f, 0x3f, 0x9a, 0x78, 0x9e, 0x96, 0x5c, 0xc7, 0x88, 0x50, 0x8c, 0x34, 0xcc,
|
||||
0x51, 0xe5, 0xd5, 0xbd, 0xb8, 0xab, 0xed, 0x28, 0x7f, 0x68, 0x6e, 0x27, 0x2a, 0x1d, 0xdb, 0x9a,
|
||||
0xe9, 0x1d, 0xbc, 0xd8, 0xbf, 0xca, 0xdf, 0x65, 0xa3, 0x0a, 0x19, 0x3d, 0x00, 0x14, 0x16, 0xdd,
|
||||
0x87, 0x9f, 0xf5, 0x44, 0x9e, 0x56, 0x1e, 0xfd, 0xb5, 0xf0, 0x75, 0x3d, 0x11, 0x4c, 0x4d, 0xa5,
|
||||
0x1a, 0x24, 0xfe, 0x31, 0x77, 0xc1, 0x55, 0xf7, 0x5d, 0x9c, 0x34, 0xbe, 0x5f, 0x9d, 0x73, 0x2c,
|
||||
0x3e, 0xdb, 0x39, 0x18, 0x3c, 0xb3, 0x46, 0xe0, 0xf4, 0xa1, 0xcc, 0x2f, 0x7b, 0x07, 0xb7, 0x0e,
|
||||
0x7a, 0x92, 0x54, 0xa9, 0x9f, 0xfc, 0x4c, 0xe0, 0xbb, 0xcf, 0xba, 0x36, 0xc6, 0xcb, 0x9d, 0xb1,
|
||||
0x12, 0x4b, 0x50, 0x1c, 0x10, 0x23, 0x87, 0x28, 0x9b, 0x73, 0xe3, 0xd5, 0xc9, 0x38, 0xae, 0xd7,
|
||||
0x66, 0x73, 0x8f, 0xf8, 0x56, 0x2e, 0x48, 0x0a, 0xdb, 0x7f, 0x11, 0xbf, 0xd6, 0x4e, 0x77, 0x6c,
|
||||
0xb8, 0x12, 0xaf, 0x0b, 0x7b, 0x08, 0xe3, 0x0f, 0x7e, 0xf1, 0x6a, 0xc0, 0xac, 0x1c, 0xe2, 0x8c,
|
||||
0x47, 0xb0, 0xec, 0x10, 0xca, 0x02, 0x9c, 0x7d, 0x27, 0x78, 0x33, 0x3c, 0x25, 0x88, 0x5c, 0x4f,
|
||||
0x4b, 0xb8, 0x72, 0xeb, 0x85, 0x31, 0x39, 0xb1, 0x95, 0xae, 0xc3, 0x79, 0x38, 0x20, 0x25, 0x0e,
|
||||
0xab, 0xdc, 0x9c, 0xc8, 0x25, 0x53, 0xd2, 0xcf, 0x93, 0xf0, 0x1d, 0x95, 0x58, 0x0b, 0x0c, 0x9f,
|
||||
0xc5, 0x01, 0x7a, 0xad, 0x4f, 0x55, 0x2f, 0x24, 0xc5, 0x02, 0x03, 0x01, 0x00, 0x01,
|
||||
];
|
||||
|
||||
// I don't know what the hell is going on with linking, but you have to link these manually,
|
||||
// otherwise you end up with the addresses to the intermediary functions, which obviously
|
||||
// doesn't exist once you unloads the original library.
|
||||
#[link(name = "kernel32")]
|
||||
extern "system" {
|
||||
pub fn GetLastError() -> u32;
|
||||
pub fn GetModuleFileNameA(hModule: HMODULE, lpFilename: LPSTR, nsize: DWORD) -> u32;
|
||||
pub fn GetProcessHeap() -> HANDLE;
|
||||
pub fn HeapFree(hHeap: HANDLE, dwFlags: DWORD, lpMem: LPVOID) -> BOOL;
|
||||
pub fn LoadLibraryW(lpFileName: LPCWSTR) -> HMODULE;
|
||||
pub fn ReplaceFileW(
|
||||
lpReplacedFileName: LPCWSTR,
|
||||
lpReplacementFileName: LPCWSTR,
|
||||
lpBackupFileName: LPCWSTR,
|
||||
dwReplaceFlags: DWORD,
|
||||
lpExclude: LPVOID,
|
||||
lpReserved: LPVOID,
|
||||
) -> BOOL;
|
||||
pub fn Sleep(dwMilliseconds: DWORD);
|
||||
}
|
||||
|
||||
#[derive(Debug, Snafu)]
|
||||
pub enum SelfUpdateError {
|
||||
#[snafu(display("Could not get the file name of the currently running hook"))]
|
||||
FailedToGetFilename { source: ReadStringFnError },
|
||||
|
||||
#[snafu(display("Invalid DOS signature"))]
|
||||
InvalidDosSignature,
|
||||
|
||||
#[snafu(display("Invalid NT signature"))]
|
||||
InvalidNtSignature,
|
||||
|
||||
#[snafu(display("Updater code section not found."))]
|
||||
NoUpdaterCodeSection,
|
||||
|
||||
#[snafu(display("Failed to allocate memory for update"))]
|
||||
FailedToAllocateMemory,
|
||||
|
||||
#[snafu(display("VirtualProtect failed with error code {errno}"))]
|
||||
FailedVirtualProtect { errno: u32 },
|
||||
|
||||
#[snafu(display("Could not execute updater code: {errno}"))]
|
||||
FailedCreateThread { errno: u32 },
|
||||
|
||||
#[snafu(display("Failed to request update information."))]
|
||||
FailedRequestingUpdate { source: ureq::Error },
|
||||
|
||||
#[snafu(display("Invalid update information."))]
|
||||
InvalidUpdateInformation { source: io::Error },
|
||||
|
||||
#[snafu(display("Could not download updated hook."))]
|
||||
FailedDownloadingUpdate { source: io::Error },
|
||||
|
||||
#[snafu(display("Could not write updated hook to file."))]
|
||||
FailedWritingUpdate { source: io::Error },
|
||||
|
||||
#[snafu(display("SHA-256 checksum mismatch."))]
|
||||
InvalidChecksum,
|
||||
|
||||
#[snafu(display("Could not verify signature: {source:#}"))]
|
||||
InvalidSignature { source: VerifySignatureError },
|
||||
|
||||
#[snafu(display("Failed to get digital signature of the update: {source:#?}"))]
|
||||
FailedGettingPubkey { source: GetSignaturePubkeyError },
|
||||
|
||||
#[snafu(display("Public key mismatched."))]
|
||||
InvalidPubkey,
|
||||
}
|
||||
|
||||
#[derive(Snafu, Debug)]
|
||||
pub enum VerifySignatureError {
|
||||
#[snafu(display("Signature verification was disabled by a local policy."))]
|
||||
VerificationDisabledByPolicy,
|
||||
|
||||
#[snafu(display("No signatures found."))]
|
||||
NoSignature,
|
||||
|
||||
#[snafu(display("The signature was explicitly distrusted."))]
|
||||
ExplicitlyDistrusted,
|
||||
|
||||
#[snafu(display("An unknown validation error occured: {errno}"))]
|
||||
Unknown { errno: i32 },
|
||||
}
|
||||
|
||||
#[derive(Snafu, Debug)]
|
||||
pub enum GetSignaturePubkeyError {
|
||||
#[snafu(display("CertQueryObject failed: {errno}"))]
|
||||
QueryObjectError { errno: u32 },
|
||||
|
||||
#[snafu(display("Could not obtain size of signer information: {errno}"))]
|
||||
SignerInfoSizeError { errno: u32 },
|
||||
|
||||
#[snafu(display("Could not allocate memory for signer information: {errno}"))]
|
||||
SignerInfoAllocError { errno: u32 },
|
||||
|
||||
#[snafu(display("Could not obtain signer information: {errno}"))]
|
||||
SignerInfoObtainError { errno: u32 },
|
||||
|
||||
#[snafu(display("Could not look up certificate in certificate store: {errno}"))]
|
||||
CertificateInStoreError { errno: u32 },
|
||||
|
||||
#[snafu(display("Could not read public key."))]
|
||||
ReadPubkeyError { source: io::Error },
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
struct UpdateInformation {
|
||||
pub version: String,
|
||||
pub commit: String,
|
||||
pub sha256: String,
|
||||
}
|
||||
|
||||
/// Checks if the hook has a newer version. Returns true if update was successful
|
||||
/// and the hook should uninject itself so a newer version can load in.
|
||||
pub fn self_update(module: &LibraryHandle) -> Result<bool, SelfUpdateError> {
|
||||
let agent = ureq::builder()
|
||||
.user_agent(concat!("saekawa/", env!("CARGO_PKG_VERSION")))
|
||||
.build();
|
||||
|
||||
info!("Checking for updates...");
|
||||
let response = agent
|
||||
.get("https://beerpiss.github.io/saekawa/update.json")
|
||||
.call()
|
||||
.context(FailedRequestingUpdateSnafu)?
|
||||
.into_json::<UpdateInformation>()
|
||||
.context(InvalidUpdateInformationSnafu)?;
|
||||
|
||||
if response.commit == env!("VERGEN_GIT_SHA") {
|
||||
info!("Already up-to-date.");
|
||||
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let module_filename =
|
||||
&get_module_file_name(module.handle()).context(FailedToGetFilenameSnafu)?;
|
||||
let module_path = Path::new(&module_filename);
|
||||
|
||||
debug!("Current hook is located at {module_filename:#?}.");
|
||||
|
||||
info!("Downloading update v{}...", response.version);
|
||||
let url = format!(
|
||||
"https://github.com/beerpiss/saekawa/releases/download/v{}/saekawa.dll",
|
||||
response.version
|
||||
);
|
||||
|
||||
debug!("Requesting content from {url}...");
|
||||
let new_hook = {
|
||||
let mut response = agent
|
||||
.get(&url)
|
||||
.call()
|
||||
.context(FailedRequestingUpdateSnafu)?
|
||||
.into_reader();
|
||||
let mut buf = vec![];
|
||||
response
|
||||
.read_to_end(&mut buf)
|
||||
.context(FailedDownloadingUpdateSnafu)?;
|
||||
|
||||
buf
|
||||
};
|
||||
|
||||
debug!("Validating update contents...");
|
||||
validate_sha256(&new_hook, &response.sha256)?;
|
||||
|
||||
let new_module_path = module_path.with_file_name("saekawa.new.dll");
|
||||
|
||||
debug!("Writing update contents to {new_module_path:#?}...");
|
||||
std::fs::write(&new_module_path, new_hook).context(FailedWritingUpdateSnafu)?;
|
||||
|
||||
debug!("Verifying digital signature...");
|
||||
let new_module_filename = new_module_path.to_string_lossy();
|
||||
verify_signature(&new_module_filename).context(InvalidSignatureSnafu)?;
|
||||
|
||||
debug!("Verifying certificate public key...");
|
||||
let actual_pubkey = match get_signature_pubkey(&new_module_filename) {
|
||||
Ok(k) => k,
|
||||
Err(e) => {
|
||||
let _ = std::fs::remove_file(&new_module_path);
|
||||
return Err(SelfUpdateError::FailedGettingPubkey { source: e });
|
||||
}
|
||||
};
|
||||
|
||||
if actual_pubkey != PUBLIC_KEY {
|
||||
let _ = std::fs::remove_file(&new_module_path);
|
||||
return Err(SelfUpdateError::InvalidPubkey);
|
||||
}
|
||||
|
||||
debug!("Starting update sequence");
|
||||
// You know stuff is going to be cursed when the unsafe block is ~120 lines long.
|
||||
//
|
||||
// TL;DR: There's a function that waits until the current hook has been unloaded,
|
||||
// then replaces the old hook with the new hook, and loads in the new hook.
|
||||
//
|
||||
// This is achieved by linking that function alongside the required functions in a different
|
||||
// code section (".rtext"), setting references for those functions, then copying out
|
||||
// that entire section to a different memory region so it can keep executing when the
|
||||
// old hook is unloaded.
|
||||
//
|
||||
// Thanks to DJTRACKERS and their fervidex hook for the approach.
|
||||
unsafe {
|
||||
external::GET_LAST_ERROR_PTR = GetLastError as PROC;
|
||||
external::GET_MODULE_FILE_NAME_A_PTR = GetModuleFileNameA as PROC;
|
||||
external::GET_PROCESS_HEAP_PTR = GetProcessHeap as PROC;
|
||||
external::HEAP_FREE_PTR = HeapFree as PROC;
|
||||
external::LOAD_LIBRARY_W_POINTER = LoadLibraryW as PROC;
|
||||
external::REPLCE_FILE_W_PTR = ReplaceFileW as PROC;
|
||||
external::SLEEP_PTR = Sleep as PROC;
|
||||
|
||||
debug!("Locating updater code...");
|
||||
let dos_header = module.handle() as *const IMAGE_DOS_HEADER;
|
||||
|
||||
if (*dos_header).e_magic != 0x5A4D {
|
||||
return Err(SelfUpdateError::InvalidDosSignature);
|
||||
}
|
||||
|
||||
let nt_header_address = module.handle().byte_offset((*dos_header).e_lfanew as isize);
|
||||
let nt_header = nt_header_address as *const IMAGE_NT_HEADERS32;
|
||||
|
||||
if (*nt_header).Signature != 0x4550 {
|
||||
return Err(SelfUpdateError::InvalidNtSignature);
|
||||
}
|
||||
|
||||
let number_of_sections = (*nt_header).FileHeader.NumberOfSections;
|
||||
|
||||
if number_of_sections < 5 {
|
||||
return Err(SelfUpdateError::NoUpdaterCodeSection);
|
||||
}
|
||||
|
||||
let section_header_offset = (&(*nt_header).OptionalHeader as *const _ as *const u8)
|
||||
.byte_add((*nt_header).FileHeader.SizeOfOptionalHeader as usize)
|
||||
as *const IMAGE_SECTION_HEADER;
|
||||
|
||||
for i in 0..number_of_sections {
|
||||
let section_header = *section_header_offset.byte_add(40 * i as usize);
|
||||
let section_name = CStr::from_bytes_until_nul(§ion_header.Name)
|
||||
.unwrap()
|
||||
.to_str()
|
||||
.unwrap();
|
||||
|
||||
if section_name != ".rtext" {
|
||||
continue;
|
||||
}
|
||||
|
||||
let src_addr = module
|
||||
.handle()
|
||||
.byte_add(section_header.VirtualAddress as usize)
|
||||
as *mut u8;
|
||||
let section_size = *section_header.Misc.VirtualSize() as usize;
|
||||
|
||||
let dst_addr = VirtualAlloc(
|
||||
ptr::null_mut(),
|
||||
section_size,
|
||||
MEM_COMMIT | MEM_RESERVE,
|
||||
PAGE_READWRITE,
|
||||
) as *mut u8;
|
||||
|
||||
if dst_addr.is_null() {
|
||||
return Err(SelfUpdateError::FailedToAllocateMemory);
|
||||
}
|
||||
|
||||
debug!(
|
||||
"Copying updater code section from {:p} to {:p}",
|
||||
src_addr, dst_addr
|
||||
);
|
||||
std::ptr::copy_nonoverlapping(src_addr, dst_addr, section_size);
|
||||
|
||||
let updater_start_address = (replace_with_new_library as PROC)
|
||||
.byte_add(dst_addr as usize)
|
||||
.byte_sub(src_addr as usize);
|
||||
|
||||
debug!("Making updater code executable");
|
||||
let mut old_protect = 0u32;
|
||||
let result = VirtualProtect(
|
||||
dst_addr as *mut _,
|
||||
section_size,
|
||||
PAGE_EXECUTE_READ,
|
||||
&mut old_protect,
|
||||
);
|
||||
|
||||
if result == 0 {
|
||||
return Err(SelfUpdateError::FailedVirtualProtect {
|
||||
errno: GetLastError(),
|
||||
});
|
||||
}
|
||||
|
||||
let process_heap = GetProcessHeap();
|
||||
let heap = HeapAlloc(
|
||||
process_heap,
|
||||
HEAP_ZERO_MEMORY,
|
||||
mem::size_of::<ReplaceArgs>(),
|
||||
) as *mut ReplaceArgs;
|
||||
|
||||
debug!("Allocated heap for updater code at {heap:p}");
|
||||
|
||||
(*heap).module = module.handle();
|
||||
let old = U16CString::from_str_truncate(&module_filename);
|
||||
let new = U16CString::from_str_truncate(&new_module_path.to_string_lossy());
|
||||
std::ptr::copy_nonoverlapping(
|
||||
old.as_ptr(),
|
||||
(*heap).old.as_mut_ptr(),
|
||||
old.as_slice().len(),
|
||||
);
|
||||
std::ptr::copy_nonoverlapping(
|
||||
new.as_ptr(),
|
||||
(*heap).new.as_mut_ptr(),
|
||||
new.as_slice().len(),
|
||||
);
|
||||
|
||||
debug!("Executing updater code at {updater_start_address:p}");
|
||||
let handle = CreateThread(
|
||||
ptr::null_mut(),
|
||||
0,
|
||||
Some(std::mem::transmute(updater_start_address)),
|
||||
heap as *mut _,
|
||||
0,
|
||||
ptr::null_mut(),
|
||||
);
|
||||
|
||||
if handle.is_null() {
|
||||
error!("Could not execute updater code: {}", GetLastError());
|
||||
return Err(SelfUpdateError::FailedCreateThread {
|
||||
errno: GetLastError(),
|
||||
});
|
||||
}
|
||||
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
return Err(SelfUpdateError::NoUpdaterCodeSection);
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_sha256(data: &[u8], expected: &str) -> Result<(), SelfUpdateError> {
|
||||
let mut hasher = Sha256::new();
|
||||
|
||||
hasher.update(data);
|
||||
|
||||
let hash = hasher.finalize();
|
||||
let hash_string = faster_hex::hex_string(&hash[..]);
|
||||
|
||||
debug!("Expected checksum: {}", expected);
|
||||
debug!("Actual checksum: {}", hash_string);
|
||||
|
||||
if hash_string != expected {
|
||||
return Err(SelfUpdateError::InvalidChecksum);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn verify_signature(file: &str) -> Result<(), VerifySignatureError> {
|
||||
let file_osstr = U16CString::from_str_truncate(file);
|
||||
let mut verification_type = WINTRUST_ACTION_GENERIC_VERIFY_V2;
|
||||
let mut wintrust_data_buf = vec![0u8; mem::size_of::<WINTRUST_DATA>()];
|
||||
let mut fileinfo_buf = vec![0u8; mem::size_of::<WINTRUST_FILE_INFO>()];
|
||||
|
||||
unsafe {
|
||||
let fileinfo = fileinfo_buf.as_mut_ptr() as *mut WINTRUST_FILE_INFO;
|
||||
let wintrust_data = wintrust_data_buf.as_mut_ptr() as *mut WINTRUST_DATA;
|
||||
|
||||
(*fileinfo).cbStruct = mem::size_of::<WINTRUST_FILE_INFO>() as u32;
|
||||
(*fileinfo).pcwszFilePath = file_osstr.as_ptr();
|
||||
(*fileinfo).hFile = ptr::null_mut();
|
||||
(*fileinfo).pgKnownSubject = ptr::null_mut();
|
||||
|
||||
(*wintrust_data).pPolicyCallbackData = ptr::null_mut();
|
||||
(*wintrust_data).pSIPClientData = ptr::null_mut();
|
||||
(*wintrust_data).cbStruct = mem::size_of::<WINTRUST_DATA>() as u32;
|
||||
(*wintrust_data).dwStateAction = WTD_STATEACTION_VERIFY;
|
||||
(*wintrust_data).dwUIChoice = WTD_UI_NONE;
|
||||
(*wintrust_data).fdwRevocationChecks = WTD_REVOKE_NONE;
|
||||
(*wintrust_data).dwUnionChoice = WTD_CHOICE_FILE;
|
||||
(*wintrust_data).hWVTStateData = ptr::null_mut();
|
||||
(*wintrust_data).pwszURLReference = ptr::null_mut();
|
||||
(*wintrust_data).dwUIContext = 0;
|
||||
*(*wintrust_data).u.pFile_mut() = fileinfo;
|
||||
|
||||
let status = WinVerifyTrust(
|
||||
ptr::null_mut(),
|
||||
&mut verification_type,
|
||||
wintrust_data as *mut _,
|
||||
);
|
||||
|
||||
match status {
|
||||
0 | CERT_E_UNTRUSTEDROOT | CERT_E_EXPIRED | CERT_E_CHAINING => Ok(()),
|
||||
CRYPT_E_SECURITY_SETTINGS => Err(VerifySignatureError::VerificationDisabledByPolicy),
|
||||
TRUST_E_NOSIGNATURE => Err(VerifySignatureError::NoSignature),
|
||||
TRUST_E_EXPLICIT_DISTRUST => Err(VerifySignatureError::ExplicitlyDistrusted),
|
||||
_ => Err(VerifySignatureError::Unknown { errno: status }),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn get_signature_pubkey(file: &str) -> Result<Vec<u8>, GetSignaturePubkeyError> {
|
||||
debug!("Getting public key of {file}.");
|
||||
|
||||
let file_osstr = U16CString::from_str_truncate(file);
|
||||
let mut cert_store: HCERTSTORE = ptr::null_mut();
|
||||
let mut crypt_msg: HCRYPTMSG = ptr::null_mut();
|
||||
let result = unsafe {
|
||||
CryptQueryObject(
|
||||
CERT_QUERY_OBJECT_FILE,
|
||||
file_osstr.as_ptr() as *const _,
|
||||
CERT_QUERY_CONTENT_FLAG_PKCS7_SIGNED_EMBED,
|
||||
CERT_QUERY_FORMAT_FLAG_BINARY,
|
||||
0,
|
||||
ptr::null_mut(),
|
||||
ptr::null_mut(),
|
||||
ptr::null_mut(),
|
||||
&mut cert_store,
|
||||
&mut crypt_msg,
|
||||
ptr::null_mut(),
|
||||
)
|
||||
};
|
||||
|
||||
if result == 0 {
|
||||
return Err(GetSignaturePubkeyError::QueryObjectError {
|
||||
errno: unsafe { GetLastError() },
|
||||
});
|
||||
}
|
||||
|
||||
let mut signer_info_length = 0;
|
||||
let result = unsafe {
|
||||
CryptMsgGetParam(
|
||||
crypt_msg,
|
||||
CMSG_SIGNER_INFO_PARAM,
|
||||
0,
|
||||
ptr::null_mut(),
|
||||
&mut signer_info_length,
|
||||
)
|
||||
};
|
||||
|
||||
if result == 0 {
|
||||
return Err(GetSignaturePubkeyError::SignerInfoSizeError {
|
||||
errno: unsafe { GetLastError() },
|
||||
});
|
||||
}
|
||||
|
||||
let signer_info =
|
||||
unsafe { LocalAlloc(LMEM_ZEROINIT, signer_info_length as usize) } as PCMSG_SIGNER_INFO;
|
||||
|
||||
if signer_info.is_null() {
|
||||
return Err(GetSignaturePubkeyError::SignerInfoAllocError {
|
||||
errno: unsafe { GetLastError() },
|
||||
});
|
||||
}
|
||||
|
||||
let result = unsafe {
|
||||
CryptMsgGetParam(
|
||||
crypt_msg,
|
||||
CMSG_SIGNER_INFO_PARAM,
|
||||
0,
|
||||
signer_info as *mut _,
|
||||
&mut signer_info_length,
|
||||
)
|
||||
};
|
||||
|
||||
if result == 0 {
|
||||
return Err(GetSignaturePubkeyError::SignerInfoObtainError {
|
||||
errno: unsafe { GetLastError() },
|
||||
});
|
||||
}
|
||||
|
||||
let mut cert_search_params_buf = vec![0u8; mem::size_of::<CERT_INFO>()];
|
||||
let cert_search_params = cert_search_params_buf.as_mut_ptr() as *mut CERT_INFO;
|
||||
|
||||
unsafe {
|
||||
(*cert_search_params).Issuer = (*signer_info).Issuer;
|
||||
(*cert_search_params).SerialNumber = (*signer_info).SerialNumber;
|
||||
}
|
||||
|
||||
let cert = unsafe {
|
||||
CertFindCertificateInStore(
|
||||
cert_store,
|
||||
X509_ASN_ENCODING | PKCS_7_ASN_ENCODING,
|
||||
0,
|
||||
CERT_FIND_SUBJECT_CERT,
|
||||
cert_search_params as *const _,
|
||||
ptr::null(),
|
||||
)
|
||||
};
|
||||
|
||||
if cert.is_null() {
|
||||
return Err(GetSignaturePubkeyError::CertificateInStoreError {
|
||||
errno: unsafe { GetLastError() },
|
||||
});
|
||||
}
|
||||
|
||||
unsafe {
|
||||
let cert_info = (*cert).pCertInfo;
|
||||
let cbb = (*cert_info).SubjectPublicKeyInfo.PublicKey;
|
||||
let public_key_length = cbb.cbData;
|
||||
let public_key = cbb.pbData;
|
||||
let mut public_key =
|
||||
std::slice::from_raw_parts(public_key as *const _, public_key_length as _);
|
||||
let mut pubkey_vec = Vec::with_capacity(public_key_length as _);
|
||||
|
||||
public_key
|
||||
.read_to_end(&mut pubkey_vec)
|
||||
.context(ReadPubkeySnafu)?;
|
||||
|
||||
CertCloseStore(cert_store, 0);
|
||||
CryptMsgClose(crypt_msg);
|
||||
|
||||
Ok(pubkey_vec)
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user