add detailed manifest validation errors at build time

This commit is contained in:
Tyler 2022-10-18 20:03:28 -05:00
parent 0e4dd8a7b4
commit 61d6ec12da
No known key found for this signature in database
GPG Key ID: 76974E65DA9B27D8
4 changed files with 138 additions and 101 deletions

View File

@ -7,7 +7,10 @@ using System.Reflection;
using System.Text.RegularExpressions;
using Microsoft.Build.Framework;
using Microsoft.Build.Utilities;
using Newtonsoft.Json;
using StardewModdingAPI.ModBuildConfig.Framework;
using StardewModdingAPI.Toolkit.Serialization;
using StardewModdingAPI.Toolkit.Serialization.Models;
using StardewModdingAPI.Toolkit.Utilities;
namespace StardewModdingAPI.ModBuildConfig
@ -75,6 +78,34 @@ namespace StardewModdingAPI.ModBuildConfig
this.Log.LogMessage(MessageImportance.High, $"[mod build package] Handling build with options {string.Join(", ", properties)}");
}
// check if manifest file exists
FileInfo manifestFile = new(Path.Combine(this.ProjectDir, "manifest.json"));
if (!manifestFile.Exists)
{
this.Log.LogError("[mod build package] The mod does not have a manifest.json file.");
return false;
}
// check if the json is valid
Manifest manifest;
try
{
new JsonHelper().ReadJsonFileIfExists(manifestFile.FullName, out manifest);
} catch (JsonReaderException ex)
{
// log the inner exception, otherwise the message will be generic
Exception exToShow = ex.InnerException ?? ex;
this.Log.LogError($"[mod build package] Failed to parse manifest.json: {exToShow.Message}");
return false;
}
// validate the manifest's fields
if (!manifest.TryValidate(out string error))
{
this.Log.LogError($"[mod build package] The mod manifest is invalid: {error}");
return false;
}
if (!this.EnableModDeploy && !this.EnableModZip)
return true; // nothing to do
@ -101,7 +132,7 @@ namespace StardewModdingAPI.ModBuildConfig
// create release zip
if (this.EnableModZip)
{
string zipName = this.EscapeInvalidFilenameCharacters($"{this.ModFolderName} {package.GetManifestVersion()}.zip");
string zipName = this.EscapeInvalidFilenameCharacters($"{this.ModFolderName} {manifest.Version}.zip");
string zipPath = Path.Combine(this.ModZipPath, zipName);
this.Log.LogMessage(MessageImportance.High, $"[mod build package] Generating the release zip at {zipPath}...");

View File

@ -3,8 +3,6 @@ using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using StardewModdingAPI.Toolkit.Serialization;
using StardewModdingAPI.Toolkit.Serialization.Models;
using StardewModdingAPI.Toolkit.Utilities;
namespace StardewModdingAPI.ModBuildConfig.Framework
@ -113,16 +111,6 @@ namespace StardewModdingAPI.ModBuildConfig.Framework
return new Dictionary<string, FileInfo>(this.Files, StringComparer.OrdinalIgnoreCase);
}
/// <summary>Get a semantic version from the mod manifest.</summary>
/// <exception cref="UserErrorException">The manifest is missing or invalid.</exception>
public string GetManifestVersion()
{
if (!this.Files.TryGetValue(this.ManifestFileName, out FileInfo manifestFile) || !new JsonHelper().ReadJsonFileIfExists(manifestFile.FullName, out Manifest manifest))
throw new InvalidOperationException($"The mod does not have a {this.ManifestFileName} file."); // shouldn't happen since we validate in constructor
return manifest.Version.ToString();
}
/*********
** Private methods

View File

@ -1,9 +1,12 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Linq;
using System.Text;
using Newtonsoft.Json;
using StardewModdingAPI.Toolkit.Serialization.Converters;
using StardewModdingAPI.Toolkit.Utilities;
namespace StardewModdingAPI.Toolkit.Serialization.Models
{
@ -103,6 +106,99 @@ namespace StardewModdingAPI.Toolkit.Serialization.Models
this.UpdateKeys = updateKeys ?? Array.Empty<string>();
}
/// <summary>Try to validate a manifest's fields. Fails if any invalid field is found.</summary>
/// <param name="error">The error message to display to the user.</param>
/// <returns>Returns whether the manifest was validated successfully.</returns>
public bool TryValidate(out string error)
{
// validate DLL / content pack fields
bool hasDll = !string.IsNullOrWhiteSpace(this.EntryDll);
bool isContentPack = this.ContentPackFor != null;
// validate field presence
if (!hasDll && !isContentPack)
{
error = $"manifest has no {nameof(IManifest.EntryDll)} or {nameof(IManifest.ContentPackFor)} field; must specify one.";
return false;
}
if (hasDll && isContentPack)
{
error = $"manifest sets both {nameof(IManifest.EntryDll)} and {nameof(IManifest.ContentPackFor)}, which are mutually exclusive.";
return false;
}
// validate DLL filename format
if (hasDll && this.EntryDll!.Intersect(Path.GetInvalidFileNameChars()).Any())
{
error = $"manifest has invalid filename '{this.EntryDll}' for the EntryDLL field.";
return false;
}
// validate content pack
else if (isContentPack)
{
// invalid content pack ID
if (string.IsNullOrWhiteSpace(this.ContentPackFor!.UniqueID))
{
error = $"manifest declares {nameof(IManifest.ContentPackFor)} without its required {nameof(IManifestContentPackFor.UniqueID)} field.";
return false;
}
}
// validate required fields
{
List<string> missingFields = new List<string>(3);
if (string.IsNullOrWhiteSpace(this.Name))
missingFields.Add(nameof(IManifest.Name));
if (this.Version == null || this.Version.ToString() == "0.0.0")
missingFields.Add(nameof(IManifest.Version));
if (string.IsNullOrWhiteSpace(this.UniqueID))
missingFields.Add(nameof(IManifest.UniqueID));
if (missingFields.Any())
{
error = $"manifest is missing required fields ({string.Join(", ", missingFields)}).";
return false;
}
}
// validate ID format
if (!PathUtilities.IsSlug(this.UniqueID))
{
error = "manifest specifies an invalid ID (IDs must only contain letters, numbers, underscores, periods, or hyphens).";
return false;
}
// validate dependencies
foreach (IManifestDependency? dependency in this.Dependencies)
{
// null dependency
if (dependency == null)
{
error = $"manifest has a null entry under {nameof(IManifest.Dependencies)}.";
return false;
}
// missing ID
if (string.IsNullOrWhiteSpace(dependency.UniqueID))
{
error = $"manifest has a {nameof(IManifest.Dependencies)} entry with no {nameof(IManifestDependency.UniqueID)} field.";
return false;
}
// invalid ID
if (!PathUtilities.IsSlug(dependency.UniqueID))
{
error = $"manifest has a {nameof(IManifest.Dependencies)} entry with an invalid {nameof(IManifestDependency.UniqueID)} field (IDs must only contain letters, numbers, underscores, periods, or hyphens).";
return false;
}
}
error = "";
return true;
}
/// <summary>Override the update keys loaded from the mod info.</summary>
/// <param name="updateKeys">The new update keys to set.</param>
internal void OverrideUpdateKeys(params string[] updateKeys)

View File

@ -8,7 +8,6 @@ using StardewModdingAPI.Toolkit.Framework.ModData;
using StardewModdingAPI.Toolkit.Framework.ModScanning;
using StardewModdingAPI.Toolkit.Framework.UpdateData;
using StardewModdingAPI.Toolkit.Serialization.Models;
using StardewModdingAPI.Toolkit.Utilities;
using StardewModdingAPI.Toolkit.Utilities.PathLookups;
namespace StardewModdingAPI.Framework.ModLoading
@ -126,100 +125,23 @@ namespace StardewModdingAPI.Framework.ModLoading
continue;
}
// validate DLL / content pack fields
// check for dll if it's supposed to have one
if (!string.IsNullOrEmpty(mod.Manifest.EntryDll) && validateFilesExist)
{
bool hasDll = !string.IsNullOrWhiteSpace(mod.Manifest.EntryDll);
bool isContentPack = mod.Manifest.ContentPackFor != null;
// validate field presence
if (!hasDll && !isContentPack)
IFileLookup pathLookup = getFileLookup(mod.DirectoryPath);
FileInfo file = pathLookup.GetFile(mod.Manifest.EntryDll!);
if (!file.Exists)
{
mod.SetStatus(ModMetadataStatus.Failed, ModFailReason.InvalidManifest, $"its manifest has no {nameof(IManifest.EntryDll)} or {nameof(IManifest.ContentPackFor)} field; must specify one.");
continue;
}
if (hasDll && isContentPack)
{
mod.SetStatus(ModMetadataStatus.Failed, ModFailReason.InvalidManifest, $"its manifest sets both {nameof(IManifest.EntryDll)} and {nameof(IManifest.ContentPackFor)}, which are mutually exclusive.");
continue;
}
// validate DLL
if (hasDll)
{
// invalid filename format
if (mod.Manifest.EntryDll!.Intersect(Path.GetInvalidFileNameChars()).Any())
{
mod.SetStatus(ModMetadataStatus.Failed, ModFailReason.InvalidManifest, $"its manifest has invalid filename '{mod.Manifest.EntryDll}' for the EntryDLL field.");
continue;
}
// file doesn't exist
if (validateFilesExist)
{
IFileLookup pathLookup = getFileLookup(mod.DirectoryPath);
FileInfo file = pathLookup.GetFile(mod.Manifest.EntryDll!);
if (!file.Exists)
{
mod.SetStatus(ModMetadataStatus.Failed, ModFailReason.InvalidManifest, $"its DLL '{mod.Manifest.EntryDll}' doesn't exist.");
continue;
}
}
}
// validate content pack
else
{
// invalid content pack ID
if (string.IsNullOrWhiteSpace(mod.Manifest.ContentPackFor!.UniqueID))
{
mod.SetStatus(ModMetadataStatus.Failed, ModFailReason.InvalidManifest, $"its manifest declares {nameof(IManifest.ContentPackFor)} without its required {nameof(IManifestContentPackFor.UniqueID)} field.");
continue;
}
}
}
// validate required fields
{
List<string> missingFields = new List<string>(3);
if (string.IsNullOrWhiteSpace(mod.Manifest.Name))
missingFields.Add(nameof(IManifest.Name));
if (mod.Manifest.Version == null || mod.Manifest.Version.ToString() == "0.0.0")
missingFields.Add(nameof(IManifest.Version));
if (string.IsNullOrWhiteSpace(mod.Manifest.UniqueID))
missingFields.Add(nameof(IManifest.UniqueID));
if (missingFields.Any())
{
mod.SetStatus(ModMetadataStatus.Failed, ModFailReason.InvalidManifest, $"its manifest is missing required fields ({string.Join(", ", missingFields)}).");
mod.SetStatus(ModMetadataStatus.Failed, ModFailReason.InvalidManifest, $"its DLL '{mod.Manifest.EntryDll}' doesn't exist.");
continue;
}
}
// validate ID format
if (!PathUtilities.IsSlug(mod.Manifest.UniqueID))
mod.SetStatus(ModMetadataStatus.Failed, ModFailReason.InvalidManifest, "its manifest specifies an invalid ID (IDs must only contain letters, numbers, underscores, periods, or hyphens).");
// validate dependencies
foreach (IManifestDependency? dependency in mod.Manifest.Dependencies)
// validate manifest
if (mod.Manifest is Manifest manifest && !manifest.TryValidate(out string manifestError))
{
// null dependency
if (dependency == null)
{
mod.SetStatus(ModMetadataStatus.Failed, ModFailReason.InvalidManifest, $"its manifest has a null entry under {nameof(IManifest.Dependencies)}.");
continue;
}
// missing ID
if (string.IsNullOrWhiteSpace(dependency.UniqueID))
{
mod.SetStatus(ModMetadataStatus.Failed, ModFailReason.InvalidManifest, $"its manifest has a {nameof(IManifest.Dependencies)} entry with no {nameof(IManifestDependency.UniqueID)} field.");
continue;
}
// invalid ID
if (!PathUtilities.IsSlug(dependency.UniqueID))
mod.SetStatus(ModMetadataStatus.Failed, ModFailReason.InvalidManifest, $"its manifest has a {nameof(IManifest.Dependencies)} entry with an invalid {nameof(IManifestDependency.UniqueID)} field (IDs must only contain letters, numbers, underscores, periods, or hyphens).");
mod.SetStatus(ModMetadataStatus.Failed, ModFailReason.InvalidManifest, $"its {manifestError}");
continue;
}
}