Editor/Core/TestHarnessRules.cs
using System;

namespace TeamCreate;

/// <summary>
/// The rules that let one person test a two-editor session alone, and the fences around them.
///
/// A real join needs two different Steam accounts: the host refuses a second connection from the same account,
/// and it asks Steam's auth service to confirm every guest. With one account, the only way to exercise a full
/// join is to let the host accept ONE reserved, obviously synthetic identity for a guest editor. That is a
/// deliberate hole in admission, so it is confined three ways: it exists only inside a disposable copy of the
/// project (a folder under <c>.collaboration/test-copies/</c> that carries a marker file the test launcher writes),
/// it can only be installed by local editor code, and it accepts exactly the reserved identity below and nothing
/// else; every other guest still goes through the real verification.
/// </summary>
public static class TestHarnessRules
{
    /// <summary>The folder that holds disposable copies, relative to the real project's <c>.collaboration</c> folder.</summary>
    public const string CopiesSegment = "/.collaboration/test-copies/";

    /// <summary>Written by the test launcher into each copy. A folder that merely has a matching name is not enough.</summary>
    public const string MarkerFile = "disposable-test-copy.txt";

    /// <summary>
    /// A well-formed Steam account number at the very top of the individual-account range, chosen so it cannot be
    /// mistaken for a person. It is the only identity the test verifier will vouch for.
    /// </summary>
    public const string SoloGuestSteamId = "76561202255233023";

    public const string SoloGuestName = "Solo Test Guest";

    /// <summary>
    /// A fresh secret for one solo-test run: 32 random bytes as text, printable and long enough to pass the handshake's shape check.
    /// It lives in memory only. The host vouches for the reserved identity only when it presents THIS value, so a remote peer that merely
    /// claims the reserved account number, even one that has the invite, is not admitted.
    /// </summary>
    public static string NewRunToken()
    {
        return "solo-" + Convert.ToHexString(System.Security.Cryptography.RandomNumberGenerator.GetBytes(32));
    }

    /// <summary>Constant-time comparison; false when either side is empty.</summary>
    public static bool TokenMatches(string presented, string expected)
    {
        if (string.IsNullOrEmpty(presented) || string.IsNullOrEmpty(expected))
        {
            return false;
        }
        byte[] a = System.Text.Encoding.UTF8.GetBytes(presented);
        byte[] b = System.Text.Encoding.UTF8.GetBytes(expected);
        return a.Length == b.Length && System.Security.Cryptography.CryptographicOperations.FixedTimeEquals(a, b);
    }

    /// <summary>True only for a path that is a folder inside <c>.collaboration/test-copies/</c>.</summary>
    public static bool IsDisposableCopyPath(string projectRoot)
    {
        if (string.IsNullOrWhiteSpace(projectRoot))
        {
            return false;
        }
        string path = projectRoot.Replace('\\', '/');
        if (path.Contains("/../", StringComparison.Ordinal) || path.EndsWith("/..", StringComparison.Ordinal))
        {
            return false;
        }
        int at = path.IndexOf(CopiesSegment, StringComparison.OrdinalIgnoreCase);
        if (at < 0)
        {
            return false;
        }
        // Something must follow the segment: the copy's own folder name. The folder that merely holds the copies
        // is not itself a copy.
        string rest = path.Substring(at + CopiesSegment.Length).Trim('/');
        return rest.Length > 0;
    }

    /// <summary>The path check plus the marker file, which is what the session insists on before it honours the test verifier.</summary>
    public static bool IsDisposableCopy(string projectRoot, bool markerFilePresent)
    {
        return markerFilePresent && IsDisposableCopyPath(projectRoot);
    }

    /// <summary>The one identity the test verifier may accept. Compared as text, exactly, after trimming.</summary>
    public static bool IsSyntheticIdentity(string steamId)
    {
        return string.Equals(steamId?.Trim(), SoloGuestSteamId, StringComparison.Ordinal);
    }

    /// <summary>
    /// Whether a hello may be answered by the test verifier instead of Steam's service: the reserved identity AND this run's token.
    /// Anything else, including the reserved identity with any other token and the host's own real account, is left to real verification.
    /// </summary>
    public static bool MayVouchFor(string steamId, string presentedToken, string runToken)
    {
        return IsSyntheticIdentity(steamId) && TokenMatches(presentedToken, runToken);
    }
}