Merge pull request 'feat(bridge): read the whole permission store (perm.inventory), and sync group parents' (#19) from feat/perm-inventory into edge

Reviewed-on: #19
This commit is contained in:
2026-09-28 16:38:56 +00:00

View File

@@ -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,
/// <summary>
/// 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.
/// </summary>
EnsureGroupParent,
/// <summary>Protocol 12: a group's BetterChat style (D138).</summary>
EnsureChatStyle,
@@ -3066,6 +3084,12 @@ namespace Oxide.Plugins
public string Title;
public int Rank;
/// <summary>
/// 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.
/// </summary>
public bool HasRank;
}
/// <summary>
@@ -3083,6 +3107,10 @@ namespace Oxide.Plugins
public int Grants;
public int Revokes;
public int GroupsCreated;
/// <summary>Protocol 13: an existing group whose title, rank or parent the sync changed.</summary>
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<string> Unresolved = new List<string>();
public readonly List<object> Foreign = new List<object>();
public readonly List<string> Pending = new List<string>();
/// <summary>
@@ -3103,8 +3130,8 @@ namespace Oxide.Plugins
public readonly List<string> NotLanded = new List<string>();
/// <summary>
/// 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.
/// </summary>
public readonly List<object> Leased = new List<object>();
@@ -3113,17 +3140,6 @@ namespace Oxide.Plugins
/// <summary>Permission names already reported unresolved, so each is reported once.</summary>
public readonly HashSet<string> UnresolvedSeen = new HashSet<string>();
/// <summary>Who the site says should hold each managed permission, for the foreign scan.</summary>
public readonly Dictionary<string, HashSet<string>> DesiredHolders =
new Dictionary<string, HashSet<string>>();
/// <summary>Who the site says is in each managed group, and what it should carry.</summary>
public readonly Dictionary<string, HashSet<string>> DesiredMembers =
new Dictionary<string, HashSet<string>>();
public readonly Dictionary<string, HashSet<string>> DesiredGroupPermissions =
new Dictionary<string, HashSet<string>>();
/// <summary>
/// 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.
/// <summary>How many permissions or groups one build step asks about.</summary>
private const int InventorySourcesPerStep = 25;
/// <summary>How many bytes of rows one page may carry, well inside the 1 MiB line cap.</summary>
private const int InventoryPageBudgetBytes = 700 * 1024;
/// <summary>
/// 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.
/// </summary>
private const long InventoryMaxBuildMs = 8000;
/// <summary>How long a finished snapshot is kept for the pages after the first.</summary>
private const long InventoryKeepMs = 2 * 60 * 1000;
private sealed class InventoryUser
{
public string SteamId;
public string Name;
public readonly List<string> Permissions = new List<string>();
public readonly List<string> Groups = new List<string>();
}
/// <summary>A build in flight. There is at most one; a second request is answered `busy`.</summary>
private sealed class InventoryJob
{
public string ReqId;
public long StartedMs;
/// <summary>Started from the server console (<c>rg.inventory</c>): logged, never sent.</summary>
public bool FromConsole;
/// <summary>Permissions first, then groups prefixed with a NUL, one store question each.</summary>
public readonly List<string> Sources = new List<string>();
public int Next;
public readonly Dictionary<string, InventoryUser> Users = new Dictionary<string, InventoryUser>();
public List<object> Permissions;
public List<object> Groups;
public List<object> Leased;
}
/// <summary>A finished snapshot, paged from memory.</summary>
private sealed class Inventory
{
public string Id;
public long BuiltMs;
public long BuildMs;
public List<object> Permissions;
public List<object> Groups;
public List<object> Leased;
public int UserCount;
public readonly List<List<object>> Pages = new List<List<object>>();
}
private InventoryJob _inventoryJob;
private Timer _inventoryTimer;
private Inventory _inventory;
/// <summary>The last inventory built, for <c>rg.perms</c>. Diagnostics only.</summary>
private string _lastInventorySummary = "none yet";
/// <summary>
/// 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.
/// </summary>
[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");
}
/// <summary>Main thread. Page 0 starts a build; any other page is read from the snapshot it names.</summary>
private void HandlePermInventory(Dictionary<string, object> 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);
}
/// <summary>Main thread. Reads the small parts at once and starts the sliced holder walk.</summary>
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<string, string>(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<object>();
foreach (string perm in registered)
{
if (string.IsNullOrEmpty(perm))
continue;
string lowered = Lower(perm);
var row = new Dictionary<string, object> { { "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<object>();
foreach (string name in permission.GetGroups() ?? new string[0])
{
if (string.IsNullOrEmpty(name))
continue;
string lowered = Lower(name);
var carried = new List<object>();
foreach (string perm in permission.GetGroupPermissions(name, false) ?? new string[0])
{
if (!string.IsNullOrEmpty(perm))
carried.Add(Lower(perm));
}
job.Groups.Add(new Dictionary<string, object>
{
{ "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<object>();
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<string, object>
{
{ "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();
}
/// <summary>Main thread. Asks up to <see cref="InventorySourcesPerStep"/> permissions or groups who holds them.</summary>
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<string, int>();
foreach (Dictionary<string, object> 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<string>();
foreach (KeyValuePair<string, int> kv in owners)
ownerText.Add(kv.Key + "=" + kv.Value);
List<object> 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;
}
/// <summary>
/// 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.
/// </summary>
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<string, object>
{
{ "permissions", job.Permissions },
{ "groups", job.Groups },
{ "leased", job.Leased }
}, Formatting.None));
var ids = new List<string>(job.Users.Keys);
ids.Sort(StringComparer.Ordinal);
var current = new List<object>();
int bytes = header;
foreach (string id in ids)
{
InventoryUser u = job.Users[id];
var row = new Dictionary<string, object>
{
{ "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<object>();
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<string, object> 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<string, object>
{
{ "permissions", inv.Permissions.Count },
{ "groups", inv.Groups.Count },
{ "users", inv.UserCount },
{ "buildMs", inv.BuildMs }
};
}
if (reqId != null)
reply["reqId"] = reqId;
EmitFrame(reply);
}
/// <summary>
/// The name out of a store listing entry — <c>76561198…(Wanderer)</c> or
/// <c>76561198… (Wanderer)</c> — or null. Between the FIRST bracket and the LAST, so a
/// name with brackets of its own survives.
/// </summary>
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;
}
/// <summary>
/// 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<PermOp>();
foreach (Dictionary<string, object> 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<string> 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
}
}
/// <summary>
/// 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.
/// </summary>
private void ScanPermission(PermSyncJob job, string perm)
{
if (!permission.PermissionExists(perm))
return;
HashSet<string> desired;
if (!job.DesiredHolders.TryGetValue(perm, out desired))
desired = new HashSet<string>();
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<string, object>
{
{ "kind", "grant" },
{ "subject", id },
{ "object", perm }
});
}
}
/// <summary>
/// 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.
/// </summary>
private void ScanGroup(PermSyncJob job, string group)
{
if (!permission.GroupExists(group))
return;
HashSet<string> members;
if (!job.DesiredMembers.TryGetValue(group, out members))
members = new HashSet<string>();
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<string, object>
{
{ "kind", "member" },
{ "subject", id },
{ "object", group }
});
}
}
HashSet<string> wanted;
if (!job.DesiredGroupPermissions.TryGetValue(group, out wanted))
wanted = new HashSet<string>();
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<string, object>
{
{ "kind", "group-permission" },
{ "subject", group },
{ "object", lowered }
});
}
}
/// <summary>
/// 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
/// <summary>
/// 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 <c>leased</c> so the site never takes it for a hand edit.
/// </summary>
private LeaseHold LeasedGroupPermission(string group, string perm)
{
@@ -11045,19 +11457,6 @@ namespace Oxide.Plugins
return value == null ? null : value.Trim().ToLowerInvariant();
}
private static HashSet<string> Bucket(Dictionary<string, HashSet<string>> map, string key)
{
HashSet<string> bucket;
if (!map.TryGetValue(key, out bucket))
{
bucket = new HashSet<string>();
map[key] = bucket;
}
return bucket;
}
// ---- diagnostics ----
/// <summary>
@@ -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}");
}
/// <summary>