// SPDX-FileCopyrightText: 2026 Progmasoft // SPDX-License-Identifier: AGPL-3.0-or-later WITH AdditionRef-Progmasoft-Patent-Grant-1.1 using System.IO.Compression; using System.Text; using System.Text.RegularExpressions; namespace Progmasoft.Progmaweb.Api.ViGet; /// /// The descriptor of a Tazel plugin, META-INF/tazel-plugin.properties of its JAR, as Tazel reads it, with the /// fields ViGet shows on the page of the plugin. /// /// The Progmasoft Account the plugin is published under. /// The name of the plugin; one identifier. /// An exact version of three to five numbers. /// The plugin API of Tazel it was built for. /// What the plugin does. /// The authors, in the order of the descriptor. /// An SPDX license expression. /// An https address of the plugin. /// An https address of its sources. internal sealed partial record TazelPluginDescriptor( string Publisher, string Name, string Version, int ApiVersion, string? Description, IReadOnlyList Authors, string? License, string? Homepage, string? Repository) { /// Where the descriptor lies in the JAR. public const string DescriptorEntry = "META-INF/tazel-plugin.properties"; /// The service entry that names the class Tazel loads. public const string ServiceEntry = "META-INF/services/com.progmasoft.api.tazel.dsl.plugin.ProjectPlugin"; /// Whether a text is one identifier, as Tazel requires of a publisher and a plugin name. public static bool IsIdentifier(string value) => IdentifierPattern().IsMatch(value); /// Whether a text is an exact version of three to five numbers, as Tazel requires of a plugin version. public static bool IsVersion(string value) => VersionPattern().IsMatch(value); /// Reads and checks the descriptor of a plugin JAR. /// The bytes of the JAR. /// The descriptor, when the JAR is a valid plugin. /// Why the JAR is refused, or when it is valid. public static string? TryRead(byte[] jar, out TazelPluginDescriptor? descriptor) { descriptor = null; IReadOnlyDictionary properties; try { using ZipArchive archive = new(new MemoryStream(jar, writable: false), ZipArchiveMode.Read); ZipArchiveEntry? entry = archive.GetEntry(DescriptorEntry); if (entry is null) { return $"The JAR has no {DescriptorEntry}."; } ZipArchiveEntry? service = archive.GetEntry(ServiceEntry); if (service is null || service.Length == 0) { return $"The JAR has no {ServiceEntry}."; } using StreamReader reader = new(entry.Open(), Encoding.Latin1); properties = JavaProperties.Parse(reader.ReadToEnd()); } catch (InvalidDataException) { return "The file is not a JAR."; } catch (FormatException error) { return $"The descriptor is not a properties file: {error.Message}"; } string? Text(string key) => properties.TryGetValue(key, out string? value) && !string.IsNullOrWhiteSpace(value) ? value.Trim() : null; string? publisher = Text("publisher"); string? name = Text("name"); string? version = Text("version"); if (publisher is null || !IsIdentifier(publisher)) { return "The descriptor needs a publisher that is one identifier."; } if (name is null || !IsIdentifier(name)) { return "The descriptor needs a name that is one identifier."; } if (version is null || !IsVersion(version)) { return "The descriptor needs an exact version of three to five numbers."; } if (!int.TryParse(Text("apiVersion"), out int apiVersion) || apiVersion < 1) { return "The descriptor needs an API version."; } List authors = []; for (int index = 0; Text($"author.{index}") is { } author; index++) { authors.Add(author); } string? homepage = Text("homepage"); string? repository = Text("repository"); foreach ((string key, string? address) in new[] { ("homepage", homepage), ("repository", repository) }) { if (address is not null && (!Uri.TryCreate(address, UriKind.Absolute, out Uri? uri) || uri.Scheme != Uri.UriSchemeHttps)) { return $"The {key} of the descriptor is not an https address."; } } descriptor = new TazelPluginDescriptor( publisher, name, version, apiVersion, Text("description"), authors, Text("license"), homepage, repository); return null; } /// One identifier: a letter or _, then letters, digits or _. /// The compiled expression. [GeneratedRegex("^[A-Za-z_][A-Za-z0-9_]*$")] private static partial Regex IdentifierPattern(); /// An exact version of three to five numbers without leading zeros, as Tazel accepts it. /// The compiled expression. [GeneratedRegex(@"^(?:0|[1-9][0-9]*)(?:\.(?:0|[1-9][0-9]*)){2,4}(?:-[0-9A-Za-z.-]+)?$")] private static partial Regex VersionPattern(); }