All checks were successful
PR Checks / rust-gates (pull_request) Successful in 1m59s
PLAN.md §5.3. Before anything is written, every file this run will replace is copied into <state>/backups/<utc-stamp>/ with a manifest naming where each came from. --no-backup opts out; --verify takes none. Scoped by what cannot be fetched again. The sidecar binary and the overlay files are re-downloadable and hash-named in the bundle, and the database is a cache with a schema -- link's store.rs creates every table IF NOT EXISTS over shard state the sweeps repopulate. What a run can destroy for good is an operator's edits to a deployed .cs file, which Phase 1 overwrites unconditionally and by design, and sidecar.toml, whose token the website already holds. Two deviations from §5.3 as written, both found by building it: - The trigger is "this run is about to overwrite something", not "an update, or an install over an existing record". §5.3 justified the latter with "a first install overwrites nothing" -- which is not true of a tree deployed by hand per INSTALL.md Appendix A2, a documented path. There the first install finds .cs files that differ, plans them as Change, and overwrites them with no record anywhere. The direct test covers that case and still writes nothing for a genuine first install, because there is nothing to copy. - sidecar.toml joins a backup that is already being taken and is never the reason for one. Nothing here rewrites it, so making it a trigger would put a dated directory on disk after every no-op update; it is copied so a restored set of files comes with the token that matches them. The directory is created lazily and the manifest is written last, so a directory carrying one is a complete backup -- and pruning only considers those, so a run interrupted mid-copy cannot evict a good backup by being newer than it. Three are kept. uninstall keeps them and names them in its report; --purge removes them, alongside the config, the database and the cached patch set. doctor reports the newest. Restoring stays printed rather than done, as the uninstall report is: the installer cannot know what has changed since, and putting an old .cs file back over a newer overlay eats work rather than saving it. Verified live against two scratch ServUO trees built from the real 57.4 files: a clean first install leaving no backups directory at all, an update after editing a deployed .cs (copy holds the edit, tree gets the release's file, manifest lists both it and sidecar.toml), a no-op update taking none, --no-backup and --verify each taking none, a fourth backup pruning the oldest, doctor's row, uninstall keeping three and listing them, --purge removing them, and a --patches run capturing the pre-patch Logging.cs while the two rung-0 patches correctly captured nothing. fmt, clippy -D warnings and 144 tests on both Linux and Windows. Co-Authored-By: Claude <noreply@anthropic.com>
1073 lines
42 KiB
Rust
1073 lines
42 KiB
Rust
//! Running the patch tier as part of an `install`.
|
|
//!
|
|
//! [`crate::patch`] decides what may be written; this module decides whether it is offered at all,
|
|
//! writes it, reports it, and records it. The split matters because the two halves fail
|
|
//! differently: a wrong answer in `patch` corrupts a stock ServUO file, and a wrong answer here
|
|
//! means an operator was never asked, or believes something was patched that was not.
|
|
//!
|
|
//! ## Consent (PLAN.md §2.2.2)
|
|
//!
|
|
//! The tier is opt-in on every tree, and on a tree that is not stock ServUO 57.4 it is opt-in
|
|
//! *twice*:
|
|
//!
|
|
//! - The interactive prompt defaults to **no**, and on a non-57.4 tree prints an unmissable banner
|
|
//! before it is even offered — that 57.4 is the only supported version, that the operator is on
|
|
//! their own, and that a bad outcome may not surface until the shard is running.
|
|
//! - `--patches` alone is **not** consent there. An unattended run must also pass
|
|
//! `--patches-unsupported-servuo`, because a flag someone had to look up cannot be hit by
|
|
//! accident in a script copied from somewhere else.
|
|
//!
|
|
//! Withholding that second flag **skips the tier loudly; it does not fail the run.** By the time
|
|
//! this runs the overlay is deployed and the sidecar is about to be installed, and turning a
|
|
//! completed base install into exit 1 over a tier that is documented as optional would cost the
|
|
//! operator more than the tier is worth. Saying nothing would be the real failure, so the skip is
|
|
//! reported at the point it happens and again in the closing summary.
|
|
//!
|
|
//! ## All-or-nothing, at two levels
|
|
//!
|
|
//! `patch::resolve` is all-or-nothing per patch file: if one hunk reaches rung 3, none of that
|
|
//! patch's hunks are written. This module adds the second level — **per feature**. The two
|
|
//! vendor-sale patches are one unit (`EventSink.cs` grows the event, `PlayerVendorGumps.cs` raises
|
|
//! it, and the companion `BridgeVendorSale.cs` subscribes to it); applying either alone produces a
|
|
//! tree that either does not compile or silently never emits. So every patch in a feature is
|
|
//! resolved first, and nothing is written unless all of them can be.
|
|
|
|
use std::path::Path;
|
|
|
|
use anyhow::{Context, Result};
|
|
|
|
use crate::cli::{Cli, PatchChoice};
|
|
use crate::patch::{
|
|
self, AppliedPatch, CompanionRecord, Feature, FeatureRecord, Rebuild, Resolution, Rung, Tier,
|
|
};
|
|
use crate::servuo::{self, ServUoRoot};
|
|
use crate::util::{sha256_file, write_atomic};
|
|
use crate::{paths, ui};
|
|
|
|
/// What the tier did, for the closing summary and the record.
|
|
pub struct Outcome {
|
|
/// One entry per feature now in place. Written to `install.json`.
|
|
pub records: Vec<FeatureRecord>,
|
|
/// The tier ran (as opposed to being declined, skipped or unavailable).
|
|
pub ran: bool,
|
|
/// A feature whose patches touch a core file was applied, so the solution must be rebuilt —
|
|
/// ServUO's dynamic script build is not enough and will not say so.
|
|
pub core_rebuild: bool,
|
|
}
|
|
|
|
impl Outcome {
|
|
fn skipped() -> Self {
|
|
Self {
|
|
records: Vec::new(),
|
|
ran: false,
|
|
core_rebuild: false,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Decides whether the tier runs, then runs it.
|
|
///
|
|
/// `prior` is the previous run's records: on rung 0 they are preserved verbatim rather than
|
|
/// re-minted, which is what keeps a second `install` byte-identical (see [`record_for`]).
|
|
///
|
|
/// ## Scope under `update`
|
|
///
|
|
/// An `update` re-resolves **only the features a previous run recorded as applied**, and does so
|
|
/// without asking again. Two things follow from that, and both are deliberate:
|
|
///
|
|
/// - It is not a fresh offer. A shard that declined the tier stays unpatched through every update,
|
|
/// which is what "opt-in" has to mean if it means anything; the new release's features are named
|
|
/// so the operator knows they exist, and `--patches` is how they are taken up.
|
|
/// - Consent is not re-asked for what is already in the tree — including on an unsupported ServUO,
|
|
/// where `install` demanded a second flag. The record is the evidence that the operator opted in,
|
|
/// and re-prompting would make an unattended update of a working shard impossible on precisely
|
|
/// the hosts that most need the patches re-checked after an overlay moves.
|
|
///
|
|
/// Normally every one of those re-resolutions lands on rung 0 and writes nothing. When a release
|
|
/// genuinely changes a patch, it is applied through the same ladder as any other — the target is
|
|
/// still a file the operator may have edited, and nothing here loosens that check.
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub fn run(
|
|
cli: &Cli,
|
|
mode: crate::install::Mode,
|
|
root: &ServUoRoot,
|
|
unpacked: &Path,
|
|
declared: Option<&Tier>,
|
|
layout: &paths::Layout,
|
|
prior: &[FeatureRecord],
|
|
backup: &mut crate::backup::Session,
|
|
) -> Result<Outcome> {
|
|
let declared_tier = Tier::resolve(declared);
|
|
if declared_tier.features.is_empty() {
|
|
ui::row(
|
|
"Patch tier",
|
|
"not offered — this overlay declares no patches",
|
|
);
|
|
return Ok(Outcome::skipped());
|
|
}
|
|
let supported = root.is_supported_version();
|
|
|
|
// Under `update`, scope narrows to what is already applied unless --patches widens it back.
|
|
let widen = cli.patches == PatchChoice::Yes;
|
|
let tier = if mode.is_update() && !widen {
|
|
scope_to_applied(&declared_tier, prior)
|
|
} else {
|
|
declared_tier.clone()
|
|
};
|
|
|
|
if mode.is_update() && !widen {
|
|
if tier.features.is_empty() {
|
|
ui::row(
|
|
"Patch tier",
|
|
"nothing to re-check — no feature was applied by an earlier run",
|
|
);
|
|
print_available(&declared_tier);
|
|
return Ok(Outcome::skipped());
|
|
}
|
|
ui::row(
|
|
"Patch tier",
|
|
&format!(
|
|
"re-checking {} feature(s) an earlier run applied",
|
|
tier.features.len()
|
|
),
|
|
);
|
|
announce_new_features(&declared_tier, &tier);
|
|
return apply_tier(cli, root, unpacked, &tier, layout, prior, supported, backup);
|
|
}
|
|
|
|
match consent(cli, root, supported, &tier)? {
|
|
Consent::Yes => {}
|
|
Consent::No(reason) => {
|
|
ui::row("Patch tier", &reason);
|
|
print_cost(&tier, prior);
|
|
return Ok(Outcome::skipped());
|
|
}
|
|
Consent::RefusedUnsupported => {
|
|
println!();
|
|
ui::warn(&format!(
|
|
"Patch tier REQUESTED BUT NOT RUN — this tree reports ServUO {}, and \
|
|
{} is the\n only supported version. --patches on its own is not consent here.\n \
|
|
No stock ServUO file has been touched.\n\n \
|
|
To run it anyway, unsupported and untested, add --patches-unsupported-servuo.\n \
|
|
Read INSTALL.md §4 first, and back up your ServUO tree.",
|
|
root.version_display(),
|
|
servuo::SUPPORTED_VERSION
|
|
));
|
|
print_cost(&tier, prior);
|
|
return Ok(Outcome::skipped());
|
|
}
|
|
}
|
|
|
|
apply_tier(cli, root, unpacked, &tier, layout, prior, supported, backup)
|
|
}
|
|
|
|
/// The subset of a release's tier that a previous run actually applied.
|
|
fn scope_to_applied(declared: &Tier, prior: &[FeatureRecord]) -> Tier {
|
|
let applied = patch::index_records(prior);
|
|
Tier {
|
|
features: declared
|
|
.features
|
|
.iter()
|
|
.filter(|f| applied.contains_key(f.name.as_str()))
|
|
.cloned()
|
|
.collect(),
|
|
}
|
|
}
|
|
|
|
/// Names what this release offers that the shard does not have, without offering it.
|
|
///
|
|
/// An update must not quietly become the moment a shard acquires edits to stock ServUO files, but
|
|
/// an operator who never learns the feature exists cannot opt in either.
|
|
fn announce_new_features(declared: &Tier, in_scope: &Tier) {
|
|
let scoped: Vec<&str> = in_scope.features.iter().map(|f| f.name.as_str()).collect();
|
|
let new: Vec<&Feature> = declared
|
|
.features
|
|
.iter()
|
|
.filter(|f| !scoped.contains(&f.name.as_str()))
|
|
.collect();
|
|
if new.is_empty() {
|
|
return;
|
|
}
|
|
println!(
|
|
" This release also offers {} feature(s) this shard does not have:",
|
|
new.len()
|
|
);
|
|
for feature in new {
|
|
println!(" - {}", feature.summary);
|
|
}
|
|
println!(" Add them with: install --patches (they edit stock ServUO files; INSTALL.md §4)");
|
|
}
|
|
|
|
/// The tier's offer, printed by an update that has nothing of its own to re-check.
|
|
fn print_available(declared: &Tier) {
|
|
println!(" This release offers:");
|
|
for feature in &declared.features {
|
|
println!(" - {}", feature.summary);
|
|
}
|
|
println!(" Add them with: install --patches (they edit stock ServUO files; INSTALL.md §4)");
|
|
}
|
|
|
|
enum Consent {
|
|
Yes,
|
|
/// Not selected, with the reason to print.
|
|
No(String),
|
|
/// Asked for on a tree where `--patches` alone is not enough.
|
|
RefusedUnsupported,
|
|
}
|
|
|
|
fn consent(cli: &Cli, root: &ServUoRoot, supported: bool, tier: &Tier) -> Result<Consent> {
|
|
if cli.patches == PatchChoice::No {
|
|
return Ok(Consent::No("skipped (--no-patches)".into()));
|
|
}
|
|
if cli.patches == PatchChoice::Yes {
|
|
return Ok(if supported || cli.patches_unsupported_servuo {
|
|
Consent::Yes
|
|
} else {
|
|
Consent::RefusedUnsupported
|
|
});
|
|
}
|
|
|
|
// PatchChoice::Ask — offer it.
|
|
ui::heading("Patch tier (optional)");
|
|
println!(
|
|
" {} feature(s) need edits to stock ServUO files. Without them:",
|
|
tier.features.len()
|
|
);
|
|
for feature in &tier.features {
|
|
println!(" - {}", feature.summary);
|
|
}
|
|
if !supported {
|
|
print_unsupported_banner(root);
|
|
} else {
|
|
println!(
|
|
"\n Every patch is checked before anything is written, and any whose target lines are\n \
|
|
no longer stock is reported for you to apply by hand rather than forced."
|
|
);
|
|
}
|
|
|
|
// The default is no on every tree (INSTALL.md §2). `--yes` takes that default, which makes an
|
|
// unattended run that did not ask for the tier safely skip it.
|
|
match ui::confirm("Apply the patch tier?", false, cli.assume_yes) {
|
|
Ok(true) => Ok(Consent::Yes),
|
|
Ok(false) => Ok(Consent::No("not selected".into())),
|
|
// A piped run with no --yes cannot answer, and the base install has already succeeded.
|
|
// Declining for it is the documented default, so this is a note rather than a failure.
|
|
Err(_) => Ok(Consent::No(
|
|
"not selected (no terminal to ask; pass --patches to enable)".into(),
|
|
)),
|
|
}
|
|
}
|
|
|
|
fn print_unsupported_banner(root: &ServUoRoot) {
|
|
println!();
|
|
println!(" ┌──────────────────────────────────────────────────────────────────────────────┐");
|
|
println!(" │ ⚠ UNSUPPORTED, UNTESTED, NOT GUARANTEED TO WORK │");
|
|
println!(" └──────────────────────────────────────────────────────────────────────────────┘");
|
|
println!(
|
|
" This tree reports ServUO {}. Runic Gateway is designed, built and tested against\n \
|
|
stock ServUO {} — that is the only supported version.",
|
|
root.version_display(),
|
|
servuo::SUPPORTED_VERSION
|
|
);
|
|
println!(
|
|
"\n You may run the tier here. If you do, you are on your own: it is not covered by\n \
|
|
support, and a bad outcome may not show up until your shard is live, because ServUO's\n \
|
|
script build reports success even when it failed and quietly keeps running the previous\n \
|
|
Scripts.dll."
|
|
);
|
|
println!(
|
|
"\n A patch is still refused wherever the exact lines it edits have changed — but matching\n \
|
|
text is not matching behaviour. A hunk can land correctly and still be wrong for a tree\n \
|
|
that has diverged around it."
|
|
);
|
|
println!(
|
|
"\n Back up your ServUO tree first, and verify your shard boots and compiles afterwards."
|
|
);
|
|
}
|
|
|
|
/// What declining costs — counting only the features that are not already in place.
|
|
///
|
|
/// A decline does not inspect the tree, so the previous run's record is the only evidence
|
|
/// available. Ignoring it produced a run that skipped the tier and then announced the loss of two
|
|
/// features `install.json` shows as applied, which is worse than saying nothing: an operator
|
|
/// reading it would go looking for a problem that does not exist.
|
|
fn print_cost(tier: &Tier, prior: &[FeatureRecord]) {
|
|
let applied = patch::index_records(prior);
|
|
let lost: Vec<&str> = tier
|
|
.features
|
|
.iter()
|
|
.filter(|f| !applied.contains_key(f.name.as_str()))
|
|
.map(|f| f.lost.as_str())
|
|
.collect();
|
|
|
|
if lost.is_empty() && !applied.is_empty() {
|
|
println!(" Everything this tier provides is already applied — nothing was changed.");
|
|
} else {
|
|
println!(" Without it: {}.", lost.join(", "));
|
|
if !applied.is_empty() {
|
|
println!(
|
|
" ({} already applied by an earlier run and left in place.)",
|
|
applied.keys().cloned().collect::<Vec<_>>().join(", ")
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Resolves and applies every feature.
|
|
#[allow(clippy::too_many_arguments)]
|
|
fn apply_tier(
|
|
cli: &Cli,
|
|
root: &ServUoRoot,
|
|
unpacked: &Path,
|
|
tier: &Tier,
|
|
layout: &paths::Layout,
|
|
prior: &[FeatureRecord],
|
|
supported: bool,
|
|
backup: &mut crate::backup::Session,
|
|
) -> Result<Outcome> {
|
|
let previous = patch::index_records(prior);
|
|
let mut records: Vec<FeatureRecord> = Vec::new();
|
|
let mut lines: Vec<String> = Vec::new();
|
|
let mut lost: Vec<&str> = Vec::new();
|
|
let mut applied_patches = 0usize;
|
|
let mut core_rebuild = false;
|
|
let mut core_targets: Vec<String> = Vec::new();
|
|
|
|
for feature in &tier.features {
|
|
let resolved = resolve_feature(root, unpacked, feature)?;
|
|
let placeable = resolved.iter().all(|r| r.resolution.rung().is_some());
|
|
|
|
for r in &resolved {
|
|
lines.push(render(feature, r, placeable, layout, cli.verify));
|
|
}
|
|
|
|
// Every patch the tier *evaluated* is cached, applied or not. `uninstall` needs the applied
|
|
// ones to print the exact hunks to revert long after the release tarball is gone (PLAN.md
|
|
// §5) — and a refused one is the file this run has just told the operator to apply by hand,
|
|
// so pointing them at a path that only exists on success would be the less useful half.
|
|
if !cli.verify {
|
|
cache_patches(&resolved, layout)?;
|
|
}
|
|
|
|
if !placeable {
|
|
lost.push(&feature.lost);
|
|
continue;
|
|
}
|
|
|
|
if !cli.verify {
|
|
write_feature(root, unpacked, feature, &resolved, layout, backup)?;
|
|
}
|
|
applied_patches += resolved.len();
|
|
|
|
// The rebuild warning is about files this run *edited*, not about every file the feature
|
|
// covers. A feature whose patches were all already present changed nothing, so telling the
|
|
// operator to rebuild the core would be noise — and on a re-run, noise that recurs forever.
|
|
// The list names only the files actually written, since a core feature can also carry
|
|
// patches against Scripts files, and calling one of those a core file is simply wrong.
|
|
let written: Vec<String> = resolved
|
|
.iter()
|
|
.filter(|r| matches!(r.resolution, Resolution::Applicable { .. }))
|
|
.map(|r| r.target.clone())
|
|
.collect();
|
|
if feature.rebuild == Rebuild::Core && !written.is_empty() {
|
|
core_rebuild = true;
|
|
core_targets.extend(written);
|
|
}
|
|
records.push(record_for(
|
|
feature,
|
|
&resolved,
|
|
root,
|
|
supported,
|
|
previous.get(feature.name.as_str()).copied(),
|
|
));
|
|
}
|
|
|
|
// A feature an earlier run applied that this release no longer declares still has its edits
|
|
// sitting in the ServUO tree. Its record is carried through rather than dropped: `uninstall`
|
|
// renders the hunks to revert from these entries, and a record that quietly forgot them would
|
|
// leave the operator with modified stock files and nothing saying so.
|
|
let in_scope: Vec<&str> = tier.features.iter().map(|f| f.name.as_str()).collect();
|
|
let carried: Vec<&FeatureRecord> = prior
|
|
.iter()
|
|
.filter(|r| !in_scope.contains(&r.feature.as_str()))
|
|
.collect();
|
|
for record in &carried {
|
|
records.push((*record).clone());
|
|
}
|
|
|
|
// ── Report ───────────────────────────────────────────────────────────────
|
|
println!();
|
|
ui::row(
|
|
"Patch tier",
|
|
&format!(
|
|
"{applied_patches} of {} {}",
|
|
tier.patch_count(),
|
|
if cli.verify {
|
|
"would be applied [--verify]"
|
|
} else {
|
|
"applied"
|
|
}
|
|
),
|
|
);
|
|
for line in lines {
|
|
println!("{line}");
|
|
}
|
|
for record in &carried {
|
|
println!(
|
|
" · {:<30} applied by an earlier run; this release's tier does not describe it,\n \
|
|
so it was left exactly as it is and its record kept",
|
|
record.feature
|
|
);
|
|
}
|
|
|
|
if core_rebuild {
|
|
println!();
|
|
ui::warn(&format!(
|
|
"{} — a CORE ServUO file was patched. Rebuild the solution:\n \
|
|
dotnet build ServUO.sln\n \
|
|
A shard restart is not enough; ServUO's dynamic script build does not rebuild the \
|
|
core, and it will not tell you so.",
|
|
core_targets.join(", ")
|
|
));
|
|
}
|
|
if !lost.is_empty() {
|
|
println!();
|
|
println!(" Not applied, so you do not get: {}.", lost.join(", "));
|
|
println!(
|
|
" Everything else works. Apply the hunks by hand if you want them, then re-run \
|
|
install to record it."
|
|
);
|
|
}
|
|
if cli.verify {
|
|
println!("\n VERIFY only. No ServUO file was edited and no patch was cached.");
|
|
}
|
|
|
|
Ok(Outcome {
|
|
records,
|
|
ran: true,
|
|
core_rebuild: core_rebuild && !cli.verify,
|
|
})
|
|
}
|
|
|
|
/// One patch, resolved against the tree.
|
|
struct Resolved {
|
|
name: String,
|
|
/// Relative to the ServUO root.
|
|
target: String,
|
|
/// Relative to the extracted release.
|
|
file: String,
|
|
bytes: Vec<u8>,
|
|
content: Vec<u8>,
|
|
resolution: Resolution,
|
|
}
|
|
|
|
fn resolve_feature(root: &ServUoRoot, unpacked: &Path, feature: &Feature) -> Result<Vec<Resolved>> {
|
|
let mut out = Vec::with_capacity(feature.patches.len());
|
|
for declared in &feature.patches {
|
|
let (bytes, parsed) = patch::load(unpacked, declared)?;
|
|
let target = patch::join(&root.path, &declared.target);
|
|
|
|
let (content, resolution) = match std::fs::read(&target) {
|
|
Ok(content) => {
|
|
let resolution = patch::resolve(&parsed, &content);
|
|
(content, resolution)
|
|
}
|
|
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
|
(Vec::new(), Resolution::Refused(patch::Refusal::Missing))
|
|
}
|
|
Err(error) => (
|
|
Vec::new(),
|
|
Resolution::Refused(patch::Refusal::Unreadable(error.to_string())),
|
|
),
|
|
};
|
|
|
|
out.push(Resolved {
|
|
name: declared.name.clone(),
|
|
target: declared.target.clone(),
|
|
file: declared.file.clone(),
|
|
bytes,
|
|
content,
|
|
resolution,
|
|
});
|
|
}
|
|
Ok(out)
|
|
}
|
|
|
|
/// Copies every evaluated patch into the state directory.
|
|
///
|
|
/// Deliberately separate from [`write_feature`] and called for **refused** features too, because
|
|
/// the refusal message names this path as the file to apply by hand. Caching only what applied
|
|
/// would make that message point at a file the run had chosen not to write.
|
|
fn cache_patches(resolved: &[Resolved], layout: &paths::Layout) -> Result<()> {
|
|
for r in resolved {
|
|
let cached = layout.patches_dir().join(file_name(&r.file));
|
|
write_atomic(&cached, &r.bytes).with_context(|| {
|
|
format!(
|
|
"cannot cache {} — the run needs somewhere to put the patch it is about to \
|
|
reference",
|
|
r.name
|
|
)
|
|
})?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Writes one feature: the patched files and its companion sources.
|
|
///
|
|
/// Reached only when every patch in the feature is placeable, so a partial write is not a state
|
|
/// this function can produce. The pre-image is cached **before** the file is written and never
|
|
/// overwritten afterwards, so it stays the content from before the tier first touched it.
|
|
fn write_feature(
|
|
root: &ServUoRoot,
|
|
unpacked: &Path,
|
|
feature: &Feature,
|
|
resolved: &[Resolved],
|
|
layout: &paths::Layout,
|
|
backup: &mut crate::backup::Session,
|
|
) -> Result<()> {
|
|
for r in resolved {
|
|
if let Resolution::Applicable { edits, .. } = &r.resolution {
|
|
// `patches/originals/` holds the pre-*tier* copy and is never overwritten, which is the
|
|
// right thing to revert to. It is not a copy of what this file looked like before *this*
|
|
// run, though — on a second tier pass the operator's own later edits are only in the
|
|
// backup (PLAN.md §5.3).
|
|
backup.capture(
|
|
&patch::join(&root.path, &r.target),
|
|
crate::backup::Reason::PatchTarget,
|
|
)?;
|
|
let original = patch::join(&layout.patch_originals_dir(), &r.target);
|
|
if !original.exists() {
|
|
write_atomic(&original, &r.content)
|
|
.with_context(|| format!("cannot save the pre-patch copy of {}", r.target))?;
|
|
}
|
|
let patched = patch::apply(&r.content, edits);
|
|
let path = patch::join(&root.path, &r.target);
|
|
write_atomic(&path, &patched)
|
|
.with_context(|| format!("cannot write the patched {}", r.target))?;
|
|
}
|
|
}
|
|
|
|
// Companions last, and only now: they reference symbols the patches introduce, so a companion
|
|
// copied beside an unpatched file is a shard that does not compile — and ServUO would report a
|
|
// clean boot anyway (PLAN.md §2.1).
|
|
for companion in &feature.companions {
|
|
let src = patch::join(unpacked, &companion.file);
|
|
let dst = patch::join(&root.path, &companion.install_to);
|
|
// Copied unconditionally, like every other `.cs` the overlay owns — so an operator who
|
|
// edited one loses it here unless a copy is taken first.
|
|
backup.capture(&dst, crate::backup::Reason::PatchCompanion)?;
|
|
if let Some(parent) = dst.parent() {
|
|
std::fs::create_dir_all(parent)
|
|
.with_context(|| format!("cannot create {}", parent.display()))?;
|
|
}
|
|
std::fs::copy(&src, &dst).with_context(|| {
|
|
format!(
|
|
"cannot copy {} into the ServUO tree — the patches applied, so this file is \
|
|
required for the shard to compile",
|
|
companion.install_to
|
|
)
|
|
})?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Builds the `install.json` entry for an applied feature.
|
|
///
|
|
/// **A rung-0 result reuses the previous record whole.** The rung is the support-relevant fact —
|
|
/// how did this land? — and re-deriving it on a later run answers `already-present` for something
|
|
/// that first landed as `region-match`. That flip would rewrite `install.json` on the second run of
|
|
/// an otherwise-identical install, which is the same class of bug as the `Bridge.cfg` comparison in
|
|
/// `overlay::plan`: a record that describes the run instead of the state.
|
|
fn record_for(
|
|
feature: &Feature,
|
|
resolved: &[Resolved],
|
|
root: &ServUoRoot,
|
|
supported: bool,
|
|
prior: Option<&FeatureRecord>,
|
|
) -> FeatureRecord {
|
|
let all_already_present = resolved
|
|
.iter()
|
|
.all(|r| r.resolution.rung() == Some(Rung::AlreadyPresent));
|
|
if all_already_present {
|
|
if let Some(prior) = prior {
|
|
return prior.clone();
|
|
}
|
|
}
|
|
|
|
FeatureRecord {
|
|
feature: feature.name.clone(),
|
|
rebuild: feature.rebuild,
|
|
servuo_version: root.version.clone(),
|
|
unsupported_servuo: !supported,
|
|
patches: resolved
|
|
.iter()
|
|
.map(|r| AppliedPatch {
|
|
name: r.name.clone(),
|
|
target: r.target.clone(),
|
|
rung: r
|
|
.resolution
|
|
.rung()
|
|
.map(Rung::as_str)
|
|
.unwrap_or("unknown")
|
|
.to_string(),
|
|
sha256: crate::util::sha256_bytes(&r.bytes),
|
|
hunks: r.resolution.placements().to_vec(),
|
|
})
|
|
.collect(),
|
|
companions: feature
|
|
.companions
|
|
.iter()
|
|
.map(|c| CompanionRecord {
|
|
path: c.install_to.clone(),
|
|
// Hashed from the tree after the copy, so the record describes what is actually
|
|
// there — which is what lets `doctor` notice a companion that was later edited.
|
|
sha256: sha256_file(&patch::join(&root.path, &c.install_to)).unwrap_or_default(),
|
|
})
|
|
.collect(),
|
|
}
|
|
}
|
|
|
|
/// One reported line per patch, in the layout INSTALL.md §4 illustrates.
|
|
fn render(
|
|
feature: &Feature,
|
|
r: &Resolved,
|
|
placeable: bool,
|
|
layout: &paths::Layout,
|
|
verify: bool,
|
|
) -> String {
|
|
let mark = if placeable { "✓" } else { "✗" };
|
|
let mut out = format!(" {mark} {:<30} {}", r.name, r.target);
|
|
|
|
let detail = match &r.resolution {
|
|
Resolution::AlreadyPresent { .. } => Rung::AlreadyPresent.detail().to_string(),
|
|
Resolution::Applicable { rung, hunks, .. } => {
|
|
let at = hunks
|
|
.iter()
|
|
.map(|h| h.matched_line.to_string())
|
|
.collect::<Vec<_>>()
|
|
.join(", ");
|
|
// "applied" is only ever claimed for something that was actually written. A dry run
|
|
// wrote nothing, and a patch held back by its feature's all-or-nothing rule was
|
|
// resolvable but left alone — reporting either as applied is the exact misreading this
|
|
// whole tier is built to avoid.
|
|
let verb = match (placeable, verify) {
|
|
(true, false) => "applied at line",
|
|
(true, true) => "would be applied at line",
|
|
(false, _) => "could have been placed at line",
|
|
};
|
|
format!("{} — {verb} {at}", rung.detail())
|
|
}
|
|
Resolution::Refused(refusal) => refusal.detail(),
|
|
};
|
|
out.push_str(&format!("\n {detail}"));
|
|
|
|
// A feature is all-or-nothing, so a patch that could have been placed is still not written when
|
|
// a sibling could not. Saying "applied" there would be a lie the operator finds out about later.
|
|
if !placeable {
|
|
if r.resolution.rung().is_some() {
|
|
out.push_str(&format!(
|
|
"\n held back — {} is applied as one unit and a sibling patch could not be \
|
|
placed",
|
|
feature.name
|
|
));
|
|
}
|
|
if !verify {
|
|
out.push_str(&format!(
|
|
"\n apply this by hand, then re-run install to record it:\n {}",
|
|
layout.patches_dir().join(file_name(&r.file)).display()
|
|
));
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
fn file_name(rel: &str) -> &str {
|
|
rel.rsplit('/').next().unwrap_or(rel)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::patch::{Companion, PatchRef};
|
|
|
|
fn feature() -> Feature {
|
|
Feature {
|
|
name: "vendor-sale".into(),
|
|
summary: "vendor.sale events".into(),
|
|
lost: "no vendor.sale events".into(),
|
|
rebuild: Rebuild::Core,
|
|
patches: vec![PatchRef {
|
|
name: "playervendor-sale-eventsink".into(),
|
|
file: "patches/playervendor-sale-eventsink.patch".into(),
|
|
target: "Server/EventSink.cs".into(),
|
|
}],
|
|
companions: vec![Companion {
|
|
file: "patches/BridgeVendorSale.cs".into(),
|
|
install_to: "Scripts/Custom/Bridge/BridgeVendorSale.cs".into(),
|
|
}],
|
|
}
|
|
}
|
|
|
|
fn resolved(resolution: Resolution) -> Resolved {
|
|
Resolved {
|
|
name: "playervendor-sale-eventsink".into(),
|
|
target: "Server/EventSink.cs".into(),
|
|
file: "patches/playervendor-sale-eventsink.patch".into(),
|
|
bytes: b"--- a/x\n".to_vec(),
|
|
content: Vec::new(),
|
|
resolution,
|
|
}
|
|
}
|
|
|
|
fn root() -> ServUoRoot {
|
|
ServUoRoot {
|
|
path: std::path::PathBuf::from("/opt/ServUO"),
|
|
version: Some("57.4".into()),
|
|
}
|
|
}
|
|
|
|
fn layout() -> paths::Layout {
|
|
paths::Layout {
|
|
state_dir: std::path::PathBuf::from("/etc/runicgateway"),
|
|
data_dir: std::path::PathBuf::from("/var/lib/runicgateway"),
|
|
sidecar_bin: std::path::PathBuf::from("/usr/bin/runicgateway-link"),
|
|
relocated: false,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn a_refused_patch_points_at_the_cached_file_to_apply_by_hand() {
|
|
let r = resolved(Resolution::Refused(patch::Refusal::RegionModified {
|
|
hunk: 2,
|
|
}));
|
|
let line = render(&feature(), &r, false, &layout(), false);
|
|
assert!(line.contains('✗'), "{line}");
|
|
assert!(line.contains("patched region has been modified"), "{line}");
|
|
assert!(
|
|
line.contains("playervendor-sale-eventsink.patch"),
|
|
"the operator needs the path of the file to apply: {line}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_placeable_patch_held_back_by_its_sibling_says_so() {
|
|
// The failure this prevents: reporting a patch as applied because it *could* have been,
|
|
// when the feature's all-or-nothing rule meant nothing was written.
|
|
let r = resolved(Resolution::Applicable {
|
|
rung: Rung::RegionMatch,
|
|
hunks: vec![patch::HunkPlacement {
|
|
declared_line: 171,
|
|
matched_line: 173,
|
|
}],
|
|
edits: Vec::new(),
|
|
});
|
|
let line = render(&feature(), &r, false, &layout(), false);
|
|
assert!(line.contains("held back"), "{line}");
|
|
assert!(line.contains("as one unit"), "{line}");
|
|
}
|
|
|
|
#[test]
|
|
fn declining_does_not_claim_a_loss_that_an_earlier_run_already_prevented() {
|
|
// Caught live: `--no-patches` on a host whose install.json shows both features applied
|
|
// still announced the loss of both. A decline inspects nothing, so the record is the only
|
|
// evidence there is — and ignoring it sends an operator looking for a problem they do not
|
|
// have.
|
|
let tier = Tier::builtin();
|
|
let applied = |name: &str| FeatureRecord {
|
|
feature: name.into(),
|
|
rebuild: Rebuild::Core,
|
|
servuo_version: Some("57.4".into()),
|
|
unsupported_servuo: false,
|
|
patches: Vec::new(),
|
|
companions: Vec::new(),
|
|
};
|
|
|
|
let all: Vec<FeatureRecord> = tier.features.iter().map(|f| applied(&f.name)).collect();
|
|
let none: Vec<FeatureRecord> = Vec::new();
|
|
|
|
// The three cases differ only in what the record holds, so assert on the filtering itself
|
|
// rather than on captured stdout.
|
|
let remaining = |prior: &[FeatureRecord]| -> Vec<String> {
|
|
let have = patch::index_records(prior);
|
|
tier.features
|
|
.iter()
|
|
.filter(|f| !have.contains_key(f.name.as_str()))
|
|
.map(|f| f.lost.clone())
|
|
.collect()
|
|
};
|
|
assert_eq!(remaining(&none).len(), 2, "a fresh host loses both");
|
|
assert!(
|
|
remaining(&all).is_empty(),
|
|
"a fully patched host loses nothing"
|
|
);
|
|
assert_eq!(
|
|
remaining(&[applied("vendor-sale")]),
|
|
vec!["no in-game moderation audit forwarding".to_string()],
|
|
"only the feature that is genuinely absent is named"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn an_update_re_checks_only_what_an_earlier_run_applied() {
|
|
// The scope rule for `update`: it must not become the moment a shard acquires edits to
|
|
// stock ServUO files, and it must not stop re-checking the ones it already has.
|
|
let tier = Tier::builtin();
|
|
let applied = |name: &str| FeatureRecord {
|
|
feature: name.into(),
|
|
rebuild: Rebuild::Scripts,
|
|
servuo_version: Some("57.4".into()),
|
|
unsupported_servuo: false,
|
|
patches: Vec::new(),
|
|
companions: Vec::new(),
|
|
};
|
|
|
|
let scoped = scope_to_applied(&tier, &[applied("moderation-audit")]);
|
|
assert_eq!(scoped.features.len(), 1);
|
|
assert_eq!(scoped.features[0].name, "moderation-audit");
|
|
|
|
// A host that declined the tier stays declined through every update.
|
|
assert!(scope_to_applied(&tier, &[]).features.is_empty());
|
|
// And one that took everything keeps re-checking everything.
|
|
let all: Vec<FeatureRecord> = tier.features.iter().map(|f| applied(&f.name)).collect();
|
|
assert_eq!(
|
|
scope_to_applied(&tier, &all).features.len(),
|
|
tier.features.len()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_feature_the_release_no_longer_declares_is_still_scoped_out_not_forgotten() {
|
|
// `scope_to_applied` can only return what the release declares, so a record for a feature
|
|
// that has been withdrawn falls outside it — which is exactly why `apply_tier` carries such
|
|
// records through instead of rebuilding the section from what it processed.
|
|
let tier = Tier {
|
|
features: vec![feature()],
|
|
};
|
|
let withdrawn = FeatureRecord {
|
|
feature: "some-old-feature".into(),
|
|
rebuild: Rebuild::Scripts,
|
|
servuo_version: Some("57.4".into()),
|
|
unsupported_servuo: false,
|
|
patches: Vec::new(),
|
|
companions: Vec::new(),
|
|
};
|
|
assert!(scope_to_applied(&tier, std::slice::from_ref(&withdrawn))
|
|
.features
|
|
.is_empty());
|
|
|
|
let in_scope: Vec<&str> = tier.features.iter().map(|f| f.name.as_str()).collect();
|
|
assert!(!in_scope.contains(&withdrawn.feature.as_str()));
|
|
}
|
|
|
|
#[test]
|
|
fn nothing_that_was_not_written_is_reported_as_applied() {
|
|
// Both halves caught on a live tree: a --verify run said "applied at line 75", and a patch
|
|
// held back by its sibling said "applied" on the line above the one explaining it was not.
|
|
let r = || {
|
|
resolved(Resolution::Applicable {
|
|
rung: Rung::RegionMatch,
|
|
hunks: vec![patch::HunkPlacement {
|
|
declared_line: 75,
|
|
matched_line: 75,
|
|
}],
|
|
edits: Vec::new(),
|
|
})
|
|
};
|
|
let dry = render(&feature(), &r(), true, &layout(), true);
|
|
assert!(dry.contains("would be applied at line 75"), "{dry}");
|
|
|
|
let held = render(&feature(), &r(), false, &layout(), false);
|
|
assert!(held.contains("could have been placed at line 75"), "{held}");
|
|
assert!(!held.contains("— applied at"), "{held}");
|
|
|
|
let real = render(&feature(), &r(), true, &layout(), false);
|
|
assert!(real.contains("applied at line 75"), "{real}");
|
|
}
|
|
|
|
#[test]
|
|
fn an_applied_patch_reports_the_line_it_matched_not_the_one_it_declared() {
|
|
// The declared line is advisory: an insertion above the region shifts it. Printing the
|
|
// declared number would send an operator to the wrong place in their own file.
|
|
let r = resolved(Resolution::Applicable {
|
|
rung: Rung::RegionMatch,
|
|
hunks: vec![patch::HunkPlacement {
|
|
declared_line: 171,
|
|
matched_line: 1180,
|
|
}],
|
|
edits: Vec::new(),
|
|
});
|
|
let line = render(&feature(), &r, true, &layout(), false);
|
|
assert!(line.contains("applied at line 1180"), "{line}");
|
|
assert!(!line.contains("171"), "{line}");
|
|
assert!(
|
|
line.contains("file modified, patched region stock"),
|
|
"{line}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_rung_zero_rerun_keeps_the_rung_that_first_applied_it() {
|
|
// Idempotence: re-deriving would answer `already-present` for something that landed as
|
|
// `region-match`, rewriting install.json on the second run of an identical install.
|
|
let first = record_for(
|
|
&feature(),
|
|
&[resolved(Resolution::Applicable {
|
|
rung: Rung::RegionMatch,
|
|
hunks: vec![patch::HunkPlacement {
|
|
declared_line: 171,
|
|
matched_line: 173,
|
|
}],
|
|
edits: Vec::new(),
|
|
})],
|
|
&root(),
|
|
true,
|
|
None,
|
|
);
|
|
assert_eq!(first.patches[0].rung, "region-match");
|
|
|
|
let second = record_for(
|
|
&feature(),
|
|
&[resolved(Resolution::AlreadyPresent {
|
|
hunks: vec![patch::HunkPlacement {
|
|
declared_line: 171,
|
|
matched_line: 173,
|
|
}],
|
|
})],
|
|
&root(),
|
|
true,
|
|
Some(&first),
|
|
);
|
|
assert_eq!(
|
|
second, first,
|
|
"a second run must record exactly the same thing"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_hand_patched_tree_with_no_prior_record_is_recorded_as_already_present() {
|
|
// The other half: someone applied the hunks by hand per INSTALL.md Appendix A2, then ran
|
|
// the installer. There is nothing to preserve, so the state it finds is what it records.
|
|
let record = record_for(
|
|
&feature(),
|
|
&[resolved(Resolution::AlreadyPresent {
|
|
hunks: vec![patch::HunkPlacement {
|
|
declared_line: 171,
|
|
matched_line: 171,
|
|
}],
|
|
})],
|
|
&root(),
|
|
true,
|
|
None,
|
|
);
|
|
assert_eq!(record.patches[0].rung, "already-present");
|
|
assert!(!record.unsupported_servuo);
|
|
}
|
|
|
|
#[test]
|
|
fn an_unsupported_tree_labels_the_record_it_writes() {
|
|
// The label follows the install (PLAN.md §2.2.2): whoever inherits this shard must be able
|
|
// to see it from install.json without being told.
|
|
let mut root = root();
|
|
root.version = Some("58.1".into());
|
|
let record = record_for(
|
|
&feature(),
|
|
&[resolved(Resolution::Applicable {
|
|
rung: Rung::RegionMatch,
|
|
hunks: Vec::new(),
|
|
edits: Vec::new(),
|
|
})],
|
|
&root,
|
|
false,
|
|
None,
|
|
);
|
|
assert!(record.unsupported_servuo);
|
|
assert_eq!(record.servuo_version.as_deref(), Some("58.1"));
|
|
}
|
|
|
|
#[test]
|
|
fn the_core_rebuild_warning_names_only_files_this_run_wrote() {
|
|
// Caught on a live tree whose vendor-sale patches were already applied by hand: the run
|
|
// wrote nothing and still demanded a core rebuild, listing a Scripts file as core. On a
|
|
// re-run that warning would recur forever, which is how a real one stops being read.
|
|
let already = [resolved(Resolution::AlreadyPresent { hunks: Vec::new() })];
|
|
let written: Vec<String> = already
|
|
.iter()
|
|
.filter(|r| matches!(r.resolution, Resolution::Applicable { .. }))
|
|
.map(|r| r.target.clone())
|
|
.collect();
|
|
assert!(
|
|
written.is_empty(),
|
|
"nothing was written, so nothing to rebuild"
|
|
);
|
|
|
|
let fresh = [resolved(Resolution::Applicable {
|
|
rung: Rung::StockHash,
|
|
hunks: Vec::new(),
|
|
edits: Vec::new(),
|
|
})];
|
|
let written: Vec<String> = fresh
|
|
.iter()
|
|
.filter(|r| matches!(r.resolution, Resolution::Applicable { .. }))
|
|
.map(|r| r.target.clone())
|
|
.collect();
|
|
assert_eq!(written, vec!["Server/EventSink.cs".to_string()]);
|
|
}
|
|
|
|
#[test]
|
|
fn patches_alone_is_not_consent_on_an_unsupported_tree() {
|
|
let mut cli = Cli {
|
|
patches: PatchChoice::Yes,
|
|
..Cli::default()
|
|
};
|
|
assert!(matches!(
|
|
consent(&cli, &root(), false, &Tier::builtin()).unwrap(),
|
|
Consent::RefusedUnsupported
|
|
));
|
|
|
|
// ...and the separate flag is.
|
|
cli.patches_unsupported_servuo = true;
|
|
assert!(matches!(
|
|
consent(&cli, &root(), false, &Tier::builtin()).unwrap(),
|
|
Consent::Yes
|
|
));
|
|
|
|
// On a supported tree the extra flag is not needed and is simply ignored.
|
|
let plain = Cli {
|
|
patches: PatchChoice::Yes,
|
|
..Cli::default()
|
|
};
|
|
assert!(matches!(
|
|
consent(&plain, &root(), true, &Tier::builtin()).unwrap(),
|
|
Consent::Yes
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn no_patches_declines_without_asking_anything() {
|
|
let cli = Cli {
|
|
patches: PatchChoice::No,
|
|
..Cli::default()
|
|
};
|
|
// False for `supported` too: an explicit decline is never escalated into a prompt.
|
|
assert!(matches!(
|
|
consent(&cli, &root(), false, &Tier::builtin()).unwrap(),
|
|
Consent::No(_)
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn an_unattended_run_that_did_not_ask_for_the_tier_takes_the_no_default() {
|
|
// --yes means "take the default answer", and the default is no on every tree (INSTALL.md
|
|
// §2). An unattended install must not start editing stock files because nobody objected.
|
|
let cli = Cli {
|
|
patches: PatchChoice::Ask,
|
|
assume_yes: true,
|
|
..Cli::default()
|
|
};
|
|
assert!(matches!(
|
|
consent(&cli, &root(), true, &Tier::builtin()).unwrap(),
|
|
Consent::No(_)
|
|
));
|
|
}
|
|
}
|