CatStation/Content.Server/Administration/Managers/AdminManager.cs
Dmitry 25c5e59248
Upstream (#3172)
Signed-off-by: Prole <172158352+Prole0@users.noreply.github.com>
Co-authored-by: PJBot <pieterjan.briers+bot@gmail.com>
Co-authored-by: ScarKy0 <106310278+ScarKy0@users.noreply.github.com>
Co-authored-by: Samuka-C <47865393+Samuka-C@users.noreply.github.com>
Co-authored-by: ArtisticRoomba <145879011+ArtisticRoomba@users.noreply.github.com>
Co-authored-by: Partmedia <kevinz5000@gmail.com>
Co-authored-by: slarticodefast <161409025+slarticodefast@users.noreply.github.com>
Co-authored-by: themias <89101928+themias@users.noreply.github.com>
Co-authored-by: Victor Shen <71985089+Vexerot@users.noreply.github.com>
Co-authored-by: Ed <96445749+TheShuEd@users.noreply.github.com>
Co-authored-by: Milon <milonpl.git@proton.me>
Co-authored-by: Kirus59 <145689588+Kirus59@users.noreply.github.com>
Co-authored-by: Nemanja <98561806+EmoGarbage404@users.noreply.github.com>
Co-authored-by: Stomf <5dorkydorks@gmail.com>
Co-authored-by: drakewill-CRL <46307022+drakewill-CRL@users.noreply.github.com>
Co-authored-by: PraxisMapper <praxismapper@gmail.com>
Co-authored-by: EmoGarbage404 <retron404@gmail.com>
Co-authored-by: lzk <124214523+lzk228@users.noreply.github.com>
Co-authored-by: Princess Cheeseballs <66055347+Pronana@users.noreply.github.com>
Co-authored-by: IProduceWidgets <107586145+IProduceWidgets@users.noreply.github.com>
Co-authored-by: TytosB <54259736+TytosB@users.noreply.github.com>
Co-authored-by: abadaba695 <spacestation13thingy@gmail.com>
Co-authored-by: kosticia <kosticia46@gmail.com>
Co-authored-by: Thinbug <101073555+Thinbug0@users.noreply.github.com>
Co-authored-by: pathetic meowmeow <uhhadd@gmail.com>
Co-authored-by: SlamBamActionman <83650252+SlamBamActionman@users.noreply.github.com>
Co-authored-by: Boaz1111 <149967078+Boaz1111@users.noreply.github.com>
Co-authored-by: ActiveMammmoth <140334666+ActiveMammmoth@users.noreply.github.com>
Co-authored-by: Myra <vasilis@pikachu.systems>
Co-authored-by: Whatstone <166147148+whatston3@users.noreply.github.com>
Co-authored-by: Pieter-Jan Briers <pieterjan.briers+git@gmail.com>
Co-authored-by: K-Dynamic <20566341+K-Dynamic@users.noreply.github.com>
Co-authored-by: Gentleman-Bird <dcgreen406@gmail.com>
Co-authored-by: metalgearsloth <31366439+metalgearsloth@users.noreply.github.com>
Co-authored-by: BIGZi0348 <svalker0348@gmail.com>
Co-authored-by: LaCumbiaDelCoronavirus <90893484+LaCumbiaDelCoronavirus@users.noreply.github.com>
Co-authored-by: imatsoup <93290208+imatsoup@users.noreply.github.com>
Co-authored-by: Matthew Herber <32679887+happyrobot33@users.noreply.github.com>
Co-authored-by: Ertanic <36124833+Ertanic@users.noreply.github.com>
Co-authored-by: MissKay1994 <15877268+MissKay1994@users.noreply.github.com>
Co-authored-by: Errant <35878406+Errant-4@users.noreply.github.com>
Co-authored-by: eoineoineoin <helloworld@eoinrul.es>
Co-authored-by: Tiniest Shark <head.rebel@yahoo.com>
Co-authored-by: nikitosych <boriszyn@gmail.com>
Co-authored-by: Tayrtahn <tayrtahn@gmail.com>
Co-authored-by: Perry Fraser <perryprog@users.noreply.github.com>
Co-authored-by: YoungThug <ramialanbagy@gmail.com>
Co-authored-by: beck-thompson <107373427+beck-thompson@users.noreply.github.com>
Co-authored-by: metalgearsloth <comedian_vs_clown@hotmail.com>
Co-authored-by: Southbridge <7013162+southbridge-fur@users.noreply.github.com>
Co-authored-by: Vladislav Suchkov <20380250+murolem@users.noreply.github.com>
Co-authored-by: Prole <172158352+Prole0@users.noreply.github.com>
Co-authored-by: Unkn0wn_Gh0st <shadowstalkermll@gmail.com>
Co-authored-by: 3nderall <101940324+3nderall@users.noreply.github.com>
Co-authored-by: Radezolid <snappednexus@gmail.com>
Co-authored-by: J <billsmith116@gmail.com>
Co-authored-by: Ghagliiarghii <68826635+Ghagliiarghii@users.noreply.github.com>
Co-authored-by: chromiumboy <50505512+chromiumboy@users.noreply.github.com>
Co-authored-by: youtissoum <51883137+youtissoum@users.noreply.github.com>
Co-authored-by: Minemoder5000 <minemoder50000@gmail.com>
Co-authored-by: Spanky <scott@wearejacob.com>
Co-authored-by: Spessmann <156740760+Spessmann@users.noreply.github.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: brainfood1183 <113240905+brainfood1183@users.noreply.github.com>
Co-authored-by: Deerstop <edainturner@gmail.com>
Co-authored-by: B_Kirill <153602297+B-Kirill@users.noreply.github.com>
Co-authored-by: archee1 <archee3@hotmail.co.uk>
Co-authored-by: Cojoke <83733158+Cojoke-dot@users.noreply.github.com>
Co-authored-by: Quantum-cross <7065792+Quantum-cross@users.noreply.github.com>
Co-authored-by: poklj <compgeek223@gmail.com>
Co-authored-by: Krunklehorn <42424291+Krunklehorn@users.noreply.github.com>
Co-authored-by: OnyxTheBrave <131422822+OnyxTheBrave@users.noreply.github.com>
Co-authored-by: UpAndLeaves <92269094+Alpha-Two@users.noreply.github.com>
Co-authored-by: Flareguy <78941145+Flareguy@users.noreply.github.com>
Co-authored-by: Zalycon <84675130+Zalycon@users.noreply.github.com>
Co-authored-by: deltanedas <39013340+deltanedas@users.noreply.github.com>
Co-authored-by: Verm <32827189+Vermidia@users.noreply.github.com>
Co-authored-by: nikthechampiongr <32041239+nikthechampiongr@users.noreply.github.com>
Co-authored-by: ScarKy0 <scarky0@onet.eu>
Co-authored-by: Dmitry <57028746+dimm00n@users.noreply.github.com>
2025-05-24 11:00:17 +03:00

709 lines
23 KiB
C#

using System.Diagnostics;
using System.Linq;
using System.Reflection;
using System.Threading.Tasks;
using Content.Server.Chat.Managers;
using Content.Server.Database;
using Content.Shared.Administration;
using Content.Shared.CCVar;
using Content.Shared.Players;
using Robust.Server.Console;
using Robust.Server.Player;
using Robust.Shared.Configuration;
using Robust.Shared.Console;
using Robust.Shared.ContentPack;
using Robust.Shared.Enums;
using Robust.Shared.Network;
using Robust.Shared.Player;
using Robust.Shared.Toolshed;
using Robust.Shared.Toolshed.Errors;
using Robust.Shared.Utility;
namespace Content.Server.Administration.Managers
{
public sealed partial class AdminManager : IAdminManager, IPostInjectInit, IConGroupControllerImplementation
{
[Dependency] private readonly IPlayerManager _playerManager = default!;
[Dependency] private readonly IServerDbManager _dbManager = default!;
[Dependency] private readonly IConfigurationManager _cfg = default!;
[Dependency] private readonly IServerNetManager _netMgr = default!;
[Dependency] private readonly IConGroupController _conGroup = default!;
[Dependency] private readonly IResourceManager _res = default!;
[Dependency] private readonly IServerConsoleHost _consoleHost = default!;
[Dependency] private readonly IChatManager _chat = default!;
[Dependency] private readonly ToolshedManager _toolshed = default!;
[Dependency] private readonly ILogManager _logManager = default!;
private readonly Dictionary<ICommonSession, AdminReg> _admins = new();
private readonly HashSet<NetUserId> _promotedPlayers = new();
public event Action<AdminPermsChangedEventArgs>? OnPermsChanged;
public IEnumerable<ICommonSession> ActiveAdmins => _admins
.Where(p => p.Value.Data.Active)
.Select(p => p.Key);
public IEnumerable<ICommonSession> AllAdmins => _admins.Select(p => p.Key);
private readonly AdminCommandPermissions _commandPermissions = new();
private readonly AdminCommandPermissions _toolshedCommandPermissions = new();
private ISawmill _sawmill = default!;
public bool IsAdmin(ICommonSession session, bool includeDeAdmin = false)
{
return GetAdminData(session, includeDeAdmin) != null;
}
public AdminData? GetAdminData(ICommonSession session, bool includeDeAdmin = false)
{
if (_admins.TryGetValue(session, out var reg) && (reg.Data.Active || includeDeAdmin))
{
return reg.Data;
}
return null;
}
public AdminData? GetAdminData(EntityUid uid, bool includeDeAdmin = false)
{
if (_playerManager.TryGetSessionByEntity(uid, out var session))
return GetAdminData(session, includeDeAdmin);
return null;
}
public void DeAdmin(ICommonSession session)
{
if (!_admins.TryGetValue(session, out var reg))
{
throw new ArgumentException($"Player {session} is not an admin");
}
if (!reg.Data.Active)
{
return;
}
_chat.SendAdminAnnouncement(Loc.GetString("admin-manager-self-de-admin-message", ("exAdminName", session.Name)));
_chat.DispatchServerMessage(session, Loc.GetString("admin-manager-became-normal-player-message"));
UpdateDatabaseDeadminnedState(session, true);
reg.Data.Active = false;
SendPermsChangedEvent(session);
UpdateAdminStatus(session);
}
private async void UpdateDatabaseDeadminnedState(ICommonSession player, bool newState)
{
try
{
// NOTE: This function gets called if you deadmin/readmin from a transient admin status.
// (e.g. loginlocal)
// In which case there may not be a database record.
// The DB function handles this scenario fine, but it's worth noting.
await _dbManager.UpdateAdminDeadminnedAsync(player.UserId, newState);
}
catch (Exception)
{
_sawmill.Error("Failed to save deadmin state to database for {Admin}", player.UserId);
}
}
public void Stealth(ICommonSession session)
{
if (!_admins.TryGetValue(session, out var reg))
{
throw new ArgumentException($"Player {session} is not an admin");
}
if (reg.Data.Stealth)
return;
var playerData = session.ContentData()!;
playerData.Stealthed = true;
reg.Data.Stealth = true;
_chat.DispatchServerMessage(session, Loc.GetString("admin-manager-stealthed-message"));
_chat.SendAdminAnnouncement(Loc.GetString("admin-manager-self-de-admin-message", ("exAdminName", session.Name)), AdminFlags.Stealth);
_chat.SendAdminAnnouncement(Loc.GetString("admin-manager-self-enable-stealth", ("stealthAdminName", session.Name)), flagWhitelist: AdminFlags.Stealth);
}
public void UnStealth(ICommonSession session)
{
if (!_admins.TryGetValue(session, out var reg))
{
throw new ArgumentException($"Player {session} is not an admin");
}
if (!reg.Data.Stealth)
return;
var playerData = session.ContentData()!;
playerData.Stealthed = false;
reg.Data.Stealth = false;
_chat.DispatchServerMessage(session, Loc.GetString("admin-manager-unstealthed-message"));
_chat.SendAdminAnnouncement(Loc.GetString("admin-manager-self-re-admin-message", ("newAdminName", session.Name)), flagBlacklist: AdminFlags.Stealth);
_chat.SendAdminAnnouncement(Loc.GetString("admin-manager-self-disable-stealth", ("exStealthAdminName", session.Name)), flagWhitelist: AdminFlags.Stealth);
}
public void ReAdmin(ICommonSession session)
{
if (!_admins.TryGetValue(session, out var reg))
{
throw new ArgumentException($"Player {session} is not an admin");
}
if (reg.Data.Active)
{
return;
}
_chat.DispatchServerMessage(session, Loc.GetString("admin-manager-became-admin-message"));
UpdateDatabaseDeadminnedState(session, false);
reg.Data.Active = true;
if (!reg.Data.Stealth)
{
_chat.SendAdminAnnouncement(Loc.GetString("admin-manager-self-re-admin-message", ("newAdminName", session.Name)));
}
else
{
_chat.DispatchServerMessage(session, Loc.GetString("admin-manager-stealthed-message"));
_chat.SendAdminAnnouncement(Loc.GetString("admin-manager-self-re-admin-message",
("newAdminName", session.Name)), flagWhitelist: AdminFlags.Stealth);
}
SendPermsChangedEvent(session);
UpdateAdminStatus(session);
}
public async void ReloadAdmin(ICommonSession player)
{
var data = await LoadAdminData(player);
var curAdmin = _admins.GetValueOrDefault(player);
if (data == null && curAdmin == null)
{
// Wasn't admin before or after.
return;
}
if (data == null)
{
// No longer admin.
_admins.Remove(player);
_chat.DispatchServerMessage(player, Loc.GetString("admin-manager-no-longer-admin-message"));
}
else
{
var (aData, rankId, special) = data.Value;
if (curAdmin == null)
{
// Now an admin.
var reg = new AdminReg(player, aData)
{
IsSpecialLogin = special,
RankId = rankId
};
_admins.Add(player, reg);
_chat.DispatchServerMessage(player, Loc.GetString("admin-manager-became-admin-message"));
}
else
{
// Perms changed.
curAdmin.IsSpecialLogin = special;
curAdmin.RankId = rankId;
curAdmin.Data = aData;
if (curAdmin.Data.Active)
{
aData.Active = true;
_chat.DispatchServerMessage(player, Loc.GetString("admin-manager-admin-permissions-updated-message"));
}
}
if (player.ContentData()!.Stealthed)
{
aData.Stealth = true;
}
}
SendPermsChangedEvent(player);
UpdateAdminStatus(player);
}
public void ReloadAdminsWithRank(int rankId)
{
foreach (var dat in _admins.Values.Where(p => p.RankId == rankId).ToArray())
{
ReloadAdmin(dat.Session);
}
}
public void Initialize()
{
_sawmill = _logManager.GetSawmill("admin");
_netMgr.RegisterNetMessage<MsgUpdateAdminStatus>();
// Cache permissions for loaded console commands with the requisite attributes.
foreach (var (cmdName, cmd) in _consoleHost.AvailableCommands)
{
var (isAvail, flagsReq) = GetRequiredFlag(cmd);
if (!isAvail)
{
continue;
}
if (flagsReq.Length != 0)
{
_commandPermissions.AdminCommands.Add(cmdName, flagsReq);
}
else
{
_commandPermissions.AnyCommands.Add(cmdName);
}
}
foreach (var spec in _toolshed.DefaultEnvironment.AllCommands())
{
var (isAvail, flagsReq) = GetRequiredFlag(spec.Cmd);
if (!isAvail)
{
continue;
}
if (flagsReq.Length != 0)
{
_toolshedCommandPermissions.AdminCommands.TryAdd(spec.Cmd.Name, flagsReq);
}
else
{
_toolshedCommandPermissions.AnyCommands.Add(spec.Cmd.Name);
}
}
// Load flags for engine commands, since those don't have the attributes.
if (_res.TryContentFileRead(new ResPath("/engineCommandPerms.yml"), out var efs))
{
_commandPermissions.LoadPermissionsFromStream(efs);
}
if (_res.TryContentFileRead(new ResPath("/toolshedEngineCommandPerms.yml"), out var toolshedPerms))
{
_toolshedCommandPermissions.LoadPermissionsFromStream(toolshedPerms);
}
_toolshed.ActivePermissionController = this;
InitializeMetrics();
}
public void PromoteHost(ICommonSession player)
{
_promotedPlayers.Add(player.UserId);
ReloadAdmin(player);
}
void IPostInjectInit.PostInject()
{
_playerManager.PlayerStatusChanged += PlayerStatusChanged;
_conGroup.Implementation = this;
}
// NOTE: Also sends commands list for non admins..
private void UpdateAdminStatus(ICommonSession session)
{
var msg = new MsgUpdateAdminStatus();
var commands = new List<string>(_commandPermissions.AnyCommands);
if (_admins.TryGetValue(session, out var adminData))
{
msg.Admin = adminData.Data;
commands.AddRange(_commandPermissions.AdminCommands
.Where(p => p.Value.Any(f => adminData.Data.HasFlag(f)))
.Select(p => p.Key));
}
msg.AvailableCommands = commands.ToArray();
_netMgr.ServerSendMessage(msg, session.Channel);
}
private void PlayerStatusChanged(object? sender, SessionStatusEventArgs e)
{
if (e.NewStatus == SessionStatus.Connected)
{
// Run this so that available commands list gets sent.
UpdateAdminStatus(e.Session);
}
else if (e.NewStatus == SessionStatus.InGame)
{
LoginAdminMaybe(e.Session);
}
else if (e.NewStatus == SessionStatus.Disconnected)
{
if (_admins.Remove(e.Session, out var reg ) && _cfg.GetCVar(CCVars.AdminAnnounceLogout))
{
if (reg.Data.Stealth)
{
_chat.SendAdminAnnouncement(Loc.GetString("admin-manager-admin-logout-message",
("name", e.Session.Name)), flagWhitelist: AdminFlags.Stealth);
}
else
{
_chat.SendAdminAnnouncement(Loc.GetString("admin-manager-admin-logout-message",
("name", e.Session.Name)));
}
}
}
}
private async void LoginAdminMaybe(ICommonSession session)
{
var adminDat = await LoadAdminData(session);
if (adminDat == null)
{
// Not an admin.
return;
}
var (dat, rankId, specialLogin) = adminDat.Value;
var reg = new AdminReg(session, dat)
{
IsSpecialLogin = specialLogin,
RankId = rankId
};
_admins.Add(session, reg);
if (session.ContentData()!.Stealthed)
reg.Data.Stealth = true;
if (reg.Data.Active)
{
if (_cfg.GetCVar(CCVars.AdminAnnounceLogin))
{
if (reg.Data.Stealth)
{
_chat.DispatchServerMessage(session, Loc.GetString("admin-manager-stealthed-message"));
_chat.SendAdminAnnouncement(Loc.GetString("admin-manager-admin-login-message",
("name", session.Name)), flagWhitelist: AdminFlags.Stealth);
}
else
{
_chat.SendAdminAnnouncement(Loc.GetString("admin-manager-admin-login-message",
("name", session.Name)));
}
}
SendPermsChangedEvent(session);
}
UpdateAdminStatus(session);
}
private async Task<(AdminData dat, int? rankId, bool specialLogin)?> LoadAdminData(ICommonSession session)
{
var result = await LoadAdminDataCore(session);
// Make sure admin didn't disconnect while data was loading.
if (session.Status != SessionStatus.InGame)
return null;
return result;
}
private async Task<(AdminData dat, int? rankId, bool specialLogin)?> LoadAdminDataCore(ICommonSession session)
{
var promoteHost = IsLocal(session) && _cfg.GetCVar(CCVars.ConsoleLoginLocal)
|| _promotedPlayers.Contains(session.UserId)
|| session.Name == _cfg.GetCVar(CCVars.ConsoleLoginHostUser);
if (promoteHost)
{
var data = new AdminData
{
Title = Loc.GetString("admin-manager-admin-data-host-title"),
Flags = AdminFlagsHelper.Everything,
Active = true,
};
return (data, null, true);
}
else
{
var dbData = await _dbManager.GetAdminDataForAsync(session.UserId);
if (dbData == null)
{
// Not an admin!
return null;
}
if (dbData.Suspended)
{
// Suspended admins don't count.
return null;
}
var flags = AdminFlags.None;
if (dbData.AdminRank != null)
{
flags = AdminFlagsHelper.NamesToFlags(dbData.AdminRank.Flags.Select(p => p.Flag));
}
foreach (var dbFlag in dbData.Flags)
{
var flag = AdminFlagsHelper.NameToFlag(dbFlag.Flag);
if (dbFlag.Negative)
{
flags &= ~flag;
}
else
{
flags |= flag;
}
}
var data = new AdminData
{
Flags = flags,
Active = !dbData.Deadminned,
};
if (dbData.Title != null && _cfg.GetCVar(CCVars.AdminUseCustomNamesAdminRank))
{
data.Title = dbData.Title;
}
else if (dbData.AdminRank != null)
{
data.Title = dbData.AdminRank.Name;
}
return (data, dbData.AdminRankId, false);
}
}
private static bool IsLocal(ICommonSession player)
{
var ep = player.Channel.RemoteEndPoint;
var addr = ep.Address;
if (addr.IsIPv4MappedToIPv6)
{
addr = addr.MapToIPv4();
}
return Equals(addr, System.Net.IPAddress.Loopback) || Equals(addr, System.Net.IPAddress.IPv6Loopback);
}
public bool TryGetCommandFlags(CommandSpec command, out AdminFlags[]? flags)
{
var cmdName = command.Cmd.Name;
if (_toolshedCommandPermissions.AnyCommands.Contains(cmdName))
{
// Anybody can use this command.
flags = null;
return true;
}
if (_toolshedCommandPermissions.AdminCommands.TryGetValue(cmdName, out flags))
{
return true;
}
flags = null;
return false;
}
public bool CanCommand(ICommonSession session, string cmdName)
{
if (_commandPermissions.AnyCommands.Contains(cmdName))
{
// Anybody can use this command.
return true;
}
if (!_commandPermissions.AdminCommands.TryGetValue(cmdName, out var flagsReq))
{
// Server-console only.
return false;
}
var data = GetAdminData(session);
if (data == null)
{
// Player isn't an admin.
return false;
}
foreach (var flagReq in flagsReq)
{
if (data.HasFlag(flagReq))
{
return true;
}
}
return false;
}
public bool CheckInvokable(CommandSpec command, ICommonSession? user, out IConError? error)
{
if (user is null)
{
error = null;
return true; // Server console.
}
var name = command.Cmd.Name;
if (!TryGetCommandFlags(command, out var flags))
{
// Command is missing permissions.
error = new CommandPermissionsUnassignedError(command);
return false;
}
if (flags is null)
{
// Anyone can execute this.
error = null;
return true;
}
var data = GetAdminData(user);
if (data == null)
{
// Player isn't an admin.
error = new NoPermissionError(command);
return false;
}
foreach (var flag in flags)
{
if (data.HasFlag(flag))
{
error = null;
return true;
}
}
error = new NoPermissionError(command);
return false;
}
private static (bool isAvail, AdminFlags[] flagsReq) GetRequiredFlag(object cmd)
{
MemberInfo type = cmd.GetType();
if (cmd is ConsoleHost.RegisteredCommand registered)
{
type = registered.Callback.Method;
}
if (Attribute.IsDefined(type, typeof(AnyCommandAttribute)))
{
// Available to everybody.
return (true, Array.Empty<AdminFlags>());
}
var attribs = type.GetCustomAttributes(typeof(AdminCommandAttribute))
.Cast<AdminCommandAttribute>()
.Select(p => p.Flags)
.ToArray();
// If attribs.length == 0 then no access attribute is specified,
// and this is a server-only command.
return (attribs.Length != 0, attribs);
}
public bool CanViewVar(ICommonSession session)
{
return CanCommand(session, "vv");
}
public bool CanAdminPlace(ICommonSession session)
{
return GetAdminData(session)?.CanAdminPlace() ?? false;
}
public bool CanScript(ICommonSession session)
{
return GetAdminData(session)?.CanScript() ?? false;
}
public bool CanAdminMenu(ICommonSession session)
{
return GetAdminData(session)?.CanAdminMenu() ?? false;
}
public bool CanAdminReloadPrototypes(ICommonSession session)
{
return GetAdminData(session)?.CanAdminReloadPrototypes() ?? false;
}
private void SendPermsChangedEvent(ICommonSession session)
{
var flags = GetAdminData(session)?.Flags;
OnPermsChanged?.Invoke(new AdminPermsChangedEventArgs(session, flags));
}
private sealed class AdminReg
{
public readonly ICommonSession Session;
public AdminData Data;
public int? RankId;
// Such as console.loginlocal or promotehost
public bool IsSpecialLogin;
public AdminReg(ICommonSession session, AdminData data)
{
Data = data;
Session = session;
}
}
}
}
public record struct CommandPermissionsUnassignedError(CommandSpec Command) : IConError
{
public FormattedMessage DescribeInner()
{
return FormattedMessage.FromMarkupOrThrow($"The command {Command.FullName()} is missing permission flags and cannot be executed.");
}
public string? Expression { get; set; }
public Vector2i? IssueSpan { get; set; }
public StackTrace? Trace { get; set; }
}
public record struct NoPermissionError(CommandSpec Command) : IConError
{
public FormattedMessage DescribeInner()
{
return FormattedMessage.FromMarkupOrThrow($"You do not have permission to execute {Command.FullName()}");
}
public string? Expression { get; set; }
public Vector2i? IssueSpan { get; set; }
public StackTrace? Trace { get; set; }
}