From b437ca30702491c352ee57eb9d00301fc78668d9 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Mon, 15 Jun 2026 19:18:23 -0400 Subject: [PATCH] Installer: optional custom data/log file locations for CREATE DATABASE (#768) Add optional --data-path / --log-path CLI flags so the installer can place the PerformanceMonitor data (.mdf) and log (.ldf) files in caller-specified server-side directories. When omitted, behavior is unchanged: the database is created using SERVERPROPERTY('InstanceDefaultDataPath'/'InstanceDefaultLogPath'). - 01_install_database.sql declares @data_path_override/@log_path_override and an inert comment token the installer replaces with SET statements. The CREATE DATABASE step uses the override when present, else the instance default. Only applies on first creation (guarded by IF DB_ID(...) IS NULL); ignored if the database already exists. Managed Instance (engine edition 8) is unaffected. - Injection safety is two-layer: PathValidation rejects relative paths, control characters (CR/LF/tab), and "<>|*? plus a length cap; the C# layer doubles single quotes when building the SET literal; and the SQL builds the FILENAME literal via REPLACE(@path, N'''', N''''''). BuildFilePathOverrideSql re-validates so the Core API is self-defending regardless of caller. - New PathValidation class (Installer.Core) with 34 unit tests; help text, usage doc, and README options table updated. Co-Authored-By: Claude Opus 4.8 (1M context) --- Installer.Core/InstallationService.cs | 97 +++++++++++++++- Installer.Core/PathValidation.cs | 151 +++++++++++++++++++++++++ Installer.Tests/PathValidationTests.cs | 128 +++++++++++++++++++++ Installer/Program.cs | 82 +++++++++++++- README.md | 10 ++ install/01_install_database.sql | 40 +++++-- 6 files changed, 493 insertions(+), 15 deletions(-) create mode 100644 Installer.Core/PathValidation.cs create mode 100644 Installer.Tests/PathValidationTests.cs 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