fix: 完善整合包同步与启动器交互
Some checks failed
Axolotl desktop CI / guardrails (push) Has been cancelled
Repository checks / typos (push) Has been cancelled
Repository checks / tombi (push) Has been cancelled
Rust checks / shear (push) Has been cancelled
Sync source to CNB / Sync Git ref (push) Has been cancelled
Axolotl desktop CI / desktop (macos-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (ubuntu-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (windows-latest) (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
Sync LobeHub models / sync (push) Has been cancelled

This commit is contained in:
2026-09-16 17:01:22 +08:00
parent bd09fed0ed
commit 51fb0c30f7
45 changed files with 1804 additions and 219 deletions

View File

@ -78,19 +78,32 @@ async fn run_with_extra_launch_args_inner(
let _hosted_guard =
crate::pack::hosted::prepare_launch(instance_id, offline_mode).await?;
let state = State::get().await?;
let launch_preparation_timeout =
let context =
crate::state::instances::commands::get_instance_launch_context(
instance_id,
&state.pool,
)
.await?
.await?;
let launch_preparation_timeout = context
.as_ref()
.and_then(|context| context.launch_overrides.launch_preparation_timeout)
.unwrap_or(DEFAULT_LAUNCH_PREPARATION_TIMEOUT)
.clamp(
MIN_LAUNCH_PREPARATION_TIMEOUT,
MAX_LAUNCH_PREPARATION_TIMEOUT,
);
let default_account = if offline_mode {
let saved_player = context
.as_ref()
.and_then(|context| context.launch_overrides.player.as_ref());
let default_account = if let Some(player) = saved_player {
if offline_mode
&& player.account_type
!= crate::state::MinecraftAccountType::Offline
{
return Err(crate::ErrorKind::InputError("当前实例绑定的是在线玩家,请恢复网络后启动,或在实例设置中手动切换玩家".into()).as_error());
}
Credentials::for_instance_player(player, &state.pool).await?
} else if offline_mode {
Credentials::get_offline_credential(&state.pool)
.await?
.ok_or_else(|| {

View File

@ -7,6 +7,7 @@ use std::time::Duration;
use uuid::Uuid;
use crate::State;
pub use crate::state::InstancePlayer;
pub use crate::state::YggdrasilLoginResult;
use crate::state::{
Credentials, MinecraftAccountType, MinecraftLoginFlow, MinecraftProfile,
@ -172,6 +173,72 @@ pub fn normalize_yggdrasil_api_root(api_root: &str) -> crate::Result<String> {
crate::state::normalize_api_root(api_root)
}
pub async fn get_instance_player(
instance_id: &str,
) -> crate::Result<Option<crate::state::InstancePlayer>> {
let state = State::get().await?;
let context =
crate::state::instances::commands::get_instance_launch_context(
instance_id,
&state.pool,
)
.await?
.ok_or_else(|| {
crate::ErrorKind::InputError("Unknown instance".into()).as_error()
})?;
Ok(context.launch_overrides.player)
}
pub async fn set_instance_player(
instance_id: &str,
player: crate::state::InstancePlayer,
) -> crate::Result<()> {
let state = State::get().await?;
let accounts = Credentials::get_all_without_refresh(&state.pool).await?;
let account = accounts.get(&player.id).ok_or_else(|| {
crate::ErrorKind::InputError("所选玩家未登录,请重新登录该账号".into())
.as_error()
})?;
if account.account_type != player.account_type
|| player.skin_site_user.as_ref().is_some_and(|user| {
account.yggdrasil.as_ref().is_none_or(|ygg| {
ygg.login != *user
|| ygg.api_root != "https://skin.starlight.cool/yggdrasil"
})
})
{
return Err(
crate::ErrorKind::InputError("玩家身份不匹配".into()).as_error()
);
}
drop(account);
crate::state::edit_instance(
instance_id,
crate::state::EditInstance {
launch_overrides: Some(
crate::state::InstanceLaunchOverridesPatch {
player: Some(player),
..Default::default()
},
),
..Default::default()
},
&state.pool,
)
.await?;
Ok(())
}
pub async fn login_skin_site_player(
token: &str,
player_id: uuid::Uuid,
user_id: &str,
) -> crate::Result<Credentials> {
let state = State::get().await?;
crate::state::login_skin_site_player(token, player_id, user_id, &state.pool)
.await
}
#[tracing::instrument]
pub async fn get_default_user(
offline_mode: bool,

View File

@ -1,6 +1,7 @@
//! Administrator-approved skin-site packs. Only content-addressed changed files cross the network.
mod progress;
mod tagged;
mod transport;
use crate::{
State,
state::{
@ -32,6 +33,7 @@ use tokio::{
const API: &str = "https://skin.starlight.cool/starlight/mod/packs";
const BINDING: &str = ".starlight-pack.json";
const JOURNAL: &str = ".starlight-pack-pending.json";
const MAX_HOSTED_CONCURRENT_FILES: usize = 8;
static GATES: LazyLock<dashmap::DashMap<String, Arc<Mutex<()>>>> =
LazyLock::new(dashmap::DashMap::new);
static SESSION: Mutex<Option<DownloadSession>> = Mutex::const_new(None);
@ -230,11 +232,42 @@ fn validate(files: &[PackFile]) -> crate::Result<()> {
Ok(())
}
/// Reject symlinks and junctions in every existing ancestor, including internal state files.
#[cfg(windows)]
fn filesystem_path(path: &Path) -> PathBuf {
use std::ffi::OsString;
use std::os::windows::ffi::{OsStrExt, OsStringExt};
if !path.is_absolute() {
return path.to_path_buf();
}
let path = path.as_os_str().encode_wide().collect::<Vec<_>>();
const BACKSLASH: u16 = b'\\' as u16;
const QUESTION_MARK: u16 = b'?' as u16;
if path.starts_with(&[BACKSLASH, BACKSLASH, QUESTION_MARK, BACKSLASH]) {
return PathBuf::from(OsString::from_wide(&path));
}
let mut extended = "\\\\?\\".encode_utf16().collect::<Vec<_>>();
if path.starts_with(&[BACKSLASH, BACKSLASH]) {
extended.extend("UNC\\".encode_utf16());
extended.extend_from_slice(&path[2..]);
} else {
extended.extend_from_slice(&path);
}
PathBuf::from(OsString::from_wide(&extended))
}
#[cfg(not(windows))]
fn filesystem_path(path: &Path) -> PathBuf {
path.to_path_buf()
}
fn target(root: &Path, relative: &str) -> crate::Result<PathBuf> {
let mut path = root.to_path_buf();
for part in relative.split('/') {
path.push(part);
match std::fs::symlink_metadata(&path) {
match std::fs::symlink_metadata(filesystem_path(&path)) {
Ok(meta) => {
#[cfg(windows)]
{
@ -250,21 +283,32 @@ fn target(root: &Path, relative: &str) -> crate::Result<PathBuf> {
}
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => (),
Err(e) => return Err(e.into()),
Err(e) => {
return Err(
crate::util::io::IOError::with_path(e, &path).into()
);
}
}
}
Ok(path)
// Windows' legacy Win32 path parser reports ERROR_PATH_NOT_FOUND once a
// perfectly valid pack path exceeds MAX_PATH. Extended-length paths keep
// deep KubeJS/data-pack trees addressable without changing their layout.
Ok(filesystem_path(&path))
}
async fn hash(path: &Path) -> crate::Result<Option<String>> {
let mut input = match tokio::fs::File::open(path).await {
Ok(f) => f,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(e) => return Err(e.into()),
Err(e) => {
return Err(crate::util::io::IOError::with_path(e, path).into());
}
};
let mut digest = Sha256::new();
let mut buffer = vec![0; 64 * 1024];
loop {
let n = input.read(&mut buffer).await?;
let n = input.read(&mut buffer).await.map_err(|error| {
crate::util::io::IOError::with_path(error, path)
})?;
if n == 0 {
break;
}
@ -276,10 +320,11 @@ async fn read_json<T: serde::de::DeserializeOwned>(
root: &Path,
name: &str,
) -> crate::Result<Option<T>> {
match tokio::fs::read(target(root, name)?).await {
let path = target(root, name)?;
match tokio::fs::read(&path).await {
Ok(bytes) => Ok(Some(serde_json::from_slice(&bytes)?)),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(e.into()),
Err(e) => Err(crate::util::io::IOError::with_path(e, &path).into()),
}
}
fn write_json<T: Serialize>(
@ -289,10 +334,31 @@ fn write_json<T: Serialize>(
) -> crate::Result<()> {
use std::io::Write;
let dest = target(root, name)?;
let mut file = tempfile::NamedTempFile::new_in(root)?;
file.write_all(&serde_json::to_vec(value)?)?;
file.as_file().sync_all()?;
file.persist(dest).map_err(|e| e.error)?;
let mut file = tempfile::NamedTempFile::new_in(root).map_err(|error| {
crate::Error::from(crate::util::io::IOError::with_path(error, root))
.with_context(format!("创建 {name} 的临时文件失败"))
})?;
let temporary = file.path().to_path_buf();
file.write_all(&serde_json::to_vec(value)?)
.map_err(|error| {
crate::Error::from(crate::util::io::IOError::with_path(
error, &temporary,
))
.with_context(format!("写入 {name} 的临时文件失败"))
})?;
file.as_file().sync_all().map_err(|error| {
crate::Error::from(crate::util::io::IOError::with_path(
error, &temporary,
))
.with_context(format!("同步 {name} 的临时文件失败"))
})?;
file.persist(&dest).map_err(|error| {
crate::Error::from(error.error).with_context(format!(
"提交 {name} 失败;临时文件:{};目标文件:{}",
temporary.display(),
dest.display()
))
})?;
Ok(())
}
async fn authorization() -> crate::Result<String> {
@ -330,18 +396,22 @@ async fn request_authorized<T: serde::de::DeserializeOwned>(
auth: &str,
) -> crate::Result<T> {
ensure_session(auth).await?;
let response = configured_client()
.await?
.get(format!("{API}{suffix}"))
.header(reqwest::header::AUTHORIZATION, auth)
.header(reqwest::header::CACHE_CONTROL, "no-cache")
.timeout(std::time::Duration::from_secs(30))
.send()
.await?;
if matches!(response.status().as_u16(), 401 | 403) {
return Err(invalid(
"StarLight 登录凭据已失效或无权限,请重新登录后重试",
));
let client = configured_client().await?;
let response =
transport::metadata_request(&client, &format!("{API}{suffix}"), auth)
.await?;
if !response.status().is_success() {
let message = transport::response_failure(
&client,
response,
"https://skin.starlight.cool/starlight/user",
suffix,
auth,
)
.await;
ensure_session(auth).await?;
tracing::warn!("{message}");
return Err(invalid(message));
}
let data: Response<T> = response.error_for_status()?.json().await?;
ensure_session(auth).await?;
@ -433,7 +503,13 @@ async fn apply_files(
) -> crate::Result<()> {
for action in actions {
let live = target(root, &action.path)?;
if hash(&live).await? != action.old_hash {
if hash(&live).await.map_err(|error| {
error.with_context(format!(
"检查整合包目标文件失败:{}",
live.display()
))
})? != action.old_hash
{
return Err(invalid(format!(
"File changed during sync: {}",
action.path
@ -442,16 +518,82 @@ async fn apply_files(
if action.old_hash.is_some() {
let backup =
target(root, &format!("{}/{}", backup_dir, action.path))?;
tokio::fs::create_dir_all(backup.parent().unwrap()).await?;
tokio::fs::rename(&live, backup).await?;
let backup_parent = backup.parent().unwrap();
tokio::fs::create_dir_all(backup_parent).await.map_err(
|error| {
crate::Error::from(crate::util::io::IOError::with_path(
error,
backup_parent,
))
.with_context(format!(
"创建整合包备份目录失败:{}",
backup_parent.display()
))
},
)?;
tokio::fs::rename(&live, &backup).await.map_err(|error| {
crate::Error::from(error).with_context(format!(
"备份整合包文件失败;源文件:{};目标文件:{}",
live.display(),
backup.display()
))
})?;
}
if let Some(next) = &action.next_hash {
tokio::fs::create_dir_all(live.parent().unwrap()).await?;
let staged =
tempfile::NamedTempFile::new_in(live.parent().unwrap())?;
tokio::fs::copy(target(cache, next)?, staged.path()).await?;
staged.as_file().sync_all()?;
staged.persist(&live).map_err(|e| e.error)?;
let live_parent = live.parent().unwrap();
tokio::fs::create_dir_all(live_parent)
.await
.map_err(|error| {
crate::Error::from(crate::util::io::IOError::with_path(
error,
live_parent,
))
.with_context(format!(
"创建整合包目标目录失败:{}",
live_parent.display()
))
})?;
let staged = tempfile::NamedTempFile::new_in(live_parent).map_err(
|error| {
crate::Error::from(crate::util::io::IOError::with_path(
error,
live_parent,
))
.with_context(format!(
"创建整合包临时文件失败:{}",
live.display()
))
},
)?;
let staged_path = staged.path().to_path_buf();
let cache_object = target(cache, next)?;
tokio::fs::copy(&cache_object, &staged_path)
.await
.map_err(|error| {
crate::Error::from(error).with_context(format!(
"复制整合包缓存文件失败;缓存文件:{};临时文件:{};目标文件:{}",
cache_object.display(),
staged_path.display(),
live.display()
))
})?;
staged.as_file().sync_all().map_err(|error| {
crate::Error::from(crate::util::io::IOError::with_path(
error,
&staged_path,
))
.with_context(format!(
"同步整合包临时文件失败:{}",
staged_path.display()
))
})?;
staged.persist(&live).map_err(|error| {
crate::Error::from(error.error).with_context(format!(
"提交整合包文件失败;临时文件:{};目标文件:{}",
staged_path.display(),
live.display()
))
})?;
}
}
Ok(())
@ -975,7 +1117,7 @@ async fn synchronize_with_progress(
let concurrency = crate::api::settings::get()
.await?
.effective_max_concurrent_downloads()
.clamp(1, 16);
.clamp(1, MAX_HOSTED_CONCURRENT_FILES);
let current = std::sync::Mutex::new(BTreeMap::<String, u64>::new());
let completed = AtomicUsize::new(0);
progress.download(
@ -1038,6 +1180,10 @@ async fn synchronize_with_progress(
};
let mut request =
DownloadRequest::new(&url, ResourceClass::Modpack)
// The batch already downloads several files in
// parallel. Do not let an HTTP/1 fallback multiply
// every large file into another four connections.
.with_http1_segmented_download(false)
.with_integrity(Integrity {
size: Some(file_size),
sha256: Some(file.sha256),
@ -1047,14 +1193,34 @@ async fn synchronize_with_progress(
request = request
.with_header("Authorization", auth.clone());
}
download_to_path(
request,
let first_result = download_to_path(
request.clone(),
&object,
&state.fetch_semaphore,
&state.pool,
Some(&mut on_progress),
)
.await?;
.await;
if let Err(error) = first_result {
tracing::warn!(
path = %file_path,
%error,
"Parallel StarLight file download failed; retrying after the batch load has eased"
);
ensure_session(auth).await?;
tokio::time::sleep(std::time::Duration::from_millis(
750,
))
.await;
download_to_path(
request,
&object,
&state.fetch_semaphore,
&state.pool,
Some(&mut on_progress),
)
.await?;
}
let completed_files =
completed.fetch_add(1, Ordering::Relaxed) + 1;
let completed_bytes = {
@ -1158,12 +1324,33 @@ async fn synchronize_with_progress(
}
ensure_session(&auth).await?;
progress.update(0, 0, "正在应用更新", true);
apply_files(&root, &cache, &journal.backup, &journal.actions).await?;
apply_files(&root, &cache, &journal.backup, &journal.actions)
.await
.map_err(|error| error.with_context(format!(
"应用 StarLight 整合包文件失败;实例目录:{}",
root.display()
)))?;
progress.update(0, 0, "正在完成安装", true);
write_json(&root, BINDING, &Binding { publication: publication.clone(), files, sync_marker: Some(sync_marker), resolved_external })?;
write_json(&root, BINDING, &Binding { publication: publication.clone(), files, sync_marker: Some(sync_marker), resolved_external })
.map_err(|error| error.with_context(format!(
"写入 StarLight 整合包绑定信息失败;实例目录:{}",
root.display()
)))?;
crate::instance::edit(instance_id, EditInstance { install_stage: Some(InstanceInstallStage::Installed), ..Default::default() }).await?;
crate::instance::sync_content_files(instance_id).await?;
tokio::fs::remove_file(target(&root, JOURNAL)?).await?;
crate::instance::sync_content_files(instance_id).await.map_err(|error| {
error.with_context(format!(
"扫描 StarLight 整合包内容失败;实例目录:{}",
root.display()
))
})?;
let journal_path = target(&root, JOURNAL)?;
tokio::fs::remove_file(&journal_path).await.map_err(|error| {
crate::Error::from(crate::util::io::IOError::with_path(
error,
&journal_path,
))
.with_context("清理 StarLight 整合包安装日志失败")
})?;
Ok::<_, crate::Error>(())
}.await;
if let Err(error) = result {

View File

@ -184,6 +184,41 @@ mod tests {
assert!(root.path().join("mods/personal.jar").is_file());
}
#[tokio::test]
async fn identical_tagged_mod_keeps_the_modpack_file() {
let root = tempfile::tempdir().unwrap();
let original = PackFile {
mod_ids: vec![],
external: None,
path: "mods/example-from-pack.jar".into(),
sha256: "a".repeat(64),
size: 10,
force: true,
preserve: false,
};
let mut files = vec![original.clone()];
let mut sources = BTreeMap::from([(
original.path.clone(),
"https://example.invalid/pack-file".into(),
)]);
let mut tagged = manifest();
tagged.replaces.push(original.path.clone());
let actions = merge(
root.path(),
&root.path().join("cache"),
&mut files,
&mut sources,
&tagged,
)
.await
.unwrap();
assert!(actions.is_empty());
assert_eq!(files, vec![original]);
assert!(!sources.contains_key("mods/example.starlight.jar"));
}
#[test]
fn multi_mod_jar_cannot_be_partially_replaced() {
assert!(is_managed_file(&manifest().files[0].pack_file()));
@ -266,10 +301,33 @@ pub(super) async fn merge(
if ids.is_empty() {
return Ok(Vec::new());
}
let mut replaced: HashSet<String> =
manifest.replaces.iter().cloned().collect();
// The modpack is the installation baseline. A tagged snapshot with the
// same content already passes verification, even when the skin site uses
// a canonical filename. Keep the pack's file and only replace it when the
// tagged JAR is actually different.
let satisfied_pack_paths: HashSet<String> = manifest
.files
.iter()
.filter_map(|tagged| {
files
.iter()
.find(|file| {
is_mod_path(&file.path) && file.sha256 == tagged.sha256
})
.map(|file| file.path.clone())
})
.collect();
let mut replaced: HashSet<String> = manifest
.replaces
.iter()
.filter(|path| !satisfied_pack_paths.contains(*path))
.cloned()
.collect();
for file in files.iter().filter(|file| is_mod_path(&file.path)) {
if replaced.contains(&file.path) {
if satisfied_pack_paths.contains(&file.path)
|| replaced.contains(&file.path)
{
continue;
}
let object = target(cache, &file.sha256)?;
@ -284,6 +342,11 @@ pub(super) async fn merge(
}
files.retain(|file| !replaced.contains(&file.path));
for file in &manifest.files {
if files.iter().any(|existing| {
is_mod_path(&existing.path) && existing.sha256 == file.sha256
}) {
continue;
}
if files
.iter()
.any(|existing| existing.path.eq_ignore_ascii_case(&file.path))
@ -299,6 +362,11 @@ pub(super) async fn merge(
);
files.push(file.pack_file());
}
let desired_paths: HashSet<_> = files
.iter()
.filter(|file| is_mod_path(&file.path))
.map(|file| file.path.to_ascii_lowercase())
.collect();
let mut duplicate_actions = Vec::new();
let mods_dir = target(root, "mods")?;
if mods_dir.is_dir() {
@ -309,7 +377,9 @@ pub(super) async fn merge(
continue;
};
let path = format!("mods/{name}");
if !is_mod_path(&path) || manifest.contains(&path) {
if !is_mod_path(&path)
|| desired_paths.contains(&path.to_ascii_lowercase())
{
continue;
}
let local = target(root, &path)?;

View File

@ -0,0 +1,228 @@
use reqwest::{Client, Response, header};
pub(super) async fn metadata_request(
client: &Client,
url: &str,
auth: &str,
) -> Result<Response, reqwest::Error> {
client
.get(url)
.query(&[("starlight_request", uuid::Uuid::new_v4().to_string())])
.header(header::AUTHORIZATION, auth)
.header(header::CACHE_CONTROL, "no-cache, no-store")
.header(header::PRAGMA, "no-cache")
.timeout(std::time::Duration::from_secs(30))
.send()
.await
}
async fn bounded_json(mut response: Response) -> Option<serde_json::Value> {
let mut bytes = Vec::new();
while let Some(chunk) = response.chunk().await.ok()? {
if bytes.len() + chunk.len() > 16_384 {
return None;
}
bytes.extend_from_slice(&chunk);
}
serde_json::from_slice(&bytes).ok()
}
pub(super) async fn response_failure(
client: &Client,
response: Response,
user_url: &str,
suffix: &str,
auth: &str,
) -> String {
let status = response.status().as_u16();
let trace = response
.headers()
.get("eo-log-uuid")
.and_then(|value| value.to_str().ok())
.filter(|value| {
!value.is_empty()
&& value.len() <= 128
&& value
.bytes()
.all(|c| c.is_ascii_alphanumeric() || c == b'-')
})
.map(|value| format!("CDN 请求编号 {value}"))
.unwrap_or_default();
let context = format!("(整合包 HTTP {status};接口 {suffix}{trace}");
if let Some(body) = bounded_json(response).await {
if let Some(message) = body["errorMessage"]
.as_str()
.filter(|s| !s.trim().is_empty())
{
let token = auth.strip_prefix("Bearer ").unwrap_or(auth);
let message = if token.is_empty() {
message.to_owned()
} else {
message.replace(token, "[凭据已隐藏]")
};
let message: String = message
.chars()
.filter(|c| !c.is_control())
.take(500)
.collect();
return format!("StarLight 服务端返回:{message}{context}");
}
}
if !matches!(status, 401 | 403) {
return format!("StarLight 服务端请求失败{context}");
}
let detail = match metadata_request(client, user_url, auth).await {
Ok(user_response) => {
let user_status = user_response.status().as_u16();
if user_status == 200 {
let valid_user =
bounded_json(user_response).await.is_some_and(|body| {
body["payload"]["uuid"]
.as_str()
.is_some_and(|id| !id.is_empty())
&& body["payload"]["username"].is_string()
});
if valid_user {
"皮肤站登录有效,但整合包接口返回错误,且没有提供具体原因。请用请求编号查询服务端或 CDN 日志".to_owned()
} else {
"整合包接口拒绝访问,用户接口返回内容异常,无法确认登录状态"
.to_owned()
}
} else if matches!(user_status, 401 | 403) {
format!(
"整合包和用户接口均拒绝登录凭据(用户接口 HTTP {user_status})。请重新登录;若仍失败,请检查服务端鉴权及 CDN 配置"
)
} else {
format!(
"整合包接口拒绝访问,暂时无法复核登录状态(用户接口 HTTP {user_status}"
)
}
}
Err(_) => "整合包接口拒绝访问,用户接口连接失败,暂时无法复核登录状态"
.to_owned(),
};
format!("{detail}{context}")
}
#[cfg(test)]
mod tests {
use super::*;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
#[tokio::test]
async fn hosted_auth_failure_verifies_same_credential_without_exposing_it()
{
for (pack_status, user_status, body, expected) in [
(
401,
200,
r#"{"payload":{"uuid":"owner","username":"Player"}}"#,
"皮肤站登录有效",
),
(403, 401, "{}", "均拒绝登录凭据"),
(401, 200, "<html>Proxy error</html>", "用户接口返回内容异常"),
] {
let listener =
tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let base = format!("http://{}", listener.local_addr().unwrap());
let server = tokio::spawn(async move {
let mut requests = Vec::new();
for (status, body) in [(pack_status, ""), (user_status, body)] {
let (mut socket, _) = listener.accept().await.unwrap();
let mut request = Vec::new();
while !request.ends_with(b"\r\n\r\n") {
request.push(socket.read_u8().await.unwrap());
}
requests.push(String::from_utf8(request).unwrap());
let response = format!(
"HTTP/1.1 {status} Test\r\nContent-Length: {}\r\nEO-LOG-UUID: trace-123\r\nConnection: close\r\n\r\n{body}",
body.len()
);
socket.write_all(response.as_bytes()).await.unwrap();
}
requests
});
let client = Client::builder().no_proxy().build().unwrap();
let auth = "Bearer test-secret-never-display";
let response =
metadata_request(&client, &format!("{base}/default"), auth)
.await
.unwrap();
let message = response_failure(
&client,
response,
&format!("{base}/user"),
"/default",
auth,
)
.await;
assert!(message.contains(expected), "{message}");
assert!(message.contains(&format!("整合包 HTTP {pack_status}")));
assert!(message.contains("trace-123"));
assert!(!message.contains("test-secret"));
let requests = server.await.unwrap();
for (request, path) in requests.iter().zip(["/default", "/user"]) {
assert!(
request
.starts_with(&format!("GET {path}?starlight_request="))
);
let headers = request.to_ascii_lowercase();
assert!(headers.contains(
"authorization: bearer test-secret-never-display\r\n"
));
assert!(
headers.contains("cache-control: no-cache, no-store\r\n")
);
}
}
}
#[tokio::test]
async fn hosted_business_errors_preserve_server_reason_without_login_probe()
{
for status in [400, 403, 409, 500] {
let listener =
tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let base = format!("http://{}", listener.local_addr().unwrap());
let server = tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
let mut request = Vec::new();
while !request.ends_with(b"\r\n\r\n") {
request.push(socket.read_u8().await.unwrap());
}
let body = r#"{"error":"IllegalState","errorMessage":"整合包 Mod 标签已删除,请管理员重新设置 test-secret-never-display"}"#;
let response = format!(
"HTTP/1.1 {status} Test\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
);
socket.write_all(response.as_bytes()).await.unwrap();
});
let client = Client::builder().no_proxy().build().unwrap();
let auth = "Bearer test-secret-never-display";
let response = metadata_request(
&client,
&format!("{base}/tagged-mods/2"),
auth,
)
.await
.unwrap();
let message = response_failure(
&client,
response,
&format!("{base}/user"),
"/tagged-mods/2",
auth,
)
.await;
assert!(
message.contains("整合包 Mod 标签已删除,请管理员重新设置"),
"{message}"
);
assert!(message.contains("/tagged-mods/2"));
assert!(message.contains(&format!("HTTP {status}")));
assert!(!message.contains("test-secret"));
assert!(!message.contains("请重新登录"));
server.await.unwrap();
}
}
}

View File

@ -142,6 +142,7 @@ pub(crate) async fn import_axolotl(
loader_version: Some(config.content_set.loader_version.clone()),
}),
launch_overrides: Some(InstanceLaunchOverridesPatch {
player: None,
instance_mode: Some(crate::state::InstanceMode::Local),
java_path: Some(config.launch_overrides.java_path.clone()),
extra_launch_args: Some(

View File

@ -1,7 +1,7 @@
pub(crate) mod archive_util;
pub mod detect;
pub mod import;
pub mod hosted;
pub mod import;
pub mod install_from;
pub(crate) mod install_hmcl;
pub(crate) mod install_mcbbs;