Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 96 additions & 1 deletion Installer.Core/InstallationService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -300,17 +300,42 @@ await CleanInstallAsync(connectionString, progress, cancellationToken)
return true;
}

/// <summary>
/// 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.
/// </summary>
private const string FilePathOverrideToken = "/*__PM_FILE_PATH_OVERRIDES__*/";

/// <summary>
/// Execute SQL installation files from the given ScriptProvider.
/// </summary>
/// <param name="dataPath">
/// 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.)
/// </param>
/// <param name="logPath">
/// Optional server-side directory for the PerformanceMonitor log file.
/// </param>
[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<InstallationResult> ExecuteInstallationAsync(
string connectionString,
ScriptProvider provider,
bool cleanInstall,
bool resetSchedule = false,
IProgress<InstallationProgress>? progress = null,
Func<Task>? preValidationAction = null,
CancellationToken cancellationToken = default)
CancellationToken cancellationToken = default,
string? dataPath = null,
string? logPath = null)
{
var scriptFiles = provider.GetInstallFiles();
ArgumentNullException.ThrowIfNull(scriptFiles);
Expand Down Expand Up @@ -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, "");

Expand Down Expand Up @@ -501,6 +544,58 @@ Files execute without transaction wrapping because many contain DDL.
return result;
}

/// <summary>
/// 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).
/// </summary>
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();
}

/// <summary>
/// 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.
/// </summary>
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 ");
}

/// <summary>
/// Doubles single quotes so a value can be embedded safely inside a
/// single-quoted T-SQL string literal.
/// </summary>
private static string EscapeSqlStringLiteral(string value) =>
value.Replace("'", "''", StringComparison.Ordinal);

/// <summary>
/// Run validation (master collector) after installation.
/// </summary>
Expand Down
151 changes: 151 additions & 0 deletions Installer.Core/PathValidation.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// 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.
/// </summary>
public static class PathValidation
{
/// <summary>
/// Maximum directory length. Leaves room for the appended file name
/// (e.g., "PerformanceMonitor_log.ldf") inside the nvarchar(512) variable.
/// </summary>
private const int MaxDirectoryLength = 480;

/// <summary>
/// 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.
/// </summary>
private static readonly SearchValues<char> ForbiddenCharacters =
SearchValues.Create("\"<>|*?");

/// <summary>
/// Validates a user-supplied directory path and, on success, returns a
/// normalized form that ends with a path separator.
/// </summary>
/// <param name="input">Raw path from the command line.</param>
/// <param name="normalized">Normalized path (with trailing separator) when valid.</param>
/// <param name="error">Human-readable reason when invalid.</param>
/// <returns>True when the path is acceptable.</returns>
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;
}

/// <summary>
/// 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.
/// </summary>
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] == '/';
}

/// <summary>
/// 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.
/// </summary>
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;
}
}
Loading
Loading