C#: Generate source files from .cshtml files in standalone

This commit is contained in:
Tamas Vajk
2023-08-14 16:02:32 +02:00
parent ba0f07b66c
commit d391246f27
10 changed files with 356 additions and 84 deletions

View File

@@ -23,13 +23,14 @@ namespace Semmle.Extraction.CSharp.DependencyFetching
private readonly IDictionary<string, string> unresolvedReferences = new ConcurrentDictionary<string, string>();
private int failedProjects;
private int succeededProjects;
private readonly string[] allSources;
private readonly List<string> allSources;
private int conflictedReferences = 0;
private readonly IDependencyOptions options;
private readonly DirectoryInfo sourceDir;
private readonly DotNet dotnet;
private readonly FileContent fileContent;
private readonly TemporaryDirectory packageDirectory;
private readonly TemporaryDirectory? razorWorkingDirectory;
/// <summary>
@@ -60,7 +61,7 @@ namespace Semmle.Extraction.CSharp.DependencyFetching
packageDirectory = new TemporaryDirectory(ComputeTempDirectory(sourceDir.FullName));
this.fileContent = new FileContent(packageDirectory, progressMonitor, () => GetFiles("*.*"));
this.allSources = GetFiles("*.cs").ToArray();
this.allSources = GetFiles("*.cs").ToList();
var allProjects = GetFiles("*.csproj");
var solutions = options.SolutionFile is not null
? new[] { options.SolutionFile }
@@ -131,6 +132,32 @@ namespace Semmle.Extraction.CSharp.DependencyFetching
progressMonitor.UnresolvedReference(r.Key, r.Value);
}
var views = GetFiles("*.cshtml")
.Concat(GetFiles("*.razor"))
.ToArray();
if (views.Length > 0)
{
// TODO: use SDK specified in global.json
// TODO: add feature flag to control razor generation
var sdk = new Sdk(dotnet).GetNewestSdk();
if (sdk != null)
{
try
{
var razor = new Razor(sdk, dotnet, progressMonitor);
razorWorkingDirectory = new TemporaryDirectory(ComputeTempDirectory(sourceDir.FullName, "razor"));
var generatedFiles = razor.GenerateFiles(views, usedReferences.Keys, razorWorkingDirectory.ToString());
this.allSources.AddRange(generatedFiles);
}
catch (Exception ex)
{
// It's okay, we tried our best to generate source files from cshtml files.
progressMonitor.LogInfo($"Failed to generate source files from cshtml files: {ex.Message}");
}
}
}
progressMonitor.Summary(
AllSourceFiles.Count(),
ProjectSourceFiles.Count(),
@@ -156,9 +183,8 @@ namespace Semmle.Extraction.CSharp.DependencyFetching
/// Computes a unique temp directory for the packages associated
/// with this source tree. Use a SHA1 of the directory name.
/// </summary>
/// <param name="srcDir"></param>
/// <returns>The full path of the temp directory.</returns>
private static string ComputeTempDirectory(string srcDir)
private static string ComputeTempDirectory(string srcDir, string subfolderName = "packages")
{
var bytes = Encoding.Unicode.GetBytes(srcDir);
var sha = SHA1.HashData(bytes);
@@ -166,7 +192,7 @@ namespace Semmle.Extraction.CSharp.DependencyFetching
foreach (var b in sha.Take(8))
sb.AppendFormat("{0:x2}", b);
return Path.Combine(Path.GetTempPath(), "GitHub", "packages", sb.ToString());
return Path.Combine(Path.GetTempPath(), "GitHub", subfolderName, sb.ToString());
}
/// <summary>
@@ -392,6 +418,10 @@ namespace Semmle.Extraction.CSharp.DependencyFetching
});
}
public void Dispose() => packageDirectory?.Dispose();
public void Dispose()
{
packageDirectory?.Dispose();
razorWorkingDirectory?.Dispose();
}
}
}

View File

