From 59db88d006da98045659063e0b75601e5f101ad8 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Fri, 30 Oct 2026 04:23:31 -0500 Subject: [PATCH] fix(perms): never write a never-seen player into Carbon's shared blank record Carbon 2.0.262 answers GetUserData for a Steam id it has no record for with one shared, static blank record, and GrantUserPermission and AddUserGroup write into it. A grant or membership for one never-seen player was then held by every Steam id Carbon had no record for, until the server restarted, and UserHasGroup read the membership back as a success, so the sync reported it applied and later syncs saw nothing to fix. On Carbon the sync now checks permission.UserExists before either write. A never-seen player's grant is reported notLanded and their membership pending, without touching the store; both land at the first audit sync after they join, as PLAN.md section 29.8 and section 12.2 rule 4 already describe. Oxide creates a real record and is unchanged. Proven on the Carbon rig (Rust 26139947, Carbon 2.0.262): before, three random never-seen ids each held autodoors.use after one admin grant; after, a known id got its grant and membership, the never-seen id was reported notLanded + pending, and random ids held nothing. Found in the account deletion walk (docs/website/ACCOUNT_DELETION.md step 6, W3). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- overlay/oxide/plugins/RunicGateway.cs | 36 +++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/overlay/oxide/plugins/RunicGateway.cs b/overlay/oxide/plugins/RunicGateway.cs index fc17597..25c8752 100644 --- a/overlay/oxide/plugins/RunicGateway.cs +++ b/overlay/oxide/plugins/RunicGateway.cs @@ -4778,6 +4778,15 @@ namespace Oxide.Plugins break; case PermOpKind.EnsureMember: + // Before the read, because on Carbon the read is the thing that lies: a + // never-seen player's UserHasGroup reads the shared blank record (see + // HasNoCarbonRecord), so it can say "already a member" of nobody. + if (HasNoCarbonRecord(op.Subject)) + { + job.Pending.Add(op.Subject + ":" + op.Object); + break; + } + if (permission.UserHasGroup(op.Subject, op.Object)) { job.AlreadyCorrect++; @@ -4800,6 +4809,15 @@ namespace Oxide.Plugins if (!Registered(job, op.Object)) break; + // Never-seen on Carbon: the grant would land in the shared blank record and be + // held by every Steam id Carbon has no record for. It lands at the first sync + // after they join, as PLAN.md §29.8 already says of Carbon. + if (HasNoCarbonRecord(op.Subject)) + { + job.NotLanded.Add(op.Subject + ":" + op.Object); + break; + } + if (HoldsDirectly(op.Subject, op.Object)) { job.AlreadyCorrect++; @@ -4942,6 +4960,24 @@ namespace Oxide.Plugins return false; } + /// + /// Whether this is Carbon and Carbon has no user record for the player. + /// + /// + /// Carbon (2.0.262) answers GetUserData for a Steam id it has never seen with ONE + /// shared, static blank record, and AddUserGroup and GrantUserPermission + /// write into whatever it returns. So a membership or grant for one never-seen player + /// goes to that shared record and is then held by every Steam id Carbon has no record + /// for, until the server restarts — and UserHasGroup reads it back as a success. + /// Oxide creates a real record instead, so this is never true there and Oxide's + /// behaviour is unchanged. Found in the account deletion walk (W3). + /// + /// + private bool HasNoCarbonRecord(string steamId) + { + return IsCarbon() && !permission.UserExists(steamId); + } + /// /// Whether a player holds a permission through a grant of their own, as opposed to /// through a group. -- 2.49.1