5 Commits

Author SHA1 Message Date
ab2efb62e4 feat(rust): RunicNPC as the Rust bundle's third artefact (runicnpc stage 4, D224)
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 1m27s
The compose job carries the latest RunicNPC release that answers the API the
bridge's manifest declares (runicnpc_api), verified against its SHA256SUMS
like the other two; none when the bridge needs none, RunicNPC has not
released, or its API is too old, each said in the summary. Walked against a
mock Gitea in all three cases.

The installer reads the optional npc component (older bundles still parse),
fetches and checks RunicNPC's tarball against its manifest, places
RunicNPC.cs before the bridge in each instance's plugins directory, records
it, puts it back on update when edited or deleted, reports it in doctor, and
removes it on uninstall. Its data directory is never touched. Kits joins the
required plugins it reports.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-09-30 04:52:36 -05:00
c68a7d9d32 Merge pull request 'feat(rust): doctor says what a missing ZoneDomes helper costs' (#36) from feat/rust-domes-helper into main
All checks were successful
sync-project-tree / sync (push) Successful in 8s
Release installer / release (push) Successful in 6m0s
Compose bundle / compose (push) Successful in 22s
Reviewed-on: #36
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-09-29 16:28:32 +00:00
939dd0465d feat(rust): doctor says what a missing ZoneDomes helper costs
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 1m4s
RunicGatewayDomes.cs (PLAN_REDESIGNS §3.2, D194) ships beside the bridge
from the next plugin release; the installer already places every helper
the manifest lists. doctor now names its absence in the operator's words:
zones still open, but the site offers no dome.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-09-29 05:25:44 -05:00
331e30710e Merge pull request 'feat(rust): install the helpers a plugin release ships beside the bridge (D182)' (#35) from feat/rust-plugin-helpers into main
All checks were successful
sync-project-tree / sync (push) Successful in 10s
Release installer / release (push) Successful in 6m12s
Compose bundle / compose (push) Successful in -1m19s
Reviewed-on: #35
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-09-27 06:11:18 +00:00
9d9ee3344b feat(rust): install the helpers a plugin release ships beside the bridge (D182)
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 1m37s
Rust-Plugins now releases a ZoneManager helper, RunicGatewayZones.cs, beside
RunicGateway.cs (docs/modules/rust/PLAN_FIXES.md D181, D182), and the org lead
decided it is installed by default. The tarball reader took only the bridge,
so the helper would have been downloaded and dropped.

- plugin::read_tarball reads every other .cs the release's manifest lists in
  `files`, and refuses the release when one is missing, does not match its own
  sha256, or is not a plain `<Name>.cs` (the name is written into a plugins
  directory, so nothing in a tarball may reach outside it). A .cs the manifest
  does not list is ignored, so a release older than helpers has none.
- install/update place each helper before the bridge, in place like the
  bridge (Oxide and Carbon reload on a change, not on a rename), put back one
  that was deleted or edited, and remove one a later release stops shipping.
- The record gains per-instance `helpers` (path + sha256). It is absent from
  every record written before this and reads back as none.
- doctor warns — never fails — on a missing or edited helper and says what
  the bridge loses without it; uninstall removes helpers before the bridge.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-09-26 21:24:33 -05:00
10 changed files with 864 additions and 23 deletions

View File

@@ -64,12 +64,14 @@ SERVUO_LINK_REPO="RunicGateway/link"
SERVUO_OVERLAY_REPO="RunicGateway/servuo-plugins"
RUST_LINK_REPO="RunicGateway/Rust-Link"
RUST_PLUGIN_REPO="RunicGateway/Rust-Plugins"
RUNICNPC_REPO="RunicGateway/runicnpc-rust"
# Fixed top-level directories inside each payload tarball. Deliberately NOT
# versioned — a versioned prefix would mean parsing the version out of a path in
# order to read the manifest that declares the version.
SERVUO_OVERLAY_PREFIX="runicgateway-overlay"
RUST_PLUGIN_PREFIX="runicgateway-rust-plugin"
RUNICNPC_PREFIX="runicnpc"
mkdir -p "$WORK" "$PUBLISHED"
: > "$WORK/summary.md"
@@ -375,6 +377,45 @@ compose_rust() {
[ "$link_protocol" = "$plugin_protocol" ] || fail \
"PROTOCOL MISMATCH — Rust-Link ${link_tag} speaks ${link_protocol}, Rust-Plugins ${plugin_tag} declares ${plugin_protocol}. The game link has no 409: a mismatched plugin would mis-parse. Fix: land the matching half and let its release cut, or bump Rust-Plugins/overlay.toml."
# RunicNPC (docs/runicnpc/PLAN.md D224): a third file, optional until its stage 9.
# The bridge's manifest says which RunicNPC API its npc.* commands need
# (`runicnpc_api`, from overlay.toml); the latest RunicNPC release is carried
# only when it answers at least that. A bridge that needs none carries none: a
# RunicNPC it cannot talk to would be an NPC plugin nobody manages.
local npc='null' need npc_tag npc_tarball npc_manifest npc_api npc_carried=''
need="$(jq -r '.runicnpc_api // 0' "$manifest")"
if [ "$need" -gt 0 ]; then
rc=0
resolve_latest "$RUNICNPC_REPO" runicnpc || rc=$?
if [ "$rc" -eq 2 ]; then
note "- **Rust**: the bridge needs RunicNPC API ${need}, and RunicNPC has not released yet — the bundle carries none."
else
npc_tag="$(release_tag runicnpc)"
verify_assets runicnpc
npc_tarball="$(single_tarball runicnpc)"
npc_manifest="$(payload_manifest runicnpc "$npc_tarball" "$RUNICNPC_PREFIX" "$npc_tag")"
[ -f "$(dirname "$npc_manifest")/RunicNPC.cs" ] \
|| fail "the RunicNPC tarball has no ${RUNICNPC_PREFIX}/RunicNPC.cs"
npc_api="$(jq -r '.api' "$npc_manifest")"
echo "==> rust: RunicNPC ${npc_tag} api=${npc_api} | the bridge needs ${need}"
if [ "$npc_api" -lt "$need" ]; then
note "- **Rust**: RunicNPC \`${npc_tag}\` answers API ${npc_api} and the bridge needs ${need} — the bundle carries none until RunicNPC releases one that does."
else
npc="$(jq -n --arg repo "$RUNICNPC_REPO" --arg tag "$npc_tag" --slurpfile m "$npc_manifest" \
--argjson asset "$(asset_json runicnpc "$npc_tarball")" \
'{ repo: $repo, tag: $tag, version: ($tag | ltrimstr("v")), commit: $m[0].commit, api: $m[0].api,
compat: { frameworks: { oxide: { min_version: $m[0].min_oxide_version },
carbon: { min_version: $m[0].min_carbon_version } },
requires_plugins: $m[0].requires_plugins },
asset: $asset }')"
jq -e '([.commit, .compat.frameworks[].min_version] | all(type == "string"))
and (.api | type == "number") and (.compat.requires_plugins | type == "array")' <<<"$npc" >/dev/null \
|| fail "the RunicNPC manifest is missing commit, api, a framework floor, or requires_plugins"
npc_carried="$npc_tag"
fi
fi
fi
jq -n \
--arg link_repo "$RUST_LINK_REPO" --arg link_tag "$link_tag" \
--argjson assets "$assets" --argjson launcher "$launcher" \
@@ -382,6 +423,7 @@ compose_rust() {
--slurpfile m "$manifest" \
--argjson p_asset "$(asset_json rust-plugin "$tarball")" \
--argjson protocol "$link_protocol" \
--argjson npc "$npc" \
'{ schema: 2, game: "rust", protocol: $protocol,
sidecar: { repo: $link_repo, tag: $link_tag, version: ($link_tag | ltrimstr("v")),
protocol: $protocol, assets: $assets, launcher: $launcher },
@@ -390,7 +432,8 @@ compose_rust() {
compat: { frameworks: { oxide: { min_version: $m[0].min_oxide_version },
carbon: { min_version: $m[0].min_carbon_version } },
requires_plugins: $m[0].requires_plugins },
asset: $p_asset } }' > "$WORK/rust-s2.json"
asset: $p_asset } }
+ (if $npc == null then {} else { npc: $npc } end)' > "$WORK/rust-s2.json"
# A manifest missing a key would compose a `null` the installer then trusts.
jq -e '([.payload.compat.frameworks[].min_version, .payload.commit] | all(type == "string"))
and (.payload.compat.requires_plugins | type == "array")' "$WORK/rust-s2.json" >/dev/null \
@@ -403,7 +446,7 @@ compose_rust() {
local tag
tag="$(fresh_tag "$PUBLISHED/v2/rust")"
publish_doc "$PUBLISHED/v2/rust" "$tag" "$WORK/rust-s2.json"
note "- **Rust**: published \`${tag}\` — Rust-Link \`${link_tag}\`, Rust-Plugins \`${plugin_tag}\`, protocol ${link_protocol}."
note "- **Rust**: published \`${tag}\` — Rust-Link \`${link_tag}\`, Rust-Plugins \`${plugin_tag}\`, protocol ${link_protocol}${npc_carried:+, RunicNPC \`${npc_tag}\`}."
echo "rust ${tag}" >> "$WORK/published-games.txt"
}

