Skip to content
Draft
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
5 changes: 4 additions & 1 deletion .gitattributes
Original file line number Diff line number Diff line change
@@ -1,2 +1,5 @@
src/UniGetUI.PackageEngine.Managers.Chocolatey/choco-cli/** linguist-vendored
.githooks/* text eol=lf
.githooks/* text eol=lf

policies/samples/** linguist-generated
policies/schemas/** linguist-generated
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -96,3 +96,7 @@ src/UniGetUI.v3.ncrunchsolution
# macOS Finder metadata
.DS_Store
/src/UniGetUI.Avalonia/Generated Files


policies/csharp/.vs/*
src/UniGetUI.PackageEngine.Managers.WinGet
2 changes: 1 addition & 1 deletion global.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"sdk": {
"version": "10.0.103",
"rollForward": "latestPatch"
"rollForward": "latestFeature"
}
}
327 changes: 327 additions & 0 deletions policies/README.md

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions policies/csharp/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
bin/
obj/
**/bin/
**/obj/
58 changes: 58 additions & 0 deletions policies/csharp/UniGetUI.PolicySimulator.Client/Program.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
using System.Net;
using System.Net.Http.Headers;
using UniGetUI.PolicySimulator.Core;

var parsedArgs = ArgumentParser.Parse(args);
var requestPath = parsedArgs.GetValueOrDefault("request") ?? throw new ArgumentException("Missing required --request <path> argument.");
var server = parsedArgs.GetValueOrDefault("server") ?? "http://127.0.0.1:8765";
var endpoint = parsedArgs.GetValueOrDefault("endpoint") ?? "/v1/package-operations/evaluate";
var asJson = parsedArgs.ContainsKey("json");

var fullRequestPath = PolicyPathResolver.ResolveExistingPath(requestPath);
var requestText = await File.ReadAllTextAsync(fullRequestPath);
var format = DocumentLoader.InferFormatFromPath(fullRequestPath);
var contentType = format == "yaml" ? "application/x-yaml" : "application/vnd.unigetui.package-request+json; version=1.0";

using var client = new HttpClient();
client.DefaultRequestHeaders.Accept.Add(MediaTypeWithQualityHeaderValue.Parse("application/vnd.unigetui.package-broker-response+json; version=1.0"));
client.DefaultRequestHeaders.Add("UniGetUI-Protocol-Version", "1.0");
using var content = new StringContent(requestText);
content.Headers.ContentType = MediaTypeHeaderValue.Parse(contentType);

var response = await client.PostAsync(new Uri(new Uri(server), endpoint), content);
var responseText = await response.Content.ReadAsStringAsync();

if (asJson)
{
Console.WriteLine(responseText);
}
else
{
Console.WriteLine($"HTTP {(int)response.StatusCode} {response.StatusCode}");
Console.WriteLine(responseText);
}

return response.StatusCode is HttpStatusCode.OK or HttpStatusCode.Forbidden or HttpStatusCode.BadRequest or HttpStatusCode.UnprocessableEntity ? 0 : 1;

