Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions Catglobe.CgScript.Common/IScriptDefinition.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,11 @@ public interface IScriptDefinition
/// If true, the script can be run without a user being logged in
/// </summary>
bool AllowExecuteWithoutLogin { get; }
/// <summary>
/// Maps to the workflow PII flag of the site, declaring that the script may read personal data.
/// It is only honored together with <see cref="Impersonation"/>: 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.
/// </summary>
bool CanAccessPII => false;
}
18 changes: 18 additions & 0 deletions Catglobe.CgScript.Common/InvalidScriptFileNameException.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
using System;

namespace Catglobe.CgScript.Common;

/// <summary>A script file path does not follow the script naming grammar.</summary>
public class InvalidScriptFileNameException : Exception
{
/// <summary>Creates an error identifying the file and the reason its name is invalid.</summary>
/// <param name="fileName">The original relative file path.</param>
/// <param name="message">The failure reason, including the offending suffix and expected shape.</param>
public InvalidScriptFileNameException(string fileName, string message) : base(message)
{
FileName = fileName;
}

/// <summary>The original relative path that failed parsing.</summary>
public string FileName { get; }
}
25 changes: 25 additions & 0 deletions Catglobe.CgScript.Common/ScriptFileName.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
namespace Catglobe.CgScript.Common;

/// <summary>A script identity and the security metadata declared in its file name.</summary>
public sealed class ScriptFileName
{
internal ScriptFileName(string scriptName, uint? impersonation, bool allowExecuteWithoutLogin, bool canAccessPII)
{
ScriptName = scriptName;
Impersonation = impersonation;
AllowExecuteWithoutLogin = allowExecuteWithoutLogin;
CanAccessPII = canAccessPII;
}

/// <summary>The relative script name without metadata or extension, with forward-slash separators.</summary>
public string ScriptName { get; }

/// <summary>The declared user id; null means absent, while zero explicitly means no impersonation user.</summary>
public uint? Impersonation { get; }

/// <summary>Whether the script may execute without login; requires a nonzero impersonation user.</summary>
public bool AllowExecuteWithoutLogin { get; }

/// <summary>Whether the script requests PII access; requires a nonzero, site-eligible impersonation user.</summary>
public bool CanAccessPII { get; }
}
92 changes: 92 additions & 0 deletions Catglobe.CgScript.Common/ScriptFileNameParser.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
using System;

namespace Catglobe.CgScript.Common;

/// <summary>Parses security metadata from the leaf of a relative CgScript file path.</summary>
public static class ScriptFileNameParser
{
/// <summary>Parses a path shaped as name[@&lt;userId&gt;[.pii][.public]].cgs, preserving whitespace.</summary>
/// <exception cref="InvalidScriptFileNameException">The file name does not follow the naming grammar.</exception>
public static ScriptFileName Parse(string fileName)
{
if (TryParse(fileName, out var result, out var reason))
return result!;
throw new InvalidScriptFileNameException(fileName, reason!);
}

/// <summary>Attempts to parse a relative file path, returning a reason on failure.</summary>
/// <param name="fileName">The relative path, including its .cgs extension.</param>
/// <param name="result">The parsed name and metadata, or null on failure.</param>
/// <param name="reason">The failure reason, or null on success.</param>
/// <returns>Whether the file name follows the naming grammar.</returns>
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[@<userId>[.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;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>preview</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<IsTestProject>true</IsTestProject>
<CoverletOutputFormat>cobertura</CoverletOutputFormat>
<CoverletOutput>$(MSBuildThisFileDirectory)../test-results-output/$(MSBuildProjectName)/</CoverletOutput>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="coverlet.msbuild" Version="6.0.4">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="5.6.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.7.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\Catglobe.CgScript.Common\Catglobe.CgScript.Common.csproj" />
<ProjectReference Include="..\Catglobe.CgScript.Deployment\Catglobe.CgScript.Deployment.csproj" />
<ProjectReference Include="..\Catglobe.CgScript.EditorSupport.SourceGenerator\Catglobe.CgScript.EditorSupport.SourceGenerator.csproj" Aliases="generator" />
</ItemGroup>

</Project>
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Drives <see cref="CgScriptWrapperGenerator"/> with a Roslyn <see cref="CSharpGeneratorDriver"/>
/// and asserts that the wrapper name is derived through the shared filename grammar.
/// </summary>
public class CgScriptWrapperGeneratorFileNameTests
{
/// <summary>A minimal [CgScriptSerializer] context plus a stub of the marker attribute.</summary>
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 { }
}
""";

/// <summary>A minimal script whose parameters parse without any annotation diagnostics.</summary>
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[@<userId>[.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;
}
}
Loading
Loading