Skip to content

Fix Skyrim deployment pipeline integrity - #105

Open
cashcon57 wants to merge 12 commits into
mainfrom
fix/skyrim-pipeline-integrity
Open

Fix Skyrim deployment pipeline integrity#105
cashcon57 wants to merge 12 commits into
mainfrom
fix/skyrim-pipeline-integrity

Conversation

@cashcon57

Copy link
Copy Markdown
Owner

Summary

  • harden Skyrim deployment routing and manifest identity across data/root/custom targets
  • make atomic deploy fail closed on partial failures, unsafe paths, missing files, and rollback displaced files
  • preserve unmanaged loose files instead of overwriting/deleting them
  • copy mutable Skyrim config/text files instead of hardlinking them, including legacy hardlink repair

Verification

  • cargo test deployer --lib → 40 passed
  • cargo test --lib → 1628 passed, 0 failed, 1 ignored
  • npx svelte-check --threshold error → 0 errors, 271 warnings
  • git diff --check → clean

Review gates

  • Phase 3.1 spec: PASS; quality/security: APPROVED
  • Phase 3.3 spec: PASS; quality/security: APPROVED
  • Phase 3.4 spec: PASS; quality/security: APPROVED
  • Phase 3.5 spec: PASS; quality/security: APPROVED

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request implements target-aware deployment identity and splits mixed Root/Data mods into separate deployment batches during collection installation. It also introduces durable mod-level and per-file deploy target metadata to survive manifest purges, and ensures zero-file deployments fail instead of reporting false success. However, several critical issues were identified: the durable deploy target and base path are not persisted to the database for collection-installed mods; rolling back a mod version with a 'mixed' target incorrectly deploys root files to the data directory; mixed mod deployment does not short-circuit if the first batch fails; and unvalidated paths are checked on the filesystem before path safety validation, posing a path traversal probing risk.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +4618 to +4619
db.set_deploy_file_targets_for_mod(mod_id, &deploy_file_targets)
.map_err(|e| InstallError::Failed(e.to_string()))?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The durable deploy_target and deploy_base_path are never persisted to the database for collection-installed mods, leaving them as the default 'data'. This breaks the ability to recover the correct target (like "root", "custom", or "mixed") during a full redeploy after a manifest purge. We should call db.set_deploy_target_for_mod_with_base to persist this metadata.

    db.set_deploy_file_targets_for_mod(mod_id, &deploy_file_targets)
        .map_err(|e| InstallError::Failed(e.to_string()))?;
    db.set_deploy_target_for_mod_with_base(
        mod_id,
        deploy_target_str,
        vortex_dir.as_ref().and_then(|p| p.to_str()),
    )
    .map_err(|e| InstallError::Failed(e.to_string()))?;

