diff --git a/overlay.toml b/overlay.toml index e80c61a..31b2da0 100644 --- a/overlay.toml +++ b/overlay.toml @@ -26,9 +26,9 @@ # the same change that alters the emitters, exactly as the sidecar bumps # PROTOCOL_VERSION and the module bumps its own constant. # -# Current: 4 — the transport, the read path, identity, and the permission mirror -# (docs/rust-link/PROTOCOL.md §10). -protocol = 4 +# Current: 5 — the transport, the read path, identity, the permission mirror, and +# configuration from the site (docs/rust-link/PROTOCOL.md §11). +protocol = 5 # ── Oxide compatibility ────────────────────────────────────────────────────── # diff --git a/overlay/oxide/plugins/RunicGateway.cs b/overlay/oxide/plugins/RunicGateway.cs index c32cdf7..55732b4 100644 --- a/overlay/oxide/plugins/RunicGateway.cs +++ b/overlay/oxide/plugins/RunicGateway.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Concurrent; using System.Collections.Generic; +using System.IO; using System.Net.Sockets; using System.Text; using System.Threading; @@ -56,7 +57,7 @@ namespace Oxide.Plugins /// overlay's overlay.toml carries this same number so the installer can refuse /// to pair a sidecar and an overlay that disagree. /// - private const int ProtocolVersion = 4; + private const int ProtocolVersion = 5; /// /// Loopback reconnects are cheap, so the ceiling is low. A sidecar restart should cost @@ -234,7 +235,8 @@ namespace Oxide.Plugins "CanUserLogin", "OnUserApproved", "OnUserPermissionGranted", "OnUserPermissionRevoked", "OnUserGroupAdded", "OnUserGroupRemoved", - "OnGroupPermissionGranted", "OnGroupPermissionRevoked" + "OnGroupPermissionGranted", "OnGroupPermissionRevoked", + "OnPluginLoaded", "OnPluginUnloaded" }; private void Init() @@ -296,6 +298,13 @@ namespace Oxide.Plugins _permJob = null; StopPermTimer(); + // A config write waiting on a reload dies here too, and its files stay as written. + // That is the honest outcome: the rollback exists to undo an edit that took a plugin + // down, and this plugin being unloaded is not evidence of either — it is usually THIS + // plugin being reloaded by somebody. The caller times out and re-reads. + StopConfigWatch(); + _configJob = null; + _running = false; _stopping.Set(); Wake.Set(); @@ -777,6 +786,22 @@ namespace Oxide.Plugins HandlePermSync(cmd, reqId); break; + case "config.list": + // Protocol 5 (R18). What settings files exist on this host, and which plugins + // are loaded to reload them. A description of the tree, never its contents. + HandleConfigList(reqId); + break; + + case "config.read": + HandleConfigRead(cmd, reqId); + break; + + case "config.write": + // The first command that writes to the game HOST's filesystem, which is the + // most powerful thing the website can do to a server. + HandleConfigWrite(cmd, reqId); + break; + default: Puts($"unknown command '{verb}'"); break; @@ -1675,8 +1700,7 @@ namespace Oxide.Plugins private static long NowMs() { - return (long)(DateTime.UtcNow - new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc)) - .TotalMilliseconds; + return ToUnixMs(DateTime.UtcNow); } // ---- account linking ---- @@ -2867,6 +2891,45 @@ namespace Oxide.Plugins EmitPermDrift("group-permission-revoked", null, permName, name); } + // ---- the reload, watched ---- + // + // These two are why an automatic rollback is possible at all (R18): a reload either + // produces `OnPluginLoaded` for the target or it does not, and the second case is a plugin + // that is DOWN because of an edit the website just made. + // + // They are deliberately not a general plugin-lifecycle feed. Nothing is emitted from + // either: a busy server loads and unloads plugins for reasons that have nothing to do with + // us, and a frame per event would be noise on the wire and a trigger nobody asked for. + + private void OnPluginLoaded(Plugin plugin) + { + MarkHook("OnPluginLoaded"); + + ConfigWriteJob job = _configJob; + + if (job == null || plugin == null) + return; + + if (string.Equals(plugin.Name, job.Reload, StringComparison.OrdinalIgnoreCase)) + ConfigReloadArrived(); + } + + private void OnPluginUnloaded(Plugin plugin) + { + MarkHook("OnPluginUnloaded"); + + ConfigWriteJob job = _configJob; + + if (job == null || plugin == null) + return; + + // Only ever used to word the failure. "It unloaded and did not come back" is a + // compile error in the edited file; "it never reloaded" is a framework that did not + // act on the request at all, and those send an operator to different places. + if (string.Equals(plugin.Name, job.Reload, StringComparison.OrdinalIgnoreCase)) + job.Unloaded = true; + } + /// /// Main thread. Says that the store changed under us, and by how much it is worth /// caring. @@ -2899,6 +2962,1053 @@ namespace Oxide.Plugins EmitFrame(frame); } + // ---- protocol 5: plugin configuration ---- + // + // R18. An admin edits a loaded plugin's settings on the website and the plugin reloads. + // Three request/reply verbs: `config.list` describes the tree, `config.read` returns one + // file, `config.write` replaces a set and reloads whatever owns it. + // + // Four properties live on this side because only the game host can hold them: + // + // * **The roots come from the framework, never from a literal.** `ConfigDirectory` is + // `oxide/config` on Oxide, `carbon/configs` on Carbon, and neither on a server whose + // operator moved it (`CARBON.md` §3). That amendment was proven the best way it could + // have been: this plugin's own config landed in both places, from this same file. + // * **`DataDirectory` is never walked.** It holds live state — kit cooldowns, zone + // definitions — and both frameworks' permission stores, which is R2's mirror one + // directory over. A settings editor that strayed there would be editing phase 7 + // underneath itself. + // * **A reload is observable.** `OnPluginLoaded` is a real hook, so "did that work" has + // an answer rather than an assumption — and an answer is the whole reason an automatic + // rollback is possible at all. + // * **The site composes the bytes; this plugin writes them.** It receives whole file + // text, not a key and a value, because JavaScript cannot tell `1` from `1.0` and Oxide + // deserializes into typed C# classes (D35/D36). Everything this end does to a document + // is check that it parses. + + /// How deep the walk goes before it stops trying to describe a tree. + private const int ConfigMaxDepth = 6; + + /// How many files the walk will report. + private const int ConfigMaxFiles = 500; + + /// + /// The largest file this plugin will read or write. + /// + /// + /// A file past it is reported and marked un-editable with the reason, never + /// omitted: an operator who cannot find a file they know exists goes looking for a bug + /// in the bridge, and one who can see why it was refused does not. + /// + /// + private const int ConfigMaxFileBytes = 256 * 1024; + + /// Files one write may touch. A plugin can own several; ten of them is not a tree. + private const int ConfigMaxWriteFiles = 10; + + /// + /// How long a reload has to produce OnPluginLoaded before the write is undone. + /// + /// + /// It is under half the sidecar's RPC timeout, and that is arithmetic rather than + /// taste. The worst path through one write is two of these windows — wait for the + /// edit's reload, give up, restore, wait for the restore's reload — with a caller + /// holding a socket for the whole of it (REPLY_TIMEOUT, 10s, rust-link + /// sidecar/src/rpc.rs). A window that does not leave room for both makes the + /// rollback report arrive after the only thing waiting to hear it has given up, which + /// is the one outcome worse than no rollback: the plugin is fine and nobody knows. + /// + /// + private const float ConfigReloadWindowSeconds = 4f; + + /// How much of the newest log file is read back when a reload does not arrive. + private const int ConfigLogTailBytes = 8 * 1024; + + /// One file, resolved and checked, on its way to disk. + private sealed class StagedConfigWrite + { + public string RelPath; + public string FullPath; + public string Text; + + /// What was there before, held for the rollback that may never be needed. + public string Original; + + public bool Written; + } + + /// + /// A write that has landed and is waiting to hear whether the plugin came back. + /// + /// + /// At most one exists at a time. A second write arriving mid-window is refused rather + /// than queued: two overlapping rollbacks over intersecting file sets can restore a + /// file to a state that was never on disk. + /// + /// + private sealed class ConfigWriteJob + { + public string ReqId; + public string Reload; + public List Staged; + public Timer Watch; + + /// Whether the target was seen leaving. It separates "never started" from "did not come back". + public bool Unloaded; + } + + private ConfigWriteJob _configJob; + + private string _lastConfigSummary = "none yet"; + + /// Whether this is a Carbon host. Resolved once, by looking, never by compiling twice (R19). + private static bool? _carbon; + + private void HandleConfigList(string reqId) + { + string root = ConfigRoot(); + + if (string.IsNullOrEmpty(root) || !Directory.Exists(root)) + { + EmitConfigError(reqId, "no-root", "this framework reports no configuration directory"); + return; + } + + var files = new List(); + bool truncated = false; + + try + { + WalkConfig(root, root, 0, files, ref truncated); + } + catch (Exception ex) + { + EmitConfigError(reqId, "unreadable", ex.Message); + return; + } + + Dictionary reply = Frame("config.catalogue", "reply"); + reply["root"] = root; + reply["files"] = files; + reply["plugins"] = LoadedPlugins(); + reply["truncated"] = truncated; + + // Named rather than inferred, so the website's locked-key guard (D38) is written + // against what this plugin calls itself instead of against a filename somebody may + // rename. The file carrying the link is not a file the site may break from a form. + reply["self"] = Name; + + reply["limits"] = new Dictionary + { + { "depth", ConfigMaxDepth }, + { "files", ConfigMaxFiles }, + { "fileBytes", ConfigMaxFileBytes }, + { "writeFiles", ConfigMaxWriteFiles } + }; + + if (reqId != null) + reply["reqId"] = reqId; + + EmitFrame(reply); + } + + /// + /// Main thread, and deliberately cheap: it reads directory entries, never file + /// contents. A tree of 500 files costs 500 stat calls here; hashing all of them would + /// cost up to 128 MB of reads in one frame, which is exactly the unbounded work §10.5 + /// forbids. A file's version is therefore produced by config.read, on the one + /// file somebody actually opened. + /// + private void WalkConfig(string root, string dir, int depth, List into, ref bool truncated) + { + if (depth > ConfigMaxDepth) + { + truncated = true; + return; + } + + string[] entries; + + try + { + entries = Directory.GetFiles(dir, "*.json"); + } + catch (Exception) + { + truncated = true; + return; + } + + Array.Sort(entries, StringComparer.OrdinalIgnoreCase); + + foreach (string full in entries) + { + if (into.Count >= ConfigMaxFiles) + { + truncated = true; + return; + } + + FileInfo info; + + try + { + info = new FileInfo(full); + } + catch (Exception) + { + continue; + } + + // A link is not followed even when it points somewhere legitimate. Resolving one + // is how a tree that looks bounded turns out not to be, and how a path that + // validates as "under the root" writes somewhere else. + if ((info.Attributes & FileAttributes.ReparsePoint) != 0) + continue; + + string rel = RelativeConfigPath(root, full); + + var row = new Dictionary + { + { "path", rel }, + { "bytes", info.Length }, + { "modified", ToUnixMs(info.LastWriteTimeUtc) }, + { "plugin", PluginGuess(rel) } + }; + + if (info.Length > ConfigMaxFileBytes) + { + row["editable"] = false; + row["reason"] = "larger than this bridge will carry"; + } + else + { + row["editable"] = true; + } + + into.Add(row); + } + + string[] dirs; + + try + { + dirs = Directory.GetDirectories(dir); + } + catch (Exception) + { + truncated = true; + return; + } + + Array.Sort(dirs, StringComparer.OrdinalIgnoreCase); + + foreach (string sub in dirs) + { + if (into.Count >= ConfigMaxFiles) + { + truncated = true; + return; + } + + try + { + if ((new DirectoryInfo(sub).Attributes & FileAttributes.ReparsePoint) != 0) + continue; + } + catch (Exception) + { + continue; + } + + WalkConfig(root, sub, depth + 1, into, ref truncated); + } + } + + private void HandleConfigRead(Dictionary cmd, string reqId) + { + string rel = StringField(cmd, "path"); + string full; + string refusal; + + if (!ResolveConfigPath(rel, out full, out refusal)) + { + EmitConfigError(reqId, "path", refusal); + return; + } + + if (!File.Exists(full)) + { + EmitConfigError(reqId, "missing", "no such configuration file"); + return; + } + + string text; + long bytes; + DateTime modified; + + try + { + var info = new FileInfo(full); + bytes = info.Length; + modified = info.LastWriteTimeUtc; + + if (bytes > ConfigMaxFileBytes) + { + EmitConfigError(reqId, "too-large", "larger than this bridge will carry"); + return; + } + + text = File.ReadAllText(full, Encoding.UTF8); + } + catch (Exception ex) + { + EmitConfigError(reqId, "unreadable", ex.Message); + return; + } + + Dictionary reply = Frame("config.file", "reply"); + reply["path"] = rel; + reply["text"] = text; + reply["version"] = VersionOf(text); + reply["bytes"] = bytes; + reply["modified"] = ToUnixMs(modified); + reply["plugin"] = PluginGuess(rel); + + if (reqId != null) + reply["reqId"] = reqId; + + EmitFrame(reply); + } + + /// + /// Main thread. Replaces a set of files and reloads what owns them. + /// + /// + /// Everything is resolved, version-checked and parsed before the first byte is + /// written — the same posture as perm.sync, and for the same reason: a + /// refusal that has touched nothing has nothing to unwind. What cannot be checked in + /// advance is whether the plugin will come back, which is what the window and the + /// rollback are for. + /// + /// + private void HandleConfigWrite(Dictionary cmd, string reqId) + { + if (_configJob != null) + { + EmitConfigError(reqId, "busy", "another configuration write is still waiting for its reload"); + return; + } + + var rows = new List>(Rows(cmd, "files")); + + if (rows.Count == 0) + { + EmitConfigError(reqId, "empty", "a write must name at least one file"); + return; + } + + if (rows.Count > ConfigMaxWriteFiles) + { + EmitConfigError(reqId, "too-many", $"a write may touch at most {ConfigMaxWriteFiles} files"); + return; + } + + string reload = StringField(cmd, "reload"); + + // The site may not reload the bridge. The reload would unload this plugin, close the + // link the answer travels on, and leave a rollback with nothing watching it — so the + // one failure this whole mechanism exists to prevent would be the one it could not + // report. Our own settings are still editable (D38); they take effect the next time + // somebody reloads this plugin deliberately. + if (!string.IsNullOrEmpty(reload) && reload.Equals(Name, StringComparison.OrdinalIgnoreCase)) + { + EmitConfigError(reqId, "reload-self", + "this bridge will not reload itself: the reload would close the link carrying the answer"); + return; + } + + var staged = new List(); + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (Dictionary row in rows) + { + string rel = StringField(row, "path"); + string version = StringField(row, "version"); + string text = StringField(row, "text"); + string full; + string refusal; + + if (!ResolveConfigPath(rel, out full, out refusal)) + { + EmitConfigError(reqId, "path", refusal); + return; + } + + if (!seen.Add(full)) + { + EmitConfigError(reqId, "duplicate", $"'{rel}' is named twice in one write"); + return; + } + + if (text == null) + { + EmitConfigError(reqId, "empty", $"'{rel}' carries no text"); + return; + } + + if (Encoding.UTF8.GetByteCount(text) > ConfigMaxFileBytes) + { + EmitConfigError(reqId, "too-large", $"'{rel}' is larger than this bridge will carry"); + return; + } + + // A config file is edited, never created. A plugin writes its own defaults, and a + // file this walk has never seen is not a settings file somebody forgot — it is a + // path we have no reason to believe in. + if (!File.Exists(full)) + { + EmitConfigError(reqId, "missing", $"'{rel}' does not exist"); + return; + } + + string current; + + try + { + current = File.ReadAllText(full, Encoding.UTF8); + } + catch (Exception ex) + { + EmitConfigError(reqId, "unreadable", $"'{rel}': {ex.Message}"); + return; + } + + string actual = VersionOf(current); + + if (version != actual) + { + Dictionary conflict = Frame("config.error", "reply"); + conflict["reason"] = "conflict"; + conflict["detail"] = $"'{rel}' changed on disk since it was read"; + conflict["path"] = rel; + conflict["version"] = actual; + + if (reqId != null) + conflict["reqId"] = reqId; + + EmitFrame(conflict); + return; + } + + try + { + Newtonsoft.Json.Linq.JToken.Parse(text); + } + catch (Exception ex) + { + EmitConfigError(reqId, "invalid", $"'{rel}' is not valid JSON: {ex.Message}"); + return; + } + + staged.Add(new StagedConfigWrite + { + RelPath = rel, + FullPath = full, + Text = text, + Original = current + }); + } + + foreach (StagedConfigWrite file in staged) + { + try + { + BackupConfig(file); + File.WriteAllText(file.FullPath, file.Text, Utf8NoBom); + file.Written = true; + } + catch (Exception ex) + { + // Half a set on disk is the state nothing can reason about. Put back whatever + // did land before answering. + RestoreConfig(staged); + EmitConfigError(reqId, "unwritable", $"'{file.RelPath}': {ex.Message}"); + return; + } + } + + if (string.IsNullOrEmpty(reload)) + { + SendConfigReport(reqId, staged, false, false, "no reload was asked for", null); + return; + } + + if (plugins.Find(reload) == null) + { + // The file is written and the plugin is not loaded, so there is nothing to reload + // and nothing to roll back from: the change applies when it is next loaded. Said + // plainly, because "saved" and "in effect" are different claims. + SendConfigReport(reqId, staged, false, false, $"'{reload}' is not loaded", null); + return; + } + + _configJob = new ConfigWriteJob + { + ReqId = reqId, + Reload = reload, + Staged = staged + }; + + _configJob.Watch = timer.Once(ConfigReloadWindowSeconds, ConfigReloadTimedOut); + + if (!ReloadPluginByName(reload)) + { + StopConfigWatch(); + _configJob = null; + RestoreConfig(staged); + EmitConfigError(reqId, "reload-failed", $"this framework refused to reload '{reload}'"); + } + } + + /// Main thread. The reload arrived: the plugin is back, so the write stands. + private void ConfigReloadArrived() + { + ConfigWriteJob job = _configJob; + + if (job == null) + return; + + StopConfigWatch(); + _configJob = null; + + SendConfigReport(job.ReqId, job.Staged, true, false, null, null); + } + + /// + /// Main thread. The window closed with no OnPluginLoaded, which means the plugin + /// is down and the edit is why. Put every file back, reload again, and say so. + /// + /// + /// This is the feature's real content. Without it, R18 is a web form that takes a + /// required plugin off a production server one typo at a time. + /// + /// + private void ConfigReloadTimedOut() + { + ConfigWriteJob job = _configJob; + + if (job == null) + return; + + _configJob = null; + StopConfigWatch(); + + RestoreConfig(job.Staged); + + string reason = job.Unloaded + ? $"'{job.Reload}' unloaded and did not come back within {ConfigReloadWindowSeconds:0}s" + : $"'{job.Reload}' did not reload within {ConfigReloadWindowSeconds:0}s"; + + // Fire and forget: the restore is on disk either way, and waiting for a second window + // would spend the caller's whole RPC budget to learn something the operator can see. + ReloadPluginByName(job.Reload); + + SendConfigReport(job.ReqId, job.Staged, false, true, reason, LogTail()); + } + + private void StopConfigWatch() + { + ConfigWriteJob job = _configJob; + + if (job == null || job.Watch == null) + return; + + job.Watch.Destroy(); + job.Watch = null; + } + + /// + /// Main thread. What happened, and what is on disk now. + /// + /// + /// The versions are re-read rather than assumed, because both frameworks merge missing + /// defaults into a config on load and save it back — so the file after a successful + /// reload is regularly not the file we wrote, and a site holding our version would + /// conflict with itself on the next save. + /// + /// + private void SendConfigReport( + string reqId, + List staged, + bool reloaded, + bool rolledBack, + string reason, + string log) + { + var files = new List(); + + foreach (StagedConfigWrite file in staged) + { + var row = new Dictionary { { "path", file.RelPath } }; + + try + { + string now = File.ReadAllText(file.FullPath, Encoding.UTF8); + row["version"] = VersionOf(now); + row["bytes"] = Encoding.UTF8.GetByteCount(now); + row["rewritten"] = !rolledBack && now != file.Text; + } + catch (Exception) + { + row["version"] = null; + } + + files.Add(row); + } + + Dictionary report = Frame("config.report", "reply"); + report["ok"] = !rolledBack; + report["reloaded"] = reloaded; + report["rolledBack"] = rolledBack; + report["files"] = files; + + if (!string.IsNullOrEmpty(reason)) + report["reason"] = reason; + + if (!string.IsNullOrEmpty(log)) + report["log"] = log; + + if (reqId != null) + report["reqId"] = reqId; + + _lastConfigSummary = + $"files={staged.Count} reloaded={reloaded} rolledBack={rolledBack} " + + $"at={DateTime.UtcNow:HH:mm:ss}Z" + + (string.IsNullOrEmpty(reason) ? "" : $" reason={reason}"); + + EmitFrame(report); + } + + private void EmitConfigError(string reqId, string reason, string detail) + { + Dictionary frame = Frame("config.error", "reply"); + frame["reason"] = reason; + + if (!string.IsNullOrEmpty(detail)) + frame["detail"] = detail; + + if (reqId != null) + frame["reqId"] = reqId; + + EmitFrame(frame); + } + + /// Main thread. Puts every file that landed back the way it was. + private void RestoreConfig(List staged) + { + foreach (StagedConfigWrite file in staged) + { + if (!file.Written) + continue; + + try + { + File.WriteAllText(file.FullPath, file.Original, Utf8NoBom); + } + catch (Exception ex) + { + // Nothing else can be done from here, and it must be loud: the file on disk is + // now the edit that took a plugin down, and the operator is the only one who + // can fix it. + Puts($"could not restore '{file.RelPath}': {ex.Message}"); + } + } + } + + /// + /// Keeps a copy of a file about to be overwritten, and the nine before it. + /// + /// + /// Under DataDirectory, which is this plugin writing its own state — not the + /// editor reaching into the tree it must never walk. The rollback itself does not + /// depend on these files; it holds the original in memory. They are for the operator + /// who wants last Tuesday's config back. + /// + /// + private void BackupConfig(StagedConfigWrite file) + { + string dir = Path.Combine(Interface.Oxide.DataDirectory, Name, "config-backups"); + Directory.CreateDirectory(dir); + + string flat = file.RelPath.Replace('/', '_'); + string stamp = DateTime.UtcNow.ToString("yyyyMMdd-HHmmss"); + + File.WriteAllText(Path.Combine(dir, $"{flat}.{stamp}.bak"), file.Original, Utf8NoBom); + + string[] kept; + + try + { + kept = Directory.GetFiles(dir, $"{flat}.*.bak"); + } + catch (Exception) + { + return; + } + + if (kept.Length <= 10) + return; + + Array.Sort(kept, StringComparer.Ordinal); + + for (int i = 0; i < kept.Length - 10; i++) + { + try + { + File.Delete(kept[i]); + } + catch (Exception) + { + } + } + } + + /// + /// The tail of the newest log file, read only when a reload failed. + /// + /// + /// A rollback that says "it did not come back" and nothing else sends the operator to a + /// console to find the line that says why. This carries the line instead. Bounded, and + /// paid for only on the failure path. + /// + /// + private string LogTail() + { + try + { + string dir = Interface.Oxide.LogDirectory; + + if (string.IsNullOrEmpty(dir) || !Directory.Exists(dir)) + return null; + + string[] logs = Directory.GetFiles(dir, "*.txt"); + + if (logs.Length == 0) + return null; + + string newest = null; + DateTime newestAt = DateTime.MinValue; + + foreach (string candidate in logs) + { + DateTime at = File.GetLastWriteTimeUtc(candidate); + + if (at <= newestAt) + continue; + + newest = candidate; + newestAt = at; + } + + if (newest == null) + return null; + + using (var stream = new FileStream(newest, FileMode.Open, FileAccess.Read, FileShare.ReadWrite)) + { + int take = (int)Math.Min(stream.Length, ConfigLogTailBytes); + stream.Seek(-take, SeekOrigin.End); + + var buffer = new byte[take]; + int read = stream.Read(buffer, 0, take); + + return Encoding.UTF8.GetString(buffer, 0, read); + } + } + catch (Exception) + { + return null; + } + } + + /// + /// Reloads a plugin through the framework rather than by composing a console command. + /// + /// + /// The console is the one place the two frameworks genuinely differ — Carbon's commands + /// are c.-prefixed and an alias for the Oxide names is opt-in — and a wrong + /// prefix on Carbon prints nothing at all, so it looks exactly like a command that + /// worked (`CARBON.md` §5). Reflection rather than #if CARBON, because one + /// byte-identical source file on both frameworks is the property R19 proved and is + /// worth keeping. + /// + /// + private bool ReloadPluginByName(string name) + { + try + { + System.Reflection.MethodInfo reload = Interface.Oxide.GetType() + .GetMethod("ReloadPlugin", new[] { typeof(string) }); + + if (reload != null) + { + reload.Invoke(Interface.Oxide, new object[] { name }); + return true; + } + } + catch (Exception ex) + { + Puts($"framework reload of '{name}' threw: {ex.Message}"); + } + + try + { + covalence.Server.Command(IsCarbon() ? "c.reload" : "oxide.reload", name); + return true; + } + catch (Exception ex) + { + Puts($"console reload of '{name}' threw: {ex.Message}"); + return false; + } + } + + private static bool IsCarbon() + { + if (_carbon.HasValue) + return _carbon.Value; + + bool found = false; + + try + { + foreach (System.Reflection.Assembly loaded in AppDomain.CurrentDomain.GetAssemblies()) + { + string assembly = loaded.GetName().Name; + + if (assembly == null) + continue; + + if (assembly == "Carbon" || assembly.StartsWith("Carbon.", StringComparison.Ordinal)) + { + found = true; + break; + } + } + } + catch (Exception) + { + } + + _carbon = found; + return found; + } + + private static string ConfigRoot() + { + try + { + return Interface.Oxide.ConfigDirectory; + } + catch (Exception) + { + return null; + } + } + + /// + /// Turns a path from a web form into a path on this host, or refuses it with a reason. + /// + /// + /// Phase 7 addressed things by name; this phase addresses them by path, which is + /// exactly the change that introduces this bug class. Four refusals, and the last is the + /// one that matters: a resolved path must genuinely sit under the config root, checked + /// after canonicalisation rather than before. + /// + /// + private static bool ResolveConfigPath(string rel, out string full, out string refusal) + { + full = null; + refusal = null; + + if (string.IsNullOrEmpty(rel)) + { + refusal = "no path"; + return false; + } + + if (rel.IndexOf('\0') >= 0 || rel.IndexOf("..", StringComparison.Ordinal) >= 0) + { + refusal = "a path may not step outside the configuration directory"; + return false; + } + + if (rel.StartsWith("/", StringComparison.Ordinal) + || rel.StartsWith("\\", StringComparison.Ordinal) + || Path.IsPathRooted(rel) + || rel.IndexOf(':') >= 0) + { + refusal = "a path must be relative to the configuration directory"; + return false; + } + + if (!rel.EndsWith(".json", StringComparison.OrdinalIgnoreCase)) + { + refusal = "only .json files are configuration"; + return false; + } + + string root = ConfigRoot(); + + if (string.IsNullOrEmpty(root)) + { + refusal = "this framework reports no configuration directory"; + return false; + } + + string rooted; + + try + { + rooted = Path.GetFullPath(root); + full = Path.GetFullPath(Path.Combine(rooted, rel.Replace('/', Path.DirectorySeparatorChar))); + } + catch (Exception ex) + { + refusal = ex.Message; + full = null; + return false; + } + + string prefix = rooted.EndsWith(Path.DirectorySeparatorChar.ToString(), StringComparison.Ordinal) + ? rooted + : rooted + Path.DirectorySeparatorChar; + + if (!full.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + { + refusal = "that path is not inside the configuration directory"; + full = null; + return false; + } + + try + { + var info = new FileInfo(full); + + if (info.Exists && (info.Attributes & FileAttributes.ReparsePoint) != 0) + { + refusal = "that file is a link, and a link is not followed"; + full = null; + return false; + } + } + catch (Exception) + { + } + + return true; + } + + private static string RelativeConfigPath(string root, string full) + { + string prefix = root.EndsWith(Path.DirectorySeparatorChar.ToString(), StringComparison.Ordinal) + ? root + : root + Path.DirectorySeparatorChar; + + string rel = full.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) + ? full.Substring(prefix.Length) + : Path.GetFileName(full); + + return rel.Replace(Path.DirectorySeparatorChar, '/').Replace('\\', '/'); + } + + /// + /// Which plugin a file probably belongs to: the folder for a nested file, the file's own + /// name otherwise. + /// + /// + /// A guess, and it is labelled one all the way to the form. A folder name is + /// convention, not contract. Infer it silently and the failure is the nastiest available + /// here: we reload the wrong plugin, watch OnPluginLoaded fire for it, and + /// report success while the plugin that was actually edited never re-read a thing. + /// + /// + private static string PluginGuess(string rel) + { + int slash = rel.IndexOf('/'); + + return slash > 0 + ? rel.Substring(0, slash) + : Path.GetFileNameWithoutExtension(rel); + } + + private List LoadedPlugins() + { + var rows = new List(); + Plugin[] all; + + try + { + all = plugins.GetAll(); + } + catch (Exception) + { + return rows; + } + + if (all == null) + return rows; + + foreach (Plugin loaded in all) + { + if (loaded == null || string.IsNullOrEmpty(loaded.Name)) + continue; + + rows.Add(new Dictionary + { + { "name", loaded.Name }, + { "title", loaded.Title }, + { "version", loaded.Version.ToString() }, + { "author", loaded.Author } + }); + } + + return rows; + } + + /// + /// A file's identity for optimistic concurrency: its length and an FNV-1a hash of its + /// text. + /// + /// + /// Deliberately not a cryptographic digest. Nothing here is a security claim — the site + /// never computes one of these, it only echoes back the one it was given — and + /// System.Security.Cryptography is a namespace whose availability under a plugin + /// compiler is one more thing that would have to be true on two frameworks. + /// + /// + private static string VersionOf(string text) + { + byte[] bytes = Encoding.UTF8.GetBytes(text ?? string.Empty); + ulong hash = 14695981039346656037UL; + + foreach (byte b in bytes) + { + hash ^= b; + hash *= 1099511628211UL; + } + + return $"{bytes.Length:x}-{hash:x16}"; + } + + /// UTF-8 with no byte-order mark, which is what both frameworks write. + private static readonly UTF8Encoding Utf8NoBom = new UTF8Encoding(false); + + private static long ToUnixMs(DateTime utc) + { + return (long)(utc - new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc)).TotalMilliseconds; + } + // ---- reading a command's fields ---- // // `JsonConvert` hands back `object`, and every field below arrives as one. These are the @@ -3037,6 +4147,29 @@ namespace Oxide.Plugins $"last sync: {_lastPermSummary}"); } + /// + /// Where this framework keeps configuration, and what the last write from the website + /// did. + /// + /// + /// The root is the interesting half. It is oxide/config on one rig and + /// carbon/configs on the other, from this same source file, and an operator + /// wondering why the website is offering a tree they do not recognise is usually + /// looking at a server whose directories were moved. + /// + /// + [ConsoleCommand("rg.config")] + private void CmdConfig(ConsoleSystem.Arg arg) + { + string root = ConfigRoot(); + + arg.ReplyWith( + $"protocol={ProtocolVersion} framework={(IsCarbon() ? "carbon" : "oxide")} " + + $"root={(string.IsNullOrEmpty(root) ? "(none)" : root)}" + Environment.NewLine + + $"waiting={(_configJob != null ? _configJob.Reload : "no")}" + Environment.NewLine + + $"last write: {_lastConfigSummary}"); + } + /// /// Which of this plugin's hooks have actually fired, and how often. ///