← All source files

V18 / 8d896b8a86e5

AddonPackage.cs

Lilith/Core/Addons/AddonPackage.cs

Changed in this version
CS
using System.Diagnostics;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Xml.Linq;
using Lilith.Core.Scenarios;
using Lilith.Core.Tools;
using Lilith.Core.Workspace;
using Lilith.Core.Workspace.Extensions;

namespace Lilith.Core.Addons;

public sealed record AddonPackage(string Id,string Name,string Request,string ToolName,string ToolSource,string ScenarioXml,string Review,string Status,string? AssemblyHash=null)
{
    public string SourceSignature()=>AddonStore.Signature(this);
}

/// <summary>Exact source approvals and framework-only builds. Generated code is trusted code, not sandboxed.</summary>
internal sealed class AddonStore(WorkspaceSession workspace)
{
    internal string Root => workspace.Options.ResolvePath("Addons");
    internal string Folder(string id)
    {
        if(!Guid.TryParseExact(id,"N",out _))throw new ArgumentException("Invalid addon ID.");
        return workspace.Options.ResolvePath("Addons/"+id);
    }
    internal static readonly JsonSerializerOptions Json=new(){WriteIndented=true};
    internal static string Signature(AddonPackage p)=>Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(p with{Status="",AssemblyHash=null},Json))));
    internal AddonPackage Load(string id)
    {
        string path=Path.Combine(Folder(id),"package.json");
        path.EnsureNoLinks();
        if(new FileInfo(path).Length>400000)throw new InvalidDataException("Oversized addon manifest.");
        var p=JsonSerializer.Deserialize<AddonPackage>(File.ReadAllText(path),Json)??throw new InvalidDataException("Missing addon manifest.");
        if(p.Id!=id)throw new InvalidDataException("Addon ID mismatch.");
        Validate(p);
        return p;
    }
    internal void Save(AddonPackage p)
    {
        string folder=Folder(p.Id);Directory.CreateDirectory(folder);
        string temp=Path.Combine(folder,"package.tmp");
        temp.EnsureNoLinks();Path.Combine(folder,"package.json").EnsureNoLinks();
        File.WriteAllText(temp,JsonSerializer.Serialize(p,Json));File.Move(temp,Path.Combine(folder,"package.json"),true);
    }
    internal static void Validate(AddonPackage p)
    {
        if(!ScenarioXmlParser.ValidName(p.Name)||!ScenarioXmlParser.ValidName(p.ToolName)||p.ToolSource.Length is <10 or >100000||p.ScenarioXml.Length>100000)throw new InvalidDataException("Invalid addon source contract.");
        var scenario=ScenarioXmlParser.Parse(p.ScenarioXml);
        if(scenario.Id!=p.Name||scenario.ExitMode!="copy"||scenario.CodeTools.Length!=0||!scenario.AllowedTools.Contains(p.ToolName))throw new InvalidDataException("Addon XML must have matching ID, exact-copy exit and declare its separately compiled tool; embedded C# is forbidden.");
    }
    internal IEnumerable<AddonPackage> List()=>Directory.Exists(Root)?Directory.GetDirectories(Root).Select(Path.GetFileName).OfType<string>().Where(n=>Guid.TryParseExact(n,"N",out _)).Select(Load):[];
    internal async Task<string> Build(AddonPackage p,CancellationToken ct)
    {
        Validate(p);
        string folder=Path.Combine(Folder(p.Id),"Build");folder.EnsureNoLinks();Directory.CreateDirectory(folder);
        foreach(string file in new[]{"Addon.csproj","Tool.cs","NuGet.Config","build.log"})Path.Combine(folder,file).EnsureNoLinks();
        string core=typeof(ILilithTool).Assembly.Location;
        var projectXml=new XDocument(new XElement("Project",new XAttribute("Sdk","Microsoft.NET.Sdk"),
            new XElement("PropertyGroup",new XElement("TargetFramework",$"net{Environment.Version.Major}.0"),new XElement("AssemblyName","CraftedAddon"),new XElement("ImplicitUsings","enable"),new XElement("Nullable","enable"),new XElement("EnableDefaultCompileItems","false"),new XElement("EnableNETAnalyzers","false")),
            new XElement("ItemGroup",new XElement("Using",new XAttribute("Include","Lilith.Core.Tools")),new XElement("Using",new XAttribute("Include","Lilith.Core.Addons")),new XElement("Compile",new XAttribute("Include","Tool.cs")),new XElement("Reference",new XAttribute("Include","Core"),new XElement("HintPath",core),new XElement("Private","false")))));
        string projectText=projectXml.ToString();await File.WriteAllTextAsync(Path.Combine(folder,"Addon.csproj"),projectText,ct);
        await File.WriteAllTextAsync(Path.Combine(folder,"Tool.cs"),p.ToolSource,ct);
        const string config="<configuration><packageSources><clear /></packageSources></configuration>";
        await File.WriteAllTextAsync(Path.Combine(folder,"NuGet.Config"),config,ct);
        using var sourceLease=Lease(Path.Combine(folder,"Tool.cs"),p.ToolSource);
        using var projectLease=Lease(Path.Combine(folder,"Addon.csproj"),projectText);
        using var configLease=Lease(Path.Combine(folder,"NuGet.Config"),config);
        var start=new ProcessStartInfo("dotnet"){WorkingDirectory=folder,UseShellExecute=false,CreateNoWindow=true,RedirectStandardOutput=true,RedirectStandardError=true};
        foreach(string a in new[]{"build","Addon.csproj","-c","Release","--nologo","--disable-build-servers","-p:ImportDirectoryBuildProps=false","-p:ImportDirectoryBuildTargets=false","-v","quiet"})start.ArgumentList.Add(a);
        using var process=Process.Start(start)??throw new IOException("Cannot start addon compiler.");
        var output=process.StandardOutput.ReadToEndAsync();var error=process.StandardError.ReadToEndAsync();
        using var deadline=CancellationTokenSource.CreateLinkedTokenSource(ct);deadline.CancelAfter(TimeSpan.FromMinutes(2));
        try{await process.WaitForExitAsync(deadline.Token);}catch{if(!process.HasExited)process.Kill(true);await process.WaitForExitAsync();await Task.WhenAll(output,error);throw;}
        string diagnostic=await output+await error;await File.WriteAllTextAsync(Path.Combine(Folder(p.Id),"build.log"),diagnostic,ct);
        if(process.ExitCode!=0)throw new InvalidDataException("Addon compilation failed: "+diagnostic[..Math.Min(6000,diagnostic.Length)]);
        string dll=Path.Combine(folder,"bin","Release",$"net{Environment.Version.Major}.0","CraftedAddon.dll");dll.EnsureNoLinks();return dll;
    }
    private static FileStream Lease(string file,string expected)
    {
        var stream=new FileStream(file,FileMode.Open,FileAccess.Read,FileShare.Read);
        try { using var reader=new StreamReader(stream,Encoding.UTF8,true,1024,leaveOpen:true);if(reader.ReadToEnd()!=expected)throw new IOException("Addon compiler input changed before it could be locked.");return stream; }
        catch { stream.Dispose();throw; }
    }
}