@@ -5,14 +5,6 @@ using Semmle.Util;
namespace Semmle.Extraction.CSharp.DependencyFetching
{
internal interface IDotNet
{
bool RestoreToDirectory(string project, string directory, string? pathToNugetConfig = null);
bool New(string folder);
bool AddPackage(string folder, string package);
IList<string> GetListedRuntimes();
}
/// <summary>
/// Utilities to run the "dotnet" command.
/// </summary>
@@ -76,23 +68,33 @@ namespace Semmle.Extraction.CSharp.DependencyFetching
return RunCommand(args);
}
public IList<string> GetListedRuntimes()
public IList<string> GetListedRuntimes() => GetListed("--list-runtimes", "runtime");
public IList<string> GetListedSdks() => GetListed("--list-sdks", "SDK");
private IList<string> GetListed(string args, string artifact)
{
const string args = "--list-runtimes";
progressMonitor.RunningProcess($"{dotnet} {args}");
var pi = new ProcessStartInfo(dotnet, args)
{
RedirectStandardOutput = true,
UseShellExecute = false
};
var exitCode = pi.ReadOutput(out var runtimes);
var exitCode = pi.ReadOutput(out var artifacts);
if (exitCode != 0)
{
progressMonitor.CommandFailed(dotnet, args, exitCode);
return new List<string>();
}
progressMonitor.LogInfo($"Found runtimes: {string.Join("\n", runtimes)}");
return runtimes;
progressMonitor.LogInfo($"Found {artifact}s: {string.Join("\n", artifacts)}");
return artifacts;
}
public bool Exec(string execArgs)
{
// TODO: we might need to swallow the stdout of the started process to not pollute the logs of the extraction.
var args = $"exec {execArgs}";
return RunCommand(args);
}
}
}

View File

@@ -108,5 +108,11 @@ namespace Semmle.Extraction.CSharp.DependencyFetching
internal void NoTopLevelNugetConfig() =>
LogInfo("Could not find a top-level nuget.config file.");
internal void RazorSourceGeneratorMissing(string fullPath) =>
LogInfo($"Razor source generator folder {fullPath} does not exist.");
internal void CscMissing(string cscPath) =>
LogInfo($"Csc.exe not found at {cscPath}.");
}
}

View File

