Skip to content
Merged
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
70 changes: 52 additions & 18 deletions src/SMAPI/Framework/ContentManagers/ModContentManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ internal sealed class ModContentManager : BaseContentManager
/*********
** Fields
*********/
/// <summary>A path segment which navigates to the parent directory.</summary>
private const string DirectoryClimbingPathSegment = "..";

/// <summary>Encapsulates SMAPI's JSON file parsing.</summary>
private readonly JsonHelper JsonHelper;

Expand Down Expand Up @@ -399,28 +402,41 @@ private void FixTilesheetPaths(Map map, string relativeMapPath, bool fixEagerPat
this.Monitor.VerboseLog($"Fixing tilesheet paths for map '{relativeMapPath}' from mod '{this.ModName}'...");
foreach (TileSheet tilesheet in map.TileSheets)
{
// get image source
// get image path
tilesheet.ImageSource = this.NormalizePathSeparators(tilesheet.ImageSource);
string imageSource = tilesheet.ImageSource;
if (fixEagerPathPrefixes && relativeMapFolder.Length > 0 && imageSource?.StartsWith(relativeMapFolder) is true)
imageSource = imageSource[(relativeMapFolder.Length + 1)..];

// validate image source
// ensure path isn't empty
if (string.IsNullOrWhiteSpace(imageSource))
throw new SContentLoadException(ContentLoadErrorType.InvalidData, $"{this.ModName} loaded map '{relativeMapPath}' with invalid tilesheet '{tilesheet.Id}'. This tilesheet has no image source.");

// reverse incorrect eager tilesheet path prefixing
if (fixEagerPathPrefixes && relativeMapFolder.Length > 0 && imageSource.StartsWith(relativeMapFolder))
imageSource = imageSource[(relativeMapFolder.Length + 1)..];
// ensure path is relative
if (Path.IsPathRooted(imageSource))
throw this.GetPathError(relativeMapFolder, imageSource, $"Tilesheet paths must not be an absolute path ({Path.GetPathRoot(imageSource)}).");

// validate tilesheet path
string errorPrefix = $"{this.ModName} loaded map '{relativeMapPath}' with invalid tilesheet path '{imageSource}'.";
if (Path.IsPathRooted(imageSource) || PathUtilities.GetSegments(imageSource).Contains(".."))
throw new SContentLoadException(ContentLoadErrorType.InvalidData, $"{errorPrefix} Tilesheet paths must be a relative path without directory climbing (../).");
// ensure any directory climbing is valid
// Tilesheet paths are relative to either the `Content/Maps` folder or the map file. Directory climbing is
// restricted for safety and simplicity:
// 1. Can only climb at the start of the path (e.g. `../LooseSprites/Cursors` but not `Mines/../Barn`).
// 2. Can only climb once (to avoid escaping the `Content` folder).
// 3. Always relative to the `Content/Maps` folder (not the map file).
{
const string climbSegment = ModContentManager.DirectoryClimbingPathSegment;
string[] pathSegments = PathUtilities.GetSegments(imageSource);
if (pathSegments.Contains(climbSegment))
{
if (pathSegments[0] != climbSegment || pathSegments.Count(segment => segment == climbSegment) > 1)
throw this.GetPathError(relativeMapFolder, imageSource, $"Directory climbing ({climbSegment}/) is only permitted once at the start of the path.");
}
}

// load best match
try
{
if (!this.TryGetTilesheetAssetName(relativeMapFolder, imageSource, out IAssetName? assetName, out string? error))
throw new SContentLoadException(ContentLoadErrorType.InvalidData, $"{errorPrefix} {error}");
throw this.GetPathError(relativeMapFolder, imageSource, error);

if (assetName is not null)
{
Expand All @@ -435,7 +451,7 @@ private void FixTilesheetPaths(Map map, string relativeMapPath, bool fixEagerPat
if (ex is SContentLoadException)
throw;

throw new SContentLoadException(ContentLoadErrorType.InvalidData, $"{errorPrefix} The tilesheet couldn't be loaded.", ex);
throw this.GetPathError(relativeMapFolder, imageSource, "The tilesheet couldn't be loaded.", ex);
}
}
}
Expand All @@ -444,10 +460,10 @@ private void FixTilesheetPaths(Map map, string relativeMapPath, bool fixEagerPat
/// <param name="modRelativeMapFolder">The folder path containing the map, relative to the mod folder.</param>
/// <param name="relativePath">The tilesheet path to load.</param>
/// <param name="assetName">The found asset name.</param>
/// <param name="error">A message indicating why the file couldn't be loaded.</param>
/// <param name="error">A message indicating why the file couldn't be loaded, if applicable.</param>
/// <returns>Returns whether the asset name was found.</returns>
/// <remarks>See remarks on <see cref="FixTilesheetPaths"/>.</remarks>
private bool TryGetTilesheetAssetName(string modRelativeMapFolder, string relativePath, out IAssetName? assetName, out string? error)
private bool TryGetTilesheetAssetName(string modRelativeMapFolder, string relativePath, out IAssetName? assetName, [NotNullWhen(false)] out string? error)
{
error = null;

Expand All @@ -467,7 +483,9 @@ private bool TryGetTilesheetAssetName(string modRelativeMapFolder, string relati
relativePath = Path.Combine(Path.GetDirectoryName(relativePath) ?? "", filename.TrimStart('.'));
}

// get relative to map file
// get relative to map file unless path has directory climbing
const string climbSegment = ModContentManager.DirectoryClimbingPathSegment;
if (!relativePath.StartsWith(climbSegment) && PathUtilities.GetSegments(relativePath, 2)[0] != climbSegment) // directory climbing can only be at the start of the path
{
string localKey = Path.Combine(modRelativeMapFolder, relativePath);
if (this.GetModFile<Texture2D>(localKey).Exists)
Expand Down Expand Up @@ -522,16 +540,32 @@ private bool GetContentFolderFileExists(string key)
private string GetContentKeyForTilesheetImageSource(string relativePath)
{
string key = relativePath;
string topFolder = PathUtilities.GetSegments(key, limit: 2)[0];

// convert image source relative to map file into asset key
if (!topFolder.Equals("Maps", StringComparison.OrdinalIgnoreCase))
key = Path.Combine("Maps", key);
// make path relative to Content folder
{
string topFolder = PathUtilities.GetSegments(key, limit: 2)[0];
const string climbSegment = ModContentManager.DirectoryClimbingPathSegment;

if (topFolder == climbSegment)
key = key[(climbSegment.Length + 1)..];
else if (!topFolder.Equals("Maps", StringComparison.OrdinalIgnoreCase))
key = Path.Combine("Maps", key);
}

// remove file extension from unpacked file
if (key.EndsWith(".png", StringComparison.OrdinalIgnoreCase))
key = key[..^4];

return key;
}

/// <summary>Get an exception indicating that a map asset can't be loaded because one of its tilesheets has an invalid path.</summary>
/// <param name="relativeMapPath">The relative path to the folder which contains the map asset, relative to the mod folder.</param>
/// <param name="imageSource">The tilesheet's path to the image file.</param>
/// <param name="error">The error message indicating why loading it failed. (Basic metadata like the image source is prepended automatically.)</param>
/// <param name="ex">The inner exception which caused the error, if applicable.</param>
private SContentLoadException GetPathError(string relativeMapPath, string imageSource, string error, Exception? ex = null)
{
return new SContentLoadException(ContentLoadErrorType.InvalidData, $"{this.ModName} loaded map '{relativeMapPath}' with invalid tilesheet path '{imageSource}'. {error}", ex);
}
}