diff --git a/Lip.Tests/PackageManifestTests.cs b/Lip.Tests/PackageManifestTests.cs index 547e261..aa79cbf 100644 --- a/Lip.Tests/PackageManifestTests.cs +++ b/Lip.Tests/PackageManifestTests.cs @@ -39,7 +39,7 @@ public void FromBytes_FullInput_Parsed() "name": "Test Package", "description": "A test package", "author": "Test Author", - "tags": ["test", "example"], + "tags": ["test", "example:tag"], "avatar_url": "https://example.com/avatar.png" }, "variants": [ @@ -57,7 +57,7 @@ public void FromBytes_FullInput_Parsed() "urls": ["https://example.com/asset.zip"], "place": [ { - "type": "directory", + "type": "dir", "src": "src", "dest": "dest" } @@ -94,7 +94,7 @@ public void FromBytes_FullInput_Parsed() Assert.Equal("Test Package", manifest.Info.Name); Assert.Equal("A test package", manifest.Info.Description); Assert.Equal("Test Author", manifest.Info.Author); - Assert.Equal(new[] { "test", "example" }, manifest.Info.Tags); + Assert.Equal(new[] { "test", "example:tag" }, manifest.Info.Tags); Assert.Equal("https://example.com/avatar.png", manifest.Info.AvatarUrl); Assert.NotNull(manifest.Variants); @@ -111,13 +111,13 @@ public void FromBytes_FullInput_Parsed() Assert.NotNull(variant.Assets); Assert.Single(variant.Assets); PackageManifest.AssetType asset = variant.Assets[0]; - Assert.Equal("zip", asset.Type); + Assert.Equal(PackageManifest.AssetType.TypeEnum.Zip, asset.Type); Assert.Equal(new[] { "https://example.com/asset.zip" }, asset.Urls); Assert.NotNull(asset.Place); Assert.Single(asset.Place); PackageManifest.PlaceType place = asset.Place[0]; - Assert.Equal("directory", place.Type); + Assert.Equal(PackageManifest.PlaceType.TypeEnum.Dir, place.Type); Assert.Equal("src", place.Src); Assert.Equal("dest", place.Dest); @@ -158,15 +158,49 @@ public void FromBytes_MissingRequiredField_ThrowsArgumentException() Assert.Throws(() => PackageManifest.FromBytes(bytes)); } - [Theory] - [InlineData(1)] - [InlineData(2)] - [InlineData(4)] - public void FromBytes_InvalidFormatVersion_ThrowsArgumentException(int version) + [Fact] + public void FromBytes_InvalidFieldType_ThrowsJsonException() + { + byte[] bytes = """ + { + "format_version": "3", + "format_uuid": "289f771f-2c9a-4d73-9f3f-8492495a924d", + "tooth": "test-tooth", + "version": 1 + } + """u8.ToArray(); + + Assert.Throws(() => PackageManifest.FromBytes(bytes)); + } + + [Fact] + public void FromBytes_AdditionalField_Parsed() + { + byte[] bytes = """ + { + "format_version": 3, + "format_uuid": "289f771f-2c9a-4d73-9f3f-8492495a924d", + "tooth": "test-tooth", + "version": "1.0.0", + "additional_field": "additional-value" + } + """u8.ToArray(); + + var manifest = PackageManifest.FromBytes(bytes); + + Assert.NotNull(manifest); + Assert.Equal(3, manifest.FormatVersion); + Assert.Equal("289f771f-2c9a-4d73-9f3f-8492495a924d", manifest.FormatUuid); + Assert.Equal("test-tooth", manifest.Tooth); + Assert.Equal("1.0.0", manifest.Version); + } + + [Fact] + public void FromBytes_InvalidFormatVersion_ThrowsArgumentException() { byte[] bytes = Encoding.UTF8.GetBytes($$""" { - "format_version": {{version}}, + "format_version": 0, "format_uuid": "289f771f-2c9a-4d73-9f3f-8492495a924d", "tooth": "test-tooth", "version": "1.0.0" @@ -192,22 +226,79 @@ public void FromBytes_InvalidFormatUuid_ThrowsArgumentException() } [Fact] - public void FromBytes_InvalidFieldType_ThrowsJsonException() + public void FromBytes_InvalidVersion_ThrowsArgumentException() { byte[] bytes = """ { - "format_version": "3", + "format_version": 3, "format_uuid": "289f771f-2c9a-4d73-9f3f-8492495a924d", "tooth": "test-tooth", - "version": 1 + "version": "invalid-version" } """u8.ToArray(); - Assert.Throws(() => PackageManifest.FromBytes(bytes)); + Assert.Throws(() => PackageManifest.FromBytes(bytes)); + } + + [Fact] + public void FromBytes_InvalidTag_ThrowsJsonException() + { + byte[] bytes = """ + { + "format_version": 3, + "format_uuid": "289f771f-2c9a-4d73-9f3f-8492495a924d", + "tooth": "test-tooth", + "version": "1.0.0", + "info": { + "tags": ["invalid.tag"], + } + } + """u8.ToArray(); + + Assert.Throws(() => PackageManifest.FromBytes(bytes)); + } + + [Fact] + public void FromBytes_ValidAdditionalScript_Parsed() + { + byte[] bytes = """ + { + "format_version": 3, + "format_uuid": "289f771f-2c9a-4d73-9f3f-8492495a924d", + "tooth": "test-tooth", + "version": "1.0.0", + "variants": [ + { + "platform": "windows", + "scripts": { + "pre_install": ["echo pre-install"], + "custom_script": ["echo custom"] + } + } + ] + } + """u8.ToArray(); + + var manifest = PackageManifest.FromBytes(bytes); + + Assert.NotNull(manifest); + Assert.Equal(3, manifest.FormatVersion); + Assert.Equal("289f771f-2c9a-4d73-9f3f-8492495a924d", manifest.FormatUuid); + Assert.Equal("test-tooth", manifest.Tooth); + Assert.Equal("1.0.0", manifest.Version); + + Assert.NotNull(manifest.Variants); + Assert.Single(manifest.Variants); + PackageManifest.VariantType variant = manifest.Variants[0]; + Assert.Equal("windows", variant.Platform); + + Assert.NotNull(variant.Scripts); + Assert.Equal(new[] { "echo pre-install" }, variant.Scripts.PreInstall); + Assert.Equal(new[] { "echo custom" }, variant.Scripts.AdditionalScripts["custom_script"]); } [Fact] - public void FromBytes_InvalidAdditionalScripts_ThrowsJsonException() + public void FromBytes_InvalidAdditionalScriptKey_ThrowsJsonException() { byte[] bytes = """ { @@ -220,7 +311,7 @@ public void FromBytes_InvalidAdditionalScripts_ThrowsJsonException() "platform": "windows", "scripts": { "pre_install": ["echo pre-install"], - "invalid_script": "echo invalid" + "invalid-script": ["echo invalid"] } } ] @@ -229,4 +320,30 @@ public void FromBytes_InvalidAdditionalScripts_ThrowsJsonException() Assert.Throws(() => PackageManifest.FromBytes(bytes)); } + + [Theory] + [InlineData("\"echo valid\"")] + [InlineData("[0]")] + public void FromBytes_InvalidAdditionalScriptValue_ThrowsJsonException(object invalidScript) + { + byte[] bytes = Encoding.UTF8.GetBytes($$""" + { + "format_version": 3, + "format_uuid": "289f771f-2c9a-4d73-9f3f-8492495a924d", + "tooth": "test-tooth", + "version": "1.0.0", + "variants": [ + { + "platform": "windows", + "scripts": { + "pre_install": ["echo pre-install"], + "invalid_script": {{invalidScript}} + } + } + ] + } + """); + + Assert.Throws(() => PackageManifest.FromBytes(bytes)); + } } diff --git a/Lip/PackageManifest.cs b/Lip/PackageManifest.cs index 3856496..238cfef 100644 --- a/Lip/PackageManifest.cs +++ b/Lip/PackageManifest.cs @@ -1,14 +1,30 @@ using System.Text.Json; using System.Text.Json.Serialization; +using System.Text.RegularExpressions; namespace Lip; -public record PackageManifest +public partial record PackageManifest { public record AssetType { + [JsonConverter(typeof(JsonStringEnumConverter))] + public enum TypeEnum + { + [JsonStringEnumMemberName("self")] + Self, + [JsonStringEnumMemberName("tar")] + Tar, + [JsonStringEnumMemberName("tgz")] + Tgz, + [JsonStringEnumMemberName("uncompressed")] + Uncompressed, + [JsonStringEnumMemberName("zip")] + Zip, + } + [JsonPropertyName("type")] - public required string Type { get; init; } + public required TypeEnum Type { get; init; } [JsonPropertyName("urls")] public List? Urls { get; init; } @@ -23,7 +39,7 @@ public record AssetType public List? Remove { get; init; } } - public record InfoType + public partial record InfoType { [JsonPropertyName("name")] public string? Name { get; init; } @@ -35,16 +51,47 @@ public record InfoType public string? Author { get; init; } [JsonPropertyName("tags")] - public List? Tags { get; init; } + public List? Tags + { + get => _tags; + init + { + if (value is not null) + { + foreach (string tag in value) + { + if (!TagGeneratedRegex().IsMatch(tag)) + { + throw new ArgumentException($"Tag {tag} must match the regex pattern {TagGeneratedRegex()}"); + } + } + } + _tags = value; + } + } [JsonPropertyName("avatar_url")] public string? AvatarUrl { get; init; } + + private List? _tags; + + [GeneratedRegex("^[a-z0-9-]+(:[a-z0-9-]+)?$")] + private static partial Regex TagGeneratedRegex(); } public record PlaceType { + [JsonConverter(typeof(JsonStringEnumConverter))] + public enum TypeEnum + { + [JsonStringEnumMemberName("file")] + File, + [JsonStringEnumMemberName("dir")] + Dir, + } + [JsonPropertyName("type")] - public required string Type { get; init; } + public required TypeEnum Type { get; init; } [JsonPropertyName("src")] public required string Src { get; init; } @@ -119,19 +166,29 @@ public record VariantType public ScriptsType? Scripts { get; init; } } + private const int DefaultFormatVersion = 3; + private const string DefaultFormatUuid = "289f771f-2c9a-4d73-9f3f-8492495a924d"; + + private static readonly JsonSerializerOptions s_jsonSerializerOptions = new() + { + AllowTrailingCommas = true, + ReadCommentHandling = JsonCommentHandling.Skip, + WriteIndented = true, + }; + [JsonPropertyName("format_version")] public required int FormatVersion { - get => 3; - init => _ = value == 3 ? 0 - : throw new ArgumentException("FormatVersion must be 3", nameof(value)); + get => DefaultFormatVersion; + init => _ = value == DefaultFormatVersion ? 0 + : throw new ArgumentException($"FormatVersion must be {DefaultFormatVersion}", nameof(value)); } [JsonPropertyName("format_uuid")] public required string FormatUuid { - get => "289f771f-2c9a-4d73-9f3f-8492495a924d"; - init => _ = value == "289f771f-2c9a-4d73-9f3f-8492495a924d" ? 0 + get => DefaultFormatUuid; + init => _ = value == DefaultFormatUuid ? 0 : throw new ArgumentException("FormatUuid must be 114514", nameof(value)); } @@ -139,7 +196,22 @@ public required string FormatUuid public required string Tooth { get; init; } [JsonPropertyName("version")] - public required string Version { get; init; } + public required string Version + { + get + { + return _version; + } + init + { + if (!VersionGeneratedRegex().IsMatch(value)) + { + throw new ArgumentException($"Version {value} must match the regex pattern {VersionGeneratedRegex()}"); + } + + _version = value; + } + } [JsonPropertyName("info")] public InfoType? Info { get; init; } @@ -147,11 +219,7 @@ public required string FormatUuid [JsonPropertyName("variants")] public VariantType[]? Variants { get; init; } - private static readonly JsonSerializerOptions s_jsonSerializerOptions = new() - { - AllowTrailingCommas = true, - ReadCommentHandling = JsonCommentHandling.Skip, - }; + private string _version = string.Empty; public static PackageManifest? FromBytes(byte[] bytes) { @@ -169,6 +237,11 @@ public required string FormatUuid { foreach (KeyValuePair kvp in variant.Scripts.AdditionalProperties) { + if (!ScriptNameGeneratedRegex().IsMatch(kvp.Key)) + { + throw new JsonException($"Script name {kvp.Key} must match the regex pattern {ScriptNameGeneratedRegex()}"); + } + if (kvp.Value.ValueKind != JsonValueKind.Array) { throw new JsonException("Self-defined scripts must be arrays of strings."); @@ -188,4 +261,10 @@ public required string FormatUuid return manifest; } + + [GeneratedRegex("^[a-z0-9]+(_[a-z0-9]+)*$")] + private static partial Regex ScriptNameGeneratedRegex(); + + [GeneratedRegex(@"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$")] + private static partial Regex VersionGeneratedRegex(); } diff --git a/docs/schemas/tooth.v3.schema.json b/docs/schemas/tooth.v3.schema.json index 5d905bc..5d2d41c 100644 --- a/docs/schemas/tooth.v3.schema.json +++ b/docs/schemas/tooth.v3.schema.json @@ -14,7 +14,8 @@ "type": "string" }, "version": { - "type": "string" + "type": "string", + "pattern": "^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)(?:-((?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\\.(?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\\+([0-9a-zA-Z-]+(?:\\.[0-9a-zA-Z-]+)*))?$" }, "info": { "type": "object", @@ -73,9 +74,9 @@ "type": "string", "enum": [ "self", - "uncompressed", "tar", - "tar.gz", + "tgz", + "uncompressed", "zip" ] }, @@ -182,7 +183,7 @@ } }, "patternProperties": { - "^[a-z0-9]+(-[a-z0-9]+)*$": { + "^[a-z0-9]+(_[a-z0-9]+)*$": { "type": "array", "items": { "type": "string" diff --git a/docs/user-guide/files/tooth-json.md b/docs/user-guide/files/tooth-json.md index e348b3b..449d1da 100644 --- a/docs/user-guide/files/tooth-json.md +++ b/docs/user-guide/files/tooth-json.md @@ -120,9 +120,9 @@ Defines how package files should be handled. The asset type: - `self`: Files from the package itself -- `uncompressed`: Single uncompressed file - `tar`: TAR archive -- `tar.gz`: Gzipped TAR archive +- `tgz`: Gzipped TAR archive +- `uncompressed`: Single uncompressed file - `zip`: ZIP archive ### variants[].assets[].urls (required) @@ -180,4 +180,4 @@ Built-in script hooks: - `uninstall`: After file removal - `post_uninstall`: After uninstallation -Custom scripts can be run using `lip run