From d02cd40f1d8dc47d2f4322c6761c940f3768199d Mon Sep 17 00:00:00 2001 From: Paul Hebble Date: Tue, 18 Jun 2019 22:41:26 -0500 Subject: [PATCH] Migrate Perl validation checks into netkan.exe --- Cmdline/packages.config | 1 + Netkan/CKAN-netkan.csproj | 16 ++++ Netkan/Model/Metadata.cs | 15 ++-- Netkan/Program.cs | 2 +- .../AlphaNumericIdentifierValidator.cs | 21 +++++ Netkan/Validators/CkanValidator.cs | 4 +- Netkan/Validators/DownloadVersionValidator.cs | 16 ++++ Netkan/Validators/InstallValidator.cs | 56 ++++++++++++ .../Validators/KrefDownloadMutexValidator.cs | 16 ++++ Netkan/Validators/LicensesValidator.cs | 60 +++++++++++++ .../MatchesKnownGameVersionsValidator.cs | 19 ++++ Netkan/Validators/NetkanValidator.cs | 18 +++- Netkan/Validators/ObeysCKANSchemaValidator.cs | 36 ++++++++ Netkan/Validators/OverrideValidator.cs | 32 +++++++ Netkan/Validators/RelationshipsValidator.cs | 62 +++++++++++++ Netkan/Validators/ReplacedByValidator.cs | 19 ++++ .../Validators/SpecVersionFormatValidator.cs | 23 +++++ Netkan/Validators/VersionStrictValidator.cs | 19 ++++ Netkan/packages.config | 2 +- .../AlphaNumericIdentifierValidatorTests.cs | 44 ++++++++++ Tests/NetKAN/Validators/CkanValidatorTests.cs | 14 ++- .../DownloadVersionValidatorTests.cs | 49 +++++++++++ .../Validators/InstallValidatorTests.cs | 86 +++++++++++++++++++ .../KrefDownloadMutexValidatorTests.cs | 49 +++++++++++ .../Validators/LicensesValidatorTests.cs | 46 ++++++++++ .../MatchesKnownGameVersionsValidatorTests.cs | 62 +++++++++++++ .../NetKAN/Validators/NetkanValidatorTests.cs | 5 +- .../ObeysCKANSchemaValidatorTests.cs | 82 ++++++++++++++++++ .../Validators/OverrideValidatorTests.cs | 48 +++++++++++ .../Validators/RelationshipsValidatorTests.cs | 47 ++++++++++ .../Validators/ReplacedByValidatorTests.cs | 44 ++++++++++ .../SpecVersionFormatValidatorTests.cs | 45 ++++++++++ .../Validators/VersionStrictValidatorTests.cs | 44 ++++++++++ Tests/Tests.csproj | 12 +++ 34 files changed, 1094 insertions(+), 20 deletions(-) create mode 100644 Netkan/Validators/AlphaNumericIdentifierValidator.cs create mode 100644 Netkan/Validators/DownloadVersionValidator.cs create mode 100644 Netkan/Validators/InstallValidator.cs create mode 100644 Netkan/Validators/KrefDownloadMutexValidator.cs create mode 100644 Netkan/Validators/LicensesValidator.cs create mode 100644 Netkan/Validators/MatchesKnownGameVersionsValidator.cs create mode 100644 Netkan/Validators/ObeysCKANSchemaValidator.cs create mode 100644 Netkan/Validators/OverrideValidator.cs create mode 100644 Netkan/Validators/RelationshipsValidator.cs create mode 100644 Netkan/Validators/ReplacedByValidator.cs create mode 100644 Netkan/Validators/SpecVersionFormatValidator.cs create mode 100644 Netkan/Validators/VersionStrictValidator.cs create mode 100644 Tests/NetKAN/Validators/AlphaNumericIdentifierValidatorTests.cs create mode 100644 Tests/NetKAN/Validators/DownloadVersionValidatorTests.cs create mode 100644 Tests/NetKAN/Validators/InstallValidatorTests.cs create mode 100644 Tests/NetKAN/Validators/KrefDownloadMutexValidatorTests.cs create mode 100644 Tests/NetKAN/Validators/LicensesValidatorTests.cs create mode 100644 Tests/NetKAN/Validators/MatchesKnownGameVersionsValidatorTests.cs create mode 100644 Tests/NetKAN/Validators/ObeysCKANSchemaValidatorTests.cs create mode 100644 Tests/NetKAN/Validators/OverrideValidatorTests.cs create mode 100644 Tests/NetKAN/Validators/RelationshipsValidatorTests.cs create mode 100644 Tests/NetKAN/Validators/ReplacedByValidatorTests.cs create mode 100644 Tests/NetKAN/Validators/SpecVersionFormatValidatorTests.cs create mode 100644 Tests/NetKAN/Validators/VersionStrictValidatorTests.cs diff --git a/Cmdline/packages.config b/Cmdline/packages.config index cf073cef53..3cb2f585f3 100644 --- a/Cmdline/packages.config +++ b/Cmdline/packages.config @@ -1,5 +1,6 @@  + \ No newline at end of file diff --git a/Netkan/CKAN-netkan.csproj b/Netkan/CKAN-netkan.csproj index baaec58b8d..bdcf9017d0 100644 --- a/Netkan/CKAN-netkan.csproj +++ b/Netkan/CKAN-netkan.csproj @@ -52,6 +52,7 @@ ..\_build\lib\nuget\Newtonsoft.Json.12.0.2\lib\net45\Newtonsoft.Json.dll + @@ -131,6 +132,21 @@ + + + + + + + + + + + + + + + diff --git a/Netkan/Model/Metadata.cs b/Netkan/Model/Metadata.cs index c3bc929c0a..8e2ef10806 100644 --- a/Netkan/Model/Metadata.cs +++ b/Netkan/Model/Metadata.cs @@ -15,14 +15,13 @@ internal sealed class Metadata private readonly JObject _json; - // FIXME: Alignment - public string Identifier { get { return (string)_json["identifier"]; } } - public RemoteRef Kref { get; private set; } - public RemoteRef Vref { get; private set; } - public ModuleVersion SpecVersion { get; private set; } - public ModuleVersion Version { get; private set; } - public Uri Download { get; private set; } - public DateTime? RemoteTimestamp { get; private set; } + public string Identifier { get { return (string)_json["identifier"]; } } + public RemoteRef Kref { get; private set; } + public RemoteRef Vref { get; private set; } + public ModuleVersion SpecVersion { get; private set; } + public ModuleVersion Version { get; private set; } + public Uri Download { get; private set; } + public DateTime? RemoteTimestamp { get; private set; } public Metadata(JObject json) { diff --git a/Netkan/Program.cs b/Netkan/Program.cs index 1624c1947f..ebbb76ba3c 100644 --- a/Netkan/Program.cs +++ b/Netkan/Program.cs @@ -64,7 +64,7 @@ public static int Main(string[] args) var netkan = ReadNetkan(); Log.Info("Finished reading input"); - new NetkanValidator().Validate(netkan); + new NetkanValidator(Options.File).Validate(netkan); Log.Info("Input successfully passed pre-validation"); var transformer = new NetkanTransformer( diff --git a/Netkan/Validators/AlphaNumericIdentifierValidator.cs b/Netkan/Validators/AlphaNumericIdentifierValidator.cs new file mode 100644 index 0000000000..fb13c2fc21 --- /dev/null +++ b/Netkan/Validators/AlphaNumericIdentifierValidator.cs @@ -0,0 +1,21 @@ +using System.Text.RegularExpressions; +using CKAN.NetKAN.Model; + +namespace CKAN.NetKAN.Validators +{ + internal sealed class AlphaNumericIdentifierValidator : IValidator + { + public void Validate(Metadata metadata) + { + if (!alphanumeric.IsMatch(metadata.Identifier)) + { + throw new Kraken("CKAN identifiers must consist only of letters, numbers, and dashes, and must start with a letter or number."); + } + } + + private static readonly Regex alphanumeric = new Regex( + @"^[A-Za-z0-9-]+$", + RegexOptions.Compiled + ); + } +} diff --git a/Netkan/Validators/CkanValidator.cs b/Netkan/Validators/CkanValidator.cs index a430acea1a..8fd3f5a78e 100644 --- a/Netkan/Validators/CkanValidator.cs +++ b/Netkan/Validators/CkanValidator.cs @@ -14,7 +14,9 @@ public CkanValidator(Metadata netkan, IHttpService downloader, IModuleService mo { new IsCkanModuleValidator(), new MatchingIdentifiersValidator(netkan.Identifier), - new InstallsFilesValidator(downloader, moduleService) + new InstallsFilesValidator(downloader, moduleService), + new MatchesKnownGameVersionsValidator(), + new ObeysCKANSchemaValidator() }; } diff --git a/Netkan/Validators/DownloadVersionValidator.cs b/Netkan/Validators/DownloadVersionValidator.cs new file mode 100644 index 0000000000..3b3e8cf2ce --- /dev/null +++ b/Netkan/Validators/DownloadVersionValidator.cs @@ -0,0 +1,16 @@ +using CKAN.NetKAN.Model; + +namespace CKAN.NetKAN.Validators +{ + internal sealed class DownloadVersionValidator : IValidator + { + public void Validate(Metadata metadata) + { + var json = metadata.Json(); + if (json.ContainsKey("download") && !json.ContainsKey("version")) + { + throw new Kraken($"{metadata.Identifier} expects a version when a download url is provided"); + } + } + } +} diff --git a/Netkan/Validators/InstallValidator.cs b/Netkan/Validators/InstallValidator.cs new file mode 100644 index 0000000000..a62196ed94 --- /dev/null +++ b/Netkan/Validators/InstallValidator.cs @@ -0,0 +1,56 @@ +using Newtonsoft.Json.Linq; +using CKAN.Versioning; +using CKAN.NetKAN.Model; + +namespace CKAN.NetKAN.Validators +{ + internal sealed class InstallValidator : IValidator + { + public void Validate(Metadata metadata) + { + var json = metadata.Json(); + if (json.ContainsKey("install")) + { + foreach (JObject stanza in json["install"]) + { + string install_to = (string)stanza["install_to"]; + if (metadata.SpecVersion < v1p2 && install_to.StartsWith("GameData/")) + { + throw new Kraken("spec_version v1.2+ required for GameData with path"); + } + if (metadata.SpecVersion < v1p12 && install_to.StartsWith("Ships/")) + { + throw new Kraken("spec_version v1.12+ required to install to Ships/ with path"); + } + if (metadata.SpecVersion < v1p16 && install_to.StartsWith("Ships/@thumbs")) + { + throw new Kraken("spec_version v1.16+ required to install to Ships/@thumbs with path"); + } + if (metadata.SpecVersion < v1p4 && stanza.ContainsKey("find")) + { + throw new Kraken("spec_version v1.4+ required for install with 'find'"); + } + if (metadata.SpecVersion < v1p10 && stanza.ContainsKey("find_regexp")) + { + throw new Kraken("spec_version v1.10+ required for install with 'find_regexp'"); + } + if (metadata.SpecVersion < v1p16 && stanza.ContainsKey("find_matches_files")) + { + throw new Kraken("spec_version v1.16+ required for 'find_matches_files'"); + } + if (metadata.SpecVersion < v1p18 && stanza.ContainsKey("as")) + { + throw new Kraken("spec_version v1.18+ required for 'as'"); + } + } + } + } + + private static readonly ModuleVersion v1p2 = new ModuleVersion("v1.2"); + private static readonly ModuleVersion v1p4 = new ModuleVersion("v1.4"); + private static readonly ModuleVersion v1p10 = new ModuleVersion("v1.10"); + private static readonly ModuleVersion v1p12 = new ModuleVersion("v1.12"); + private static readonly ModuleVersion v1p16 = new ModuleVersion("v1.16"); + private static readonly ModuleVersion v1p18 = new ModuleVersion("v1.18"); + } +} diff --git a/Netkan/Validators/KrefDownloadMutexValidator.cs b/Netkan/Validators/KrefDownloadMutexValidator.cs new file mode 100644 index 0000000000..13706abcaa --- /dev/null +++ b/Netkan/Validators/KrefDownloadMutexValidator.cs @@ -0,0 +1,16 @@ +using CKAN.NetKAN.Model; + +namespace CKAN.NetKAN.Validators +{ + internal sealed class KrefDownloadMutexValidator : IValidator + { + public void Validate(Metadata metadata) + { + var json = metadata.Json(); + if (json.ContainsKey("download") && json.ContainsKey("$kref")) + { + throw new Kraken($"{metadata.Identifier} has a $kref and a download field, this is likely incorrect"); + } + } + } +} diff --git a/Netkan/Validators/LicensesValidator.cs b/Netkan/Validators/LicensesValidator.cs new file mode 100644 index 0000000000..eb444a55c8 --- /dev/null +++ b/Netkan/Validators/LicensesValidator.cs @@ -0,0 +1,60 @@ +using System.Text.RegularExpressions; +using Newtonsoft.Json.Linq; +using CKAN.Versioning; +using CKAN.NetKAN.Model; + +namespace CKAN.NetKAN.Validators +{ + internal sealed class LicensesValidator : IValidator + { + public void Validate(Metadata metadata) + { + var json = metadata.Json(); + JArray licenses = !json.ContainsKey("license") ? null + : json["license"] is JArray + ? (JArray)json["license"] + : new JArray() { json["license"] }; + if (licenses != null) + { + foreach (var lic in licenses) + { + if (metadata.SpecVersion < v1p2 && (string)lic == "WTFPL") + { + throw new Kraken("spec_version v1.2+ required for license 'WTFPL'"); + } + if (metadata.SpecVersion < v1p18 && (string)lic == "Unlicense") + { + throw new Kraken("spec_version v1.18+ required for license 'Unlicense'"); + } + } + } + var kref = (string)json["$kref"] ?? ""; + if (!metanetkan.IsMatch(kref) && !json.ContainsKey("x_netkan_license_ok")) + { + if (licenses == null || licenses.Count < 1) + { + throw new Kraken("License should match spec. Set `x_netkan_license_ok` to supress"); + } + else foreach (var lic in licenses) + { + try + { + // This will throw BadMetadataKraken if the license isn't known + new CKAN.License((string)lic); + } + catch + { + throw new Kraken($"License {lic} should match spec. Set `x_netkan_license_ok` to supress"); + } + } + } + } + + private static readonly Regex metanetkan = new Regex( + @"^#/ckan/netkan/", + RegexOptions.Compiled + ); + private static readonly ModuleVersion v1p2 = new ModuleVersion("v1.2"); + private static readonly ModuleVersion v1p18 = new ModuleVersion("v1.18"); + } +} diff --git a/Netkan/Validators/MatchesKnownGameVersionsValidator.cs b/Netkan/Validators/MatchesKnownGameVersionsValidator.cs new file mode 100644 index 0000000000..b26d50054b --- /dev/null +++ b/Netkan/Validators/MatchesKnownGameVersionsValidator.cs @@ -0,0 +1,19 @@ +using CKAN.GameVersionProviders; +using CKAN.Versioning; +using CKAN.NetKAN.Model; + +namespace CKAN.NetKAN.Validators +{ + internal sealed class MatchesKnownGameVersionsValidator : IValidator + { + public void Validate(Metadata metadata) + { + var mod = CkanModule.FromJson(metadata.Json().ToString()); + var knownVersions = new KspBuildMap(new Win32Registry()).KnownVersions; + if (!mod.IsCompatibleKSP(new KspVersionCriteria(null, knownVersions))) + { + throw new Kraken($"{metadata.Identifier} doesn't match any valid game version"); + } + } + } +} diff --git a/Netkan/Validators/NetkanValidator.cs b/Netkan/Validators/NetkanValidator.cs index b480860953..73be2d988d 100644 --- a/Netkan/Validators/NetkanValidator.cs +++ b/Netkan/Validators/NetkanValidator.cs @@ -1,3 +1,4 @@ +using System.IO; using System.Collections.Generic; using CKAN.NetKAN.Model; @@ -7,12 +8,23 @@ internal sealed class NetkanValidator : IValidator { private readonly List _validators; - public NetkanValidator() + public NetkanValidator(string filename) { - _validators = new List + _validators = new List() { + new SpecVersionFormatValidator(), new HasIdentifierValidator(), - new KrefValidator() + new KrefValidator(), + new MatchingIdentifiersValidator(Path.GetFileNameWithoutExtension(filename)), + new AlphaNumericIdentifierValidator(), + new RelationshipsValidator(), + new LicensesValidator(), + new KrefDownloadMutexValidator(), + new DownloadVersionValidator(), + new OverrideValidator(), + new VersionStrictValidator(), + new ReplacedByValidator(), + new InstallValidator(), }; } diff --git a/Netkan/Validators/ObeysCKANSchemaValidator.cs b/Netkan/Validators/ObeysCKANSchemaValidator.cs new file mode 100644 index 0000000000..4643ed0ec1 --- /dev/null +++ b/Netkan/Validators/ObeysCKANSchemaValidator.cs @@ -0,0 +1,36 @@ +using System.IO; +using System.Reflection; +using System.Collections.Generic; +using System.Linq; +using NJsonSchema; +using CKAN.NetKAN.Model; + +namespace CKAN.NetKAN.Validators +{ + internal sealed class ObeysCKANSchemaValidator : IValidator + { + static ObeysCKANSchemaValidator() + { + var resourceStream = Assembly.GetExecutingAssembly().GetManifestResourceStream(embeddedSchema); + using (var reader = new StreamReader(resourceStream)) + { + schema = JsonSchema.FromJsonAsync(reader.ReadToEnd()).Result; + } + } + + public void Validate(Metadata metadata) + { + var errors = schema.Validate(metadata.Json()); + if (errors.Any()) + { + string msg = errors + .Select(err => $"{err.Path}: {err.Kind}") + .Aggregate((a, b) => $"{a}\r\n{b}"); + throw new Kraken($"Schema validation failed: {msg}"); + } + } + + private static readonly JsonSchema schema; + private const string embeddedSchema = "CKAN.NetKAN.CKAN.schema"; + } +} diff --git a/Netkan/Validators/OverrideValidator.cs b/Netkan/Validators/OverrideValidator.cs new file mode 100644 index 0000000000..9b90fa919d --- /dev/null +++ b/Netkan/Validators/OverrideValidator.cs @@ -0,0 +1,32 @@ +using Newtonsoft.Json.Linq; +using CKAN.NetKAN.Model; + +namespace CKAN.NetKAN.Validators +{ + internal sealed class OverrideValidator : IValidator + { + public void Validate(Metadata metadata) + { + var json = metadata.Json(); + var overrides = json["x_netkan_override"]; + if (overrides != null) + { + if (!(overrides is JArray)) + { + throw new Kraken("Netkan overrides require an array"); + } + foreach (JObject ovr in overrides) + { + if (!ovr.ContainsKey("version")) + { + throw new Kraken("Netkan overrides require a version"); + } + if (!ovr.ContainsKey("delete") && !ovr.ContainsKey("override")) + { + throw new Kraken("Netkan overrides require a delete or override section"); + } + } + } + } + } +} diff --git a/Netkan/Validators/RelationshipsValidator.cs b/Netkan/Validators/RelationshipsValidator.cs new file mode 100644 index 0000000000..3d69af77b6 --- /dev/null +++ b/Netkan/Validators/RelationshipsValidator.cs @@ -0,0 +1,62 @@ +using System.Text.RegularExpressions; +using Newtonsoft.Json.Linq; +using CKAN.Versioning; +using CKAN.NetKAN.Model; + +namespace CKAN.NetKAN.Validators +{ + internal sealed class RelationshipsValidator : IValidator + { + public void Validate(Metadata metadata) + { + var json = metadata.Json(); + foreach (string relName in relProps) + { + if (json.ContainsKey(relName)) + { + foreach (JObject rel in json[relName]) + { + if (rel.ContainsKey("any_of")) + { + if (metadata.SpecVersion < v1p26) + { + throw new Kraken("spec_version v1.26+ required for 'any_of'"); + } + foreach (JObject opt in rel["any_of"]) + { + string name = (string)opt["name"]; + if (!alphanumeric.IsMatch(name)) + { + throw new Kraken($"{name} in {relName} any_of is not a valid CKAN identifier"); + } + } + } + else + { + string name = (string)rel["name"]; + if (!alphanumeric.IsMatch(name)) + { + throw new Kraken($"{name} in {relName} is not a valid CKAN identifier"); + } + } + } + } + } + + } + + private static readonly string[] relProps = new string[] + { + "depends", + "recommends", + "suggests", + "conflicts", + "supports" + }; + private static readonly Regex alphanumeric = new Regex( + @"^[A-Za-z0-9-]+$", + RegexOptions.Compiled + ); + private static readonly ModuleVersion v1p26 = new ModuleVersion("v1.26"); + } +} diff --git a/Netkan/Validators/ReplacedByValidator.cs b/Netkan/Validators/ReplacedByValidator.cs new file mode 100644 index 0000000000..a1c5dac9a4 --- /dev/null +++ b/Netkan/Validators/ReplacedByValidator.cs @@ -0,0 +1,19 @@ +using CKAN.Versioning; +using CKAN.NetKAN.Model; + +namespace CKAN.NetKAN.Validators +{ + internal sealed class ReplacedByValidator : IValidator + { + public void Validate(Metadata metadata) + { + var json = metadata.Json(); + if (metadata.SpecVersion < v1p26 && json.ContainsKey("replaced_by")) + { + throw new Kraken("spec_version v1.26+ required for 'replaced_by'"); + } + } + + private static readonly ModuleVersion v1p26 = new ModuleVersion("v1.26"); + } +} diff --git a/Netkan/Validators/SpecVersionFormatValidator.cs b/Netkan/Validators/SpecVersionFormatValidator.cs new file mode 100644 index 0000000000..504cd97304 --- /dev/null +++ b/Netkan/Validators/SpecVersionFormatValidator.cs @@ -0,0 +1,23 @@ +using System.Text.RegularExpressions; +using CKAN.NetKAN.Model; + +namespace CKAN.NetKAN.Validators +{ + internal sealed class SpecVersionFormatValidator : IValidator + { + public void Validate(Metadata metadata) + { + var json = metadata.Json(); + if (json["spec_version"] == null + || !specVersionFormat.IsMatch((string)json["spec_version"])) + { + throw new Kraken("spec version must be 1 or in the 'vX.X' format"); + } + } + + private static readonly Regex specVersionFormat = new Regex( + @"^1$|^v\d\.\d\d?$", + RegexOptions.Compiled + ); + } +} diff --git a/Netkan/Validators/VersionStrictValidator.cs b/Netkan/Validators/VersionStrictValidator.cs new file mode 100644 index 0000000000..5b3be7cf92 --- /dev/null +++ b/Netkan/Validators/VersionStrictValidator.cs @@ -0,0 +1,19 @@ +using CKAN.Versioning; +using CKAN.NetKAN.Model; + +namespace CKAN.NetKAN.Validators +{ + internal sealed class VersionStrictValidator : IValidator + { + public void Validate(Metadata metadata) + { + var json = metadata.Json(); + if (metadata.SpecVersion < v1p16 && json.ContainsKey("ksp_version_strict")) + { + throw new Kraken("spec_version v1.16+ required for 'ksp_version_strict'"); + } + } + + private static readonly ModuleVersion v1p16 = new ModuleVersion("v1.16"); + } +} diff --git a/Netkan/packages.config b/Netkan/packages.config index a161ad7051..1af1055569 100644 --- a/Netkan/packages.config +++ b/Netkan/packages.config @@ -4,4 +4,4 @@ - \ No newline at end of file + diff --git a/Tests/NetKAN/Validators/AlphaNumericIdentifierValidatorTests.cs b/Tests/NetKAN/Validators/AlphaNumericIdentifierValidatorTests.cs new file mode 100644 index 0000000000..7a715231e8 --- /dev/null +++ b/Tests/NetKAN/Validators/AlphaNumericIdentifierValidatorTests.cs @@ -0,0 +1,44 @@ +using Newtonsoft.Json.Linq; +using NUnit.Framework; +using CKAN.NetKAN.Model; +using CKAN.NetKAN.Validators; + +namespace Tests.NetKAN.Validators +{ + [TestFixture] + public sealed class AlphaNumericIdentifierValidatorTests + { + [Test, + TestCase("Normal"), + TestCase("Has-Dash"), + TestCase("ALLUPPER"), + TestCase("alllower"), + ] + public void Validate_ValidIdentifier_DoesNotThrow(string identifier) + { + Assert.DoesNotThrow(() => TryId(identifier)); + } + + [Test, + TestCase("#HashTag"), + TestCase("Under_Score"), + TestCase("Dot.Dot"), + ] + public void Validate_BadIdentifier_Throws(string identifier) + { + Assert.Throws(() => TryId(identifier)); + } + + private void TryId(string identifier) + { + // Arrange + var json = new JObject(); + json["spec_version"] = 1; + json["identifier"] = identifier; + + // Act + var val = new AlphaNumericIdentifierValidator(); + val.Validate(new Metadata(json)); + } + } +} diff --git a/Tests/NetKAN/Validators/CkanValidatorTests.cs b/Tests/NetKAN/Validators/CkanValidatorTests.cs index ffd2b2d978..e4389fdb69 100644 --- a/Tests/NetKAN/Validators/CkanValidatorTests.cs +++ b/Tests/NetKAN/Validators/CkanValidatorTests.cs @@ -18,6 +18,9 @@ public void SetUp() { ValidCkan["spec_version"] = 1; ValidCkan["identifier"] = "AwesomeMod"; + ValidCkan["name"] = "Awesome Mod"; + ValidCkan["abstract"] = "A great mod"; + ValidCkan["license"] = "GPL-3.0"; ValidCkan["version"] = "1.0.0"; ValidCkan["download"] = "https://www.awesome-mod.example/AwesomeMod.zip"; } @@ -32,11 +35,14 @@ public void DoesNotThrowOnValidCkan() mModuleService.Setup(i => i.HasInstallableFiles(It.IsAny(), It.IsAny())) .Returns(true); - var netkan = new JObject(); - netkan["spec_version"] = 1; - netkan["identifier"] = "AwesomeMod"; + var ckan = new JObject(); + ckan["spec_version"] = 1; + ckan["identifier"] = "AwesomeMod"; + ckan["name"] = "Awesome Mod"; + ckan["abstract"] = "A great mod"; + ckan["license"] = "GPL-3.0"; - var sut = new CkanValidator(new Metadata(netkan), mHttp.Object, mModuleService.Object); + var sut = new CkanValidator(new Metadata(ckan), mHttp.Object, mModuleService.Object); var json = (JObject)ValidCkan.DeepClone(); // Act diff --git a/Tests/NetKAN/Validators/DownloadVersionValidatorTests.cs b/Tests/NetKAN/Validators/DownloadVersionValidatorTests.cs new file mode 100644 index 0000000000..3dedcee40a --- /dev/null +++ b/Tests/NetKAN/Validators/DownloadVersionValidatorTests.cs @@ -0,0 +1,49 @@ +using Newtonsoft.Json.Linq; +using NUnit.Framework; +using CKAN.NetKAN.Model; +using CKAN.NetKAN.Validators; + +namespace Tests.NetKAN.Validators +{ + [TestFixture] + public sealed class DownloadVersionValidatorTests + { + [Test, + TestCase("https://mysite.org/mymod.zip", "1.2.3"), + TestCase(null, null), + TestCase(null, "4.5.6"), + ] + public void Validate_NoDownloadWithoutVersion_DoesNotThrow(string download, string version) + { + Assert.DoesNotThrow(() => TryDownloadVersion(download, version)); + } + + [Test, + TestCase("https://mysite.org/mymod.zip", null), + ] + public void Validate_DownloadWithoutVersion_Throws(string download, string version) + { + Assert.Throws(() => TryDownloadVersion(download, version)); + } + + private void TryDownloadVersion(string download, string version) + { + // Arrange + var json = new JObject(); + json["spec_version"] = 1; + json["identifier"] = "AwesomeMod"; + if (download != null) + { + json["download"] = download; + } + if (version != null) + { + json["version"] = version; + } + + // Act + var val = new DownloadVersionValidator(); + val.Validate(new Metadata(json)); + } + } +} diff --git a/Tests/NetKAN/Validators/InstallValidatorTests.cs b/Tests/NetKAN/Validators/InstallValidatorTests.cs new file mode 100644 index 0000000000..17f5fca49f --- /dev/null +++ b/Tests/NetKAN/Validators/InstallValidatorTests.cs @@ -0,0 +1,86 @@ +using Newtonsoft.Json.Linq; +using NUnit.Framework; +using CKAN.NetKAN.Model; +using CKAN.NetKAN.Validators; + +namespace Tests.NetKAN.Validators +{ + [TestFixture] + public sealed class InstallValidatorTests + { + [Test, + TestCase("v1.2", "GameData/something"), + TestCase("v1.12", "Ships/something"), + TestCase("v1.16", "Ships/@thumbs"), + ] + public void Validate_GoodSpecVersionInstallTo_DoesNotThrow(string spec_version, string install_to) + { + Assert.DoesNotThrow(() => TryInstallTo(spec_version, install_to)); + } + + [Test, + TestCase("1", "GameData/something"), + TestCase("v1.11", "Ships/something"), + TestCase("v1.15", "Ships/@thumbs"), + ] + public void Validate_BadSpecVersionInstallTo_Throws(string spec_version, string install_to) + { + Assert.Throws(() => TryInstallTo(spec_version, install_to)); + } + + [Test, + TestCase("v1.4", "{ \"find\": \"something\", \"install_to\": \"GameData\" }"), + TestCase("v1.10", "{ \"find_regexp\": \"something\", \"install_to\": \"GameData\" }"), + TestCase("v1.16", "{ \"find_matches_files\": true, \"find\": \"something\", \"install_to\": \"GameData\" }"), + TestCase("v1.18", "{ \"as\": \"somethingelse\", \"find\": \"something\", \"install_to\": \"GameData\" }"), + ] + public void Validate_GoodSpecVersionInstallStanza_DoesNotThrow(string spec_version, string install_stanza) + { + Assert.DoesNotThrow(() => TryInstallStanza(spec_version, install_stanza)); + } + + [Test, + TestCase("v1.3", "{ \"find\": \"something\", \"install_to\": \"GameData\" }"), + TestCase("v1.9", "{ \"find_regexp\": \"something\", \"install_to\": \"GameData\" }"), + TestCase("v1.15", "{ \"find_matches_files\": true, \"find\": \"something\", \"install_to\": \"GameData\" }"), + TestCase("v1.17", "{ \"as\": \"somethingelse\", \"find\": \"something\", \"install_to\": \"GameData\" }"), + ] + public void Validate_BadSpecVersionInstallStanza_Throws(string spec_version, string install_stanza) + { + Assert.Throws(() => TryInstallStanza(spec_version, install_stanza)); + } + + private void TryInstallTo(string spec_version, string install_to) + { + // Arrange + var json = new JObject(); + json["spec_version"] = spec_version; + json["identifier"] = "AwesomeMod"; + json["install"] = new JArray() { + new JObject() { + { "file", "something" }, + { "install_to", install_to } + } + }; + + // Act + var val = new InstallValidator(); + val.Validate(new Metadata(json)); + } + + private void TryInstallStanza(string spec_version, string install_stanza) + { + // Arrange + var json = new JObject(); + json["spec_version"] = spec_version; + json["identifier"] = "AwesomeMod"; + json["install"] = new JArray() { + JObject.Parse(install_stanza) + }; + + // Act + var val = new InstallValidator(); + val.Validate(new Metadata(json)); + } + } +} diff --git a/Tests/NetKAN/Validators/KrefDownloadMutexValidatorTests.cs b/Tests/NetKAN/Validators/KrefDownloadMutexValidatorTests.cs new file mode 100644 index 0000000000..4c9d6ac80f --- /dev/null +++ b/Tests/NetKAN/Validators/KrefDownloadMutexValidatorTests.cs @@ -0,0 +1,49 @@ +using Newtonsoft.Json.Linq; +using NUnit.Framework; +using CKAN.NetKAN.Model; +using CKAN.NetKAN.Validators; + +namespace Tests.NetKAN.Validators +{ + [TestFixture] + public sealed class KrefDownloadMutexValidatorTests + { + [Test, + TestCase(null, null), + TestCase(null, "https://mysite.org/mymod.zip"), + TestCase("#/ckan/spacedock/1", null), + ] + public void Validate_OneWithoutTheOther_DoesNotThrow(string kref, string download) + { + Assert.DoesNotThrow(() => TryKrefDownload(kref, download)); + } + + [Test, + TestCase("#/ckan/spacedock/1", "https://mysite.org/mymod.zip"), + ] + public void Validate_Both_Throws(string kref, string download) + { + Assert.Throws(() => TryKrefDownload(kref, download)); + } + + private void TryKrefDownload(string kref, string download) + { + // Arrange + var json = new JObject(); + json["spec_version"] = 1; + json["identifier"] = "AwesomeMod"; + if (kref != null) + { + json["$kref"] = kref; + } + if (download != null) + { + json["download"] = download; + } + + // Act + var val = new KrefDownloadMutexValidator(); + val.Validate(new Metadata(json)); + } + } +} diff --git a/Tests/NetKAN/Validators/LicensesValidatorTests.cs b/Tests/NetKAN/Validators/LicensesValidatorTests.cs new file mode 100644 index 0000000000..4958716d67 --- /dev/null +++ b/Tests/NetKAN/Validators/LicensesValidatorTests.cs @@ -0,0 +1,46 @@ +using Newtonsoft.Json.Linq; +using NUnit.Framework; +using CKAN.NetKAN.Model; +using CKAN.NetKAN.Validators; + +namespace Tests.NetKAN.Validators +{ + [TestFixture] + public sealed class LicensesValidatorTests + { + [Test, + TestCase("v1.2", @"""WTFPL"""), + TestCase("v1.18", @"""Unlicense"""), + TestCase("v1.4", @"[ ""GPL-3.0"", ""MIT"" ]"), + ] + public void Validate_GoodSpecVersionLicense_DoesNotThrow(string spec_version, string license) + { + Assert.DoesNotThrow(() => TryLicense(spec_version, license)); + } + + [Test, + TestCase("1", @"""WTFPL"""), + TestCase("v1.17", @"""Unlicense"""), + TestCase("v1.4", @"""NotARealLicense"""), + TestCase("v1.4", @"[ ""GPL-3.0"", ""Unlicense"" ]"), + TestCase("v1.4", @"[ ""GPL-3.0"", ""NotARealLicense"" ]"), + ] + public void Validate_BadSpecVersionLicense_Throws(string spec_version, string license) + { + Assert.Throws(() => TryLicense(spec_version, license)); + } + + private void TryLicense(string spec_version, string license) + { + // Arrange + var json = new JObject(); + json["spec_version"] = spec_version; + json["identifier"] = "AwesomeMod"; + json["license"] = JToken.Parse(license); + + // Act + var val = new LicensesValidator(); + val.Validate(new Metadata(json)); + } + } +} diff --git a/Tests/NetKAN/Validators/MatchesKnownGameVersionsValidatorTests.cs b/Tests/NetKAN/Validators/MatchesKnownGameVersionsValidatorTests.cs new file mode 100644 index 0000000000..fecd7f4c5c --- /dev/null +++ b/Tests/NetKAN/Validators/MatchesKnownGameVersionsValidatorTests.cs @@ -0,0 +1,62 @@ +using Newtonsoft.Json.Linq; +using NUnit.Framework; +using CKAN.NetKAN.Model; +using CKAN.NetKAN.Validators; + +namespace Tests.NetKAN.Validators +{ + [TestFixture] + public sealed class MatchesKnownGameVersionsValidatorTests + { + [Test, + TestCase(null, null, null), + TestCase("0.90", null, null), + TestCase("1.2", null, null), + TestCase("1.7.2", null, null), + TestCase(null, "1.3.1", "1.3.1"), + TestCase(null, "1.1.10", "1.2.20"), + TestCase(null, "1.6", "1.7"), + ] + public void Validate_KnownVersions_DoesNotThrow(string ksp_version, string ksp_version_min, string ksp_version_max) + { + Assert.DoesNotThrow(() => TryVersion(ksp_version, ksp_version_min, ksp_version_max)); + } + + [Test, + TestCase("0.26.0", null, null), + TestCase("1.4.99", null, null), + TestCase(null, "1.0.10", "1.0.99"), + TestCase(null, "1.99.0", "1.99.99"), + ] + public void Validate_UnknownVersions_Throws(string ksp_version, string ksp_version_min, string ksp_version_max) + { + Assert.Throws(() => TryVersion(ksp_version, ksp_version_min, ksp_version_max)); + } + + private void TryVersion(string ksp_version, string ksp_version_min, string ksp_version_max) + { + // Arrange + var json = new JObject(); + json["spec_version"] = 1; + json["version"] = "1.0"; + json["identifier"] = "AwesomeMod"; + json["download"] = "https://mysite.org/mymod.zip"; + if (ksp_version != null) + { + json["ksp_version"] = ksp_version; + } + if (ksp_version_min != null) + { + json["ksp_version_min"] = ksp_version_min; + } + if (ksp_version_max != null) + { + json["ksp_version_max"] = ksp_version_max; + } + + // Act + var val = new MatchesKnownGameVersionsValidator(); + val.Validate(new Metadata(json)); + } + } +} diff --git a/Tests/NetKAN/Validators/NetkanValidatorTests.cs b/Tests/NetKAN/Validators/NetkanValidatorTests.cs index 7ebd27ee84..ca998707db 100644 --- a/Tests/NetKAN/Validators/NetkanValidatorTests.cs +++ b/Tests/NetKAN/Validators/NetkanValidatorTests.cs @@ -12,10 +12,11 @@ public sealed class NetkanValidatorTests public void DoesNotThrowWhenIdentifierPresent() { // Arrange - var sut = new NetkanValidator(); + var sut = new NetkanValidator("AwesomeMod.netkan"); var json = new JObject(); json["spec_version"] = 1; json["identifier"] = "AwesomeMod"; + json["license"] = "GPL-3.0"; // Act TestDelegate act = () => sut.Validate(new Metadata(json)); @@ -30,7 +31,7 @@ public void DoesNotThrowWhenIdentifierPresent() public void DoesThrowWhenIdentifierMissing() { // Arrange - var sut = new NetkanValidator(); + var sut = new NetkanValidator("AwesomeMod.netkan"); var json = new JObject(); json["spec_version"] = 1; diff --git a/Tests/NetKAN/Validators/ObeysCKANSchemaValidatorTests.cs b/Tests/NetKAN/Validators/ObeysCKANSchemaValidatorTests.cs new file mode 100644 index 0000000000..be90cb2e51 --- /dev/null +++ b/Tests/NetKAN/Validators/ObeysCKANSchemaValidatorTests.cs @@ -0,0 +1,82 @@ +using Newtonsoft.Json.Linq; +using NUnit.Framework; +using CKAN.NetKAN.Model; +using CKAN.NetKAN.Validators; + +namespace Tests.NetKAN.Validators +{ + [TestFixture] + public sealed class ObeysCKANSchemaValidatorTests + { + [Test, + TestCase(boringModule), + ] + public void Validate_Obeys_DoesNotThrow(string json) + { + Assert.DoesNotThrow(() => TryModule(json)); + } + + [Test, + TestCase(boringModule, "spec_version"), + TestCase(boringModule, "identifier"), + TestCase(boringModule, "name"), + TestCase(boringModule, "version"), + TestCase(boringModule, "license"), + TestCase(boringModule, "download"), + ] + public void Validate_MissingProperty_Throws(string json, string removeProperty) + { + Assert.Throws(() => TryModule(json, removeProperty)); + } + + [Test, + TestCase(boringModule, @"[ ""en-us"" ]"), + ] + public void Validate_UniqueLocalizations_DoesNotThrow(string json, string localizations) + { + Assert.DoesNotThrow( + () => TryModule(json, null, "localizations", JArray.Parse(localizations)) + ); + } + + [Test, + TestCase(boringModule, @"[ ""en-us"", ""en-us"" ]"), + TestCase(boringModule, @"[ ""en-us"", ""es-es"", ""en-us"" ]"), + TestCase(boringModule, @"[ ""en-us"", ""de-de"", ""fr-fr"", ""de-de"" ]"), + ] + public void Validate_DuplicateLocalizations_Throws(string json, string localizations) + { + Assert.Throws( + () => TryModule(json, null, "localizations", JArray.Parse(localizations)) + ); + } + + private void TryModule(string json, string removeProperty = null, string addProperty = null, JToken addPropertyValue = null) + { + // Arrange + var jObj = JObject.Parse(json); + if (removeProperty != null) + { + jObj.Remove(removeProperty); + } + if (addProperty != null && addPropertyValue != null) + { + jObj[addProperty] = addPropertyValue; + } + + // Act + var val = new ObeysCKANSchemaValidator(); + val.Validate(new Metadata(jObj)); + } + + private const string boringModule = @"{ + ""spec_version"": 1, + ""identifier"": ""BoringModule"", + ""name"": ""Boring Module"", + ""abstract"": ""A minimal module that obeys CKAN.schema"", + ""version"": ""1.0.0"", + ""license"": ""MIT"", + ""download"": ""https://mysite.org/mymod.zip"" + }"; + } +} diff --git a/Tests/NetKAN/Validators/OverrideValidatorTests.cs b/Tests/NetKAN/Validators/OverrideValidatorTests.cs new file mode 100644 index 0000000000..c1ff577276 --- /dev/null +++ b/Tests/NetKAN/Validators/OverrideValidatorTests.cs @@ -0,0 +1,48 @@ +using Newtonsoft.Json.Linq; +using NUnit.Framework; +using CKAN.NetKAN.Model; +using CKAN.NetKAN.Validators; + +namespace Tests.NetKAN.Validators +{ + [TestFixture] + public sealed class OverrideValidatorTests + { + [Test, + TestCase(null), + TestCase(@"[ { ""version"": ""1.0"", ""delete"": ""ksp_version"" } ]"), + TestCase(@"[ { ""version"": ""1.0"", ""override"": { ""ksp_version"": ""0.90"" } } ]"), + ] + public void Validate_ValidOverride_DoesNotThrow(string json) + { + Assert.DoesNotThrow(() => TryOverride(json)); + } + + [Test, + TestCase(@"{ ""version"": ""1.0"", ""delete"": ""ksp_version"" }"), + TestCase(@"[ { ""version"": ""1.0"" } ]"), + TestCase(@"[ { ""delete"": ""identifier"" } ]"), + ] + public void Validate_BadOverride_Throws(string json) + { + Assert.Throws(() => TryOverride(json)); + } + + private void TryOverride(string ovr) + { + // Arrange + var json = new JObject(); + json["spec_version"] = 1; + json["identifier"] = "AwesomeMod"; + if (ovr != null) + { + json["x_netkan_override"] = JToken.Parse(ovr); + } + + // Act + var val = new OverrideValidator(); + val.Validate(new Metadata(json)); + } + + } +} diff --git a/Tests/NetKAN/Validators/RelationshipsValidatorTests.cs b/Tests/NetKAN/Validators/RelationshipsValidatorTests.cs new file mode 100644 index 0000000000..2c7785712a --- /dev/null +++ b/Tests/NetKAN/Validators/RelationshipsValidatorTests.cs @@ -0,0 +1,47 @@ +using Newtonsoft.Json.Linq; +using NUnit.Framework; +using CKAN.NetKAN.Model; +using CKAN.NetKAN.Validators; + +namespace Tests.NetKAN.Validators +{ + [TestFixture] + public sealed class RelationshipsValidatorTests + { + [Test, + TestCase("v1.4", null, null), + TestCase("v1.4", "depends", @"[ { ""name"": ""ModuleManager"" } ]"), + TestCase("v1.26", "depends", @"[ { ""any_of"": [ { ""name"": ""ModuleManager"" } ] } ]"), + ] + public void Validate_ValidRelationships_DoesNotThrow(string spec_version, string relationName, string relationValue) + { + Assert.DoesNotThrow(() => TryRelationships(spec_version, relationName, relationValue)); + } + + [Test, + TestCase("v1.4", "depends", @"[ { ""name"": ""Module Manager"" } ]"), + TestCase("v1.25", "depends", @"[ { ""any_of"": [ { ""name"": ""ModuleManager"" } ] } ]"), + TestCase("v1.26", "depends", @"[ { ""any_of"": [ { ""name"": ""Module Manager"" } ] } ]"), + ] + public void Validate_BadRelationships_Throws(string spec_version, string relationName, string relationValue) + { + Assert.Throws(() => TryRelationships(spec_version, relationName, relationValue)); + } + + private void TryRelationships(string spec_version, string relationName, string relationValue) + { + // Arrange + var json = new JObject(); + json["spec_version"] = spec_version; + json["identifier"] = "AwesomeMod"; + if (relationName != null && relationValue != null) + { + json[relationName] = JToken.Parse(relationValue); + } + + // Act + var val = new RelationshipsValidator(); + val.Validate(new Metadata(json)); + } + } +} diff --git a/Tests/NetKAN/Validators/ReplacedByValidatorTests.cs b/Tests/NetKAN/Validators/ReplacedByValidatorTests.cs new file mode 100644 index 0000000000..de50209fb2 --- /dev/null +++ b/Tests/NetKAN/Validators/ReplacedByValidatorTests.cs @@ -0,0 +1,44 @@ +using Newtonsoft.Json.Linq; +using NUnit.Framework; +using CKAN.NetKAN.Model; +using CKAN.NetKAN.Validators; + +namespace Tests.NetKAN.Validators +{ + [TestFixture] + public sealed class ReplacedByValidatorTests + { + [Test, + TestCase("v1.4", null), + TestCase("v1.26", @"{ ""name"": ""AwesomeModContinued"" }"), + ] + public void Validate_ValidReplacement_DoesNotThrow(string spec_version, string replacement) + { + Assert.DoesNotThrow(() => TryRelationships(spec_version, replacement)); + } + + [Test, + TestCase("v1.25", @"{ ""name"": ""AwesomeModContinued"" }"), + ] + public void Validate_BadReplacement_Throws(string spec_version, string replacement) + { + Assert.Throws(() => TryRelationships(spec_version, replacement)); + } + + private void TryRelationships(string spec_version, string replacement) + { + // Arrange + var json = new JObject(); + json["spec_version"] = spec_version; + json["identifier"] = "AwesomeMod"; + if (replacement != null) + { + json["replaced_by"] = JToken.Parse(replacement); + } + + // Act + var val = new ReplacedByValidator(); + val.Validate(new Metadata(json)); + } + } +} diff --git a/Tests/NetKAN/Validators/SpecVersionFormatValidatorTests.cs b/Tests/NetKAN/Validators/SpecVersionFormatValidatorTests.cs new file mode 100644 index 0000000000..3b99f80920 --- /dev/null +++ b/Tests/NetKAN/Validators/SpecVersionFormatValidatorTests.cs @@ -0,0 +1,45 @@ +using Newtonsoft.Json.Linq; +using NUnit.Framework; +using CKAN.NetKAN.Model; +using CKAN.NetKAN.Validators; + +namespace Tests.NetKAN.Validators +{ + [TestFixture] + public sealed class SpecVersionFormatValidatorTests + { + [Test, + TestCase("1"), + TestCase("v1.4"), + TestCase("v1.26"), + ] + public void Validate_ValidSpecVersion_DoesNotThrow(string spec_version) + { + Assert.DoesNotThrow(() => TrySpecVersion(spec_version)); + } + + [Test, + //TestCase(null), // NetKAN.Model.Metadata can't handle this, so we can't test null + TestCase(""), + TestCase("0"), + TestCase("2"), + TestCase("1.4"), + TestCase("v1.4.1"), + ] + public void Validate_BadSpecVersion_Throws(string spec_version) + { + Assert.Throws(() => TrySpecVersion(spec_version)); + } + + private void TrySpecVersion(string spec_version) + { + // Arrange + var json = new JObject(); + json["spec_version"] = spec_version; + + // Act + var val = new SpecVersionFormatValidator(); + val.Validate(new Metadata(json)); + } + } +} diff --git a/Tests/NetKAN/Validators/VersionStrictValidatorTests.cs b/Tests/NetKAN/Validators/VersionStrictValidatorTests.cs new file mode 100644 index 0000000000..9ce8565132 --- /dev/null +++ b/Tests/NetKAN/Validators/VersionStrictValidatorTests.cs @@ -0,0 +1,44 @@ +using Newtonsoft.Json.Linq; +using NUnit.Framework; +using CKAN.NetKAN.Model; +using CKAN.NetKAN.Validators; + +namespace Tests.NetKAN.Validators +{ + [TestFixture] + public sealed class VersionStrictValidatorTests + { + [Test, + TestCase("v1.2", false), + TestCase("v1.16", true), + ] + public void Validate_GoodStrictVersion_DoesNotThrow(string spec_version, bool strict) + { + Assert.DoesNotThrow(() => TryVersionStrict(spec_version, strict)); + } + + [Test, + TestCase("v1.15", true), + ] + public void Validate_BadStrictVersion_Throws(string spec_version, bool strict) + { + Assert.Throws(() => TryVersionStrict(spec_version, strict)); + } + + private void TryVersionStrict(string spec_version, bool strict) + { + // Arrange + var json = new JObject(); + json["spec_version"] = spec_version; + json["identifier"] = "AwesomeMod"; + if (strict) + { + json["ksp_version_strict"] = true; + } + + // Act + var val = new VersionStrictValidator(); + val.Validate(new Metadata(json)); + } + } +} diff --git a/Tests/Tests.csproj b/Tests/Tests.csproj index 5bd00ace61..6b8d6f141b 100644 --- a/Tests/Tests.csproj +++ b/Tests/Tests.csproj @@ -136,6 +136,18 @@ + + + + + + + + + + + +