internal static class ArgumentParser
{
public static Dictionary<string, string> Parse(string[] args)
{
var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
for (var index = 0; index < args.Length; index++)
{
var current = args[index];
if (!current.StartsWith("--", StringComparison.Ordinal)) continue;
var key = current[2..];
if (index + 1 >= args.Length || args[index + 1].StartsWith("--", StringComparison.Ordinal))
{
result[key] = "true";
continue;
}

result[key] = args[++index];
}

return result;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../UniGetUI.PolicySimulator.Core/UniGetUI.PolicySimulator.Core.csproj" />
</ItemGroup>
</Project>
42 changes: 42 additions & 0 deletions policies/csharp/UniGetUI.PolicySimulator.Core/BrokerSimulator.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
namespace UniGetUI.PolicySimulator.Core;

public sealed class BrokerSimulator(PolicyDocument policy)
{
private readonly PolicyEvaluator _evaluator = new();

public BrokerEvaluationResponse Evaluate(PackageRequest request)
{
try
{
var decision = _evaluator.Evaluate(policy, request);
var command = decision.Decision == "allow" ? CommandLineBuilder.Build(request) : [];
return new BrokerEvaluationResponse(
request.RequestId,
request.Manager.Name,
request.Source.Name,
request.Package.Id,
request.Operation,
decision.Decision,
decision.RuleId,
decision.Reason,
decision.Decision == "allow",
command,
"simulated-elevated");
}
catch (Exception exception) when (exception is PolicyValidationException or InvalidOperationException)
{
return new BrokerEvaluationResponse(
request.RequestId,
request.Manager.Name,
request.Source.Name,
request.Package.Id,
request.Operation,
"deny",
"<validation-failure>",
exception.Message,
false,
[],
"simulated-elevated");
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
namespace UniGetUI.PolicySimulator.Core;

public static class CommandLineBuilder
{
public static IReadOnlyList<string> Build(PackageRequest request)
{
return request.Manager.Name switch
{
"Winget" => BuildWinget(request),
"PowerShell" => BuildPowerShell(request),
_ => throw new InvalidOperationException($"Unsupported manager '{request.Manager.Name}'.")
};
}

private static IReadOnlyList<string> BuildWinget(PackageRequest request)
{
var operation = request.Operation switch
{
"install" => "install",
"update" => "upgrade",
"uninstall" => "uninstall",
_ => throw new InvalidOperationException($"Unsupported WinGet operation '{request.Operation}'.")
};

var command = new List<string> { "winget.exe", operation, "--id", request.Package.Id, "--exact" };
AddPair(command, "--source", request.Source.Name);
AddPair(command, "--scope", request.Options.Scope);
AddPair(command, "--version", request.Package.Version);
command.Add(request.Options.Interactive ? "--interactive" : "--silent");
AddPair(command, "--architecture", request.Package.Architecture);
if (request.Options.SkipHashCheck) command.Add("--ignore-security-hash");
AddPair(command, "--location", request.Options.CustomInstallLocation);
command.AddRange(request.Options.CustomParameters ?? []);
return command;
}

private static IReadOnlyList<string> BuildPowerShell(PackageRequest request)
{
var verb = request.Operation switch
{
"install" => "Install-Module",
"update" => "Update-Module",
"uninstall" => "Uninstall-Module",
_ => throw new InvalidOperationException($"Unsupported PowerShell operation '{request.Operation}'.")
};

var command = new List<string> { "pwsh.exe", "-NoProfile", "-Command", verb, "-Name", request.Package.Id };
if (request.Operation == "install" && request.Options.Scope == "user") command.AddRange(["-Scope", "CurrentUser"]);
if (request.Operation == "install" && request.Options.Scope == "machine") command.AddRange(["-Scope", "AllUsers"]);
AddPair(command, "-RequiredVersion", request.Package.Version);
if (request.Options.PreRelease) command.Add("-AllowPrerelease");
if (request.Options.SkipHashCheck) command.Add("-SkipPublisherCheck");
command.AddRange(request.Options.CustomParameters ?? []);
return command;
}

private static void AddPair(List<string> command, string name, string? value)
{
if (string.IsNullOrWhiteSpace(value)) return;
command.Add(name);
command.Add(value);
}
}
113 changes: 113 additions & 0 deletions policies/csharp/UniGetUI.PolicySimulator.Core/DocumentLoader.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using Json.Schema;
using YamlDotNet.Serialization;

namespace UniGetUI.PolicySimulator.Core;

public sealed class DocumentLoader
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
PropertyNameCaseInsensitive = true
};

public LoadedDocument<T> LoadFile<T>(string path, string? schemaPath = null)
{
var fullPath = Path.GetFullPath(path);
var text = File.ReadAllText(fullPath);
return LoadText<T>(text, fullPath, InferFormatFromPath(fullPath), schemaPath);
}

public LoadedDocument<T> LoadText<T>(string text, string documentName, string format, string? schemaPath = null)
{
var canonicalJson = ConvertToCanonicalJson(text, format);
if (!string.IsNullOrWhiteSpace(schemaPath))
{
ValidateJsonSchema(canonicalJson, schemaPath, documentName);
}

var value = JsonSerializer.Deserialize<T>(canonicalJson, JsonOptions)
?? throw new PolicyValidationException($"Document '{documentName}' did not deserialize to {typeof(T).Name}.");

return new LoadedDocument<T>(documentName, format, canonicalJson, value);
}

public static string InferFormatFromPath(string path)
{
var extension = Path.GetExtension(path).ToLowerInvariant();
return extension switch
{
".json" => "json",
".yaml" or ".yml" => "yaml",
_ => throw new PolicyValidationException($"Unsupported document extension '{extension}'. Use .json, .yaml, or .yml.")
};
}

public static string InferFormatFromContentType(string? contentType)
{
if (contentType?.Contains("yaml", StringComparison.OrdinalIgnoreCase) == true ||
contentType?.Contains("yml", StringComparison.OrdinalIgnoreCase) == true)
{
return "yaml";
}

return "json";
}

private static string ConvertToCanonicalJson(string text, string format)
{
if (format.Equals("json", StringComparison.OrdinalIgnoreCase))
{
using var document = JsonDocument.Parse(text);
return JsonSerializer.Serialize(document.RootElement, JsonOptions);
}

if (!format.Equals("yaml", StringComparison.OrdinalIgnoreCase))
{
throw new PolicyValidationException($"Unsupported document format '{format}'.");
}

var deserializer = new DeserializerBuilder()
.WithAttemptingUnquotedStringTypeDeserialization()
.Build();
var yamlObject = deserializer.Deserialize(new StringReader(text));
var normalized = NormalizeYamlObject(yamlObject);
return JsonSerializer.Serialize(normalized, JsonOptions);
}

private static object? NormalizeYamlObject(object? value)
{
return value switch
{
null => null,
IDictionary<object, object> dictionary => dictionary.ToDictionary(
item => Convert.ToString(item.Key, System.Globalization.CultureInfo.InvariantCulture) ?? string.Empty,
item => NormalizeYamlObject(item.Value)),
IEnumerable<object> list => list.Select(NormalizeYamlObject).ToList(),
_ => value
};
}

private static void ValidateJsonSchema(string canonicalJson, string schemaPath, string documentName)
{
var schemaText = File.ReadAllText(schemaPath);
var schema = JsonSchema.FromText(schemaText);
var instance = JsonNode.Parse(canonicalJson)
?? throw new PolicyValidationException($"Document '{documentName}' could not be parsed as JSON.");

var results = schema.Evaluate(instance, new EvaluationOptions { OutputFormat = OutputFormat.List });
if (results.IsValid)
{
return;
}

var details = results.Details
.Where(detail => detail.HasErrors)
.SelectMany(detail => detail.Errors?.Select(error => $"{detail.InstanceLocation}: {error.Key} {error.Value}") ?? [])
.ToList();

var message = details.Count == 0 ? "schema validation failed" : string.Join("; ", details);
throw new PolicyValidationException($"Document '{documentName}' failed schema validation: {message}");
}
}
Loading
Loading