@@ -17,8 +17,8 @@ namespace Semmle.Extraction.CSharp.DependencyFetching
private const string aspNetCoreApp = "Microsoft.AspNetCore.App";
private readonly IDotNet dotNet;
private readonly Lazy<Dictionary<string, RuntimeVersion>> newestRuntimes;
private Dictionary<string, RuntimeVersion> NewestRuntimes => newestRuntimes.Value;
private readonly Lazy<Dictionary<string, DotnetVersion>> newestRuntimes;
private Dictionary<string, DotnetVersion> NewestRuntimes => newestRuntimes.Value;
private static string ExecutingRuntime => RuntimeEnvironment.GetRuntimeDirectory();
public Runtime(IDotNet dotNet)
@@ -27,58 +27,6 @@ namespace Semmle.Extraction.CSharp.DependencyFetching
this.newestRuntimes = new(GetNewestRuntimes);
}
internal record RuntimeVersion : IComparable<RuntimeVersion>
{
private readonly string dir;
private readonly Version version;
private readonly Version? preReleaseVersion;
private readonly string? preReleaseVersionType;
private bool IsPreRelease => preReleaseVersionType is not null && preReleaseVersion is not null;
public string FullPath
{
get
{
var preRelease = IsPreRelease ? $"-{preReleaseVersionType}.{preReleaseVersion}" : "";
var version = this.version + preRelease;
return Path.Combine(dir, version);
}
}
public RuntimeVersion(string dir, string version, string preReleaseVersionType, string preReleaseVersion)
{
this.dir = dir;
this.version = Version.Parse(version);
if (!string.IsNullOrEmpty(preReleaseVersion) && !string.IsNullOrEmpty(preReleaseVersionType))
{
this.preReleaseVersionType = preReleaseVersionType;
this.preReleaseVersion = Version.Parse(preReleaseVersion);
}
}
public int CompareTo(RuntimeVersion? other)
{
var c = version.CompareTo(other?.version);
if (c == 0 && IsPreRelease)
{
if (!other!.IsPreRelease)
{
return -1;
}
// Both are pre-release like runtime versions.
// The pre-release version types are sorted alphabetically (e.g. alpha, beta, preview, rc)
// and the pre-release version types are more important that the pre-release version numbers.
return preReleaseVersionType != other!.preReleaseVersionType
? preReleaseVersionType!.CompareTo(other!.preReleaseVersionType)
: preReleaseVersion!.CompareTo(other!.preReleaseVersion);
}
return c;
}
public override string ToString() => FullPath;
}
[GeneratedRegex(@"^(\S+)\s(\d+\.\d+\.\d+)(-([a-z]+)\.(\d+\.\d+\.\d+))?\s\[(.+)\]$")]
private static partial Regex RuntimeRegex();
@@ -88,16 +36,17 @@ namespace Semmle.Extraction.CSharp.DependencyFetching
/// It is assume that the format of a listed runtime is something like:
/// Microsoft.NETCore.App 7.0.2 [/usr/share/dotnet/shared/Microsoft.NETCore.App]
/// </summary>
private static Dictionary<string, RuntimeVersion> ParseRuntimes(IList<string> listed)
private static Dictionary<string, DotnetVersion> ParseRuntimes(IList<string> listed)
{
// Parse listed runtimes.
var runtimes = new Dictionary<string, RuntimeVersion>();
var runtimes = new Dictionary<string, DotnetVersion>();
var regex = RuntimeRegex();
listed.ForEach(r =>
{
var match = RuntimeRegex().Match(r);
var match = regex.Match(r);
if (match.Success)
{
runtimes.AddOrUpdateToLatest(match.Groups[1].Value, new RuntimeVersion(match.Groups[6].Value, match.Groups[2].Value, match.Groups[4].Value, match.Groups[5].Value));
runtimes.AddOrUpdateToLatest(match.Groups[1].Value, new DotnetVersion(match.Groups[6].Value, match.Groups[2].Value, match.Groups[4].Value, match.Groups[5].Value));
}
});
@@ -107,7 +56,7 @@ namespace Semmle.Extraction.CSharp.DependencyFetching
/// <summary>
/// Returns a dictionary mapping runtimes to their newest version.
/// </summary>
internal Dictionary<string, RuntimeVersion> GetNewestRuntimes()
internal Dictionary<string, DotnetVersion> GetNewestRuntimes()
{
var listed = dotNet.GetListedRuntimes();
return ParseRuntimes(listed);

View File

@@ -0,0 +1,57 @@
using System;
using System.IO;
namespace Semmle.Extraction.CSharp.Standalone
{
internal record DotnetVersion : IComparable<DotnetVersion>
{
private readonly string dir;
private readonly Version version;
private readonly Version? preReleaseVersion;
private readonly string? preReleaseVersionType;
private bool IsPreRelease => preReleaseVersionType is not null && preReleaseVersion is not null;
public string FullPath
{
get
{
var preRelease = IsPreRelease ? $"-{preReleaseVersionType}.{preReleaseVersion}" : "";
var version = this.version + preRelease;
return Path.Combine(dir, version);
}
}
public DotnetVersion(string dir, string version, string preReleaseVersionType, string preReleaseVersion)
{
this.dir = dir;
this.version = Version.Parse(version);
if (!string.IsNullOrEmpty(preReleaseVersion) && !string.IsNullOrEmpty(preReleaseVersionType))
{
this.preReleaseVersionType = preReleaseVersionType;
this.preReleaseVersion = Version.Parse(preReleaseVersion);
}
}
public int CompareTo(DotnetVersion? other)
{
var c = version.CompareTo(other?.version);
if (c == 0 && IsPreRelease)
{
if (!other!.IsPreRelease)
{
return -1;
}
// Both are pre-release like runtime versions.
// The pre-release version types are sorted alphabetically (e.g. alpha, beta, preview, rc)
// and the pre-release version types are more important that the pre-release version numbers.
return preReleaseVersionType != other!.preReleaseVersionType
? preReleaseVersionType!.CompareTo(other!.preReleaseVersionType)
: preReleaseVersion!.CompareTo(other!.preReleaseVersion);
}
return c;
}
public override string ToString() => FullPath;
}
}

View File

@@ -0,0 +1,14 @@
using System.Collections.Generic;
namespace Semmle.BuildAnalyser
{
internal interface IDotNet
{
bool RestoreToDirectory(string project, string directory, string? pathToNugetConfig = null);
bool New(string folder);
bool AddPackage(string folder, string package);
IList<string> GetListedRuntimes();
IList<string> GetListedSdks();
bool Exec(string execArgs);
}
}

View File

@@ -0,0 +1,108 @@
using System;
using System.Collections.Generic;
using System.IO;
using Semmle.BuildAnalyser;
using Semmle.Util;
using System.Text;
namespace Semmle.Extraction.CSharp.Standalone
{
internal class Razor
{
private readonly DotnetVersion sdk;
private readonly ProgressMonitor progressMonitor;
private readonly DotNet dotNet;
private readonly string sourceGeneratorFolder;
private readonly string cscPath;
public Razor(DotnetVersion sdk, DotNet dotNet, ProgressMonitor progressMonitor)
{
this.sdk = sdk;
this.progressMonitor = progressMonitor;
this.dotNet = dotNet;
sourceGeneratorFolder = Path.Combine(this.sdk.FullPath, "Sdks/Microsoft.NET.Sdk.Razor/source-generators");
if (!Directory.Exists(sourceGeneratorFolder))
{
this.progressMonitor.RazorSourceGeneratorMissing(sourceGeneratorFolder);
throw new Exception($"Razor source generator folder {sourceGeneratorFolder} does not exist.");
}
cscPath = Path.Combine(this.sdk.FullPath, "Roslyn/bincore/csc.dll");
if (!File.Exists(cscPath))
{
this.progressMonitor.CscMissing(cscPath);
throw new Exception($"csc.dll {cscPath} does not exist.");
}
}
private static void GenerateAnalyzerConfig(IEnumerable<string> cshtmls, string analyzerConfigPath)
{
using var sw = new StreamWriter(analyzerConfigPath);
sw.WriteLine("is_global = true");
foreach (var f in cshtmls)
{
sw.WriteLine($"\n[{f}]");
var base64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(f)); // TODO: this should be the relative path of the file.
sw.WriteLine($"build_metadata.AdditionalFiles.TargetPath = {base64}");
}
}
public IEnumerable<string> GenerateFiles(IEnumerable<string> cshtmls, IEnumerable<string> references, string workingDirectory)
{
// TODO: the below command might be too long. It should be written to a temp file and passed to csc via @.
var name = Guid.NewGuid().ToString("N").ToUpper();
var analyzerConfig = Path.Combine(Path.GetTempPath(), name + ".txt");
var dllPath = Path.Combine(Path.GetTempPath(), name + ".dll");
var outputFolder = Path.Combine(workingDirectory, name);
Directory.CreateDirectory(outputFolder);
try
{
GenerateAnalyzerConfig(cshtmls, Path.Combine(sourceGeneratorFolder, analyzerConfig));
var args = new StringBuilder();
args.Append($"\"{cscPath}\" /target:exe /generatedfilesout:\"{outputFolder}\" /out:\"{dllPath}\" /analyzerconfig:\"{analyzerConfig}\" ");
// TODO: quote paths:
foreach (var f in Directory.GetFiles(sourceGeneratorFolder, "*.dll"))
{
args.Append($"/analyzer:\"{f}\" ");
}
foreach (var f in cshtmls)
{
args.Append($"/additionalfile:\"{f}\" ");
}
foreach (var f in references)
{
args.Append($"/reference:\"{f}\" ");
}
dotNet.Exec(args.ToString());
return Directory.GetFiles(outputFolder, "*.*", new EnumerationOptions { RecurseSubdirectories = true });
}
finally
{
DeleteFile(analyzerConfig);
DeleteFile(dllPath);
}
}
private static void DeleteFile(string path)
{
try
{
File.Delete(path);
}
catch
{
// Ignore
}
}
}
}

View File

@@ -0,0 +1,41 @@
using System.Collections.Generic;
using Semmle.BuildAnalyser;
using Semmle.Util;
using System.Text.RegularExpressions;
using System.Linq;
namespace Semmle.Extraction.CSharp.Standalone
{
internal partial class Sdk
{
private readonly IDotNet dotNet;
public Sdk(IDotNet dotNet) => this.dotNet = dotNet;
[GeneratedRegex(@"^(\d+\.\d+\.\d+)(-([a-z]+)\.(\d+\.\d+\.\d+))?\s\[(.+)\]$")]
private static partial Regex SdkRegex();
private static HashSet<DotnetVersion> ParseSdks(IList<string> listed)
{
var sdks = new HashSet<DotnetVersion>();
var regex = SdkRegex();
listed.ForEach(r =>
{
var match = regex.Match(r);
if (match.Success)
{
sdks.Add(new DotnetVersion(match.Groups[5].Value, match.Groups[1].Value, match.Groups[3].Value, match.Groups[4].Value));
}
});
return sdks;
}
public DotnetVersion? GetNewestSdk()
{
var listed = dotNet.GetListedSdks();
var sdks = ParseSdks(listed);
return sdks.OrderByDescending(s => s).FirstOrDefault();
}
}
}

View File

@@ -7,9 +7,13 @@ namespace Semmle.Extraction.Tests
internal class DotNetStub : IDotNet
{
private readonly IList<string> runtimes;
private readonly IList<string> sdks;
public DotNetStub(IList<string> runtimes) => this.runtimes = runtimes;
public DotNetStub(IList<string> runtimes, IList<string> sdks)
{
this.runtimes = runtimes;
this.sdks = sdks;
}
public bool AddPackage(string folder, string package) => true;
public bool New(string folder) => true;
@@ -17,6 +21,10 @@ namespace Semmle.Extraction.Tests
public bool RestoreToDirectory(string project, string directory, string? pathToNugetConfig = null) => true;
public IList<string> GetListedRuntimes() => runtimes;
public IList<string> GetListedSdks() => sdks;
public bool Exec(string execArgs) => true;
}
public class RuntimeTests
@@ -37,7 +45,7 @@ namespace Semmle.Extraction.Tests
"Microsoft.NETCore.App 7.0.0 [/path/dotnet/shared/Microsoft.NETCore.App]",
"Microsoft.NETCore.App 7.0.2 [/path/dotnet/shared/Microsoft.NETCore.App]"
};
var dotnet = new DotNetStub(listedRuntimes);
var dotnet = new DotNetStub(listedRuntimes, null!);
var runtime = new Runtime(dotnet);
// Execute
@@ -63,7 +71,7 @@ namespace Semmle.Extraction.Tests
"Microsoft.NETCore.App 8.0.0-preview.5.43280.8 [/path/dotnet/shared/Microsoft.NETCore.App]",
"Microsoft.NETCore.App 8.0.0-preview.5.23280.8 [/path/dotnet/shared/Microsoft.NETCore.App]"
};
var dotnet = new DotNetStub(listedRuntimes);
var dotnet = new DotNetStub(listedRuntimes, null!);
var runtime = new Runtime(dotnet);
// Execute
@@ -86,7 +94,7 @@ namespace Semmle.Extraction.Tests
"Microsoft.NETCore.App 8.0.0-rc.4.43280.8 [/path/dotnet/shared/Microsoft.NETCore.App]",
"Microsoft.NETCore.App 8.0.0-preview.5.23280.8 [/path/dotnet/shared/Microsoft.NETCore.App]"
};
var dotnet = new DotNetStub(listedRuntimes);
var dotnet = new DotNetStub(listedRuntimes, null!);
var runtime = new Runtime(dotnet);
// Execute
@@ -115,7 +123,7 @@ namespace Semmle.Extraction.Tests
@"Microsoft.WindowsDesktop.App 6.0.20 [C:\Program Files\dotnet\shared\Microsoft.WindowsDesktop.App]",
@"Microsoft.WindowsDesktop.App 7.0.4 [C:\Program Files\dotnet\shared\Microsoft.WindowsDesktop.App]"
};
var dotnet = new DotNetStub(listedRuntimes);
var dotnet = new DotNetStub(listedRuntimes, null!);
var runtime = new Runtime(dotnet);
// Execute
@@ -131,4 +139,56 @@ namespace Semmle.Extraction.Tests
Assert.Equal(@"C:/Program Files/dotnet/shared/Microsoft.NETCore.App/7.0.2", FixExpectedPathOnWindows(netCoreApp.FullPath));
}
}
public class SdkTests
{
private static string FixExpectedPathOnWindows(string path) => path.Replace('\\', '/');
[Fact]
public void TestSdk1()
{
// Setup
var listedSdks = new List<string>
{
"6.0.413 [/usr/local/share/dotnet/sdk1]",
"7.0.102 [/usr/local/share/dotnet/sdk2]",
"7.0.302 [/usr/local/share/dotnet/sdk3]",
"7.0.400 [/usr/local/share/dotnet/sdk4]",
"5.0.402 [/usr/local/share/dotnet/sdk5]",
"6.0.102 [/usr/local/share/dotnet/sdk6]",
"6.0.301 [/usr/local/share/dotnet/sdk7]",
};
var dotnet = new DotNetStub(null!, listedSdks);
var sdk = new Sdk(dotnet);
// Execute
var version = sdk.GetNewestSdk();
// Verify
Assert.NotNull(version);
Assert.Equal("/usr/local/share/dotnet/sdk4/7.0.400", FixExpectedPathOnWindows(version.FullPath));
}
[Fact]
public void TestSdk2()
{
// Setup
var listedSdks = new List<string>
{
"6.0.413 [/usr/local/share/dotnet/sdk1]",
"7.0.102 [/usr/local/share/dotnet/sdk2]",
"8.0.100-preview.7.23376.3 [/usr/local/share/dotnet/sdk3]",
"7.0.400 [/usr/local/share/dotnet/sdk4]",
};
var dotnet = new DotNetStub(null!, listedSdks);
var sdk = new Sdk(dotnet);
// Execute
var version = sdk.GetNewestSdk();
// Verify
Assert.NotNull(version);
Assert.Equal("/usr/local/share/dotnet/sdk3/8.0.100-preview.7.23376.3", FixExpectedPathOnWindows(version.FullPath));
}
}
}

View File

@@ -150,6 +150,8 @@ function RegisterExtractorPack(id)
end
local windowsMatchers = {
CreatePatternMatcher({ '^semmle%.extraction%.csharp%.standalone%.exe$' },
MatchCompilerName, nil, { trace = false }),
DotnetMatcherBuild,
MsBuildMatcher,
CreatePatternMatcher({ '^csc.*%.exe$' }, MatchCompilerName, extractor, {
@@ -191,6 +193,9 @@ function RegisterExtractorPack(id)
end
}
local posixMatchers = {
-- The compiler name is case sensitive on Linux and lower cased on MacOS
CreatePatternMatcher({ '^semmle%.extraction%.csharp%.standalone$', '^Semmle%.Extraction%.CSharp%.Standalone$' },
MatchCompilerName, nil, { trace = false }),
DotnetMatcherBuild,
CreatePatternMatcher({ '^mcs%.exe$', '^csc%.exe$' }, MatchCompilerName,
extractor, {