diff --git a/Catglobe.CgScript.Common/IScriptDefinition.cs b/Catglobe.CgScript.Common/IScriptDefinition.cs index 5b1d099b..dd336934 100644 --- a/Catglobe.CgScript.Common/IScriptDefinition.cs +++ b/Catglobe.CgScript.Common/IScriptDefinition.cs @@ -21,4 +21,11 @@ public interface IScriptDefinition /// If true, the script can be run without a user being logged in /// bool AllowExecuteWithoutLogin { get; } + /// + /// Maps to the workflow PII flag of the site, declaring that the script may read personal data. + /// It is only honored together with : a PII script needs an impersonated + /// user that is PII-eligible on the site. The default is false, so implementations must override + /// this member when their scripts may read PII. + /// + bool CanAccessPII => false; } diff --git a/Catglobe.CgScript.Common/InvalidScriptFileNameException.cs b/Catglobe.CgScript.Common/InvalidScriptFileNameException.cs new file mode 100644 index 00000000..22e689d1 --- /dev/null +++ b/Catglobe.CgScript.Common/InvalidScriptFileNameException.cs @@ -0,0 +1,18 @@ +using System; + +namespace Catglobe.CgScript.Common; + +/// A script file path does not follow the script naming grammar. +public class InvalidScriptFileNameException : Exception +{ + /// Creates an error identifying the file and the reason its name is invalid. + /// The original relative file path. + /// The failure reason, including the offending suffix and expected shape. + public InvalidScriptFileNameException(string fileName, string message) : base(message) + { + FileName = fileName; + } + + /// The original relative path that failed parsing. + public string FileName { get; } +} diff --git a/Catglobe.CgScript.Common/ScriptFileName.cs b/Catglobe.CgScript.Common/ScriptFileName.cs new file mode 100644 index 00000000..840ecef3 --- /dev/null +++ b/Catglobe.CgScript.Common/ScriptFileName.cs @@ -0,0 +1,25 @@ +namespace Catglobe.CgScript.Common; + +/// A script identity and the security metadata declared in its file name. +public sealed class ScriptFileName +{ + internal ScriptFileName(string scriptName, uint? impersonation, bool allowExecuteWithoutLogin, bool canAccessPII) + { + ScriptName = scriptName; + Impersonation = impersonation; + AllowExecuteWithoutLogin = allowExecuteWithoutLogin; + CanAccessPII = canAccessPII; + } + + /// The relative script name without metadata or extension, with forward-slash separators. + public string ScriptName { get; } + + /// The declared user id; null means absent, while zero explicitly means no impersonation user. + public uint? Impersonation { get; } + + /// Whether the script may execute without login; requires a nonzero impersonation user. + public bool AllowExecuteWithoutLogin { get; } + + /// Whether the script requests PII access; requires a nonzero, site-eligible impersonation user. + public bool CanAccessPII { get; } +} diff --git a/Catglobe.CgScript.Common/ScriptFileNameParser.cs b/Catglobe.CgScript.Common/ScriptFileNameParser.cs new file mode 100644 index 00000000..ad2a1943 --- /dev/null +++ b/Catglobe.CgScript.Common/ScriptFileNameParser.cs @@ -0,0 +1,92 @@ +using System; + +namespace Catglobe.CgScript.Common; + +/// Parses security metadata from the leaf of a relative CgScript file path. +public static class ScriptFileNameParser +{ + /// Parses a path shaped as name[@<userId>[.pii][.public]].cgs, preserving whitespace. + /// The file name does not follow the naming grammar. + public static ScriptFileName Parse(string fileName) + { + if (TryParse(fileName, out var result, out var reason)) + return result!; + throw new InvalidScriptFileNameException(fileName, reason!); + } + + /// Attempts to parse a relative file path, returning a reason on failure. + /// The relative path, including its .cgs extension. + /// The parsed name and metadata, or null on failure. + /// The failure reason, or null on success. + /// Whether the file name follows the naming grammar. + public static bool TryParse(string fileName, out ScriptFileName? result, out string? reason) + { + var normalized = fileName.Replace('\\', '/'); + var leafStart = normalized.LastIndexOf('/') + 1; + var leaf = normalized.Substring(leafStart); + var hasExtension = leaf.EndsWith(".cgs", StringComparison.OrdinalIgnoreCase); + var stem = hasExtension ? leaf.Substring(0, leaf.Length - 4) : leaf; + var boundary = stem.LastIndexOf('@'); + var suffix = !hasExtension || stem.Length == 0 ? leaf : boundary >= 0 ? stem.Substring(boundary) : stem; + + result = null; + reason = $"Invalid script file name '{fileName}': offending suffix '{suffix}'. Expected name[@[.pii][.public]].cgs; userId must be ASCII digits in 0..2147483647, markers require a nonzero userId, and the script name must be nonempty without control characters or double quotes."; + if (!hasExtension) + return false; + + var name = boundary >= 0 ? stem.Substring(0, boundary) : stem; + if (name.Length == 0) + return false; + + var scriptName = normalized.Substring(0, leafStart) + name; + foreach (var character in scriptName) + if (char.IsControl(character) || character == '"') + return false; + + uint? impersonation = null; + var canAccessPII = false; + var allowExecuteWithoutLogin = false; + if (boundary < 0) + { + if (stem.EndsWith(".pii", StringComparison.OrdinalIgnoreCase) || stem.EndsWith(".public", StringComparison.OrdinalIgnoreCase)) + return false; + } + else + { + var position = boundary + 1; + var digitsStart = position; + uint userId = 0; + while (position < stem.Length && stem[position] >= '0' && stem[position] <= '9') + { + var digit = (uint)(stem[position] - '0'); + if (userId > (int.MaxValue - digit) / 10) + return false; + userId = userId * 10 + digit; + position++; + } + if (position == digitsStart) + return false; + + var markers = stem.Substring(position); + if (markers.Equals(".pii.public", StringComparison.OrdinalIgnoreCase)) + { + canAccessPII = true; + allowExecuteWithoutLogin = true; + } + else if (markers.Equals(".pii", StringComparison.OrdinalIgnoreCase)) + canAccessPII = true; + else if (markers.Equals(".public", StringComparison.OrdinalIgnoreCase)) + allowExecuteWithoutLogin = true; + else if (markers.Length != 0) + return false; + + if (userId == 0 && (canAccessPII || allowExecuteWithoutLogin)) + return false; + impersonation = userId; + } + + result = new ScriptFileName(scriptName, impersonation, allowExecuteWithoutLogin, canAccessPII); + reason = null; + return true; + } +} diff --git a/Catglobe.CgScript.Deployment.Tests/Catglobe.CgScript.Deployment.Tests.csproj b/Catglobe.CgScript.Deployment.Tests/Catglobe.CgScript.Deployment.Tests.csproj new file mode 100644 index 00000000..aecc1436 --- /dev/null +++ b/Catglobe.CgScript.Deployment.Tests/Catglobe.CgScript.Deployment.Tests.csproj @@ -0,0 +1,34 @@ + + + + net10.0 + enable + enable + preview + true + true + cobertura + $(MSBuildThisFileDirectory)../test-results-output/$(MSBuildProjectName)/ + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + diff --git a/Catglobe.CgScript.Deployment.Tests/CgScriptWrapperGeneratorFileNameTests.cs b/Catglobe.CgScript.Deployment.Tests/CgScriptWrapperGeneratorFileNameTests.cs new file mode 100644 index 00000000..6da6be28 --- /dev/null +++ b/Catglobe.CgScript.Deployment.Tests/CgScriptWrapperGeneratorFileNameTests.cs @@ -0,0 +1,107 @@ +extern alias generator; + +using System.Text; +using CgScriptWrapperGenerator = generator::Catglobe.CgScript.EditorSupport.SourceGenerator.CgScriptWrapperGenerator; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Text; +using Xunit; + +namespace Catglobe.CgScript.Deployment.Tests; + +/// +/// Drives with a Roslyn +/// and asserts that the wrapper name is derived through the shared filename grammar. +/// +public class CgScriptWrapperGeneratorFileNameTests +{ + /// A minimal [CgScriptSerializer] context plus a stub of the marker attribute. + private const string SerializerContextSource = """ + namespace Catglobe.CgScript + { + [System.AttributeUsage(System.AttributeTargets.Class)] + public sealed class CgScriptSerializerAttribute : System.Attribute { } + } + + namespace TestApp + { + [Catglobe.CgScript.CgScriptSerializer] + public partial class TestCgScriptSerializerContext { } + } + """; + + /// A minimal script whose parameters parse without any annotation diagnostics. + private const string SimpleScriptSource = """ + Dictionary namedParameters = Workflow_getParameters()[0]; + string city = namedParameters["city"]; + return city; + """; + + [Fact] + public void PiiMarkedFileName_EmitsWrapperWithScriptNameWithoutMetadata() + { + var runResult = RunGenerator(new InMemoryAdditionalText("Foo@42.pii.cgs", SimpleScriptSource)); + + var generated = Assert.Single(runResult.Results[0].GeneratedSources); + var source = generated.SourceText.ToString(); + Assert.Contains("\"Foo\"", source); + Assert.DoesNotContain("Foo@42", source); + } + + [Fact] + public void MalformedFileName_ReportsCgs028AndEmitsNothing() + { + var additionalText = new InMemoryAdditionalText("Foo@42.public.pii.cgs", SimpleScriptSource); + var runResult = RunGenerator(additionalText); + + var diagnostic = Assert.Single(runResult.Diagnostics); + Assert.Equal("CGS028", diagnostic.Id); + Assert.Equal(DiagnosticSeverity.Error, diagnostic.Severity); + Assert.Equal(additionalText.Path, diagnostic.Location.GetLineSpan().Path); + Assert.Contains("@42.public.pii", diagnostic.GetMessage()); + Assert.Contains("name[@[.pii][.public]].cgs", diagnostic.GetMessage()); + Assert.Empty(runResult.Results[0].GeneratedSources); + } + + [Fact] + public void MetadataFreeFileName_EmitsWrapperWithScriptName() + { + var runResult = RunGenerator(new InMemoryAdditionalText("Weather.cgs", SimpleScriptSource)); + + var generated = Assert.Single(runResult.Results[0].GeneratedSources); + Assert.Contains("\"Weather\"", generated.SourceText.ToString()); + Assert.Empty(runResult.Diagnostics); + } + + private static GeneratorDriverRunResult RunGenerator(params AdditionalText[] additionalTexts) + { + var compilation = CSharpCompilation.Create( + assemblyName: "CgScriptWrapperGeneratorFileNameTests", + syntaxTrees: new[] { CSharpSyntaxTree.ParseText(SerializerContextSource) }, + references: ((string)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES")!) + .Split(Path.PathSeparator) + .Select(p => MetadataReference.CreateFromFile(p)), + options: new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + + var driver = CSharpGeneratorDriver.Create( + generators: new ISourceGenerator[] { new CgScriptWrapperGenerator().AsSourceGenerator() }, + additionalTexts: additionalTexts); + + return driver.RunGenerators(compilation).GetRunResult(); + } + + private sealed class InMemoryAdditionalText : AdditionalText + { + private readonly SourceText _text; + + public InMemoryAdditionalText(string path, string text) + { + Path = path; + _text = SourceText.From(text, Encoding.UTF8); + } + + public override string Path { get; } + + public override SourceText? GetText(CancellationToken cancellationToken = default) => _text; + } +} diff --git a/Catglobe.CgScript.Deployment.Tests/DeployerPayloadTests.cs b/Catglobe.CgScript.Deployment.Tests/DeployerPayloadTests.cs new file mode 100644 index 00000000..961a9da8 --- /dev/null +++ b/Catglobe.CgScript.Deployment.Tests/DeployerPayloadTests.cs @@ -0,0 +1,207 @@ +using System.Net; +using System.Text; +using System.Text.Json.Nodes; +using Catglobe.CgScript.Common; +using Microsoft.Extensions.Options; +using Xunit; +using Xunit.Abstractions; + +namespace Catglobe.CgScript.Deployment.Tests; + +/// +/// Drives the real against a stub HTTP handler that captures every request body, +/// so the assertions run against the actual bytes that would go on the wire. +/// +public class DeployerPayloadTests(ITestOutputHelper output) +{ + private const string PiiScript = "PiiScript"; + private const string PlainScript = "Plain"; + + [Fact] + public async Task SyncPayloadMarksThePiiScriptAndOnlyThatScript() + { + using var folder = new TempScriptFolder(); + folder.Write("PiiScript@7.pii.cgs", "return 1;"); + folder.Write("Plain.cgs", "return 1;"); + + var capture = await RunSync(folder.Root); + + var body = capture.BodyOf("UpdateScripts"); + output.WriteLine($"UpdateScripts body: {body}"); + + var entries = JsonNode.Parse(body)!.AsArray(); + Assert.Equal(2, entries.Count); + var pii = FindEntry(entries, PiiScript); + var plain = FindEntry(entries, PlainScript); + + Assert.True(pii["canAccessPII"]?.GetValue() ?? false, + $"{PiiScript} (from PiiScript@7.pii.cgs) must declare canAccessPII: true. Raw body: {body}"); + Assert.False(plain["canAccessPII"]?.GetValue() ?? true, + $"{PlainScript} (from Plain.cgs) must declare canAccessPII: false. Raw body: {body}"); + Assert.Contains("\"canAccessPII\":true", body); + Assert.Contains("\"canAccessPII\":false", body); + } + + [Fact] + public async Task SyncCallsSyncMapBeforeUpdateScriptsAndAsksNoPiiScope() + { + using var folder = new TempScriptFolder(); + folder.Write("PiiScript@7.pii.cgs", "return 1;"); + folder.Write("Plain.cgs", "return 1;"); + + var capture = await RunSync(folder.Root); + output.WriteLine($"recorded requests: {capture.Describe()}"); + + var syncMapIndex = capture.IndexOf("SyncMap"); + var updateScriptsIndex = capture.IndexOf("UpdateScripts"); + Assert.True(syncMapIndex >= 0 && updateScriptsIndex >= 0, $"both calls must happen. Captured: {capture.Describe()}"); + Assert.True(syncMapIndex < updateScriptsIndex, $"SyncMap must be called before UpdateScripts. Captured: {capture.Describe()}"); + Assert.Equal("POST", capture.Requests[updateScriptsIndex].Method); + + var tokenBody = capture.BodyOf("connect/token"); + output.WriteLine($"token body: {tokenBody}"); + + Assert.DoesNotContain("pii", tokenBody, StringComparison.OrdinalIgnoreCase); + Assert.Equal("scriptdeployment:w", CaptureHandler.FormValue(tokenBody, "scope")); + } + + [Fact] + public async Task SyncFailsBeforeAnyHttpRequestForAMalformedFileName() + { + using var folder = new TempScriptFolder(); + folder.Write("Broken@1.public.pii.cgs", "return 1;"); + + var capture = new CaptureHandler(); + var exception = await Assert.ThrowsAsync( + () => CreateDeployer(folder.Root, capture).Sync("Deployment", CancellationToken.None)); + + Assert.Contains("Broken@1.public.pii.cgs", exception.Message); + output.WriteLine($"thrown: {exception.Message}"); + Assert.Empty(capture.Requests); + } + + private static JsonNode FindEntry(JsonArray entries, string scriptName) + { + var matches = entries.Where(entry => entry!["scriptName"]?.GetValue() == scriptName).ToList(); + var entry = Assert.Single(matches); + return entry!; + } + + private static async Task RunSync(string scriptFolder) + { + var capture = new CaptureHandler(); + await CreateDeployer(scriptFolder, capture).Sync("Deployment", CancellationToken.None); + return capture; + } + + private static Deployer CreateDeployer(string scriptFolder, HttpMessageHandler handler) + { + var options = Options.Create(new DeploymentOptions { + Authority = new Uri("https://example.test"), + ClientId = "test-client", + ClientSecret = "test-secret", + FolderResourceId = 4242, + ScriptFolder = scriptFolder, + }); + + var authenticator = new DeploymentAuthenticator( + new HttpClient(handler, disposeHandler: false) { BaseAddress = options.Value.Authority }, + options); + var authHandler = new DeploymentAuthHandler(authenticator) { InnerHandler = handler }; + var httpClient = new HttpClient(authHandler, disposeHandler: false) { + BaseAddress = new Uri(options.Value.Authority, "api/CgScriptDeployment/"), + }; + + return new Deployer(httpClient, new FilesFromDirectoryScriptProvider(options), new StubMapping(), options); + } + + private sealed class StubMapping : IScriptMapping + { + public int ResetCount { get; private set; } + + public int GetIdOf(string scriptName) => throw new KeyNotFoundException(scriptName); + + public ValueTask EnsureDownloaded() => ValueTask.CompletedTask; + + public void Reset() => ResetCount++; + } + + private sealed class CaptureHandler : HttpMessageHandler + { + private readonly List<(string Method, string Path, string Body)> _requests = []; + + public IReadOnlyList<(string Method, string Path, string Body)> Requests => _requests; + + public string Describe() => + string.Join(" | ", _requests.Select(request => $"{request.Method} {request.Path} body={request.Body}")); + + public int IndexOf(string pathFragment) => + _requests.FindIndex(request => request.Path.Contains(pathFragment, StringComparison.OrdinalIgnoreCase)); + + public string BodyOf(string pathFragment) + { + var index = IndexOf(pathFragment); + Assert.True(index >= 0, $"no captured request for '{pathFragment}'. Captured: {Describe()}"); + return _requests[index].Body; + } + + public static string FormValue(string formBody, string key) => + formBody.Split('&', StringSplitOptions.RemoveEmptyEntries) + .Select(part => part.Split('=', 2)) + .Where(pair => pair.Length == 2 && Uri.UnescapeDataString(pair[0]) == key) + .Select(pair => Uri.UnescapeDataString(pair[1])) + .Single(); + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var body = request.Content is null ? string.Empty : await request.Content.ReadAsStringAsync(cancellationToken); + var path = request.RequestUri!.AbsolutePath; + _requests.Add((request.Method.Method, path, body)); + + if (path.EndsWith("/connect/token", StringComparison.Ordinal)) + return Json("""{"access_token":"test-token"}"""); + if (path.Contains("/SyncMap/", StringComparison.Ordinal)) + return Json(BuildMapFrom(body)); + if (path.Contains("/UpdateScripts/", StringComparison.Ordinal)) + return Json("{}"); + + return new HttpResponseMessage(HttpStatusCode.NotFound) { + Content = new StringContent($"unexpected request {request.Method} {request.RequestUri}"), + }; + } + + /// Builds the deployment map from the scripts the deployer just asked about, fresh for every call. + private static string BuildMapFrom(string syncMapBody) + { + var map = new JsonObject(); + var id = 1; + foreach (var script in JsonNode.Parse(syncMapBody)!.AsArray()) + map[script!["scriptName"]!.GetValue()] = new JsonObject { + ["allowExecuteWithoutLogin"] = false, + ["scriptResourceId"] = id, + ["sha256"] = $"sha-{id++}", + }; + return map.ToJsonString(); + } + + private static HttpResponseMessage Json(string json) => + new(HttpStatusCode.OK) { Content = new StringContent(json, Encoding.UTF8, "application/json") }; + } + + private sealed class TempScriptFolder : IDisposable + { + public TempScriptFolder() => Root = Directory.CreateTempSubdirectory("cgscript-t3-").FullName; + + public string Root { get; } + + public string Write(string relativePath, string content) + { + var fullPath = Path.Combine(Root, relativePath.Replace('/', Path.DirectorySeparatorChar)); + Directory.CreateDirectory(Path.GetDirectoryName(fullPath)!); + File.WriteAllText(fullPath, content); + return fullPath; + } + + public void Dispose() => Directory.Delete(Root, recursive: true); + } +} diff --git a/Catglobe.CgScript.Deployment.Tests/FilesFromDirectoryScriptProviderTests.cs b/Catglobe.CgScript.Deployment.Tests/FilesFromDirectoryScriptProviderTests.cs new file mode 100644 index 00000000..dff25d19 --- /dev/null +++ b/Catglobe.CgScript.Deployment.Tests/FilesFromDirectoryScriptProviderTests.cs @@ -0,0 +1,79 @@ +using Catglobe.CgScript.Deployment; +using Microsoft.Extensions.Options; +using Xunit; + +namespace Catglobe.CgScript.Deployment.Tests; + +public class FilesFromDirectoryScriptProviderTests +{ + [Fact] + public async Task GetAllReadsNamesWithoutMarkersFromDisk() + { + using var folder = new TempScriptFolder(); + folder.Write("Plain.cgs", "return 1;"); + folder.Write("Impersonated@123.cgs", "return 1;"); + folder.Write("Public@123.public.cgs", "return 1;"); + folder.Write("Dir/File.cgs", "return 1;"); + + var scripts = await CreateProvider(folder.Root).GetAll(); + + Assert.Equal(4, scripts.Count); + Assert.Equal("Plain", scripts["Plain"].ScriptName); + Assert.Null(scripts["Plain"].Impersonation); + Assert.False(scripts["Plain"].AllowExecuteWithoutLogin); + Assert.Equal((uint?)123, scripts["Impersonated"].Impersonation); + Assert.False(scripts["Impersonated"].AllowExecuteWithoutLogin); + Assert.Equal((uint?)123, scripts["Public"].Impersonation); + Assert.True(scripts["Public"].AllowExecuteWithoutLogin); + Assert.Equal("Dir/File", scripts["Dir/File"].ScriptName); + Assert.Null(scripts["Dir/File"].Impersonation); + } + + [Fact] + public async Task GetAllReportsDuplicateScriptNamesWithBothFiles() + { + using var folder = new TempScriptFolder(); + folder.Write("A.cgs", "return 1;"); + folder.Write("A@1.cgs", "return 1;"); + + var exception = await Assert.ThrowsAsync(() => CreateProvider(folder.Root).GetAll()); + + Assert.Contains("'A'", exception.Message); + Assert.Contains("A.cgs", exception.Message); + Assert.Contains("A@1.cgs", exception.Message); + } + + [Fact] + public async Task GetAllCarriesThePiiMarker() + { + using var folder = new TempScriptFolder(); + folder.Write("Plain.cgs", "return 1;"); + folder.Write("Pii@1.pii.cgs", "return 1;"); + + var scripts = await CreateProvider(folder.Root).GetAll(); + + Assert.False(scripts["Plain"].CanAccessPII); + Assert.Equal((uint?)1, scripts["Pii"].Impersonation); + Assert.True(scripts["Pii"].CanAccessPII); + } + + private static FilesFromDirectoryScriptProvider CreateProvider(string scriptFolder) => + new(Options.Create(new DeploymentOptions { ScriptFolder = scriptFolder })); + + private sealed class TempScriptFolder : IDisposable + { + public TempScriptFolder() => Root = Directory.CreateTempSubdirectory("cgscript-t2-").FullName; + + public string Root { get; } + + public string Write(string relativePath, string content) + { + var fullPath = Path.Combine(Root, relativePath.Replace('/', Path.DirectorySeparatorChar)); + Directory.CreateDirectory(Path.GetDirectoryName(fullPath)!); + File.WriteAllText(fullPath, content); + return fullPath; + } + + public void Dispose() => Directory.Delete(Root, recursive: true); + } +} diff --git a/Catglobe.CgScript.Deployment.Tests/IScriptDefinitionTests.cs b/Catglobe.CgScript.Deployment.Tests/IScriptDefinitionTests.cs new file mode 100644 index 00000000..9af42b54 --- /dev/null +++ b/Catglobe.CgScript.Deployment.Tests/IScriptDefinitionTests.cs @@ -0,0 +1,26 @@ +using Catglobe.CgScript.Common; +using Xunit; + +namespace Catglobe.CgScript.Deployment.Tests; + +public class IScriptDefinitionTests +{ + [Fact] + public void CustomDefinitionsKeepCompilingAndFailClosedOnPii() + { + IScriptDefinition definition = new CustomScriptDefinition(); + + Assert.False(definition.CanAccessPII); + } + + private sealed class CustomScriptDefinition : IScriptDefinition + { + public string ScriptName => "Custom"; + + public Task Content => Task.FromResult(Stream.Null); + + public uint? Impersonation => null; + + public bool AllowExecuteWithoutLogin => false; + } +} diff --git a/Catglobe.CgScript.Deployment.Tests/ScriptFileNameParserTests.cs b/Catglobe.CgScript.Deployment.Tests/ScriptFileNameParserTests.cs new file mode 100644 index 00000000..3120963b --- /dev/null +++ b/Catglobe.CgScript.Deployment.Tests/ScriptFileNameParserTests.cs @@ -0,0 +1,102 @@ +using Catglobe.CgScript.Common; +using Xunit; + +namespace Catglobe.CgScript.Deployment.Tests; + +public class ScriptFileNameParserTests +{ + [Theory] + [InlineData("Report.cgs", "Report", null, false, false)] + [InlineData("Report@0.cgs", "Report", 0, false, false)] + [InlineData("Report@123.cgs", "Report", 123, false, false)] + [InlineData("Report@123.pii.cgs", "Report", 123, false, true)] + [InlineData("Report@123.public.cgs", "Report", 123, true, false)] + [InlineData("Report@123.pii.public.cgs", "Report", 123, true, true)] + [InlineData("Dir.with.dots/Report.v2@123.pii.cgs", "Dir.with.dots/Report.v2", 123, false, true)] + [InlineData("REPORT@00123.PUBLIC.CGS", "REPORT", 123, true, false)] + [InlineData("a@b@123.pii.cgs", "a@b", 123, false, true)] + [InlineData("Dir@42.pii/Report.cgs", "Dir@42.pii/Report", null, false, false)] + [InlineData("Dir@42.pii\\Report@123.PII.PUBLIC.CGS", "Dir@42.pii/Report", 123, true, true)] + [InlineData("Dir.with.dots\\Report.v2.cgs", "Dir.with.dots/Report.v2", null, false, false)] + [InlineData(" Report .cgs", " Report ", null, false, false)] + [InlineData(" Report @123.cgs", " Report ", 123, false, false)] + [InlineData("Report@2147483647.pii.public.cgs", "Report", 2147483647, true, true)] + [InlineData("Report@00000000000000000000000123.cgs", "Report", 123, false, false)] + [InlineData("Report@000.cgs", "Report", 0, false, false)] + [InlineData("Dir@abc.public.pii/Report.cgs", "Dir@abc.public.pii/Report", null, false, false)] + [InlineData("Report.pii.v2.cgs", "Report.pii.v2", null, false, false)] + public void ParsesValidScriptFileName(string fileName, string scriptName, int? impersonation, bool isPublic, bool canAccessPII) + { + var parsed = ScriptFileNameParser.Parse(fileName); + AssertMetadata(parsed, scriptName, impersonation, isPublic, canAccessPII); + + Assert.True(ScriptFileNameParser.TryParse(fileName, out var result, out var reason)); + Assert.Null(reason); + AssertMetadata(Assert.IsType(result), scriptName, impersonation, isPublic, canAccessPII); + } + + [Theory] + [InlineData("Report@123.secret.cgs", "@123.secret")] + [InlineData("Report@123.publc.cgs", "@123.publc")] + [InlineData("Report@123.public.pii.cgs", "@123.public.pii")] + [InlineData("Report@123.pii.pii.cgs", "@123.pii.pii")] + [InlineData("Report@123.public.public.cgs", "@123.public.public")] + [InlineData("Report@123..pii.cgs", "@123..pii")] + [InlineData("Report@abc.cgs", "@abc")] + [InlineData("Report@-1.cgs", "@-1")] + [InlineData("Report@2147483648.cgs", "@2147483648")] + [InlineData("Report", "Report")] + [InlineData("Report@0.pii.cgs", "@0.pii")] + [InlineData("Report@0.public.cgs", "@0.public")] + [InlineData("Report.public.cgs", ".public")] + [InlineData("Report.pii.cgs", ".pii")] + [InlineData(".cgs", ".cgs")] + [InlineData("@123.cgs", "@123")] + [InlineData("Report.cgs\n", "Report.cgs\n")] + [InlineData("Line\nBreak@123.public.cgs", "@123.public")] + [InlineData("Report@\u0661\u0662\u0663.cgs", "@\u0661\u0662\u0663")] + [InlineData("Report@\uff11\uff12\uff13.cgs", "@\uff11\uff12\uff13")] + [InlineData("Report@+1.cgs", "@+1")] + [InlineData("Report@ 1.cgs", "@ 1")] + [InlineData("Report@1 .cgs", "@1 ")] + [InlineData("Report@.pii.cgs", "@.pii")] + [InlineData("Report@.cgs", "@")] + [InlineData("Report@1.pii.public.pii.cgs", "@1.pii.public.pii")] + [InlineData("Report@1.pii..public.cgs", "@1.pii..public")] + [InlineData("Report@1..cgs", "@1.")] + [InlineData("Report@000.pii.public.cgs", "@000.pii.public")] + [InlineData("Report.PII.CGS", ".PII")] + [InlineData("Report.PUBLIC.CGS", ".PUBLIC")] + [InlineData("Report.cgs ", "Report.cgs ")] + [InlineData("Line\rBreak.cgs", "Line\rBreak")] + [InlineData("Line\0Break.cgs", "Line\0Break")] + [InlineData("Line\u007fBreak.cgs", "Line\u007fBreak")] + [InlineData("Line\u0085Break.cgs", "Line\u0085Break")] + [InlineData("Line\"Break.cgs", "Line\"Break")] + [InlineData("Dir\nName/Report.cgs", "Report")] + [InlineData("Dir\"Name/Report.cgs", "Report")] + [InlineData("Dir/.cgs", ".cgs")] + [InlineData("Dir/@123.cgs", "@123")] + [InlineData("Report@999999999999999999999999999999.cgs", "@999999999999999999999999999999")] + [InlineData("", "")] + public void RejectsInvalidScriptFileName(string fileName, string suffix) + { + var exception = Assert.Throws(() => ScriptFileNameParser.Parse(fileName)); + Assert.Equal(fileName, exception.FileName); + Assert.Contains(fileName, exception.Message); + Assert.Contains(suffix, exception.Message); + Assert.Contains("name[@[.pii][.public]].cgs", exception.Message); + + Assert.False(ScriptFileNameParser.TryParse(fileName, out var result, out var reason)); + Assert.Null(result); + Assert.Equal(exception.Message, reason); + } + + private static void AssertMetadata(ScriptFileName parsed, string scriptName, int? impersonation, bool isPublic, bool canAccessPII) + { + Assert.Equal(scriptName, parsed.ScriptName); + Assert.Equal((uint?)impersonation, parsed.Impersonation); + Assert.Equal(isPublic, parsed.AllowExecuteWithoutLogin); + Assert.Equal(canAccessPII, parsed.CanAccessPII); + } +} diff --git a/Catglobe.CgScript.Deployment.Tests/ScriptFromFileOnDiskTests.cs b/Catglobe.CgScript.Deployment.Tests/ScriptFromFileOnDiskTests.cs new file mode 100644 index 00000000..f756ac97 --- /dev/null +++ b/Catglobe.CgScript.Deployment.Tests/ScriptFromFileOnDiskTests.cs @@ -0,0 +1,75 @@ +using Catglobe.CgScript.Common; +using Catglobe.CgScript.Deployment; +using Xunit; + +namespace Catglobe.CgScript.Deployment.Tests; + +public class ScriptFromFileOnDiskTests +{ + [Theory] + [InlineData("Name.cgs", "Name", null, false)] + [InlineData("Name@123.cgs", "Name", 123, false)] + [InlineData("Name@123.public.cgs", "Name", 123, true)] + public void ReadsSecurityMetadataFromNamesWithoutMarkers(string relativePath, string scriptName, int? impersonation, bool allowExecuteWithoutLogin) + { + using var folder = new TempScriptFolder(); + var definition = new ScriptFromFileOnDisk(folder.Write(relativePath, "return 1;"), relativePath); + + Assert.Equal(scriptName, definition.ScriptName); + Assert.Equal((uint?)impersonation, definition.Impersonation); + Assert.Equal(allowExecuteWithoutLogin, definition.AllowExecuteWithoutLogin); + } + + [Theory] + [InlineData("Weather/GetData@42.pii.cgs", false)] + [InlineData("Weather/GetData@42.pii.public.cgs", true)] + public void StripsThePiiMarkerFromTheScriptName(string relativePath, bool allowExecuteWithoutLogin) + { + var definition = new ScriptFromFileOnDisk(Path.Combine(@"C:\scripts\", relativePath.Replace('/', '\\')), relativePath); + + Assert.Equal("Weather/GetData", definition.ScriptName); + Assert.Equal((uint?)42, definition.Impersonation); + Assert.Equal(allowExecuteWithoutLogin, definition.AllowExecuteWithoutLogin); + Assert.True(definition.CanAccessPII); + } + + [Theory] + [InlineData("Weather/GetData.cgs")] + [InlineData("Weather/GetData@42.cgs")] + [InlineData("Weather/GetData@42.public.cgs")] + public void DoesNotRequestPiiWithoutTheMarker(string relativePath) + { + var definition = new ScriptFromFileOnDisk(Path.Combine(@"C:\scripts\", relativePath.Replace('/', '\\')), relativePath); + + Assert.False(definition.CanAccessPII); + } + + [Fact] + public void RejectsMalformedNameWithTheParserError() + { + const string relativePath = "Weather/GetData@42.public.pii.cgs"; + + var exception = Assert.Throws(() => new ScriptFromFileOnDisk(Path.Combine(@"C:\scripts\", "Weather", "GetData@42.public.pii.cgs"), relativePath)); + + Assert.Contains(relativePath, exception.Message); + Assert.Contains("@42.public.pii", exception.Message); + Assert.Contains("name[@[.pii][.public]].cgs", exception.Message); + } + + private sealed class TempScriptFolder : IDisposable + { + public TempScriptFolder() => Root = Directory.CreateTempSubdirectory("cgscript-t2-").FullName; + + public string Root { get; } + + public string Write(string relativePath, string content) + { + var fullPath = Path.Combine(Root, relativePath.Replace('/', Path.DirectorySeparatorChar)); + Directory.CreateDirectory(Path.GetDirectoryName(fullPath)!); + File.WriteAllText(fullPath, content); + return fullPath; + } + + public void Dispose() => Directory.Delete(Root, recursive: true); + } +} diff --git a/Catglobe.CgScript.Deployment/Catglobe.CgScript.Deployment.csproj b/Catglobe.CgScript.Deployment/Catglobe.CgScript.Deployment.csproj index 7c0969a9..a36f98cb 100644 --- a/Catglobe.CgScript.Deployment/Catglobe.CgScript.Deployment.csproj +++ b/Catglobe.CgScript.Deployment/Catglobe.CgScript.Deployment.csproj @@ -32,6 +32,10 @@ + + + + true diff --git a/Catglobe.CgScript.Deployment/Deployer.cs b/Catglobe.CgScript.Deployment/Deployer.cs index 72c50d0b..e7552e7d 100644 --- a/Catglobe.CgScript.Deployment/Deployer.cs +++ b/Catglobe.CgScript.Deployment/Deployer.cs @@ -36,6 +36,7 @@ public async Task Sync(string environmentName, CancellationToken token) Content = await maker.GetContent(scriptName), Impersonation = scriptDefinition.Impersonation, AllowExecuteWithoutLogin = scriptDefinition.AllowExecuteWithoutLogin, + CanAccessPII = scriptDefinition.CanAccessPII, }); await UpdateScripts(cgScriptDefinitions, token); map.Reset(); @@ -118,5 +119,10 @@ private record CgScriptDefinition /// If true, the script can be run without a user being logged in. For obvious reasons this must be used with impersonation. /// public bool AllowExecuteWithoutLogin { get; set; } + /// + /// If true, the script declares that it may read personal data. + /// For obvious reasons this must be used with impersonation, and the impersonated user must be PII-eligible on the site. + /// + public bool CanAccessPII { get; set; } } } \ No newline at end of file diff --git a/Catglobe.CgScript.Deployment/FilesFromDirectoryScriptProvider.cs b/Catglobe.CgScript.Deployment/FilesFromDirectoryScriptProvider.cs index 19bc2f53..067dab35 100644 --- a/Catglobe.CgScript.Deployment/FilesFromDirectoryScriptProvider.cs +++ b/Catglobe.CgScript.Deployment/FilesFromDirectoryScriptProvider.cs @@ -6,6 +6,7 @@ namespace Catglobe.CgScript.Deployment; /// /// Default implementation of that fetches *.cgs scripts from a file folder. /// Files in sub folders will get script names that include the folder name. Example: MyFolder\MyScript@123.cgs will get the scriptName MyFolder/MyScript. +/// Two files that map to the same script name are rejected with an naming both files. /// public class FilesFromDirectoryScriptProvider(IOptions options) : IScriptProvider { @@ -13,9 +14,17 @@ public class FilesFromDirectoryScriptProvider(IOptions option public Task> GetAll() { var directory = options.Value.ScriptFolder; + var scripts = Directory.EnumerateFiles(directory, "*.cgs", SearchOption.AllDirectories) + .Select(file => (File: file, Script: new ScriptFromFileOnDisk(file, Path.GetRelativePath(directory, file)))) + .ToList(); + + var duplicate = scripts.GroupBy(entry => entry.Script.ScriptName) + .FirstOrDefault(group => group.Count() > 1); + if (duplicate is not null) + throw new InvalidOperationException( + $"Duplicate script name '{duplicate.Key}': the files {string.Join(", ", duplicate.Select(entry => $"'{entry.File}'"))} all map to that script name. Rename or remove one of these files so that every script name is unique."); + return Task.FromResult> - (Directory.EnumerateFiles(directory, "*.cgs", SearchOption.AllDirectories) - .Select(file => new ScriptFromFileOnDisk(file, Path.GetRelativePath(directory, file))) - .ToDictionary(script => script.ScriptName, IScriptDefinition (script) => script)); + (scripts.ToDictionary(entry => entry.Script.ScriptName, IScriptDefinition (entry) => entry.Script)); } } diff --git a/Catglobe.CgScript.Deployment/ScriptFromFileOnDisk.cs b/Catglobe.CgScript.Deployment/ScriptFromFileOnDisk.cs index e8cdfb81..f4549c21 100644 --- a/Catglobe.CgScript.Deployment/ScriptFromFileOnDisk.cs +++ b/Catglobe.CgScript.Deployment/ScriptFromFileOnDisk.cs @@ -1,5 +1,4 @@ -using System.Text.RegularExpressions; -using Catglobe.CgScript.Common; +using Catglobe.CgScript.Common; namespace Catglobe.CgScript.Deployment; @@ -7,8 +6,10 @@ namespace Catglobe.CgScript.Deployment; /// Script file from disk. /// Impersonation is inferred from the file name. If file is named MyFolder\MyScript@123.cgs, impersonation will be 123. /// If script is named MyScript@123.public.cgs, it will be possible to run the script without a user being logged in. +/// If script is named MyScript@123.pii.cgs, the script declares that it may read personal data, which is only honored +/// together with an impersonated user that is PII-eligible on the site. /// -public partial class ScriptFromFileOnDisk : IScriptDefinition +public class ScriptFromFileOnDisk : IScriptDefinition { private readonly string _fullPath; @@ -17,18 +18,17 @@ public partial class ScriptFromFileOnDisk : IScriptDefinition /// /// Full path on disk to load file /// Path that is interpreted as scriptName + /// The file name does not follow the script naming grammar. public ScriptFromFileOnDisk(string fullPath, string relativePath) { - _fullPath = fullPath; - var match = GetScriptNameAndImpersonation().Match(relativePath); - ScriptName = match.Groups["scriptName"].Value.Replace('\\', '/'); - Impersonation = match.Groups["impersonation"] is {Success: true} g ? uint.Parse(g.Value) : null; - AllowExecuteWithoutLogin = match.Groups["AllowExecuteWithoutLogin"] is {Success: true}; + _fullPath = fullPath; + var parsed = ScriptFileNameParser.Parse(relativePath); + ScriptName = parsed.ScriptName.Replace('\\', '/'); + Impersonation = parsed.Impersonation; + AllowExecuteWithoutLogin = parsed.AllowExecuteWithoutLogin; + CanAccessPII = parsed.CanAccessPII; } - [GeneratedRegex(@"^(?.*?)(?:@(?\d+)(?:\.(?public))?)?\.cgs$", RegexOptions.Singleline | RegexOptions.IgnoreCase, -1)] - private static partial Regex GetScriptNameAndImpersonation(); - /// public string ScriptName { get; } /// @@ -36,5 +36,7 @@ public ScriptFromFileOnDisk(string fullPath, string relativePath) /// public bool AllowExecuteWithoutLogin { get; } /// + public bool CanAccessPII { get; } + /// public Task Content => Task.FromResult(File.OpenRead(_fullPath)); } diff --git a/Catglobe.CgScript.EditorSupport.SourceGenerator/AnalyzerReleases.Shipped.md b/Catglobe.CgScript.EditorSupport.SourceGenerator/AnalyzerReleases.Shipped.md index 50b820a3..519e2de2 100644 --- a/Catglobe.CgScript.EditorSupport.SourceGenerator/AnalyzerReleases.Shipped.md +++ b/Catglobe.CgScript.EditorSupport.SourceGenerator/AnalyzerReleases.Shipped.md @@ -87,3 +87,4 @@ CGS000 | CgScript | Error | Internal LSP error Rule ID | Category | Severity | Notes --------|----------|----------|------- CGS027 | CgScript | Error | Unknown where-expression function +CGS028 | CgScript | Error | Invalid script file name (expected name[@[.pii][.public]].cgs) diff --git a/Catglobe.CgScript.EditorSupport.SourceGenerator/Catglobe.CgScript.EditorSupport.SourceGenerator.csproj b/Catglobe.CgScript.EditorSupport.SourceGenerator/Catglobe.CgScript.EditorSupport.SourceGenerator.csproj index 030f37df..d2291072 100644 --- a/Catglobe.CgScript.EditorSupport.SourceGenerator/Catglobe.CgScript.EditorSupport.SourceGenerator.csproj +++ b/Catglobe.CgScript.EditorSupport.SourceGenerator/Catglobe.CgScript.EditorSupport.SourceGenerator.csproj @@ -33,6 +33,13 @@ + + + + + + + diff --git a/Catglobe.CgScript.EditorSupport.SourceGenerator/CgScriptDiagnostics.cs b/Catglobe.CgScript.EditorSupport.SourceGenerator/CgScriptDiagnostics.cs index 9e3ed714..fc219169 100644 --- a/Catglobe.CgScript.EditorSupport.SourceGenerator/CgScriptDiagnostics.cs +++ b/Catglobe.CgScript.EditorSupport.SourceGenerator/CgScriptDiagnostics.cs @@ -230,6 +230,14 @@ internal static class CgScriptDiagnostics defaultSeverity: DiagnosticSeverity.Error, isEnabledByDefault: true); + public static readonly DiagnosticDescriptor InvalidScriptFileName = new( + id: "CGS028", + title: "Invalid script file name", + messageFormat: "{0}", + category: Category, + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true); + public static DiagnosticSeverity ToRoslyn(Catglobe.CgScript.EditorSupport.Parsing.DiagnosticSeverity s) => s == Catglobe.CgScript.EditorSupport.Parsing.DiagnosticSeverity.Error ? DiagnosticSeverity.Error @@ -265,6 +273,7 @@ public static DiagnosticDescriptor DescriptorFor(Catglobe.CgScript.EditorSupport "CGS025" => IndexerCallMismatch, "CGS026" => ObsoleteUsage, "CGS027" => UnknownWhereFunction, + "CGS028" => InvalidScriptFileName, _ => throw new ArgumentOutOfRangeException(nameof(d), d.Code, "Unknown diagnostic code"), }; } diff --git a/Catglobe.CgScript.EditorSupport.SourceGenerator/CgScriptWrapperGenerator.cs b/Catglobe.CgScript.EditorSupport.SourceGenerator/CgScriptWrapperGenerator.cs index a1e3515f..1c5102eb 100644 --- a/Catglobe.CgScript.EditorSupport.SourceGenerator/CgScriptWrapperGenerator.cs +++ b/Catglobe.CgScript.EditorSupport.SourceGenerator/CgScriptWrapperGenerator.cs @@ -1,3 +1,4 @@ +using Catglobe.CgScript.Common; using Catglobe.CgScript.EditorSupport.Parsing; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp.Syntax; @@ -7,7 +8,6 @@ using System.IO; using System.Linq; using System.Text; -using System.Text.RegularExpressions; namespace Catglobe.CgScript.EditorSupport.SourceGenerator; @@ -130,8 +130,17 @@ public void Initialize(IncrementalGeneratorInitializationContext context) var dirPart = lastSlash >= 0 ? relNoExt.Substring(0, lastSlash) : ""; var fileBase = lastSlash >= 0 ? relNoExt.Substring(lastSlash + 1) : relNoExt; - // Strip deployer metadata (@NNN[.public]) from the file name only - var scriptFile = s_deployerSuffix.Replace(fileBase, string.Empty); + // Derive the script name through the shared grammar the deployer applies too. + // The parser is defined on the leaf WITH its extension, so re-append ".cgs" here. + if (!ScriptFileNameParser.TryParse(fileBase + ".cgs", out var parsedFileName, out var parseError)) + { + spc.ReportDiagnostic(Microsoft.CodeAnalysis.Diagnostic.Create( + CgScriptDiagnostics.InvalidScriptFileName, + AdditionalFileLocation(file), + parseError!)); + return; + } + var scriptFile = parsedFileName!.ScriptName; // Full relative path (used for hint name / namespace computation) var fullRelPath = dirPart.Length > 0 ? dirPart + "/" + scriptFile : scriptFile; @@ -232,10 +241,6 @@ public void Initialize(IncrementalGeneratorInitializationContext context) }); } - /// Strips deployer metadata (@NNN[.public]) from a script base name. - private static readonly Regex s_deployerSuffix = - new Regex(@"@\d+(?:\.public)?$", RegexOptions.IgnoreCase); - /// /// Strips the namespace prefix from a fully-qualified type name so that unqualified /// annotations (@return TagSummary) can match against fully-qualified registrations @@ -318,6 +323,15 @@ private static void ReportSemanticDiagnostics( } } + /// + /// Creates a location at the start of an additional file, which has no syntax tree of its own. + /// + private static Location AdditionalFileLocation(AdditionalText file) + { + var span = new TextSpan(0, 0); + return Location.Create(file.Path, span, new LinePositionSpan(new LinePosition(0, 0), new LinePosition(0, 0))); + } + private static Location ToLocation(AdditionalText file, SourceText text, Parsing.Diagnostic d) { if (d.Line <= 0 || d.Line > text.Lines.Count) diff --git a/Catglobe.CgScript.slnx b/Catglobe.CgScript.slnx index fb86683f..3acb8ef4 100644 --- a/Catglobe.CgScript.slnx +++ b/Catglobe.CgScript.slnx @@ -9,6 +9,7 @@ + diff --git a/README.md b/README.md index a31417f4..acd3c4e4 100644 --- a/README.md +++ b/README.md @@ -746,9 +746,17 @@ The editors connect to the `Catglobe.CgScript.EditorSupport.Lsp.Server` process ## File name mapping to security -It is possible to specify which user a script runs under and if the script needs a user to be logged in. +The file name carries a script's security metadata: which user it runs under, whether it may run without a login, and whether it may read personal data. -See the documentation for ScriptFromFileOnDisk for details. +``` +name[@[.pii][.public]].cgs +``` + +`.public` runs the script without a login; `.pii` declares that it may read personal data and needs an impersonation user the site accepts. A malformed name - an unknown token, the wrong marker order, a `.pii`/`.public` with no `@` - is rejected with an error naming the file. + +Development mode honours neither marker: such a script runs as an ordinary script there. + +See the documentation for `ScriptFromFileOnDisk` for details. ## Can I adapt my scripts to do something special in development mode? diff --git a/templates/cgscript-skill-template.md b/templates/cgscript-skill-template.md index 779b696f..2f8cf2cf 100644 --- a/templates/cgscript-skill-template.md +++ b/templates/cgscript-skill-template.md @@ -111,7 +111,9 @@ int userId = p["userId"]; |---------|---------|---------| | Standard | `GetCompany.cgs` | Normal script, runs as authenticated user | | Impersonation | `GetCompany@123.cgs` | Script runs impersonating user 123 | +| PII | `GetCompany@123.pii.cgs` | Script may read personal data, runs as user 123 | | Public | `GetCompany@123.public.cgs` | Public script, no login required | +| PII + public | `GetCompany@123.pii.public.cgs` | May read personal data and run without a login | ## How to create a new .cgs script