View File

@@ -18,7 +18,8 @@
#
# current.json, bundle-<tag>.json schema 1, ServUO: link + overlay
# v2/servuo/current.json, bundle-<tag>.json schema 2, game "servuo"
# v2/rust/current.json, bundle-<tag>.json schema 2, game "rust": Rust-Link + Rust-Plugins
# v2/rust/current.json, bundle-<tag>.json schema 2, game "rust": Rust-Link + Rust-Plugins,
# and RunicNPC when the bridge needs it (D224)
#
# Schema 2 (D146) names ONE game, with a `game` discriminant and a `payload`
# that is an overlay for ServUO and a plugin for Rust. Schema 1 (D147) is still
@@ -56,8 +57,8 @@
# the push.
#
# ── Triggers (PLAN.md §7.2) ──────────────────────────────────────────────────
# workflow_dispatch — POSTed by link's, servuo-plugins', Rust-Link's and
# Rust-Plugins' release workflows as their final step,
# workflow_dispatch — POSTed by link's, servuo-plugins', Rust-Link's,
# Rust-Plugins' and runicnpc-rust's release workflows as their final step,
# so a new release recomposes the bundle immediately.
# schedule (nightly) — recomputes from whatever the latest releases actually
# are, so a missed or failed dispatch self-heals instead

View File

