diff --git a/Installer.Core/InstallationService.cs b/Installer.Core/InstallationService.cs index ded2cb18..258dd13d 100644 --- a/Installer.Core/InstallationService.cs +++ b/Installer.Core/InstallationService.cs @@ -300,9 +300,32 @@ await CleanInstallAsync(connectionString, progress, cancellationToken) return true; } + /// + /// Token in 01_install_database.sql that the installer replaces with + /// SET statements supplying custom data/log file paths (issue #768). + /// When left untouched it is an inert SQL comment and the SERVERPROPERTY + /// defaults are used. + /// + private const string FilePathOverrideToken = "/*__PM_FILE_PATH_OVERRIDES__*/"; + /// /// Execute SQL installation files from the given ScriptProvider. /// + /// + /// Optional server-side directory for the PerformanceMonitor data file. + /// When supplied, it overrides SERVERPROPERTY('InstanceDefaultDataPath') + /// on first creation of the database. Ignored if the database already + /// exists. (Placed after cancellationToken so existing callers that pass + /// the token positionally keep compiling.) + /// + /// + /// Optional server-side directory for the PerformanceMonitor log file. + /// + [System.Diagnostics.CodeAnalysis.SuppressMessage( + "Design", + "CA1068:CancellationToken parameters must come last", + Justification = "dataPath/logPath are appended after cancellationToken so existing " + + "callers that pass the token positionally (e.g. Dashboard AddServerDialog) keep compiling.")] public static async Task ExecuteInstallationAsync( string connectionString, ScriptProvider provider, @@ -310,7 +333,9 @@ public static async Task ExecuteInstallationAsync( bool resetSchedule = false, IProgress? progress = null, Func? preValidationAction = null, - CancellationToken cancellationToken = default) + CancellationToken cancellationToken = default, + string? dataPath = null, + string? logPath = null) { var scriptFiles = provider.GetInstallFiles(); ArgumentNullException.ThrowIfNull(scriptFiles); @@ -410,6 +435,24 @@ Files execute without transaction wrapping because many contain DDL. }); } + /*Inject custom data/log file paths into the CREATE DATABASE step (#768). + Only applies on first creation; if the database already exists the + guarded block is skipped, so the paths are silently ignored.*/ + if (fileName.StartsWith("01_", StringComparison.Ordinal) && + (!string.IsNullOrWhiteSpace(dataPath) || !string.IsNullOrWhiteSpace(logPath))) + { + sqlContent = sqlContent.Replace( + FilePathOverrideToken, + BuildFilePathOverrideSql(dataPath, logPath), + StringComparison.Ordinal); + + progress?.Report(new InstallationProgress + { + Message = "Applying custom database file path(s) to CREATE DATABASE...", + Status = "Info" + }); + } + /*Remove SQLCMD directives*/ sqlContent = Patterns.SqlCmdDirectivePattern.Replace(sqlContent, ""); @@ -501,6 +544,58 @@ Files execute without transaction wrapping because many contain DDL. return result; } + /// + /// Builds the T-SQL that sets the override path variables in + /// 01_install_database.sql. Each path is normalized to end with a + /// separator and single quotes are doubled so the value cannot break out + /// of the surrounding N'...' literal. The install script applies a second + /// REPLACE(...) escape when it concatenates the value into the dynamic + /// CREATE DATABASE statement (defense in depth). + /// + private static string BuildFilePathOverrideSql(string? dataPath, string? logPath) + { + var sb = new StringBuilder(); + + if (!string.IsNullOrWhiteSpace(dataPath)) + { + AppendPathOverride(sb, "@data_path_override", dataPath); + } + + if (!string.IsNullOrWhiteSpace(logPath)) + { + AppendPathOverride(sb, "@log_path_override", logPath); + } + + return sb.ToString(); + } + + /// + /// Appends a "SET @override = N'...'" statement for a validated path. + /// Re-validates so the no-control-character / absolute-path guarantees hold + /// for any caller (not just the CLI, which already validates), then escapes + /// the value before embedding it in the single-quoted literal. + /// + private static void AppendPathOverride(StringBuilder sb, string variableName, string path) + { + if (!PathValidation.TryValidateDirectory(path, out string normalized, out string error)) + { + throw new ArgumentException($"Invalid database file path '{path}': {error}", nameof(path)); + } + + sb.Append("SET ") + .Append(variableName) + .Append(" = N'") + .Append(EscapeSqlStringLiteral(normalized)) + .Append("';\n "); + } + + /// + /// Doubles single quotes so a value can be embedded safely inside a + /// single-quoted T-SQL string literal. + /// + private static string EscapeSqlStringLiteral(string value) => + value.Replace("'", "''", StringComparison.Ordinal); + /// /// Run validation (master collector) after installation. /// diff --git a/Installer.Core/PathValidation.cs b/Installer.Core/PathValidation.cs new file mode 100644 index 00000000..fc6e8554 --- /dev/null +++ b/Installer.Core/PathValidation.cs @@ -0,0 +1,151 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System.Buffers; + +namespace Installer.Core; + +/// +/// Validates and normalizes user-supplied database file directory paths +/// (the --data-path / --log-path installer options, issue #768). +/// +/// The path is a SERVER-SIDE directory: it is where SQL Server places the +/// PerformanceMonitor data/log files, so it is validated for shape only +/// (absolute, no illegal characters, sane length). It is deliberately NOT +/// checked for existence on the machine running the installer, which may be +/// a different host than the SQL Server. +/// +public static class PathValidation +{ + /// + /// Maximum directory length. Leaves room for the appended file name + /// (e.g., "PerformanceMonitor_log.ldf") inside the nvarchar(512) variable. + /// + private const int MaxDirectoryLength = 480; + + /// + /// Characters that are never valid in a Windows path and that we also + /// reject for Linux targets as a defensive measure. The single quote is + /// deliberately allowed (it is legal in a Windows path, e.g. C:\Bob's Data\) + /// and is escaped before it reaches T-SQL. + /// + private static readonly SearchValues ForbiddenCharacters = + SearchValues.Create("\"<>|*?"); + + /// + /// Validates a user-supplied directory path and, on success, returns a + /// normalized form that ends with a path separator. + /// + /// Raw path from the command line. + /// Normalized path (with trailing separator) when valid. + /// Human-readable reason when invalid. + /// True when the path is acceptable. + public static bool TryValidateDirectory(string? input, out string normalized, out string error) + { + normalized = string.Empty; + error = string.Empty; + + if (string.IsNullOrWhiteSpace(input)) + { + error = "path is empty."; + return false; + } + + string path = input.Trim(); + + if (path.Length > MaxDirectoryLength) + { + error = $"path exceeds {MaxDirectoryLength} characters."; + return false; + } + + /*Reject control characters (includes CR/LF/tab) so a path can't smuggle + extra statements or break the surrounding T-SQL string literal.*/ + foreach (char c in path) + { + if (char.IsControl(c)) + { + error = "path contains control characters."; + return false; + } + } + + if (path.AsSpan().IndexOfAny(ForbiddenCharacters) >= 0) + { + error = "path contains invalid characters (\" < > | * ?)."; + return false; + } + + if (!IsAbsolute(path)) + { + error = "path must be absolute (e.g., D:\\SQLData, \\\\server\\share, or /var/opt/mssql)."; + return false; + } + + normalized = EnsureTrailingSeparator(path); + return true; + } + + /// + /// Returns true when the path is a fully-qualified Windows drive path + /// (C:\...), a UNC path (\\server\share), or a Linux absolute path (/...). + /// OS-independent on purpose: the installer is win-x64 but can target + /// SQL Server running on Linux. + /// + public static bool IsAbsolute(string path) + { + if (string.IsNullOrEmpty(path)) + { + return false; + } + + /*Windows drive: X:\ or X:/ */ + if (path.Length >= 3 && + char.IsLetter(path[0]) && + path[1] == ':' && + (path[2] == '\\' || path[2] == '/')) + { + return true; + } + + /*UNC: \\server\share*/ + if (path.StartsWith("\\\\", StringComparison.Ordinal)) + { + return true; + } + + /*Linux absolute: /var/...*/ + return path[0] == '/'; + } + + /// + /// Ensures the directory path ends with a separator so a file name can be + /// appended. Uses the separator style already present in the path so Linux + /// targets keep forward slashes. + /// + public static string EnsureTrailingSeparator(string path) + { + if (string.IsNullOrEmpty(path)) + { + return path; + } + + if (path.EndsWith('\\') || path.EndsWith('/')) + { + return path; + } + + /*A Linux-style path uses forward slashes; everything else uses backslashes.*/ + char separator = + path.Contains('/') && !path.Contains('\\') + ? '/' + : '\\'; + + return path + separator; + } +} diff --git a/Installer.Tests/PathValidationTests.cs b/Installer.Tests/PathValidationTests.cs new file mode 100644 index 00000000..5127c28f --- /dev/null +++ b/Installer.Tests/PathValidationTests.cs @@ -0,0 +1,128 @@ +using Installer.Core; + +namespace Installer.Tests; + +/// +/// Unit tests for PathValidation — the shape checks and normalization applied +/// to the --data-path / --log-path installer options (issue #768). These run +/// without a database. +/// +public class PathValidationTests +{ + [Theory] + [InlineData(@"C:\SQLData")] + [InlineData(@"D:\SQL Data\Monitor")] + [InlineData(@"C:/SQLData")] + [InlineData(@"\\fileserver\share\sql")] + [InlineData("/var/opt/mssql/data")] + [InlineData(@"C:\Bob's Data")] + public void TryValidateDirectory_AcceptsAbsolutePaths(string path) + { + bool ok = PathValidation.TryValidateDirectory(path, out string normalized, out string error); + + Assert.True(ok, error); + Assert.NotEmpty(normalized); + } + + [Theory] + [InlineData("SQLData")] + [InlineData(@"relative\path")] + [InlineData("data.mdf")] + public void TryValidateDirectory_RejectsRelativePaths(string path) + { + bool ok = PathValidation.TryValidateDirectory(path, out _, out string error); + + Assert.False(ok); + Assert.Contains("absolute", error, StringComparison.OrdinalIgnoreCase); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData(null)] + public void TryValidateDirectory_RejectsEmpty(string? path) + { + bool ok = PathValidation.TryValidateDirectory(path, out _, out string error); + + Assert.False(ok); + Assert.NotEmpty(error); + } + + [Theory] + [InlineData("C:\\SQL\nData")] // embedded newline (would break the T-SQL literal) + [InlineData("C:\\SQL\rData")] // embedded carriage return + [InlineData("C:\\SQL\tData")] // embedded tab + public void TryValidateDirectory_RejectsControlCharacters(string path) + { + bool ok = PathValidation.TryValidateDirectory(path, out _, out string error); + + Assert.False(ok); + Assert.Contains("control", error, StringComparison.OrdinalIgnoreCase); + } + + [Theory] + [InlineData("C:\\SQLData")] + [InlineData("C:\\SQL|Data")] + [InlineData("C:\\SQL*Data")] + [InlineData("C:\\SQL?Data")] + [InlineData("C:\\SQL\"Data")] + public void TryValidateDirectory_RejectsForbiddenCharacters(string path) + { + bool ok = PathValidation.TryValidateDirectory(path, out _, out string error); + + Assert.False(ok); + Assert.Contains("invalid", error, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void TryValidateDirectory_RejectsOverlyLongPath() + { + string longPath = @"C:\" + new string('a', 500); + + bool ok = PathValidation.TryValidateDirectory(longPath, out _, out string error); + + Assert.False(ok); + Assert.Contains("exceeds", error, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void TryValidateDirectory_NormalizesTrailingSeparator_Windows() + { + bool ok = PathValidation.TryValidateDirectory(@"C:\SQLData", out string normalized, out _); + + Assert.True(ok); + Assert.Equal(@"C:\SQLData\", normalized); + } + + [Fact] + public void TryValidateDirectory_TrimsWhitespaceBeforeValidating() + { + bool ok = PathValidation.TryValidateDirectory(" C:\\SQLData ", out string normalized, out _); + + Assert.True(ok); + Assert.Equal(@"C:\SQLData\", normalized); + } + + [Theory] + [InlineData(@"C:\SQLData", @"C:\SQLData\")] + [InlineData(@"C:\SQLData\", @"C:\SQLData\")] + [InlineData("/var/opt/mssql", "/var/opt/mssql/")] + [InlineData("/var/opt/mssql/", "/var/opt/mssql/")] + [InlineData(@"\\srv\share", @"\\srv\share\")] + public void EnsureTrailingSeparator_UsesPathStyle(string input, string expected) + { + Assert.Equal(expected, PathValidation.EnsureTrailingSeparator(input)); + } + + [Theory] + [InlineData(@"C:\x", true)] + [InlineData(@"\\server\share", true)] + [InlineData("/etc/data", true)] + [InlineData(@"relative\x", false)] + [InlineData("plainword", false)] + public void IsAbsolute_ClassifiesCorrectly(string path, bool expected) + { + Assert.Equal(expected, PathValidation.IsAbsolute(path)); + } +} diff --git a/Installer/Program.cs b/Installer/Program.cs index e71b30ab..e7f8db49 100644 --- a/Installer/Program.cs +++ b/Installer/Program.cs @@ -46,6 +46,8 @@ static async Task Main(string[] args) --reinstall Drop existing database and perform clean install --encrypt=X Connection encryption: mandatory (default), optional, strict --trust-cert Trust server certificate without validation (default: require valid cert) + --data-path DIR Server-side directory for the data (.mdf) file (first install only) + --log-path DIR Server-side directory for the log (.ldf) file (first install only) */ if (args.Any(a => a.Equals("--help", StringComparison.OrdinalIgnoreCase) || a.Equals("-h", StringComparison.OrdinalIgnoreCase))) @@ -65,6 +67,8 @@ static async Task Main(string[] args) Console.WriteLine(" --encrypt= Connection encryption: mandatory (default), optional, strict"); Console.WriteLine(" --trust-cert Trust server certificate without validation"); Console.WriteLine(" --entra Use Microsoft Entra ID interactive authentication (MFA)"); + Console.WriteLine(" --data-path Server-side directory for the data (.mdf) file (first install only)"); + Console.WriteLine(" --log-path Server-side directory for the log (.ldf) file (first install only)"); Console.WriteLine(); Console.WriteLine("Environment Variables:"); Console.WriteLine(" PM_SQL_PASSWORD SQL Auth password (avoids passing on command line)"); @@ -128,17 +132,57 @@ static async Task Main(string[] args) } } + /*Parse optional custom database file locations (#768). + Supports both --data-path= and --data-path (and --log-path). + These are server-side directories where SQL Server places the + PerformanceMonitor data/log files on first creation.*/ + string? dataPathArg = GetOptionValue(args, "--data-path"); + string? logPathArg = GetOptionValue(args, "--log-path"); + + string? dataPath = null; + string? logPath = null; + + if (dataPathArg != null) + { + if (!PathValidation.TryValidateDirectory(dataPathArg, out dataPath, out string dataPathError)) + { + Console.WriteLine($"Error: invalid --data-path: {dataPathError}"); + return (int)InstallationResultCode.InvalidArguments; + } + } + + if (logPathArg != null) + { + if (!PathValidation.TryValidateDirectory(logPathArg, out logPath, out string logPathError)) + { + Console.WriteLine($"Error: invalid --log-path: {logPathError}"); + return (int)InstallationResultCode.InvalidArguments; + } + } + + if (dataPath != null) + { + Console.WriteLine($"Custom data file directory: {dataPath} (used only when the database is first created)"); + } + if (logPath != null) + { + Console.WriteLine($"Custom log file directory: {logPath} (used only when the database is first created)"); + } + /*Filter out all --flags and their trailing values to get positional arguments - (server, username, password). Flags like --entra and --encrypt - have a following value that must also be removed.*/ + (server, username, password). Flags like --entra , --encrypt , + --data-path , and --log-path have a following value that must also + be removed.*/ var filteredArgsList = new List(); for (int i = 0; i < args.Length; i++) { if (args[i].StartsWith("--", StringComparison.Ordinal)) { - /*Skip flags that take a trailing value (--entra , --encrypt )*/ + /*Skip flags that take a trailing value (space-separated form)*/ if ((args[i].Equals("--entra", StringComparison.OrdinalIgnoreCase) - || args[i].Equals("--encrypt", StringComparison.OrdinalIgnoreCase)) + || args[i].Equals("--encrypt", StringComparison.OrdinalIgnoreCase) + || args[i].Equals("--data-path", StringComparison.OrdinalIgnoreCase) + || args[i].Equals("--log-path", StringComparison.OrdinalIgnoreCase)) && i + 1 < args.Length && !args[i + 1].StartsWith("--", StringComparison.Ordinal)) { i++; /*skip the value too*/ @@ -232,6 +276,8 @@ Automated mode with command-line arguments Console.WriteLine(" --reset-schedule Reset collection schedule to recommended defaults"); Console.WriteLine(" --encrypt= Connection encryption: mandatory (default), optional, strict"); Console.WriteLine(" --trust-cert Trust server certificate without validation (default: require valid cert)"); + Console.WriteLine(" --data-path Server-side directory for the data (.mdf) file (first install only)"); + Console.WriteLine(" --log-path Server-side directory for the log (.ldf) file (first install only)"); return (int)InstallationResultCode.InvalidArguments; } } @@ -756,7 +802,10 @@ await dependencyInstaller.InstallDependenciesAsync( Console.WriteLine($"Warning: Dependency installation encountered errors: {ex.Message}"); Console.WriteLine("Continuing with installation..."); } - }).ConfigureAwait(false); + }, + cancellationToken: default, + dataPath: dataPath, + logPath: logPath).ConfigureAwait(false); installSuccessCount = installResult.FilesSucceeded; installFailureCount = installResult.FilesFailed; @@ -1107,6 +1156,29 @@ private static string WriteErrorLog(Exception ex, string serverName, string inst return logPath; } + /* + Read an option value supporting both "--opt=value" and "--opt value" forms. + Returns null when the option is absent or has no value. A value that + itself starts with "--" (i.e., the next flag) is treated as absent. + */ + private static string? GetOptionValue(string[] args, string optionName) + { + string prefix = optionName + "="; + var equalsForm = args.FirstOrDefault(a => a.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)); + if (equalsForm != null) + { + return equalsForm.Substring(prefix.Length); + } + + int index = Array.FindIndex(args, a => a.Equals(optionName, StringComparison.OrdinalIgnoreCase)); + if (index >= 0 && index + 1 < args.Length && !args[index + 1].StartsWith("--", StringComparison.Ordinal)) + { + return args[index + 1]; + } + + return null; + } + /* Sanitize a string for use in a filename Replaces invalid characters with underscores diff --git a/README.md b/README.md index 73f3076a..0a288075 100644 --- a/README.md +++ b/README.md @@ -184,6 +184,12 @@ PerformanceMonitorInstaller.exe YourServerName --reinstall PerformanceMonitorInstaller.exe YourServerName sa YourPassword --reinstall ``` +Custom data/log file locations (applied only when the database is first created): + +``` +PerformanceMonitorInstaller.exe YourServerName --data-path D:\SQLData --log-path E:\SQLLogs +``` + Uninstall (removes database, Agent jobs, and XE sessions): ``` @@ -208,8 +214,12 @@ The installer automatically tests the connection, checks the SQL Server version | `--preserve-jobs` | Keep existing SQL Agent job schedules during upgrade | | `--encrypt=optional\|mandatory\|strict` | Connection encryption level (default: mandatory) | | `--trust-cert` | Trust server certificate without validation (default: require valid cert) | +| `--data-path DIR` | Server-side directory for the data (`.mdf`) file (used only on first install) | +| `--log-path DIR` | Server-side directory for the log (`.ldf`) file (used only on first install) | | `--help` | Show usage information and exit | +> **Custom file locations:** `--data-path` / `--log-path` set where SQL Server places the PerformanceMonitor data and log files. They take effect **only when the database is first created** — if the database already exists they are ignored. Either flag may be supplied independently; an omitted one falls back to the instance default (`SERVERPROPERTY('InstanceDefaultDataPath')` / `InstanceDefaultLogPath`). The directory is a path **on the SQL Server host** and must already exist, with the SQL Server service account holding write permission. Both `--data-path D:\SQLData` and `--data-path=D:\SQLData` forms are accepted; quote paths containing spaces. Not applicable to Azure SQL Managed Instance, which always uses its managed file layout. + **Environment variable:** Set `PM_SQL_PASSWORD` to avoid passing the password on the command line. ### Exit Codes diff --git a/install/01_install_database.sql b/install/01_install_database.sql index cacd3224..179e43a6 100644 --- a/install/01_install_database.sql +++ b/install/01_install_database.sql @@ -37,9 +37,19 @@ BEGIN DECLARE @data_path nvarchar(512) = N'', @log_path nvarchar(512) = N'', + @data_path_override nvarchar(512) = N'', + @log_path_override nvarchar(512) = N'', @sql nvarchar(max) = N'', @engine_edition integer = CONVERT(integer, SERVERPROPERTY(N'EngineEdition')); + /* + The installer injects SET statements for custom data/log file paths here + when the --data-path / --log-path options are supplied (#768). When this + script is run without those options (or outside the installer) the line + below stays an inert comment, so the SERVERPROPERTY defaults are used. + */ + /*__PM_FILE_PATH_OVERRIDES__*/ + /* Azure SQL Managed Instance (engine edition 8) does not support specifying files and filegroups in CREATE DATABASE. @@ -65,17 +75,29 @@ BEGIN @log_size_mb integer = 256; /* - Get the default data and log directories from instance properties + Use the installer-provided directories when supplied (#768); + otherwise fall back to the instance default data/log directories. + Override paths already carry a trailing separator. */ - SELECT - @data_path = + IF LEN(@data_path_override) > 0 + SET @data_path = + @data_path_override + + N'PerformanceMonitor.mdf'; + ELSE + SET @data_path = CONVERT ( nvarchar(512), SERVERPROPERTY(N'InstanceDefaultDataPath') ) + - N'PerformanceMonitor.mdf', - @log_path = + N'PerformanceMonitor.mdf'; + + IF LEN(@log_path_override) > 0 + SET @log_path = + @log_path_override + + N'PerformanceMonitor_log.ldf'; + ELSE + SET @log_path = CONVERT ( nvarchar(512), @@ -128,7 +150,7 @@ BEGIN ON PRIMARY ( NAME = N''PerformanceMonitor'', - FILENAME = N''' + @data_path + N''', + FILENAME = N''' + REPLACE(@data_path, N'''', N'''''') + N''', SIZE = ' + CONVERT(nvarchar(20), @data_size_mb) + N'MB, MAXSIZE = UNLIMITED, FILEGROWTH = 1024MB @@ -136,7 +158,7 @@ BEGIN LOG ON ( NAME = N''PerformanceMonitor_log'', - FILENAME = N''' + @log_path + N''', + FILENAME = N''' + REPLACE(@log_path, N'''', N'''''') + N''', SIZE = ' + CONVERT(nvarchar(20), @log_size_mb) + N'MB, MAXSIZE = UNLIMITED, FILEGROWTH = 64MB @@ -203,7 +225,7 @@ BEGIN ON PRIMARY ( NAME = N''PerformanceMonitor'', - FILENAME = N''' + @data_path + N''', + FILENAME = N''' + REPLACE(@data_path, N'''', N'''''') + N''', SIZE = ' + CONVERT(nvarchar(20), @data_size_mb) + N'MB, MAXSIZE = UNLIMITED, FILEGROWTH = 1024MB @@ -211,7 +233,7 @@ BEGIN LOG ON ( NAME = N''PerformanceMonitor_log'', - FILENAME = N''' + @log_path + N''', + FILENAME = N''' + REPLACE(@log_path, N'''', N'''''') + N''', SIZE = ' + CONVERT(nvarchar(20), @log_size_mb) + N'MB, MAXSIZE = UNLIMITED, FILEGROWTH = 64MB