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
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:
@ -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(|| {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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)?;
|
||||
|
||||
228
packages/app-lib/src/api/pack/hosted/transport.rs
Normal file
228
packages/app-lib/src/api/pack/hosted/transport.rs
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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(
|
||||
|
||||
@ -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;
|
||||
|
||||
Reference in New Issue
Block a user