Files
Starlight_Lancher/packages/app-lib/src/api/pack/hosted.rs
T
AxTps e1aa1b47ec
Sync source to CNB / Sync Git ref (push) Skipped
feat: 整合包下载地址切换到独立下载域名
2026-10-18 07:24:19 +08:00

1768 lines
63 KiB
Rust

//! 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<dashmap::DashMap<String, Arc<Mutex<()>>>> =
LazyLock::new(dashmap::DashMap::new);
static SESSION: Mutex<Option<DownloadSession>> = Mutex::const_new(None);
struct DownloadSession {
authorization: String,
updated: std::time::Instant,
}
impl DownloadSession {
fn new(token: String) -> crate::Result<Self> {
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<String> {
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<String>) -> crate::Result<()> {
let mut session = SESSION.lock().await;
*session = None;
*session = token.map(DownloadSession::new).transpose()?;
Ok(())
}
fn instance_gate(instance_id: &str) -> Arc<Mutex<()>> {
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<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct PackFile {
#[serde(default)]
pub mod_ids: Vec<String>,
#[serde(default)]
pub external: Option<External>,
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<PackFile>,
#[serde(default)]
pub external: Vec<External>,
#[serde(default)]
pub java_arguments: Vec<String>,
#[serde(default)]
pub game_arguments: Vec<String>,
}
#[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<PackFile>,
#[serde(default)]
pub sync_marker: Option<String>,
#[serde(default)]
pub resolved_external: Vec<PackFile>,
}
#[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<T> {
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<String>,
}
fn invalid(message: impl Into<String>) -> 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::<u128>()
> 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::<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(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<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(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<T: serde::de::DeserializeOwned>(
root: &Path,
name: &str,
) -> crate::Result<Option<T>> {
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<T: Serialize>(
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<String> {
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<T: serde::de::DeserializeOwned>(
suffix: &str,
) -> crate::Result<T> {
let auth = authorization().await?;
request_authorized(suffix, &auth).await
}
async fn request_authorized<T: serde::de::DeserializeOwned>(
suffix: &str,
auth: &str,
) -> crate::Result<T> {
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<T> = response.error_for_status()?.json().await?;
ensure_session(auth).await?;
Ok(data.payload)
}
pub async fn default_publication() -> crate::Result<Publication> {
request::<Option<Publication>>("/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<bool> {
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<bool> {
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<String>) -> crate::Result<String> {
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. `<root>/<pack name>`. Avoid a `versions/<name>` 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. `<root>/<pack name>`. 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<Option<Binding>> {
read_json(&crate::instance::get_full_path(instance_id).await?, BINDING)
.await
}
pub async fn java_arguments(instance_id: &str) -> crate::Result<Vec<String>> {
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<Vec<String>> {
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<String>,
next_hash: Option<String>,
}
#[derive(Serialize, Deserialize)]
struct Journal {
backup: String,
actions: Vec<Action>,
previous: Option<Binding>,
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::<Journal>(&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<SyncResult> {
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<InstanceMode>,
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<InstanceMode> {
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<OwnedMutexGuard<()>> {
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::<Option<SyncState>>("/sync-state", &auth)
.await?
.ok_or_else(|| {
invalid("管理员尚未指定已发布的默认整合包,请联系服务器管理员")
})?;
if !marker_matches(&previous, &remote) {
return Err(invalid(
"整合包有更新,请先关闭该实例的所有游戏窗口,更新完成后再多开",
));
}
let tagged = request_authorized::<tagged::TaggedManifest>(
&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<bool> {
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::<BTreeMap<_, _>>()
};
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<SyncResult> {
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<SyncResult> {
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<Binding> = read_json(&root, BINDING).await?;
let auth = authorization().await?;
let sync_state =
request_authorized::<Option<SyncState>>("/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::<Option<Publication>>("/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::<tagged::TaggedManifest>(
&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::<u64>();
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::<String, u64>::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 = &current;
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::<Vec<_>>()
.await;
downloaded += results.into_iter().try_fold(0, |sum, result| {
Ok::<u64, crate::Error>(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::<tagged::TaggedManifest>(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());
}
}