@@ -361,6 +361,26 @@ pub struct RustBundle {
pub protocol: u32,
pub sidecar: RustSidecar,
pub payload: PluginPayload,
/// RunicNPC, Runic Gateway's NPC plugin (docs/runicnpc/PLAN.md D224): a third file placed in
/// the plugins directory beside the bridge. Optional until RunicNPC's stage 9 makes it
/// required, so a bundle without it — every one before stage 4 — still parses and installs.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub npc: Option<NpcComponent>,
}
/// One exact RunicNPC release, chosen by CI to answer at least the API the bridge's
/// `runicnpc_api` asks for.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct NpcComponent {
pub repo: String,
pub tag: String,
pub version: String,
pub commit: String,
/// The RunicNPC API it answers (`RunicNpc_ApiVersion`).
pub api: u32,
pub compat: PluginCompat,
/// One tarball: `runicnpc/{RunicNPC.cs, manifest.json}`.
pub asset: Asset,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
@@ -485,6 +505,14 @@ pub fn parse_rust(body: &str) -> Result<RustBundle> {
bundle.bundle
);
}
if let Some(npc) = &bundle.npc {
if npc.asset.url.is_empty() || npc.asset.sha256.is_empty() {
bail!(
"Rust bundle {} names a RunicNPC asset with no URL or checksum",
bundle.bundle
);
}
}
Ok(bundle)
}
@@ -589,6 +617,37 @@ mod tests {
assert_eq!(v2, v1);
}
#[test]
fn a_rust_bundle_without_runicnpc_still_parses() {
let bundle = parse_rust(RUST).unwrap();
assert!(bundle.npc.is_none());
}
#[test]
fn a_rust_bundle_with_runicnpc_parses_and_is_checked() {
let mut value: serde_json::Value = serde_json::from_str(RUST).unwrap();
value["npc"] = serde_json::json!({
"repo": "RunicGateway/runicnpc-rust",
"tag": "v0.2.0",
"version": "0.2.0",
"commit": "abc",
"api": 3,
"compat": {
"frameworks": { "oxide": { "min_version": "2.0.7726" }, "carbon": { "min_version": "2.0.259" } },
"requires_plugins": ["Kits"]
},
"asset": { "name": "runicnpc-0.2.0.tar.gz", "url": "https://x/runicnpc-0.2.0.tar.gz", "sha256": "ab" }
});
let bundle = parse_rust(&value.to_string()).unwrap();
let npc = bundle.npc.as_ref().unwrap();
assert_eq!(
(npc.api, npc.compat.requires_plugins.clone()),
(3, vec!["Kits".to_string()])
);
value["npc"]["asset"]["sha256"] = serde_json::json!("");
assert!(parse_rust(&value.to_string()).is_err());
}
#[test]
fn a_rust_bundle_is_refused_by_the_servuo_reader() {
let err = parse(RUST).unwrap_err().to_string();

View File

@@ -4,6 +4,8 @@
//! says where to look. Per instance (docs/modules/rust/PLAN.md §34.2.3):
//!
//! - the framework, and whether the plugin file is still the one deployed;
//! - each helper deployed beside it (D182), as a warning when it is gone or edited — the bridge
//! runs without one, and the row says what that costs;
//! - that the plugin's config names this instance;
//! - `requires_plugins` present, as a warning (D153);
//! - the service registered and running;
@@ -151,6 +153,52 @@ fn instance_rows(
)),
}
// ── The helpers: ours too, and optional to the bridge (D182) ─────────────
// A warning rather than a failure: the bridge runs without a helper and falls back. What is
// lost is said, so an operator who removed one on purpose knows what they chose.
for (name, helper) in &instance.helpers {
match std::fs::read(&helper.path) {
Ok(bytes) if crate::util::sha256_bytes(&bytes) == helper.sha256 => {
rows.push(Row::ok(&label("helper"), helper.path.clone()))
}
Ok(_) => rows.push(
Row::warn(
&label("helper"),
format!("{} is not the file that was deployed", helper.path),
)
.note(
"edited or replaced by hand; `update --game rust` puts the released one back",
),
),
Err(_) => rows.push(
Row::warn(&label("helper"), format!("{} is missing", helper.path))
.note(helper_absence(name))
.note("`update --game rust` puts it back"),
),
}
}
// ── RunicNPC: ours when the bundle carried it (D224) ──────────────────────
// A warning, like a helper's: until RunicNPC's stage 9 the bridge runs without it, and what is
// lost is the site's NPC profiles and placements and events' profile NPCs.
if let Some(file) = &instance.npc {
let tag = record.npc.as_ref().map(|n| n.tag.as_str()).unwrap_or("?");
match std::fs::read(&file.path) {
Ok(bytes) if crate::util::sha256_bytes(&bytes) == file.sha256 => {
rows.push(Row::ok(&label("RunicNPC"), format!("{} (runicnpc-rust {tag})", file.path)))
}
Ok(_) => rows.push(
Row::warn(&label("RunicNPC"), format!("{} is not the file that was deployed", file.path))
.note("edited or replaced by hand; `update --game rust` puts the released one back"),
),
Err(_) => rows.push(
Row::warn(&label("RunicNPC"), format!("{} is missing", file.path))
.note("without it the site's NPC profiles and placements, and events' profile NPCs, are off")
.note("`update --game rust` puts it back"),
),
}
}
// ── The plugin's config: the website's ───────────────────────────────────
match plugin::read_config(Path::new(&instance.plugin_config)) {
Ok(Some(view)) if view.server_id.as_deref() == Some(id) => rows.push(Row::ok(
@@ -279,6 +327,23 @@ fn instance_rows(
}
/// Whether a newer Rust bundle than the installed one is published.
/// What a missing helper costs, in the operator's words. Unknown helpers get a generic line.
fn helper_absence(name: &str) -> String {
match name {
"RunicGatewayZones.cs" => {
"the bridge still runs and scores its zones by position, but ZoneManager's \
own flags miss anybody already standing in a zone when it is created or restored"
.to_string()
}
"RunicGatewayDomes.cs" => {
"the bridge still runs and its zones still open, but the site offers no dome over \
an event zone, because ZoneDomes cannot be called without it"
.to_string()
}
_ => format!("the bridge still runs without {name}; what it helped with falls back"),
}
}
fn bundle_row(record: &RustRecord) -> Row {
match bundle::fetch_rust(None) {
Ok((current, _)) if current.bundle == record.bundle.tag => {

View File

@@ -18,8 +18,9 @@ use std::path::{Path, PathBuf};
use anyhow::{bail, Context, Result};
use super::npc;
use super::plugin::{self, ConfigView};
use super::record::{ComponentRecord, Instance, RustRecord, SCHEMA};
use super::record::{ComponentRecord, DeployedFile, Instance, NpcRecord, RustRecord, SCHEMA};
use super::server::{self, RustServer};
use super::sidecar;
use crate::bundle::{self, RustBundle};
@@ -42,6 +43,12 @@ struct Planned {
running: bool,
plugin_config: Option<ConfigView>,
plugin_action: BinaryAction,
/// Each helper in the release and what happens to it, in the release's (name) order.
helper_actions: Vec<BinaryAction>,
/// Helpers this instance's record holds that the release no longer ships: removed.
retired_helpers: Vec<String>,
/// RunicNPC, when the bundle carries it (D224): absent, identical or replaced.
npc_action: Option<BinaryAction>,
game_port: u16,
web_port: u16,
config_path: PathBuf,
@@ -137,6 +144,16 @@ pub fn deploy(cli: &Cli, mode: Mode) -> Result<()> {
bundle.payload.protocol
),
);
if let Some(npc) = &bundle.npc {
ui::row(
"RunicNPC",
&format!(
"{:<24} API {}",
format!("runicnpc-rust {}", npc.tag),
npc.api
),
);
}
// The plugin is fetched before anything is planned: its manifest is the last statement of the
// protocol to check, and a pair that disagrees must stop the run before any file moves.
@@ -166,6 +183,31 @@ pub fn deploy(cli: &Cli, mode: Mode) -> Result<()> {
);
}
// RunicNPC (D224), fetched and checked beside the plugin, before anything is planned.
let npc_released = match &bundle.npc {
Some(component) => {
let path = scratch.path().join(&component.asset.name);
crate::net::download_verified(&component.asset.url, &path, &component.asset.sha256)?;
let released = npc::read_tarball(&path)?;
if released.manifest.api != component.api {
bail!(
"RunicNPC in bundle {} answers API {}, but the bundle says {} — refusing a component the bundle does not describe.",
bundle.bundle,
released.manifest.api,
component.api
);
}
if released.manifest.version != component.version {
ui::warn(&format!(
"the RunicNPC tarball says version {} but bundle {} names {}. The checksum matched, \n so this is a labelling mismatch in the release rather than a wrong download.",
released.manifest.version, bundle.bundle, component.version
));
}
Some(released)
}
None => None,
};
// ── Plan every instance ──────────────────────────────────────────────────
let mut planned = Vec::new();
for (id, root) in &targets {
@@ -176,10 +218,11 @@ pub fn deploy(cli: &Cli, mode: Mode) -> Result<()> {
id,
root,
&released,
npc_released.as_ref(),
)?);
}
let binary_action = crate::sidecar::decide(&asset, &layout.rust_sidecar_bin)?;
print_plan(&layout, &planned, binary_action, &bundle);
print_plan(&layout, &planned, binary_action, &bundle, &released);
if cli.verify {
println!(
@@ -225,6 +268,7 @@ pub fn deploy(cli: &Cli, mode: Mode) -> Result<()> {
&prepared,
&bundle,
&released,
npc_released.as_ref(),
plan,
binary_action.writes(),
prior
@@ -284,6 +328,16 @@ pub fn deploy(cli: &Cli, mode: Mode) -> Result<()> {
&bundle.payload.commit,
bundle.payload.protocol,
);
// A bundle without RunicNPC leaves a recorded one where it is: it may be an operator's.
if let Some(component) = &bundle.npc {
record.npc = Some(NpcRecord {
repo: component.repo.clone(),
tag: component.tag.clone(),
version: component.version.clone(),
commit: component.commit.clone(),
api: component.api,
});
}
record.service_user_created |= prepared.user_created;
match prior.as_ref() {
@@ -326,7 +380,15 @@ pub fn deploy(cli: &Cli, mode: Mode) -> Result<()> {
Some(b) if b.tag == bundle.bundle => {
// The same bundle can still have written something: a plugin edited by hand is
// put back, which is what `doctor` tells an operator to run `update` for.
let restored = planned.iter().filter(|p| p.plugin_action.writes()).count();
let restored = planned
.iter()
.filter(|p| {
p.plugin_action.writes()
|| p.npc_action.is_some_and(|a| a.writes())
|| p.helper_actions.iter().any(|a| a.writes())
|| !p.retired_helpers.is_empty()
})
.count();
if restored == 0 && !binary_action.writes() {
println!("\nAlready on bundle {} — nothing moved.", bundle.bundle)
} else {
@@ -366,6 +428,7 @@ fn plan_instance(
id: &str,
root: &Path,
released: &plugin::Released,
npc_released: Option<&npc::Released>,
) -> Result<Planned> {
let server = server::open(root)
.with_context(|| format!("cannot use {} as a Rust server root", root.display()))?;
@@ -431,6 +494,41 @@ fn plan_instance(
}
Ok(_) => BinaryAction::Replace,
};
// Helpers are decided the same way as the plugin: absent, identical or replaced. One the
// operator deleted is put back — the installer owns it, and `doctor` says what its absence costs.
let helper_actions = released
.helpers
.iter()
.map(
|helper| match std::fs::read(server.plugins_dir().join(&helper.name)) {
Err(_) => BinaryAction::Install,
Ok(bytes) if crate::util::sha256_bytes(&bytes) == helper.sha256 => {
BinaryAction::Unchanged
}
Ok(_) => BinaryAction::Replace,
},
)
.collect();
let retired_helpers = recorded
.map(|i| {
i.helpers
.keys()
.filter(|name| !released.helpers.iter().any(|h| &h.name == *name))
.cloned()
.collect()
})
.unwrap_or_default();
// RunicNPC as the plugin is: put back when it was deleted or edited (D224).
let npc_action =
npc_released.map(
|r| match std::fs::read(server.plugins_dir().join(npc::FILE)) {
Err(_) => BinaryAction::Install,
Ok(bytes) if crate::util::sha256_bytes(&bytes) == r.sha256 => {
BinaryAction::Unchanged
}
Ok(_) => BinaryAction::Replace,
},
);
let config_path = layout.rust_config(id);
Ok(Planned {
id: id.to_string(),
@@ -441,6 +539,9 @@ fn plan_instance(
server,
plugin_config,
plugin_action,
helper_actions,
retired_helpers,
npc_action,
game_port,
web_port,
})
@@ -451,6 +552,7 @@ fn print_plan(
planned: &[Planned],
binary: BinaryAction,
bundle: &RustBundle,
released: &plugin::Released,
) {
ui::row(
"binary",
@@ -479,6 +581,35 @@ fn print_plan(
p.plugin_action.label()
),
);
for (helper, action) in released.helpers.iter().zip(&p.helper_actions) {
ui::row(
"helper",
&format!(
"{} {}",
p.server.plugins_dir().join(&helper.name).display(),
action.label()
),
);
}
for name in &p.retired_helpers {
ui::row(
"helper",
&format!(
"{} removed (no longer released)",
p.server.plugins_dir().join(name).display()
),
);
}
if let Some(action) = p.npc_action {
ui::row(
"RunicNPC",
&format!(
"{} {}",
p.server.plugins_dir().join(npc::FILE).display(),
action.label()
),
);
}
ui::row(
"plugin config",
&match &p.plugin_config {
@@ -509,10 +640,16 @@ fn print_plan(
p.game_port, p.web_port
),
);
let missing = plugin::missing_plugins(
&p.server.plugins_dir(),
&bundle.payload.compat.requires_plugins,
);
// RunicNPC's own requirements join the bridge's: it will not load without Kits (D217).
let mut required = bundle.payload.compat.requires_plugins.clone();
if let Some(component) = &bundle.npc {
for name in &component.compat.requires_plugins {
if !required.contains(name) {
required.push(name.clone());
}
}
}
let missing = plugin::missing_plugins(&p.server.plugins_dir(), &required);
if !missing.is_empty() {
ui::warn(&format!(
"{} not in {} — the features that use {} stay off until you install {} from uMod. \
@@ -527,11 +664,13 @@ fn print_plan(
}
/// Writes one instance: plugin config, sidecar config, service, plugin — in that order.
#[allow(clippy::too_many_arguments)]
fn deploy_instance(
layout: &paths::Layout,
prepared: &service::Prepared,
bundle: &RustBundle,
released: &plugin::Released,
npc_released: Option<&npc::Released>,
plan: &Planned,
binary_changed: bool,
plugin_config_written_before: bool,
@@ -662,6 +801,74 @@ fn deploy_instance(
}
};
// The helpers before the plugin (D182): each loads the moment it lands, and the bridge reads a
// helper's state at hello — a helper already there is one the bridge's first hello reports.
// Written in place for the plugin's reason below.
let mut helpers = std::collections::BTreeMap::new();
for (helper, action) in released.helpers.iter().zip(&plan.helper_actions) {
let path = plan.server.plugins_dir().join(&helper.name);
if action.writes() {
std::fs::create_dir_all(plan.server.plugins_dir()).with_context(|| {
format!("cannot create {}", plan.server.plugins_dir().display())
})?;
std::fs::write(&path, &helper.source)
.with_context(|| format!("cannot write {}", path.display()))?;
ui::ok(&format!(
"helper {} {}",
if *action == BinaryAction::Replace {
"replaced"
} else {
"installed"
},
path.display()
));
}
helpers.insert(
helper.name.clone(),
DeployedFile {
path: path.display().to_string(),
sha256: helper.sha256.clone(),
},
);
}
for name in &plan.retired_helpers {
let path = plan.server.plugins_dir().join(name);
match std::fs::remove_file(&path) {
Ok(()) => ui::ok(&format!("helper removed {}", path.display())),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => ui::warn(&format!("could not remove {}: {e}", path.display())),
}
}
// RunicNPC before the bridge (D224): the bridge reports it at hello, and one already loaded is
// one its first hello names. Written in place, for the plugin's reason below.
let npc_file = match (npc_released, plan.npc_action) {
(Some(r), Some(action)) => {
let path = plan.server.plugins_dir().join(npc::FILE);
if action.writes() {
std::fs::create_dir_all(plan.server.plugins_dir()).with_context(|| {
format!("cannot create {}", plan.server.plugins_dir().display())
})?;
std::fs::write(&path, &r.source)
.with_context(|| format!("cannot write {}", path.display()))?;
ui::ok(&format!(
"RunicNPC {} {}",
if action == BinaryAction::Replace {
"replaced"
} else {
"installed"
},
path.display()
));
}
Some(DeployedFile {
path: path.display().to_string(),
sha256: r.sha256.clone(),
})
}
_ => None,
};
// The plugin last: it loads the moment it lands, and its sidecar is now there to dial.
let plugin_path = plan.server.plugin_path();
if plan.plugin_action.writes() {
@@ -694,6 +901,8 @@ fn deploy_instance(
framework: plan.server.framework.as_str().to_string(),
plugin_path: plugin_path.display().to_string(),
plugin_sha256: released.sha256.clone(),
helpers,
npc: npc_file,
plugin_config: plugin_config_path.display().to_string(),
plugin_config_written: wrote_plugin_config || plugin_config_written_before,
game_port: plan.game_port,
@@ -735,6 +944,7 @@ fn empty_record(bundle: &RustBundle, url: &str) -> RustRecord {
sha256: String::new(),
},
plugin: component("", "", "", "", 0),
npc: None,
service_user_created: false,
instances: Default::default(),
extra: Default::default(),

View File

@@ -13,6 +13,7 @@
mod doctor;
mod install;
mod npc;
mod plugin;
mod record;
mod server;

171
src/rustgame/npc.rs Normal file
View File

@@ -0,0 +1,171 @@
//! RunicNPC: Runic Gateway's NPC plugin, a third file beside the bridge (docs/runicnpc/PLAN.md
//! D224, stage 4).
//!
//! **`RunicNPC.cs` is the installer's**, like the bridge and its helpers: it comes from the bundle,
//! is replaced when the bundle moves, is put back by `update` when it was edited or deleted, and is
//! removed by `uninstall`. Its DATA is not: `data/RunicNPC/` holds an admin's placements and
//! routes, which RunicNPC writes and the site edits through the bridge, and nothing here touches
//! it. RunicNPC makes that directory itself on first load, because one made from outside the game
//! is not writable by it (runicnpc PLAN.md §1.5).
//!
//! It is optional until RunicNPC's stage 9: a bundle without it installs as before, and a host
//! without it runs the bridge with Rust's own scientists only (D243).
use std::collections::BTreeMap;
use std::io::Read;
use std::path::Path;
use anyhow::{bail, Context, Result};
use serde::Deserialize;
use crate::util::sha256_bytes;
/// The fixed top directory inside RunicNPC's release tarball (runicnpc-rust's release.yml).
const PREFIX: &str = "runicnpc";
/// The one file placed, in the same plugins directory as the bridge.
pub const FILE: &str = "RunicNPC.cs";
/// `runicnpc/manifest.json`, which RunicNPC's release folds from `plugin.toml`.
#[derive(Debug, Clone, Deserialize)]
pub struct Manifest {
pub version: String,
pub api: u32,
#[serde(default)]
pub files: BTreeMap<String, String>,
}
/// RunicNPC's released file, read out of its tarball.
#[derive(Debug, Clone)]
pub struct Released {
pub manifest: Manifest,
pub source: Vec<u8>,
pub sha256: String,
}
/// Reads RunicNPC and its manifest out of a downloaded tarball, and checks the two agree.
pub fn read_tarball(path: &Path) -> Result<Released> {
let file =
std::fs::File::open(path).with_context(|| format!("cannot open {}", path.display()))?;
let mut archive = tar::Archive::new(flate2::read::GzDecoder::new(file));
let mut manifest: Option<Vec<u8>> = None;
let mut source: Option<Vec<u8>> = None;
for entry in archive
.entries()
.context("the RunicNPC tarball is not a tar.gz")?
{
let mut entry = entry.context("the RunicNPC tarball is truncated")?;
let name = entry.path()?.to_string_lossy().replace('\\', "/");
if name == format!("{PREFIX}/manifest.json") {
let mut bytes = Vec::new();
entry.read_to_end(&mut bytes)?;
manifest = Some(bytes);
} else if name == format!("{PREFIX}/{FILE}") {
let mut bytes = Vec::new();
entry.read_to_end(&mut bytes)?;
source = Some(bytes);
}
}
let manifest = manifest
.ok_or_else(|| anyhow::anyhow!("the RunicNPC tarball has no {PREFIX}/manifest.json"))?;
let source =
source.ok_or_else(|| anyhow::anyhow!("the RunicNPC tarball has no {PREFIX}/{FILE}"))?;
let manifest: Manifest =
serde_json::from_slice(&manifest).context("RunicNPC's manifest.json is unreadable")?;
let sha256 = sha256_bytes(&source);
match manifest.files.get(FILE) {
Some(declared) if declared.eq_ignore_ascii_case(&sha256) => {}
Some(declared) => bail!(
"RunicNPC in the tarball does not match its own manifest (sha256 {sha256}, manifest \
says {declared}). The tarball matched the bundle's checksum, so the release itself is \
inconsistent — refusing it."
),
None => bail!("RunicNPC's manifest does not list {FILE} — refusing a release that does not say what it ships"),
}
Ok(Released {
manifest,
source,
sha256,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::util::TempDir;
fn tarball(dir: &Path, entries: &[(&str, &[u8])]) -> std::path::PathBuf {
let path = dir.join("runicnpc.tar.gz");
let file = std::fs::File::create(&path).unwrap();
let mut builder = tar::Builder::new(flate2::write::GzEncoder::new(
file,
flate2::Compression::default(),
));
for (name, bytes) in entries {
let mut header = tar::Header::new_gnu();
header.set_size(bytes.len() as u64);
header.set_mode(0o644);
header.set_cksum();
builder
.append_data(&mut header, format!("{PREFIX}/{name}"), *bytes)
.unwrap();
}
builder.into_inner().unwrap().finish().unwrap();
path
}
fn manifest(sha: &str) -> Vec<u8> {
serde_json::json!({
"component": "runicnpc", "version": "0.2.0", "commit": "abc", "api": 3,
"requires_plugins": ["Kits"], "files": { FILE: sha }
})
.to_string()
.into_bytes()
}
#[test]
fn a_release_is_read_and_its_file_checked_against_its_manifest() {
let dir = TempDir::new("rg-npc").unwrap();
let source = b"// Requires: Kits\nclass RunicNPC {}";
let good = tarball(
dir.path(),
&[
("manifest.json", &manifest(&sha256_bytes(source))),
(FILE, source),
],
);
let released = read_tarball(&good).unwrap();
assert_eq!(
(released.manifest.api, released.manifest.version.as_str()),
(3, "0.2.0")
);
assert_eq!(released.source, source);
let bad = tarball(
dir.path(),
&[
("manifest.json", &manifest(&"00".repeat(32))),
(FILE, source),
],
);
assert!(read_tarball(&bad)
.unwrap_err()
.to_string()
.contains("does not match its own manifest"));
}
#[test]
fn a_release_without_the_file_or_the_manifest_is_refused() {
let dir = TempDir::new("rg-npc-missing").unwrap();
let only_manifest = tarball(dir.path(), &[("manifest.json", &manifest("ab"))]);
assert!(read_tarball(&only_manifest)
.unwrap_err()
.to_string()
.contains("has no runicnpc/RunicNPC.cs"));
let only_file = tarball(dir.path(), &[(FILE, b"x")]);
assert!(read_tarball(&only_file)
.unwrap_err()
.to_string()
.contains("manifest.json"));
}
}

View File

@@ -1,9 +1,12 @@
//! The plugin: its released tarball, and its config in the server root.
//!
//! Two files, two owners (docs/modules/rust/PLAN.md §34.2.3):
//! Two kinds of file, two owners (docs/modules/rust/PLAN.md §34.2.3):
//!
//! - **`RunicGateway.cs` is the installer's.** It comes from the bundle, is replaced when the bundle
//! moves, and is removed by `uninstall`.
//! moves, and is removed by `uninstall`. So are the **helpers** released beside it
//! (docs/modules/rust/PLAN_FIXES.md D168, D182 — today `RunicGatewayZones.cs`): every other `.cs`
//! the release's manifest lists in `files`, each checked against its own sha256. A helper is
//! optional to the bridge at runtime and installed by default.
//! - **`RunicGateway.json` is the website's.** The plugin writes it, the site edits it through the
//! plugin, and it locks `ServerId`. The installer writes it exactly once — when it does not exist
//! yet, holding just `ServerId` and `Port` — and never rewrites it. An existing one whose
@@ -37,6 +40,28 @@ pub struct Released {
pub manifest: Manifest,
pub source: Vec<u8>,
pub sha256: String,
/// The helpers the manifest lists, in name order. Empty for a release older than them.
pub helpers: Vec<Helper>,
}
/// One helper plugin shipped beside the bridge (D168, D182).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Helper {
/// The file name, as the manifest and the plugins directory both spell it.
pub name: String,
pub source: Vec<u8>,
pub sha256: String,
}
/// A helper's name is written into a plugins directory, so it must be a plain `<Name>.cs` and
/// nothing a tarball could use to reach outside that directory.
pub fn is_helper_name(name: &str) -> bool {
let Some(stem) = name.strip_suffix(".cs") else {
return false;
};
!stem.is_empty()
&& name != super::server::PLUGIN_FILE
&& stem.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
}
/// Reads the plugin and its manifest out of a downloaded tarball, and checks that the two agree.
@@ -46,22 +71,27 @@ pub fn read_tarball(path: &Path) -> Result<Released> {
let mut archive = tar::Archive::new(flate2::read::GzDecoder::new(file));
let mut manifest: Option<Vec<u8>> = None;
let mut source: Option<Vec<u8>> = None;
let mut others: BTreeMap<String, Vec<u8>> = BTreeMap::new();
for entry in archive
.entries()
.context("the plugin tarball is not a tar.gz")?
{
let mut entry = entry.context("the plugin tarball is truncated")?;
let name = entry.path()?.to_string_lossy().replace('\\', "/");
let slot = if name == format!("{PREFIX}/manifest.json") {
&mut manifest
} else if name == format!("{PREFIX}/{}", super::server::PLUGIN_FILE) {
&mut source
} else {
continue;
};
let mut bytes = Vec::new();
entry.read_to_end(&mut bytes)?;
*slot = Some(bytes);
if name == format!("{PREFIX}/manifest.json") {
entry.read_to_end(&mut bytes)?;
manifest = Some(bytes);
} else if name == format!("{PREFIX}/{}", super::server::PLUGIN_FILE) {
entry.read_to_end(&mut bytes)?;
source = Some(bytes);
} else if let Some(file) = name.strip_prefix(&format!("{PREFIX}/")) {
// Kept only if it is a plain helper name; which of them count is the manifest's call.
if is_helper_name(file) {
entry.read_to_end(&mut bytes)?;
others.insert(file.to_string(), bytes);
}
}
}
let manifest = manifest
@@ -84,13 +114,51 @@ pub fn read_tarball(path: &Path) -> Result<Released> {
);
}
}
let helpers = read_helpers(&manifest, others)?;
Ok(Released {
manifest,
source,
sha256,
helpers,
})
}
/// Every file the manifest lists besides the bridge, each present and matching its sha256. A name
/// the manifest lists that is not a plain helper name refuses the release: the installer would
/// otherwise be asked to write it somewhere, and it will not guess where.
fn read_helpers(manifest: &Manifest, mut found: BTreeMap<String, Vec<u8>>) -> Result<Vec<Helper>> {
let mut helpers = Vec::new();
for (name, declared) in &manifest.files {
if name == super::server::PLUGIN_FILE {
continue;
}
if !is_helper_name(name) {
bail!(
"the plugin's manifest lists {name:?}, which is not a plugin file name this \
installer will write into a plugins directory — refusing the release."
);
}
let source = found.remove(name).ok_or_else(|| {
anyhow::anyhow!(
"the plugin's manifest lists {name} but the tarball has no {PREFIX}/{name}"
)
})?;
let sha256 = sha256_bytes(&source);
if !declared.eq_ignore_ascii_case(&sha256) {
bail!(
"{name} in the plugin tarball does not match its own manifest (sha256 {sha256}, \
manifest says {declared}) — refusing the release."
);
}
helpers.push(Helper {
name: name.clone(),
source,
sha256,
});
}
Ok(helpers)
}
/// What the plugin's config says, as far as the installer cares.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConfigView {
@@ -228,6 +296,133 @@ mod tests {
assert_eq!(view.port, Some(7799));
}
/// A plugin tarball shaped like Rust-Plugins' release: `runicgateway-rust-plugin/<name>` entries.
fn tarball(dir: &Path, entries: &[(&str, &[u8])]) -> std::path::PathBuf {
let path = dir.join("plugin.tar.gz");
let file = std::fs::File::create(&path).unwrap();
let mut builder = tar::Builder::new(flate2::write::GzEncoder::new(
file,
flate2::Compression::default(),
));
for (name, bytes) in entries {
let mut header = tar::Header::new_gnu();
header.set_size(bytes.len() as u64);
header.set_mode(0o644);
header.set_cksum();
builder
.append_data(&mut header, format!("{PREFIX}/{name}"), *bytes)
.unwrap();
}
builder.into_inner().unwrap().finish().unwrap();
path
}
fn manifest(files: &[(&str, &[u8])]) -> Vec<u8> {
let files: serde_json::Map<String, serde_json::Value> = files
.iter()
.map(|(name, bytes)| ((*name).to_string(), sha256_bytes(bytes).into()))
.collect();
serde_json::to_vec(
&serde_json::json!({ "version": "0.2.0", "protocol": 13, "files": files }),
)
.unwrap()
}
const BRIDGE: &[u8] = b"// the bridge";
const ZONES: &[u8] = b"// the zone helper";
#[test]
fn a_release_with_a_helper_carries_it_checked() {
let dir = TempDir::new("rg-rust-helper").unwrap();
let m = manifest(&[("RunicGateway.cs", BRIDGE), ("RunicGatewayZones.cs", ZONES)]);
let path = tarball(
dir.path(),
&[
("manifest.json", &m),
("RunicGateway.cs", BRIDGE),
("RunicGatewayZones.cs", ZONES),
],
);
let released = read_tarball(&path).unwrap();
assert_eq!(released.source, BRIDGE);
assert_eq!(released.helpers.len(), 1);
assert_eq!(released.helpers[0].name, "RunicGatewayZones.cs");
assert_eq!(released.helpers[0].source, ZONES);
assert_eq!(released.helpers[0].sha256, sha256_bytes(ZONES));
}
#[test]
fn a_release_older_than_helpers_has_none_even_with_a_stray_file() {
// Only what the manifest lists is a helper: a `.cs` it does not name is not installed.
let dir = TempDir::new("rg-rust-helper-old").unwrap();
let m = manifest(&[("RunicGateway.cs", BRIDGE)]);
let path = tarball(
dir.path(),
&[
("manifest.json", &m),
("RunicGateway.cs", BRIDGE),
("RunicGatewayZones.cs", ZONES),
],
);
assert!(read_tarball(&path).unwrap().helpers.is_empty());
}
#[test]
fn a_helper_the_manifest_lists_must_be_there_and_match() {
let dir = TempDir::new("rg-rust-helper-bad").unwrap();
let m = manifest(&[("RunicGateway.cs", BRIDGE), ("RunicGatewayZones.cs", ZONES)]);
let missing = tarball(
dir.path(),
&[("manifest.json", &m), ("RunicGateway.cs", BRIDGE)],
);
let err = read_tarball(&missing).unwrap_err().to_string();
assert!(err.contains("RunicGatewayZones.cs"), "{err}");
let tampered = tarball(
dir.path(),
&[
("manifest.json", &m),
("RunicGateway.cs", BRIDGE),
("RunicGatewayZones.cs", b"// something else"),
],
);
let err = read_tarball(&tampered).unwrap_err().to_string();
assert!(err.contains("does not match its own manifest"), "{err}");
}
#[test]
fn a_manifest_naming_a_path_is_refused() {
let dir = TempDir::new("rg-rust-helper-path").unwrap();
for name in ["../Evil.cs", "sub/Evil.cs", "Evil.dll", ".cs"] {
let m = manifest(&[("RunicGateway.cs", BRIDGE), (name, ZONES)]);
let path = tarball(
dir.path(),
&[("manifest.json", &m), ("RunicGateway.cs", BRIDGE)],
);
let err = read_tarball(&path).unwrap_err().to_string();
assert!(err.contains("refusing the release"), "{name}: {err}");
}
}
#[test]
fn helper_names_are_plain_plugin_files() {
assert!(is_helper_name("RunicGatewayZones.cs"));
assert!(is_helper_name("Helper_2.cs"));
for bad in [
"RunicGateway.cs",
"../X.cs",
"a/X.cs",
"a\\X.cs",
"X.cs.bak",
".cs",
"X .cs",
"X-y.cs",
] {
assert!(!is_helper_name(bad), "{bad}");
}
}
#[test]
fn a_missing_required_plugin_is_named() {
let dir = TempDir::new("rg-rust-req").unwrap();

View File

@@ -30,6 +30,9 @@ pub struct RustRecord {
pub sidecar: ComponentRecord,
pub binary: BinaryRef,
pub plugin: ComponentRecord,
/// RunicNPC's release, when the bundle carried one (docs/runicnpc/PLAN.md D224).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub npc: Option<NpcRecord>,
/// This installer created the shared `runicgateway` service user. Kept here rather than on each
/// instance: the account outlives any one instance and may also run ServUO's sidecar, so only
/// the last Rust instance's removal — on a host with no ServUO record — may delete it.
@@ -51,6 +54,17 @@ pub struct ComponentRecord {
pub protocol: u32,
}
/// Which RunicNPC release is deployed, and the API it answers.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct NpcRecord {
pub repo: String,
pub tag: String,
pub version: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub commit: String,
pub api: u32,
}
/// One Rust server and its sidecar.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Instance {
@@ -60,6 +74,13 @@ pub struct Instance {
pub plugin_path: String,
/// What was deployed, so `doctor` can tell an edited plugin file from the release's.
pub plugin_sha256: String,
/// The helpers deployed beside the plugin (D168, D182), by file name. Installer-owned like the
/// plugin. Absent from a record written before helpers existed, which reads back as none.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub helpers: BTreeMap<String, DeployedFile>,
/// `RunicNPC.cs`, when the bundle carried RunicNPC. Its data directory is not the installer's.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub npc: Option<DeployedFile>,
/// The plugin's config — the website's file, recorded so `doctor` knows where to look.
pub plugin_config: String,
/// The installer wrote that config (it did not exist). Informational; it is kept either way.
@@ -75,6 +96,13 @@ pub struct Instance {
pub service: Option<ServiceRecord>,
}
/// One installer-owned file in a server root, as deployed.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct DeployedFile {
pub path: String,
pub sha256: String,
}
impl RustRecord {
/// Everything but the timestamp, so a second run with nothing to do writes nothing.
pub fn same_deployment_as(&self, other: &Self) -> bool {
@@ -142,6 +170,8 @@ mod tests {
framework: "oxide".into(),
plugin_path: format!("{root}/oxide/plugins/RunicGateway.cs"),
plugin_sha256: "ab".repeat(32),
helpers: BTreeMap::new(),
npc: None,
plugin_config: format!("{root}/oxide/config/RunicGateway.json"),
plugin_config_written: true,
game_port: game,
@@ -179,6 +209,7 @@ mod tests {
commit: "abc".into(),
protocol: 12,
},
npc: None,
service_user_created: true,
instances: BTreeMap::from([
("alpha".to_string(), instance("/srv/a", 7799, 8090)),
@@ -199,6 +230,53 @@ mod tests {
assert!(!text.contains("auth_token") && !text.contains("token\""));
}
#[test]
fn a_record_with_runicnpc_round_trips_and_one_without_loads_as_none() {
let dir = TempDir::new("rg-rust-record-npc").unwrap();
let path = dir.path().join("install.json");
let mut record = sample();
record.npc = Some(NpcRecord {
repo: "RunicGateway/runicnpc-rust".into(),
tag: "v0.2.0".into(),
version: "0.2.0".into(),
commit: "abc".into(),
api: 3,
});
record.instances.get_mut("alpha").unwrap().npc = Some(DeployedFile {
path: "/srv/a/oxide/plugins/RunicNPC.cs".into(),
sha256: "ef".repeat(32),
});
record.save(&path).unwrap();
assert_eq!(RustRecord::load(&path).unwrap().unwrap(), record);
// A record from before stage 4 has neither key, and reads back as none.
let old = sample();
old.save(&path).unwrap();
let text = std::fs::read_to_string(&path).unwrap();
assert!(!text.contains("\"npc\""));
let back = RustRecord::load(&path).unwrap().unwrap();
assert!(back.npc.is_none() && back.instances.values().all(|i| i.npc.is_none()));
}
#[test]
fn a_record_written_before_helpers_loads_with_none() {
// Every host installed before D182 has one of these: no `helpers` key at all.
let dir = TempDir::new("rg-rust-record-old").unwrap();
let path = dir.path().join("install.json");
let mut value = serde_json::to_value(sample()).unwrap();
for instance in value["instances"].as_object_mut().unwrap().values_mut() {
assert!(instance
.as_object_mut()
.unwrap()
.remove("helpers")
.is_none());
}
std::fs::write(&path, serde_json::to_vec(&value).unwrap()).unwrap();
let record = RustRecord::load(&path).unwrap().unwrap();
assert!(record.instances.values().all(|i| i.helpers.is_empty()));
assert_eq!(record, sample());
}
#[test]
fn ports_held_by_other_instances_exclude_the_one_being_installed() {
let record = sample();

View File

@@ -59,6 +59,15 @@ pub fn run(cli: &Cli) -> Result<i32> {
println!(" · remove the service {}", svc.name);
}
println!(" · remove {}", i.plugin_path);
for helper in i.helpers.values() {
println!(" · remove {}", helper.path);
}
if let Some(npc) = &i.npc {
println!(
" · remove {} (RunicNPC; its placements in data/RunicNPC/ are kept)",
npc.path
);
}
println!(
" · keep {} (the website's; it names this server)",
i.plugin_config
@@ -106,6 +115,15 @@ pub fn run(cli: &Cli) -> Result<i32> {
done.extend(removal.done);
problems.extend(removal.problems);
}
// The helpers first: without the bridge they have nothing to do, and a helper left behind
// would keep patching ZoneManager for a bridge that is gone.
for helper in instance.helpers.values() {
remove_file(Path::new(&helper.path), &mut done, &mut problems);
}
// RunicNPC is the installer's file (D224); its data directory is the admins', and stays.
if let Some(npc) = &instance.npc {
remove_file(Path::new(&npc.path), &mut done, &mut problems);
}
remove_file(Path::new(&instance.plugin_path), &mut done, &mut problems);
if cli.purge {
remove_file(Path::new(&instance.config_path), &mut done, &mut problems);