Comment on lines +309 to +322
let deploy_base = if mod_target == "root" {
&game_path
} else if mod_target == "custom" {
rollback_base = db
.get_deploy_base_path_for_mod(mod_id)
.map_err(|e| e.to_string())?
.map(PathBuf::from)
.ok_or_else(|| {
"Cannot safely roll back custom deploy target without durable base path metadata".to_string()
})?;
&rollback_base
} else {
&data_dir
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

When rolling back a mod version, if mod_target is "mixed", the code falls back to using &data_dir as the single deploy_base. This is incorrect because mixed mods contain both root files (which must go to game_path) and data files (which go to data_dir). Using &data_dir as the single base will incorrectly deploy the root files to the data directory. Consider handling "mixed" target rollback by splitting files into batches or returning an error if mixed rollback is not supported via this path.

Comment on lines +4685 to +4721
let mut results = Vec::new();
if !root_files.is_empty() {
let mappings: Vec<deployer::DeployFileMapping> = root_files
.iter()
.map(|f| deployer::DeployFileMapping {
source_relative_path: f.source_relative_path.clone(),
relative_path: f.relative_path.clone(),
})
.collect();
results.push(deployer::deploy_mod_atomic_mapped_with_progress(
&db_c,
&gid,
&bn,
mod_id,
&sp,
&gp,
&mappings,
&progress_cb,
&gp,
"root",
));
}
if !data_files.is_empty() {
results.push(deployer::deploy_mod_atomic_with_progress(
&db_c,
&gid,
&bn,
mod_id,
&sp,
&data_effective_dir,
&data_files,
&progress_cb,
&gp,
&data_target,
));
}
results

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

When deploying mixed mods, if the first batch (root_files) fails, the code still proceeds to attempt deployment of the second batch (data_files). This is inefficient and can leave the filesystem in an inconsistent state before rollback. The deployment should short-circuit immediately if the first batch fails.

            let mut results = Vec::new();
            if !root_files.is_empty() {
                let mappings: Vec<deployer::DeployFileMapping> = root_files
                    .iter()
                    .map(|f| deployer::DeployFileMapping {
                        source_relative_path: f.source_relative_path.clone(),
                        relative_path: f.relative_path.clone(),
                    })
                    .collect();
                let res = deployer::deploy_mod_atomic_mapped_with_progress(
                    &db_c,
                    &gid,
                    &bn,
                    mod_id,
                    &sp,
                    &gp,
                    &mappings,
                    &progress_cb,
                    &gp,
                    "root",
                );
                let is_err = res.is_err();
                results.push(res);
                if is_err {
                    return results;
                }
            }
            if !data_files.is_empty() {
                results.push(deployer::deploy_mod_atomic_with_progress(
                    &db_c,
                    &gid,
                    &bn,
                    mod_id,
                    &sp,
                    &data_effective_dir,
                    &data_files,
                    &progress_cb,
                    &gp,
                    &data_target,
                ));
            }
            results

Comment on lines +5242 to +5253
if !root_dir.join(&rel_norm).is_file() {
continue;
}
if !crate::staging::is_safe_relative_path(&rel_norm)
|| !crate::staging::is_safe_relative_path(&source_norm)
{
log::warn!(
"Skipping unsafe Root/ file during collection deploy: {}",
rel_norm
);
continue;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-medium medium

In split_collection_root_data_files, the code performs a filesystem check root_dir.join(&rel_norm).is_file() before validating that rel_norm and source_norm are safe relative paths. Performing filesystem operations on unvalidated paths is a security risk (potential path traversal probing). The validation checks should be performed first.

            if !crate::staging::is_safe_relative_path(&rel_norm)
                || !crate::staging::is_safe_relative_path(&source_norm)
            {
                log::warn!(
                    "Skipping unsafe Root/ file during collection deploy: {}",
                    rel_norm
                );
                continue;
            }
            if !root_dir.join(&rel_norm).is_file() {
                continue;
            }

Comment on lines 752 to +755
let mod_target = db
.get_deploy_target_for_mod(mod_id)
.unwrap_or_else(|_| "data".to_string());
let effective_dir = match mod_target.as_str() {
"root" => game.game_path.clone(),
"custom" => {
// Recompute from staged file shape — we don't store
// the custom path itself, only the kind.
let (dir, _) = resolve_effective_deploy_dir(
db.set_enabled(mod_id, true).map_err(|e| e.to_string())?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: For mixed-target mods, toggle_mod calls db.set_enabled(mod_id, true) before deployment finishes, risking an inconsistent state if a crash occurs mid-deploy.
Severity: MEDIUM

Suggested Fix

Move the db.set_enabled(mod_id, true) call to execute only after all deployment operations for the mixed-target mod have successfully completed. This will ensure the database state is only updated once the file system is in a consistent state, matching the logic for other mod types.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent. Verify if this is a real issue. If it is, propose a fix; if not, explain why it's
not valid.

Location: src-tauri/src/commands/mods.rs#L752-L755

Potential issue: In the `toggle_mod` function, when enabling a "mixed" target mod, the
database is updated to mark the mod as enabled via `db.set_enabled(mod_id, true)` before
the file deployment operations are complete. If a crash or power loss occurs after this
database update but before deployment finishes, the application will be left in an
inconsistent state. Upon restart, the mod will appear as enabled in the UI but will have
incomplete or no files deployed, causing it to have no effect in-game. The pre-launch
self-healing mechanism does not correct this issue as it relies on a deployment manifest
that would not be fully created.

Did we get this right? 👍 / 👎 to inform future reviews.

Comment on lines +184 to +191
let (count, fallback) = deploy_staged_file(
&native.game_data_root,
&detected.data_dir,
&m.name,
&staging,
rel,
&bepinex_status,
)?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: The deploy_native function lacks a rollback mechanism. If a file deployment fails mid-process, already-deployed files from other mods are not cleaned up, leaving an inconsistent state.
Severity: MEDIUM

Suggested Fix

Implement an atomic deployment mechanism for deploy_native, similar to the deploy_mod_atomic function used for Wine deployments. On a file deployment error, the function should catch the error, remove all files deployed during the current operation, and restore any backed-up files before returning the error. This ensures the deployment is an all-or-nothing operation.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent. Verify if this is a real issue. If it is, propose a fix; if not, explain why it's
not valid.

Location: src-tauri/src/plugins/paralives_native.rs#L184-L191

Potential issue: The `deploy_native` function for native Paralives deployments iterates
through mods and deploys their files. If an error occurs during this process, for
example when `deploy_staged_file` fails because a BepInEx plugin is found without
BepInEx being installed, the function returns immediately. However, it does not roll
back any files that were successfully deployed from previous mods in the same operation.
This leaves the game installation in an inconsistent state, where some mods are
partially installed. This contrasts with the Wine deployment path, which uses an atomic
deployment with full rollback on failure.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant