From 93aed956298a0b9dc74b9ca4cc01de0faf901932 Mon Sep 17 00:00:00 2001 From: Dennis Haney Date: Fri, 18 Sep 2026 14:20:01 +0700 Subject: [PATCH 1/9] feat(common): add strict CgScript filename grammar parser Parses name[@[.pii][.public]].cgs from the leaf only; canonical .pii-then-.public order; ids 0..2147483647; fail-loud errors naming the file, the offending suffix and the expected shape. --- .../InvalidScriptFileNameException.cs | 18 ++++ Catglobe.CgScript.Common/ScriptFileName.cs | 25 +++++ .../ScriptFileNameParser.cs | 92 ++++++++++++++++ .../Catglobe.CgScript.Deployment.Tests.csproj | 26 +++++ .../ScriptFileNameParserTests.cs | 101 ++++++++++++++++++ Catglobe.CgScript.slnx | 1 + 6 files changed, 263 insertions(+) create mode 100644 Catglobe.CgScript.Common/InvalidScriptFileNameException.cs create mode 100644 Catglobe.CgScript.Common/ScriptFileName.cs create mode 100644 Catglobe.CgScript.Common/ScriptFileNameParser.cs create mode 100644 Catglobe.CgScript.Deployment.Tests/Catglobe.CgScript.Deployment.Tests.csproj create mode 100644 Catglobe.CgScript.Deployment.Tests/ScriptFileNameParserTests.cs 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..d1face81 --- /dev/null +++ b/Catglobe.CgScript.Deployment.Tests/Catglobe.CgScript.Deployment.Tests.csproj @@ -0,0 +1,26 @@ + + + + net10.0 + enable + enable + preview + true + true + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + diff --git a/Catglobe.CgScript.Deployment.Tests/ScriptFileNameParserTests.cs b/Catglobe.CgScript.Deployment.Tests/ScriptFileNameParserTests.cs new file mode 100644 index 00000000..96750e1e --- /dev/null +++ b/Catglobe.CgScript.Deployment.Tests/ScriptFileNameParserTests.cs @@ -0,0 +1,101 @@ +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.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.slnx b/Catglobe.CgScript.slnx index fb86683f..3acb8ef4 100644 --- a/Catglobe.CgScript.slnx +++ b/Catglobe.CgScript.slnx @@ -9,6 +9,7 @@ + From 4b0eb28a4fa2cf5f35ef9ed86e7b6630af6db02d Mon Sep 17 00:00:00 2001 From: Dennis Haney Date: Fri, 18 Sep 2026 14:20:11 +0700 Subject: [PATCH 2/9] docs: document the .pii filename marker and site prerequisites Grammar, valid and rejected shapes, the four accepted behaviour changes including the one-time rename, the site PII policy predicate, and that the deployer still requests only scriptdeployment:w. --- README.md | 72 +++++++++++++++++++++++++++- templates/cgscript-skill-template.md | 4 ++ 2 files changed, 74 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index a31417f4..207cce3b 100644 --- a/README.md +++ b/README.md @@ -746,9 +746,77 @@ 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 is the only channel that carries a script's security metadata to the site: 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. +### The naming grammar + +The grammar applies to the **leaf** of the script path, that is the file name itself. Directory segments are never read for metadata, so a folder called `Reports@42.pii` stays exactly that and marks nothing. The leaf must end in `.cgs`, and the extension plus every marker is compared case-insensitively. + +The metadata boundary is the **last** `@` in the leaf. Everything before it is the script name; with no `@` there is no metadata at all. After the last `@`, the shape is: + +``` +name[@[.pii][.public]].cgs +``` + +- `` is ASCII digits only, with no sign, and the value must fit the range `0` to `2147483647`. The site casts the value to a 32-bit signed integer, so anything larger is rejected. +- `.pii` declares that the script may read personal data. It requires an impersonation user, and the site checks that user against its own PII policy. +- `.public` declares that the script may run without a user being logged in. It too needs an impersonation user, and that user can't be `@0`. +- The canonical marker order is `.pii` then `.public`, so `.pii` sits closest to the id it scopes. Each marker may appear at most once. +- `@0` is a real value meaning "no impersonation user" (used for development mapping). It is not a synonym for leaving the `@` out. + +| File name | Script name | Impersonation | Public | PII | +|---|---|---|---|---| +| `Name.cgs` | `Name` | none | no | no | +| `Name@0.cgs` | `Name` | 0 | no | no | +| `Name@123.cgs` | `Name` | 123 | no | no | +| `Name@123.pii.cgs` | `Name` | 123 | no | yes | +| `Name@123.public.cgs` | `Name` | 123 | yes | no | +| `Name@123.pii.public.cgs` | `Name` | 123 | yes | yes | + +Script names may contain any character except control characters and the `"` character. Both break the generated C# wrapper, which embeds the name in a method name and in a string literal. Backslashes in a relative path are normalized to `/`. Whitespace is preserved and never trimmed. + +### Names that fail the deploy + +A malformed name fails loudly, with an error that names the file and the offending suffix, before any HTTP request is made. Both the deployer and the source generator (diagnostic `CGS028`, an error) apply the same grammar, so a bad name also fails the build of a project that uses the analyzer package. + +These shapes are rejected: + +| Rejected shape | Example | +|---|---| +| Unknown token after the id | `Name@123.secret.cgs` | +| Wrong marker order | `Name@123.public.pii.cgs` | +| Duplicate marker | `Name@123.pii.pii.cgs`, `Name@123.public.public.cgs` | +| Empty token | `Name@123..pii.cgs` | +| Non-numeric or signed id | `Name@abc.cgs`, `Name@-1.cgs` | +| Id above `2147483647` | `Name@2147483648.cgs` | +| A marker combined with `@0` | `Name@0.pii.cgs`, `Name@0.public.cgs` | +| A `.pii`/`.public` tail with no `@` | `Name.pii.cgs`, `Name.public.cgs` | +| A leaf that does not end in `.cgs` | `Name` | +| An empty script name | `@123.cgs`, `.cgs` | +| A control character in the name | `Line\nBreak@123.public.cgs` | + +### What changes for existing files + +File names that carry no metadata keep exactly the result they have today: `Name.cgs`, `Name@123.cgs`, `Name@123.public.cgs`, dotted names and `@` inside a name all behave as before. Four changes are accepted, and they are changes, not a promise that nothing moved: + +1. A malformed tail after the last `@` is now an error where it used to be folded into the script name (`Name@123.publc.cgs`, `Name@abc.cgs`, `Name@123.public.pii.cgs`). This makes **any** `@` in the final segment a metadata boundary. +2. A trailing `.public` or `.pii` with no `@` is now an error (`Name.public.cgs`, `Name.pii.cgs`). +3. `.public` or `.pii` combined with `@0` is now an error. Today such a file registers and then fails on the site with `412` or `403`. +4. A file named `Name@123.pii.cgs` now registers as script `Name` instead of the literal `Name@123.pii`. Treat this as a one-time rename: the next sync deletes the old resource on the site, and any external `new WorkflowScript()` reference to it breaks. If `Name.cgs` also exists in the same folder, the deployer reports a duplicate script identity naming both files instead of renaming silently. + +### Site prerequisites + +Two site-side conditions decide whether a `.pii` deploy actually grants PII access. The deployer cannot check either one for you. + +**The impersonated user must satisfy the site's workflow-PII policy.** The current rule is `TemplateEligible && !BuiltInAdmin && !AdminGroup`, which in plain words means the user must not be the built-in admin and must not belong to an admin group. A `.pii` file with no impersonation user, or with an impersonation user that the policy rejects, fails on the site with `403`: the script requests PII access but no impersonation is set, or the impersonation does not have PII. + +**The server must already know the `canAccessPII` member.** A site that predates it silently ignores the member, so a `.pii` deploy against such a site succeeds with PII turned off. Deploy your Catglobe site first, then the scripts. + +Deploying a PII script asks for no extra permission on your side. The deployer keeps requesting the same `scriptdeployment:w` scope as before; there is no separate PII scope to grant or request. + +### Development mode + +Development mode honours neither marker. A `.pii` or `.public` script run through development-generated code is treated as an ordinary script, exactly as `public` is ignored there today. ## 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..8168b68f 100644 --- a/templates/cgscript-skill-template.md +++ b/templates/cgscript-skill-template.md @@ -111,7 +111,11 @@ 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 | + +Rules for the file name: the metadata boundary is the last `@` in the file name, the leaf must end in `.cgs`, an id is ASCII digits in the range `0` to `2147483647`, and the marker order is `.pii` then `.public`. A `.pii` script must also declare an impersonation user, and that user must be PII-eligible on the site (not the built-in admin and not in an admin group). Anything else in the tail, such as `GetCompany@123.public.pii.cgs`, `GetCompany@0.pii.cgs` or `GetCompany.pii.cgs`, is an error that names the file. Do not name a script `*.public.cgs` or `*.pii.cgs` by accident: a trailing `.public`/`.pii` with no `@` is rejected as well. ## How to create a new .cgs script From 488099f25b1e3a0910e96e77e3ece484da66582f Mon Sep 17 00:00:00 2001 From: Dennis Haney Date: Fri, 18 Sep 2026 14:27:49 +0700 Subject: [PATCH 3/9] feat(common): expose CanAccessPII on IScriptDefinition and script files Default interface member is fail-closed (false) so custom providers keep compiling; ScriptFromFileOnDisk now delegates the whole filename grammar to ScriptFileNameParser; the directory provider reports duplicate script identities with both file names instead of a raw dictionary error. --- Catglobe.CgScript.Common/IScriptDefinition.cs | 7 ++ .../FilesFromDirectoryScriptProviderTests.cs | 79 +++++++++++++++++++ .../IScriptDefinitionTests.cs | 26 ++++++ .../ScriptFromFileOnDiskTests.cs | 75 ++++++++++++++++++ .../FilesFromDirectoryScriptProvider.cs | 15 +++- .../ScriptFromFileOnDisk.cs | 24 +++--- 6 files changed, 212 insertions(+), 14 deletions(-) create mode 100644 Catglobe.CgScript.Deployment.Tests/FilesFromDirectoryScriptProviderTests.cs create mode 100644 Catglobe.CgScript.Deployment.Tests/IScriptDefinitionTests.cs create mode 100644 Catglobe.CgScript.Deployment.Tests/ScriptFromFileOnDiskTests.cs 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.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/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/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)); } From 3e62bd9bc10c7838d069c0309e997ffacecca21e Mon Sep 17 00:00:00 2001 From: Dennis Haney Date: Fri, 18 Sep 2026 14:27:50 +0700 Subject: [PATCH 4/9] feat(sourcegenerator): share the filename grammar and diagnose malformed names The analyzer source-links ScriptFileNameParser and passes the leaf with its .cgs extension, so wrapper names always match what the deployer registers. Malformed names now fail the build with CGS028 (Error) instead of silently emitting a wrapper for a wrong script name that would never exist on the site. Accepted naming breaks shipped with this change, documented in the README: - a malformed tail after the last @ is an error where it used to be folded into the script name (Name@123.publc.cgs, Name@abc.cgs, Name@123.public.pii.cgs); - a trailing .pii/.public with no @ is an error (Name.pii.cgs, Name.public.cgs); - .pii/.public combined with @0 is an error; such a file used to register and then fail on the site with 412/403; - an existing Name@123.pii.cgs now registers as script 'Name' instead of the literal 'Name@123.pii' - a one-time rename; the deployer reports a duplicate script identity naming both files if Name.cgs also exists. --- .../Catglobe.CgScript.Deployment.Tests.csproj | 2 + .../CgScriptWrapperGeneratorFileNameTests.cs | 107 ++++++++++++++++++ .../AnalyzerReleases.Unshipped.md | 1 + ...cript.EditorSupport.SourceGenerator.csproj | 7 ++ .../CgScriptDiagnostics.cs | 9 ++ .../CgScriptWrapperGenerator.cs | 28 +++-- 6 files changed, 147 insertions(+), 7 deletions(-) create mode 100644 Catglobe.CgScript.Deployment.Tests/CgScriptWrapperGeneratorFileNameTests.cs diff --git a/Catglobe.CgScript.Deployment.Tests/Catglobe.CgScript.Deployment.Tests.csproj b/Catglobe.CgScript.Deployment.Tests/Catglobe.CgScript.Deployment.Tests.csproj index d1face81..46a338bf 100644 --- a/Catglobe.CgScript.Deployment.Tests/Catglobe.CgScript.Deployment.Tests.csproj +++ b/Catglobe.CgScript.Deployment.Tests/Catglobe.CgScript.Deployment.Tests.csproj @@ -10,6 +10,7 @@ + @@ -21,6 +22,7 @@ + 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.EditorSupport.SourceGenerator/AnalyzerReleases.Unshipped.md b/Catglobe.CgScript.EditorSupport.SourceGenerator/AnalyzerReleases.Unshipped.md index d6b137df..9ffe0361 100644 --- a/Catglobe.CgScript.EditorSupport.SourceGenerator/AnalyzerReleases.Unshipped.md +++ b/Catglobe.CgScript.EditorSupport.SourceGenerator/AnalyzerReleases.Unshipped.md @@ -5,3 +5,4 @@ Rule ID | Category | Severity | Notes --------|----------|----------|------- +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) From aab061b5f3d13ed3b7f0f532fe481894254eb32a Mon Sep 17 00:00:00 2001 From: Dennis Haney Date: Fri, 18 Sep 2026 14:31:32 +0700 Subject: [PATCH 5/9] feat(deployment): send canAccessPII in the UpdateScripts payload The deployer now forwards the PII classification parsed from the file name to the site. The token request still asks only for scriptdeployment:w: the site infers the capability from that scope, and its deploy gate checks the impersonated user instead of a pii scope. Capture-handler tests assert the raw request bodies, the call order, and the zero-request failure path for a malformed file name. --- .../DeployerPayloadTests.cs | 207 ++++++++++++++++++ .../Catglobe.CgScript.Deployment.csproj | 4 + Catglobe.CgScript.Deployment/Deployer.cs | 6 + 3 files changed, 217 insertions(+) create mode 100644 Catglobe.CgScript.Deployment.Tests/DeployerPayloadTests.cs 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/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 From 4d99c457f1f5e4de58c403d46d852c3985d3f3bd Mon Sep 17 00:00:00 2001 From: Dennis Haney Date: Fri, 18 Sep 2026 14:43:13 +0700 Subject: [PATCH 6/9] docs: state that any @ in the leaf starts the metadata suffix The previous wording implied @-containing names keep deploying; they do not. Also adds the README example Report@123.publc.cgs as a literal test row so the documented examples and the test data agree in both directions. Plan: .omo/plans/cgscript-pii-filename-marker.md --- Catglobe.CgScript.Deployment.Tests/ScriptFileNameParserTests.cs | 1 + README.md | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/Catglobe.CgScript.Deployment.Tests/ScriptFileNameParserTests.cs b/Catglobe.CgScript.Deployment.Tests/ScriptFileNameParserTests.cs index 96750e1e..3120963b 100644 --- a/Catglobe.CgScript.Deployment.Tests/ScriptFileNameParserTests.cs +++ b/Catglobe.CgScript.Deployment.Tests/ScriptFileNameParserTests.cs @@ -37,6 +37,7 @@ public void ParsesValidScriptFileName(string fileName, string scriptName, int? i [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")] diff --git a/README.md b/README.md index 207cce3b..55345e19 100644 --- a/README.md +++ b/README.md @@ -797,7 +797,7 @@ These shapes are rejected: ### What changes for existing files -File names that carry no metadata keep exactly the result they have today: `Name.cgs`, `Name@123.cgs`, `Name@123.public.cgs`, dotted names and `@` inside a name all behave as before. Four changes are accepted, and they are changes, not a promise that nothing moved: +File names that carry no metadata keep exactly the result they have today: `Name.cgs`, `Name@123.cgs`, `Name@123.public.cgs` and dotted names all behave as before. An `@` in the leaf is the exception: the last `@` in the leaf always starts the metadata suffix, so a script file whose name merely contains an `@`, such as `user@domain.cgs`, is read as metadata, is rejected and never deploys - rename it (for example to `user-domain.cgs`) to keep it. Four changes are accepted, and they are changes, not a promise that nothing moved: 1. A malformed tail after the last `@` is now an error where it used to be folded into the script name (`Name@123.publc.cgs`, `Name@abc.cgs`, `Name@123.public.pii.cgs`). This makes **any** `@` in the final segment a metadata boundary. 2. A trailing `.public` or `.pii` with no `@` is now an error (`Name.public.cgs`, `Name.pii.cgs`). From eb9343c23f242ce466a4eea7c77f3d5d441628c3 Mon Sep 17 00:00:00 2001 From: Dennis Haney Date: Fri, 18 Sep 2026 15:18:57 +0700 Subject: [PATCH 7/9] docs: trim the naming documentation and move CGS028 to shipped rules The rule row belongs in AnalyzerReleases.Shipped.md with the other shipped rules; Unshipped keeps its empty table. The README section shrinks to the grammar line, what the markers mean, the fail-loud behaviour and the pointer to the ScriptFromFileOnDisk documentation, which holds the details. The skill template keeps the two marker rows without the added rules paragraph. --- .../AnalyzerReleases.Shipped.md | 1 + .../AnalyzerReleases.Unshipped.md | 1 - README.md | 68 +------------------ templates/cgscript-skill-template.md | 2 - 4 files changed, 4 insertions(+), 68 deletions(-) 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/AnalyzerReleases.Unshipped.md b/Catglobe.CgScript.EditorSupport.SourceGenerator/AnalyzerReleases.Unshipped.md index 9ffe0361..d6b137df 100644 --- a/Catglobe.CgScript.EditorSupport.SourceGenerator/AnalyzerReleases.Unshipped.md +++ b/Catglobe.CgScript.EditorSupport.SourceGenerator/AnalyzerReleases.Unshipped.md @@ -5,4 +5,3 @@ Rule ID | Category | Severity | Notes --------|----------|----------|------- -CGS028 | CgScript | Error | Invalid script file name (expected name[@[.pii][.public]].cgs) diff --git a/README.md b/README.md index 55345e19..ef9a6882 100644 --- a/README.md +++ b/README.md @@ -746,77 +746,15 @@ The editors connect to the `Catglobe.CgScript.EditorSupport.Lsp.Server` process ## File name mapping to security -The file name is the only channel that carries a script's security metadata to the site: which user it runs under, whether it may run without a login, and whether it may read personal data. - -### The naming grammar - -The grammar applies to the **leaf** of the script path, that is the file name itself. Directory segments are never read for metadata, so a folder called `Reports@42.pii` stays exactly that and marks nothing. The leaf must end in `.cgs`, and the extension plus every marker is compared case-insensitively. - -The metadata boundary is the **last** `@` in the leaf. Everything before it is the script name; with no `@` there is no metadata at all. After the last `@`, the shape is: +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. ``` name[@[.pii][.public]].cgs ``` -- `` is ASCII digits only, with no sign, and the value must fit the range `0` to `2147483647`. The site casts the value to a 32-bit signed integer, so anything larger is rejected. -- `.pii` declares that the script may read personal data. It requires an impersonation user, and the site checks that user against its own PII policy. -- `.public` declares that the script may run without a user being logged in. It too needs an impersonation user, and that user can't be `@0`. -- The canonical marker order is `.pii` then `.public`, so `.pii` sits closest to the id it scopes. Each marker may appear at most once. -- `@0` is a real value meaning "no impersonation user" (used for development mapping). It is not a synonym for leaving the `@` out. - -| File name | Script name | Impersonation | Public | PII | -|---|---|---|---|---| -| `Name.cgs` | `Name` | none | no | no | -| `Name@0.cgs` | `Name` | 0 | no | no | -| `Name@123.cgs` | `Name` | 123 | no | no | -| `Name@123.pii.cgs` | `Name` | 123 | no | yes | -| `Name@123.public.cgs` | `Name` | 123 | yes | no | -| `Name@123.pii.public.cgs` | `Name` | 123 | yes | yes | - -Script names may contain any character except control characters and the `"` character. Both break the generated C# wrapper, which embeds the name in a method name and in a string literal. Backslashes in a relative path are normalized to `/`. Whitespace is preserved and never trimmed. - -### Names that fail the deploy - -A malformed name fails loudly, with an error that names the file and the offending suffix, before any HTTP request is made. Both the deployer and the source generator (diagnostic `CGS028`, an error) apply the same grammar, so a bad name also fails the build of a project that uses the analyzer package. - -These shapes are rejected: - -| Rejected shape | Example | -|---|---| -| Unknown token after the id | `Name@123.secret.cgs` | -| Wrong marker order | `Name@123.public.pii.cgs` | -| Duplicate marker | `Name@123.pii.pii.cgs`, `Name@123.public.public.cgs` | -| Empty token | `Name@123..pii.cgs` | -| Non-numeric or signed id | `Name@abc.cgs`, `Name@-1.cgs` | -| Id above `2147483647` | `Name@2147483648.cgs` | -| A marker combined with `@0` | `Name@0.pii.cgs`, `Name@0.public.cgs` | -| A `.pii`/`.public` tail with no `@` | `Name.pii.cgs`, `Name.public.cgs` | -| A leaf that does not end in `.cgs` | `Name` | -| An empty script name | `@123.cgs`, `.cgs` | -| A control character in the name | `Line\nBreak@123.public.cgs` | - -### What changes for existing files - -File names that carry no metadata keep exactly the result they have today: `Name.cgs`, `Name@123.cgs`, `Name@123.public.cgs` and dotted names all behave as before. An `@` in the leaf is the exception: the last `@` in the leaf always starts the metadata suffix, so a script file whose name merely contains an `@`, such as `user@domain.cgs`, is read as metadata, is rejected and never deploys - rename it (for example to `user-domain.cgs`) to keep it. Four changes are accepted, and they are changes, not a promise that nothing moved: - -1. A malformed tail after the last `@` is now an error where it used to be folded into the script name (`Name@123.publc.cgs`, `Name@abc.cgs`, `Name@123.public.pii.cgs`). This makes **any** `@` in the final segment a metadata boundary. -2. A trailing `.public` or `.pii` with no `@` is now an error (`Name.public.cgs`, `Name.pii.cgs`). -3. `.public` or `.pii` combined with `@0` is now an error. Today such a file registers and then fails on the site with `412` or `403`. -4. A file named `Name@123.pii.cgs` now registers as script `Name` instead of the literal `Name@123.pii`. Treat this as a one-time rename: the next sync deletes the old resource on the site, and any external `new WorkflowScript()` reference to it breaks. If `Name.cgs` also exists in the same folder, the deployer reports a duplicate script identity naming both files instead of renaming silently. - -### Site prerequisites - -Two site-side conditions decide whether a `.pii` deploy actually grants PII access. The deployer cannot check either one for you. - -**The impersonated user must satisfy the site's workflow-PII policy.** The current rule is `TemplateEligible && !BuiltInAdmin && !AdminGroup`, which in plain words means the user must not be the built-in admin and must not belong to an admin group. A `.pii` file with no impersonation user, or with an impersonation user that the policy rejects, fails on the site with `403`: the script requests PII access but no impersonation is set, or the impersonation does not have PII. - -**The server must already know the `canAccessPII` member.** A site that predates it silently ignores the member, so a `.pii` deploy against such a site succeeds with PII turned off. Deploy your Catglobe site first, then the scripts. - -Deploying a PII script asks for no extra permission on your side. The deployer keeps requesting the same `scriptdeployment:w` scope as before; there is no separate PII scope to grant or request. - -### Development mode +`.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. A `.pii` or `.public` script run through development-generated code is treated as an ordinary script, exactly as `public` is ignored there today. +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 8168b68f..2f8cf2cf 100644 --- a/templates/cgscript-skill-template.md +++ b/templates/cgscript-skill-template.md @@ -115,8 +115,6 @@ int userId = p["userId"]; | 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 | -Rules for the file name: the metadata boundary is the last `@` in the file name, the leaf must end in `.cgs`, an id is ASCII digits in the range `0` to `2147483647`, and the marker order is `.pii` then `.public`. A `.pii` script must also declare an impersonation user, and that user must be PII-eligible on the site (not the built-in admin and not in an admin group). Anything else in the tail, such as `GetCompany@123.public.pii.cgs`, `GetCompany@0.pii.cgs` or `GetCompany.pii.cgs`, is an error that names the file. Do not name a script `*.public.cgs` or `*.pii.cgs` by accident: a trailing `.public`/`.pii` with no `@` is rejected as well. - ## How to create a new .cgs script 1. **Create the file** at `__SCRIPT_FOLDER__/YourPath/Name.cgs` From cb7d24217c912e37a07e1ae787cec305ca9169d1 Mon Sep 17 00:00:00 2001 From: Dennis Haney Date: Fri, 18 Sep 2026 15:24:34 +0700 Subject: [PATCH 8/9] docs: note that development mode honours neither marker Development-run scripts treat .pii and .public as ordinary names; the sentence states the rule without referring to past behaviour, keeping the security section terse. --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index ef9a6882..acd3c4e4 100644 --- a/README.md +++ b/README.md @@ -754,6 +754,8 @@ 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? From f256d0f5f0ff9213c0543cece08781e9b1a6ba1e Mon Sep 17 00:00:00 2001 From: Dennis Haney Date: Fri, 18 Sep 2026 15:45:39 +0700 Subject: [PATCH 9/9] build: collect coverage from the deployment tests The new test project now emits coverlet cobertura like the LSP test project, into its own subdirectory of test-results-output: both projects used coverlet default file name in the same directory, so a solution-wide run (what CI does) silently kept only one report. The workflow glob is recursive, so both reports now reach ReportGenerator. --- .../Catglobe.CgScript.Deployment.Tests.csproj | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Catglobe.CgScript.Deployment.Tests/Catglobe.CgScript.Deployment.Tests.csproj b/Catglobe.CgScript.Deployment.Tests/Catglobe.CgScript.Deployment.Tests.csproj index 46a338bf..aecc1436 100644 --- a/Catglobe.CgScript.Deployment.Tests/Catglobe.CgScript.Deployment.Tests.csproj +++ b/Catglobe.CgScript.Deployment.Tests/Catglobe.CgScript.Deployment.Tests.csproj @@ -7,9 +7,15 @@ preview true true + cobertura + $(MSBuildThisFileDirectory)../test-results-output/$(MSBuildProjectName)/ + + runtime; build; native; contentfiles; analyzers; buildtransitive + all +