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}");
}
///