diff --git a/src/Metin2Launcher/Config/InstallDir.cs b/src/Metin2Launcher/Config/InstallDir.cs new file mode 100644 index 0000000..d16006e --- /dev/null +++ b/src/Metin2Launcher/Config/InstallDir.cs @@ -0,0 +1,136 @@ +using System.Diagnostics; + +namespace Metin2Launcher.Config; + +/// +/// Resolves the install directory the launcher operates on, with strict +/// safety rules so the updater can never clobber a user's home directory. +/// +/// The contract, in priority order: +/// +/// 1. The install directory is the directory containing the running +/// launcher executable (Process.MainModule.FileName). It is NOT +/// Directory.GetCurrentDirectory() — a user who runs +/// wine /path/to/Metin2Launcher.exe from their home directory +/// must not cause the launcher to treat their home as the install dir. +/// +/// 2. The resolved directory is rejected if it matches a known-dangerous +/// target: the user's home directory itself, common system directories, +/// or the filesystem root. In that case the launcher refuses to run +/// with throwing . +/// +/// 3. If METIN2_INSTALL_DIR is set in the environment, that value +/// wins over the exe location. Used for local dev and CI, where the +/// launcher is run from bin/Release/net8.0/ and we want the +/// installed client to land in a separate sandbox. +/// +/// There is no CLI override. The previous --install-dir flag was +/// silently ignored anyway; re-adding it without thinking through the +/// safety semantics would reintroduce the same footgun. +/// +public static class InstallDir +{ + public sealed class UnsafeInstallDirException : Exception + { + public UnsafeInstallDirException(string message) : base(message) { } + } + + public const string EnvOverride = "METIN2_INSTALL_DIR"; + + public static string Resolve() + { + var candidate = ResolveCandidate(); + var full = Path.GetFullPath(candidate); + AssertSafe(full); + return full; + } + + private static string ResolveCandidate() + { + var env = Environment.GetEnvironmentVariable(EnvOverride); + if (!string.IsNullOrWhiteSpace(env)) + return env; + + var exePath = Process.GetCurrentProcess().MainModule?.FileName; + if (!string.IsNullOrEmpty(exePath)) + { + var dir = Path.GetDirectoryName(exePath); + if (!string.IsNullOrEmpty(dir)) + return dir; + } + + // Last-resort fallback: current working directory. This is the + // legacy behaviour and is inherently unsafe, so we still run the + // safety gate below on whatever it resolves to. + return Directory.GetCurrentDirectory(); + } + + private static void AssertSafe(string full) + { + // Normalize trailing separator for comparison. + var normalized = full.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + if (normalized.Length == 0) + normalized = Path.DirectorySeparatorChar.ToString(); + + foreach (var danger in DangerousDirs()) + { + if (string.Equals(normalized, danger, StringComparison.OrdinalIgnoreCase)) + throw new UnsafeInstallDirException( + $"refusing to use {full} as install directory — this is a user/system " + + $"location. Create a dedicated folder (e.g. {SuggestedExample()}) " + + $"and run the launcher from inside it, or set {EnvOverride}=."); + } + } + + private static string SuggestedExample() + { + if (OperatingSystem.IsWindows()) + return @"C:\Games\Metin2"; + var home = Environment.GetEnvironmentVariable("HOME") ?? "/home/you"; + return $"{home}/Games/Metin2"; + } + + private static IEnumerable DangerousDirs() + { + var list = new List(); + + // User home dir and common subdirs a user would *not* want + // overwritten by a game client installer. + var home = Environment.GetEnvironmentVariable("HOME") + ?? Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + if (!string.IsNullOrEmpty(home)) + { + list.Add(home.TrimEnd(Path.DirectorySeparatorChar)); + foreach (var sub in new[] { "Desktop", "Documents", "Downloads", "Music", + "Pictures", "Videos", "plocha", "Dokumenty", + "Stažené" }) + { + list.Add(Path.Combine(home, sub).TrimEnd(Path.DirectorySeparatorChar)); + } + } + + // Filesystem roots and system dirs. + if (OperatingSystem.IsWindows()) + { + foreach (var drive in new[] { "C:", "D:", "E:", "F:" }) + { + list.Add(drive); + list.Add(drive + @"\"); + list.Add(drive + @"\Windows"); + list.Add(drive + @"\Program Files"); + list.Add(drive + @"\Program Files (x86)"); + list.Add(drive + @"\Users"); + } + } + else + { + foreach (var d in new[] { "/", "/bin", "/boot", "/dev", "/etc", "/home", + "/lib", "/lib64", "/media", "/mnt", "/opt", + "/proc", "/root", "/run", "/sbin", "/srv", + "/sys", "/tmp", "/usr", "/var" }) + list.Add(d); + } + + return list; + } +} diff --git a/src/Metin2Launcher/Program.cs b/src/Metin2Launcher/Program.cs index 97a6974..166a773 100644 --- a/src/Metin2Launcher/Program.cs +++ b/src/Metin2Launcher/Program.cs @@ -23,7 +23,24 @@ public static class Program // install / firstrun / uninstall hooks without the rest of our code running. VelopackApp.Build().SetArgs(args).Run(); - var clientRoot = Directory.GetCurrentDirectory(); + // Resolve the install directory via InstallDir (not CWD!) so users + // who run `wine /path/to/Metin2Launcher.exe` from any shell CWD + // still install into the dir the exe lives in. Dangerous locations + // (user home, system dirs) are refused outright. + string clientRoot; + try + { + clientRoot = InstallDir.Resolve(); + } + catch (InstallDir.UnsafeInstallDirException ex) + { + Console.Error.WriteLine(ex.Message); + return 4; + } + // Set CWD so downstream code using Directory.GetCurrentDirectory() + // lands in the same place as clientRoot. Keeps invariants simple. + Directory.SetCurrentDirectory(clientRoot); + var stateDir = Path.Combine(clientRoot, LauncherConfig.StateDirName); Directory.CreateDirectory(stateDir); Log.SetLogFilePath(Path.Combine(stateDir, "launcher.log"));