//! Administrator-approved skin-site packs. Only content-addressed changed files cross the network. mod progress; mod tagged; mod transport; use crate::{ State, state::{ AppliedContentSetPatch, Credentials, EditInstance, InstanceInstallStage, InstanceLaunchOverridesPatch, InstanceMode, MinecraftAccountType, ModLoader, }, util::fetch::{ DownloadRequest, Integrity, ResourceClass, configured_client, download_to_path, }, }; use futures::{FutureExt, StreamExt, stream}; use progress::PackProgress; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use std::{ collections::{BTreeMap, HashSet}, path::{Path, PathBuf}, sync::{ Arc, LazyLock, atomic::{AtomicUsize, Ordering}, }, }; use tokio::{ io::AsyncReadExt, sync::{Mutex, OwnedMutexGuard}, }; const API: &str = "https://download.starlight.cool:20443/starlight/mod/packs"; const STARLIGHT_YGGDRASIL_API_ROOT: &str = "https://skin.starlight.cool/yggdrasil"; const ACCOUNT_REQUIRED_MESSAGE: &str = "您需要登录正版或皮肤站账号后才能操作"; const BINDING: &str = ".starlight-pack.json"; const JOURNAL: &str = ".starlight-pack-pending.json"; const MAX_HOSTED_CONCURRENT_FILES: usize = 8; static GATES: LazyLock>>> = LazyLock::new(dashmap::DashMap::new); static SESSION: Mutex> = Mutex::const_new(None); struct DownloadSession { authorization: String, updated: std::time::Instant, } impl DownloadSession { fn new(token: String) -> crate::Result { if token.is_empty() || token.len() > 16_384 || token.bytes().any(|b| !b.is_ascii_graphic()) { return Err(invalid("StarLight 登录凭据无效,请重新登录")); } Ok(Self { authorization: format!("Bearer {token}"), updated: std::time::Instant::now(), }) } fn authorization(&self) -> crate::Result { if self.updated.elapsed() > std::time::Duration::from_secs(90) { return Err(invalid("StarLight 登录状态需要刷新,请重试")); } Ok(self.authorization.clone()) } } /// The embedded skin site's JWT is held in memory only; the server validates it. pub async fn set_session(token: Option) -> crate::Result<()> { let mut session = SESSION.lock().await; *session = None; *session = token.map(DownloadSession::new).transpose()?; Ok(()) } fn instance_gate(instance_id: &str) -> Arc> { GATES.entry(instance_id.to_owned()).or_default().clone() } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct Runtime { pub game_version: String, pub loader: String, pub loader_version: Option, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct PackFile { #[serde(default)] pub mod_ids: Vec, #[serde(default)] pub external: Option, pub path: String, pub sha256: String, pub size: u64, #[serde(default)] pub force: bool, #[serde(default)] pub preserve: bool, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct External { pub project_id: u32, pub file_id: u32, } #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Manifest { pub schema_version: u32, pub name: String, pub version: String, pub format: String, pub runtime: Runtime, pub files: Vec, #[serde(default)] pub external: Vec, #[serde(default)] pub java_arguments: Vec, #[serde(default)] pub game_arguments: Vec, } #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Publication { pub pack_id: String, pub release_id: u64, pub manifest: Manifest, } #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Binding { pub publication: Publication, pub files: Vec, #[serde(default)] pub sync_marker: Option, #[serde(default)] pub resolved_external: Vec, } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct SyncState { pack_id: String, release_id: u64, marker: String, } fn marker_matches(previous: &Binding, state: &SyncState) -> bool { previous.sync_marker.as_deref() == Some(state.marker.as_str()) && state.marker == format!("{}:{}", state.pack_id, state.release_id) && previous.publication.pack_id == state.pack_id && previous.publication.release_id == state.release_id } #[derive(Deserialize)] struct Response { payload: T, } #[derive(Serialize)] #[serde(rename_all = "camelCase")] pub struct SyncResult { pub instance_id: String, pub version: String, pub downloaded_bytes: u64, pub changed_files: usize, pub preserved_files: Vec, } fn invalid(message: impl Into) -> crate::Error { crate::ErrorKind::InputError(message.into()).into() } fn safe_path(path: &str) -> crate::Result<()> { if path.is_empty() || path.len() > 1024 || path.contains('\\') || path.to_ascii_lowercase().starts_with(".starlight-") || path.split('/').any(|part| { let stem = part.split('.').next().unwrap_or("").to_ascii_lowercase(); part.is_empty() || part == "." || part == ".." || part.ends_with(['.', ' ']) || part .chars() .any(|c| c.is_control() || ":*?\"<>|".contains(c)) || matches!(stem.as_str(), "con" | "prn" | "aux" | "nul") || (stem.len() == 4 && (stem.starts_with("com") || stem.starts_with("lpt")) && matches!(stem.as_bytes()[3], b'1'..=b'9')) }) { return Err(invalid(format!("Unsafe modpack path: {path}"))); } Ok(()) } fn validate(files: &[PackFile]) -> crate::Result<()> { if files.iter().map(|f| u128::from(f.size)).sum::() > 16 * 1024 * 1024 * 1024 || files.len() > 100_000 { return Err(invalid("Modpack has too many files")); } let mut paths = HashSet::new(); for f in files { safe_path(&f.path)?; if f.sha256.len() != 64 || !f .sha256 .bytes() .all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase()) || f.size > 2 * 1024 * 1024 * 1024 || !paths.insert(f.path.to_lowercase()) { return Err(invalid( "Invalid hash, size or duplicate modpack path", )); } } for f in files { let mut p = Path::new(&f.path).parent(); while let Some(parent) = p { if paths.contains( &parent.to_string_lossy().replace('\\', "/").to_lowercase(), ) { return Err(invalid("Modpack file/directory conflict")); } p = parent.parent(); } } 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::>(); 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::>(); 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 { let mut path = root.to_path_buf(); for part in relative.split('/') { path.push(part); match std::fs::symlink_metadata(filesystem_path(&path)) { Ok(meta) => { #[cfg(windows)] { use std::os::windows::fs::MetadataExt; if meta.file_attributes() & 0x400 != 0 { return Err(invalid( "Modpack path is a junction or symlink", )); } } if meta.file_type().is_symlink() { return Err(invalid("Modpack path is a symlink")); } } Err(e) if e.kind() == std::io::ErrorKind::NotFound => (), Err(e) => { return Err( crate::util::io::IOError::with_path(e, &path).into() ); } } } // 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> { 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(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.map_err(|error| { crate::util::io::IOError::with_path(error, path) })?; if n == 0 { break; } digest.update(&buffer[..n]); } Ok(Some(format!("{:x}", digest.finalize()))) } async fn read_json( root: &Path, name: &str, ) -> crate::Result> { 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(crate::util::io::IOError::with_path(e, &path).into()), } } fn write_json( root: &Path, name: &str, value: &T, ) -> crate::Result<()> { use std::io::Write; let dest = target(root, name)?; 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 { SESSION .lock() .await .as_ref() .ok_or_else(|| { invalid("请先登录 StarLight 皮肤站,再下载整合包;无需选择玩家") })? .authorization() } async fn ensure_session(auth: &str) -> crate::Result<()> { if !SESSION .lock() .await .as_ref() .is_some_and(|session| session.authorization == auth) { return Err(invalid("StarLight 登录状态已变化,请重试整合包同步")); } Ok(()) } async fn request( suffix: &str, ) -> crate::Result { let auth = authorization().await?; request_authorized(suffix, &auth).await } async fn request_authorized( suffix: &str, auth: &str, ) -> crate::Result { ensure_session(auth).await?; 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 = response.error_for_status()?.json().await?; ensure_session(auth).await?; Ok(data.payload) } pub async fn default_publication() -> crate::Result { request::>("/default") .await? .ok_or_else(|| { invalid("管理员尚未指定已发布的默认整合包,请联系服务器管理员") }) } fn is_supported_game_account( account_type: MinecraftAccountType, yggdrasil_api_root: Option<&str>, ) -> bool { account_type == MinecraftAccountType::Microsoft || (account_type == MinecraftAccountType::Yggdrasil && yggdrasil_api_root.map(|value| value.trim_end_matches('/')) == Some(STARLIGHT_YGGDRASIL_API_ROOT)) } async fn has_game_account_in(state: &State) -> crate::Result { let accounts = Credentials::get_all_without_refresh(&state.pool).await?; Ok(accounts.iter().any(|account| { let account = account.value(); is_supported_game_account( account.account_type, account .yggdrasil .as_ref() .map(|value| value.api_root.as_str()), ) })) } pub async fn has_game_account() -> crate::Result { let state = State::get().await?; has_game_account_in(&state).await } async fn require_game_account(state: &State) -> crate::Result<()> { if !has_game_account_in(state).await? { return Err(invalid(ACCOUNT_REQUIRED_MESSAGE)); } Ok(()) } pub async fn create(game_dir_root: Option) -> crate::Result { let state = State::get().await?; require_game_account(&state).await?; let publication = default_publication().await?; let runtime = &publication.manifest.runtime; // The pack's game files live in their own folder under the chosen root, // e.g. `/`. Avoid a `versions/` layout: that shape // is reserved for externally linked launcher instances and would make the // launcher expect a Minecraft version JSON beside the pack. let game_dir_override = match game_dir_root .as_deref() .map(str::trim) .filter(|root| !root.is_empty()) { Some(root) => { // The pack's game files live in their own folder under the chosen // root, e.g. `/`. If that folder already exists // (a previous install of the same pack, or a name clash), pick a // suffixed sibling instead of sharing the folder with another // instance. let base = Path::new(root).join(&publication.manifest.name); let resolved = unique_game_dir(&base); Some(resolved.to_string_lossy().into_owned()) } None => None, }; let instance = crate::state::create_instance( crate::state::CreateInstance { name: publication.manifest.name.clone(), path: None, game_version: runtime.game_version.clone(), loader: ModLoader::try_from_string(&runtime.loader)?, loader_version: runtime.loader_version.clone(), icon_path: None, link: crate::state::InstanceLink::Unmanaged, symlink_target: None, game_dir_override, }, &state, ) .await?; crate::instance::edit( &instance.id, EditInstance { launch_overrides: Some(InstanceLaunchOverridesPatch { instance_mode: Some(InstanceMode::StarLight), ..Default::default() }), ..Default::default() }, ) .await?; crate::event::emit::emit_instance( &instance.id, crate::event::InstancePayloadType::Created, ) .await?; Ok(instance.id) } /// Returns `base` when its directory does not exist yet; otherwise returns the /// first `base (n)` (n = 1, 2, …) whose directory is still free. Mirrors the /// instance-folder de-duplication in `create_instance::resolve_instance_path`, /// so re-installing the same hosted pack no longer makes two instances share a /// single game folder. fn unique_game_dir(base: &Path) -> PathBuf { if !base.exists() { return base.to_path_buf(); } let parent = base.parent().unwrap_or_else(|| Path::new("")); let name = base .file_name() .map(|n| n.to_string_lossy().into_owned()) .unwrap_or_else(|| "instance".to_string()); let mut which = 1u32; loop { let candidate = parent.join(format!("{name} ({which})")); if !candidate.exists() { return candidate; } which += 1; } } pub async fn binding(instance_id: &str) -> crate::Result> { read_json(&crate::instance::get_full_path(instance_id).await?, BINDING) .await } pub async fn java_arguments(instance_id: &str) -> crate::Result> { Ok(binding(instance_id) .await? .map(|b| b.publication.manifest.java_arguments) .unwrap_or_default()) } pub async fn game_arguments(instance_id: &str) -> crate::Result> { Ok(binding(instance_id) .await? .map(|b| b.publication.manifest.game_arguments) .unwrap_or_default()) } #[derive(Serialize, Deserialize)] struct Action { path: String, old_hash: Option, next_hash: Option, } #[derive(Serialize, Deserialize)] struct Journal { backup: String, actions: Vec, previous: Option, metadata: crate::state::InstanceMetadata, } async fn apply_files( root: &Path, cache: &Path, backup_dir: &str, actions: &[Action], ) -> crate::Result<()> { for action in actions { let live = target(root, &action.path)?; 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 ))); } if action.old_hash.is_some() { let backup = target(root, &format!("{}/{}", backup_dir, action.path))?; 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 { 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(()) } async fn restore_files( root: &Path, backup_dir: &str, actions: &[Action], ) -> crate::Result<()> { // A durable journal is written before touching live files; it also recovers interrupted runs. if !backup_dir.starts_with(".starlight-pack-backup-") || backup_dir.contains(['/', '\\']) { return Err(invalid("Invalid modpack recovery directory")); } for action in actions.iter().rev() { safe_path(&action.path)?; let live = target(root, &action.path)?; let backup = target(root, &format!("{}/{}", backup_dir, action.path))?; if backup.is_file() { if live.exists() { tokio::fs::remove_file(&live).await?; } tokio::fs::create_dir_all(live.parent().unwrap()).await?; tokio::fs::rename(backup, live).await?; } else if action.old_hash.is_none() && action.next_hash.is_some() && hash(&live).await? == action.next_hash { tokio::fs::remove_file(live).await?; } } Ok(()) } async fn restore(root: &Path, journal: &Journal) -> crate::Result<()> { restore_files(root, &journal.backup, &journal.actions).await?; let state = State::get().await?; crate::state::instances::commands::restore_instance_metadata( &journal.metadata, &state.pool, ) .await?; if let Some(old) = &journal.previous { write_json(root, BINDING, old)?; } else if target(root, BINDING)?.exists() { tokio::fs::remove_file(target(root, BINDING)?).await?; } tokio::fs::remove_file(target(root, JOURNAL)?).await?; Ok(()) } async fn ensure_idle(instance_id: &str) -> crate::Result<()> { if !crate::process::get_by_instance_id(instance_id) .await? .is_empty() { return Err(invalid( "Close Minecraft before synchronizing its modpack", )); } if crate::install::list_jobs(false).await?.iter().any(|job| { job.instance_id.as_deref() == Some(instance_id) && matches!( job.status, crate::install::InstallJobStatus::Running | crate::install::InstallJobStatus::Canceling | crate::install::InstallJobStatus::Queued | crate::install::InstallJobStatus::WaitingForUser ) }) { return Err(invalid("An installation is already using this instance")); } Ok(()) } async fn recover(instance_id: &str) -> crate::Result<()> { let root = crate::instance::get_full_path(instance_id).await?; if let Some(journal) = read_json::(&root, JOURNAL).await? { if journal.metadata.instance.id != instance_id { return Err(invalid( "Recovery journal belongs to another instance", )); } ensure_idle(instance_id).await?; restore(&root, &journal).await?; } Ok(()) } pub async fn synchronize(instance_id: &str) -> crate::Result { let state = State::get().await?; require_game_account(&state).await?; let _guard = instance_gate(instance_id).lock_owned().await; if instance_mode(instance_id).await? != InstanceMode::StarLight { return Err(invalid( "请先将实例类型设为 StarLight 实例,再同步官方整合包", )); } synchronize_locked(instance_id).await } fn effective_mode( explicit: Option, has_binding: bool, ) -> InstanceMode { explicit.unwrap_or(if has_binding { InstanceMode::StarLight } else { InstanceMode::Local }) } pub async fn instance_mode(instance_id: &str) -> crate::Result { let metadata = crate::instance::get(instance_id) .await? .ok_or_else(|| invalid("Unknown instance"))?; if let Some(mode) = metadata.launch_overrides.instance_mode { return Ok(mode); } Ok(effective_mode(None, binding(instance_id).await?.is_some())) } pub async fn set_instance_mode( instance_id: &str, mode: InstanceMode, ) -> crate::Result<()> { let _guard = instance_gate(instance_id).lock_owned().await; ensure_idle(instance_id).await?; recover(instance_id).await?; let metadata = crate::instance::get(instance_id) .await? .ok_or_else(|| invalid("Unknown instance"))?; if mode == InstanceMode::StarLight && (metadata.instance.linked_launcher.is_some() || metadata.instance.symlink_target.is_some() || !matches!( metadata.link, crate::state::InstanceLink::Unmanaged | crate::state::InstanceLink::ImportedModpack { .. } )) { return Err(invalid( "外部关联、共享目录或由第三方整合包平台管理的实例不能开启 StarLight 自动同步,请创建独立实例", )); } if mode == InstanceMode::StarLight { synchronize_locked(instance_id).await?; } crate::instance::edit( instance_id, EditInstance { launch_overrides: Some(InstanceLaunchOverridesPatch { instance_mode: Some(mode), ..Default::default() }), ..Default::default() }, ) .await?; Ok(()) } /// Held through process creation so a manual sync cannot race a launch. pub async fn prepare_launch( instance_id: &str, offline: bool, ) -> crate::Result> { let guard = instance_gate(instance_id).lock_owned().await; recover(instance_id).await?; if instance_mode(instance_id).await? == InstanceMode::StarLight { if offline { return Err(invalid( "StarLight 实例必须登录并联网检查更新后才能启动;离线游玩请使用本地实例", )); } if crate::process::get_by_instance_id(instance_id) .await? .is_empty() { synchronize_locked(instance_id).await?; } else { check_running_pack(instance_id).await?; } } Ok(guard) } async fn check_running_pack(instance_id: &str) -> crate::Result<()> { let auth = authorization().await?; let root = crate::instance::get_full_path(instance_id).await?; let previous: Binding = read_json(&root, BINDING) .await? .ok_or_else(|| invalid("请先关闭该实例的所有游戏窗口,再同步整合包"))?; let remote = request_authorized::>("/sync-state", &auth) .await? .ok_or_else(|| { invalid("管理员尚未指定已发布的默认整合包,请联系服务器管理员") })?; if !marker_matches(&previous, &remote) { return Err(invalid( "整合包有更新,请先关闭该实例的所有游戏窗口,更新完成后再多开", )); } let tagged = request_authorized::( &format!("/tagged-mods/{}", previous.publication.release_id), &auth, ) .await?; tagged.validate()?; if !running_pack_matches(&root, &previous, &tagged).await? { return Err(invalid( "整合包 Mod 有变更,请先关闭该实例的所有游戏窗口,更新完成后再多开", )); } ensure_session(&auth).await } async fn running_pack_matches( root: &Path, previous: &Binding, tagged: &tagged::TaggedManifest, ) -> crate::Result { let mut desired = previous.publication.manifest.files.clone(); desired.extend(previous.resolved_external.iter().cloned()); let duplicates = tagged::merge( root, &target(root, ".starlight-pack-cache")?, &mut desired, &mut BTreeMap::new(), tagged, ) .await?; validate(&desired)?; let inventory = |files: &[PackFile]| { files .iter() .map(|file| (file.path.clone(), file.sha256.clone())) .collect::>() }; if !duplicates.is_empty() || inventory(&desired) != inventory(&previous.files) { return Ok(false); } for file in desired.iter().filter(|file| tagged.includes_content(file)) { if hash(&target(root, &file.path)?).await?.as_deref() != Some(&file.sha256) { return Ok(false); } } Ok(true) } async fn synchronize_locked(instance_id: &str) -> crate::Result { ensure_idle(instance_id).await?; recover(instance_id).await?; let metadata = crate::instance::get(instance_id) .await? .ok_or_else(|| invalid("Unknown instance"))?; let progress = PackProgress::new(instance_id, &metadata.instance.name).await?; let result = synchronize_with_progress(instance_id, metadata, &progress).await; if let Err(error) = &result { progress.fail(error); } result } async fn synchronize_with_progress( instance_id: &str, metadata: crate::state::InstanceMetadata, progress: &PackProgress, ) -> crate::Result { if metadata.instance.linked_launcher.is_some() || metadata.instance.symlink_target.is_some() || !matches!( metadata.link, crate::state::InstanceLink::Unmanaged | crate::state::InstanceLink::ImportedModpack { .. } ) { return Err(invalid( "Hosted modpacks require a launcher-managed instance", )); } let root = crate::instance::get_full_path(instance_id).await?; let previous: Option = read_json(&root, BINDING).await?; let auth = authorization().await?; let sync_state = request_authorized::>("/sync-state", &auth) .await? .ok_or_else(|| { invalid("管理员尚未指定已发布的默认整合包,请联系服务器管理员") })?; let pack_unchanged = previous .as_ref() .is_some_and(|binding| marker_matches(binding, &sync_state)) && metadata.instance.install_stage == InstanceInstallStage::Installed; let publication = if pack_unchanged { progress.update(0, 0, "整合包已是最新,正在检查标签 Mod", true); previous.as_ref().unwrap().publication.clone() } else { request_authorized::>("/default", &auth) .await? .ok_or_else(|| { invalid("管理员尚未指定已发布的默认整合包,请联系服务器管理员") })? }; let sync_marker = format!("{}:{}", publication.pack_id, publication.release_id); uuid::Uuid::parse_str(&publication.pack_id) .map_err(|_| invalid("Invalid modpack ID"))?; if publication.manifest.schema_version != 1 { return Err(invalid("Invalid modpack publication")); } if previous.as_ref().is_some_and(|b| { b.publication.pack_id == publication.pack_id && b.publication.release_id > publication.release_id }) { return Err(invalid("Server returned an older modpack publication")); } let manifest = &publication.manifest; let loader = ModLoader::try_from_string(&manifest.runtime.loader)?; let resolved_loader = if pack_unchanged { metadata.applied_content_set.loader_version.clone() } else if loader == ModLoader::Vanilla { None } else { Some(crate::launcher::get_loader_version_from_profile(&manifest.runtime.game_version, loader, manifest.runtime.loader_version.as_deref()).await?.ok_or_else(|| invalid("Modpack loader version is unavailable for this Minecraft version"))?.id) }; let runtime_changed = metadata.applied_content_set.game_version != manifest.runtime.game_version || metadata.applied_content_set.loader != loader || metadata.applied_content_set.loader_version != resolved_loader; let cache = target(&root, ".starlight-pack-cache")?; tokio::fs::create_dir_all(&cache).await?; let state = State::get().await?; let mut files = manifest.files.clone(); let tagged_manifest = request_authorized::( &format!("/tagged-mods/{}", publication.release_id), &auth, ) .await?; tagged_manifest.validate()?; let mut resolved_external = Vec::new(); if pack_unchanged { resolved_external = previous.as_ref().unwrap().resolved_external.clone(); files.extend(resolved_external.iter().cloned()); } validate(&files)?; let mut downloaded = 0; let mut sources = BTreeMap::new(); for f in &files { sources.insert( f.path.clone(), format!("{API}/files/{}/{}", publication.release_id, f.sha256), ); } // Resolve CurseForge references using the launcher's existing API and integrity checks. for (external_index, external) in manifest.external.iter().enumerate() { if pack_unchanged { break; } progress.update( 0, 0, &format!( "正在解析外部模组 {}/{}", external_index + 1, manifest.external.len() ), true, ); let cf = crate::api::curseforge::get_file( external.project_id, external.file_id, ) .await?; if cf.mod_id != external.project_id || cf.id != external.file_id { return Err(invalid("CurseForge returned a mismatched file")); } safe_path(&cf.file_name)?; if cf.file_name.contains('/') { return Err(invalid("Invalid CurseForge filename")); } let path = format!("mods/{}", cf.file_name); if files.iter().any(|f| f.path.eq_ignore_ascii_case(&path)) { continue; } // overrides take precedence let sha1 = cf .hashes .iter() .find(|h| h.algo == 1) .map(|h| h.value.clone()) .ok_or_else(|| invalid("CurseForge file has no SHA-1"))?; let staged = target( &cache, &format!("cf-{}-{}", external.project_id, external.file_id), )?; let local = target(&root, &path)?; if !staged.exists() && local.is_file() && crate::util::fetch::sha1_file_async(&local).await? == (cf.file_length, sha1.clone()) { tokio::fs::copy(&local, &staged).await?; } if !staged.exists() { let url = match cf.download_url { Some(url) => url, None => crate::api::curseforge::get_download_url( external.project_id, external.file_id, ) .await? .ok_or_else(|| { invalid("This CurseForge file requires a manual download") })?, }; let label = format!( "外部模组 {}/{} · {}", external_index + 1, manifest.external.len(), cf.file_name ); let mut transferred = 0; let mut on_progress = |received: u64, _total: u64| { transferred = received.min(cf.file_length); progress.download(transferred, cf.file_length, &label, false); futures::future::ready(Ok(())).boxed() }; progress.download(0, cf.file_length, &label, true); download_to_path( DownloadRequest::new(url, ResourceClass::CurseForge) .with_integrity( Integrity::sha1(sha1.clone()).with_size(cf.file_length), ), &staged, &state.fetch_semaphore, &state.pool, Some(&mut on_progress), ) .await?; progress.download(cf.file_length, cf.file_length, &label, true); downloaded += cf.file_length; } else if crate::util::fetch::sha1_file_async(&staged).await? != (cf.file_length, sha1) { return Err(invalid("Cached CurseForge file failed verification")); } let sha256 = hash(&staged) .await? .ok_or_else(|| invalid("CurseForge download is missing"))?; let object = target(&cache, &sha256)?; if !object.exists() { tokio::fs::copy(&staged, object).await?; } let resolved = PackFile { mod_ids: tagged::mod_ids(target(&cache, &sha256)?).await?, external: Some(external.clone()), path, sha256, size: cf.file_length, force: true, preserve: false, }; resolved_external.push(resolved.clone()); files.push(resolved); } tagged::merge(&root, &cache, &mut files, &mut sources, &tagged_manifest) .await?; validate(&files)?; if let Some(old) = &previous { validate(&old.files)?; } let old: BTreeMap<_, _> = previous .as_ref() .map(|b| b.files.iter().map(|f| (f.path.as_str(), f)).collect()) .unwrap_or_default(); let mut actions = Vec::new(); let mut pending_downloads = BTreeMap::new(); let mut preserved = Vec::new(); let next_paths: BTreeMap<_, _> = files .iter() .map(|f| (f.path.to_ascii_lowercase(), f.path.as_str())) .collect(); for prior in old.values() { if next_paths .get(&prior.path.to_ascii_lowercase()) .is_some_and(|next| **next != prior.path) { return Err(invalid( "Case-only filename changes require a new instance", )); } } for (index, f) in files.iter().enumerate() { if pack_unchanged && !tagged_manifest.contains(&f.path) && old .get(f.path.as_str()) .is_some_and(|prior| prior.sha256 == f.sha256) { continue; } progress.update( index as u64, files.len() as u64, &format!("正在检查文件 · {}", f.path), false, ); let live = target(&root, &f.path)?; let local = hash(&live).await?; if local.as_deref() == Some(&f.sha256) { continue; } let prior = old.get(f.path.as_str()); if local.is_some() && (f.preserve || (!f.force && prior .is_some_and(|p| local.as_deref() != Some(&p.sha256)))) { preserved.push(f.path.clone()); continue; } if previous.is_none() && local.is_some() { return Err(invalid(format!( "Existing file conflicts with the modpack: {}. Use an empty instance.", f.path ))); } let object = target(&cache, &f.sha256)?; if hash(&object).await?.as_deref() != Some(&f.sha256) { let url = sources .get(&f.path) .ok_or_else(|| invalid("Missing modpack download source"))?; pending_downloads .entry(f.sha256.clone()) .or_insert_with(|| (f.clone(), url.clone(), object)); } actions.push(Action { path: f.path.clone(), old_hash: local, next_hash: Some(f.sha256.clone()), }); } for prior in old.values() { if !next_paths.contains_key(&prior.path.to_ascii_lowercase()) { let local = hash(&target(&root, &prior.path)?).await?; if local.is_none() { continue; } if !tagged::is_managed_file(prior) && (prior.preserve || local.as_deref() != Some(&prior.sha256)) { preserved.push(prior.path.clone()); continue; } actions.push(Action { path: prior.path.clone(), old_hash: local, next_hash: None, }); } } let mut tagged_downloads = Vec::new(); pending_downloads.retain(|_, (file, url, object)| { if tagged_manifest.contains(&file.path) { tagged_downloads.push((file.clone(), url.clone(), object.clone())); false } else { true } }); let total_bytes = pending_downloads .values() .map(|(file, _, _)| file.size) .sum::(); let total_files = pending_downloads.len(); if total_files > 0 { let concurrency = crate::api::settings::get() .await? .effective_max_concurrent_downloads() .clamp(1, MAX_HOSTED_CONCURRENT_FILES); let current = std::sync::Mutex::new(BTreeMap::::new()); let completed = AtomicUsize::new(0); progress.download( 0, total_bytes, &format!("文件 0/{total_files}"), true, ); let results = stream::iter(pending_downloads.into_values().map( |(file, url, object)| { let current = ¤t; let completed = &completed; let state = &state; let auth = &auth; async move { ensure_session(auth).await?; let url = if let Some(external) = &file.external { match crate::api::curseforge::get_file( external.project_id, external.file_id, ) .await? .download_url { Some(url) => url, None => crate::api::curseforge::get_download_url( external.project_id, external.file_id, ) .await? .ok_or_else(|| { invalid( "This CurseForge file requires a manual download", ) })?, } } else { url }; let file_size = file.size; let file_path = file.path.clone(); let progress_key = file.path.clone(); let mut on_progress = |received: u64, _total: u64| { let mut bytes = current .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()); bytes.insert(progress_key.clone(), received.min(file_size)); progress.download( bytes.values().sum(), total_bytes, &format!( "文件 {}/{} · {}", completed.load(Ordering::Relaxed), total_files, file_path ), false, ); futures::future::ready(Ok(())).boxed() }; 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), ..Default::default() }); if url.starts_with(&format!("{API}/files/")) { request = request .with_header("Authorization", auth.clone()); } let first_result = download_to_path( request.clone(), &object, &state.fetch_semaphore, &state.pool, Some(&mut on_progress), ) .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 = { let mut bytes = current .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()); bytes.insert(progress_key, file_size); bytes.values().sum() }; progress.download( completed_bytes, total_bytes, &format!( "文件 {completed_files}/{total_files} · {file_path}" ), completed_files == total_files, ); Ok::<_, crate::Error>(file_size) } }, )) .buffer_unordered(concurrency) .collect::>() .await; downloaded += results.into_iter().try_fold(0, |sum, result| { Ok::(sum + result?) })?; } ensure_session(&auth).await?; downloaded += tagged::download( instance_id, &metadata.instance.name, tagged_downloads, &auth, progress, ) .await?; ensure_session(&auth).await?; let duplicate_mods = tagged::reconcile_staged( &root, &cache, &mut files, &mut sources, &tagged_manifest, &mut actions, ) .await?; for action in duplicate_mods { preserved.retain(|path| path != &action.path); if !actions.iter().any(|existing| existing.path == action.path) { actions.push(action); } } if actions.is_empty() && !runtime_changed && metadata.instance.install_stage == InstanceInstallStage::Installed && previous.as_ref().is_some_and(|b| { b.sync_marker.as_deref() == Some(sync_marker.as_str()) && b.files == files && b.resolved_external == resolved_external }) { return Ok(SyncResult { instance_id: instance_id.into(), version: manifest.version.clone(), downloaded_bytes: downloaded, changed_files: 0, preserved_files: preserved, }); } let journal = Journal { backup: format!(".starlight-pack-backup-{}", uuid::Uuid::new_v4()), actions, previous, metadata, }; write_json(&root, JOURNAL, &journal)?; let result = async { progress.update(0, 0, "正在安装游戏组件", true); // Stage all content before changing the game/runtime. The journal blocks launch until recovery. crate::instance::edit(instance_id, EditInstance { install_stage: Some(InstanceInstallStage::PackInstalling), content_set_patch: Some(AppliedContentSetPatch { game_version: Some(manifest.runtime.game_version.clone()), loader: Some(loader), loader_version: Some(resolved_loader.clone()), ..Default::default() }), ..Default::default() }).await?; if runtime_changed || journal.metadata.instance.install_stage != InstanceInstallStage::Installed { let mut job = crate::install::install_existing_instance(instance_id.to_string(), false).await?; loop { progress.install(&job); use crate::install::InstallJobStatus::*; match job.status { Succeeded => break, Queued | Running => { tokio::time::sleep(std::time::Duration::from_millis(250)).await; job = crate::install::get_job(job.job_id).await?; } status => { let details = job .error .as_ref() .or(job.rollback_error.as_ref()) .map(|error| error.message.as_str()) .unwrap_or(match status { WaitingForUser => "安装任务正在等待用户处理", Interrupted => "安装任务意外中断", Canceled | Canceling => "安装任务已取消", Failed => "安装任务失败", _ => "安装任务未能完成", }); if status == WaitingForUser { let _ = crate::install::cancel_job(job.job_id).await; } return Err(invalid(format!( "Minecraft 游戏组件安装失败:{details};请在“下载”中查看详情后重试" ))); } } } } ensure_session(&auth).await?; progress.update(0, 0, "正在应用更新", true); 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 }) .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.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 { progress.update(0, 0, "正在恢复安装前的文件", true); ensure_idle(instance_id).await.map_err(|busy| invalid(format!("Sync failed: {error}; recovery is pending until the active installation stops: {busy}")))?; restore(&root, &journal).await.map_err(|recovery| invalid(format!("Sync failed: {error}; recovery failed: {recovery}. Retry before launching.")))?; return Err(error); } Ok(SyncResult { instance_id: instance_id.to_string(), version: publication.manifest.version, downloaded_bytes: downloaded, changed_files: journal.actions.len(), preserved_files: preserved, }) } #[cfg(test)] mod tests { use super::*; #[test] fn hosted_operations_accept_only_microsoft_or_starlight_accounts() { assert!(is_supported_game_account( MinecraftAccountType::Microsoft, None )); assert!(is_supported_game_account( MinecraftAccountType::Yggdrasil, Some("https://skin.starlight.cool/yggdrasil/") )); assert!(!is_supported_game_account( MinecraftAccountType::Yggdrasil, Some("https://auth.example.com/yggdrasil") )); assert!(!is_supported_game_account( MinecraftAccountType::Offline, None )); } #[tokio::test] async fn running_pack_checks_tag_changes_without_writing_live_files() { let root = tempfile::tempdir().unwrap(); tokio::fs::create_dir(root.path().join("mods")) .await .unwrap(); let path = root.path().join("mods/example.starlight.jar"); tokio::fs::write(&path, b"installed").await.unwrap(); let sha = hash(&path).await.unwrap().unwrap(); let file = serde_json::json!({"path":"mods/example.starlight.jar", "sha256":sha, "size":9, "modIds":["example"], "force":true}); let previous: Binding = serde_json::from_value(serde_json::json!({ "publication":{"packId":"pack", "releaseId":1, "manifest":{ "schemaVersion":1, "name":"Pack", "version":"1", "format":"multimc", "runtime":{"gameVersion":"1.21.1", "loader":"vanilla"}, "files":[] }}, "files":[file], "syncMarker":"pack:1" })).unwrap(); let manifest = |sha: &str| { serde_json::from_value::(serde_json::json!({ "files":[{"modId":"example", "modIds":["example"], "path":"mods/example.starlight.jar", "sha256":sha, "size":9}], "replaces":[] })).unwrap() }; assert!( running_pack_matches(root.path(), &previous, &manifest(&sha)) .await .unwrap() ); assert!( !running_pack_matches( root.path(), &previous, &manifest(&"a".repeat(64)) ) .await .unwrap() ); let empty = serde_json::from_value( serde_json::json!({"files":[], "replaces":[]}), ) .unwrap(); assert!( !running_pack_matches(root.path(), &previous, &empty) .await .unwrap() ); assert_eq!(tokio::fs::read(&path).await.unwrap(), b"installed"); assert!(!root.path().join(JOURNAL).exists()); tokio::fs::write(&path, b"modified").await.unwrap(); assert!( !running_pack_matches(root.path(), &previous, &manifest(&sha)) .await .unwrap() ); } #[test] fn sync_marker_requires_an_installed_publication_and_changes_on_default_or_release() { let mut binding: Binding = serde_json::from_value(serde_json::json!({ "publication": {"packId":"pack", "releaseId":3, "manifest": { "schemaVersion":1, "name":"Pack", "version":"1", "format":"multimc", "runtime":{"gameVersion":"1.20.1", "loader":"vanilla", "loaderVersion":null}, "files":[] }}, "files":[] })).unwrap(); let mut state = SyncState { pack_id: "pack".into(), release_id: 3, marker: "pack:3".into(), }; assert!(!marker_matches(&binding, &state)); binding.sync_marker = Some("pack:3".into()); assert!(marker_matches(&binding, &state)); state.release_id = 4; assert!(!marker_matches(&binding, &state)); state.release_id = 3; state.pack_id = "other".into(); assert!(!marker_matches(&binding, &state)); state.pack_id = "pack".into(); state.marker = "pack:4".into(); assert!(!marker_matches(&binding, &state)); } #[test] fn download_session_uses_site_token_without_a_game_profile() { let mut session = DownloadSession::new("site.jwt.token".into()).unwrap(); assert_eq!(session.authorization().unwrap(), "Bearer site.jwt.token"); session.updated -= std::time::Duration::from_secs(91); assert!(session.authorization().is_err()); assert!(DownloadSession::new(String::new()).is_err()); assert!( DownloadSession::new("token\r\nInjected: header".into()).is_err() ); } #[tokio::test] async fn logout_and_invalid_replacement_clear_download_authorization() { set_session(Some("first.jwt.token".into())).await.unwrap(); assert_eq!(authorization().await.unwrap(), "Bearer first.jwt.token"); let old = authorization().await.unwrap(); set_session(None).await.unwrap(); assert!(authorization().await.is_err()); assert!(ensure_session(&old).await.is_err()); set_session(Some("second.jwt.token".into())).await.unwrap(); assert!(ensure_session(&old).await.is_err()); assert!(set_session(Some("bad token".into())).await.is_err()); assert!(authorization().await.is_err()); } #[tokio::test] async fn instance_operations_exclude_each_other_without_blocking_other_instances() { let first = instance_gate("mode-test-first"); let held = first.lock_owned().await; assert!(instance_gate("mode-test-first").try_lock_owned().is_err()); assert!(instance_gate("mode-test-second").try_lock_owned().is_ok()); drop(held); assert!(instance_gate("mode-test-first").try_lock_owned().is_ok()); } #[test] fn explicit_local_disables_legacy_binding_sync() { assert_eq!(effective_mode(None, false), InstanceMode::Local); assert_eq!(effective_mode(None, true), InstanceMode::StarLight); assert_eq!( effective_mode(Some(InstanceMode::Local), true), InstanceMode::Local ); assert_eq!( effective_mode(Some(InstanceMode::StarLight), false), InstanceMode::StarLight ); } #[tokio::test] async fn partial_update_recovers_modified_added_and_removed_files() { let directory = tempfile::tempdir().unwrap(); let root = directory.path(); let cache = root.join(".starlight-pack-cache"); std::fs::create_dir_all(&cache).unwrap(); std::fs::write(root.join("old.jar"), b"old").unwrap(); std::fs::write(root.join("removed.jar"), b"removed").unwrap(); std::fs::write(cache.join("next"), b"next").unwrap(); let next = hash(&cache.join("next")).await.unwrap().unwrap(); std::fs::rename(cache.join("next"), cache.join(&next)).unwrap(); let actions = vec![ Action { path: "old.jar".into(), old_hash: hash(&root.join("old.jar")).await.unwrap(), next_hash: Some(next.clone()), }, Action { path: "new.jar".into(), old_hash: None, next_hash: Some(next.clone()), }, Action { path: "removed.jar".into(), old_hash: hash(&root.join("removed.jar")).await.unwrap(), next_hash: None, }, Action { path: "missing.jar".into(), old_hash: None, next_hash: Some("0".repeat(64)), }, ]; let backup = ".starlight-pack-backup-test"; assert!(apply_files(root, &cache, backup, &actions).await.is_err()); assert_eq!(hash(&root.join("old.jar")).await.unwrap(), Some(next)); assert!(!root.join("removed.jar").exists()); restore_files(root, backup, &actions).await.unwrap(); assert_eq!(std::fs::read(root.join("old.jar")).unwrap(), b"old"); assert_eq!( std::fs::read(root.join("removed.jar")).unwrap(), b"removed" ); assert!(!root.join("new.jar").exists()); assert!(!root.join("missing.jar").exists()); restore_files(root, backup, &actions).await.unwrap(); } #[test] fn rejects_cross_platform_escape_paths() { for path in [ "../mods/a", "/mods/a", "C:/a", "mods\\a", "mods/NUL.jar", "mods/a.", "mods//a", ".starlight-pack.json", ] { assert!(safe_path(path).is_err(), "{path}"); } assert!(safe_path("mods/中文.jar").is_ok()); } #[test] fn rejects_case_and_ancestor_collisions() { let make = |p: &str| PackFile { mod_ids: Vec::new(), external: None, path: p.into(), sha256: "a".repeat(64), size: 1, force: true, preserve: false, }; assert!(validate(&[make("mods/a.jar"), make("mods/A.jar")]).is_err()); assert!(validate(&[make("config/a"), make("config/a/b")]).is_err()); } }