1455 lines
48 KiB
Rust
1455 lines
48 KiB
Rust
use super::events::emit_install_job;
|
|
use super::model::{
|
|
InstallCleanup, InstallContentRollbackScope,
|
|
InstallContentRollbackSnapshot, InstallContentRollbackStage,
|
|
InstallErrorView, InstallInterruptReason, InstallJobDisplay,
|
|
InstallJobEventKind, InstallJobState, InstallJobStatus,
|
|
InstallPhaseDetails, InstallPhaseId, InstallRequest,
|
|
InstallRollbackContentEntry, InstallRollbackFile,
|
|
InstallRollbackProviderRef, InstallTarget,
|
|
};
|
|
use super::store;
|
|
use crate::event::InstancePayloadType;
|
|
use crate::event::emit::emit_instance;
|
|
use crate::state::instances::adapters::sqlite::content_rows;
|
|
use crate::state::{ContentOwnershipKind, State};
|
|
use path_util::SafeRelativeUtf8UnixPathBuf;
|
|
use std::collections::{HashMap, HashSet};
|
|
use std::path::{Path, PathBuf};
|
|
use uuid::Uuid;
|
|
|
|
pub async fn recover_interrupted_jobs(state: &State) -> crate::Result<()> {
|
|
let jobs = store::list_interrupted_candidates(state).await?;
|
|
|
|
for mut job in jobs {
|
|
if job.state.display.is_none() {
|
|
job.state.display = display_from_request(&job.state);
|
|
}
|
|
let interrupted_phase = job.state.progress.phase;
|
|
job.state.record_event(InstallJobEventKind::Interrupted {
|
|
reason: InstallInterruptReason::AppClosed,
|
|
phase: interrupted_phase,
|
|
});
|
|
job.state.progress.phase = InstallPhaseId::RollingBack;
|
|
job.state.progress.progress = None;
|
|
job.state.progress.details = InstallPhaseDetails::Empty;
|
|
job.state.progress.parallel = None;
|
|
job.state.error = Some(InstallErrorView::from_message(
|
|
"app_closed",
|
|
interrupted_phase,
|
|
"App closed while install was running",
|
|
));
|
|
|
|
job.state
|
|
.record_event(InstallJobEventKind::RollbackStarted {
|
|
cleanup: job.state.cleanup.clone(),
|
|
});
|
|
let cleanup_succeeded = match apply_cleanup(&mut job.state, state).await
|
|
{
|
|
Err(error) => {
|
|
tracing::error!(
|
|
"Error cleaning up interrupted install job {}: {error}",
|
|
job.id
|
|
);
|
|
job.state.rollback_error = Some(InstallErrorView::from_error(
|
|
"rollback_error",
|
|
InstallPhaseId::RollingBack,
|
|
&error,
|
|
None,
|
|
));
|
|
job.state.record_event(InstallJobEventKind::RollbackFailed {
|
|
message: error.to_string(),
|
|
});
|
|
false
|
|
}
|
|
Ok(()) => true,
|
|
};
|
|
finalize_rollback_state(&mut job.state, cleanup_succeeded);
|
|
if cleanup_succeeded {
|
|
clear_deleted_new_instance_id(&mut job.state);
|
|
}
|
|
|
|
let record = store::update_status(
|
|
job.id,
|
|
InstallJobStatus::Interrupted,
|
|
&job.state,
|
|
state,
|
|
)
|
|
.await?;
|
|
emit_install_job(&record.snapshot()).await?;
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
pub(crate) fn finalize_rollback_state(
|
|
job_state: &mut InstallJobState,
|
|
cleanup_succeeded: bool,
|
|
) {
|
|
if !cleanup_succeeded {
|
|
return;
|
|
}
|
|
job_state.record_event(InstallJobEventKind::RollbackCompleted);
|
|
job_state.progress.phase = InstallPhaseId::Finalizing;
|
|
job_state.progress.progress = None;
|
|
job_state.progress.details = InstallPhaseDetails::Empty;
|
|
job_state.progress.parallel = None;
|
|
}
|
|
|
|
fn clear_deleted_new_instance_id(job_state: &mut InstallJobState) {
|
|
if matches!(job_state.cleanup, InstallCleanup::DeleteNewInstance { .. }) {
|
|
job_state.target = InstallTarget::NewInstance { instance_id: None };
|
|
job_state.cleanup =
|
|
InstallCleanup::DeleteNewInstance { instance_id: None };
|
|
}
|
|
}
|
|
|
|
fn display_from_request(state: &InstallJobState) -> Option<InstallJobDisplay> {
|
|
match &state.request {
|
|
InstallRequest::CreateInstance { name, icon_path, .. } => {
|
|
Some(InstallJobDisplay {
|
|
title: name.clone(),
|
|
icon: icon_path.clone(),
|
|
})
|
|
}
|
|
InstallRequest::CreateModpackInstance { location, .. } => match location {
|
|
crate::api::pack::install_from::CreatePackLocation::FromVersionId {
|
|
title,
|
|
icon_url,
|
|
..
|
|
} => Some(InstallJobDisplay {
|
|
title: title.clone(),
|
|
icon: icon_url.clone(),
|
|
}),
|
|
crate::api::pack::install_from::CreatePackLocation::FromFile {
|
|
..
|
|
} => None,
|
|
},
|
|
InstallRequest::ImportInstance {
|
|
instance_folder, ..
|
|
} => Some(InstallJobDisplay {
|
|
title: instance_folder.clone(),
|
|
icon: None,
|
|
}),
|
|
InstallRequest::DuplicateInstance { .. }
|
|
| InstallRequest::InstallExistingInstance { .. }
|
|
| InstallRequest::UpgradeUnmanagedInstance { .. }
|
|
| InstallRequest::InstallPackToExistingInstance { .. }
|
|
| InstallRequest::UpdateManagedCurseForgeModpack { .. } => {
|
|
state.rollback.as_ref().map(|rollback| InstallJobDisplay {
|
|
title: rollback.instance.instance.name.clone(),
|
|
icon: rollback.instance.instance.icon_path.clone(),
|
|
})
|
|
}
|
|
InstallRequest::InstallContent {
|
|
display_title,
|
|
display_icon,
|
|
..
|
|
} => {
|
|
Some(InstallJobDisplay {
|
|
title: display_title.clone(),
|
|
icon: display_icon.clone(),
|
|
})
|
|
}
|
|
InstallRequest::InstallCurseForgeContent {
|
|
display_title,
|
|
display_icon,
|
|
..
|
|
} => Some(InstallJobDisplay {
|
|
title: display_title.clone(),
|
|
icon: display_icon.clone(),
|
|
}),
|
|
InstallRequest::InstallCurseForgeWorld {
|
|
display_title,
|
|
display_icon,
|
|
..
|
|
} => Some(InstallJobDisplay {
|
|
title: display_title.clone(),
|
|
icon: display_icon.clone(),
|
|
}),
|
|
InstallRequest::DownloadJava { vendor, version } => Some(InstallJobDisplay {
|
|
title: format!("Java {version} ({vendor})"),
|
|
icon: None,
|
|
}),
|
|
}
|
|
}
|
|
|
|
pub async fn apply_cleanup(
|
|
job_state: &mut InstallJobState,
|
|
state: &State,
|
|
) -> crate::Result<()> {
|
|
match job_state.cleanup.clone() {
|
|
InstallCleanup::DeleteNewInstance { instance_id } => {
|
|
if let Some(instance_id) = instance_id {
|
|
if !job_state.instance_deleted() {
|
|
crate::state::remove_instance(&instance_id, state).await?;
|
|
job_state.record_event(
|
|
InstallJobEventKind::TargetInstanceDeleted {
|
|
instance_id: instance_id.clone(),
|
|
},
|
|
);
|
|
if let Err(error) = emit_instance(
|
|
&instance_id,
|
|
InstancePayloadType::Removed,
|
|
)
|
|
.await
|
|
{
|
|
tracing::warn!(
|
|
instance_id,
|
|
error = %error,
|
|
"Install cleanup deleted a new instance, but its removal event could not be emitted"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
InstallCleanup::RestoreExistingInstance { instance_id } => {
|
|
if job_state.rollback.is_some() {
|
|
restore_existing_instance(job_state, state, &instance_id)
|
|
.await?;
|
|
if let Err(error) =
|
|
emit_instance(&instance_id, InstancePayloadType::Edited)
|
|
.await
|
|
{
|
|
tracing::warn!(
|
|
instance_id,
|
|
error = %error,
|
|
"Install cleanup restored an instance, but its edited event could not be emitted"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
InstallCleanup::None => {}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
pub(crate) async fn prepare_existing_content_rollback(
|
|
job_id: Uuid,
|
|
job_state: &mut InstallJobState,
|
|
state: &State,
|
|
extra_relative_paths: Vec<String>,
|
|
) -> crate::Result<()> {
|
|
prepare_existing_content_rollback_with_scope(
|
|
job_id,
|
|
job_state,
|
|
state,
|
|
extra_relative_paths,
|
|
InstallContentRollbackScope::PackManaged,
|
|
)
|
|
.await
|
|
}
|
|
|
|
pub(crate) async fn prepare_existing_upgrade_content_rollback(
|
|
job_id: Uuid,
|
|
job_state: &mut InstallJobState,
|
|
state: &State,
|
|
replacement_paths: Vec<String>,
|
|
) -> crate::Result<()> {
|
|
prepare_existing_content_rollback_with_scope(
|
|
job_id,
|
|
job_state,
|
|
state,
|
|
replacement_paths,
|
|
InstallContentRollbackScope::AllContent,
|
|
)
|
|
.await
|
|
}
|
|
|
|
async fn prepare_existing_content_rollback_with_scope(
|
|
job_id: Uuid,
|
|
job_state: &mut InstallJobState,
|
|
state: &State,
|
|
extra_relative_paths: Vec<String>,
|
|
scope: InstallContentRollbackScope,
|
|
) -> crate::Result<()> {
|
|
if job_state
|
|
.rollback
|
|
.as_ref()
|
|
.and_then(|rollback| rollback.content.as_ref())
|
|
.is_some()
|
|
{
|
|
return Ok(());
|
|
}
|
|
|
|
let rollback = job_state.rollback.as_ref().ok_or_else(|| {
|
|
crate::ErrorKind::OtherError(
|
|
"Existing instance rollback metadata is missing".to_string(),
|
|
)
|
|
})?;
|
|
let instance_id = rollback.instance.instance.id.clone();
|
|
let content_set_id = rollback.instance.applied_content_set.id.clone();
|
|
let instance_base = state
|
|
.directories
|
|
.instance_game_dir(&rollback.instance.instance);
|
|
let _instance_lock = state.lock_instance_content(&instance_id).await;
|
|
|
|
let all_entries =
|
|
content_rows::get_content_entries(&content_set_id, &state.pool).await?;
|
|
let selected_entries = all_entries
|
|
.into_iter()
|
|
.filter(|entry| {
|
|
scope == InstallContentRollbackScope::AllContent
|
|
|| entry.ownership_kind == ContentOwnershipKind::PackManaged
|
|
})
|
|
.collect::<Vec<_>>();
|
|
let instance_files =
|
|
content_rows::get_instance_files(&instance_id, &state.pool).await?;
|
|
let files_by_id = instance_files
|
|
.iter()
|
|
.map(|file| (file.id.clone(), file.clone()))
|
|
.collect::<HashMap<_, _>>();
|
|
let user_owned_paths = if scope == InstallContentRollbackScope::PackManaged
|
|
{
|
|
user_owned_paths(
|
|
&selected_entries,
|
|
&instance_files,
|
|
&content_set_id,
|
|
&state.pool,
|
|
)
|
|
.await?
|
|
} else {
|
|
HashSet::new()
|
|
};
|
|
|
|
let mut entry_snapshots = Vec::with_capacity(selected_entries.len());
|
|
for entry in &selected_entries {
|
|
let provider_refs =
|
|
content_rows::get_content_provider_refs_with_origin(
|
|
&entry.id,
|
|
&state.pool,
|
|
)
|
|
.await?
|
|
.into_iter()
|
|
.map(|(provider_ref, origin)| InstallRollbackProviderRef {
|
|
provider_ref,
|
|
origin,
|
|
})
|
|
.collect();
|
|
let update_check =
|
|
content_rows::get_content_update_check(&entry.id, &state.pool)
|
|
.await?;
|
|
entry_snapshots.push(InstallRollbackContentEntry {
|
|
entry: entry.clone(),
|
|
provider_refs,
|
|
update_check,
|
|
});
|
|
}
|
|
|
|
let mut paths = selected_entries
|
|
.iter()
|
|
.filter_map(|entry| entry.file_id.as_ref())
|
|
.filter_map(|file_id| files_by_id.get(file_id))
|
|
.map(|file| file.relative_path.clone())
|
|
.chain(extra_relative_paths)
|
|
.filter(|path| !user_owned_paths.contains(path))
|
|
.collect::<Vec<_>>();
|
|
paths.sort();
|
|
paths.dedup();
|
|
|
|
let mut file_snapshots = Vec::with_capacity(paths.len());
|
|
let mut protected_paths = Vec::new();
|
|
for (index, relative_path) in paths.into_iter().enumerate() {
|
|
let source = checked_instance_path(&instance_base, &relative_path)?;
|
|
let instance_file = instance_files
|
|
.iter()
|
|
.find(|file| file.relative_path == relative_path)
|
|
.cloned();
|
|
let metadata = match tokio::fs::symlink_metadata(&source).await {
|
|
Ok(metadata) => Some(metadata),
|
|
Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
|
|
Err(error) => return Err(error.into()),
|
|
};
|
|
if let Some(metadata) = &metadata
|
|
&& (!metadata.is_file()
|
|
|| crate::util::io::is_symlink_or_reparse(metadata))
|
|
{
|
|
if scope == InstallContentRollbackScope::AllContent
|
|
&& crate::util::io::is_symlink_or_reparse(metadata)
|
|
{
|
|
protected_paths.push(relative_path);
|
|
continue;
|
|
}
|
|
return Err(crate::ErrorKind::FSError(format!(
|
|
"Rollback source is not a regular managed file: {relative_path}"
|
|
))
|
|
.into());
|
|
}
|
|
let (physical_sha1, physical_size) = if metadata.is_some() {
|
|
let (size, sha1) =
|
|
crate::util::fetch::sha1_file_async(&source).await?;
|
|
(Some(sha1), Some(size))
|
|
} else {
|
|
(None, None)
|
|
};
|
|
file_snapshots.push(InstallRollbackFile {
|
|
relative_path,
|
|
staged_name: format!("{index:08}.file"),
|
|
existed: metadata.is_some(),
|
|
physical_sha1,
|
|
physical_size,
|
|
instance_file,
|
|
});
|
|
}
|
|
|
|
let snapshot = InstallContentRollbackSnapshot {
|
|
staging_id: job_id.to_string(),
|
|
stage: InstallContentRollbackStage::Planned,
|
|
scope,
|
|
files: file_snapshots,
|
|
entries: entry_snapshots,
|
|
dependency_edges: content_rows::get_content_dependency_edges(
|
|
&content_set_id,
|
|
&state.pool,
|
|
)
|
|
.await?,
|
|
pack_members: content_rows::get_pack_members(
|
|
&content_set_id,
|
|
&state.pool,
|
|
)
|
|
.await?,
|
|
replacement_paths: Vec::new(),
|
|
protected_paths,
|
|
};
|
|
job_state.rollback.as_mut().unwrap().content = Some(snapshot);
|
|
super::events::InstallProgressReporter::new(job_id, job_state.clone())
|
|
.set_rollback(job_state.rollback.clone())
|
|
.await?;
|
|
|
|
let staging_root = staging_root(job_state, state)?;
|
|
crate::util::io::create_dir_all(staging_root.join("files")).await?;
|
|
let stage_result =
|
|
stage_snapshot_files(job_state, &instance_base, state).await;
|
|
if let Err(error) = stage_result {
|
|
let restore_result =
|
|
restore_snapshot_files(job_state, &instance_base, state).await;
|
|
if let Err(restore_error) = restore_result {
|
|
return Err(crate::ErrorKind::OtherError(format!(
|
|
"Failed to stage existing pack content: {error}; partial staging restore failed: {restore_error}"
|
|
))
|
|
.into());
|
|
}
|
|
discard_content_rollback(job_state, state).await?;
|
|
super::events::InstallProgressReporter::new(job_id, job_state.clone())
|
|
.set_rollback(job_state.rollback.clone())
|
|
.await?;
|
|
return Err(error);
|
|
}
|
|
|
|
job_state
|
|
.rollback
|
|
.as_mut()
|
|
.unwrap()
|
|
.content
|
|
.as_mut()
|
|
.unwrap()
|
|
.stage = InstallContentRollbackStage::Ready;
|
|
super::events::InstallProgressReporter::new(job_id, job_state.clone())
|
|
.set_rollback(job_state.rollback.clone())
|
|
.await?;
|
|
remove_snapshotted_db_state(job_state, state).await?;
|
|
Ok(())
|
|
}
|
|
|
|
pub(crate) async fn discard_content_rollback(
|
|
job_state: &mut InstallJobState,
|
|
state: &State,
|
|
) -> crate::Result<()> {
|
|
let Some(snapshot) = job_state
|
|
.rollback
|
|
.as_ref()
|
|
.and_then(|rollback| rollback.content.as_ref())
|
|
else {
|
|
return Ok(());
|
|
};
|
|
let staging = staging_root_from_id(&snapshot.staging_id, state)?;
|
|
match crate::util::io::remove_dir_all(&staging).await {
|
|
Ok(()) => {}
|
|
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
|
|
Err(error) => return Err(error.into()),
|
|
}
|
|
if let Some(rollback) = job_state.rollback.as_mut() {
|
|
rollback.content = None;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub(crate) async fn restore_upgrade_db_baseline(
|
|
job_state: &InstallJobState,
|
|
state: &State,
|
|
) -> crate::Result<()> {
|
|
let scope = job_state
|
|
.rollback
|
|
.as_ref()
|
|
.and_then(|rollback| rollback.content.as_ref())
|
|
.map(|snapshot| snapshot.scope);
|
|
if scope != Some(InstallContentRollbackScope::AllContent) {
|
|
return Err(crate::ErrorKind::OtherError(
|
|
"Upgrade rollback snapshot is missing".to_string(),
|
|
)
|
|
.into());
|
|
}
|
|
restore_snapshotted_db_state(job_state, state).await
|
|
}
|
|
|
|
async fn restore_existing_instance(
|
|
job_state: &mut InstallJobState,
|
|
state: &State,
|
|
instance_id: &str,
|
|
) -> crate::Result<()> {
|
|
let rollback = job_state.rollback.as_ref().ok_or_else(|| {
|
|
crate::ErrorKind::OtherError(
|
|
"Existing instance rollback metadata is missing".to_string(),
|
|
)
|
|
})?;
|
|
if rollback.instance.instance.id != instance_id {
|
|
return Err(crate::ErrorKind::InputError(
|
|
"Rollback instance does not match cleanup target".to_string(),
|
|
)
|
|
.into());
|
|
}
|
|
let instance_base = state
|
|
.directories
|
|
.instance_game_dir(&rollback.instance.instance);
|
|
let _instance_lock = state.lock_instance_content(instance_id).await;
|
|
|
|
if rollback.content.is_some() {
|
|
clean_replacement_files(job_state, &instance_base, state).await?;
|
|
restore_snapshot_files(job_state, &instance_base, state).await?;
|
|
restore_snapshotted_db_state(job_state, state).await?;
|
|
}
|
|
let metadata = job_state.rollback.as_ref().unwrap().instance.clone();
|
|
crate::state::restore_instance_metadata(&metadata, &state.pool).await?;
|
|
verify_restored_snapshot(job_state, &instance_base, state).await?;
|
|
discard_content_rollback(job_state, state).await?;
|
|
Ok(())
|
|
}
|
|
|
|
async fn user_owned_paths(
|
|
managed_entries: &[crate::state::ContentEntry],
|
|
files: &[crate::state::InstanceFile],
|
|
content_set_id: &str,
|
|
pool: &sqlx::SqlitePool,
|
|
) -> crate::Result<HashSet<String>> {
|
|
let managed_ids = managed_entries
|
|
.iter()
|
|
.map(|entry| entry.id.as_str())
|
|
.collect::<HashSet<_>>();
|
|
let file_paths = files
|
|
.iter()
|
|
.map(|file| (file.id.as_str(), file.relative_path.clone()))
|
|
.collect::<HashMap<_, _>>();
|
|
Ok(content_rows::get_content_entries(content_set_id, pool)
|
|
.await?
|
|
.into_iter()
|
|
.filter(|entry| !managed_ids.contains(entry.id.as_str()))
|
|
.filter_map(|entry| entry.file_id)
|
|
.filter_map(|file_id| file_paths.get(file_id.as_str()).cloned())
|
|
.collect())
|
|
}
|
|
|
|
pub(crate) fn checked_instance_path(
|
|
instance_base: &Path,
|
|
relative_path: &str,
|
|
) -> crate::Result<PathBuf> {
|
|
let safe =
|
|
SafeRelativeUtf8UnixPathBuf::try_from(relative_path.to_string())?;
|
|
let mut current = instance_base.to_path_buf();
|
|
for component in safe.as_str().split('/') {
|
|
current.push(component);
|
|
match std::fs::symlink_metadata(¤t) {
|
|
Ok(metadata)
|
|
if crate::util::io::is_symlink_or_reparse(&metadata) =>
|
|
{
|
|
return Err(crate::ErrorKind::FSError(format!(
|
|
"Instance path crosses a symlink or reparse point: {relative_path}"
|
|
))
|
|
.into());
|
|
}
|
|
Ok(_) => {}
|
|
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
|
|
Err(error) => return Err(error.into()),
|
|
}
|
|
}
|
|
Ok(current)
|
|
}
|
|
|
|
fn staging_root(
|
|
job_state: &InstallJobState,
|
|
state: &State,
|
|
) -> crate::Result<PathBuf> {
|
|
let snapshot = job_state
|
|
.rollback
|
|
.as_ref()
|
|
.and_then(|rollback| rollback.content.as_ref())
|
|
.ok_or_else(|| {
|
|
crate::ErrorKind::OtherError(
|
|
"Content rollback snapshot is missing".to_string(),
|
|
)
|
|
})?;
|
|
staging_root_from_id(&snapshot.staging_id, state)
|
|
}
|
|
|
|
fn staging_root_from_id(
|
|
staging_id: &str,
|
|
state: &State,
|
|
) -> crate::Result<PathBuf> {
|
|
let id = Uuid::parse_str(staging_id).map_err(|_| {
|
|
crate::ErrorKind::InputError(
|
|
"Invalid persisted rollback staging ID".to_string(),
|
|
)
|
|
})?;
|
|
Ok(state
|
|
.directories
|
|
.install_rollbacks_dir()
|
|
.join(id.to_string()))
|
|
}
|
|
|
|
async fn stage_snapshot_files(
|
|
job_state: &InstallJobState,
|
|
instance_base: &Path,
|
|
state: &State,
|
|
) -> crate::Result<()> {
|
|
let staging = staging_root(job_state, state)?.join("files");
|
|
let snapshot = job_state
|
|
.rollback
|
|
.as_ref()
|
|
.and_then(|rollback| rollback.content.as_ref())
|
|
.unwrap();
|
|
for file in snapshot.files.iter().filter(|file| file.existed) {
|
|
let source = checked_instance_path(instance_base, &file.relative_path)?;
|
|
let target = staging.join(&file.staged_name);
|
|
if target.exists() {
|
|
verify_file(&target, file).await?;
|
|
if source.exists()
|
|
&& snapshot.scope == InstallContentRollbackScope::PackManaged
|
|
{
|
|
verify_file(&source, file).await?;
|
|
crate::util::io::remove_file(&source).await?;
|
|
}
|
|
continue;
|
|
}
|
|
if snapshot.scope == InstallContentRollbackScope::AllContent {
|
|
crate::util::io::copy(&source, &target).await?;
|
|
verify_file(&target, file).await?;
|
|
} else {
|
|
move_file_verified(&source, &target, file).await?;
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
async fn restore_snapshot_files(
|
|
job_state: &InstallJobState,
|
|
instance_base: &Path,
|
|
state: &State,
|
|
) -> crate::Result<()> {
|
|
let staging = staging_root(job_state, state)?.join("files");
|
|
let snapshot = job_state
|
|
.rollback
|
|
.as_ref()
|
|
.and_then(|rollback| rollback.content.as_ref())
|
|
.unwrap();
|
|
for file in snapshot.files.iter().filter(|file| file.existed) {
|
|
let source = staging.join(&file.staged_name);
|
|
let target = checked_instance_path(instance_base, &file.relative_path)?;
|
|
if target.exists() {
|
|
if verify_file(&target, file).await.is_ok() {
|
|
if source.exists() {
|
|
crate::util::io::remove_file(&source).await?;
|
|
}
|
|
continue;
|
|
}
|
|
crate::util::io::remove_file(&target).await?;
|
|
}
|
|
if !source.exists() {
|
|
return Err(crate::ErrorKind::FSError(format!(
|
|
"Rollback staged file is missing: {}",
|
|
file.relative_path
|
|
))
|
|
.into());
|
|
}
|
|
if let Some(parent) = target.parent() {
|
|
crate::util::io::create_dir_all(parent).await?;
|
|
}
|
|
move_file_verified(&source, &target, file).await?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
async fn move_file_verified(
|
|
source: &Path,
|
|
target: &Path,
|
|
expected: &InstallRollbackFile,
|
|
) -> crate::Result<()> {
|
|
if let Some(parent) = target.parent() {
|
|
crate::util::io::create_dir_all(parent).await?;
|
|
}
|
|
match tokio::fs::rename(source, target).await {
|
|
Ok(()) => verify_file(target, expected).await,
|
|
Err(_) => {
|
|
crate::util::io::copy(source, target).await?;
|
|
if let Err(error) = verify_file(target, expected).await {
|
|
let _ = crate::util::io::remove_file(target).await;
|
|
return Err(error);
|
|
}
|
|
crate::util::io::remove_file(source).await?;
|
|
Ok(())
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn verify_file(
|
|
path: &Path,
|
|
expected: &InstallRollbackFile,
|
|
) -> crate::Result<()> {
|
|
let metadata = tokio::fs::symlink_metadata(path).await?;
|
|
if !metadata.is_file()
|
|
|| crate::util::io::is_symlink_or_reparse(&metadata)
|
|
|| expected.physical_size != Some(metadata.len())
|
|
{
|
|
return Err(crate::ErrorKind::FSError(format!(
|
|
"Rollback file integrity check failed: {}",
|
|
expected.relative_path
|
|
))
|
|
.into());
|
|
}
|
|
let (_, actual_sha1) = crate::util::fetch::sha1_file_async(path).await?;
|
|
if expected.physical_sha1.as_deref() != Some(actual_sha1.as_str()) {
|
|
return Err(crate::ErrorKind::FSError(format!(
|
|
"Rollback file integrity check failed: {}",
|
|
expected.relative_path
|
|
))
|
|
.into());
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
async fn remove_snapshotted_db_state(
|
|
job_state: &InstallJobState,
|
|
state: &State,
|
|
) -> crate::Result<()> {
|
|
let rollback = job_state.rollback.as_ref().unwrap();
|
|
let snapshot = rollback.content.as_ref().unwrap();
|
|
let content_set_id = &rollback.instance.applied_content_set.id;
|
|
let instance_id = &rollback.instance.instance.id;
|
|
let file_ids = snapshot
|
|
.files
|
|
.iter()
|
|
.filter_map(|file| file.instance_file.as_ref())
|
|
.map(|file| file.id.clone())
|
|
.collect::<Vec<_>>();
|
|
let mut tx = state.pool.begin().await?;
|
|
sqlx::query("DELETE FROM instance_pack_members WHERE content_set_id = ?")
|
|
.bind(content_set_id)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
match snapshot.scope {
|
|
InstallContentRollbackScope::PackManaged => {
|
|
sqlx::query(
|
|
"DELETE FROM instance_content_entries
|
|
WHERE content_set_id = ? AND ownership_kind = 'pack_managed'",
|
|
)
|
|
.bind(content_set_id)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
}
|
|
InstallContentRollbackScope::AllContent => {
|
|
sqlx::query(
|
|
"DELETE FROM instance_content_entries WHERE content_set_id = ?",
|
|
)
|
|
.bind(content_set_id)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
}
|
|
}
|
|
for file_id in file_ids {
|
|
sqlx::query(
|
|
"DELETE FROM instance_files
|
|
WHERE instance_id = ? AND id = ?
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM instance_content_entries
|
|
WHERE file_id = ?
|
|
)",
|
|
)
|
|
.bind(instance_id)
|
|
.bind(&file_id)
|
|
.bind(&file_id)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
}
|
|
tx.commit().await?;
|
|
Ok(())
|
|
}
|
|
|
|
async fn clean_replacement_files(
|
|
job_state: &InstallJobState,
|
|
instance_base: &Path,
|
|
state: &State,
|
|
) -> crate::Result<()> {
|
|
let rollback = job_state.rollback.as_ref().unwrap();
|
|
let snapshot = rollback.content.as_ref().unwrap();
|
|
let current_entries = content_rows::get_content_entries(
|
|
&rollback.instance.applied_content_set.id,
|
|
&state.pool,
|
|
)
|
|
.await?;
|
|
let current_files = content_rows::get_instance_files(
|
|
&rollback.instance.instance.id,
|
|
&state.pool,
|
|
)
|
|
.await?;
|
|
let paths_by_id = current_files
|
|
.iter()
|
|
.map(|file| (file.id.as_str(), file.relative_path.as_str()))
|
|
.collect::<HashMap<_, _>>();
|
|
let rollback_paths = current_entries
|
|
.iter()
|
|
.filter(|entry| {
|
|
snapshot.scope == InstallContentRollbackScope::AllContent
|
|
|| entry.ownership_kind == ContentOwnershipKind::PackManaged
|
|
})
|
|
.filter_map(|entry| entry.file_id.as_deref())
|
|
.filter_map(|id| paths_by_id.get(id).copied())
|
|
.map(ToString::to_string)
|
|
.collect::<HashSet<_>>();
|
|
let protected_paths = current_entries
|
|
.iter()
|
|
.filter(|entry| {
|
|
snapshot.scope == InstallContentRollbackScope::PackManaged
|
|
&& entry.ownership_kind != ContentOwnershipKind::PackManaged
|
|
})
|
|
.filter_map(|entry| entry.file_id.as_deref())
|
|
.filter_map(|id| paths_by_id.get(id).copied())
|
|
.collect::<HashSet<_>>();
|
|
let old_paths = snapshot
|
|
.files
|
|
.iter()
|
|
.map(|file| file.relative_path.as_str())
|
|
.collect::<HashSet<_>>();
|
|
let cleanup_paths = rollback_paths
|
|
.into_iter()
|
|
.chain(snapshot.replacement_paths.iter().cloned())
|
|
.collect::<HashSet<_>>();
|
|
for relative_path in cleanup_paths {
|
|
if snapshot.protected_paths.contains(&relative_path) {
|
|
continue;
|
|
}
|
|
if protected_paths.contains(relative_path.as_str()) {
|
|
return Err(crate::ErrorKind::FSError(format!(
|
|
"Refusing to remove user-owned rollback path: {relative_path}"
|
|
))
|
|
.into());
|
|
}
|
|
if old_paths.contains(relative_path.as_str()) {
|
|
continue;
|
|
}
|
|
let path = checked_instance_path(instance_base, &relative_path)?;
|
|
match crate::util::io::remove_file(&path).await {
|
|
Ok(()) => {}
|
|
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
|
|
Err(error) => return Err(error.into()),
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
async fn restore_snapshotted_db_state(
|
|
job_state: &InstallJobState,
|
|
state: &State,
|
|
) -> crate::Result<()> {
|
|
let rollback = job_state.rollback.as_ref().unwrap();
|
|
let snapshot = rollback.content.as_ref().unwrap();
|
|
let content_set_id = &rollback.instance.applied_content_set.id;
|
|
let instance_id = &rollback.instance.instance.id;
|
|
let current_entries =
|
|
content_rows::get_content_entries(content_set_id, &state.pool).await?;
|
|
let current_file_ids = current_entries
|
|
.iter()
|
|
.filter(|entry| {
|
|
snapshot.scope == InstallContentRollbackScope::AllContent
|
|
|| entry.ownership_kind == ContentOwnershipKind::PackManaged
|
|
})
|
|
.filter_map(|entry| entry.file_id.clone())
|
|
.collect::<Vec<_>>();
|
|
|
|
let mut tx = state.pool.begin().await?;
|
|
sqlx::query("DELETE FROM instance_pack_members WHERE content_set_id = ?")
|
|
.bind(content_set_id)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
match snapshot.scope {
|
|
InstallContentRollbackScope::PackManaged => {
|
|
sqlx::query(
|
|
"DELETE FROM instance_content_entries
|
|
WHERE content_set_id = ? AND ownership_kind = 'pack_managed'",
|
|
)
|
|
.bind(content_set_id)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
}
|
|
InstallContentRollbackScope::AllContent => {
|
|
sqlx::query(
|
|
"DELETE FROM instance_content_entries WHERE content_set_id = ?",
|
|
)
|
|
.bind(content_set_id)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
}
|
|
}
|
|
for file_id in current_file_ids {
|
|
sqlx::query(
|
|
"DELETE FROM instance_files
|
|
WHERE instance_id = ? AND id = ?
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM instance_content_entries WHERE file_id = ?
|
|
)",
|
|
)
|
|
.bind(instance_id)
|
|
.bind(&file_id)
|
|
.bind(&file_id)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
}
|
|
for file in snapshot
|
|
.files
|
|
.iter()
|
|
.filter_map(|file| file.instance_file.as_ref())
|
|
{
|
|
content_rows::upsert_instance_file(file, &mut tx).await?;
|
|
}
|
|
for entry in &snapshot.entries {
|
|
content_rows::restore_content_entry_in_transaction(
|
|
&entry.entry,
|
|
&mut tx,
|
|
)
|
|
.await?;
|
|
for provider in &entry.provider_refs {
|
|
content_rows::upsert_content_provider_ref_in_transaction(
|
|
&entry.entry.id,
|
|
&provider.provider_ref,
|
|
provider.origin,
|
|
&mut tx,
|
|
)
|
|
.await?;
|
|
}
|
|
if let Some(check) = &entry.update_check {
|
|
content_rows::restore_content_update_check_in_transaction(
|
|
check, &mut tx,
|
|
)
|
|
.await?;
|
|
}
|
|
}
|
|
for edge in &snapshot.dependency_edges {
|
|
content_rows::upsert_content_dependency_edge_in_transaction(
|
|
edge, &mut tx,
|
|
)
|
|
.await?;
|
|
}
|
|
for member in &snapshot.pack_members {
|
|
content_rows::upsert_pack_member_in_transaction(member, &mut tx)
|
|
.await?;
|
|
}
|
|
tx.commit().await?;
|
|
Ok(())
|
|
}
|
|
|
|
async fn verify_restored_snapshot(
|
|
job_state: &InstallJobState,
|
|
instance_base: &Path,
|
|
state: &State,
|
|
) -> crate::Result<()> {
|
|
let Some(snapshot) = job_state
|
|
.rollback
|
|
.as_ref()
|
|
.and_then(|rollback| rollback.content.as_ref())
|
|
else {
|
|
return Ok(());
|
|
};
|
|
for file in snapshot.files.iter().filter(|file| file.existed) {
|
|
let path = checked_instance_path(instance_base, &file.relative_path)?;
|
|
verify_file(&path, file).await?;
|
|
}
|
|
let rollback = job_state.rollback.as_ref().unwrap();
|
|
let entries = content_rows::get_content_entries(
|
|
&rollback.instance.applied_content_set.id,
|
|
&state.pool,
|
|
)
|
|
.await?;
|
|
let entry_ids = entries
|
|
.iter()
|
|
.map(|entry| entry.id.as_str())
|
|
.collect::<HashSet<_>>();
|
|
if snapshot
|
|
.entries
|
|
.iter()
|
|
.any(|entry| !entry_ids.contains(entry.entry.id.as_str()))
|
|
{
|
|
return Err(crate::ErrorKind::OtherError(
|
|
"Rollback DB verification found missing content entries"
|
|
.to_string(),
|
|
)
|
|
.into());
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(all(test, not(feature = "tauri")))]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::api::pack::install_from::CreatePackLocation;
|
|
use crate::install::model::{InstallRollbackState, InstallTarget};
|
|
use crate::state::{
|
|
AppliedContentSetPatch, ContentProviderRef, ContentSourceKind,
|
|
EditInstance, InstanceInstallStage, InstanceLink, ModLoader,
|
|
ModrinthProjectId, ModrinthVersionId, ProjectType,
|
|
};
|
|
|
|
#[tokio::test]
|
|
async fn b10_restores_existing_pack_files_and_database_state() {
|
|
crate::event::EventState::init().await.unwrap();
|
|
let root = tempfile::tempdir().unwrap().keep();
|
|
let state = State::init_for_test(root.to_string_lossy().to_string())
|
|
.await
|
|
.unwrap();
|
|
let old_link = InstanceLink::ImportedModpack {
|
|
project_id: Some("old-project".to_string()),
|
|
version_id: Some("old-version".to_string()),
|
|
name: Some("Old Pack".to_string()),
|
|
version_number: Some("1.0.0".to_string()),
|
|
filename: Some("old.mrpack".to_string()),
|
|
};
|
|
let created = crate::api::instance::create(
|
|
"B10 Existing".to_string(),
|
|
"1.20.1".to_string(),
|
|
ModLoader::Vanilla,
|
|
None,
|
|
None,
|
|
old_link.clone(),
|
|
None,
|
|
None,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let instance_id = created.instance.id.clone();
|
|
crate::state::instances::commands::set_instance_install_stage(
|
|
&instance_id,
|
|
InstanceInstallStage::Installed,
|
|
&state.pool,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let instance_base = state
|
|
.directories
|
|
.instances_dir()
|
|
.join(&created.instance.path);
|
|
crate::util::io::create_dir_all(instance_base.join("mods"))
|
|
.await
|
|
.unwrap();
|
|
|
|
let old_files = [
|
|
("mods/old-a.jar", b"old-a-content".as_slice()),
|
|
("mods/old-b.jar", b"old-b-content".as_slice()),
|
|
];
|
|
for (index, (relative_path, bytes)) in old_files.iter().enumerate() {
|
|
let path = instance_base.join(relative_path);
|
|
crate::util::io::write(&path, bytes).await.unwrap();
|
|
let (_, sha1) =
|
|
crate::util::fetch::sha1_file_async(&path).await.unwrap();
|
|
let provider = ContentProviderRef::Modrinth {
|
|
project_id: ModrinthProjectId::new(format!("project-{index}"))
|
|
.unwrap(),
|
|
version_id: Some(
|
|
ModrinthVersionId::new(format!("version-{index}")).unwrap(),
|
|
),
|
|
};
|
|
crate::state::record_project_file_atomic(
|
|
&instance_id,
|
|
relative_path,
|
|
&sha1,
|
|
bytes.len() as u64,
|
|
ProjectType::Mod,
|
|
ContentSourceKind::ImportedModpack,
|
|
ContentOwnershipKind::PackManaged,
|
|
Some(&provider),
|
|
true,
|
|
None,
|
|
&state,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
}
|
|
crate::util::io::write(
|
|
instance_base.join("B10_KEEP_ME.txt"),
|
|
b"user-data",
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let user_mod_path = instance_base.join("mods/user-added.jar");
|
|
crate::util::io::write(&user_mod_path, b"user-mod")
|
|
.await
|
|
.unwrap();
|
|
let (_, user_mod_sha1) =
|
|
crate::util::fetch::sha1_file_async(&user_mod_path)
|
|
.await
|
|
.unwrap();
|
|
crate::state::record_project_file_atomic(
|
|
&instance_id,
|
|
"mods/user-added.jar",
|
|
&user_mod_sha1,
|
|
8,
|
|
ProjectType::Mod,
|
|
ContentSourceKind::Local,
|
|
ContentOwnershipKind::UserAdded,
|
|
None,
|
|
false,
|
|
None,
|
|
&state,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
let original = crate::state::get_instance(&instance_id, &state.pool)
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
let original_entries = content_rows::get_content_entries(
|
|
&original.applied_content_set.id,
|
|
&state.pool,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let original_members = content_rows::get_pack_members(
|
|
&original.applied_content_set.id,
|
|
&state.pool,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(
|
|
original_entries
|
|
.iter()
|
|
.filter(|entry| {
|
|
entry.ownership_kind == ContentOwnershipKind::PackManaged
|
|
})
|
|
.count(),
|
|
2
|
|
);
|
|
assert_eq!(original_members.len(), 2);
|
|
|
|
let request = InstallRequest::InstallPackToExistingInstance {
|
|
instance_id: instance_id.clone(),
|
|
location: CreatePackLocation::FromFile {
|
|
path: root.join("new.mrpack"),
|
|
},
|
|
post_install_edit: None,
|
|
};
|
|
let mut job_state = InstallJobState::new(request);
|
|
job_state.rollback = Some(InstallRollbackState {
|
|
instance: original.clone(),
|
|
install_stage: InstanceInstallStage::Installed,
|
|
content: None,
|
|
});
|
|
job_state.cleanup = InstallCleanup::RestoreExistingInstance {
|
|
instance_id: instance_id.clone(),
|
|
};
|
|
assert!(matches!(
|
|
job_state.target,
|
|
InstallTarget::ExistingInstance { instance_id: ref target }
|
|
if target == &instance_id
|
|
));
|
|
let job_id = Uuid::new_v4();
|
|
store::insert(job_id, &job_state, InstallJobStatus::Queued, &state)
|
|
.await
|
|
.unwrap();
|
|
|
|
prepare_existing_content_rollback(
|
|
job_id,
|
|
&mut job_state,
|
|
&state,
|
|
Vec::new(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
job_state = store::get_required(job_id, &state).await.unwrap().state;
|
|
let staging_id = job_state
|
|
.rollback
|
|
.as_ref()
|
|
.unwrap()
|
|
.content
|
|
.as_ref()
|
|
.unwrap()
|
|
.staging_id
|
|
.clone();
|
|
assert!(!instance_base.join("mods/old-a.jar").exists());
|
|
assert!(!instance_base.join("mods/old-b.jar").exists());
|
|
assert!(instance_base.join("B10_KEEP_ME.txt").exists());
|
|
let entries_after_remove = content_rows::get_content_entries(
|
|
&original.applied_content_set.id,
|
|
&state.pool,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(entries_after_remove.len(), 1);
|
|
assert_eq!(
|
|
entries_after_remove[0].ownership_kind,
|
|
ContentOwnershipKind::UserAdded
|
|
);
|
|
|
|
let new_path = instance_base.join("mods/new-pack.jar");
|
|
crate::util::io::write(&new_path, b"new-pack-partial")
|
|
.await
|
|
.unwrap();
|
|
let (_, new_sha1) = crate::util::fetch::sha1_file_async(&new_path)
|
|
.await
|
|
.unwrap();
|
|
crate::state::record_project_file_atomic(
|
|
&instance_id,
|
|
"mods/new-pack.jar",
|
|
&new_sha1,
|
|
16,
|
|
ProjectType::Mod,
|
|
ContentSourceKind::ImportedModpack,
|
|
ContentOwnershipKind::PackManaged,
|
|
None,
|
|
false,
|
|
None,
|
|
&state,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
crate::api::instance::edit(
|
|
&instance_id,
|
|
EditInstance {
|
|
install_stage: Some(InstanceInstallStage::PackInstalling),
|
|
name: Some("New Partial Pack".to_string()),
|
|
link: Some(InstanceLink::ImportedModpack {
|
|
project_id: None,
|
|
version_id: None,
|
|
name: Some("New Pack".to_string()),
|
|
version_number: Some("2.0.0".to_string()),
|
|
filename: Some("new.mrpack".to_string()),
|
|
}),
|
|
content_set_patch: Some(AppliedContentSetPatch {
|
|
source_kind: Some(ContentSourceKind::ImportedModpack),
|
|
game_version: Some("1.21.1".to_string()),
|
|
protocol_version: Some(None),
|
|
loader: Some(ModLoader::Vanilla),
|
|
loader_version: Some(None),
|
|
}),
|
|
..EditInstance::default()
|
|
},
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let untracked_new_path = instance_base.join("config/new-pack.cfg");
|
|
crate::util::io::create_dir_all(untracked_new_path.parent().unwrap())
|
|
.await
|
|
.unwrap();
|
|
crate::util::io::write(&untracked_new_path, b"partial-config")
|
|
.await
|
|
.unwrap();
|
|
job_state
|
|
.rollback
|
|
.as_mut()
|
|
.unwrap()
|
|
.content
|
|
.as_mut()
|
|
.unwrap()
|
|
.replacement_paths
|
|
.push("config/new-pack.cfg".to_string());
|
|
|
|
apply_cleanup(&mut job_state, &state).await.unwrap();
|
|
apply_cleanup(&mut job_state, &state).await.unwrap();
|
|
store::update_status(
|
|
job_id,
|
|
InstallJobStatus::Canceled,
|
|
&job_state,
|
|
&state,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(
|
|
crate::util::io::read(instance_base.join("mods/old-a.jar"))
|
|
.await
|
|
.unwrap(),
|
|
b"old-a-content"
|
|
);
|
|
assert_eq!(
|
|
crate::util::io::read(instance_base.join("mods/old-b.jar"))
|
|
.await
|
|
.unwrap(),
|
|
b"old-b-content"
|
|
);
|
|
assert!(!new_path.exists());
|
|
assert!(!untracked_new_path.exists());
|
|
assert_eq!(
|
|
crate::util::io::read(instance_base.join("B10_KEEP_ME.txt"))
|
|
.await
|
|
.unwrap(),
|
|
b"user-data"
|
|
);
|
|
assert_eq!(
|
|
crate::util::io::read(&user_mod_path).await.unwrap(),
|
|
b"user-mod"
|
|
);
|
|
let restored = crate::state::get_instance(&instance_id, &state.pool)
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
assert_eq!(restored.instance.id, instance_id);
|
|
assert_eq!(
|
|
restored.instance.install_stage,
|
|
InstanceInstallStage::Installed
|
|
);
|
|
assert_eq!(restored.instance.name, original.instance.name);
|
|
assert_eq!(
|
|
serde_json::to_value(&restored.link).unwrap(),
|
|
serde_json::to_value(&old_link).unwrap()
|
|
);
|
|
assert_eq!(
|
|
restored.applied_content_set.game_version,
|
|
original.applied_content_set.game_version
|
|
);
|
|
let restored_entries = content_rows::get_content_entries(
|
|
&original.applied_content_set.id,
|
|
&state.pool,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(restored_entries.len(), 3);
|
|
let restored_pack_entries = restored_entries
|
|
.iter()
|
|
.filter(|entry| {
|
|
entry.ownership_kind == ContentOwnershipKind::PackManaged
|
|
})
|
|
.collect::<Vec<_>>();
|
|
assert_eq!(restored_pack_entries.len(), 2);
|
|
assert!(restored_pack_entries.iter().all(|entry| {
|
|
entry.source_kind == ContentSourceKind::ImportedModpack
|
|
}));
|
|
for entry in restored_pack_entries {
|
|
let refs = content_rows::get_content_provider_refs_with_origin(
|
|
&entry.id,
|
|
&state.pool,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(refs.len(), 1);
|
|
assert!(refs[0].1);
|
|
}
|
|
assert_eq!(
|
|
content_rows::get_pack_members(
|
|
&original.applied_content_set.id,
|
|
&state.pool,
|
|
)
|
|
.await
|
|
.unwrap()
|
|
.len(),
|
|
original_members.len()
|
|
);
|
|
assert!(
|
|
!state
|
|
.directories
|
|
.install_rollbacks_dir()
|
|
.join(staging_id)
|
|
.exists()
|
|
);
|
|
assert!(job_state.rollback.as_ref().unwrap().content.is_none());
|
|
let persisted = store::get_required(job_id, &state).await.unwrap();
|
|
assert_eq!(persisted.status, InstallJobStatus::Canceled);
|
|
assert!(persisted.state.rollback.as_ref().unwrap().content.is_none());
|
|
assert!(persisted.state.rollback_error.is_none());
|
|
assert_eq!(
|
|
crate::state::list_instances(&state.pool)
|
|
.await
|
|
.unwrap()
|
|
.len(),
|
|
1
|
|
);
|
|
|
|
let new_instance = crate::api::instance::create(
|
|
"Canceled New Instance".to_string(),
|
|
"1.21.1".to_string(),
|
|
ModLoader::Vanilla,
|
|
None,
|
|
None,
|
|
InstanceLink::Unmanaged,
|
|
None,
|
|
None,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let new_instance_id = new_instance.instance.id.clone();
|
|
let new_instance_base = state
|
|
.directories
|
|
.instances_dir()
|
|
.join(&new_instance.instance.path);
|
|
crate::util::io::create_dir_all(new_instance_base.join("mods"))
|
|
.await
|
|
.unwrap();
|
|
crate::util::io::write(
|
|
new_instance_base.join("mods/partial.jar"),
|
|
b"partial install",
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let mut new_instance_job =
|
|
InstallJobState::new(InstallRequest::DownloadJava {
|
|
vendor: "test".to_string(),
|
|
version: 21,
|
|
});
|
|
new_instance_job.cleanup = InstallCleanup::DeleteNewInstance {
|
|
instance_id: Some(new_instance_id.clone()),
|
|
};
|
|
let cancellation = tokio_util::sync::CancellationToken::new();
|
|
let writer_cancellation = cancellation.clone();
|
|
let writer_path = new_instance_base.join("config/late-write.cfg");
|
|
let writer_lock = state
|
|
.lock_instance_content_exclusive(&new_instance_id)
|
|
.await;
|
|
let (started_tx, started_rx) = tokio::sync::oneshot::channel();
|
|
let (done_tx, done_rx) = tokio::sync::oneshot::channel();
|
|
let writer = tokio::task::spawn_blocking(move || {
|
|
let _writer_lock = writer_lock;
|
|
let _ = started_tx.send(());
|
|
while !writer_cancellation.is_cancelled() {
|
|
std::thread::yield_now();
|
|
}
|
|
std::thread::sleep(std::time::Duration::from_millis(100));
|
|
std::fs::create_dir_all(writer_path.parent().unwrap()).unwrap();
|
|
std::fs::write(writer_path, b"late write").unwrap();
|
|
let _ = done_tx.send(());
|
|
});
|
|
started_rx.await.unwrap();
|
|
cancellation.cancel();
|
|
drop(writer);
|
|
apply_cleanup(&mut new_instance_job, &state).await.unwrap();
|
|
done_rx.await.unwrap();
|
|
assert!(new_instance_job.instance_deleted());
|
|
assert!(!new_instance_base.exists());
|
|
assert!(
|
|
crate::state::get_instance(&new_instance_id, &state.pool)
|
|
.await
|
|
.unwrap()
|
|
.is_none()
|
|
);
|
|
|
|
let mut missing_instance_job =
|
|
InstallJobState::new(InstallRequest::DownloadJava {
|
|
vendor: "test".to_string(),
|
|
version: 21,
|
|
});
|
|
missing_instance_job.cleanup = InstallCleanup::DeleteNewInstance {
|
|
instance_id: Some("missing-instance".to_string()),
|
|
};
|
|
assert!(
|
|
apply_cleanup(&mut missing_instance_job, &state)
|
|
.await
|
|
.is_err(),
|
|
"new-instance cleanup must report deletion failures"
|
|
);
|
|
}
|
|
}
|