diff --git a/overlay/oxide/plugins/RunicGateway.cs b/overlay/oxide/plugins/RunicGateway.cs index 3b5d6ab..23202db 100644 --- a/overlay/oxide/plugins/RunicGateway.cs +++ b/overlay/oxide/plugins/RunicGateway.cs @@ -319,6 +319,12 @@ namespace Oxide.Plugins _permJob = null; StopPermTimer(); + // The same for an inventory being built: the site's request times out and it asks + // again from page 0, which is the only way it ever reads one. + _inventoryJob = null; + _inventory = null; + StopInventoryTimer(); + // 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 @@ -955,6 +961,12 @@ namespace Oxide.Plugins HandlePermSync(cmd, reqId); break; + case "perm.inventory": + // Protocol 13 (D160). The whole store, owners included, in pages — what the + // site reads before every sync now that it owns all of it. + HandlePermInventory(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. @@ -2996,9 +3008,11 @@ namespace Oxide.Plugins // log (PLAN.md §12.2 rule 1) — so without the pre-check a re-push into a server missing // one plugin is a silent partial. Registering the name here instead would fabricate a // permission the operator never installed (D33). - // • A holder the site does not author is reported `foreign` and never touched (D31). - // Somebody typing `oxide.grant` during an incident is drift, not an error, and the - // website offers an operator two answers to it. + // • A holder the site does not author is never touched by a sync. Until protocol 13 the + // sync scanned the names the site claimed and reported such holders `foreign` (D31). + // Now the site owns the whole store (D160) and reads it with `perm.inventory`, so it + // finds a hand edit itself and answers it with the server's policy (D161): the sync + // only ever carries what the site decided. // • A group member who has never connected is reported `pending`. The store has no user // record to put in a group yet (§12.2 rule 4), and the membership lands on their first // connection — a direct grant would have reached them immediately, and pretending the @@ -3042,8 +3056,12 @@ namespace Oxide.Plugins RetireMember, RetireGrant, RetireGroup, - ScanPermission, - ScanGroup, + + /// + /// Protocol 13: a group's parent (PLAN_REDESIGNS §1.4). Compiled after every group, + /// so the parent it names already exists when it is applied. + /// + EnsureGroupParent, /// Protocol 12: a group's BetterChat style (D138). EnsureChatStyle, @@ -3066,6 +3084,12 @@ namespace Oxide.Plugins public string Title; public int Rank; + + /// + /// Whether the site sent a rank. A group whose frame has none keeps the rank it has, + /// rather than being set to 0 by an absent field. + /// + public bool HasRank; } /// @@ -3083,6 +3107,10 @@ namespace Oxide.Plugins public int Grants; public int Revokes; public int GroupsCreated; + + /// Protocol 13: an existing group whose title, rank or parent the sync changed. + public int GroupsUpdated; + public int GroupsRemoved; public int GroupPermissions; public int GroupPermissionsRemoved; @@ -3092,7 +3120,6 @@ namespace Oxide.Plugins public int Absent; public readonly List Unresolved = new List(); - public readonly List Foreign = new List(); public readonly List Pending = new List(); /// @@ -3103,8 +3130,8 @@ namespace Oxide.Plugins public readonly List NotLanded = new List(); /// - /// Group permissions an event lease holds (protocol 8). Neither applied to nor - /// reported foreign; what the site asked for is kept on the hold instead. + /// Group permissions an event lease holds (protocol 8). Not applied; what the site + /// asked for is kept on the hold instead. /// public readonly List Leased = new List(); @@ -3113,17 +3140,6 @@ namespace Oxide.Plugins /// Permission names already reported unresolved, so each is reported once. public readonly HashSet UnresolvedSeen = new HashSet(); - /// Who the site says should hold each managed permission, for the foreign scan. - public readonly Dictionary> DesiredHolders = - new Dictionary>(); - - /// Who the site says is in each managed group, and what it should carry. - public readonly Dictionary> DesiredMembers = - new Dictionary>(); - - public readonly Dictionary> DesiredGroupPermissions = - new Dictionary>(); - /// /// The site's kit credits for this server (protocol 10, D103), or null when the sync /// carried none — an older website, whose silence must not withdraw anybody's credit. @@ -3259,6 +3275,471 @@ namespace Oxide.Plugins EmitFrame(reply); } + // ---- protocol 13: the whole store (PLAN_REDESIGNS §1.2, D160) ---- + // + // The site owns every permission and group on this server, so it has to be able to read + // all of them — not just the names it once authored, which is all the foreign scan ever + // looked at. One verb, `perm.inventory`, answers with: + // + // • every registered permission and the plugin that REGISTERED it. The owner comes from + // `PermissionExists(name, plugin)` asked of each loaded plugin — public API on both + // frameworks, proven on both rigs — never from the name's prefix, which is how + // `zonemanager.ignoreflag.nokits` ends up filed under Kits in other tools. + // • every group with its title, rank, parent and the permissions it carries itself. + // • every Steam id that holds anything: its own permissions and its groups, except + // `default`, which every connected player is in by the framework's own rule. + // • the group permissions an event lease is holding, which are the lease's, not a hand + // edit's. + // + // Neither framework can list its users, so the holders come from asking each permission + // and each group who has it, and each of those walks every user. On a real store that is + // permissions × users, so it is built a slice per tick, like the sync drain, and then + // sent in pages that each fit the game link's 1 MiB line cap. The snapshot is taken once + // and paged from memory, so a grant made between two pages cannot tear it. + + /// How many permissions or groups one build step asks about. + private const int InventorySourcesPerStep = 25; + + /// How many bytes of rows one page may carry, well inside the 1 MiB line cap. + private const int InventoryPageBudgetBytes = 700 * 1024; + + /// + /// How long a build may run before it is abandoned. The sidecar waits ten seconds for a + /// reply; a build past this has already lost that wait, and a store that large needs a + /// person to hear about it rather than a hitch every sync. + /// + private const long InventoryMaxBuildMs = 8000; + + /// How long a finished snapshot is kept for the pages after the first. + private const long InventoryKeepMs = 2 * 60 * 1000; + + private sealed class InventoryUser + { + public string SteamId; + public string Name; + public readonly List Permissions = new List(); + public readonly List Groups = new List(); + } + + /// A build in flight. There is at most one; a second request is answered `busy`. + private sealed class InventoryJob + { + public string ReqId; + public long StartedMs; + + /// Started from the server console (rg.inventory): logged, never sent. + public bool FromConsole; + + /// Permissions first, then groups prefixed with a NUL, one store question each. + public readonly List Sources = new List(); + + public int Next; + public readonly Dictionary Users = new Dictionary(); + public List Permissions; + public List Groups; + public List Leased; + } + + /// A finished snapshot, paged from memory. + private sealed class Inventory + { + public string Id; + public long BuiltMs; + public long BuildMs; + public List Permissions; + public List Groups; + public List Leased; + public int UserCount; + public readonly List> Pages = new List>(); + } + + private InventoryJob _inventoryJob; + private Timer _inventoryTimer; + private Inventory _inventory; + + /// The last inventory built, for rg.perms. Diagnostics only. + private string _lastInventorySummary = "none yet"; + + /// + /// The same build from the server console, logged rather than sent: how a rig proves the + /// read — and times it on a large store — without the website or the sidecar's token. + /// + [ConsoleCommand("rg.inventory")] + private void CmdInventory(ConsoleSystem.Arg arg) + { + if (arg.Connection != null) + return; + + if (_inventoryJob != null) + { + arg.ReplyWith("an inventory is already being built"); + return; + } + + StartInventory(null, true); + arg.ReplyWith("inventory started; the summary is logged"); + } + + /// Main thread. Page 0 starts a build; any other page is read from the snapshot it names. + private void HandlePermInventory(Dictionary cmd, string reqId) + { + string snapshotId = StringField(cmd, "snapshotId"); + int page = IntField(cmd, "page"); + + if (!string.IsNullOrEmpty(snapshotId)) + { + Inventory held = _inventory; + + if (held == null || held.Id != snapshotId || NowMs() - held.BuiltMs > InventoryKeepMs || + page < 0 || page >= held.Pages.Count) + { + // The site starts again from page 0; a stale page is never a partial answer. + EmitPermError(reqId, "stale"); + return; + } + + EmitInventoryPage(held, page, reqId); + return; + } + + if (_inventoryJob != null) + { + EmitPermError(reqId, "busy"); + return; + } + + StartInventory(reqId, false); + } + + /// Main thread. Reads the small parts at once and starts the sliced holder walk. + private void StartInventory(string reqId, bool fromConsole) + { + var job = new InventoryJob { ReqId = reqId, StartedMs = NowMs(), FromConsole = fromConsole }; + + // Owners, groups and leases are small and read at once; the holder walk is sliced. + var owner = new Dictionary(StringComparer.OrdinalIgnoreCase); + string[] registered = permission.GetPermissions() ?? new string[0]; + + foreach (Plugin plugin in plugins.GetAll()) + { + if (plugin == null) + continue; + + foreach (string perm in registered) + { + if (!string.IsNullOrEmpty(perm) && !owner.ContainsKey(perm) && permission.PermissionExists(perm, plugin)) + owner[perm] = plugin.Name; + } + } + + job.Permissions = new List(); + + foreach (string perm in registered) + { + if (string.IsNullOrEmpty(perm)) + continue; + + string lowered = Lower(perm); + var row = new Dictionary { { "name", lowered } }; + string by; + + // A name no plugin owns — Carbon's built-in modules register theirs that way — is + // sent without one, and the site groups it by prefix. + if (owner.TryGetValue(perm, out by)) + row["owner"] = by; + + job.Permissions.Add(row); + job.Sources.Add(lowered); + } + + job.Groups = new List(); + + foreach (string name in permission.GetGroups() ?? new string[0]) + { + if (string.IsNullOrEmpty(name)) + continue; + + string lowered = Lower(name); + var carried = new List(); + + foreach (string perm in permission.GetGroupPermissions(name, false) ?? new string[0]) + { + if (!string.IsNullOrEmpty(perm)) + carried.Add(Lower(perm)); + } + + job.Groups.Add(new Dictionary + { + { "name", lowered }, + { "title", permission.GetGroupTitle(name) ?? "" }, + { "rank", permission.GetGroupRank(name) }, + { "parent", Lower(permission.GetGroupParent(name)) ?? "" }, + { "permissions", carried } + }); + + // `default` holds every player who ever connected, by the framework's rule rather + // than anybody's decision, so its members are not the site's to own. + if (!string.Equals(lowered, "default", StringComparison.Ordinal)) + job.Sources.Add("\0" + lowered); + } + + job.Leased = new List(); + + foreach (LeaseHold hold in _leases.Values) + { + if (hold.Key != GroupPermissionKey || string.IsNullOrEmpty(hold.Target)) + continue; + + int slash = hold.Target.IndexOf('/'); + + if (slash <= 0) + continue; + + job.Leased.Add(new Dictionary + { + { "kind", "group-permission" }, + { "subject", Lower(hold.Target.Substring(0, slash)) }, + { "object", Lower(hold.Target.Substring(slash + 1)) } + }); + } + + _inventoryJob = job; + _inventoryTimer = timer.Every(PermStepSeconds, StepInventory); + + // A small store — every rig, and most real servers — finishes inside this call. + StepInventory(); + } + + /// Main thread. Asks up to permissions or groups who holds them. + private void StepInventory() + { + InventoryJob job = _inventoryJob; + + if (job == null) + { + StopInventoryTimer(); + return; + } + + if (NowMs() - job.StartedMs > InventoryMaxBuildMs) + { + _inventoryJob = null; + StopInventoryTimer(); + Puts($"permission inventory abandoned after {InventoryMaxBuildMs} ms at {job.Next} of {job.Sources.Count} sources, {job.Users.Count} users"); + + if (!job.FromConsole) + EmitPermError(job.ReqId, "too-large"); + + return; + } + + int budget = InventorySourcesPerStep; + + while (budget-- > 0 && job.Next < job.Sources.Count) + { + string source = job.Sources[job.Next++]; + + try + { + bool isGroup = source.Length > 0 && source[0] == '\0'; + string name = isGroup ? source.Substring(1) : source; + string[] entries = isGroup ? permission.GetUsersInGroup(name) : permission.GetPermissionUsers(name); + + if (entries == null) + continue; + + foreach (string entry in entries) + { + string id = UserId(entry); + + if (string.IsNullOrEmpty(id)) + continue; + + InventoryUser user; + + if (!job.Users.TryGetValue(id, out user)) + { + user = new InventoryUser { SteamId = id, Name = StoredName(entry) }; + job.Users[id] = user; + } + + if (isGroup) + user.Groups.Add(name); + else + user.Permissions.Add(name); + } + } + catch (Exception ex) + { + // One odd name must not lose the whole read; the next sync reads it again. + Puts($"permission inventory: '{source.TrimStart('\0')}' threw: {Flatten(ex.Message)}"); + } + } + + if (job.Next < job.Sources.Count) + return; + + _inventoryJob = null; + StopInventoryTimer(); + + Inventory done = Paginate(job); + _inventory = done; + _lastInventorySummary = $"{done.Id}: {done.Permissions.Count} permissions, {done.Groups.Count} groups, " + + $"{done.UserCount} users, {done.Pages.Count} pages, {done.BuildMs} ms at={DateTime.UtcNow:HH:mm:ss}Z"; + + if (job.FromConsole) + { + // To the log, where a rig walk can read it: the console's own reply never reaches + // Oxide's log file. + Puts($"inventory {_lastInventorySummary}"); + + var owners = new Dictionary(); + + foreach (Dictionary row in done.Permissions) + { + object by; + string key = row.TryGetValue("owner", out by) ? (string)by : "(none)"; + int n; + owners.TryGetValue(key, out n); + owners[key] = n + 1; + } + + var ownerText = new List(); + + foreach (KeyValuePair kv in owners) + ownerText.Add(kv.Key + "=" + kv.Value); + + List sample = done.Pages[0].GetRange(0, Math.Min(10, done.Pages[0].Count)); + + Puts("inventory owners: " + string.Join(", ", ownerText.ToArray())); + Puts("inventory groups: " + JsonConvert.SerializeObject(done.Groups, Formatting.None)); + Puts("inventory leased: " + JsonConvert.SerializeObject(done.Leased, Formatting.None)); + Puts("inventory users: " + JsonConvert.SerializeObject(sample, Formatting.None)); + return; + } + + EmitInventoryPage(done, 0, job.ReqId); + } + + private void StopInventoryTimer() + { + if (_inventoryTimer == null) + return; + + _inventoryTimer.Destroy(); + _inventoryTimer = null; + } + + /// + /// Splits the users into pages by their serialised size. Page 0 also carries the + /// permissions, groups and leases, so its budget is what they leave. + /// + private Inventory Paginate(InventoryJob job) + { + var inv = new Inventory + { + Id = Guid.NewGuid().ToString("N").Substring(0, 12), + BuiltMs = NowMs(), + BuildMs = NowMs() - job.StartedMs, + Permissions = job.Permissions, + Groups = job.Groups, + Leased = job.Leased, + UserCount = job.Users.Count + }; + + int header = Encoding.UTF8.GetByteCount(JsonConvert.SerializeObject(new Dictionary + { + { "permissions", job.Permissions }, + { "groups", job.Groups }, + { "leased", job.Leased } + }, Formatting.None)); + + var ids = new List(job.Users.Keys); + ids.Sort(StringComparer.Ordinal); + + var current = new List(); + int bytes = header; + + foreach (string id in ids) + { + InventoryUser u = job.Users[id]; + var row = new Dictionary + { + { "steamId", u.SteamId }, + { "permissions", u.Permissions }, + { "groups", u.Groups } + }; + + if (!string.IsNullOrEmpty(u.Name)) + row["name"] = u.Name; + + int size = Encoding.UTF8.GetByteCount(JsonConvert.SerializeObject(row, Formatting.None)) + 1; + + if (current.Count > 0 && bytes + size > InventoryPageBudgetBytes) + { + inv.Pages.Add(current); + current = new List(); + bytes = 0; + } + + current.Add(row); + bytes += size; + } + + // Always at least one page, so an empty store still answers page 0. + inv.Pages.Add(current); + return inv; + } + + private void EmitInventoryPage(Inventory inv, int page, string reqId) + { + Dictionary reply = Frame("perm.inventory", "reply"); + reply["snapshotId"] = inv.Id; + reply["page"] = page; + reply["pages"] = inv.Pages.Count; + reply["more"] = page + 1 < inv.Pages.Count; + reply["users"] = inv.Pages[page]; + + if (page == 0) + { + reply["permissions"] = inv.Permissions; + reply["groups"] = inv.Groups; + reply["leased"] = inv.Leased; + reply["stats"] = new Dictionary + { + { "permissions", inv.Permissions.Count }, + { "groups", inv.Groups.Count }, + { "users", inv.UserCount }, + { "buildMs", inv.BuildMs } + }; + } + + if (reqId != null) + reply["reqId"] = reqId; + + EmitFrame(reply); + } + + /// + /// The name out of a store listing entry — 76561198…(Wanderer) or + /// 76561198… (Wanderer) — or null. Between the FIRST bracket and the LAST, so a + /// name with brackets of its own survives. + /// + private static string StoredName(string entry) + { + if (string.IsNullOrEmpty(entry)) + return null; + + int open = entry.IndexOf('('); + int close = entry.LastIndexOf(')'); + + if (open < 0 || close <= open + 1) + return null; + + string name = entry.Substring(open + 1, close - open - 1).Trim(); + return name.Length > 0 && name != "Unnamed" ? name : null; + } + /// /// Main thread. Compiles a desired set into operations and starts draining it. /// @@ -3291,21 +3772,39 @@ namespace Oxide.Plugins // Order matters and it is not cosmetic: a group has to exist before anything can be // put in it, and a permission has to be on the group before a member inherits it. + // Protocol 13: parents are applied after every group exists, because a group may name + // a parent that comes later in the frame. + var parentOps = new List(); + foreach (Dictionary group in Rows(cmd, "groups")) { string name = Lower(StringField(group, "name")); if (string.IsNullOrEmpty(name)) continue; + // Protocol 13: an absent title or rank means "leave it" — the site holds an + // in-game change to them while a person decides (D161's `adopt`) — and an + // empty title is a title, sent verbatim, not a reason to use the name. job.Ops.Add(new PermOp { Kind = PermOpKind.EnsureGroup, Subject = name, - Title = StringField(group, "title") ?? name, - Rank = IntField(group, "rank") + Title = group.ContainsKey("title") ? (StringField(group, "title") ?? "") : null, + Rank = IntField(group, "rank"), + HasRank = group.ContainsKey("rank") }); - HashSet wanted = Bucket(job.DesiredGroupPermissions, name); + // Only a frame that says something about the parent changes it: "" clears it, and + // an absent key leaves whatever the group has. + if (group.ContainsKey("parent")) + { + parentOps.Add(new PermOp + { + Kind = PermOpKind.EnsureGroupParent, + Subject = name, + Object = Lower(StringField(group, "parent")) ?? "" + }); + } foreach (string perm in Strings(group, "permissions")) { @@ -3313,7 +3812,6 @@ namespace Oxide.Plugins if (string.IsNullOrEmpty(lowered)) continue; - wanted.Add(lowered); job.Ops.Add(new PermOp { Kind = PermOpKind.EnsureGroupPermission, @@ -3327,7 +3825,6 @@ namespace Oxide.Plugins if (string.IsNullOrEmpty(member)) continue; - Bucket(job.DesiredMembers, name).Add(member); job.Ops.Add(new PermOp { Kind = PermOpKind.EnsureMember, @@ -3336,10 +3833,6 @@ namespace Oxide.Plugins }); } - // A group with no members still has to be scanned: the interesting case is a - // member the site did not put there. - Bucket(job.DesiredMembers, name); - // Protocol 12: the group's BetterChat style. After the group's own ops, so the // Oxide group BetterChat's members are read from exists first. With BetterChat // absent nothing is compiled, and the report says why (§33.2). @@ -3367,7 +3860,6 @@ namespace Oxide.Plugins if (string.IsNullOrEmpty(lowered)) continue; - Bucket(job.DesiredHolders, lowered).Add(steamId); job.Ops.Add(new PermOp { Kind = PermOpKind.EnsureGrant, @@ -3377,6 +3869,10 @@ namespace Oxide.Plugins } } + // Parents after every group exists and before any retirement, so a group the site is + // about to remove is no longer anybody's parent when it goes. + job.Ops.AddRange(parentOps); + // ── What the site has withdrawn ──────────────────────────────────── // // The site remembers what it put here, so a grant it has since removed arrives as an @@ -3416,24 +3912,8 @@ namespace Oxide.Plugins } } - // ── The namespace the site claims ────────────────────────────────── - // - // Foreign holders can only be found for names the site says it manages. A permission it - // does not claim is somebody else's business entirely and is never enumerated — which - // also bounds this scan by the site's own set rather than by the size of the store. - - foreach (string perm in Strings(cmd, "managed")) - { - string lowered = Lower(perm); - if (string.IsNullOrEmpty(lowered)) - continue; - - Bucket(job.DesiredHolders, lowered); - job.Ops.Add(new PermOp { Kind = PermOpKind.ScanPermission, Subject = lowered }); - } - - foreach (string group in job.DesiredMembers.Keys) - job.Ops.Add(new PermOp { Kind = PermOpKind.ScanGroup, Subject = group }); + // Protocol 13 dropped the `managed` list and the foreign scan it drove: the site reads + // the whole store with `perm.inventory` and decides for itself (D160, D161). // ── The kit credits (protocol 10) ────────────────────────────────── // @@ -3550,15 +4030,54 @@ namespace Oxide.Plugins switch (op.Kind) { case PermOpKind.EnsureGroup: - if (permission.GroupExists(op.Subject)) + if (!permission.GroupExists(op.Subject)) + { + permission.CreateGroup(op.Subject, op.Title ?? op.Subject, op.Rank); + job.GroupsCreated++; + break; + } + + // Protocol 13: the site owns an existing group's title and rank too (D160), so + // a difference is written rather than left. Until now a group's title was only + // ever set when the sync created it. + bool updated = false; + + if (op.Title != null && !string.Equals(permission.GetGroupTitle(op.Subject) ?? "", op.Title, StringComparison.Ordinal)) + { + permission.SetGroupTitle(op.Subject, op.Title); + updated = true; + } + + if (op.HasRank && permission.GetGroupRank(op.Subject) != op.Rank) + { + permission.SetGroupRank(op.Subject, op.Rank); + updated = true; + } + + if (updated) + job.GroupsUpdated++; + else + job.AlreadyCorrect++; + + break; + + case PermOpKind.EnsureGroupParent: + string currentParent = Lower(permission.GetGroupParent(op.Subject)) ?? ""; + + if (string.Equals(currentParent, op.Object, StringComparison.Ordinal)) { job.AlreadyCorrect++; + break; } + + // Both frameworks refuse a parent that does not exist or that would make a + // loop, and say so only by returning false. The read-back is what counts. + permission.SetGroupParent(op.Subject, string.IsNullOrEmpty(op.Object) ? null : op.Object); + + if (string.Equals(Lower(permission.GetGroupParent(op.Subject)) ?? "", op.Object, StringComparison.Ordinal)) + job.GroupsUpdated++; else - { - permission.CreateGroup(op.Subject, op.Title, op.Rank); - job.GroupsCreated++; - } + job.NotLanded.Add(op.Subject + ":parent:" + op.Object); break; @@ -3685,14 +4204,6 @@ namespace Oxide.Plugins break; - case PermOpKind.ScanPermission: - ScanPermission(job, op.Subject); - break; - - case PermOpKind.ScanGroup: - ScanGroup(job, op.Subject); - break; - case PermOpKind.EnsureChatStyle: ApplyChatStyle(job, op.Subject); break; @@ -3703,105 +4214,6 @@ namespace Oxide.Plugins } } - /// - /// Everybody holding a managed permission DIRECTLY, minus everybody the site says - /// should — the rest is a hand edit, and it is reported rather than undone. - /// - private void ScanPermission(PermSyncJob job, string perm) - { - if (!permission.PermissionExists(perm)) - return; - - HashSet desired; - if (!job.DesiredHolders.TryGetValue(perm, out desired)) - desired = new HashSet(); - - string[] holders = permission.GetPermissionUsers(perm); - - if (holders == null) - return; - - foreach (string entry in holders) - { - string id = UserId(entry); - - if (string.IsNullOrEmpty(id) || desired.Contains(id)) - continue; - - job.Foreign.Add(new Dictionary - { - { "kind", "grant" }, - { "subject", id }, - { "object", perm } - }); - } - } - - /// - /// The same question about a managed group, asked twice: who is in it that the site did - /// not put there, and what does it carry that the site did not put on it. - /// - private void ScanGroup(PermSyncJob job, string group) - { - if (!permission.GroupExists(group)) - return; - - HashSet members; - if (!job.DesiredMembers.TryGetValue(group, out members)) - members = new HashSet(); - - string[] present = permission.GetUsersInGroup(group); - - if (present != null) - { - foreach (string entry in present) - { - string id = UserId(entry); - - if (string.IsNullOrEmpty(id) || members.Contains(id)) - continue; - - job.Foreign.Add(new Dictionary - { - { "kind", "member" }, - { "subject", id }, - { "object", group } - }); - } - } - - HashSet wanted; - if (!job.DesiredGroupPermissions.TryGetValue(group, out wanted)) - wanted = new HashSet(); - - string[] carried = permission.GetGroupPermissions(group, false); - - if (carried == null) - return; - - foreach (string perm in carried) - { - string lowered = Lower(perm); - - if (string.IsNullOrEmpty(lowered) || wanted.Contains(lowered)) - continue; - - // A pair an event has borrowed is not somebody's console grant (§27.3). - if (LeasedGroupPermission(group, lowered) != null) - { - NoteLeased(job, group, lowered); - continue; - } - - job.Foreign.Add(new Dictionary - { - { "kind", "group-permission" }, - { "subject", group }, - { "object", lowered } - }); - } - } - /// /// A sync op about a pair an event lease holds. The store is left to the lease; what /// the site wants is recorded on the hold and becomes the value the lease gives back @@ -3919,6 +4331,7 @@ namespace Oxide.Plugins { "grants", job.Grants }, { "revokes", job.Revokes }, { "groupsCreated", job.GroupsCreated }, + { "groupsUpdated", job.GroupsUpdated }, { "groupsRemoved", job.GroupsRemoved }, { "groupPermissions", job.GroupPermissions }, { "groupPermissionsRemoved", job.GroupPermissionsRemoved }, @@ -3931,7 +4344,6 @@ namespace Oxide.Plugins report["alreadyCorrect"] = job.AlreadyCorrect; report["absent"] = job.Absent; report["unresolved"] = job.Unresolved; - report["foreign"] = job.Foreign; report["leased"] = job.Leased; report["pending"] = job.Pending; report["notLanded"] = job.NotLanded; @@ -3972,8 +4384,8 @@ namespace Oxide.Plugins _lastPermSummary = $"ops={job.Ops.Count} grants={job.Grants} revokes={job.Revokes} " + - $"groups={job.GroupsCreated} members={job.Members} same={job.AlreadyCorrect} " + - $"unresolved={job.Unresolved.Count} foreign={job.Foreign.Count} " + + $"groups={job.GroupsCreated}+{job.GroupsUpdated} members={job.Members} same={job.AlreadyCorrect} " + + $"unresolved={job.Unresolved.Count} " + $"pending={job.Pending.Count} notLanded={job.NotLanded.Count} " + $"chat={(job.ChatLoaded ? $"applied {job.ChatApplied} saved {job.ChatSaved} drift {job.ChatDrift.Count} removed {job.ChatRemoved.Count} failed {job.ChatFailed.Count}" : "absent")} " + $"at={DateTime.UtcNow:HH:mm:ss}Z"; @@ -3996,8 +4408,8 @@ namespace Oxide.Plugins // ---- drift, as it happens ---- // // Both frameworks raise a hook for every permission write, ours included. They are NOT the - // drift record — the sync report is, because only the site's desired set can say whether a - // grant is foreign. What they are is a reason to reconcile SOON: an operator typing + // drift record — the inventory is (protocol 13), because only the site's own record can say + // whether a grant is a hand edit. What they are is a reason to reconcile SOON: an operator typing // `oxide.grant` at the console shows up on the website in seconds instead of at the next // scheduled audit, and the authoritative answer still comes from a sync. // @@ -6893,8 +7305,8 @@ namespace Oxide.Plugins /// /// Whether a lease holds this group permission, for the permission mirror. A pair a - /// lease has borrowed is the lease's until it ends: a sync neither applies to it nor - /// reports it foreign. + /// lease has borrowed is the lease's until it ends: a sync does not apply to it, and the + /// inventory lists it under leased so the site never takes it for a hand edit. /// private LeaseHold LeasedGroupPermission(string group, string perm) { @@ -11045,19 +11457,6 @@ namespace Oxide.Plugins return value == null ? null : value.Trim().ToLowerInvariant(); } - private static HashSet Bucket(Dictionary> map, string key) - { - HashSet bucket; - - if (!map.TryGetValue(key, out bucket)) - { - bucket = new HashSet(); - map[key] = bucket; - } - - return bucket; - } - // ---- diagnostics ---- /// @@ -11097,8 +11496,9 @@ namespace Oxide.Plugins arg.ReplyWith( $"protocol={ProtocolVersion} registered={(registered != null ? registered.Length : 0)} " + $"groups={(groups != null ? groups.Length : 0)} " + - $"running={(_permJob != null)}" + Environment.NewLine + - $"last sync: {_lastPermSummary}"); + $"running={(_permJob != null)} inventoryBuilding={(_inventoryJob != null)}" + Environment.NewLine + + $"last sync: {_lastPermSummary}" + Environment.NewLine + + $"last inventory: {_lastInventorySummary}"); } ///