feat(installer): implement Phase 3 — the patch tier
Some checks failed
PR Checks / rust-gates (pull_request) Failing after 46s
Some checks failed
PR Checks / rust-gates (pull_request) Failing after 46s
Two features need edits to stock ServUO sources, because the events they depend on do not exist. This adds the rung ladder of PLAN.md §2.2.1, the unsupported-version path of §2.2.2, and the record and cache Phase 4 will read. Three decisions were not settled by the plan: * The engine is fully native, with no `git`. §2.2.1 wrote rung 1 as "apply verbatim with git apply", but §1 chose the release tarball specifically so there would be no git on the shard host, and rung 2 needs a native applier regardless. Rung 1 keeps its distinct, stronger verdict — the whole file reproduced the diff's `index` pre-image, computed as a git blob SHA1 in process — while the write goes through the same code path as rung 2. On the real trees here that is not academic: the shipped .patch files are CRLF in a Windows checkout and two of their three targets are LF, so `git apply` refuses patches this applies correctly. * Per-patch metadata is declared by the release, with a built-in fallback. Which patches form one all-or-nothing unit, which companion .cs follows which, whether a CORE rebuild is needed and what declining costs are not derivable from a diff. servuo-plugins now declares them; overlay v0.1.1 is in the current bundle and declares nothing, so a built-in copy stands in for it. A checked-in fixture of the release workflow's own jq output asserts the two descriptions are identical, so the repos cannot drift quietly. * Pre-images are cached in the state directory. The tier edits files the operator owns, and `/etc/runicgateway/patches/originals/` is what turns "here are the hunks we added" into a revert anyone can verify — kept out of the ServUO tree, which uninstall has promised never to clean up. Everything else follows §2.2.1: exact matching with only line-ending and trailing-whitespace normalization, exactly one occurrence or it fails, all-or-nothing per patch file and again per feature, and a byte-preserving splice so nothing outside a hunk can be reformatted. Verified against the ServUO 57.4 tree on this machine across four scratch roots: a hand-patched tree (rung 0), a reverse-applied stock one (rung 1 on the real EventSink.cs, its blob matching the patch's declared pre-image), a mixed-rung feature, a tree with edits inside two patched regions (rung 3 — nothing written, nothing held back applied, no companions copied), and a non-57.4 tree both with and without the extra consent flag. Three consecutive runs left install.json byte-identical and the cached pre-image still pre-patch. Three reporting defects the live runs caught are fixed with tests: a dry run and a held-back patch both claimed to be "applied", the core-rebuild warning fired when nothing had been written and named a Scripts file as core, and a declined tier announced the loss of features install.json showed as applied. Refused patches are now cached too, since the refusal message names that path. Refs: docs/installer/PLAN.md §2.2, §5 Phase 3 Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
894
src/tier.rs
Normal file
894
src/tier.rs
Normal file
@@ -0,0 +1,894 @@
|
||||
//! 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`]).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn run(
|
||||
cli: &Cli,
|
||||
root: &ServUoRoot,
|
||||
unpacked: &Path,
|
||||
declared: Option<&Tier>,
|
||||
layout: &paths::Layout,
|
||||
prior: &[FeatureRecord],
|
||||
) -> Result<Outcome> {
|
||||
let tier = Tier::resolve(declared);
|
||||
if 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();
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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,
|
||||
) -> 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)?;
|
||||
}
|
||||
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(),
|
||||
));
|
||||
}
|
||||
|
||||
// ── 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}");
|
||||
}
|
||||
|
||||
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,
|
||||
) -> Result<()> {
|
||||
for r in resolved {
|
||||
if let Resolution::Applicable { edits, .. } = &r.resolution {
|
||||
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);
|
||||
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 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(_)
|
||||
));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user