diff --git a/src/SMAPI/Framework/ContentManagers/ModContentManager.cs b/src/SMAPI/Framework/ContentManagers/ModContentManager.cs
index f11ce9648..52287445a 100644
--- a/src/SMAPI/Framework/ContentManagers/ModContentManager.cs
+++ b/src/SMAPI/Framework/ContentManagers/ModContentManager.cs
@@ -30,6 +30,9 @@ internal sealed class ModContentManager : BaseContentManager
/*********
** Fields
*********/
+ /// A path segment which navigates to the parent directory.
+ private const string DirectoryClimbingPathSegment = "..";
+
/// Encapsulates SMAPI's JSON file parsing.
private readonly JsonHelper JsonHelper;
@@ -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)
{
@@ -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);
}
}
}
@@ -444,10 +460,10 @@ private void FixTilesheetPaths(Map map, string relativeMapPath, bool fixEagerPat
/// The folder path containing the map, relative to the mod folder.
/// The tilesheet path to load.
/// The found asset name.
- /// A message indicating why the file couldn't be loaded.
+ /// A message indicating why the file couldn't be loaded, if applicable.
/// Returns whether the asset name was found.
/// See remarks on .
- 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;
@@ -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(localKey).Exists)
@@ -522,11 +540,17 @@ 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))
@@ -534,4 +558,14 @@ private string GetContentKeyForTilesheetImageSource(string relativePath)
return key;
}
+
+ /// Get an exception indicating that a map asset can't be loaded because one of its tilesheets has an invalid path.
+ /// The relative path to the folder which contains the map asset, relative to the mod folder.
+ /// The tilesheet's path to the image file.
+ /// The error message indicating why loading it failed. (Basic metadata like the image source is prepended automatically.)
+ /// The inner exception which caused the error, if applicable.
+ 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);
+ }
}