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); + } }