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