| | | 1 | | using System.Reflection; |
| | | 2 | | using Kestrun.Runner; |
| | | 3 | | using System.Diagnostics; |
| | | 4 | | using System.ComponentModel; |
| | | 5 | | using System.Security.Principal; |
| | | 6 | | using System.Runtime.Versioning; |
| | | 7 | | using System.Runtime.InteropServices; |
| | | 8 | | using System.IO.Compression; |
| | | 9 | | using System.Formats.Tar; |
| | | 10 | | using System.Net.Http.Headers; |
| | | 11 | | using System.Security.Cryptography; |
| | | 12 | | using System.Text; |
| | | 13 | | using System.Text.RegularExpressions; |
| | | 14 | | using System.Xml.Linq; |
| | | 15 | | |
| | | 16 | | namespace Kestrun.Tool; |
| | | 17 | | |
| | | 18 | | internal static partial class Program |
| | | 19 | | { |
| | | 20 | | private static int Main(string[] args) |
| | | 21 | | { |
| | 2 | 22 | | if (TryHandleInternalServiceRegisterMode(args, out var serviceRegisterExitCode)) |
| | | 23 | | { |
| | 0 | 24 | | return serviceRegisterExitCode; |
| | | 25 | | } |
| | | 26 | | |
| | 2 | 27 | | var globalOptions = ParseGlobalOptions(args); |
| | 2 | 28 | | var commandArgs = globalOptions.CommandArgs; |
| | | 29 | | |
| | 2 | 30 | | if (TryHandleMetaCommands(commandArgs, out var metaExitCode)) |
| | | 31 | | { |
| | 1 | 32 | | return metaExitCode; |
| | | 33 | | } |
| | | 34 | | |
| | 1 | 35 | | if (!TryParseArguments(commandArgs, out var parsedCommand, out var parseError)) |
| | | 36 | | { |
| | 1 | 37 | | Console.Error.WriteLine(parseError); |
| | 1 | 38 | | PrintUsage(); |
| | 1 | 39 | | return 2; |
| | | 40 | | } |
| | | 41 | | |
| | 0 | 42 | | if (TryDispatchParsedCommand(parsedCommand, globalOptions, args, out var commandExitCode)) |
| | | 43 | | { |
| | 0 | 44 | | return commandExitCode; |
| | | 45 | | } |
| | | 46 | | // If the command was not handled by dispatch, it must be a run command. Execute the default run mode path. |
| | 0 | 47 | | return ExecuteRunMode(parsedCommand, globalOptions.SkipGalleryCheck); |
| | | 48 | | } |
| | | 49 | | |
| | | 50 | | /// <summary> |
| | | 51 | | /// Handles internal Windows-only service registration mode prior to normal command parsing. |
| | | 52 | | /// </summary> |
| | | 53 | | /// <param name="args">Raw command-line arguments.</param> |
| | | 54 | | /// <param name="exitCode">Exit code when internal service registration mode is handled.</param> |
| | | 55 | | /// <returns>True when internal service registration mode was handled.</returns> |
| | | 56 | | private static bool TryHandleInternalServiceRegisterMode(string[] args, out int exitCode) |
| | | 57 | | { |
| | 4 | 58 | | exitCode = 0; |
| | | 59 | | |
| | 4 | 60 | | if (TryParseServiceRegisterArguments(args, out var serviceRegisterOptions, out var serviceRegisterError)) |
| | | 61 | | { |
| | 0 | 62 | | if (!OperatingSystem.IsWindows()) |
| | | 63 | | { |
| | 0 | 64 | | Console.Error.WriteLine("Internal service registration mode is only supported on Windows."); |
| | 0 | 65 | | exitCode = 1; |
| | 0 | 66 | | return true; |
| | | 67 | | } |
| | | 68 | | |
| | 0 | 69 | | exitCode = RegisterWindowsService(serviceRegisterOptions!); |
| | 0 | 70 | | return true; |
| | | 71 | | } |
| | | 72 | | |
| | 4 | 73 | | if (string.IsNullOrWhiteSpace(serviceRegisterError)) |
| | | 74 | | { |
| | 3 | 75 | | return false; |
| | | 76 | | } |
| | | 77 | | |
| | 1 | 78 | | Console.Error.WriteLine(serviceRegisterError); |
| | 1 | 79 | | exitCode = 2; |
| | 1 | 80 | | return true; |
| | | 81 | | } |
| | | 82 | | |
| | | 83 | | /// <summary> |
| | | 84 | | /// Dispatches parsed non-run commands and returns an exit code when handled. |
| | | 85 | | /// </summary> |
| | | 86 | | /// <param name="parsedCommand">Parsed command.</param> |
| | | 87 | | /// <param name="globalOptions">Parsed global options.</param> |
| | | 88 | | /// <param name="args">Raw command-line arguments.</param> |
| | | 89 | | /// <param name="exitCode">Exit code when command is handled.</param> |
| | | 90 | | /// <returns>True when the command mode is handled by dispatch.</returns> |
| | | 91 | | private static bool TryDispatchParsedCommand(ParsedCommand parsedCommand, GlobalOptions globalOptions, string[] args |
| | | 92 | | { |
| | 1 | 93 | | switch (parsedCommand.Mode) |
| | | 94 | | { |
| | | 95 | | case CommandMode.ServiceInstall: |
| | 0 | 96 | | exitCode = InstallService(parsedCommand, globalOptions.SkipGalleryCheck); |
| | 0 | 97 | | return true; |
| | | 98 | | case CommandMode.ServiceUpdate: |
| | 0 | 99 | | exitCode = UpdateService(parsedCommand); |
| | 0 | 100 | | return true; |
| | | 101 | | case CommandMode.ModuleInstall: |
| | | 102 | | case CommandMode.ModuleUpdate: |
| | | 103 | | case CommandMode.ModuleRemove: |
| | | 104 | | case CommandMode.ModuleInfo: |
| | 0 | 105 | | exitCode = HandleModuleCommand(parsedCommand, args); |
| | 0 | 106 | | return true; |
| | | 107 | | case CommandMode.ServiceRemove: |
| | 0 | 108 | | exitCode = HandleServiceRemoveCommand(parsedCommand, args); |
| | 0 | 109 | | return true; |
| | | 110 | | case CommandMode.ServiceStart: |
| | 0 | 111 | | exitCode = HandleServiceStartCommand(parsedCommand, args); |
| | 0 | 112 | | return true; |
| | | 113 | | case CommandMode.ServiceStop: |
| | 0 | 114 | | exitCode = HandleServiceStopCommand(parsedCommand, args); |
| | 0 | 115 | | return true; |
| | | 116 | | case CommandMode.ServiceQuery: |
| | 0 | 117 | | exitCode = QueryService(parsedCommand); |
| | 0 | 118 | | return true; |
| | | 119 | | case CommandMode.ServiceInfo: |
| | 0 | 120 | | exitCode = InfoService(parsedCommand); |
| | 0 | 121 | | return true; |
| | | 122 | | default: |
| | 1 | 123 | | exitCode = 0; |
| | 1 | 124 | | return false; |
| | | 125 | | } |
| | | 126 | | } |
| | | 127 | | |
| | | 128 | | /// <summary> |
| | | 129 | | /// Handles module command execution, including Windows elevation for global scope changes. |
| | | 130 | | /// </summary> |
| | | 131 | | /// <param name="parsedCommand">Parsed module command.</param> |
| | | 132 | | /// <param name="args">Raw command-line arguments.</param> |
| | | 133 | | /// <returns>Process exit code.</returns> |
| | | 134 | | private static int HandleModuleCommand(ParsedCommand parsedCommand, string[] args) |
| | | 135 | | { |
| | 0 | 136 | | if (OperatingSystem.IsWindows() && RequiresWindowsElevationForGlobalModuleOperation(parsedCommand)) |
| | | 137 | | { |
| | 0 | 138 | | return RelaunchElevatedOnWindows(args); |
| | | 139 | | } |
| | | 140 | | |
| | | 141 | | // For non-Windows OSes, attempt module management without elevation and rely on error handling for permission i |
| | 0 | 142 | | return ManageModuleCommand(parsedCommand); |
| | | 143 | | } |
| | | 144 | | |
| | | 145 | | /// <summary> |
| | | 146 | | /// Returns true when a module install/update/remove command requires Windows elevation. |
| | | 147 | | /// </summary> |
| | | 148 | | /// <param name="parsedCommand">Parsed command.</param> |
| | | 149 | | /// <returns>True when the command targets global scope on Windows without elevation.</returns> |
| | | 150 | | private static bool RequiresWindowsElevationForGlobalModuleOperation(ParsedCommand parsedCommand) |
| | | 151 | | { |
| | 0 | 152 | | if (!OperatingSystem.IsWindows()) |
| | | 153 | | { |
| | 0 | 154 | | return false; |
| | | 155 | | } |
| | | 156 | | // Global scope module operations require admin rights on Windows. |
| | 0 | 157 | | return parsedCommand.Mode is CommandMode.ModuleInstall or CommandMode.ModuleUpdate or CommandMode.ModuleRemove |
| | 0 | 158 | | && parsedCommand.ModuleScope == ModuleStorageScope.Global |
| | 0 | 159 | | && !IsWindowsAdministrator(); |
| | | 160 | | } |
| | | 161 | | |
| | | 162 | | /// <summary> |
| | | 163 | | /// Handles service remove command execution and Windows elevation preflight. |
| | | 164 | | /// </summary> |
| | | 165 | | /// <param name="parsedCommand">Parsed service command.</param> |
| | | 166 | | /// <param name="args">Raw command-line arguments.</param> |
| | | 167 | | /// <returns>Process exit code.</returns> |
| | | 168 | | private static int HandleServiceRemoveCommand(ParsedCommand parsedCommand, string[] args) |
| | | 169 | | { |
| | 1 | 170 | | if (OperatingSystem.IsWindows() && !IsWindowsAdministrator()) |
| | | 171 | | { |
| | 0 | 172 | | return !TryPreflightWindowsServiceRemove(parsedCommand, out var preflightExitCode) |
| | 0 | 173 | | ? preflightExitCode |
| | 0 | 174 | | : RelaunchElevatedOnWindows(args); |
| | | 175 | | } |
| | | 176 | | |
| | | 177 | | // For non-Windows OSes, attempt removal without elevation and rely on permission/service-state errors. |
| | 1 | 178 | | return RemoveService(parsedCommand); |
| | | 179 | | } |
| | | 180 | | |
| | | 181 | | /// <summary> |
| | | 182 | | /// Handles service start command execution and Windows elevation preflight. |
| | | 183 | | /// </summary> |
| | | 184 | | /// <param name="parsedCommand">Parsed service command.</param> |
| | | 185 | | /// <param name="args">Raw command-line arguments.</param> |
| | | 186 | | /// <returns>Process exit code.</returns> |
| | | 187 | | private static int HandleServiceStartCommand(ParsedCommand parsedCommand, string[] args) |
| | | 188 | | { |
| | 1 | 189 | | if (OperatingSystem.IsWindows() && !IsWindowsAdministrator()) |
| | | 190 | | { |
| | 0 | 191 | | if (!TryPreflightWindowsServiceControl(parsedCommand, out var preflightExitCode, out var preflightMessage)) |
| | | 192 | | { |
| | 0 | 193 | | if (parsedCommand.RawOutput) |
| | | 194 | | { |
| | 0 | 195 | | Console.Error.WriteLine(preflightMessage); |
| | 0 | 196 | | return preflightExitCode; |
| | | 197 | | } |
| | | 198 | | |
| | 0 | 199 | | return WriteServiceControlResult( |
| | 0 | 200 | | parsedCommand, |
| | 0 | 201 | | new ServiceControlResult( |
| | 0 | 202 | | "start", |
| | 0 | 203 | | parsedCommand.ServiceName ?? string.Empty, |
| | 0 | 204 | | "windows", |
| | 0 | 205 | | "unknown", |
| | 0 | 206 | | null, |
| | 0 | 207 | | preflightExitCode, |
| | 0 | 208 | | preflightMessage, |
| | 0 | 209 | | string.Empty, |
| | 0 | 210 | | string.Empty)); |
| | | 211 | | } |
| | | 212 | | |
| | 0 | 213 | | var relaunchExitCode = RelaunchElevatedOnWindows(args, suppressStatusMessages: true); |
| | 0 | 214 | | return relaunchExitCode == 1223 && !parsedCommand.RawOutput |
| | 0 | 215 | | ? WriteServiceControlResult( |
| | 0 | 216 | | parsedCommand, |
| | 0 | 217 | | new ServiceControlResult( |
| | 0 | 218 | | "start", |
| | 0 | 219 | | parsedCommand.ServiceName ?? string.Empty, |
| | 0 | 220 | | "windows", |
| | 0 | 221 | | "unknown", |
| | 0 | 222 | | null, |
| | 0 | 223 | | 1, |
| | 0 | 224 | | "Elevation was canceled by the user. Run this command from an elevated terminal if you want to p |
| | 0 | 225 | | string.Empty, |
| | 0 | 226 | | string.Empty)) |
| | 0 | 227 | | : relaunchExitCode; |
| | | 228 | | } |
| | | 229 | | |
| | | 230 | | // For non-Windows OSes, attempt start without elevation and rely on permission/service-state errors. |
| | 1 | 231 | | return StartService(parsedCommand); |
| | | 232 | | } |
| | | 233 | | |
| | | 234 | | /// <summary> |
| | | 235 | | /// Handles service stop command execution and Windows elevation preflight. |
| | | 236 | | /// </summary> |
| | | 237 | | /// <param name="parsedCommand">Parsed service command.</param> |
| | | 238 | | /// <param name="args">Raw command-line arguments.</param> |
| | | 239 | | /// <returns>Process exit code.</returns> |
| | | 240 | | private static int HandleServiceStopCommand(ParsedCommand parsedCommand, string[] args) |
| | | 241 | | { |
| | 1 | 242 | | if (OperatingSystem.IsWindows() && !IsWindowsAdministrator()) |
| | | 243 | | { |
| | 0 | 244 | | if (!TryPreflightWindowsServiceControl(parsedCommand, out var preflightExitCode, out var preflightMessage)) |
| | | 245 | | { |
| | 0 | 246 | | if (parsedCommand.RawOutput) |
| | | 247 | | { |
| | 0 | 248 | | Console.Error.WriteLine(preflightMessage); |
| | 0 | 249 | | return preflightExitCode; |
| | | 250 | | } |
| | | 251 | | |
| | 0 | 252 | | return WriteServiceControlResult( |
| | 0 | 253 | | parsedCommand, |
| | 0 | 254 | | new ServiceControlResult( |
| | 0 | 255 | | "stop", |
| | 0 | 256 | | parsedCommand.ServiceName ?? string.Empty, |
| | 0 | 257 | | "windows", |
| | 0 | 258 | | "unknown", |
| | 0 | 259 | | null, |
| | 0 | 260 | | preflightExitCode, |
| | 0 | 261 | | preflightMessage, |
| | 0 | 262 | | string.Empty, |
| | 0 | 263 | | string.Empty)); |
| | | 264 | | } |
| | | 265 | | |
| | 0 | 266 | | var relaunchExitCode = RelaunchElevatedOnWindows(args, suppressStatusMessages: true); |
| | 0 | 267 | | return relaunchExitCode == 1223 && !parsedCommand.RawOutput |
| | 0 | 268 | | ? WriteServiceControlResult( |
| | 0 | 269 | | parsedCommand, |
| | 0 | 270 | | new ServiceControlResult( |
| | 0 | 271 | | "stop", |
| | 0 | 272 | | parsedCommand.ServiceName ?? string.Empty, |
| | 0 | 273 | | "windows", |
| | 0 | 274 | | "unknown", |
| | 0 | 275 | | null, |
| | 0 | 276 | | 1, |
| | 0 | 277 | | "Elevation was canceled by the user. Run this command from an elevated terminal if you want to p |
| | 0 | 278 | | string.Empty, |
| | 0 | 279 | | string.Empty)) |
| | 0 | 280 | | : relaunchExitCode; |
| | | 281 | | } |
| | | 282 | | |
| | | 283 | | // For non-Windows OSes, attempt stop without elevation and rely on permission/service-state errors. |
| | 1 | 284 | | return StopService(parsedCommand); |
| | | 285 | | } |
| | | 286 | | |
| | | 287 | | /// <summary> |
| | | 288 | | /// Executes the default run mode path after command parsing succeeds. |
| | | 289 | | /// </summary> |
| | | 290 | | /// <param name="parsedCommand">Parsed run command.</param> |
| | | 291 | | /// <param name="skipGalleryCheck">True to skip gallery version checks.</param> |
| | | 292 | | /// <returns>Process exit code.</returns> |
| | | 293 | | private static int ExecuteRunMode(ParsedCommand parsedCommand, bool skipGalleryCheck) |
| | | 294 | | { |
| | 1 | 295 | | var fullScriptPath = Path.GetFullPath(parsedCommand.ScriptPath); |
| | 1 | 296 | | if (!File.Exists(fullScriptPath)) |
| | | 297 | | { |
| | 1 | 298 | | Console.Error.WriteLine($"Script file not found: {fullScriptPath}"); |
| | 1 | 299 | | return 2; |
| | | 300 | | } |
| | | 301 | | |
| | 0 | 302 | | var moduleManifestPath = ResolveRunModuleManifestPath(parsedCommand.KestrunManifestPath, parsedCommand.KestrunFo |
| | 0 | 303 | | if (moduleManifestPath is null) |
| | | 304 | | { |
| | 0 | 305 | | WriteModuleNotFoundMessage(parsedCommand.KestrunManifestPath, parsedCommand.KestrunFolder, Console.Error.Wri |
| | 0 | 306 | | return 3; |
| | | 307 | | } |
| | | 308 | | |
| | 0 | 309 | | if (!skipGalleryCheck) |
| | | 310 | | { |
| | 0 | 311 | | WarnIfNewerGalleryVersionExists(moduleManifestPath); |
| | | 312 | | } |
| | | 313 | | |
| | | 314 | | try |
| | | 315 | | { |
| | 0 | 316 | | return ExecuteScriptViaServiceHost(fullScriptPath, parsedCommand.ScriptArguments, moduleManifestPath); |
| | | 317 | | } |
| | 0 | 318 | | catch (Exception ex) |
| | | 319 | | { |
| | 0 | 320 | | Console.Error.WriteLine($"Execution failed: {ex.Message}"); |
| | 0 | 321 | | return 1; |
| | | 322 | | } |
| | 0 | 323 | | } |
| | | 324 | | |
| | | 325 | | /// <summary> |
| | | 326 | | /// Checks whether the current Windows process token has administrator privileges. |
| | | 327 | | /// </summary> |
| | | 328 | | /// <returns>True when running elevated as administrator.</returns> |
| | | 329 | | [SupportedOSPlatform("windows")] |
| | | 330 | | private static bool IsWindowsAdministrator() |
| | | 331 | | { |
| | 0 | 332 | | using var identity = WindowsIdentity.GetCurrent(); |
| | 0 | 333 | | var principal = new WindowsPrincipal(identity); |
| | 0 | 334 | | return principal.IsInRole(WindowsBuiltInRole.Administrator); |
| | 0 | 335 | | } |
| | | 336 | | |
| | | 337 | | /// <summary> |
| | | 338 | | /// Performs non-admin checks before elevating a Windows service install request. |
| | | 339 | | /// </summary> |
| | | 340 | | /// <param name="command">Parsed service command.</param> |
| | | 341 | | /// <param name="serviceName">Resolved service name.</param> |
| | | 342 | | /// <param name="exitCode">Exit code when preflight fails.</param> |
| | | 343 | | /// <returns>True when install should proceed with elevation.</returns> |
| | | 344 | | [SupportedOSPlatform("windows")] |
| | | 345 | | private static bool TryPreflightWindowsServiceInstall(ParsedCommand command, string serviceName, out int exitCode) |
| | | 346 | | { |
| | 0 | 347 | | exitCode = 0; |
| | 0 | 348 | | if (string.IsNullOrWhiteSpace(serviceName)) |
| | | 349 | | { |
| | 0 | 350 | | Console.Error.WriteLine("Service name is required. Use --name <value>."); |
| | 0 | 351 | | exitCode = 2; |
| | 0 | 352 | | return false; |
| | | 353 | | } |
| | | 354 | | |
| | 0 | 355 | | var moduleManifestPath = LocateModuleManifest(command.KestrunManifestPath, command.KestrunFolder); |
| | 0 | 356 | | if (moduleManifestPath is null) |
| | | 357 | | { |
| | 0 | 358 | | WriteModuleNotFoundMessage(command.KestrunManifestPath, command.KestrunFolder, Console.Error.WriteLine); |
| | 0 | 359 | | exitCode = 3; |
| | 0 | 360 | | return false; |
| | | 361 | | } |
| | | 362 | | |
| | 0 | 363 | | if (!TryResolveServiceRuntimeExecutableFromModule(moduleManifestPath, out _, out var runtimeError)) |
| | | 364 | | { |
| | 0 | 365 | | Console.Error.WriteLine(runtimeError); |
| | 0 | 366 | | exitCode = 1; |
| | 0 | 367 | | return false; |
| | | 368 | | } |
| | | 369 | | |
| | | 370 | | // Do not hard-fail preflight on bundled modules payload resolution. |
| | | 371 | | // Elevated relaunch can run under a different working-directory/layout context, |
| | | 372 | | // so definitive payload validation is performed during actual bundle preparation. |
| | | 373 | | |
| | 0 | 374 | | if (WindowsServiceExists(serviceName)) |
| | | 375 | | { |
| | 0 | 376 | | Console.Error.WriteLine($"Windows service '{serviceName}' already exists."); |
| | 0 | 377 | | exitCode = 2; |
| | 0 | 378 | | return false; |
| | | 379 | | } |
| | | 380 | | |
| | 0 | 381 | | return true; |
| | | 382 | | } |
| | | 383 | | |
| | | 384 | | /// <summary> |
| | | 385 | | /// Performs non-admin checks before elevating a Windows service removal request. |
| | | 386 | | /// </summary> |
| | | 387 | | /// <param name="command">Parsed service command.</param> |
| | | 388 | | /// <param name="exitCode">Exit code when preflight fails.</param> |
| | | 389 | | /// <returns>True when remove should proceed with elevation.</returns> |
| | | 390 | | [SupportedOSPlatform("windows")] |
| | | 391 | | private static bool TryPreflightWindowsServiceRemove(ParsedCommand command, out int exitCode) |
| | | 392 | | { |
| | 0 | 393 | | exitCode = 0; |
| | 0 | 394 | | if (string.IsNullOrWhiteSpace(command.ServiceName)) |
| | | 395 | | { |
| | 0 | 396 | | Console.Error.WriteLine("Service name is required. Use --name <value>."); |
| | 0 | 397 | | exitCode = 2; |
| | 0 | 398 | | return false; |
| | | 399 | | } |
| | | 400 | | |
| | 0 | 401 | | if (!WindowsServiceExists(command.ServiceName)) |
| | | 402 | | { |
| | 0 | 403 | | Console.Error.WriteLine($"Windows service '{command.ServiceName}' was not found."); |
| | 0 | 404 | | exitCode = 2; |
| | 0 | 405 | | return false; |
| | | 406 | | } |
| | | 407 | | |
| | 0 | 408 | | return true; |
| | | 409 | | } |
| | | 410 | | |
| | | 411 | | /// <summary> |
| | | 412 | | /// Performs non-admin checks before elevating a Windows service start/stop request. |
| | | 413 | | /// </summary> |
| | | 414 | | /// <param name="command">Parsed service command.</param> |
| | | 415 | | /// <param name="exitCode">Exit code when preflight fails.</param> |
| | | 416 | | /// <param name="errorMessage">Preflight failure message when validation fails.</param> |
| | | 417 | | /// <returns>True when control should proceed with elevation.</returns> |
| | | 418 | | [SupportedOSPlatform("windows")] |
| | | 419 | | private static bool TryPreflightWindowsServiceControl(ParsedCommand command, out int exitCode, out string errorMessa |
| | | 420 | | { |
| | 0 | 421 | | exitCode = 0; |
| | 0 | 422 | | errorMessage = string.Empty; |
| | 0 | 423 | | if (string.IsNullOrWhiteSpace(command.ServiceName)) |
| | | 424 | | { |
| | 0 | 425 | | exitCode = 2; |
| | 0 | 426 | | errorMessage = "Service name is required. Use --name <value>."; |
| | 0 | 427 | | return false; |
| | | 428 | | } |
| | | 429 | | |
| | 0 | 430 | | if (!WindowsServiceExists(command.ServiceName)) |
| | | 431 | | { |
| | 0 | 432 | | exitCode = 2; |
| | 0 | 433 | | errorMessage = $"Windows service '{command.ServiceName}' was not found."; |
| | 0 | 434 | | return false; |
| | | 435 | | } |
| | | 436 | | |
| | 0 | 437 | | return true; |
| | | 438 | | } |
| | | 439 | | |
| | | 440 | | /// <summary> |
| | | 441 | | /// Determines whether a Windows service exists. |
| | | 442 | | /// </summary> |
| | | 443 | | /// <param name="serviceName">Service name.</param> |
| | | 444 | | /// <returns>True when the service exists.</returns> |
| | | 445 | | [SupportedOSPlatform("windows")] |
| | | 446 | | private static bool WindowsServiceExists(string serviceName) |
| | | 447 | | { |
| | 0 | 448 | | var result = RunProcess("sc.exe", ["query", serviceName], writeStandardOutput: false); |
| | 0 | 449 | | if (result.ExitCode == 0) |
| | | 450 | | { |
| | 0 | 451 | | return true; |
| | | 452 | | } |
| | | 453 | | |
| | 0 | 454 | | var combined = $"{result.Output}\n{result.Error}"; |
| | 0 | 455 | | if (combined.Contains("1060", StringComparison.OrdinalIgnoreCase) |
| | 0 | 456 | | || combined.Contains("does not exist", StringComparison.OrdinalIgnoreCase)) |
| | | 457 | | { |
| | 0 | 458 | | return false; |
| | | 459 | | } |
| | | 460 | | |
| | | 461 | | // A non-zero exit without a clear not-found signal is likely permission-related. |
| | 0 | 462 | | return true; |
| | | 463 | | } |
| | | 464 | | |
| | | 465 | | /// <summary> |
| | | 466 | | /// Relaunches the current executable with UAC elevation on Windows. |
| | | 467 | | /// </summary> |
| | | 468 | | /// <param name="args">Original command-line arguments.</param> |
| | | 469 | | /// <param name="exePath">Optional executable path to launch. When null, the current process executable will be used |
| | | 470 | | /// <returns>Exit code from the elevated child process or an error code.</returns> |
| | | 471 | | [SupportedOSPlatform("windows")] |
| | | 472 | | private static int RelaunchElevatedOnWindows(IReadOnlyList<string> args, string? exePath = null, bool suppressStatus |
| | | 473 | | { |
| | 0 | 474 | | exePath ??= Environment.ProcessPath; |
| | 0 | 475 | | if (!TryResolveElevationExecutablePath(exePath, out var resolvedExePath)) |
| | | 476 | | { |
| | 0 | 477 | | return 1; |
| | | 478 | | } |
| | | 479 | | |
| | 0 | 480 | | if (!suppressStatusMessages) |
| | | 481 | | { |
| | 0 | 482 | | Console.Error.WriteLine("Administrator rights are required. Requesting elevation..."); |
| | | 483 | | } |
| | | 484 | | |
| | 0 | 485 | | var relaunchArgs = BuildElevatedRelaunchArguments(resolvedExePath, args); |
| | 0 | 486 | | var tempDirectory = Path.Combine(Path.GetTempPath(), ProductName); |
| | 0 | 487 | | _ = Directory.CreateDirectory(tempDirectory); |
| | | 488 | | |
| | 0 | 489 | | var outputPath = Path.Combine(tempDirectory, $"elevated-{Guid.NewGuid():N}.log"); |
| | 0 | 490 | | var wrapperPath = Path.Combine(tempDirectory, $"elevated-{Guid.NewGuid():N}.cmd"); |
| | | 491 | | |
| | 0 | 492 | | WriteElevationWrapperScript(wrapperPath, outputPath, resolvedExePath, relaunchArgs); |
| | | 493 | | |
| | | 494 | | try |
| | | 495 | | { |
| | 0 | 496 | | return StartElevatedProcess(wrapperPath, outputPath, suppressStatusMessages); |
| | | 497 | | } |
| | 0 | 498 | | catch (Win32Exception ex) when (ex.NativeErrorCode == 1223) |
| | | 499 | | { |
| | 0 | 500 | | WriteElevationCanceledMessage(suppressStatusMessages); |
| | 0 | 501 | | return 1223; |
| | | 502 | | } |
| | | 503 | | catch (Exception ex) |
| | | 504 | | { |
| | 0 | 505 | | WriteElevationFailureMessage(ex.Message, suppressStatusMessages); |
| | 0 | 506 | | return 1; |
| | | 507 | | } |
| | | 508 | | finally |
| | | 509 | | { |
| | 0 | 510 | | TryDeleteFileQuietly(wrapperPath); |
| | 0 | 511 | | TryDeleteFileQuietly(outputPath); |
| | 0 | 512 | | } |
| | 0 | 513 | | } |
| | | 514 | | |
| | | 515 | | /// <summary> |
| | | 516 | | /// Resolves and validates the executable path used for elevation relaunch. |
| | | 517 | | /// </summary> |
| | | 518 | | /// <param name="exePath">Input executable path.</param> |
| | | 519 | | /// <param name="resolvedExePath">Resolved executable path when validation succeeds.</param> |
| | | 520 | | /// <returns>True when the executable path is valid.</returns> |
| | | 521 | | private static bool TryResolveElevationExecutablePath(string? exePath, out string resolvedExePath) |
| | | 522 | | { |
| | 0 | 523 | | resolvedExePath = exePath ?? string.Empty; |
| | 0 | 524 | | if (string.IsNullOrWhiteSpace(resolvedExePath) || !File.Exists(resolvedExePath)) |
| | | 525 | | { |
| | 0 | 526 | | Console.Error.WriteLine("Unable to resolve KestrunTool executable path for elevation."); |
| | 0 | 527 | | return false; |
| | | 528 | | } |
| | | 529 | | |
| | 0 | 530 | | return true; |
| | | 531 | | } |
| | | 532 | | |
| | | 533 | | /// <summary> |
| | | 534 | | /// Writes the temporary wrapper script used to capture elevated process output. |
| | | 535 | | /// </summary> |
| | | 536 | | /// <param name="wrapperPath">Wrapper script path.</param> |
| | | 537 | | /// <param name="outputPath">Output capture file path.</param> |
| | | 538 | | /// <param name="exePath">Executable path to launch.</param> |
| | | 539 | | /// <param name="relaunchArgs">Relaunch argument tokens.</param> |
| | | 540 | | private static void WriteElevationWrapperScript(string wrapperPath, string outputPath, string exePath, IReadOnlyList |
| | | 541 | | { |
| | 0 | 542 | | var commandLine = BuildWindowsCommandLine(exePath, relaunchArgs); |
| | 0 | 543 | | var wrapperContents = $"@echo off{Environment.NewLine}{commandLine} > \"{outputPath}\" 2>&1{Environment.NewLine} |
| | 0 | 544 | | File.WriteAllText(wrapperPath, wrapperContents, Encoding.ASCII); |
| | 0 | 545 | | } |
| | | 546 | | |
| | | 547 | | /// <summary> |
| | | 548 | | /// Starts the elevated wrapper process and relays captured output. |
| | | 549 | | /// </summary> |
| | | 550 | | /// <param name="wrapperPath">Wrapper script path.</param> |
| | | 551 | | /// <param name="outputPath">Output capture file path.</param> |
| | | 552 | | /// <param name="suppressStatusMessages">True to suppress non-essential status messages.</param> |
| | | 553 | | /// <returns>Exit code from the elevated child process.</returns> |
| | | 554 | | private static int StartElevatedProcess(string wrapperPath, string outputPath, bool suppressStatusMessages) |
| | | 555 | | { |
| | 0 | 556 | | var startInfo = new ProcessStartInfo |
| | 0 | 557 | | { |
| | 0 | 558 | | FileName = "cmd.exe", |
| | 0 | 559 | | Arguments = $"/c \"{wrapperPath}\"", |
| | 0 | 560 | | UseShellExecute = true, |
| | 0 | 561 | | Verb = "runas", |
| | 0 | 562 | | WorkingDirectory = Environment.CurrentDirectory, |
| | 0 | 563 | | }; |
| | | 564 | | |
| | 0 | 565 | | using var process = Process.Start(startInfo); |
| | 0 | 566 | | if (process is null) |
| | | 567 | | { |
| | 0 | 568 | | Console.Error.WriteLine("Failed to start elevated process."); |
| | 0 | 569 | | return 1; |
| | | 570 | | } |
| | | 571 | | |
| | 0 | 572 | | process.WaitForExit(); |
| | 0 | 573 | | RelayElevatedOutput(outputPath); |
| | | 574 | | |
| | 0 | 575 | | if (process.ExitCode != 0 && !suppressStatusMessages) |
| | | 576 | | { |
| | 0 | 577 | | Console.Error.WriteLine("Elevated operation failed. If no UAC prompt was shown, run this command from an ele |
| | | 578 | | } |
| | | 579 | | |
| | 0 | 580 | | return process.ExitCode; |
| | 0 | 581 | | } |
| | | 582 | | |
| | | 583 | | /// <summary> |
| | | 584 | | /// Writes captured elevated output to standard output when available. |
| | | 585 | | /// </summary> |
| | | 586 | | /// <param name="outputPath">Output capture file path.</param> |
| | | 587 | | private static void RelayElevatedOutput(string outputPath) |
| | | 588 | | { |
| | 0 | 589 | | if (!File.Exists(outputPath)) |
| | | 590 | | { |
| | 0 | 591 | | return; |
| | | 592 | | } |
| | | 593 | | |
| | 0 | 594 | | var elevatedOutput = File.ReadAllText(outputPath); |
| | 0 | 595 | | if (!string.IsNullOrWhiteSpace(elevatedOutput)) |
| | | 596 | | { |
| | 0 | 597 | | Console.Write(elevatedOutput); |
| | | 598 | | } |
| | 0 | 599 | | } |
| | | 600 | | |
| | | 601 | | /// <summary> |
| | | 602 | | /// Writes the standard elevation canceled message when status output is enabled. |
| | | 603 | | /// </summary> |
| | | 604 | | /// <param name="suppressStatusMessages">True to suppress status messages.</param> |
| | | 605 | | private static void WriteElevationCanceledMessage(bool suppressStatusMessages) |
| | | 606 | | { |
| | 0 | 607 | | if (suppressStatusMessages) |
| | | 608 | | { |
| | 0 | 609 | | return; |
| | | 610 | | } |
| | | 611 | | |
| | 0 | 612 | | Console.Error.WriteLine("Elevation was canceled by the user."); |
| | 0 | 613 | | Console.Error.WriteLine("Run this command from an elevated terminal if you want to proceed without UAC interacti |
| | 0 | 614 | | } |
| | | 615 | | |
| | | 616 | | /// <summary> |
| | | 617 | | /// Writes the standard elevation failure message when status output is enabled. |
| | | 618 | | /// </summary> |
| | | 619 | | /// <param name="errorMessage">Error message from the failed elevation attempt.</param> |
| | | 620 | | /// <param name="suppressStatusMessages">True to suppress status messages.</param> |
| | | 621 | | private static void WriteElevationFailureMessage(string errorMessage, bool suppressStatusMessages) |
| | | 622 | | { |
| | 0 | 623 | | if (suppressStatusMessages) |
| | | 624 | | { |
| | 0 | 625 | | return; |
| | | 626 | | } |
| | | 627 | | |
| | 0 | 628 | | Console.Error.WriteLine($"Failed to elevate process: {errorMessage}"); |
| | 0 | 629 | | Console.Error.WriteLine("Run this command from an elevated terminal if automatic elevation is unavailable."); |
| | 0 | 630 | | } |
| | | 631 | | |
| | | 632 | | /// <summary> |
| | | 633 | | /// Best-effort delete for temporary files used by elevated relaunch flow. |
| | | 634 | | /// </summary> |
| | | 635 | | /// <param name="path">File path to remove.</param> |
| | | 636 | | private static void TryDeleteFileQuietly(string path) |
| | | 637 | | { |
| | 3 | 638 | | if (string.IsNullOrWhiteSpace(path)) |
| | | 639 | | { |
| | 0 | 640 | | return; |
| | | 641 | | } |
| | | 642 | | |
| | | 643 | | try |
| | | 644 | | { |
| | 3 | 645 | | if (File.Exists(path)) |
| | | 646 | | { |
| | 3 | 647 | | File.Delete(path); |
| | | 648 | | } |
| | 3 | 649 | | } |
| | 0 | 650 | | catch |
| | | 651 | | { |
| | | 652 | | // Best-effort cleanup only. |
| | 0 | 653 | | } |
| | 3 | 654 | | } |
| | | 655 | | |
| | | 656 | | /// <summary> |
| | | 657 | | /// Builds argument tokens for elevated relaunch scenarios. |
| | | 658 | | /// </summary> |
| | | 659 | | /// <param name="executablePath">Current process executable path.</param> |
| | | 660 | | /// <param name="args">Original command-line arguments.</param> |
| | | 661 | | /// <returns>Argument token list for elevated invocation.</returns> |
| | | 662 | | private static IReadOnlyList<string> BuildElevatedRelaunchArguments(string executablePath, IReadOnlyList<string> arg |
| | | 663 | | { |
| | 2 | 664 | | if (!IsDotnetHostExecutable(executablePath)) |
| | | 665 | | { |
| | 1 | 666 | | return [.. args]; |
| | | 667 | | } |
| | | 668 | | |
| | 1 | 669 | | var assemblyPath = typeof(Program).Assembly.Location; |
| | 1 | 670 | | if (!string.IsNullOrWhiteSpace(assemblyPath) && File.Exists(assemblyPath)) |
| | | 671 | | { |
| | 1 | 672 | | var elevatedArgs = new List<string>(args.Count + 1) |
| | 1 | 673 | | { |
| | 1 | 674 | | Path.GetFullPath(assemblyPath), |
| | 1 | 675 | | }; |
| | | 676 | | |
| | 1 | 677 | | elevatedArgs.AddRange(args); |
| | 1 | 678 | | return elevatedArgs; |
| | | 679 | | } |
| | | 680 | | |
| | | 681 | | // Fallback path when assembly location is unavailable. |
| | 0 | 682 | | var fallbackArgs = new List<string>(args.Count + 1) |
| | 0 | 683 | | { |
| | 0 | 684 | | ProductName, |
| | 0 | 685 | | }; |
| | | 686 | | |
| | 0 | 687 | | fallbackArgs.AddRange(args); |
| | 0 | 688 | | return fallbackArgs; |
| | | 689 | | } |
| | | 690 | | |
| | | 691 | | /// <summary> |
| | | 692 | | /// Determines whether the given executable path is the dotnet host executable. |
| | | 693 | | /// </summary> |
| | | 694 | | /// <param name="executablePath">Executable path to inspect.</param> |
| | | 695 | | /// <returns>True when the executable is dotnet host.</returns> |
| | | 696 | | private static bool IsDotnetHostExecutable(string executablePath) |
| | | 697 | | { |
| | 2 | 698 | | var fileNameWithoutExtension = Path.GetFileNameWithoutExtension(executablePath); |
| | 2 | 699 | | return string.Equals(fileNameWithoutExtension, "dotnet", StringComparison.OrdinalIgnoreCase); |
| | | 700 | | } |
| | | 701 | | |
| | | 702 | | /// <summary> |
| | | 703 | | /// Builds KestrunTool command-line arguments used by installed services/daemons. |
| | | 704 | | /// </summary> |
| | | 705 | | /// <param name="scriptPath">Absolute script path to execute.</param> |
| | | 706 | | /// <param name="scriptArguments">Script arguments for the run command.</param> |
| | | 707 | | /// <param name="kestrunFolder">Optional folder containing Kestrun module manifest.</param> |
| | | 708 | | /// <param name="kestrunManifestPath">Optional explicit Kestrun module manifest path.</param> |
| | | 709 | | /// <returns>Ordered runner argument tokens.</returns> |
| | | 710 | | private static IReadOnlyList<string> BuildRunnerArgumentsForService(string scriptPath, IReadOnlyList<string> scriptA |
| | | 711 | | { |
| | 1 | 712 | | var arguments = new List<string>(8 + scriptArguments.Count); |
| | 1 | 713 | | if (!string.IsNullOrWhiteSpace(kestrunManifestPath)) |
| | | 714 | | { |
| | 1 | 715 | | arguments.Add("--kestrun-manifest"); |
| | 1 | 716 | | arguments.Add(Path.GetFullPath(kestrunManifestPath)); |
| | | 717 | | } |
| | | 718 | | |
| | 1 | 719 | | if (!string.IsNullOrWhiteSpace(kestrunFolder)) |
| | | 720 | | { |
| | 0 | 721 | | arguments.Add("--kestrun-folder"); |
| | 0 | 722 | | arguments.Add(Path.GetFullPath(kestrunFolder)); |
| | | 723 | | } |
| | | 724 | | |
| | 1 | 725 | | arguments.Add("run"); |
| | 1 | 726 | | arguments.Add(scriptPath); |
| | | 727 | | |
| | 1 | 728 | | if (scriptArguments.Count > 0) |
| | | 729 | | { |
| | 1 | 730 | | arguments.Add("--arguments"); |
| | 1 | 731 | | arguments.AddRange(scriptArguments); |
| | | 732 | | } |
| | | 733 | | |
| | 1 | 734 | | return arguments; |
| | | 735 | | } |
| | | 736 | | |
| | | 737 | | /// <summary> |
| | | 738 | | /// Builds command-line arguments for daemon host registration. |
| | | 739 | | /// </summary> |
| | | 740 | | /// <param name="serviceName">Service name.</param> |
| | | 741 | | /// <param name="serviceHostExecutablePath">Service-host executable path.</param> |
| | | 742 | | /// <param name="runnerExecutablePath">Runner executable path.</param> |
| | | 743 | | /// <param name="scriptPath">Absolute script path.</param> |
| | | 744 | | /// <param name="moduleManifestPath">Absolute module manifest path.</param> |
| | | 745 | | /// <param name="scriptArguments">Script arguments for run mode.</param> |
| | | 746 | | /// <param name="serviceLogPath">Optional service log path.</param> |
| | | 747 | | /// <returns>Ordered daemon-host argument tokens.</returns> |
| | | 748 | | private static IReadOnlyList<string> BuildDaemonHostArgumentsForService( |
| | | 749 | | string serviceName, |
| | | 750 | | string serviceHostExecutablePath, |
| | | 751 | | string runnerExecutablePath, |
| | | 752 | | string scriptPath, |
| | | 753 | | string moduleManifestPath, |
| | | 754 | | IReadOnlyList<string> scriptArguments, |
| | | 755 | | string? serviceLogPath) |
| | | 756 | | { |
| | 2 | 757 | | if (UsesDedicatedServiceHostExecutable(serviceHostExecutablePath)) |
| | | 758 | | { |
| | 1 | 759 | | return BuildDedicatedServiceHostArguments(serviceName, runnerExecutablePath, scriptPath, moduleManifestPath, |
| | | 760 | | } |
| | | 761 | | // Fallback to generic runner invocation when the service host is not the dedicated one. |
| | 1 | 762 | | return BuildRunnerArgumentsForService(scriptPath, scriptArguments, null, moduleManifestPath); |
| | | 763 | | } |
| | | 764 | | |
| | | 765 | | /// <summary> |
| | | 766 | | /// Installs a Windows service using sc.exe. |
| | | 767 | | /// </summary> |
| | | 768 | | /// <param name="command">Parsed service command.</param> |
| | | 769 | | /// <param name="serviceName">Resolved service name.</param> |
| | | 770 | | /// <param name="serviceLogPath">Effective service log path.</param> |
| | | 771 | | /// <param name="serviceHostExecutablePath">Service host executable path.</param> |
| | | 772 | | /// <param name="runnerExecutablePath">Runner executable path.</param> |
| | | 773 | | /// <param name="scriptPath">Bundled script path.</param> |
| | | 774 | | /// <param name="moduleManifestPath">Bundled module manifest path.</param> |
| | | 775 | | /// <returns>Process exit code.</returns> |
| | | 776 | | [SupportedOSPlatform("windows")] |
| | | 777 | | private static int InstallWindowsService( |
| | | 778 | | ParsedCommand command, |
| | | 779 | | string serviceName, |
| | | 780 | | string? serviceLogPath, |
| | | 781 | | string serviceHostExecutablePath, |
| | | 782 | | string runnerExecutablePath, |
| | | 783 | | string scriptPath, |
| | | 784 | | string moduleManifestPath) |
| | | 785 | | { |
| | 0 | 786 | | if (!IsWindowsAdministrator()) |
| | | 787 | | { |
| | 0 | 788 | | var relaunchArgs = BuildWindowsServiceRegisterArguments(command, serviceName, serviceLogPath, serviceHostExe |
| | 0 | 789 | | return RelaunchElevatedOnWindows(relaunchArgs); |
| | | 790 | | } |
| | | 791 | | |
| | 0 | 792 | | var createResult = CreateWindowsServiceRegistration( |
| | 0 | 793 | | serviceName, |
| | 0 | 794 | | Path.GetFullPath(serviceHostExecutablePath), |
| | 0 | 795 | | Path.GetFullPath(runnerExecutablePath), |
| | 0 | 796 | | Path.GetFullPath(scriptPath), |
| | 0 | 797 | | Path.GetFullPath(moduleManifestPath), |
| | 0 | 798 | | command.ScriptArguments, |
| | 0 | 799 | | serviceLogPath, |
| | 0 | 800 | | command.ServiceUser, |
| | 0 | 801 | | command.ServicePassword); |
| | | 802 | | |
| | 0 | 803 | | if (createResult.ExitCode != 0) |
| | | 804 | | { |
| | 0 | 805 | | Console.Error.WriteLine(createResult.Error); |
| | 0 | 806 | | return createResult.ExitCode; |
| | | 807 | | } |
| | | 808 | | |
| | 0 | 809 | | WriteServiceOperationLog($"Service '{serviceName}' install operation completed.", serviceLogPath, serviceName); |
| | | 810 | | |
| | 0 | 811 | | Console.WriteLine($"Installed Windows service '{serviceName}' (not started)."); |
| | 0 | 812 | | return 0; |
| | | 813 | | } |
| | | 814 | | |
| | | 815 | | /// <summary> |
| | | 816 | | /// Registers a Windows service using pre-staged runtime/module/script paths. |
| | | 817 | | /// </summary> |
| | | 818 | | /// <param name="options">Parsed service registration options.</param> |
| | | 819 | | /// <returns>Process exit code.</returns> |
| | | 820 | | [SupportedOSPlatform("windows")] |
| | | 821 | | private static int RegisterWindowsService(ServiceRegisterOptions options) |
| | | 822 | | { |
| | 0 | 823 | | var serviceName = options.ServiceName; |
| | 0 | 824 | | var createResult = CreateWindowsServiceRegistration( |
| | 0 | 825 | | serviceName, |
| | 0 | 826 | | Path.GetFullPath(options.ServiceHostExecutablePath), |
| | 0 | 827 | | Path.GetFullPath(options.RunnerExecutablePath), |
| | 0 | 828 | | Path.GetFullPath(options.ScriptPath), |
| | 0 | 829 | | Path.GetFullPath(options.ModuleManifestPath), |
| | 0 | 830 | | options.ScriptArguments, |
| | 0 | 831 | | options.ServiceLogPath, |
| | 0 | 832 | | options.ServiceUser, |
| | 0 | 833 | | options.ServicePassword); |
| | | 834 | | |
| | 0 | 835 | | if (createResult.ExitCode != 0) |
| | | 836 | | { |
| | 0 | 837 | | Console.Error.WriteLine(createResult.Error); |
| | 0 | 838 | | return createResult.ExitCode; |
| | | 839 | | } |
| | | 840 | | |
| | 0 | 841 | | WriteServiceOperationLog($"Service '{serviceName}' install operation completed.", options.ServiceLogPath, servic |
| | | 842 | | |
| | 0 | 843 | | Console.WriteLine($"Installed Windows service '{serviceName}' (not started)."); |
| | 0 | 844 | | return 0; |
| | | 845 | | } |
| | | 846 | | |
| | | 847 | | /// <summary> |
| | | 848 | | /// Creates a Windows service registration using sc.exe. |
| | | 849 | | /// </summary> |
| | | 850 | | /// <param name="serviceName">Service name.</param> |
| | | 851 | | /// <param name="serviceHostExecutablePath">Service-host executable path.</param> |
| | | 852 | | /// <param name="runnerExecutablePath">Runner executable path.</param> |
| | | 853 | | /// <param name="scriptPath">Absolute script path.</param> |
| | | 854 | | /// <param name="moduleManifestPath">Absolute module manifest path.</param> |
| | | 855 | | /// <param name="scriptArguments">Script arguments for service-host mode.</param> |
| | | 856 | | /// <param name="serviceLogPath">Optional service log path.</param> |
| | | 857 | | /// <returns>Process result from sc.exe create.</returns> |
| | | 858 | | private static ProcessResult CreateWindowsServiceRegistration( |
| | | 859 | | string serviceName, |
| | | 860 | | string serviceHostExecutablePath, |
| | | 861 | | string runnerExecutablePath, |
| | | 862 | | string scriptPath, |
| | | 863 | | string moduleManifestPath, |
| | | 864 | | IReadOnlyList<string> scriptArguments, |
| | | 865 | | string? serviceLogPath, |
| | | 866 | | string? serviceUser, |
| | | 867 | | string? servicePassword) |
| | | 868 | | { |
| | 0 | 869 | | if (!UsesDedicatedServiceHostExecutable(serviceHostExecutablePath)) |
| | | 870 | | { |
| | 0 | 871 | | return new ProcessResult( |
| | 0 | 872 | | 1, |
| | 0 | 873 | | string.Empty, |
| | 0 | 874 | | "Service registration now requires the dedicated kestrun-service-host executable. Reinstall or update Ke |
| | | 875 | | } |
| | | 876 | | |
| | 0 | 877 | | var hostArgs = BuildDedicatedServiceHostArguments(serviceName, runnerExecutablePath, scriptPath, moduleManifestP |
| | | 878 | | |
| | 0 | 879 | | var imagePath = BuildWindowsCommandLine(serviceHostExecutablePath, hostArgs); |
| | 0 | 880 | | var scArgs = new List<string> |
| | 0 | 881 | | { |
| | 0 | 882 | | "create", |
| | 0 | 883 | | serviceName, |
| | 0 | 884 | | "start=", |
| | 0 | 885 | | "auto", |
| | 0 | 886 | | "binPath=", |
| | 0 | 887 | | imagePath, |
| | 0 | 888 | | "DisplayName=", |
| | 0 | 889 | | serviceName, |
| | 0 | 890 | | }; |
| | | 891 | | |
| | 0 | 892 | | if (!string.IsNullOrWhiteSpace(serviceUser)) |
| | | 893 | | { |
| | 0 | 894 | | var normalizedServiceUser = NormalizeWindowsServiceAccountName(serviceUser); |
| | 0 | 895 | | scArgs.Add("obj="); |
| | 0 | 896 | | scArgs.Add(normalizedServiceUser); |
| | | 897 | | |
| | | 898 | | // Windows built-in service accounts do not require a password. |
| | 0 | 899 | | if (!IsWindowsBuiltinServiceAccount(normalizedServiceUser) && !string.IsNullOrWhiteSpace(servicePassword)) |
| | | 900 | | { |
| | 0 | 901 | | scArgs.Add("password="); |
| | 0 | 902 | | scArgs.Add(servicePassword); |
| | | 903 | | } |
| | | 904 | | } |
| | | 905 | | |
| | 0 | 906 | | return RunProcess("sc.exe", scArgs); |
| | | 907 | | } |
| | | 908 | | |
| | | 909 | | /// <summary> |
| | | 910 | | /// Normalizes friendly Windows built-in service account aliases to SCM-compatible names. |
| | | 911 | | /// </summary> |
| | | 912 | | /// <param name="serviceUser">Raw service user argument.</param> |
| | | 913 | | /// <returns>Normalized account name for sc.exe registration.</returns> |
| | | 914 | | private static string NormalizeWindowsServiceAccountName(string serviceUser) |
| | | 915 | | { |
| | 5 | 916 | | var trimmed = serviceUser.Trim(); |
| | | 917 | | |
| | 5 | 918 | | return trimmed.ToLowerInvariant() switch |
| | 5 | 919 | | { |
| | 2 | 920 | | "networkservice" or "network service" or @"nt authority\networkservice" => @"NT AUTHORITY\NetworkService", |
| | 1 | 921 | | "localservice" or "local service" or @"nt authority\localservice" => @"NT AUTHORITY\LocalService", |
| | 1 | 922 | | "localsystem" or "local system" or "system" or @"nt authority\system" => "LocalSystem", |
| | 1 | 923 | | _ => trimmed, |
| | 5 | 924 | | }; |
| | | 925 | | } |
| | | 926 | | |
| | | 927 | | /// <summary> |
| | | 928 | | /// Determines whether an account string refers to a Windows built-in service account. |
| | | 929 | | /// </summary> |
| | | 930 | | /// <param name="accountName">Account name to inspect.</param> |
| | | 931 | | /// <returns>True when account is LocalSystem, NetworkService, or LocalService.</returns> |
| | | 932 | | private static bool IsWindowsBuiltinServiceAccount(string accountName) |
| | | 933 | | { |
| | 4 | 934 | | return accountName.Equals("LocalSystem", StringComparison.OrdinalIgnoreCase) |
| | 4 | 935 | | || accountName.Equals(@"NT AUTHORITY\NetworkService", StringComparison.OrdinalIgnoreCase) |
| | 4 | 936 | | || accountName.Equals(@"NT AUTHORITY\LocalService", StringComparison.OrdinalIgnoreCase); |
| | | 937 | | } |
| | | 938 | | |
| | | 939 | | /// <summary> |
| | | 940 | | /// Determines whether a path refers to the dedicated service-host executable. |
| | | 941 | | /// </summary> |
| | | 942 | | /// <param name="executablePath">Executable path.</param> |
| | | 943 | | /// <returns>True when the executable is the dedicated service host.</returns> |
| | | 944 | | private static bool UsesDedicatedServiceHostExecutable(string executablePath) |
| | | 945 | | { |
| | 5 | 946 | | var fileName = Path.GetFileNameWithoutExtension(executablePath); |
| | 5 | 947 | | return string.Equals(fileName, "kestrun-service-host", StringComparison.OrdinalIgnoreCase) |
| | 5 | 948 | | || string.Equals(fileName, "Kestrun.ServiceHost", StringComparison.OrdinalIgnoreCase); |
| | | 949 | | } |
| | | 950 | | |
| | | 951 | | /// <summary> |
| | | 952 | | /// Builds elevated relaunch arguments for internal Windows service registration. |
| | | 953 | | /// </summary> |
| | | 954 | | /// <param name="command">Parsed service command.</param> |
| | | 955 | | /// <param name="serviceName">Resolved service name.</param> |
| | | 956 | | /// <param name="serviceLogPath">Effective service log path.</param> |
| | | 957 | | /// <param name="executablePath">Executable path.</param> |
| | | 958 | | /// <param name="scriptPath">Absolute script path.</param> |
| | | 959 | | /// <param name="moduleManifestPath">Manifest path staged for service runtime.</param> |
| | | 960 | | /// <returns>Ordered argument tokens.</returns> |
| | | 961 | | private static IReadOnlyList<string> BuildWindowsServiceRegisterArguments( |
| | | 962 | | ParsedCommand command, |
| | | 963 | | string serviceName, |
| | | 964 | | string? serviceLogPath, |
| | | 965 | | string serviceHostExecutablePath, |
| | | 966 | | string runnerExecutablePath, |
| | | 967 | | string scriptPath, |
| | | 968 | | string moduleManifestPath) |
| | | 969 | | { |
| | 0 | 970 | | var arguments = new List<string>(16 + command.ScriptArguments.Length) |
| | 0 | 971 | | { |
| | 0 | 972 | | "--service-register", |
| | 0 | 973 | | "--name", |
| | 0 | 974 | | serviceName, |
| | 0 | 975 | | "--service-host-exe", |
| | 0 | 976 | | Path.GetFullPath(serviceHostExecutablePath), |
| | 0 | 977 | | "--runner-exe", |
| | 0 | 978 | | Path.GetFullPath(runnerExecutablePath), |
| | 0 | 979 | | "--script", |
| | 0 | 980 | | Path.GetFullPath(scriptPath), |
| | 0 | 981 | | "--kestrun-manifest", |
| | 0 | 982 | | Path.GetFullPath(moduleManifestPath), |
| | 0 | 983 | | }; |
| | | 984 | | |
| | 0 | 985 | | if (!string.IsNullOrWhiteSpace(serviceLogPath)) |
| | | 986 | | { |
| | 0 | 987 | | arguments.Add("--service-log-path"); |
| | 0 | 988 | | arguments.Add(Path.GetFullPath(serviceLogPath)); |
| | | 989 | | } |
| | | 990 | | |
| | 0 | 991 | | if (!string.IsNullOrWhiteSpace(command.ServiceUser)) |
| | | 992 | | { |
| | 0 | 993 | | arguments.Add("--service-user"); |
| | 0 | 994 | | arguments.Add(command.ServiceUser); |
| | | 995 | | } |
| | | 996 | | |
| | 0 | 997 | | if (!string.IsNullOrWhiteSpace(command.ServicePassword)) |
| | | 998 | | { |
| | 0 | 999 | | arguments.Add("--service-password"); |
| | 0 | 1000 | | arguments.Add(command.ServicePassword); |
| | | 1001 | | } |
| | | 1002 | | |
| | 0 | 1003 | | if (command.ScriptArguments.Length > 0) |
| | | 1004 | | { |
| | 0 | 1005 | | arguments.Add("--arguments"); |
| | 0 | 1006 | | arguments.AddRange(command.ScriptArguments); |
| | | 1007 | | } |
| | | 1008 | | |
| | 0 | 1009 | | return arguments; |
| | | 1010 | | } |
| | | 1011 | | |
| | | 1012 | | /// <summary> |
| | | 1013 | | /// Builds arguments for the dedicated service-host executable. |
| | | 1014 | | /// </summary> |
| | | 1015 | | /// <param name="serviceName">Service name.</param> |
| | | 1016 | | /// <param name="runnerExecutablePath">Runner executable path.</param> |
| | | 1017 | | /// <param name="scriptPath">Absolute script path.</param> |
| | | 1018 | | /// <param name="moduleManifestPath">Manifest path staged for service runtime.</param> |
| | | 1019 | | /// <param name="scriptArguments">Script arguments forwarded to run mode.</param> |
| | | 1020 | | /// <param name="serviceLogPath">Optional service log path.</param> |
| | | 1021 | | /// <returns>Ordered argument tokens.</returns> |
| | | 1022 | | private static IReadOnlyList<string> BuildDedicatedServiceHostArguments( |
| | | 1023 | | string serviceName, |
| | | 1024 | | string runnerExecutablePath, |
| | | 1025 | | string scriptPath, |
| | | 1026 | | string moduleManifestPath, |
| | | 1027 | | IReadOnlyList<string> scriptArguments, |
| | | 1028 | | string? serviceLogPath) |
| | | 1029 | | { |
| | 2 | 1030 | | var arguments = new List<string>(14 + scriptArguments.Count) |
| | 2 | 1031 | | { |
| | 2 | 1032 | | "--name", |
| | 2 | 1033 | | serviceName, |
| | 2 | 1034 | | "--runner-exe", |
| | 2 | 1035 | | Path.GetFullPath(runnerExecutablePath), |
| | 2 | 1036 | | "--script", |
| | 2 | 1037 | | scriptPath, |
| | 2 | 1038 | | "--kestrun-manifest", |
| | 2 | 1039 | | Path.GetFullPath(moduleManifestPath), |
| | 2 | 1040 | | }; |
| | | 1041 | | |
| | 2 | 1042 | | if (!string.IsNullOrWhiteSpace(serviceLogPath)) |
| | | 1043 | | { |
| | 1 | 1044 | | arguments.Add("--service-log-path"); |
| | 1 | 1045 | | arguments.Add(Path.GetFullPath(serviceLogPath)); |
| | | 1046 | | } |
| | | 1047 | | |
| | 2 | 1048 | | if (scriptArguments.Count > 0) |
| | | 1049 | | { |
| | 0 | 1050 | | arguments.Add("--arguments"); |
| | 0 | 1051 | | arguments.AddRange(scriptArguments); |
| | | 1052 | | } |
| | | 1053 | | |
| | 2 | 1054 | | return arguments; |
| | | 1055 | | } |
| | | 1056 | | |
| | | 1057 | | /// <summary> |
| | | 1058 | | /// Removes a Windows service using sc.exe. |
| | | 1059 | | /// </summary> |
| | | 1060 | | /// <param name="command">Parsed service command.</param> |
| | | 1061 | | /// <returns>Process exit code.</returns> |
| | | 1062 | | private static int RemoveWindowsService(ParsedCommand command) |
| | | 1063 | | { |
| | 0 | 1064 | | var operationLogPath = ResolveServiceOperationLogPath(command.ServiceLogPath, command.ServiceName); |
| | | 1065 | | |
| | 0 | 1066 | | var stopResult = RunProcess("sc.exe", ["stop", command.ServiceName!], writeStandardOutput: false); |
| | 0 | 1067 | | if (stopResult.ExitCode != 0 && !IsWindowsServiceAlreadyStopped(stopResult)) |
| | | 1068 | | { |
| | 0 | 1069 | | WriteServiceOperationLog( |
| | 0 | 1070 | | $"Service '{command.ServiceName}' stop-before-delete returned exitCode={stopResult.ExitCode} error='{sto |
| | 0 | 1071 | | operationLogPath, |
| | 0 | 1072 | | command.ServiceName); |
| | | 1073 | | } |
| | 0 | 1074 | | else if (!WaitForWindowsServiceToStopOrDisappear(command.ServiceName!, timeoutMs: 15000)) |
| | | 1075 | | { |
| | 0 | 1076 | | WriteServiceOperationLog( |
| | 0 | 1077 | | $"Service '{command.ServiceName}' did not reach STOPPED/deleted state before delete attempt.", |
| | 0 | 1078 | | operationLogPath, |
| | 0 | 1079 | | command.ServiceName); |
| | | 1080 | | } |
| | | 1081 | | |
| | 0 | 1082 | | var deleteResult = RunProcess("sc.exe", ["delete", command.ServiceName!]); |
| | 0 | 1083 | | if (deleteResult.ExitCode != 0) |
| | | 1084 | | { |
| | 0 | 1085 | | Console.Error.WriteLine(deleteResult.Error); |
| | 0 | 1086 | | return deleteResult.ExitCode; |
| | | 1087 | | } |
| | | 1088 | | |
| | 0 | 1089 | | WriteServiceOperationLog($"Service '{command.ServiceName}' remove operation completed.", operationLogPath, comma |
| | | 1090 | | |
| | 0 | 1091 | | Console.WriteLine($"Removed Windows service '{command.ServiceName}'."); |
| | 0 | 1092 | | return 0; |
| | | 1093 | | } |
| | | 1094 | | |
| | | 1095 | | /// <summary> |
| | | 1096 | | /// Waits until a Windows service reaches STOPPED state or is no longer present in SCM. |
| | | 1097 | | /// </summary> |
| | | 1098 | | /// <param name="serviceName">Service name.</param> |
| | | 1099 | | /// <param name="timeoutMs">Maximum wait time in milliseconds.</param> |
| | | 1100 | | /// <param name="pollIntervalMs">Polling interval in milliseconds.</param> |
| | | 1101 | | /// <returns>True when the service is stopped or deleted before timeout.</returns> |
| | | 1102 | | private static bool WaitForWindowsServiceToStopOrDisappear(string serviceName, int timeoutMs = 15000, int pollInterv |
| | | 1103 | | { |
| | 0 | 1104 | | var timeout = TimeSpan.FromMilliseconds(Math.Max(timeoutMs, pollIntervalMs)); |
| | 0 | 1105 | | var deadline = DateTime.UtcNow + timeout; |
| | | 1106 | | |
| | 0 | 1107 | | while (DateTime.UtcNow <= deadline) |
| | | 1108 | | { |
| | 0 | 1109 | | var queryResult = RunProcess("sc.exe", ["query", serviceName], writeStandardOutput: false); |
| | 0 | 1110 | | var diagnostics = $"{queryResult.Output}\n{queryResult.Error}"; |
| | | 1111 | | |
| | 0 | 1112 | | if (queryResult.ExitCode == 0) |
| | | 1113 | | { |
| | 0 | 1114 | | if (diagnostics.Contains("STOPPED", StringComparison.OrdinalIgnoreCase)) |
| | | 1115 | | { |
| | 0 | 1116 | | return true; |
| | | 1117 | | } |
| | | 1118 | | } |
| | 0 | 1119 | | else if (diagnostics.Contains("1060", StringComparison.OrdinalIgnoreCase) |
| | 0 | 1120 | | || diagnostics.Contains("does not exist", StringComparison.OrdinalIgnoreCase)) |
| | | 1121 | | { |
| | 0 | 1122 | | return true; |
| | | 1123 | | } |
| | | 1124 | | |
| | 0 | 1125 | | Thread.Sleep(pollIntervalMs); |
| | | 1126 | | } |
| | | 1127 | | |
| | 0 | 1128 | | return false; |
| | | 1129 | | } |
| | | 1130 | | |
| | | 1131 | | /// <summary> |
| | | 1132 | | /// Writes an install/remove operation log line for Windows service lifecycle operations. |
| | | 1133 | | /// </summary> |
| | | 1134 | | /// <param name="message">Operation message to append.</param> |
| | | 1135 | | /// <param name="configuredPath">Optional configured log path.</param> |
| | | 1136 | | /// <param name="serviceName">Optional service name used to discover configured log path.</param> |
| | | 1137 | | private static void WriteServiceOperationLog(string message, string? configuredPath, string? serviceName) |
| | | 1138 | | { |
| | 7 | 1139 | | var logPath = ResolveServiceOperationLogPath(configuredPath, serviceName); |
| | | 1140 | | try |
| | | 1141 | | { |
| | 7 | 1142 | | var directory = Path.GetDirectoryName(logPath); |
| | 7 | 1143 | | if (!string.IsNullOrWhiteSpace(directory)) |
| | | 1144 | | { |
| | 7 | 1145 | | _ = Directory.CreateDirectory(directory); |
| | | 1146 | | } |
| | | 1147 | | |
| | 7 | 1148 | | var line = $"{DateTime.UtcNow:O} {message}{Environment.NewLine}"; |
| | 7 | 1149 | | File.AppendAllText(logPath, line, Encoding.UTF8); |
| | 7 | 1150 | | } |
| | 0 | 1151 | | catch |
| | | 1152 | | { |
| | | 1153 | | // Best-effort operation logging only. |
| | 0 | 1154 | | } |
| | 7 | 1155 | | } |
| | | 1156 | | |
| | | 1157 | | /// <summary> |
| | | 1158 | | /// Writes a standardized service operation result entry. |
| | | 1159 | | /// </summary> |
| | | 1160 | | /// <param name="operation">Operation name (install/start/stop/query/remove).</param> |
| | | 1161 | | /// <param name="platform">Platform label.</param> |
| | | 1162 | | /// <param name="serviceName">Service name.</param> |
| | | 1163 | | /// <param name="exitCode">Process exit code.</param> |
| | | 1164 | | /// <param name="configuredPath">Optional configured log path.</param> |
| | | 1165 | | private static void WriteServiceOperationResult(string operation, string platform, string serviceName, int exitCode, |
| | | 1166 | | { |
| | 1 | 1167 | | var result = exitCode == 0 ? "success" : "failed"; |
| | 1 | 1168 | | WriteServiceOperationLog( |
| | 1 | 1169 | | $"operation='{operation}' service='{serviceName}' platform='{platform}' result='{result}' exitCode={exitCode |
| | 1 | 1170 | | configuredPath, |
| | 1 | 1171 | | serviceName); |
| | 1 | 1172 | | } |
| | | 1173 | | |
| | | 1174 | | /// <summary> |
| | | 1175 | | /// Resolves the service operation log path from user input, service config, or defaults. |
| | | 1176 | | /// </summary> |
| | | 1177 | | /// <param name="configuredPath">Optional configured log path.</param> |
| | | 1178 | | /// <param name="serviceName">Optional service name for discovery from service config.</param> |
| | | 1179 | | /// <returns>Absolute log file path.</returns> |
| | | 1180 | | private static string ResolveServiceOperationLogPath(string? configuredPath, string? serviceName) |
| | | 1181 | | { |
| | 7 | 1182 | | var defaultFileName = "kestrun-tool-service.log"; |
| | 7 | 1183 | | var defaultPath = RunnerRuntime.ResolveBootstrapLogPath(null, defaultFileName); |
| | | 1184 | | |
| | 7 | 1185 | | if (!string.IsNullOrWhiteSpace(configuredPath)) |
| | | 1186 | | { |
| | 4 | 1187 | | return NormalizeServiceLogPath(configuredPath, defaultFileName); |
| | | 1188 | | } |
| | | 1189 | | // When no explicit path is configured, attempt to discover a --service-log-path from the service config for bet |
| | 3 | 1190 | | return OperatingSystem.IsWindows() && !string.IsNullOrWhiteSpace(serviceName) |
| | 3 | 1191 | | && TryGetWindowsServiceLogPath(serviceName, out var discoveredPath) |
| | 3 | 1192 | | && !string.IsNullOrWhiteSpace(discoveredPath) |
| | 3 | 1193 | | ? discoveredPath |
| | 3 | 1194 | | : defaultPath; |
| | | 1195 | | } |
| | | 1196 | | |
| | | 1197 | | /// <summary> |
| | | 1198 | | /// Tries to read the configured --service-log-path from a Windows service ImagePath. |
| | | 1199 | | /// </summary> |
| | | 1200 | | /// <param name="serviceName">Service name.</param> |
| | | 1201 | | /// <param name="logPath">Discovered log path.</param> |
| | | 1202 | | /// <returns>True when a log path could be discovered.</returns> |
| | | 1203 | | private static bool TryGetWindowsServiceLogPath(string serviceName, out string? logPath) |
| | | 1204 | | { |
| | 0 | 1205 | | logPath = null; |
| | 0 | 1206 | | var queryResult = RunProcess("sc.exe", ["qc", serviceName], writeStandardOutput: false); |
| | 0 | 1207 | | if (queryResult.ExitCode != 0) |
| | | 1208 | | { |
| | 0 | 1209 | | return false; |
| | | 1210 | | } |
| | | 1211 | | |
| | 0 | 1212 | | var text = string.Concat(queryResult.Output, Environment.NewLine, queryResult.Error); |
| | 0 | 1213 | | var match = ServiceLogPathRegex().Match(text); |
| | | 1214 | | |
| | 0 | 1215 | | if (!match.Success) |
| | | 1216 | | { |
| | 0 | 1217 | | return false; |
| | | 1218 | | } |
| | | 1219 | | |
| | 0 | 1220 | | var rawPath = match.Groups["quoted"].Success |
| | 0 | 1221 | | ? match.Groups["quoted"].Value |
| | 0 | 1222 | | : match.Groups["plain"].Value; |
| | | 1223 | | |
| | 0 | 1224 | | if (string.IsNullOrWhiteSpace(rawPath)) |
| | | 1225 | | { |
| | 0 | 1226 | | return false; |
| | | 1227 | | } |
| | | 1228 | | |
| | 0 | 1229 | | logPath = NormalizeServiceLogPath(rawPath, defaultFileName: "kestrun-tool-service.log"); |
| | 0 | 1230 | | return true; |
| | | 1231 | | } |
| | | 1232 | | |
| | | 1233 | | /// <summary> |
| | | 1234 | | /// Resolves the runtime executable path from a Kestrun module manifest directory. |
| | | 1235 | | /// </summary> |
| | | 1236 | | /// <param name="moduleManifestPath">Path to Kestrun.psd1.</param> |
| | | 1237 | | /// <param name="runtimeExecutablePath">Resolved runtime executable path.</param> |
| | | 1238 | | /// <param name="error">Error details when resolution fails.</param> |
| | | 1239 | | /// <returns>True when a runtime executable is available for the current OS/architecture.</returns> |
| | | 1240 | | private static bool TryResolveServiceRuntimeExecutableFromModule(string moduleManifestPath, out string runtimeExecut |
| | | 1241 | | { |
| | 5 | 1242 | | runtimeExecutablePath = string.Empty; |
| | 5 | 1243 | | error = string.Empty; |
| | | 1244 | | |
| | 5 | 1245 | | var fullManifestPath = Path.GetFullPath(moduleManifestPath); |
| | 5 | 1246 | | var moduleRoot = Path.GetDirectoryName(fullManifestPath); |
| | 5 | 1247 | | if (string.IsNullOrWhiteSpace(moduleRoot) || !Directory.Exists(moduleRoot)) |
| | | 1248 | | { |
| | 0 | 1249 | | error = $"Unable to resolve module root from manifest path: {fullManifestPath}"; |
| | 0 | 1250 | | return false; |
| | | 1251 | | } |
| | | 1252 | | |
| | 5 | 1253 | | if (!TryGetServiceRuntimeRid(out var runtimeRid, out var ridError)) |
| | | 1254 | | { |
| | 0 | 1255 | | error = ridError; |
| | 0 | 1256 | | return false; |
| | | 1257 | | } |
| | | 1258 | | |
| | 5 | 1259 | | var runtimeBinaryName = OperatingSystem.IsWindows() ? WindowsServiceRuntimeBinaryName : UnixServiceRuntimeBinary |
| | 23 | 1260 | | foreach (var candidate in EnumerateServiceRuntimeExecutableCandidates(moduleRoot, runtimeRid, runtimeBinaryName) |
| | | 1261 | | { |
| | 9 | 1262 | | if (!File.Exists(candidate)) |
| | | 1263 | | { |
| | | 1264 | | continue; |
| | | 1265 | | } |
| | | 1266 | | |
| | 5 | 1267 | | runtimeExecutablePath = Path.GetFullPath(candidate); |
| | 5 | 1268 | | return true; |
| | | 1269 | | } |
| | | 1270 | | |
| | | 1271 | | // Module distributions may intentionally omit tool runtime binaries. |
| | | 1272 | | // In that case, reuse the dedicated service-host payload as the runner executable. |
| | 0 | 1273 | | if (TryResolveDedicatedServiceHostExecutableFromToolDistribution(out var serviceHostExecutablePath)) |
| | | 1274 | | { |
| | 0 | 1275 | | runtimeExecutablePath = serviceHostExecutablePath; |
| | 0 | 1276 | | return true; |
| | | 1277 | | } |
| | | 1278 | | |
| | | 1279 | | // Final fallback for non-standard layouts: use the current process executable path when available. |
| | 0 | 1280 | | if (!string.IsNullOrWhiteSpace(Environment.ProcessPath) && File.Exists(Environment.ProcessPath)) |
| | | 1281 | | { |
| | 0 | 1282 | | runtimeExecutablePath = Path.GetFullPath(Environment.ProcessPath); |
| | 0 | 1283 | | return true; |
| | | 1284 | | } |
| | | 1285 | | |
| | 0 | 1286 | | error = $"Unable to locate service runner executable for '{runtimeRid}'. Checked module path '{moduleRoot}', fal |
| | 0 | 1287 | | return false; |
| | 5 | 1288 | | } |
| | | 1289 | | |
| | | 1290 | | /// <summary> |
| | | 1291 | | /// Enumerates candidate runtime executable paths for service bundle staging. |
| | | 1292 | | /// </summary> |
| | | 1293 | | /// <param name="moduleRoot">Resolved module root path.</param> |
| | | 1294 | | /// <param name="runtimeRid">Runtime identifier segment (for example, win-x64).</param> |
| | | 1295 | | /// <param name="runtimeBinaryName">Runtime executable file name.</param> |
| | | 1296 | | /// <returns>Candidate runtime executable paths in resolution priority order.</returns> |
| | | 1297 | | private static IEnumerable<string> EnumerateServiceRuntimeExecutableCandidates(string moduleRoot, string runtimeRid, |
| | | 1298 | | { |
| | 5 | 1299 | | var candidates = new List<string> |
| | 5 | 1300 | | { |
| | 5 | 1301 | | Path.Combine(moduleRoot, "runtimes", runtimeRid, runtimeBinaryName), |
| | 5 | 1302 | | Path.Combine(moduleRoot, "lib", "runtimes", runtimeRid, runtimeBinaryName), |
| | 5 | 1303 | | Path.Combine(GetExecutableDirectory(), "runtimes", runtimeRid, runtimeBinaryName), |
| | 5 | 1304 | | Path.Combine(GetExecutableDirectory(), "lib", "runtimes", runtimeRid, runtimeBinaryName), |
| | 5 | 1305 | | }; |
| | | 1306 | | |
| | 5 | 1307 | | var baseDirectory = Path.GetFullPath(AppContext.BaseDirectory); |
| | 5 | 1308 | | var executableDirectory = GetExecutableDirectory(); |
| | 5 | 1309 | | if (!string.Equals(baseDirectory, executableDirectory, StringComparison.OrdinalIgnoreCase)) |
| | | 1310 | | { |
| | 5 | 1311 | | candidates.Add(Path.Combine(baseDirectory, "runtimes", runtimeRid, runtimeBinaryName)); |
| | 5 | 1312 | | candidates.Add(Path.Combine(baseDirectory, "lib", "runtimes", runtimeRid, runtimeBinaryName)); |
| | | 1313 | | } |
| | | 1314 | | |
| | 58 | 1315 | | foreach (var parent in EnumerateDirectoryAndParents(Environment.CurrentDirectory)) |
| | | 1316 | | { |
| | 24 | 1317 | | candidates.Add(Path.Combine(parent, "src", "PowerShell", "Kestrun", "runtimes", runtimeRid, runtimeBinaryNam |
| | 24 | 1318 | | candidates.Add(Path.Combine(parent, "src", "PowerShell", "Kestrun", "lib", "runtimes", runtimeRid, runtimeBi |
| | | 1319 | | } |
| | | 1320 | | |
| | 23 | 1321 | | foreach (var candidate in candidates.Distinct(StringComparer.OrdinalIgnoreCase)) |
| | | 1322 | | { |
| | 9 | 1323 | | yield return candidate; |
| | | 1324 | | } |
| | 0 | 1325 | | } |
| | | 1326 | | |
| | | 1327 | | /// <summary> |
| | | 1328 | | /// Tries to resolve a dedicated service-host executable path from the Kestrun.Tool distribution. |
| | | 1329 | | /// </summary> |
| | | 1330 | | /// <param name="serviceHostExecutablePath">Resolved service-host executable path when available.</param> |
| | | 1331 | | /// <returns>True when a dedicated service-host executable is available.</returns> |
| | | 1332 | | private static bool TryResolveDedicatedServiceHostExecutableFromToolDistribution(out string serviceHostExecutablePat |
| | | 1333 | | { |
| | 5 | 1334 | | serviceHostExecutablePath = string.Empty; |
| | 5 | 1335 | | if (!TryGetServiceRuntimeRid(out var runtimeRid, out _)) |
| | | 1336 | | { |
| | 0 | 1337 | | return false; |
| | | 1338 | | } |
| | | 1339 | | |
| | 5 | 1340 | | var hostBinaryName = OperatingSystem.IsWindows() ? "kestrun-service-host.exe" : "kestrun-service-host"; |
| | 39 | 1341 | | foreach (var candidate in EnumerateDedicatedServiceHostCandidates(runtimeRid, hostBinaryName)) |
| | | 1342 | | { |
| | 17 | 1343 | | if (!File.Exists(candidate)) |
| | | 1344 | | { |
| | | 1345 | | continue; |
| | | 1346 | | } |
| | | 1347 | | |
| | 5 | 1348 | | serviceHostExecutablePath = Path.GetFullPath(candidate); |
| | 5 | 1349 | | return true; |
| | | 1350 | | } |
| | | 1351 | | |
| | 0 | 1352 | | return false; |
| | 5 | 1353 | | } |
| | | 1354 | | |
| | | 1355 | | /// <summary> |
| | | 1356 | | /// Tries to resolve bundled PowerShell Modules payload from the Kestrun.Tool distribution. |
| | | 1357 | | /// </summary> |
| | | 1358 | | /// <param name="modulesPayloadPath">Resolved modules payload path when available.</param> |
| | | 1359 | | /// <returns>True when the modules payload is available.</returns> |
| | | 1360 | | private static bool TryResolvePowerShellModulesPayloadFromToolDistribution(out string modulesPayloadPath) |
| | | 1361 | | { |
| | 3 | 1362 | | modulesPayloadPath = string.Empty; |
| | 3 | 1363 | | if (!TryGetServiceRuntimeRid(out var runtimeRid, out _)) |
| | | 1364 | | { |
| | 0 | 1365 | | return false; |
| | | 1366 | | } |
| | | 1367 | | |
| | 27 | 1368 | | foreach (var candidate in EnumeratePowerShellModulesPayloadCandidates(runtimeRid)) |
| | | 1369 | | { |
| | 12 | 1370 | | if (!Directory.Exists(candidate)) |
| | | 1371 | | { |
| | | 1372 | | continue; |
| | | 1373 | | } |
| | | 1374 | | |
| | 3 | 1375 | | modulesPayloadPath = Path.GetFullPath(candidate); |
| | 3 | 1376 | | return true; |
| | | 1377 | | } |
| | | 1378 | | |
| | 0 | 1379 | | return false; |
| | 3 | 1380 | | } |
| | | 1381 | | |
| | | 1382 | | /// <summary> |
| | | 1383 | | /// Enumerates candidate service-host paths shipped with Kestrun.Tool for the target RID. |
| | | 1384 | | /// </summary> |
| | | 1385 | | /// <param name="runtimeRid">Runtime identifier segment (for example, win-x64).</param> |
| | | 1386 | | /// <param name="hostBinaryName">Service-host executable file name.</param> |
| | | 1387 | | /// <returns>Candidate service-host paths in resolution priority order.</returns> |
| | | 1388 | | private static IEnumerable<string> EnumerateDedicatedServiceHostCandidates(string runtimeRid, string hostBinaryName) |
| | | 1389 | | { |
| | 5 | 1390 | | var candidates = new List<string>(); |
| | | 1391 | | |
| | | 1392 | | // 1. Development/source path: ./src/CSharp/Kestrun.Tool/kestrun-service/<rid>/ |
| | | 1393 | | // Prefer this during local builds/tests to avoid accidentally selecting a stale globally installed tool payload |
| | 76 | 1394 | | foreach (var parent in EnumerateDirectoryAndParents(Environment.CurrentDirectory)) |
| | | 1395 | | { |
| | 33 | 1396 | | candidates.Add(Path.Combine(parent, "src", "CSharp", "Kestrun.Tool", "kestrun-service", runtimeRid, hostBina |
| | | 1397 | | } |
| | | 1398 | | |
| | | 1399 | | // 2. Dotnet tool store path: ~/.dotnet/tools/.store/kestrun.tool/<version>/ |
| | 5 | 1400 | | var homeDirectory = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); |
| | 5 | 1401 | | if (!string.IsNullOrWhiteSpace(homeDirectory)) |
| | | 1402 | | { |
| | 5 | 1403 | | var toolStorePath = Path.Combine(homeDirectory, ".dotnet", "tools", ".store", "kestrun.tool"); |
| | 5 | 1404 | | if (Directory.Exists(toolStorePath)) |
| | | 1405 | | { |
| | 20 | 1406 | | foreach (var versionDir in Directory.GetDirectories(toolStorePath)) |
| | | 1407 | | { |
| | 5 | 1408 | | candidates.Add(Path.Combine(versionDir, "kestrun.tool", Path.GetFileName(versionDir), "tools", "net1 |
| | | 1409 | | } |
| | | 1410 | | } |
| | | 1411 | | } |
| | | 1412 | | |
| | 39 | 1413 | | foreach (var candidate in candidates.Distinct(StringComparer.OrdinalIgnoreCase)) |
| | | 1414 | | { |
| | 17 | 1415 | | yield return candidate; |
| | | 1416 | | } |
| | 0 | 1417 | | } |
| | | 1418 | | |
| | | 1419 | | /// <summary> |
| | | 1420 | | /// Enumerates candidate PowerShell Modules payload paths shipped with Kestrun.Tool for the target RID. |
| | | 1421 | | /// </summary> |
| | | 1422 | | /// <param name="runtimeRid">Runtime identifier segment (for example, win-x64).</param> |
| | | 1423 | | /// <returns>Candidate modules payload paths in resolution priority order.</returns> |
| | | 1424 | | private static IEnumerable<string> EnumeratePowerShellModulesPayloadCandidates(string runtimeRid) |
| | | 1425 | | { |
| | 3 | 1426 | | var executableDirectory = GetExecutableDirectory(); |
| | 3 | 1427 | | var baseDirectory = Path.GetFullPath(AppContext.BaseDirectory); |
| | 3 | 1428 | | var candidates = new List<string> |
| | 3 | 1429 | | { |
| | 3 | 1430 | | Path.Combine(executableDirectory, "kestrun-service", runtimeRid, "Modules"), |
| | 3 | 1431 | | Path.Combine(baseDirectory, "kestrun-service", runtimeRid, "Modules"), |
| | 3 | 1432 | | Path.Combine(executableDirectory, runtimeRid, "Modules"), |
| | 3 | 1433 | | Path.Combine(baseDirectory, runtimeRid, "Modules"), |
| | 3 | 1434 | | Path.Combine(executableDirectory, "runtimes", runtimeRid, "Modules"), |
| | 3 | 1435 | | Path.Combine(baseDirectory, "runtimes", runtimeRid, "Modules"), |
| | 3 | 1436 | | }; |
| | | 1437 | | |
| | 24 | 1438 | | foreach (var parent in EnumerateDirectoryAndParents(Environment.CurrentDirectory)) |
| | | 1439 | | { |
| | 9 | 1440 | | candidates.Add(Path.Combine(parent, "src", "CSharp", "Kestrun.Tool", "kestrun-service", runtimeRid, "Modules |
| | | 1441 | | } |
| | | 1442 | | |
| | 27 | 1443 | | foreach (var candidate in candidates.Distinct(StringComparer.OrdinalIgnoreCase)) |
| | | 1444 | | { |
| | 12 | 1445 | | yield return candidate; |
| | | 1446 | | } |
| | 0 | 1447 | | } |
| | | 1448 | | |
| | | 1449 | | /// <summary> |
| | | 1450 | | /// Enumerates a directory and all parents up to the filesystem root. |
| | | 1451 | | /// </summary> |
| | | 1452 | | /// <param name="startDirectory">Starting directory path.</param> |
| | | 1453 | | /// <returns>Normalized directory path sequence from leaf to root.</returns> |
| | | 1454 | | private static IEnumerable<string> EnumerateDirectoryAndParents(string startDirectory) |
| | | 1455 | | { |
| | 15 | 1456 | | var current = Path.GetFullPath(startDirectory); |
| | 68 | 1457 | | while (!string.IsNullOrWhiteSpace(current)) |
| | | 1458 | | { |
| | 68 | 1459 | | yield return current; |
| | | 1460 | | |
| | 66 | 1461 | | var parent = Directory.GetParent(current); |
| | 66 | 1462 | | if (parent is null) |
| | | 1463 | | { |
| | | 1464 | | break; |
| | | 1465 | | } |
| | | 1466 | | |
| | 53 | 1467 | | current = parent.FullName; |
| | | 1468 | | } |
| | 13 | 1469 | | } |
| | | 1470 | | |
| | | 1471 | | /// <summary> |
| | | 1472 | | /// Resolves service-install script source, including optional content-root semantics. |
| | | 1473 | | /// </summary> |
| | | 1474 | | /// <param name="command">Parsed service command.</param> |
| | | 1475 | | /// <param name="scriptSource">Resolved script source details.</param> |
| | | 1476 | | /// <param name="error">Error details when validation fails.</param> |
| | | 1477 | | /// <returns>True when script source is valid and exists.</returns> |
| | | 1478 | | private static bool TryResolveServiceScriptSource(ParsedCommand command, out ResolvedServiceScriptSource scriptSourc |
| | | 1479 | | { |
| | 20 | 1480 | | var optionFlags = GetServiceContentRootOptionFlags(command); |
| | 20 | 1481 | | if (!TryClassifyServiceContentRoot(command.ServiceContentRoot, out _, out var contentRootUri, out var fullConten |
| | | 1482 | | { |
| | 1 | 1483 | | var fallbackScriptPath = ResolveRequestedServiceScriptPath(command.ScriptPath, useDefaultWhenMissing: true); |
| | 1 | 1484 | | return TryResolveServiceScriptWithoutContentRoot(fallbackScriptPath, optionFlags, out scriptSource, out erro |
| | | 1485 | | } |
| | | 1486 | | |
| | 19 | 1487 | | if (command.Mode == CommandMode.ServiceInstall && command.ServiceNameProvided) |
| | | 1488 | | { |
| | 0 | 1489 | | scriptSource = CreateEmptyResolvedServiceScriptSource(); |
| | 0 | 1490 | | error = "--name is no longer supported for service install. Define Name in Service.psd1 inside the package." |
| | 0 | 1491 | | return false; |
| | | 1492 | | } |
| | | 1493 | | |
| | 19 | 1494 | | if (command.ScriptPathProvided) |
| | | 1495 | | { |
| | 0 | 1496 | | scriptSource = CreateEmptyResolvedServiceScriptSource(); |
| | 0 | 1497 | | error = "--script (or positional script path) is not supported when --package/--content-root is used. Define |
| | 0 | 1498 | | return false; |
| | | 1499 | | } |
| | | 1500 | | |
| | 19 | 1501 | | var requestedScriptPath = ResolveRequestedServiceScriptPath(command.ScriptPath, useDefaultWhenMissing: false); |
| | | 1502 | | // When a content-root value is supplied, attempt to resolve the script source from the content root, even if th |
| | | 1503 | | // since some content-root scenarios imply a default script name. |
| | 19 | 1504 | | return TryResolveServiceScriptFromContentRoot( |
| | 19 | 1505 | | command, |
| | 19 | 1506 | | requestedScriptPath, |
| | 19 | 1507 | | contentRootUri, |
| | 19 | 1508 | | fullContentRoot, |
| | 19 | 1509 | | optionFlags, |
| | 19 | 1510 | | out scriptSource, |
| | 19 | 1511 | | out error); |
| | | 1512 | | } |
| | | 1513 | | |
| | | 1514 | | /// <summary> |
| | | 1515 | | /// Classifies service content-root input into normalized local path or HTTP(S) URI forms. |
| | | 1516 | | /// </summary> |
| | | 1517 | | /// <param name="rawContentRoot">Raw content-root token from CLI arguments.</param> |
| | | 1518 | | /// <param name="normalizedContentRoot">Trimmed content-root token.</param> |
| | | 1519 | | /// <param name="contentRootUri">Parsed HTTP(S) URI when the content root is remote; otherwise null.</param> |
| | | 1520 | | /// <param name="fullContentRoot">Normalized absolute local path when the content root is local; otherwise empty.</p |
| | | 1521 | | /// <returns>True when a content-root value exists; false when no content-root was supplied.</returns> |
| | | 1522 | | private static bool TryClassifyServiceContentRoot( |
| | | 1523 | | string? rawContentRoot, |
| | | 1524 | | out string normalizedContentRoot, |
| | | 1525 | | out Uri? contentRootUri, |
| | | 1526 | | out string fullContentRoot) |
| | | 1527 | | { |
| | 22 | 1528 | | normalizedContentRoot = string.Empty; |
| | 22 | 1529 | | contentRootUri = null; |
| | 22 | 1530 | | fullContentRoot = string.Empty; |
| | | 1531 | | |
| | 22 | 1532 | | if (string.IsNullOrWhiteSpace(rawContentRoot)) |
| | | 1533 | | { |
| | 2 | 1534 | | return false; |
| | | 1535 | | } |
| | | 1536 | | |
| | 20 | 1537 | | normalizedContentRoot = rawContentRoot.Trim(); |
| | 20 | 1538 | | if (TryParseServiceContentRootHttpUri(normalizedContentRoot, out var parsedUri)) |
| | | 1539 | | { |
| | 7 | 1540 | | contentRootUri = parsedUri; |
| | 7 | 1541 | | return true; |
| | | 1542 | | } |
| | | 1543 | | |
| | 13 | 1544 | | fullContentRoot = Path.GetFullPath(normalizedContentRoot); |
| | 13 | 1545 | | return true; |
| | | 1546 | | } |
| | | 1547 | | |
| | | 1548 | | /// <summary> |
| | | 1549 | | /// Resolves service script source for a classified content-root input. |
| | | 1550 | | /// </summary> |
| | | 1551 | | /// <param name="command">Parsed service command.</param> |
| | | 1552 | | /// <param name="requestedScriptPath">Requested script path argument.</param> |
| | | 1553 | | /// <param name="contentRootUri">Parsed HTTP(S) content-root URI when applicable.</param> |
| | | 1554 | | /// <param name="fullContentRoot">Absolute local content-root path when applicable.</param> |
| | | 1555 | | /// <param name="optionFlags">Content-root related option usage flags.</param> |
| | | 1556 | | /// <param name="scriptSource">Resolved script source details.</param> |
| | | 1557 | | /// <param name="error">Error details when resolution fails.</param> |
| | | 1558 | | /// <returns>True when script source resolution succeeds.</returns> |
| | | 1559 | | private static bool TryResolveServiceScriptFromContentRoot( |
| | | 1560 | | ParsedCommand command, |
| | | 1561 | | string requestedScriptPath, |
| | | 1562 | | Uri? contentRootUri, |
| | | 1563 | | string fullContentRoot, |
| | | 1564 | | ServiceContentRootOptionFlags optionFlags, |
| | | 1565 | | out ResolvedServiceScriptSource scriptSource, |
| | | 1566 | | out string error) |
| | | 1567 | | { |
| | 19 | 1568 | | if (contentRootUri is not null) |
| | | 1569 | | { |
| | 6 | 1570 | | return TryResolveServiceScriptFromHttpContentRoot(command, requestedScriptPath, contentRootUri, out scriptSo |
| | | 1571 | | } |
| | | 1572 | | |
| | 13 | 1573 | | if (Directory.Exists(fullContentRoot)) |
| | | 1574 | | { |
| | 6 | 1575 | | return TryResolveServiceScriptFromDirectoryContentRoot(requestedScriptPath, fullContentRoot, optionFlags, ou |
| | | 1576 | | } |
| | | 1577 | | // When content-root is supplied but does not exist as a local directory, attempt to treat it as an archive path |
| | | 1578 | | // to a file without realizing that only directories are supported for local content roots. |
| | 7 | 1579 | | return TryResolveServiceScriptFromArchiveContentRoot(command, requestedScriptPath, fullContentRoot, optionFlags, |
| | | 1580 | | } |
| | | 1581 | | |
| | | 1582 | | /// <summary> |
| | | 1583 | | /// Represents parsed option usage for content-root related arguments. |
| | | 1584 | | /// </summary> |
| | | 1585 | | private readonly record struct ServiceContentRootOptionFlags( |
| | 7 | 1586 | | bool HasArchiveChecksum, |
| | 13 | 1587 | | bool HasBearerToken, |
| | 13 | 1588 | | bool IgnoreCertificate, |
| | 12 | 1589 | | bool HasHeaders); |
| | | 1590 | | |
| | | 1591 | | /// <summary> |
| | | 1592 | | /// Resolves the script path value for service install commands, applying default script fallback. |
| | | 1593 | | /// </summary> |
| | | 1594 | | /// <param name="scriptPath">Requested script path argument.</param> |
| | | 1595 | | /// <param name="useDefaultWhenMissing">True to apply the default script file name when no script path was provided. |
| | | 1596 | | /// <returns>Resolved script path token.</returns> |
| | | 1597 | | private static string ResolveRequestedServiceScriptPath(string scriptPath, bool useDefaultWhenMissing) |
| | 20 | 1598 | | => string.IsNullOrWhiteSpace(scriptPath) |
| | 20 | 1599 | | ? (useDefaultWhenMissing ? ServiceDefaultScriptFileName : string.Empty) |
| | 20 | 1600 | | : scriptPath; |
| | | 1601 | | |
| | | 1602 | | /// <summary> |
| | | 1603 | | /// Captures which content-root related options were supplied on the command line. |
| | | 1604 | | /// </summary> |
| | | 1605 | | /// <param name="command">Parsed service command.</param> |
| | | 1606 | | /// <returns>Normalized option usage flags.</returns> |
| | | 1607 | | private static ServiceContentRootOptionFlags GetServiceContentRootOptionFlags(ParsedCommand command) |
| | 20 | 1608 | | => new( |
| | 20 | 1609 | | !string.IsNullOrWhiteSpace(command.ServiceContentRootChecksum), |
| | 20 | 1610 | | !string.IsNullOrWhiteSpace(command.ServiceContentRootBearerToken), |
| | 20 | 1611 | | command.ServiceContentRootIgnoreCertificate, |
| | 20 | 1612 | | command.ServiceContentRootHeaders.Length > 0); |
| | | 1613 | | |
| | | 1614 | | /// <summary> |
| | | 1615 | | /// Resolves script source when no content-root argument is provided. |
| | | 1616 | | /// </summary> |
| | | 1617 | | /// <param name="requestedScriptPath">Requested script path.</param> |
| | | 1618 | | /// <param name="optionFlags">Content-root related option flags.</param> |
| | | 1619 | | /// <param name="scriptSource">Resolved script source details.</param> |
| | | 1620 | | /// <param name="error">Error details when validation fails.</param> |
| | | 1621 | | /// <returns>True when script resolution succeeds.</returns> |
| | | 1622 | | private static bool TryResolveServiceScriptWithoutContentRoot( |
| | | 1623 | | string requestedScriptPath, |
| | | 1624 | | ServiceContentRootOptionFlags optionFlags, |
| | | 1625 | | out ResolvedServiceScriptSource scriptSource, |
| | | 1626 | | out string error) |
| | | 1627 | | { |
| | 1 | 1628 | | scriptSource = CreateEmptyResolvedServiceScriptSource(); |
| | 1 | 1629 | | if (!TryValidateOptionsForMissingContentRoot(optionFlags, out error)) |
| | | 1630 | | { |
| | 0 | 1631 | | return false; |
| | | 1632 | | } |
| | | 1633 | | |
| | 1 | 1634 | | var fullScriptPath = Path.GetFullPath(requestedScriptPath); |
| | 1 | 1635 | | if (!File.Exists(fullScriptPath)) |
| | | 1636 | | { |
| | 0 | 1637 | | error = $"Script file not found: {fullScriptPath}"; |
| | 0 | 1638 | | return false; |
| | | 1639 | | } |
| | | 1640 | | |
| | 1 | 1641 | | scriptSource = new ResolvedServiceScriptSource(fullScriptPath, null, Path.GetFileName(fullScriptPath), null, nul |
| | 1 | 1642 | | error = string.Empty; |
| | 1 | 1643 | | return true; |
| | | 1644 | | } |
| | | 1645 | | |
| | | 1646 | | /// <summary> |
| | | 1647 | | /// Resolves script source when content-root points to an HTTP(S) archive. |
| | | 1648 | | /// </summary> |
| | | 1649 | | /// <param name="command">Parsed service command.</param> |
| | | 1650 | | /// <param name="requestedScriptPath">Requested script path.</param> |
| | | 1651 | | /// <param name="contentRootUri">HTTP(S) archive URI.</param> |
| | | 1652 | | /// <param name="optionFlags">Content-root related option flags.</param> |
| | | 1653 | | /// <param name="scriptSource">Resolved script source details.</param> |
| | | 1654 | | /// <param name="error">Error details when validation fails.</param> |
| | | 1655 | | /// <returns>True when script resolution succeeds.</returns> |
| | | 1656 | | private static bool TryResolveServiceScriptFromHttpContentRoot( |
| | | 1657 | | ParsedCommand command, |
| | | 1658 | | string requestedScriptPath, |
| | | 1659 | | Uri contentRootUri, |
| | | 1660 | | out ResolvedServiceScriptSource scriptSource, |
| | | 1661 | | out string error) |
| | | 1662 | | { |
| | 6 | 1663 | | scriptSource = CreateEmptyResolvedServiceScriptSource(); |
| | 6 | 1664 | | if (!TryValidateHttpContentRootScriptPath(requestedScriptPath, out error)) |
| | | 1665 | | { |
| | 0 | 1666 | | return false; |
| | | 1667 | | } |
| | | 1668 | | |
| | 6 | 1669 | | var temporaryRoot = CreateServiceContentRootExtractionDirectory(command.ServiceName); |
| | 6 | 1670 | | var downloadedContentRoot = Path.Combine(temporaryRoot, "content"); |
| | 6 | 1671 | | _ = Directory.CreateDirectory(downloadedContentRoot); |
| | | 1672 | | |
| | | 1673 | | try |
| | | 1674 | | { |
| | 6 | 1675 | | if (!TryDownloadAndExtractHttpContentRoot(command, contentRootUri, temporaryRoot, downloadedContentRoot, out |
| | | 1676 | | { |
| | 3 | 1677 | | TryDeleteDirectoryWithRetry(temporaryRoot, maxAttempts: 5, initialDelayMs: 50); |
| | 3 | 1678 | | return false; |
| | | 1679 | | } |
| | | 1680 | | |
| | 3 | 1681 | | if (!TryResolveServiceInstallDescriptor(downloadedContentRoot, out var descriptor, out error)) |
| | | 1682 | | { |
| | 0 | 1683 | | TryDeleteDirectoryWithRetry(temporaryRoot, maxAttempts: 5, initialDelayMs: 50); |
| | 0 | 1684 | | return false; |
| | | 1685 | | } |
| | | 1686 | | |
| | 3 | 1687 | | if (!TryResolveServiceDescriptorScriptPath(requestedScriptPath, descriptor, out var resolvedScriptPath, out |
| | | 1688 | | { |
| | 0 | 1689 | | TryDeleteDirectoryWithRetry(temporaryRoot, maxAttempts: 5, initialDelayMs: 50); |
| | 0 | 1690 | | return false; |
| | | 1691 | | } |
| | | 1692 | | |
| | 3 | 1693 | | if (!TryResolveScriptFromResolvedContentRoot( |
| | 3 | 1694 | | resolvedScriptPath, |
| | 3 | 1695 | | downloadedContentRoot, |
| | 3 | 1696 | | $"Script path '{resolvedScriptPath}' escapes the extracted archive content root.", |
| | 3 | 1697 | | $"Script file '{resolvedScriptPath}' was not found inside extracted archive downloaded from '{conten |
| | 3 | 1698 | | temporaryRoot, |
| | 3 | 1699 | | out scriptSource, |
| | 3 | 1700 | | out error)) |
| | | 1701 | | { |
| | 0 | 1702 | | TryDeleteDirectoryWithRetry(temporaryRoot, maxAttempts: 5, initialDelayMs: 50); |
| | 0 | 1703 | | return false; |
| | | 1704 | | } |
| | | 1705 | | |
| | 3 | 1706 | | scriptSource = ApplyDescriptorMetadata(scriptSource, descriptor); |
| | | 1707 | | |
| | 3 | 1708 | | return true; |
| | | 1709 | | } |
| | 0 | 1710 | | catch |
| | | 1711 | | { |
| | 0 | 1712 | | TryDeleteDirectoryWithRetry(temporaryRoot, maxAttempts: 5, initialDelayMs: 50); |
| | 0 | 1713 | | throw; |
| | | 1714 | | } |
| | 6 | 1715 | | } |
| | | 1716 | | |
| | | 1717 | | /// <summary> |
| | | 1718 | | /// Resolves script source when content-root points to a local directory. |
| | | 1719 | | /// </summary> |
| | | 1720 | | /// <param name="requestedScriptPath">Requested script path.</param> |
| | | 1721 | | /// <param name="fullContentRoot">Absolute content-root directory path.</param> |
| | | 1722 | | /// <param name="optionFlags">Content-root related option flags.</param> |
| | | 1723 | | /// <param name="scriptSource">Resolved script source details.</param> |
| | | 1724 | | /// <param name="error">Error details when validation fails.</param> |
| | | 1725 | | /// <returns>True when script resolution succeeds.</returns> |
| | | 1726 | | private static bool TryResolveServiceScriptFromDirectoryContentRoot( |
| | | 1727 | | string requestedScriptPath, |
| | | 1728 | | string fullContentRoot, |
| | | 1729 | | ServiceContentRootOptionFlags optionFlags, |
| | | 1730 | | out ResolvedServiceScriptSource scriptSource, |
| | | 1731 | | out string error) |
| | | 1732 | | { |
| | 6 | 1733 | | scriptSource = CreateEmptyResolvedServiceScriptSource(); |
| | 6 | 1734 | | if (!TryValidateDirectoryContentRootOptions(optionFlags, out error)) |
| | | 1735 | | { |
| | 0 | 1736 | | return false; |
| | | 1737 | | } |
| | | 1738 | | |
| | 6 | 1739 | | if (!TryResolveServiceInstallDescriptor(fullContentRoot, out var descriptor, out error)) |
| | | 1740 | | { |
| | 2 | 1741 | | return false; |
| | | 1742 | | } |
| | | 1743 | | |
| | 4 | 1744 | | if (!TryResolveServiceDescriptorScriptPath(requestedScriptPath, descriptor, out var resolvedScriptPath, out erro |
| | | 1745 | | { |
| | 1 | 1746 | | return false; |
| | | 1747 | | } |
| | | 1748 | | |
| | 3 | 1749 | | if (!TryResolveScriptFromResolvedContentRoot( |
| | 3 | 1750 | | resolvedScriptPath, |
| | 3 | 1751 | | fullContentRoot, |
| | 3 | 1752 | | $"Script path '{resolvedScriptPath}' escapes the service content root '{fullContentRoot}'.", |
| | 3 | 1753 | | $"Script file '{resolvedScriptPath}' was not found under service content root '{fullContentRoot}'.", |
| | 3 | 1754 | | null, |
| | 3 | 1755 | | out scriptSource, |
| | 3 | 1756 | | out error)) |
| | | 1757 | | { |
| | 0 | 1758 | | return false; |
| | | 1759 | | } |
| | | 1760 | | |
| | 3 | 1761 | | scriptSource = ApplyDescriptorMetadata(scriptSource, descriptor); |
| | 3 | 1762 | | return true; |
| | | 1763 | | } |
| | | 1764 | | |
| | | 1765 | | /// <summary> |
| | | 1766 | | /// Resolves script source when content-root points to a local archive file. |
| | | 1767 | | /// </summary> |
| | | 1768 | | /// <param name="command">Parsed service command.</param> |
| | | 1769 | | /// <param name="requestedScriptPath">Requested script path.</param> |
| | | 1770 | | /// <param name="fullContentRoot">Absolute archive path.</param> |
| | | 1771 | | /// <param name="optionFlags">Content-root related option flags.</param> |
| | | 1772 | | /// <param name="scriptSource">Resolved script source details.</param> |
| | | 1773 | | /// <param name="error">Error details when validation fails.</param> |
| | | 1774 | | /// <returns>True when script resolution succeeds.</returns> |
| | | 1775 | | private static bool TryResolveServiceScriptFromArchiveContentRoot( |
| | | 1776 | | ParsedCommand command, |
| | | 1777 | | string requestedScriptPath, |
| | | 1778 | | string fullContentRoot, |
| | | 1779 | | ServiceContentRootOptionFlags optionFlags, |
| | | 1780 | | out ResolvedServiceScriptSource scriptSource, |
| | | 1781 | | out string error) |
| | | 1782 | | { |
| | 7 | 1783 | | scriptSource = CreateEmptyResolvedServiceScriptSource(); |
| | 7 | 1784 | | if (!File.Exists(fullContentRoot)) |
| | | 1785 | | { |
| | 1 | 1786 | | error = $"Service content root path was not found: {fullContentRoot}"; |
| | 1 | 1787 | | return false; |
| | | 1788 | | } |
| | | 1789 | | |
| | 6 | 1790 | | if (!TryValidateLocalArchiveContentRootOptions(optionFlags, out error)) |
| | | 1791 | | { |
| | 2 | 1792 | | return false; |
| | | 1793 | | } |
| | | 1794 | | |
| | 4 | 1795 | | if (!IsSupportedServiceContentRootArchive(fullContentRoot)) |
| | | 1796 | | { |
| | 0 | 1797 | | error = $"Unsupported package format. Supported extensions: {ServicePackageExtension}, .zip, .tar, .tgz, .ta |
| | 0 | 1798 | | return false; |
| | | 1799 | | } |
| | | 1800 | | |
| | 4 | 1801 | | if (!TryValidateServiceContentRootArchiveChecksum(command, fullContentRoot, out error)) |
| | | 1802 | | { |
| | 1 | 1803 | | return false; |
| | | 1804 | | } |
| | | 1805 | | |
| | 3 | 1806 | | var extractedContentRoot = CreateServiceContentRootExtractionDirectory(command.ServiceName); |
| | | 1807 | | try |
| | | 1808 | | { |
| | 3 | 1809 | | if (TryResolveServiceScriptFromExtractedArchiveContentRoot( |
| | 3 | 1810 | | requestedScriptPath, |
| | 3 | 1811 | | fullContentRoot, |
| | 3 | 1812 | | extractedContentRoot, |
| | 3 | 1813 | | out var extractedScriptSource, |
| | 3 | 1814 | | out error)) |
| | | 1815 | | { |
| | 3 | 1816 | | scriptSource = extractedScriptSource; |
| | 3 | 1817 | | return true; |
| | | 1818 | | } |
| | | 1819 | | |
| | 0 | 1820 | | TryDeleteDirectoryWithRetry(extractedContentRoot, maxAttempts: 5, initialDelayMs: 50); |
| | 0 | 1821 | | return false; |
| | | 1822 | | } |
| | 0 | 1823 | | catch |
| | | 1824 | | { |
| | 0 | 1825 | | TryDeleteDirectoryWithRetry(extractedContentRoot, maxAttempts: 5, initialDelayMs: 50); |
| | 0 | 1826 | | throw; |
| | | 1827 | | } |
| | 3 | 1828 | | } |
| | | 1829 | | |
| | | 1830 | | /// <summary> |
| | | 1831 | | /// Resolves service script source from an already-created extraction directory for a local archive content root. |
| | | 1832 | | /// </summary> |
| | | 1833 | | /// <param name="requestedScriptPath">Requested script path.</param> |
| | | 1834 | | /// <param name="fullContentRoot">Absolute archive path.</param> |
| | | 1835 | | /// <param name="extractedContentRoot">Archive extraction directory path.</param> |
| | | 1836 | | /// <param name="scriptSource">Resolved script source details.</param> |
| | | 1837 | | /// <param name="error">Error details when validation fails.</param> |
| | | 1838 | | /// <returns>True when script source resolution succeeds.</returns> |
| | | 1839 | | private static bool TryResolveServiceScriptFromExtractedArchiveContentRoot( |
| | | 1840 | | string requestedScriptPath, |
| | | 1841 | | string fullContentRoot, |
| | | 1842 | | string extractedContentRoot, |
| | | 1843 | | out ResolvedServiceScriptSource scriptSource, |
| | | 1844 | | out string error) |
| | | 1845 | | { |
| | 3 | 1846 | | scriptSource = CreateEmptyResolvedServiceScriptSource(); |
| | | 1847 | | |
| | 3 | 1848 | | if (!TryExtractServiceContentRootArchive(fullContentRoot, extractedContentRoot, out error)) |
| | | 1849 | | { |
| | 0 | 1850 | | return false; |
| | | 1851 | | } |
| | | 1852 | | |
| | 3 | 1853 | | if (!TryResolveServiceInstallDescriptor(extractedContentRoot, out var descriptor, out error)) |
| | | 1854 | | { |
| | 0 | 1855 | | return false; |
| | | 1856 | | } |
| | | 1857 | | |
| | 3 | 1858 | | if (!TryResolveServiceDescriptorScriptPath(requestedScriptPath, descriptor, out var resolvedScriptPath, out erro |
| | | 1859 | | { |
| | 0 | 1860 | | return false; |
| | | 1861 | | } |
| | | 1862 | | |
| | 3 | 1863 | | if (!TryResolveScriptFromResolvedContentRoot( |
| | 3 | 1864 | | resolvedScriptPath, |
| | 3 | 1865 | | extractedContentRoot, |
| | 3 | 1866 | | $"Script path '{resolvedScriptPath}' escapes the extracted archive content root.", |
| | 3 | 1867 | | $"Script file '{resolvedScriptPath}' was not found inside extracted archive '{fullContentRoot}'.", |
| | 3 | 1868 | | extractedContentRoot, |
| | 3 | 1869 | | out scriptSource, |
| | 3 | 1870 | | out error)) |
| | | 1871 | | { |
| | 0 | 1872 | | return false; |
| | | 1873 | | } |
| | | 1874 | | |
| | 3 | 1875 | | scriptSource = ApplyDescriptorMetadata(scriptSource, descriptor); |
| | 3 | 1876 | | return true; |
| | | 1877 | | } |
| | | 1878 | | |
| | | 1879 | | /// <summary> |
| | | 1880 | | /// Validates content-root options when no content-root argument was supplied. |
| | | 1881 | | /// </summary> |
| | | 1882 | | /// <param name="optionFlags">Content-root related option flags.</param> |
| | | 1883 | | /// <param name="error">Validation error message when invalid combinations are detected.</param> |
| | | 1884 | | /// <returns>True when the option combination is valid.</returns> |
| | | 1885 | | private static bool TryValidateOptionsForMissingContentRoot(ServiceContentRootOptionFlags optionFlags, out string er |
| | | 1886 | | { |
| | 1 | 1887 | | if (optionFlags.HasArchiveChecksum) |
| | | 1888 | | { |
| | 0 | 1889 | | error = "--content-root-checksum requires --content-root."; |
| | 0 | 1890 | | return false; |
| | | 1891 | | } |
| | | 1892 | | |
| | 1 | 1893 | | if (optionFlags.HasBearerToken) |
| | | 1894 | | { |
| | 0 | 1895 | | error = "--content-root-bearer-token requires --content-root."; |
| | 0 | 1896 | | return false; |
| | | 1897 | | } |
| | | 1898 | | |
| | 1 | 1899 | | if (optionFlags.IgnoreCertificate) |
| | | 1900 | | { |
| | 0 | 1901 | | error = "--content-root-ignore-certificate requires --content-root."; |
| | 0 | 1902 | | return false; |
| | | 1903 | | } |
| | | 1904 | | |
| | 1 | 1905 | | if (optionFlags.HasHeaders) |
| | | 1906 | | { |
| | 0 | 1907 | | error = "--content-root-header requires --content-root."; |
| | 0 | 1908 | | return false; |
| | | 1909 | | } |
| | | 1910 | | |
| | 1 | 1911 | | error = string.Empty; |
| | 1 | 1912 | | return true; |
| | | 1913 | | } |
| | | 1914 | | |
| | | 1915 | | /// <summary> |
| | | 1916 | | /// Validates URL-only options for non-URL content roots. |
| | | 1917 | | /// </summary> |
| | | 1918 | | /// <param name="optionFlags">Content-root related option flags.</param> |
| | | 1919 | | /// <param name="error">Validation error message when invalid combinations are detected.</param> |
| | | 1920 | | /// <returns>True when URL-only options were not supplied.</returns> |
| | | 1921 | | private static bool TryValidateUrlOnlyContentRootOptions(ServiceContentRootOptionFlags optionFlags, out string error |
| | | 1922 | | { |
| | 12 | 1923 | | if (optionFlags.HasBearerToken) |
| | | 1924 | | { |
| | 0 | 1925 | | error = "--content-root-bearer-token is only supported when --content-root points to an HTTP(S) archive URL. |
| | 0 | 1926 | | return false; |
| | | 1927 | | } |
| | | 1928 | | |
| | 12 | 1929 | | if (optionFlags.IgnoreCertificate) |
| | | 1930 | | { |
| | 1 | 1931 | | error = "--content-root-ignore-certificate is only supported when --content-root points to an HTTPS archive |
| | 1 | 1932 | | return false; |
| | | 1933 | | } |
| | | 1934 | | |
| | 11 | 1935 | | if (optionFlags.HasHeaders) |
| | | 1936 | | { |
| | 1 | 1937 | | error = "--content-root-header is only supported when --content-root points to an HTTP(S) archive URL."; |
| | 1 | 1938 | | return false; |
| | | 1939 | | } |
| | | 1940 | | |
| | 10 | 1941 | | error = string.Empty; |
| | 10 | 1942 | | return true; |
| | | 1943 | | } |
| | | 1944 | | |
| | | 1945 | | /// <summary> |
| | | 1946 | | /// Validates option combinations for directory-based content roots. |
| | | 1947 | | /// </summary> |
| | | 1948 | | /// <param name="optionFlags">Content-root related option flags.</param> |
| | | 1949 | | /// <param name="error">Validation error message when invalid combinations are detected.</param> |
| | | 1950 | | /// <returns>True when the option combination is valid.</returns> |
| | | 1951 | | private static bool TryValidateDirectoryContentRootOptions(ServiceContentRootOptionFlags optionFlags, out string err |
| | | 1952 | | { |
| | 6 | 1953 | | if (optionFlags.HasArchiveChecksum) |
| | | 1954 | | { |
| | 0 | 1955 | | error = "--content-root-checksum is only supported when --content-root points to an archive file."; |
| | 0 | 1956 | | return false; |
| | | 1957 | | } |
| | | 1958 | | |
| | 6 | 1959 | | return TryValidateUrlOnlyContentRootOptions(optionFlags, out error); |
| | | 1960 | | } |
| | | 1961 | | |
| | | 1962 | | /// <summary> |
| | | 1963 | | /// Validates option combinations for local archive content roots. |
| | | 1964 | | /// </summary> |
| | | 1965 | | /// <param name="optionFlags">Content-root related option flags.</param> |
| | | 1966 | | /// <param name="error">Validation error message when invalid combinations are detected.</param> |
| | | 1967 | | /// <returns>True when the option combination is valid.</returns> |
| | | 1968 | | private static bool TryValidateLocalArchiveContentRootOptions(ServiceContentRootOptionFlags optionFlags, out string |
| | 6 | 1969 | | => TryValidateUrlOnlyContentRootOptions(optionFlags, out error); |
| | | 1970 | | |
| | | 1971 | | /// <summary> |
| | | 1972 | | /// Validates script path shape for HTTP archive content roots. |
| | | 1973 | | /// </summary> |
| | | 1974 | | /// <param name="requestedScriptPath">Requested script path.</param> |
| | | 1975 | | /// <param name="error">Validation error text.</param> |
| | | 1976 | | /// <returns>True when the script path is valid for URL archive usage.</returns> |
| | | 1977 | | private static bool TryValidateHttpContentRootScriptPath(string requestedScriptPath, out string error) |
| | | 1978 | | { |
| | 6 | 1979 | | if (!string.IsNullOrWhiteSpace(requestedScriptPath) && Path.IsPathRooted(requestedScriptPath)) |
| | | 1980 | | { |
| | 0 | 1981 | | error = "When --content-root is a URL archive, --script must be a relative path inside the archive."; |
| | 0 | 1982 | | return false; |
| | | 1983 | | } |
| | | 1984 | | |
| | 6 | 1985 | | error = string.Empty; |
| | 6 | 1986 | | return true; |
| | | 1987 | | } |
| | | 1988 | | |
| | | 1989 | | /// <summary> |
| | | 1990 | | /// Reads and validates Service.psd1 from a resolved service content-root folder. |
| | | 1991 | | /// </summary> |
| | | 1992 | | /// <param name="fullContentRoot">Absolute content-root directory path.</param> |
| | | 1993 | | /// <param name="descriptor">Resolved and validated descriptor metadata.</param> |
| | | 1994 | | /// <param name="error">Validation error details.</param> |
| | | 1995 | | /// <returns>True when descriptor exists and mandatory metadata is valid.</returns> |
| | | 1996 | | private static bool TryResolveServiceInstallDescriptor(string fullContentRoot, out ServiceInstallDescriptor descript |
| | | 1997 | | { |
| | 19 | 1998 | | descriptor = new ServiceInstallDescriptor(string.Empty, string.Empty, string.Empty, string.Empty, null, null, [] |
| | 19 | 1999 | | if (!TryReadNormalizedServiceDescriptorText(fullContentRoot, out var descriptorText, out error)) |
| | | 2000 | | { |
| | 0 | 2001 | | return false; |
| | | 2002 | | } |
| | | 2003 | | |
| | 19 | 2004 | | if (!TryResolveServiceDescriptorCoreFields(descriptorText, out var name, out var description, out var formatVers |
| | | 2005 | | { |
| | 1 | 2006 | | return false; |
| | | 2007 | | } |
| | | 2008 | | |
| | 18 | 2009 | | if (!TryResolveServiceDescriptorEntryPointAndVersion( |
| | 18 | 2010 | | descriptorText, |
| | 18 | 2011 | | formatVersion, |
| | 18 | 2012 | | out var normalizedFormatVersion, |
| | 18 | 2013 | | out var entryPoint, |
| | 18 | 2014 | | out var version, |
| | 18 | 2015 | | out error)) |
| | | 2016 | | { |
| | 1 | 2017 | | return false; |
| | | 2018 | | } |
| | | 2019 | | |
| | 17 | 2020 | | _ = TryGetServiceDescriptorStringValue(descriptorText, "ServiceLogPath", required: false, out var serviceLogPath |
| | | 2021 | | |
| | 17 | 2022 | | if (!TryGetServiceDescriptorStringArrayValue(descriptorText, "PreservePaths", out var preservePaths, out error)) |
| | | 2023 | | { |
| | 0 | 2024 | | return false; |
| | | 2025 | | } |
| | | 2026 | | |
| | 17 | 2027 | | descriptor = new ServiceInstallDescriptor( |
| | 17 | 2028 | | normalizedFormatVersion, |
| | 17 | 2029 | | name, |
| | 17 | 2030 | | entryPoint, |
| | 17 | 2031 | | description, |
| | 17 | 2032 | | version, |
| | 17 | 2033 | | string.IsNullOrWhiteSpace(serviceLogPath) ? null : serviceLogPath, |
| | 17 | 2034 | | preservePaths); |
| | 17 | 2035 | | return true; |
| | | 2036 | | } |
| | | 2037 | | |
| | | 2038 | | /// <summary> |
| | | 2039 | | /// Reads service descriptor text from disk and normalizes escaped newlines for regex parsing. |
| | | 2040 | | /// </summary> |
| | | 2041 | | /// <param name="fullContentRoot">Absolute content-root directory path.</param> |
| | | 2042 | | /// <param name="descriptorText">Normalized service descriptor text.</param> |
| | | 2043 | | /// <param name="error">Error details when file resolution or read fails.</param> |
| | | 2044 | | /// <returns>True when descriptor text is available and normalized.</returns> |
| | | 2045 | | private static bool TryReadNormalizedServiceDescriptorText(string fullContentRoot, out string descriptorText, out st |
| | | 2046 | | { |
| | 19 | 2047 | | descriptorText = string.Empty; |
| | 19 | 2048 | | var descriptorPath = Path.Combine(fullContentRoot, ServiceDescriptorFileName); |
| | 19 | 2049 | | if (!File.Exists(descriptorPath)) |
| | | 2050 | | { |
| | 0 | 2051 | | error = $"Service descriptor file '{ServiceDescriptorFileName}' was not found at content-root '{fullContentR |
| | 0 | 2052 | | return false; |
| | | 2053 | | } |
| | | 2054 | | |
| | | 2055 | | try |
| | | 2056 | | { |
| | 19 | 2057 | | descriptorText = File.ReadAllText(descriptorPath, Encoding.UTF8); |
| | 19 | 2058 | | } |
| | 0 | 2059 | | catch (Exception ex) |
| | | 2060 | | { |
| | 0 | 2061 | | error = $"Failed to read service descriptor '{descriptorPath}': {ex.Message}"; |
| | 0 | 2062 | | return false; |
| | | 2063 | | } |
| | | 2064 | | |
| | 19 | 2065 | | descriptorText = NormalizeServiceDescriptorText(descriptorText); |
| | 19 | 2066 | | error = string.Empty; |
| | 19 | 2067 | | return true; |
| | 0 | 2068 | | } |
| | | 2069 | | |
| | | 2070 | | /// <summary> |
| | | 2071 | | /// Resolves required descriptor core fields and optional format version. |
| | | 2072 | | /// </summary> |
| | | 2073 | | /// <param name="descriptorText">Normalized service descriptor text.</param> |
| | | 2074 | | /// <param name="name">Resolved service name.</param> |
| | | 2075 | | /// <param name="description">Resolved service description.</param> |
| | | 2076 | | /// <param name="formatVersion">Optional format version token.</param> |
| | | 2077 | | /// <param name="error">Validation error details.</param> |
| | | 2078 | | /// <returns>True when required core fields are valid.</returns> |
| | | 2079 | | private static bool TryResolveServiceDescriptorCoreFields( |
| | | 2080 | | string descriptorText, |
| | | 2081 | | out string name, |
| | | 2082 | | out string description, |
| | | 2083 | | out string formatVersion, |
| | | 2084 | | out string error) |
| | | 2085 | | { |
| | 19 | 2086 | | if (!TryGetServiceDescriptorStringValue(descriptorText, "Name", required: true, out name, out error)) |
| | | 2087 | | { |
| | 0 | 2088 | | description = string.Empty; |
| | 0 | 2089 | | formatVersion = string.Empty; |
| | 0 | 2090 | | return false; |
| | | 2091 | | } |
| | | 2092 | | |
| | 19 | 2093 | | if (!TryGetServiceDescriptorStringValue(descriptorText, "Description", required: true, out description, out erro |
| | | 2094 | | { |
| | 1 | 2095 | | formatVersion = string.Empty; |
| | 1 | 2096 | | return false; |
| | | 2097 | | } |
| | | 2098 | | |
| | 18 | 2099 | | _ = TryGetServiceDescriptorStringValue(descriptorText, "FormatVersion", required: false, out formatVersion, out |
| | 18 | 2100 | | error = string.Empty; |
| | 18 | 2101 | | return true; |
| | | 2102 | | } |
| | | 2103 | | |
| | | 2104 | | /// <summary> |
| | | 2105 | | /// Resolves descriptor entrypoint/version fields for legacy and format-1.0 descriptors. |
| | | 2106 | | /// </summary> |
| | | 2107 | | /// <param name="descriptorText">Normalized service descriptor text.</param> |
| | | 2108 | | /// <param name="formatVersion">Optional format version token.</param> |
| | | 2109 | | /// <param name="normalizedFormatVersion">Normalized format marker used by runtime metadata.</param> |
| | | 2110 | | /// <param name="entryPoint">Resolved script entrypoint path.</param> |
| | | 2111 | | /// <param name="version">Optional parsed version string.</param> |
| | | 2112 | | /// <param name="error">Validation error details.</param> |
| | | 2113 | | /// <returns>True when entrypoint/version resolution succeeds.</returns> |
| | | 2114 | | private static bool TryResolveServiceDescriptorEntryPointAndVersion( |
| | | 2115 | | string descriptorText, |
| | | 2116 | | string formatVersion, |
| | | 2117 | | out string normalizedFormatVersion, |
| | | 2118 | | out string entryPoint, |
| | | 2119 | | out string? version, |
| | | 2120 | | out string error) |
| | | 2121 | | { |
| | 18 | 2122 | | version = null; |
| | | 2123 | | |
| | 18 | 2124 | | if (string.IsNullOrWhiteSpace(formatVersion)) |
| | | 2125 | | { |
| | 11 | 2126 | | normalizedFormatVersion = "legacy"; |
| | 11 | 2127 | | if (!TryGetServiceDescriptorStringValue(descriptorText, "Script", required: false, out entryPoint, out error |
| | | 2128 | | { |
| | 0 | 2129 | | return false; |
| | | 2130 | | } |
| | | 2131 | | |
| | 11 | 2132 | | if (string.IsNullOrWhiteSpace(entryPoint)) |
| | | 2133 | | { |
| | 3 | 2134 | | entryPoint = ServiceDefaultScriptFileName; |
| | | 2135 | | } |
| | | 2136 | | |
| | 11 | 2137 | | return TryResolveOptionalServiceDescriptorVersion(descriptorText, out version, out error); |
| | | 2138 | | } |
| | | 2139 | | |
| | 7 | 2140 | | var trimmedFormatVersion = formatVersion.Trim(); |
| | 7 | 2141 | | normalizedFormatVersion = trimmedFormatVersion; |
| | 7 | 2142 | | if (!string.Equals(trimmedFormatVersion, "1.0", StringComparison.Ordinal)) |
| | | 2143 | | { |
| | 0 | 2144 | | entryPoint = string.Empty; |
| | 0 | 2145 | | error = "Service descriptor FormatVersion must be '1.0'."; |
| | 0 | 2146 | | return false; |
| | | 2147 | | } |
| | | 2148 | | |
| | 7 | 2149 | | return TryGetServiceDescriptorStringValue(descriptorText, "EntryPoint", required: true, out entryPoint, out erro |
| | 7 | 2150 | | && TryResolveOptionalServiceDescriptorVersion(descriptorText, out version, out error); |
| | | 2151 | | } |
| | | 2152 | | |
| | | 2153 | | /// <summary> |
| | | 2154 | | /// Resolves and validates optional descriptor version metadata. |
| | | 2155 | | /// </summary> |
| | | 2156 | | /// <param name="descriptorText">Normalized service descriptor text.</param> |
| | | 2157 | | /// <param name="version">Resolved version string when present.</param> |
| | | 2158 | | /// <param name="error">Validation error details.</param> |
| | | 2159 | | /// <returns>True when version is absent or parseable by <see cref="Version"/>.</returns> |
| | | 2160 | | private static bool TryResolveOptionalServiceDescriptorVersion(string descriptorText, out string? version, out strin |
| | | 2161 | | { |
| | 18 | 2162 | | version = null; |
| | 18 | 2163 | | _ = TryGetServiceDescriptorStringValue(descriptorText, "Version", required: false, out var rawVersion, out _); |
| | 18 | 2164 | | if (string.IsNullOrWhiteSpace(rawVersion)) |
| | | 2165 | | { |
| | 0 | 2166 | | error = string.Empty; |
| | 0 | 2167 | | return true; |
| | | 2168 | | } |
| | | 2169 | | |
| | 18 | 2170 | | var trimmedVersion = rawVersion.Trim(); |
| | 18 | 2171 | | if (!Version.TryParse(trimmedVersion, out _)) |
| | | 2172 | | { |
| | 1 | 2173 | | error = $"Service descriptor '{ServiceDescriptorFileName}' key 'Version' must be compatible with System.Vers |
| | 1 | 2174 | | return false; |
| | | 2175 | | } |
| | | 2176 | | |
| | 17 | 2177 | | version = trimmedVersion; |
| | 17 | 2178 | | error = string.Empty; |
| | 17 | 2179 | | return true; |
| | | 2180 | | } |
| | | 2181 | | |
| | | 2182 | | /// <summary> |
| | | 2183 | | /// Normalizes service descriptor text for regex parsing. |
| | | 2184 | | /// </summary> |
| | | 2185 | | /// <param name="descriptorText">Raw descriptor text.</param> |
| | | 2186 | | /// <returns>Descriptor text with PowerShell escaped newline sequences expanded.</returns> |
| | | 2187 | | private static string NormalizeServiceDescriptorText(string descriptorText) |
| | 19 | 2188 | | => descriptorText |
| | 19 | 2189 | | .Replace("`r`n", "\n", StringComparison.Ordinal) |
| | 19 | 2190 | | .Replace("`n", "\n", StringComparison.Ordinal) |
| | 19 | 2191 | | .Replace("`r", "\n", StringComparison.Ordinal); |
| | | 2192 | | |
| | | 2193 | | /// <summary> |
| | | 2194 | | /// Reads a string-valued key from Service.psd1. |
| | | 2195 | | /// </summary> |
| | | 2196 | | /// <param name="descriptorText">Raw descriptor content.</param> |
| | | 2197 | | /// <param name="key">Descriptor key name.</param> |
| | | 2198 | | /// <param name="required">True when a missing key should fail validation.</param> |
| | | 2199 | | /// <param name="value">Resolved string value.</param> |
| | | 2200 | | /// <param name="error">Validation error details when required values are missing.</param> |
| | | 2201 | | /// <returns>True when key resolution succeeded for the required/optional mode.</returns> |
| | | 2202 | | private static bool TryGetServiceDescriptorStringValue(string descriptorText, string key, bool required, out string |
| | | 2203 | | { |
| | 109 | 2204 | | var match = Regex.Match( |
| | 109 | 2205 | | descriptorText, |
| | 109 | 2206 | | $@"(?mi)(?:^|[;{{\r\n])\s*{Regex.Escape(key)}\s*=\s*(?:'(?<single>[^']*)'|""(?<double>[^""]*)"")", |
| | 109 | 2207 | | RegexOptions.CultureInvariant); |
| | | 2208 | | |
| | 109 | 2209 | | if (!match.Success) |
| | | 2210 | | { |
| | 26 | 2211 | | value = string.Empty; |
| | 26 | 2212 | | error = required |
| | 26 | 2213 | | ? $"Service descriptor '{ServiceDescriptorFileName}' is missing required key '{key}'." |
| | 26 | 2214 | | : string.Empty; |
| | 26 | 2215 | | return !required; |
| | | 2216 | | } |
| | | 2217 | | |
| | 83 | 2218 | | value = (match.Groups["single"].Success ? match.Groups["single"].Value : match.Groups["double"].Value).Trim(); |
| | 83 | 2219 | | if (required && string.IsNullOrWhiteSpace(value)) |
| | | 2220 | | { |
| | 0 | 2221 | | error = $"Service descriptor '{ServiceDescriptorFileName}' key '{key}' must not be empty."; |
| | 0 | 2222 | | return false; |
| | | 2223 | | } |
| | | 2224 | | |
| | 83 | 2225 | | error = string.Empty; |
| | 83 | 2226 | | return true; |
| | | 2227 | | } |
| | | 2228 | | |
| | | 2229 | | /// <summary> |
| | | 2230 | | /// Reads a string-array key from Service.psd1 using PowerShell array syntax: Key = @( 'a', 'b' ). |
| | | 2231 | | /// </summary> |
| | | 2232 | | /// <param name="descriptorText">Raw descriptor content.</param> |
| | | 2233 | | /// <param name="key">Descriptor key name.</param> |
| | | 2234 | | /// <param name="values">Resolved array values.</param> |
| | | 2235 | | /// <param name="error">Validation error details.</param> |
| | | 2236 | | /// <returns>True when array resolution succeeds.</returns> |
| | | 2237 | | private static bool TryGetServiceDescriptorStringArrayValue(string descriptorText, string key, out string[] values, |
| | | 2238 | | { |
| | 17 | 2239 | | values = []; |
| | 17 | 2240 | | error = string.Empty; |
| | | 2241 | | |
| | 17 | 2242 | | var arrayMatch = Regex.Match( |
| | 17 | 2243 | | descriptorText, |
| | 17 | 2244 | | $@"(?mis)(?:^|[;{{\r\n])\s*{Regex.Escape(key)}\s*=\s*@\((?<items>.*?)\)", |
| | 17 | 2245 | | RegexOptions.CultureInvariant); |
| | | 2246 | | |
| | 17 | 2247 | | if (!arrayMatch.Success) |
| | | 2248 | | { |
| | 16 | 2249 | | return true; |
| | | 2250 | | } |
| | | 2251 | | |
| | 1 | 2252 | | var itemsText = arrayMatch.Groups["items"].Value; |
| | 1 | 2253 | | var itemMatches = Regex.Matches( |
| | 1 | 2254 | | itemsText, |
| | 1 | 2255 | | "'(?<single>(?:''|[^'])*)'|\"(?<double>(?:\"\"|[^\"])*)\"", |
| | 1 | 2256 | | RegexOptions.CultureInvariant); |
| | | 2257 | | |
| | 1 | 2258 | | if (itemMatches.Count == 0 && !string.IsNullOrWhiteSpace(itemsText)) |
| | | 2259 | | { |
| | 0 | 2260 | | error = $"Service descriptor '{ServiceDescriptorFileName}' key '{key}' must be a string array, for example: |
| | 0 | 2261 | | return false; |
| | | 2262 | | } |
| | | 2263 | | |
| | 1 | 2264 | | values = [.. itemMatches |
| | 1 | 2265 | | .Select(static match => |
| | 1 | 2266 | | { |
| | 3 | 2267 | | var raw = match.Groups["single"].Success |
| | 3 | 2268 | | ? match.Groups["single"].Value.Replace("''", "'", StringComparison.Ordinal) |
| | 3 | 2269 | | : match.Groups["double"].Value.Replace("\"\"", "\"", StringComparison.Ordinal); |
| | 3 | 2270 | | return raw.Trim(); |
| | 1 | 2271 | | }) |
| | 4 | 2272 | | .Where(static path => !string.IsNullOrWhiteSpace(path))]; |
| | 1 | 2273 | | return true; |
| | | 2274 | | } |
| | | 2275 | | |
| | | 2276 | | /// <summary> |
| | | 2277 | | /// Resolves the script path for descriptor-driven service installs. |
| | | 2278 | | /// </summary> |
| | | 2279 | | /// <param name="requestedScriptPath">Script path requested on the command line.</param> |
| | | 2280 | | /// <param name="descriptor">Resolved service descriptor.</param> |
| | | 2281 | | /// <param name="resolvedScriptPath">Final script path relative to content root.</param> |
| | | 2282 | | /// <param name="error">Validation error details.</param> |
| | | 2283 | | /// <returns>True when the resolved script path is valid.</returns> |
| | | 2284 | | private static bool TryResolveServiceDescriptorScriptPath(string requestedScriptPath, ServiceInstallDescriptor descr |
| | | 2285 | | { |
| | 10 | 2286 | | if (!string.IsNullOrWhiteSpace(requestedScriptPath)) |
| | | 2287 | | { |
| | 0 | 2288 | | resolvedScriptPath = string.Empty; |
| | 0 | 2289 | | error = "--script (or positional script path) is not supported when --package/--content-root is used. Define |
| | 0 | 2290 | | return false; |
| | | 2291 | | } |
| | | 2292 | | |
| | 10 | 2293 | | resolvedScriptPath = descriptor.EntryPoint; |
| | | 2294 | | |
| | 10 | 2295 | | if (Path.IsPathRooted(resolvedScriptPath)) |
| | | 2296 | | { |
| | 1 | 2297 | | error = $"Service descriptor '{ServiceDescriptorFileName}' EntryPoint/Script must be a relative path within |
| | 1 | 2298 | | return false; |
| | | 2299 | | } |
| | | 2300 | | |
| | 9 | 2301 | | error = string.Empty; |
| | 9 | 2302 | | return true; |
| | | 2303 | | } |
| | | 2304 | | |
| | | 2305 | | /// <summary> |
| | | 2306 | | /// Applies descriptor metadata to a resolved service script source. |
| | | 2307 | | /// </summary> |
| | | 2308 | | /// <param name="scriptSource">Resolved script source.</param> |
| | | 2309 | | /// <param name="descriptor">Descriptor metadata.</param> |
| | | 2310 | | /// <returns>Script source enriched with descriptor metadata.</returns> |
| | | 2311 | | private static ResolvedServiceScriptSource ApplyDescriptorMetadata(ResolvedServiceScriptSource scriptSource, Service |
| | 9 | 2312 | | => new( |
| | 9 | 2313 | | scriptSource.FullScriptPath, |
| | 9 | 2314 | | scriptSource.FullContentRoot, |
| | 9 | 2315 | | scriptSource.RelativeScriptPath, |
| | 9 | 2316 | | scriptSource.TemporaryContentRootPath, |
| | 9 | 2317 | | descriptor.Name, |
| | 9 | 2318 | | descriptor.Description, |
| | 9 | 2319 | | descriptor.Version, |
| | 9 | 2320 | | descriptor.ServiceLogPath, |
| | 9 | 2321 | | descriptor.PreservePaths); |
| | | 2322 | | |
| | | 2323 | | /// <summary> |
| | | 2324 | | /// Downloads and extracts an HTTP content-root archive into the supplied directory. |
| | | 2325 | | /// </summary> |
| | | 2326 | | /// <param name="command">Parsed service command.</param> |
| | | 2327 | | /// <param name="contentRootUri">HTTP(S) archive URI.</param> |
| | | 2328 | | /// <param name="temporaryRoot">Temporary root folder used for download output.</param> |
| | | 2329 | | /// <param name="downloadedContentRoot">Folder where the archive should be extracted.</param> |
| | | 2330 | | /// <param name="error">Error details when any stage fails.</param> |
| | | 2331 | | /// <returns>True when download, checksum, and extraction all succeed.</returns> |
| | | 2332 | | private static bool TryDownloadAndExtractHttpContentRoot( |
| | | 2333 | | ParsedCommand command, |
| | | 2334 | | Uri contentRootUri, |
| | | 2335 | | string temporaryRoot, |
| | | 2336 | | string downloadedContentRoot, |
| | | 2337 | | out string error) |
| | | 2338 | | { |
| | 6 | 2339 | | if (!TryDownloadServiceContentRootArchive( |
| | 6 | 2340 | | contentRootUri, |
| | 6 | 2341 | | temporaryRoot, |
| | 6 | 2342 | | command.ServiceContentRootBearerToken, |
| | 6 | 2343 | | command.ServiceContentRootHeaders, |
| | 6 | 2344 | | command.ServiceContentRootIgnoreCertificate, |
| | 6 | 2345 | | out var downloadedArchivePath, |
| | 6 | 2346 | | out error)) |
| | | 2347 | | { |
| | 3 | 2348 | | return false; |
| | | 2349 | | } |
| | | 2350 | | |
| | | 2351 | | try |
| | | 2352 | | { |
| | 3 | 2353 | | return |
| | 3 | 2354 | | TryValidateServiceContentRootArchiveChecksum(command, downloadedArchivePath, out error) && |
| | 3 | 2355 | | TryExtractServiceContentRootArchive(downloadedArchivePath, downloadedContentRoot, out error); |
| | | 2356 | | } |
| | | 2357 | | finally |
| | | 2358 | | { |
| | | 2359 | | // Best-effort cleanup to avoid retaining large downloaded archives after extraction attempts. |
| | 3 | 2360 | | TryDeleteFileQuietly(downloadedArchivePath); |
| | 3 | 2361 | | } |
| | 3 | 2362 | | } |
| | | 2363 | | |
| | | 2364 | | /// <summary> |
| | | 2365 | | /// Resolves a relative script path from an already materialized content-root directory. |
| | | 2366 | | /// </summary> |
| | | 2367 | | /// <param name="requestedScriptPath">Requested relative script path.</param> |
| | | 2368 | | /// <param name="fullContentRoot">Absolute content-root path.</param> |
| | | 2369 | | /// <param name="escapedPathError">Error message used when the script path escapes the content root.</param> |
| | | 2370 | | /// <param name="missingScriptError">Error message used when the script file does not exist.</param> |
| | | 2371 | | /// <param name="temporaryContentRootPath">Optional temporary content-root path for cleanup ownership.</param> |
| | | 2372 | | /// <param name="scriptSource">Resolved script source details.</param> |
| | | 2373 | | /// <param name="error">Error details when validation fails.</param> |
| | | 2374 | | /// <returns>True when the script path resolves and exists inside the root.</returns> |
| | | 2375 | | private static bool TryResolveScriptFromResolvedContentRoot( |
| | | 2376 | | string requestedScriptPath, |
| | | 2377 | | string fullContentRoot, |
| | | 2378 | | string escapedPathError, |
| | | 2379 | | string missingScriptError, |
| | | 2380 | | string? temporaryContentRootPath, |
| | | 2381 | | out ResolvedServiceScriptSource scriptSource, |
| | | 2382 | | out string error) |
| | | 2383 | | { |
| | 9 | 2384 | | scriptSource = CreateEmptyResolvedServiceScriptSource(); |
| | 9 | 2385 | | var fullScriptPathFromRoot = Path.GetFullPath(Path.Combine(fullContentRoot, requestedScriptPath)); |
| | 9 | 2386 | | if (!IsPathWithinDirectory(fullScriptPathFromRoot, fullContentRoot)) |
| | | 2387 | | { |
| | 0 | 2388 | | error = escapedPathError; |
| | 0 | 2389 | | return false; |
| | | 2390 | | } |
| | | 2391 | | |
| | 9 | 2392 | | if (!File.Exists(fullScriptPathFromRoot)) |
| | | 2393 | | { |
| | 0 | 2394 | | error = missingScriptError; |
| | 0 | 2395 | | return false; |
| | | 2396 | | } |
| | | 2397 | | |
| | 9 | 2398 | | var relativeScriptPath = Path.GetRelativePath(fullContentRoot, fullScriptPathFromRoot); |
| | 9 | 2399 | | scriptSource = new ResolvedServiceScriptSource(fullScriptPathFromRoot, fullContentRoot, relativeScriptPath, temp |
| | 9 | 2400 | | error = string.Empty; |
| | 9 | 2401 | | return true; |
| | | 2402 | | } |
| | | 2403 | | |
| | | 2404 | | /// <summary> |
| | | 2405 | | /// Creates an empty service-script-source placeholder. |
| | | 2406 | | /// </summary> |
| | | 2407 | | /// <returns>Empty resolved service script source value.</returns> |
| | | 2408 | | private static ResolvedServiceScriptSource CreateEmptyResolvedServiceScriptSource() |
| | 36 | 2409 | | => new(string.Empty, null, string.Empty, null, null, null, null, null, []); |
| | | 2410 | | |
| | | 2411 | | /// <summary> |
| | | 2412 | | /// Creates a temporary extraction directory for archive-based service content roots. |
| | | 2413 | | /// </summary> |
| | | 2414 | | /// <param name="serviceName">Optional service name for easier diagnostics.</param> |
| | | 2415 | | /// <returns>Newly created extraction directory path.</returns> |
| | | 2416 | | private static string CreateServiceContentRootExtractionDirectory(string? serviceName) |
| | | 2417 | | { |
| | 9 | 2418 | | var safeServiceName = string.IsNullOrWhiteSpace(serviceName) |
| | 9 | 2419 | | ? "service" |
| | 9 | 2420 | | : string.Concat(serviceName.Where(ch => char.IsLetterOrDigit(ch) || ch is '-' or '_')); |
| | | 2421 | | |
| | 9 | 2422 | | if (string.IsNullOrWhiteSpace(safeServiceName)) |
| | | 2423 | | { |
| | 0 | 2424 | | safeServiceName = "service"; |
| | | 2425 | | } |
| | | 2426 | | |
| | 9 | 2427 | | var extractionRoot = Path.Combine(Path.GetTempPath(), "kestrun-content-root", safeServiceName, Guid.NewGuid().To |
| | 9 | 2428 | | _ = Directory.CreateDirectory(extractionRoot); |
| | 9 | 2429 | | return extractionRoot; |
| | | 2430 | | } |
| | | 2431 | | |
| | | 2432 | | /// <summary> |
| | | 2433 | | /// Parses an HTTP(S) URI for service content-root input. |
| | | 2434 | | /// </summary> |
| | | 2435 | | /// <param name="contentRootInput">Raw content-root input token.</param> |
| | | 2436 | | /// <param name="uri">Parsed HTTP(S) URI.</param> |
| | | 2437 | | /// <returns>True when input is an absolute HTTP(S) URI.</returns> |
| | | 2438 | | private static bool TryParseServiceContentRootHttpUri(string contentRootInput, out Uri uri) |
| | | 2439 | | { |
| | 20 | 2440 | | if (Uri.TryCreate(contentRootInput, UriKind.Absolute, out var parsed) |
| | 20 | 2441 | | && parsed is not null |
| | 20 | 2442 | | && (parsed.Scheme.Equals(Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase) |
| | 20 | 2443 | | || parsed.Scheme.Equals(Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase))) |
| | | 2444 | | { |
| | 7 | 2445 | | uri = parsed; |
| | 7 | 2446 | | return true; |
| | | 2447 | | } |
| | | 2448 | | |
| | 13 | 2449 | | uri = null!; |
| | 13 | 2450 | | return false; |
| | | 2451 | | } |
| | | 2452 | | |
| | | 2453 | | /// <summary> |
| | | 2454 | | /// Downloads a service content-root archive from HTTP(S) into a temporary directory. |
| | | 2455 | | /// </summary> |
| | | 2456 | | /// <param name="uri">HTTP(S) source URI.</param> |
| | | 2457 | | /// <param name="temporaryRoot">Temporary root directory for download output.</param> |
| | | 2458 | | /// <param name="archivePath">Downloaded archive path.</param> |
| | | 2459 | | /// <param name="error">Error details when download fails.</param> |
| | | 2460 | | /// <returns>True when archive is downloaded and has a supported extension.</returns> |
| | | 2461 | | private static bool TryDownloadServiceContentRootArchive( |
| | | 2462 | | Uri uri, |
| | | 2463 | | string temporaryRoot, |
| | | 2464 | | string? bearerToken, |
| | | 2465 | | string[] customHeaders, |
| | | 2466 | | bool ignoreCertificate, |
| | | 2467 | | out string archivePath, |
| | | 2468 | | out string error) |
| | | 2469 | | { |
| | 6 | 2470 | | archivePath = string.Empty; |
| | 6 | 2471 | | error = string.Empty; |
| | | 2472 | | |
| | 6 | 2473 | | if (ignoreCertificate && !uri.Scheme.Equals(Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase)) |
| | | 2474 | | { |
| | 0 | 2475 | | error = "--content-root-ignore-certificate is only valid for HTTPS URLs."; |
| | 0 | 2476 | | return false; |
| | | 2477 | | } |
| | | 2478 | | |
| | | 2479 | | try |
| | | 2480 | | { |
| | 6 | 2481 | | using var request = new HttpRequestMessage(HttpMethod.Get, uri); |
| | 6 | 2482 | | if (!string.IsNullOrWhiteSpace(bearerToken)) |
| | | 2483 | | { |
| | 1 | 2484 | | request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", bearerToken); |
| | | 2485 | | } |
| | | 2486 | | |
| | 6 | 2487 | | if (!TryApplyServiceContentRootCustomHeaders(request, customHeaders, out error)) |
| | | 2488 | | { |
| | 3 | 2489 | | return false; |
| | | 2490 | | } |
| | | 2491 | | |
| | 3 | 2492 | | if (!ignoreCertificate) |
| | | 2493 | | { |
| | 3 | 2494 | | using var response = ServiceContentRootHttpClient.Send(request, HttpCompletionOption.ResponseHeadersRead |
| | 3 | 2495 | | if (!response.IsSuccessStatusCode) |
| | | 2496 | | { |
| | 0 | 2497 | | error = $"Failed to download service content root from '{uri}'. HTTP {(int)response.StatusCode} {res |
| | 0 | 2498 | | return false; |
| | | 2499 | | } |
| | | 2500 | | |
| | 3 | 2501 | | return TryWriteDownloadedContentRootArchive(temporaryRoot, uri, response, out archivePath, out error); |
| | | 2502 | | } |
| | | 2503 | | |
| | 0 | 2504 | | using var insecureHandler = new HttpClientHandler |
| | 0 | 2505 | | { |
| | 0 | 2506 | | ServerCertificateCustomValidationCallback = HttpClientHandler.DangerousAcceptAnyServerCertificateValidat |
| | 0 | 2507 | | }; |
| | 0 | 2508 | | using var insecureClient = new HttpClient(insecureHandler) |
| | 0 | 2509 | | { |
| | 0 | 2510 | | Timeout = TimeSpan.FromMinutes(5), |
| | 0 | 2511 | | }; |
| | 0 | 2512 | | insecureClient.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue(ProductName, "1.0")); |
| | 0 | 2513 | | using var insecureResponse = insecureClient.Send(request, HttpCompletionOption.ResponseHeadersRead); |
| | 0 | 2514 | | if (!insecureResponse.IsSuccessStatusCode) |
| | | 2515 | | { |
| | 0 | 2516 | | error = $"Failed to download service content root from '{uri}'. HTTP {(int)insecureResponse.StatusCode} |
| | 0 | 2517 | | return false; |
| | | 2518 | | } |
| | | 2519 | | |
| | 0 | 2520 | | return TryWriteDownloadedContentRootArchive(temporaryRoot, uri, insecureResponse, out archivePath, out error |
| | | 2521 | | } |
| | 0 | 2522 | | catch (Exception ex) |
| | | 2523 | | { |
| | 0 | 2524 | | error = $"Failed to download service content root from '{uri}': {ex.Message}"; |
| | 0 | 2525 | | return false; |
| | | 2526 | | } |
| | 6 | 2527 | | } |
| | | 2528 | | |
| | | 2529 | | /// <summary> |
| | | 2530 | | /// Applies custom request headers used for service content-root URL downloads. |
| | | 2531 | | /// </summary> |
| | | 2532 | | /// <param name="request">HTTP request message to update.</param> |
| | | 2533 | | /// <param name="customHeaders">Custom header tokens in <c>name:value</c> format.</param> |
| | | 2534 | | /// <param name="error">Validation error when a header token cannot be applied.</param> |
| | | 2535 | | /// <returns>True when all headers are valid and applied to the request.</returns> |
| | | 2536 | | private static bool TryApplyServiceContentRootCustomHeaders(HttpRequestMessage request, IReadOnlyList<string> custom |
| | | 2537 | | { |
| | 6 | 2538 | | error = string.Empty; |
| | 17 | 2539 | | foreach (var headerToken in customHeaders) |
| | | 2540 | | { |
| | 4 | 2541 | | if (string.IsNullOrWhiteSpace(headerToken)) |
| | | 2542 | | { |
| | 0 | 2543 | | error = "--content-root-header value cannot be empty. Use <name:value>."; |
| | 0 | 2544 | | return false; |
| | | 2545 | | } |
| | | 2546 | | |
| | 4 | 2547 | | var separatorIndex = headerToken.IndexOf(':'); |
| | 4 | 2548 | | if (separatorIndex <= 0) |
| | | 2549 | | { |
| | 1 | 2550 | | error = $"Invalid --content-root-header value '{headerToken}'. Use <name:value>."; |
| | 1 | 2551 | | return false; |
| | | 2552 | | } |
| | | 2553 | | |
| | 3 | 2554 | | var headerName = headerToken[..separatorIndex].Trim(); |
| | 3 | 2555 | | var headerValue = headerToken[(separatorIndex + 1)..].Trim(); |
| | 3 | 2556 | | if (string.IsNullOrWhiteSpace(headerName)) |
| | | 2557 | | { |
| | 0 | 2558 | | error = $"Invalid --content-root-header value '{headerToken}'. Header name cannot be empty."; |
| | 0 | 2559 | | return false; |
| | | 2560 | | } |
| | | 2561 | | |
| | 3 | 2562 | | if (headerName.Contains('\r') || headerName.Contains('\n')) |
| | | 2563 | | { |
| | 1 | 2564 | | error = $"Invalid --content-root-header value '{headerToken}'. Header name cannot contain CR or LF chara |
| | 1 | 2565 | | return false; |
| | | 2566 | | } |
| | | 2567 | | |
| | 2 | 2568 | | if (headerValue.Contains('\r') || headerValue.Contains('\n')) |
| | | 2569 | | { |
| | 1 | 2570 | | error = $"Invalid --content-root-header value '{headerToken}'. Header value cannot contain CR or LF char |
| | 1 | 2571 | | return false; |
| | | 2572 | | } |
| | | 2573 | | |
| | 1 | 2574 | | if (!request.Headers.TryAddWithoutValidation(headerName, headerValue)) |
| | | 2575 | | { |
| | 0 | 2576 | | error = $"Invalid --content-root-header value '{headerToken}'."; |
| | 0 | 2577 | | return false; |
| | | 2578 | | } |
| | | 2579 | | } |
| | | 2580 | | |
| | 3 | 2581 | | return true; |
| | 3 | 2582 | | } |
| | | 2583 | | |
| | | 2584 | | /// <summary> |
| | | 2585 | | /// Writes a downloaded service content-root archive response to disk. |
| | | 2586 | | /// </summary> |
| | | 2587 | | /// <param name="temporaryRoot">Temporary root directory for archive output.</param> |
| | | 2588 | | /// <param name="uri">Source URI.</param> |
| | | 2589 | | /// <param name="response">HTTP response with archive payload.</param> |
| | | 2590 | | /// <param name="archivePath">Written archive path.</param> |
| | | 2591 | | /// <param name="error">Error details when write or validation fails.</param> |
| | | 2592 | | /// <returns>True when the archive is written and has a supported extension.</returns> |
| | | 2593 | | private static bool TryWriteDownloadedContentRootArchive( |
| | | 2594 | | string temporaryRoot, |
| | | 2595 | | Uri uri, |
| | | 2596 | | HttpResponseMessage response, |
| | | 2597 | | out string archivePath, |
| | | 2598 | | out string error) |
| | | 2599 | | { |
| | 3 | 2600 | | archivePath = string.Empty; |
| | 3 | 2601 | | error = string.Empty; |
| | | 2602 | | |
| | 3 | 2603 | | var resolvedFileName = TryResolveServiceContentRootArchiveFileName(uri, response) |
| | 3 | 2604 | | ?? "content-root"; |
| | 3 | 2605 | | resolvedFileName = GetSafeServiceContentRootArchiveFileName(resolvedFileName, "content-root"); |
| | | 2606 | | |
| | 3 | 2607 | | var provisionalArchivePath = Path.Combine(temporaryRoot, resolvedFileName); |
| | 3 | 2608 | | using (var sourceStream = response.Content.ReadAsStream()) |
| | 3 | 2609 | | using (var destinationStream = File.Create(provisionalArchivePath)) |
| | | 2610 | | { |
| | 3 | 2611 | | sourceStream.CopyTo(destinationStream); |
| | 3 | 2612 | | } |
| | | 2613 | | |
| | 3 | 2614 | | if (!TryResolveDownloadedServiceContentRootArchiveFileName( |
| | 3 | 2615 | | uri, |
| | 3 | 2616 | | resolvedFileName, |
| | 3 | 2617 | | provisionalArchivePath, |
| | 3 | 2618 | | response, |
| | 3 | 2619 | | out var finalizedFileName, |
| | 3 | 2620 | | out error)) |
| | | 2621 | | { |
| | | 2622 | | try |
| | | 2623 | | { |
| | 0 | 2624 | | if (File.Exists(provisionalArchivePath)) |
| | | 2625 | | { |
| | 0 | 2626 | | File.Delete(provisionalArchivePath); |
| | | 2627 | | } |
| | 0 | 2628 | | } |
| | 0 | 2629 | | catch |
| | | 2630 | | { |
| | | 2631 | | // Ignore cleanup errors because the original archive-validation error is more actionable. |
| | 0 | 2632 | | } |
| | 0 | 2633 | | return false; |
| | | 2634 | | } |
| | | 2635 | | |
| | 3 | 2636 | | archivePath = provisionalArchivePath; |
| | 3 | 2637 | | if (!string.Equals(finalizedFileName, resolvedFileName, StringComparison.OrdinalIgnoreCase)) |
| | | 2638 | | { |
| | 1 | 2639 | | var finalizedArchivePath = Path.Combine(temporaryRoot, finalizedFileName); |
| | 1 | 2640 | | File.Move(provisionalArchivePath, finalizedArchivePath, overwrite: true); |
| | 1 | 2641 | | archivePath = finalizedArchivePath; |
| | | 2642 | | } |
| | | 2643 | | |
| | 3 | 2644 | | return true; |
| | | 2645 | | } |
| | | 2646 | | |
| | | 2647 | | /// <summary> |
| | | 2648 | | /// Converts an archive file name candidate to a filesystem-safe file name. |
| | | 2649 | | /// </summary> |
| | | 2650 | | /// <param name="candidate">Raw file name candidate from headers or URI metadata.</param> |
| | | 2651 | | /// <param name="fallbackFileName">Fallback file name when the candidate is empty or invalid.</param> |
| | | 2652 | | /// <returns>Filesystem-safe file name.</returns> |
| | | 2653 | | private static string GetSafeServiceContentRootArchiveFileName(string? candidate, string fallbackFileName) |
| | | 2654 | | { |
| | 3 | 2655 | | var fileName = Path.GetFileName(candidate ?? string.Empty); |
| | 3 | 2656 | | if (string.IsNullOrWhiteSpace(fileName)) |
| | | 2657 | | { |
| | 0 | 2658 | | return fallbackFileName; |
| | | 2659 | | } |
| | | 2660 | | |
| | 3 | 2661 | | var invalidChars = Path.GetInvalidFileNameChars(); |
| | 3 | 2662 | | var builder = new StringBuilder(fileName.Length); |
| | 50 | 2663 | | foreach (var ch in fileName) |
| | | 2664 | | { |
| | 22 | 2665 | | if (char.IsControl(ch) || ch == Path.DirectorySeparatorChar || ch == Path.AltDirectorySeparatorChar || inval |
| | | 2666 | | { |
| | 0 | 2667 | | _ = builder.Append('-'); |
| | 0 | 2668 | | continue; |
| | | 2669 | | } |
| | | 2670 | | |
| | 22 | 2671 | | _ = builder.Append(ch); |
| | | 2672 | | } |
| | | 2673 | | |
| | 3 | 2674 | | var sanitized = builder.ToString().Trim().Trim('.'); |
| | 3 | 2675 | | return string.IsNullOrWhiteSpace(sanitized) ? fallbackFileName : sanitized; |
| | | 2676 | | } |
| | | 2677 | | |
| | | 2678 | | /// <summary> |
| | | 2679 | | /// Resolves a supported archive file name for a downloaded content-root payload. |
| | | 2680 | | /// </summary> |
| | | 2681 | | /// <param name="uri">Source URI.</param> |
| | | 2682 | | /// <param name="resolvedFileName">Initially resolved file name candidate.</param> |
| | | 2683 | | /// <param name="downloadedArchivePath">Downloaded archive payload path.</param> |
| | | 2684 | | /// <param name="response">HTTP response metadata.</param> |
| | | 2685 | | /// <param name="finalizedFileName">Finalized archive file name with supported extension.</param> |
| | | 2686 | | /// <param name="error">Validation error details when archive type cannot be resolved.</param> |
| | | 2687 | | /// <returns>True when a supported archive file name is resolved.</returns> |
| | | 2688 | | private static bool TryResolveDownloadedServiceContentRootArchiveFileName( |
| | | 2689 | | Uri uri, |
| | | 2690 | | string resolvedFileName, |
| | | 2691 | | string downloadedArchivePath, |
| | | 2692 | | HttpResponseMessage response, |
| | | 2693 | | out string finalizedFileName, |
| | | 2694 | | out string error) |
| | | 2695 | | { |
| | 3 | 2696 | | finalizedFileName = resolvedFileName; |
| | 3 | 2697 | | error = string.Empty; |
| | | 2698 | | |
| | 3 | 2699 | | if (IsSupportedServiceContentRootArchive(finalizedFileName)) |
| | | 2700 | | { |
| | 2 | 2701 | | return true; |
| | | 2702 | | } |
| | | 2703 | | |
| | 1 | 2704 | | var mediaType = response.Content.Headers.ContentType?.MediaType; |
| | 1 | 2705 | | if (TryGetServiceContentRootArchiveExtensionFromMediaType(mediaType, out var archiveExtension) |
| | 1 | 2706 | | || TryDetectServiceContentRootArchiveExtensionFromSignature(downloadedArchivePath, out archiveExtension)) |
| | | 2707 | | { |
| | 1 | 2708 | | finalizedFileName = BuildServiceContentRootArchiveFileName(resolvedFileName, archiveExtension); |
| | 1 | 2709 | | return true; |
| | | 2710 | | } |
| | | 2711 | | |
| | 0 | 2712 | | error = $"Downloaded package from '{uri}' is not a supported archive. Supported extensions: {ServicePackageExten |
| | 0 | 2713 | | return false; |
| | | 2714 | | } |
| | | 2715 | | |
| | | 2716 | | /// <summary> |
| | | 2717 | | /// Maps content-type metadata to a preferred service content-root archive extension. |
| | | 2718 | | /// </summary> |
| | | 2719 | | /// <param name="mediaType">HTTP response media type.</param> |
| | | 2720 | | /// <param name="archiveExtension">Resolved archive extension when available.</param> |
| | | 2721 | | /// <returns>True when the media type maps to a supported archive extension.</returns> |
| | | 2722 | | private static bool TryGetServiceContentRootArchiveExtensionFromMediaType(string? mediaType, out string archiveExten |
| | | 2723 | | { |
| | 6 | 2724 | | switch (mediaType?.ToLowerInvariant()) |
| | | 2725 | | { |
| | | 2726 | | case "application/zip": |
| | | 2727 | | case "application/x-zip-compressed": |
| | 1 | 2728 | | archiveExtension = ".zip"; |
| | 1 | 2729 | | return true; |
| | | 2730 | | case "application/x-tar": |
| | 1 | 2731 | | archiveExtension = ".tar"; |
| | 1 | 2732 | | return true; |
| | | 2733 | | case "application/gzip": |
| | | 2734 | | case "application/x-gzip": |
| | 2 | 2735 | | archiveExtension = ".tgz"; |
| | 2 | 2736 | | return true; |
| | | 2737 | | default: |
| | 2 | 2738 | | archiveExtension = string.Empty; |
| | 2 | 2739 | | return false; |
| | | 2740 | | } |
| | | 2741 | | } |
| | | 2742 | | |
| | | 2743 | | /// <summary> |
| | | 2744 | | /// Detects archive extension from file signature when metadata does not provide a usable file name. |
| | | 2745 | | /// </summary> |
| | | 2746 | | /// <param name="archivePath">Downloaded archive payload path.</param> |
| | | 2747 | | /// <param name="archiveExtension">Detected archive extension.</param> |
| | | 2748 | | /// <returns>True when a supported archive signature is recognized.</returns> |
| | | 2749 | | private static bool TryDetectServiceContentRootArchiveExtensionFromSignature(string archivePath, out string archiveE |
| | | 2750 | | { |
| | 5 | 2751 | | archiveExtension = string.Empty; |
| | | 2752 | | try |
| | | 2753 | | { |
| | 5 | 2754 | | Span<byte> signature = stackalloc byte[512]; |
| | 5 | 2755 | | using var stream = File.OpenRead(archivePath); |
| | 5 | 2756 | | var bytesRead = stream.Read(signature); |
| | 5 | 2757 | | if (bytesRead <= 0) |
| | | 2758 | | { |
| | 0 | 2759 | | return false; |
| | | 2760 | | } |
| | | 2761 | | |
| | 5 | 2762 | | if (bytesRead >= 4 |
| | 5 | 2763 | | && signature[0] == 0x50 |
| | 5 | 2764 | | && signature[1] == 0x4B |
| | 5 | 2765 | | && ((signature[2] == 0x03 && signature[3] == 0x04) |
| | 5 | 2766 | | || (signature[2] == 0x05 && signature[3] == 0x06) |
| | 5 | 2767 | | || (signature[2] == 0x07 && signature[3] == 0x08))) |
| | | 2768 | | { |
| | 1 | 2769 | | archiveExtension = ".zip"; |
| | 1 | 2770 | | return true; |
| | | 2771 | | } |
| | | 2772 | | |
| | 4 | 2773 | | if (bytesRead >= 2 && signature[0] == 0x1F && signature[1] == 0x8B) |
| | | 2774 | | { |
| | 2 | 2775 | | archiveExtension = ".tgz"; |
| | 2 | 2776 | | return true; |
| | | 2777 | | } |
| | | 2778 | | |
| | 2 | 2779 | | if (bytesRead >= 262 |
| | 2 | 2780 | | && signature[257] == (byte)'u' |
| | 2 | 2781 | | && signature[258] == (byte)'s' |
| | 2 | 2782 | | && signature[259] == (byte)'t' |
| | 2 | 2783 | | && signature[260] == (byte)'a' |
| | 2 | 2784 | | && signature[261] == (byte)'r') |
| | | 2785 | | { |
| | 1 | 2786 | | archiveExtension = ".tar"; |
| | 1 | 2787 | | return true; |
| | | 2788 | | } |
| | | 2789 | | |
| | 1 | 2790 | | return false; |
| | | 2791 | | } |
| | 0 | 2792 | | catch |
| | | 2793 | | { |
| | 0 | 2794 | | return false; |
| | | 2795 | | } |
| | 5 | 2796 | | } |
| | | 2797 | | |
| | | 2798 | | /// <summary> |
| | | 2799 | | /// Builds a normalized archive file name using the detected archive extension. |
| | | 2800 | | /// </summary> |
| | | 2801 | | /// <param name="resolvedFileName">Initially resolved file name candidate.</param> |
| | | 2802 | | /// <param name="archiveExtension">Detected archive extension.</param> |
| | | 2803 | | /// <returns>Normalized file name with archive extension.</returns> |
| | | 2804 | | private static string BuildServiceContentRootArchiveFileName(string resolvedFileName, string archiveExtension) |
| | | 2805 | | { |
| | 4 | 2806 | | var baseName = Path.GetFileNameWithoutExtension(resolvedFileName); |
| | 4 | 2807 | | if (archiveExtension.Equals(".tar.gz", StringComparison.OrdinalIgnoreCase) |
| | 4 | 2808 | | && resolvedFileName.EndsWith(".tar", StringComparison.OrdinalIgnoreCase)) |
| | | 2809 | | { |
| | 1 | 2810 | | baseName = Path.GetFileNameWithoutExtension(baseName); |
| | | 2811 | | } |
| | | 2812 | | |
| | 4 | 2813 | | if (string.IsNullOrWhiteSpace(baseName)) |
| | | 2814 | | { |
| | 1 | 2815 | | baseName = "content-root"; |
| | | 2816 | | } |
| | | 2817 | | |
| | 4 | 2818 | | return $"{baseName}{archiveExtension}"; |
| | | 2819 | | } |
| | | 2820 | | |
| | | 2821 | | /// <summary> |
| | | 2822 | | /// Resolves a best-effort archive file name from response headers and URI metadata. |
| | | 2823 | | /// </summary> |
| | | 2824 | | /// <param name="uri">Source URI.</param> |
| | | 2825 | | /// <param name="response">HTTP response.</param> |
| | | 2826 | | /// <returns>Resolved file name when available; otherwise null.</returns> |
| | | 2827 | | private static string? TryResolveServiceContentRootArchiveFileName(Uri uri, HttpResponseMessage response) |
| | | 2828 | | { |
| | 6 | 2829 | | var contentDisposition = response.Content.Headers.ContentDisposition; |
| | 6 | 2830 | | var dispositionFileName = contentDisposition?.FileNameStar ?? contentDisposition?.FileName; |
| | 6 | 2831 | | if (!string.IsNullOrWhiteSpace(dispositionFileName)) |
| | | 2832 | | { |
| | 1 | 2833 | | var trimmed = dispositionFileName.Trim().Trim('"'); |
| | 1 | 2834 | | if (!string.IsNullOrWhiteSpace(trimmed)) |
| | | 2835 | | { |
| | 1 | 2836 | | return trimmed; |
| | | 2837 | | } |
| | | 2838 | | } |
| | | 2839 | | |
| | 5 | 2840 | | var uriFileName = Path.GetFileName(uri.AbsolutePath); |
| | 5 | 2841 | | if (!string.IsNullOrWhiteSpace(uriFileName)) |
| | | 2842 | | { |
| | 4 | 2843 | | return uriFileName; |
| | | 2844 | | } |
| | | 2845 | | |
| | | 2846 | | // Fall back to media-type-based extension inference when no usable file name metadata is available, |
| | | 2847 | | // to at least get a correct extension for archive type detection and validation even if the base name is generi |
| | 1 | 2848 | | return TryGetServiceContentRootArchiveExtensionFromMediaType( |
| | 1 | 2849 | | response.Content.Headers.ContentType?.MediaType, |
| | 1 | 2850 | | out var archiveExtension) |
| | 1 | 2851 | | ? BuildServiceContentRootArchiveFileName("content-root", archiveExtension) |
| | 1 | 2852 | | : null; |
| | | 2853 | | } |
| | | 2854 | | |
| | | 2855 | | /// <summary> |
| | | 2856 | | /// Returns true when the package archive path uses the supported extension. |
| | | 2857 | | /// </summary> |
| | | 2858 | | /// <param name="archivePath">Archive file path.</param> |
| | | 2859 | | /// <returns>True when archive extension is supported.</returns> |
| | | 2860 | | private static bool IsSupportedServiceContentRootArchive(string archivePath) |
| | | 2861 | | { |
| | 13 | 2862 | | var lowerPath = archivePath.ToLowerInvariant(); |
| | 13 | 2863 | | return lowerPath.EndsWith(ServicePackageExtension, StringComparison.Ordinal) |
| | 13 | 2864 | | || lowerPath.EndsWith(".zip", StringComparison.Ordinal) |
| | 13 | 2865 | | || lowerPath.EndsWith(".tar", StringComparison.Ordinal) |
| | 13 | 2866 | | || lowerPath.EndsWith(".tar.gz", StringComparison.Ordinal) |
| | 13 | 2867 | | || lowerPath.EndsWith(".tgz", StringComparison.Ordinal); |
| | | 2868 | | } |
| | | 2869 | | |
| | | 2870 | | /// <summary> |
| | | 2871 | | /// Validates a content-root archive checksum when a checksum was provided. |
| | | 2872 | | /// </summary> |
| | | 2873 | | /// <param name="command">Parsed service command.</param> |
| | | 2874 | | /// <param name="archivePath">Archive path to validate.</param> |
| | | 2875 | | /// <param name="error">Error details when checksum validation fails.</param> |
| | | 2876 | | /// <returns>True when checksum is valid or not requested.</returns> |
| | | 2877 | | private static bool TryValidateServiceContentRootArchiveChecksum(ParsedCommand command, string archivePath, out stri |
| | | 2878 | | { |
| | 10 | 2879 | | error = string.Empty; |
| | 10 | 2880 | | if (string.IsNullOrWhiteSpace(command.ServiceContentRootChecksum)) |
| | | 2881 | | { |
| | 6 | 2882 | | return true; |
| | | 2883 | | } |
| | | 2884 | | |
| | 4 | 2885 | | var expectedHash = command.ServiceContentRootChecksum.Trim(); |
| | 4 | 2886 | | if (!Regex.IsMatch(expectedHash, "^[0-9a-fA-F]+$")) |
| | | 2887 | | { |
| | 1 | 2888 | | error = "--content-root-checksum must be a hexadecimal hash string."; |
| | 1 | 2889 | | return false; |
| | | 2890 | | } |
| | | 2891 | | |
| | 3 | 2892 | | var algorithmName = string.IsNullOrWhiteSpace(command.ServiceContentRootChecksumAlgorithm) |
| | 3 | 2893 | | ? "sha256" |
| | 3 | 2894 | | : command.ServiceContentRootChecksumAlgorithm.Trim(); |
| | | 2895 | | |
| | 3 | 2896 | | if (!TryCreateChecksumAlgorithm(algorithmName, out var algorithm, out var normalizedAlgorithmName, out error)) |
| | | 2897 | | { |
| | 0 | 2898 | | return false; |
| | | 2899 | | } |
| | | 2900 | | |
| | 3 | 2901 | | using (algorithm) |
| | 3 | 2902 | | using (var stream = File.OpenRead(archivePath)) |
| | | 2903 | | { |
| | 3 | 2904 | | var actualHash = Convert.ToHexString(algorithm.ComputeHash(stream)); |
| | 3 | 2905 | | if (!string.Equals(actualHash, expectedHash, StringComparison.OrdinalIgnoreCase)) |
| | | 2906 | | { |
| | 2 | 2907 | | error = $"Archive checksum mismatch for '{archivePath}'. Expected {normalizedAlgorithmName}:{expectedHas |
| | 2 | 2908 | | return false; |
| | | 2909 | | } |
| | 1 | 2910 | | } |
| | | 2911 | | |
| | 1 | 2912 | | return true; |
| | 2 | 2913 | | } |
| | | 2914 | | |
| | | 2915 | | /// <summary> |
| | | 2916 | | /// Creates a hash algorithm instance from a user-provided token. |
| | | 2917 | | /// </summary> |
| | | 2918 | | /// <param name="algorithmToken">Algorithm token from CLI.</param> |
| | | 2919 | | /// <param name="algorithm">Created hash algorithm instance.</param> |
| | | 2920 | | /// <param name="normalizedName">Normalized algorithm name.</param> |
| | | 2921 | | /// <param name="error">Validation error text when algorithm creation fails.</param> |
| | | 2922 | | /// <returns>True when the algorithm token is supported and can be created.</returns> |
| | | 2923 | | private static bool TryCreateChecksumAlgorithm(string algorithmToken, out HashAlgorithm algorithm, out string normal |
| | | 2924 | | { |
| | 5 | 2925 | | algorithm = null!; |
| | 5 | 2926 | | normalizedName = string.Empty; |
| | 5 | 2927 | | error = string.Empty; |
| | | 2928 | | |
| | 5 | 2929 | | var compact = algorithmToken.Replace("-", string.Empty, StringComparison.Ordinal).Trim().ToLowerInvariant(); |
| | | 2930 | | |
| | 5 | 2931 | | Func<HashAlgorithm>? algorithmFactory = compact switch |
| | 5 | 2932 | | { |
| | 0 | 2933 | | "md5" => MD5.Create, |
| | 0 | 2934 | | "sha1" or "sha" => SHA1.Create, |
| | 4 | 2935 | | "sha2" or "sha256" => SHA256.Create, |
| | 0 | 2936 | | "sha384" => SHA384.Create, |
| | 0 | 2937 | | "sha512" => SHA512.Create, |
| | 1 | 2938 | | _ => null, |
| | 5 | 2939 | | }; |
| | | 2940 | | |
| | 5 | 2941 | | if (algorithmFactory is null) |
| | | 2942 | | { |
| | 1 | 2943 | | error = "Unsupported --content-root-checksum-algorithm. Supported values: md5, sha1, sha256, sha384, sha512. |
| | 1 | 2944 | | return false; |
| | | 2945 | | } |
| | | 2946 | | |
| | 4 | 2947 | | normalizedName = compact switch |
| | 4 | 2948 | | { |
| | 0 | 2949 | | "sha" => "sha1", |
| | 1 | 2950 | | "sha2" => "sha256", |
| | 3 | 2951 | | _ => compact, |
| | 4 | 2952 | | }; |
| | | 2953 | | |
| | | 2954 | | try |
| | | 2955 | | { |
| | 4 | 2956 | | algorithm = algorithmFactory(); |
| | 4 | 2957 | | return true; |
| | | 2958 | | } |
| | 0 | 2959 | | catch (Exception ex) |
| | | 2960 | | { |
| | 0 | 2961 | | error = $"Unable to create checksum algorithm '{normalizedName}'. The algorithm may be disabled by system po |
| | 0 | 2962 | | algorithm = null!; |
| | 0 | 2963 | | normalizedName = string.Empty; |
| | 0 | 2964 | | return false; |
| | | 2965 | | } |
| | 4 | 2966 | | } |
| | | 2967 | | |
| | | 2968 | | /// <summary> |
| | | 2969 | | /// Extracts a supported archive into the specified target directory. |
| | | 2970 | | /// </summary> |
| | | 2971 | | /// <param name="archivePath">Archive file path.</param> |
| | | 2972 | | /// <param name="destinationDirectory">Extraction destination directory.</param> |
| | | 2973 | | /// <param name="error">Error details when extraction fails.</param> |
| | | 2974 | | /// <returns>True when extraction succeeds.</returns> |
| | | 2975 | | private static bool TryExtractServiceContentRootArchive(string archivePath, string destinationDirectory, out string |
| | | 2976 | | { |
| | 6 | 2977 | | error = string.Empty; |
| | | 2978 | | |
| | | 2979 | | try |
| | | 2980 | | { |
| | 6 | 2981 | | var lowerPath = archivePath.ToLowerInvariant(); |
| | 6 | 2982 | | if (lowerPath.EndsWith(ServicePackageExtension, StringComparison.Ordinal) |
| | 6 | 2983 | | || lowerPath.EndsWith(".zip", StringComparison.Ordinal)) |
| | | 2984 | | { |
| | 4 | 2985 | | return TryExtractZipArchiveSafely(archivePath, destinationDirectory, out error); |
| | | 2986 | | } |
| | | 2987 | | |
| | 2 | 2988 | | if (lowerPath.EndsWith(".tar", StringComparison.Ordinal)) |
| | | 2989 | | { |
| | 0 | 2990 | | return TryExtractTarArchiveSafely(File.OpenRead(archivePath), destinationDirectory, out error); |
| | | 2991 | | } |
| | | 2992 | | |
| | 2 | 2993 | | if (lowerPath.EndsWith(".tar.gz", StringComparison.Ordinal) || lowerPath.EndsWith(".tgz", StringComparison.O |
| | | 2994 | | { |
| | 2 | 2995 | | using var archiveStream = File.OpenRead(archivePath); |
| | 2 | 2996 | | using var gzipStream = new GZipStream(archiveStream, CompressionMode.Decompress); |
| | 2 | 2997 | | return TryExtractTarArchiveSafely(gzipStream, destinationDirectory, out error); |
| | | 2998 | | } |
| | | 2999 | | |
| | 0 | 3000 | | error = $"Unsupported package format. Supported extension: {ServicePackageExtension} (zip payload)."; |
| | 0 | 3001 | | return false; |
| | | 3002 | | } |
| | 0 | 3003 | | catch (Exception ex) |
| | | 3004 | | { |
| | 0 | 3005 | | error = $"Failed to extract service content archive '{archivePath}': {ex.Message}"; |
| | 0 | 3006 | | return false; |
| | | 3007 | | } |
| | 6 | 3008 | | } |
| | | 3009 | | |
| | | 3010 | | /// <summary> |
| | | 3011 | | /// Extracts a zip archive while enforcing destination path boundaries. |
| | | 3012 | | /// </summary> |
| | | 3013 | | /// <param name="archivePath">Archive file path.</param> |
| | | 3014 | | /// <param name="destinationDirectory">Extraction destination directory.</param> |
| | | 3015 | | /// <param name="error">Error details when extraction fails.</param> |
| | | 3016 | | /// <returns>True when extraction succeeds.</returns> |
| | | 3017 | | private static bool TryExtractZipArchiveSafely(string archivePath, string destinationDirectory, out string error) |
| | | 3018 | | { |
| | 4 | 3019 | | error = string.Empty; |
| | 4 | 3020 | | using var archive = ZipFile.OpenRead(archivePath); |
| | 26 | 3021 | | foreach (var entry in archive.Entries) |
| | | 3022 | | { |
| | 9 | 3023 | | var fullDestinationPath = Path.GetFullPath(Path.Combine(destinationDirectory, entry.FullName)); |
| | 9 | 3024 | | if (!IsPathWithinDirectory(fullDestinationPath, destinationDirectory)) |
| | | 3025 | | { |
| | 0 | 3026 | | error = $"Archive entry '{entry.FullName}' escapes extraction root."; |
| | 0 | 3027 | | return false; |
| | | 3028 | | } |
| | | 3029 | | |
| | 9 | 3030 | | var isDirectory = string.IsNullOrEmpty(entry.Name) |
| | 9 | 3031 | | || entry.FullName.EndsWith('/') |
| | 9 | 3032 | | || entry.FullName.EndsWith('\\'); |
| | 9 | 3033 | | if (isDirectory) |
| | | 3034 | | { |
| | 0 | 3035 | | _ = Directory.CreateDirectory(fullDestinationPath); |
| | 0 | 3036 | | continue; |
| | | 3037 | | } |
| | | 3038 | | |
| | 9 | 3039 | | var parentDirectory = Path.GetDirectoryName(fullDestinationPath); |
| | 9 | 3040 | | if (!string.IsNullOrWhiteSpace(parentDirectory)) |
| | | 3041 | | { |
| | 9 | 3042 | | _ = Directory.CreateDirectory(parentDirectory); |
| | | 3043 | | } |
| | | 3044 | | |
| | 9 | 3045 | | entry.ExtractToFile(fullDestinationPath, overwrite: true); |
| | | 3046 | | } |
| | | 3047 | | |
| | 4 | 3048 | | return true; |
| | 4 | 3049 | | } |
| | | 3050 | | |
| | | 3051 | | /// <summary> |
| | | 3052 | | /// Extracts a tar stream while enforcing destination path boundaries. |
| | | 3053 | | /// </summary> |
| | | 3054 | | /// <param name="archiveStream">Tar-formatted stream.</param> |
| | | 3055 | | /// <param name="destinationDirectory">Extraction destination directory.</param> |
| | | 3056 | | /// <param name="error">Error details when extraction fails.</param> |
| | | 3057 | | /// <returns>True when extraction succeeds.</returns> |
| | | 3058 | | private static bool TryExtractTarArchiveSafely(Stream archiveStream, string destinationDirectory, out string error) |
| | | 3059 | | { |
| | 2 | 3060 | | error = string.Empty; |
| | 2 | 3061 | | using var reader = new TarReader(archiveStream, leaveOpen: false); |
| | | 3062 | | TarEntry? entry; |
| | 7 | 3063 | | while ((entry = reader.GetNextEntry()) is not null) |
| | | 3064 | | { |
| | 5 | 3065 | | if (string.IsNullOrWhiteSpace(entry.Name)) |
| | | 3066 | | { |
| | | 3067 | | continue; |
| | | 3068 | | } |
| | | 3069 | | |
| | 5 | 3070 | | var fullDestinationPath = Path.GetFullPath(Path.Combine(destinationDirectory, entry.Name)); |
| | 5 | 3071 | | if (!IsPathWithinDirectory(fullDestinationPath, destinationDirectory)) |
| | | 3072 | | { |
| | 0 | 3073 | | error = $"Archive entry '{entry.Name}' escapes extraction root."; |
| | 0 | 3074 | | return false; |
| | | 3075 | | } |
| | | 3076 | | |
| | 5 | 3077 | | if (entry.EntryType is TarEntryType.Directory) |
| | | 3078 | | { |
| | 0 | 3079 | | _ = Directory.CreateDirectory(fullDestinationPath); |
| | 0 | 3080 | | continue; |
| | | 3081 | | } |
| | | 3082 | | |
| | 5 | 3083 | | if (entry.EntryType is TarEntryType.RegularFile or TarEntryType.V7RegularFile) |
| | | 3084 | | { |
| | 5 | 3085 | | var parentDirectory = Path.GetDirectoryName(fullDestinationPath); |
| | 5 | 3086 | | if (!string.IsNullOrWhiteSpace(parentDirectory)) |
| | | 3087 | | { |
| | 5 | 3088 | | _ = Directory.CreateDirectory(parentDirectory); |
| | | 3089 | | } |
| | | 3090 | | |
| | 5 | 3091 | | if (entry.DataStream is null) |
| | | 3092 | | { |
| | 0 | 3093 | | using var emptyFile = File.Create(fullDestinationPath); |
| | 0 | 3094 | | continue; |
| | | 3095 | | } |
| | | 3096 | | |
| | 5 | 3097 | | using var output = File.Create(fullDestinationPath); |
| | 5 | 3098 | | entry.DataStream.CopyTo(output); |
| | 5 | 3099 | | continue; |
| | | 3100 | | } |
| | | 3101 | | } |
| | | 3102 | | |
| | 2 | 3103 | | return true; |
| | 2 | 3104 | | } |
| | | 3105 | | |
| | | 3106 | | /// <summary> |
| | | 3107 | | /// Checks whether a path is inside (or equal to) a given directory. |
| | | 3108 | | /// </summary> |
| | | 3109 | | /// <param name="candidatePath">Candidate absolute path.</param> |
| | | 3110 | | /// <param name="directoryPath">Directory absolute path.</param> |
| | | 3111 | | /// <returns>True when candidate is within the directory tree.</returns> |
| | | 3112 | | private static bool IsPathWithinDirectory(string candidatePath, string directoryPath) |
| | | 3113 | | { |
| | 28 | 3114 | | var fullCandidate = Path.GetFullPath(candidatePath) |
| | 28 | 3115 | | .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); |
| | 28 | 3116 | | var fullDirectory = Path.GetFullPath(directoryPath) |
| | 28 | 3117 | | .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); |
| | 28 | 3118 | | var comparison = OperatingSystem.IsWindows() || OperatingSystem.IsMacOS() |
| | 28 | 3119 | | ? StringComparison.OrdinalIgnoreCase |
| | 28 | 3120 | | : StringComparison.Ordinal; |
| | | 3121 | | |
| | 28 | 3122 | | return fullCandidate.Equals(fullDirectory, comparison) |
| | 28 | 3123 | | || fullCandidate.StartsWith(fullDirectory + Path.DirectorySeparatorChar, comparison) |
| | 28 | 3124 | | || fullCandidate.StartsWith(fullDirectory + Path.AltDirectorySeparatorChar, comparison); |
| | | 3125 | | } |
| | | 3126 | | |
| | | 3127 | | /// <summary> |
| | | 3128 | | /// Resolves the deployment root path used for service bundles. |
| | | 3129 | | /// </summary> |
| | | 3130 | | /// <param name="deploymentRootOverride">Optional explicit root path.</param> |
| | | 3131 | | /// <param name="deploymentRoot">Resolved writable deployment root.</param> |
| | | 3132 | | /// <param name="error">Error details when no writable root is available.</param> |
| | | 3133 | | /// <returns>True when a writable deployment root is resolved.</returns> |
| | | 3134 | | private static bool TryResolveServiceDeploymentRoot(string? deploymentRootOverride, out string deploymentRoot, out s |
| | | 3135 | | { |
| | 5 | 3136 | | deploymentRoot = string.Empty; |
| | 5 | 3137 | | error = string.Empty; |
| | | 3138 | | |
| | 5 | 3139 | | if (!string.IsNullOrWhiteSpace(deploymentRootOverride)) |
| | | 3140 | | { |
| | 5 | 3141 | | var overrideRoot = Path.GetFullPath(deploymentRootOverride); |
| | 5 | 3142 | | if (!TryEnsureDirectoryWritable(overrideRoot, out var overrideError)) |
| | | 3143 | | { |
| | 1 | 3144 | | error = $"Unable to use deployment root '{deploymentRootOverride}': {overrideError}"; |
| | 1 | 3145 | | return false; |
| | | 3146 | | } |
| | | 3147 | | |
| | 4 | 3148 | | deploymentRoot = overrideRoot; |
| | 4 | 3149 | | return true; |
| | | 3150 | | } |
| | | 3151 | | |
| | 0 | 3152 | | var failures = new List<string>(); |
| | 0 | 3153 | | foreach (var candidate in GetServiceDeploymentRootCandidates()) |
| | | 3154 | | { |
| | 0 | 3155 | | if (string.IsNullOrWhiteSpace(candidate)) |
| | | 3156 | | { |
| | | 3157 | | continue; |
| | | 3158 | | } |
| | | 3159 | | |
| | | 3160 | | try |
| | | 3161 | | { |
| | 0 | 3162 | | var fullCandidate = Path.GetFullPath(candidate); |
| | 0 | 3163 | | if (!TryEnsureDirectoryWritable(fullCandidate, out var candidateError)) |
| | | 3164 | | { |
| | 0 | 3165 | | failures.Add($"{candidate} ({candidateError})"); |
| | 0 | 3166 | | continue; |
| | | 3167 | | } |
| | | 3168 | | |
| | 0 | 3169 | | deploymentRoot = fullCandidate; |
| | 0 | 3170 | | return true; |
| | | 3171 | | } |
| | 0 | 3172 | | catch (Exception ex) |
| | | 3173 | | { |
| | 0 | 3174 | | failures.Add($"{candidate} ({ex.Message})"); |
| | 0 | 3175 | | } |
| | | 3176 | | } |
| | | 3177 | | |
| | 0 | 3178 | | error = failures.Count == 0 |
| | 0 | 3179 | | ? "Unable to resolve a writable service deployment root." |
| | 0 | 3180 | | : $"Unable to resolve a writable service deployment root. Attempted: {string.Join("; ", failures)}"; |
| | 0 | 3181 | | return false; |
| | 0 | 3182 | | } |
| | | 3183 | | |
| | | 3184 | | /// <summary> |
| | | 3185 | | /// Ensures a directory is writable by creating and deleting a short-lived probe file. |
| | | 3186 | | /// </summary> |
| | | 3187 | | /// <param name="directoryPath">Directory path to validate.</param> |
| | | 3188 | | /// <param name="error">Error details when the path is not writable.</param> |
| | | 3189 | | /// <returns>True when the directory can be created and written to.</returns> |
| | | 3190 | | private static bool TryEnsureDirectoryWritable(string directoryPath, out string error) |
| | | 3191 | | { |
| | 5 | 3192 | | error = string.Empty; |
| | | 3193 | | |
| | | 3194 | | try |
| | | 3195 | | { |
| | 5 | 3196 | | _ = Directory.CreateDirectory(directoryPath); |
| | 4 | 3197 | | var probePath = Path.Combine(directoryPath, $".kestrun-write-probe-{Guid.NewGuid():N}"); |
| | 4 | 3198 | | File.WriteAllText(probePath, "ok"); |
| | 4 | 3199 | | File.Delete(probePath); |
| | 4 | 3200 | | return true; |
| | | 3201 | | } |
| | 1 | 3202 | | catch (Exception ex) |
| | | 3203 | | { |
| | 1 | 3204 | | error = ex.Message; |
| | 1 | 3205 | | return false; |
| | | 3206 | | } |
| | 5 | 3207 | | } |
| | | 3208 | | |
| | | 3209 | | /// <summary> |
| | | 3210 | | /// Returns candidate deployment roots for service bundle storage. |
| | | 3211 | | /// </summary> |
| | | 3212 | | /// <returns>Candidate absolute or rooted paths in priority order.</returns> |
| | | 3213 | | private static IEnumerable<string> GetServiceDeploymentRootCandidates() |
| | | 3214 | | { |
| | 10 | 3215 | | if (OperatingSystem.IsWindows()) |
| | | 3216 | | { |
| | 0 | 3217 | | yield return Path.Combine( |
| | 0 | 3218 | | Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), |
| | 0 | 3219 | | ServiceDeploymentProductFolderName, |
| | 0 | 3220 | | ServiceDeploymentServicesFolderName); |
| | 0 | 3221 | | yield break; |
| | | 3222 | | } |
| | | 3223 | | |
| | 10 | 3224 | | if (OperatingSystem.IsLinux()) |
| | | 3225 | | { |
| | 10 | 3226 | | yield return "/var/kestrun/services"; |
| | 10 | 3227 | | yield return "/usr/local/kestrun/services"; |
| | | 3228 | | |
| | 10 | 3229 | | var userProfile = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); |
| | 10 | 3230 | | if (!string.IsNullOrWhiteSpace(userProfile)) |
| | | 3231 | | { |
| | 10 | 3232 | | yield return Path.Combine(userProfile, ".local", "share", "kestrun", "services"); |
| | | 3233 | | } |
| | | 3234 | | |
| | 10 | 3235 | | yield break; |
| | | 3236 | | } |
| | | 3237 | | |
| | 0 | 3238 | | if (OperatingSystem.IsMacOS()) |
| | | 3239 | | { |
| | 0 | 3240 | | yield return "/usr/local/kestrun/services"; |
| | | 3241 | | |
| | 0 | 3242 | | var userProfile = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); |
| | 0 | 3243 | | if (!string.IsNullOrWhiteSpace(userProfile)) |
| | | 3244 | | { |
| | 0 | 3245 | | yield return Path.Combine(userProfile, "Library", "Application Support", "Kestrun", "services"); |
| | | 3246 | | } |
| | | 3247 | | |
| | 0 | 3248 | | yield break; |
| | | 3249 | | } |
| | | 3250 | | |
| | 0 | 3251 | | yield return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Kestrun", |
| | 0 | 3252 | | } |
| | | 3253 | | |
| | | 3254 | | /// <summary> |
| | | 3255 | | /// Removes service bundle directories for a given service name from known deployment roots. |
| | | 3256 | | /// </summary> |
| | | 3257 | | /// <param name="serviceName">Service name.</param> |
| | | 3258 | | private static void TryRemoveServiceBundle(string serviceName, string? deploymentRootOverride = null) |
| | | 3259 | | { |
| | 1 | 3260 | | var serviceDirectoryName = GetServiceDeploymentDirectoryName(serviceName); |
| | 1 | 3261 | | var printedPermissionHint = false; |
| | | 3262 | | |
| | 1 | 3263 | | var candidateRoots = new List<string>(); |
| | 1 | 3264 | | if (!string.IsNullOrWhiteSpace(deploymentRootOverride)) |
| | | 3265 | | { |
| | 1 | 3266 | | candidateRoots.Add(deploymentRootOverride); |
| | | 3267 | | } |
| | | 3268 | | |
| | 1 | 3269 | | candidateRoots.AddRange(GetServiceDeploymentRootCandidates()); |
| | | 3270 | | |
| | 10 | 3271 | | foreach (var candidateRoot in candidateRoots.Distinct(StringComparer.OrdinalIgnoreCase)) |
| | | 3272 | | { |
| | 4 | 3273 | | if (string.IsNullOrWhiteSpace(candidateRoot)) |
| | | 3274 | | { |
| | | 3275 | | continue; |
| | | 3276 | | } |
| | | 3277 | | |
| | 4 | 3278 | | var serviceRoot = Path.Combine(candidateRoot, serviceDirectoryName); |
| | | 3279 | | try |
| | | 3280 | | { |
| | 4 | 3281 | | if (OperatingSystem.IsWindows()) |
| | | 3282 | | { |
| | 0 | 3283 | | TryDeleteDirectoryWithRetry(serviceRoot, maxAttempts: 15, initialDelayMs: 250); |
| | | 3284 | | } |
| | | 3285 | | else |
| | | 3286 | | { |
| | 4 | 3287 | | TryDeleteDirectoryWithRetry(serviceRoot); |
| | | 3288 | | } |
| | 4 | 3289 | | } |
| | 0 | 3290 | | catch (Exception ex) |
| | | 3291 | | { |
| | 0 | 3292 | | if (IsExpectedUnixProtectedRootCleanupFailure(candidateRoot, ex, deploymentRootOverride)) |
| | | 3293 | | { |
| | 0 | 3294 | | if (!printedPermissionHint) |
| | | 3295 | | { |
| | 0 | 3296 | | Console.Error.WriteLine("Info: Skipping cleanup of root-owned service bundle locations. Use sudo |
| | 0 | 3297 | | printedPermissionHint = true; |
| | | 3298 | | } |
| | | 3299 | | |
| | 0 | 3300 | | continue; |
| | | 3301 | | } |
| | | 3302 | | |
| | 0 | 3303 | | Console.Error.WriteLine($"Warning: Failed to remove service bundle '{serviceRoot}': {ex.Message}"); |
| | 0 | 3304 | | } |
| | | 3305 | | } |
| | 1 | 3306 | | } |
| | | 3307 | | |
| | | 3308 | | /// <summary> |
| | | 3309 | | /// Returns true when cleanup failures are expected for protected Unix roots owned by another user. |
| | | 3310 | | /// </summary> |
| | | 3311 | | /// <param name="candidateRoot">Deployment root candidate being cleaned.</param> |
| | | 3312 | | /// <param name="exception">Raised exception.</param> |
| | | 3313 | | /// <param name="deploymentRootOverride">Optional explicit deployment root override.</param> |
| | | 3314 | | /// <returns>True when the error can be downgraded to informational output.</returns> |
| | | 3315 | | private static bool IsExpectedUnixProtectedRootCleanupFailure(string candidateRoot, Exception exception, string? dep |
| | | 3316 | | { |
| | 0 | 3317 | | if (!string.IsNullOrWhiteSpace(deploymentRootOverride)) |
| | | 3318 | | { |
| | 0 | 3319 | | return false; |
| | | 3320 | | } |
| | | 3321 | | |
| | 0 | 3322 | | if (exception is not UnauthorizedAccessException) |
| | | 3323 | | { |
| | 0 | 3324 | | return false; |
| | | 3325 | | } |
| | | 3326 | | |
| | 0 | 3327 | | if (!(OperatingSystem.IsLinux() || OperatingSystem.IsMacOS())) |
| | | 3328 | | { |
| | 0 | 3329 | | return false; |
| | | 3330 | | } |
| | | 3331 | | |
| | 0 | 3332 | | if (IsLikelyRunningAsRootOnUnix()) |
| | | 3333 | | { |
| | 0 | 3334 | | return false; |
| | | 3335 | | } |
| | | 3336 | | |
| | | 3337 | | // Cleanup failures for protected system roots are expected when running as a non-root user on Unix, so downgrad |
| | 0 | 3338 | | return IsProtectedUnixServiceRoot(candidateRoot); |
| | | 3339 | | } |
| | | 3340 | | |
| | | 3341 | | /// <summary> |
| | | 3342 | | /// Returns true when the path is a protected system root used for service bundle fallback on Unix. |
| | | 3343 | | /// </summary> |
| | | 3344 | | /// <param name="candidateRoot">Deployment root candidate.</param> |
| | | 3345 | | /// <returns>True when path is a protected system root.</returns> |
| | | 3346 | | private static bool IsProtectedUnixServiceRoot(string candidateRoot) |
| | | 3347 | | { |
| | 0 | 3348 | | if (string.IsNullOrWhiteSpace(candidateRoot)) |
| | | 3349 | | { |
| | 0 | 3350 | | return false; |
| | | 3351 | | } |
| | | 3352 | | |
| | 0 | 3353 | | var fullCandidate = Path.GetFullPath(candidateRoot) |
| | 0 | 3354 | | .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); |
| | | 3355 | | |
| | 0 | 3356 | | return string.Equals(fullCandidate, "/var/kestrun/services", StringComparison.Ordinal) |
| | 0 | 3357 | | || string.Equals(fullCandidate, "/usr/local/kestrun/services", StringComparison.Ordinal); |
| | | 3358 | | } |
| | | 3359 | | |
| | | 3360 | | /// <summary> |
| | | 3361 | | /// Deletes a directory recursively with retry/backoff for transient file-lock scenarios. |
| | | 3362 | | /// </summary> |
| | | 3363 | | /// <param name="directoryPath">Directory path to delete.</param> |
| | | 3364 | | /// <param name="maxAttempts">Maximum number of attempts.</param> |
| | | 3365 | | /// <param name="initialDelayMs">Initial delay between attempts in milliseconds.</param> |
| | | 3366 | | private static void TryDeleteDirectoryWithRetry(string directoryPath, int maxAttempts = 5, int initialDelayMs = 200) |
| | | 3367 | | { |
| | 57 | 3368 | | if (!Directory.Exists(directoryPath)) |
| | | 3369 | | { |
| | 4 | 3370 | | return; |
| | | 3371 | | } |
| | | 3372 | | |
| | 53 | 3373 | | var attempt = 0; |
| | 53 | 3374 | | var delayMs = initialDelayMs; |
| | 53 | 3375 | | Exception? lastError = null; |
| | | 3376 | | |
| | 53 | 3377 | | while (attempt < maxAttempts) |
| | | 3378 | | { |
| | | 3379 | | try |
| | | 3380 | | { |
| | 53 | 3381 | | Directory.Delete(directoryPath, recursive: true); |
| | 53 | 3382 | | return; |
| | | 3383 | | } |
| | 0 | 3384 | | catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) |
| | | 3385 | | { |
| | 0 | 3386 | | lastError = ex; |
| | 0 | 3387 | | attempt += 1; |
| | 0 | 3388 | | if (attempt >= maxAttempts) |
| | | 3389 | | { |
| | 0 | 3390 | | break; |
| | | 3391 | | } |
| | | 3392 | | |
| | 0 | 3393 | | Thread.Sleep(delayMs); |
| | 0 | 3394 | | delayMs = Math.Min(delayMs * 2, 2000); |
| | 0 | 3395 | | } |
| | | 3396 | | } |
| | | 3397 | | |
| | 0 | 3398 | | if (lastError is not null) |
| | | 3399 | | { |
| | 0 | 3400 | | throw lastError; |
| | | 3401 | | } |
| | 53 | 3402 | | } |
| | | 3403 | | |
| | | 3404 | | /// <summary> |
| | | 3405 | | /// Returns a filesystem-safe directory name for service deployment folders. |
| | | 3406 | | /// </summary> |
| | | 3407 | | /// <param name="serviceName">Service name.</param> |
| | | 3408 | | /// <returns>Sanitized directory name.</returns> |
| | | 3409 | | private static string GetServiceDeploymentDirectoryName(string serviceName) |
| | | 3410 | | { |
| | 14 | 3411 | | var invalid = Path.GetInvalidFileNameChars(); |
| | 14 | 3412 | | var builder = new StringBuilder(serviceName.Length); |
| | 400 | 3413 | | foreach (var ch in serviceName) |
| | | 3414 | | { |
| | 186 | 3415 | | if (char.IsControl(ch) || ch == Path.DirectorySeparatorChar || ch == Path.AltDirectorySeparatorChar || inval |
| | | 3416 | | { |
| | 0 | 3417 | | _ = builder.Append('-'); |
| | 0 | 3418 | | continue; |
| | | 3419 | | } |
| | | 3420 | | |
| | 186 | 3421 | | _ = builder.Append(ch); |
| | | 3422 | | } |
| | | 3423 | | |
| | 14 | 3424 | | var sanitized = builder.ToString().Trim().Trim('.'); |
| | 14 | 3425 | | return string.IsNullOrWhiteSpace(sanitized) ? "service" : sanitized; |
| | | 3426 | | } |
| | | 3427 | | |
| | | 3428 | | /// <summary> |
| | | 3429 | | /// Resolves runtime RID segment for service runtime payloads. |
| | | 3430 | | /// </summary> |
| | | 3431 | | /// <param name="runtimeRid">RID segment (for example win-x64).</param> |
| | | 3432 | | /// <param name="error">Error details when runtime architecture is unsupported.</param> |
| | | 3433 | | /// <returns>True when runtime RID can be resolved.</returns> |
| | | 3434 | | private static bool TryGetServiceRuntimeRid(out string runtimeRid, out string error) |
| | | 3435 | | { |
| | 13 | 3436 | | runtimeRid = string.Empty; |
| | 13 | 3437 | | error = string.Empty; |
| | | 3438 | | |
| | 13 | 3439 | | var osPrefix = OperatingSystem.IsWindows() |
| | 13 | 3440 | | ? "win" |
| | 13 | 3441 | | : OperatingSystem.IsLinux() |
| | 13 | 3442 | | ? "linux" |
| | 13 | 3443 | | : OperatingSystem.IsMacOS() |
| | 13 | 3444 | | ? "osx" |
| | 13 | 3445 | | : string.Empty; |
| | | 3446 | | |
| | 13 | 3447 | | if (string.IsNullOrWhiteSpace(osPrefix)) |
| | | 3448 | | { |
| | 0 | 3449 | | error = "Service runtime bundling is not supported on this operating system."; |
| | 0 | 3450 | | return false; |
| | | 3451 | | } |
| | | 3452 | | |
| | 13 | 3453 | | var architecture = RuntimeInformation.ProcessArchitecture switch |
| | 13 | 3454 | | { |
| | 13 | 3455 | | Architecture.X64 => "x64", |
| | 0 | 3456 | | Architecture.Arm64 => "arm64", |
| | 0 | 3457 | | _ => string.Empty, |
| | 13 | 3458 | | }; |
| | | 3459 | | |
| | 13 | 3460 | | if (string.IsNullOrWhiteSpace(architecture)) |
| | | 3461 | | { |
| | 0 | 3462 | | error = $"Service runtime bundling does not support process architecture '{RuntimeInformation.ProcessArchite |
| | 0 | 3463 | | return false; |
| | | 3464 | | } |
| | | 3465 | | |
| | 13 | 3466 | | runtimeRid = $"{osPrefix}-{architecture}"; |
| | 13 | 3467 | | return true; |
| | | 3468 | | } |
| | | 3469 | | |
| | | 3470 | | /// <summary> |
| | | 3471 | | /// Ensures execute permissions are present for service runtime files on Unix platforms. |
| | | 3472 | | /// </summary> |
| | | 3473 | | /// <param name="runtimePath">Runtime executable file path.</param> |
| | | 3474 | | [SupportedOSPlatform("linux")] |
| | | 3475 | | [SupportedOSPlatform("macos")] |
| | | 3476 | | private static void TryEnsureServiceRuntimeExecutablePermissions(string runtimePath) |
| | | 3477 | | { |
| | | 3478 | | try |
| | | 3479 | | { |
| | 6 | 3480 | | var mode = File.GetUnixFileMode(runtimePath); |
| | 6 | 3481 | | mode |= UnixFileMode.UserExecute | UnixFileMode.GroupExecute | UnixFileMode.OtherExecute; |
| | 6 | 3482 | | File.SetUnixFileMode(runtimePath, mode); |
| | 6 | 3483 | | } |
| | 0 | 3484 | | catch |
| | | 3485 | | { |
| | | 3486 | | // Ignore permission update failures and let service startup report execution errors if needed. |
| | 0 | 3487 | | } |
| | 6 | 3488 | | } |
| | | 3489 | | |
| | | 3490 | | /// <summary> |
| | | 3491 | | /// Normalizes service log path input; directory input gets the default file name. |
| | | 3492 | | /// </summary> |
| | | 3493 | | /// <param name="inputPath">Configured path input.</param> |
| | | 3494 | | /// <param name="defaultFileName">Default file name for directory-only inputs.</param> |
| | | 3495 | | /// <returns>Absolute log file path.</returns> |
| | | 3496 | | private static string NormalizeServiceLogPath(string inputPath, string defaultFileName) |
| | | 3497 | | { |
| | 6 | 3498 | | var fullPath = Path.GetFullPath(inputPath); |
| | 6 | 3499 | | return Directory.Exists(fullPath) |
| | 6 | 3500 | | || inputPath.EndsWith('\\') |
| | 6 | 3501 | | || inputPath.EndsWith('/') |
| | 6 | 3502 | | ? Path.Combine(fullPath, defaultFileName) |
| | 6 | 3503 | | : fullPath; |
| | | 3504 | | } |
| | | 3505 | | |
| | | 3506 | | /// <summary> |
| | | 3507 | | /// Escapes XML-sensitive characters. |
| | | 3508 | | /// </summary> |
| | | 3509 | | /// <param name="input">Raw input string.</param> |
| | | 3510 | | /// <returns>Escaped XML value.</returns> |
| | | 3511 | | private static string EscapeXml(string input) |
| | | 3512 | | { |
| | 11 | 3513 | | return input |
| | 11 | 3514 | | .Replace("&", "&", StringComparison.Ordinal) |
| | 11 | 3515 | | .Replace("<", "<", StringComparison.Ordinal) |
| | 11 | 3516 | | .Replace(">", ">", StringComparison.Ordinal) |
| | 11 | 3517 | | .Replace("\"", """, StringComparison.Ordinal) |
| | 11 | 3518 | | .Replace("'", "'", StringComparison.Ordinal); |
| | | 3519 | | } |
| | | 3520 | | |
| | | 3521 | | /// <summary> |
| | | 3522 | | /// Escapes one token for systemd ExecStart parsing. |
| | | 3523 | | /// </summary> |
| | | 3524 | | /// <param name="input">Raw token.</param> |
| | | 3525 | | /// <returns>Escaped token.</returns> |
| | | 3526 | | private static string EscapeSystemdToken(string input) |
| | | 3527 | | { |
| | 17 | 3528 | | if (string.IsNullOrEmpty(input)) |
| | | 3529 | | { |
| | 0 | 3530 | | return "\"\""; |
| | | 3531 | | } |
| | | 3532 | | |
| | 17 | 3533 | | var escaped = input |
| | 17 | 3534 | | .Replace("\\", "\\\\", StringComparison.Ordinal) |
| | 17 | 3535 | | .Replace(" ", "\\ ", StringComparison.Ordinal) |
| | 17 | 3536 | | .Replace("\"", "\\\"", StringComparison.Ordinal) |
| | 17 | 3537 | | .Replace("'", "\\'", StringComparison.Ordinal) |
| | 17 | 3538 | | .Replace(";", "\\;", StringComparison.Ordinal) |
| | 17 | 3539 | | .Replace("$", "\\$", StringComparison.Ordinal); |
| | | 3540 | | |
| | 17 | 3541 | | return escaped; |
| | | 3542 | | } |
| | | 3543 | | |
| | | 3544 | | /// <summary> |
| | | 3545 | | /// Builds a Windows command-line string with proper escaping for each token. |
| | | 3546 | | /// </summary> |
| | | 3547 | | /// <param name="exePath">Executable path.</param> |
| | | 3548 | | /// <param name="args">Command-line arguments.</param> |
| | | 3549 | | /// <returns>Full command line string.</returns> |
| | | 3550 | | private static string BuildWindowsCommandLine(string exePath, IReadOnlyList<string> args) |
| | | 3551 | | { |
| | 1 | 3552 | | var all = new List<string>(1 + args.Count) { exePath }; |
| | 1 | 3553 | | all.AddRange(args); |
| | 1 | 3554 | | return string.Join(" ", all.Select(EscapeWindowsCommandLineArgument)); |
| | | 3555 | | } |
| | | 3556 | | |
| | | 3557 | | /// <summary> |
| | | 3558 | | /// Escapes one command-line argument using Windows CreateProcess rules. |
| | | 3559 | | /// </summary> |
| | | 3560 | | /// <param name="arg">Input argument.</param> |
| | | 3561 | | /// <returns>Escaped argument string.</returns> |
| | | 3562 | | private static string EscapeWindowsCommandLineArgument(string arg) |
| | | 3563 | | { |
| | 4 | 3564 | | if (arg.Length == 0) |
| | | 3565 | | { |
| | 0 | 3566 | | return "\"\""; |
| | | 3567 | | } |
| | | 3568 | | |
| | 32 | 3569 | | var requiresQuotes = arg.Any(c => char.IsWhiteSpace(c) || c == '"'); |
| | 4 | 3570 | | if (!requiresQuotes) |
| | | 3571 | | { |
| | 1 | 3572 | | return arg; |
| | | 3573 | | } |
| | | 3574 | | |
| | 3 | 3575 | | var result = new StringBuilder(arg.Length + 2); |
| | 3 | 3576 | | _ = result.Append('"'); |
| | 3 | 3577 | | var backslashes = 0; |
| | 124 | 3578 | | foreach (var c in arg) |
| | | 3579 | | { |
| | 59 | 3580 | | if (c == '\\') |
| | | 3581 | | { |
| | 0 | 3582 | | backslashes += 1; |
| | 0 | 3583 | | continue; |
| | | 3584 | | } |
| | | 3585 | | |
| | 59 | 3586 | | if (c == '"') |
| | | 3587 | | { |
| | 0 | 3588 | | _ = result.Append('\\', (backslashes * 2) + 1); |
| | 0 | 3589 | | _ = result.Append('"'); |
| | 0 | 3590 | | backslashes = 0; |
| | 0 | 3591 | | continue; |
| | | 3592 | | } |
| | | 3593 | | |
| | 59 | 3594 | | if (backslashes > 0) |
| | | 3595 | | { |
| | 0 | 3596 | | _ = result.Append('\\', backslashes); |
| | 0 | 3597 | | backslashes = 0; |
| | | 3598 | | } |
| | | 3599 | | |
| | 59 | 3600 | | _ = result.Append(c); |
| | | 3601 | | } |
| | | 3602 | | |
| | 3 | 3603 | | if (backslashes > 0) |
| | | 3604 | | { |
| | 0 | 3605 | | _ = result.Append('\\', backslashes * 2); |
| | | 3606 | | } |
| | | 3607 | | |
| | 3 | 3608 | | _ = result.Append('"'); |
| | 3 | 3609 | | return result.ToString(); |
| | | 3610 | | } |
| | | 3611 | | |
| | | 3612 | | /// <summary> |
| | | 3613 | | /// Builds a normalized systemd unit name. |
| | | 3614 | | /// </summary> |
| | | 3615 | | /// <param name="serviceName">Input service name.</param> |
| | | 3616 | | /// <returns>Sanitized unit file name.</returns> |
| | | 3617 | | private static string GetLinuxUnitName(string serviceName) |
| | | 3618 | | { |
| | 661 | 3619 | | var safeName = new string([.. serviceName.Select(c => char.IsLetterOrDigit(c) || c is '-' or '_' or '.' ? c : '- |
| | | 3620 | | |
| | 23 | 3621 | | return safeName.EndsWith(".service", StringComparison.OrdinalIgnoreCase) |
| | 23 | 3622 | | ? safeName |
| | 23 | 3623 | | : $"{safeName}.service"; |
| | | 3624 | | } |
| | | 3625 | | |
| | | 3626 | | /// <summary> |
| | | 3627 | | /// Returns true when the current Linux process is likely running as root. |
| | | 3628 | | /// </summary> |
| | | 3629 | | /// <returns>True when username resolves to root on Linux.</returns> |
| | 1 | 3630 | | private static bool IsLikelyRunningAsRootOnLinux() => OperatingSystem.IsLinux() && string.Equals(Environment.UserNam |
| | | 3631 | | |
| | | 3632 | | /// <summary> |
| | | 3633 | | /// Returns true when the current Unix process is likely running as root. |
| | | 3634 | | /// </summary> |
| | | 3635 | | /// <returns>True when username resolves to root on Linux or macOS.</returns> |
| | 0 | 3636 | | private static bool IsLikelyRunningAsRootOnUnix() => (OperatingSystem.IsLinux() || OperatingSystem.IsMacOS()) |
| | 0 | 3637 | | && string.Equals(Environment.UserName, "root", StringComparison.Ordinal); |
| | | 3638 | | |
| | | 3639 | | /// <summary> |
| | | 3640 | | /// Writes actionable guidance for common user-level systemd failures on Linux. |
| | | 3641 | | /// </summary> |
| | | 3642 | | /// <param name="result">Captured process result from a failed systemctl --user call.</param> |
| | | 3643 | | private static void WriteLinuxUserSystemdFailureHint(ProcessResult result) |
| | | 3644 | | { |
| | 1 | 3645 | | if (!OperatingSystem.IsLinux()) |
| | | 3646 | | { |
| | 0 | 3647 | | return; |
| | | 3648 | | } |
| | | 3649 | | |
| | 1 | 3650 | | var diagnostics = string.IsNullOrWhiteSpace(result.Error) ? result.Output : result.Error; |
| | 1 | 3651 | | if (string.IsNullOrWhiteSpace(diagnostics)) |
| | | 3652 | | { |
| | 0 | 3653 | | return; |
| | | 3654 | | } |
| | | 3655 | | |
| | 1 | 3656 | | if (!diagnostics.Contains("Failed to connect to bus", StringComparison.OrdinalIgnoreCase) |
| | 1 | 3657 | | && !diagnostics.Contains("No medium found", StringComparison.OrdinalIgnoreCase) |
| | 1 | 3658 | | && !diagnostics.Contains("Access denied", StringComparison.OrdinalIgnoreCase) |
| | 1 | 3659 | | && !diagnostics.Contains("Permission denied", StringComparison.OrdinalIgnoreCase)) |
| | | 3660 | | { |
| | 0 | 3661 | | return; |
| | | 3662 | | } |
| | | 3663 | | |
| | 1 | 3664 | | Console.Error.WriteLine("Hint: Linux service commands use user-level systemd units (systemctl --user)."); |
| | 1 | 3665 | | Console.Error.WriteLine("Run install/start/stop/query/remove as the same non-root user that installed the unit." |
| | 1 | 3666 | | Console.Error.WriteLine("If running over SSH or a headless session, enable linger: sudo loginctl enable-linger < |
| | 1 | 3667 | | } |
| | | 3668 | | |
| | | 3669 | | /// <summary> |
| | | 3670 | | /// Runs a process and captures output for diagnostics. |
| | | 3671 | | /// </summary> |
| | | 3672 | | /// <param name="fileName">Executable to run.</param> |
| | | 3673 | | /// <param name="arguments">Argument tokens.</param> |
| | | 3674 | | /// <returns>Process result data.</returns> |
| | | 3675 | | private static ProcessResult RunProcess(string fileName, IReadOnlyList<string> arguments, bool writeStandardOutput = |
| | | 3676 | | { |
| | 11 | 3677 | | var startInfo = new ProcessStartInfo |
| | 11 | 3678 | | { |
| | 11 | 3679 | | FileName = fileName, |
| | 11 | 3680 | | UseShellExecute = false, |
| | 11 | 3681 | | RedirectStandardOutput = true, |
| | 11 | 3682 | | RedirectStandardError = true, |
| | 11 | 3683 | | CreateNoWindow = true, |
| | 11 | 3684 | | }; |
| | | 3685 | | |
| | 80 | 3686 | | foreach (var argument in arguments) |
| | | 3687 | | { |
| | 29 | 3688 | | startInfo.ArgumentList.Add(argument); |
| | | 3689 | | } |
| | | 3690 | | |
| | 11 | 3691 | | using var process = Process.Start(startInfo); |
| | 9 | 3692 | | if (process is null) |
| | | 3693 | | { |
| | 0 | 3694 | | return new ProcessResult(1, string.Empty, $"Failed to start process: {fileName}"); |
| | | 3695 | | } |
| | | 3696 | | |
| | 9 | 3697 | | var output = process.StandardOutput.ReadToEnd(); |
| | 9 | 3698 | | var error = process.StandardError.ReadToEnd(); |
| | 9 | 3699 | | process.WaitForExit(); |
| | | 3700 | | |
| | 9 | 3701 | | if (writeStandardOutput && !string.IsNullOrWhiteSpace(output)) |
| | | 3702 | | { |
| | 1 | 3703 | | Console.WriteLine(output.TrimEnd()); |
| | | 3704 | | } |
| | | 3705 | | |
| | 9 | 3706 | | return new ProcessResult(process.ExitCode, output, error); |
| | 9 | 3707 | | } |
| | | 3708 | | |
| | | 3709 | | /// <summary> |
| | | 3710 | | /// Captures child process execution results. |
| | | 3711 | | /// </summary> |
| | | 3712 | | /// <param name="ExitCode">Process exit code.</param> |
| | | 3713 | | /// <param name="Output">Captured standard output.</param> |
| | | 3714 | | /// <param name="Error">Captured standard error.</param> |
| | 53 | 3715 | | private sealed record ProcessResult(int ExitCode, string Output, string Error); |
| | | 3716 | | |
| | | 3717 | | /// <summary> |
| | | 3718 | | /// Resolves a module manifest path for run mode, preferring bundled service payload when no explicit path is provid |
| | | 3719 | | /// </summary> |
| | | 3720 | | /// <param name="kestrunManifestPath">Optional explicit manifest path.</param> |
| | | 3721 | | /// <param name="kestrunFolder">Optional module folder path.</param> |
| | | 3722 | | /// <returns>Absolute path to the resolved module manifest, or null when not found.</returns> |
| | | 3723 | | private static string? ResolveRunModuleManifestPath(string? kestrunManifestPath, string? kestrunFolder) |
| | | 3724 | | { |
| | 0 | 3725 | | if (!string.IsNullOrWhiteSpace(kestrunManifestPath) || !string.IsNullOrWhiteSpace(kestrunFolder)) |
| | | 3726 | | { |
| | 0 | 3727 | | return LocateModuleManifest(kestrunManifestPath, kestrunFolder); |
| | | 3728 | | } |
| | | 3729 | | |
| | 0 | 3730 | | if (TryResolvePowerShellModulesPayloadFromToolDistribution(out var modulesPayloadPath)) |
| | | 3731 | | { |
| | 0 | 3732 | | var bundledManifestPath = Path.Combine(modulesPayloadPath, ModuleName, ModuleManifestFileName); |
| | 0 | 3733 | | if (File.Exists(bundledManifestPath)) |
| | | 3734 | | { |
| | 0 | 3735 | | return Path.GetFullPath(bundledManifestPath); |
| | | 3736 | | } |
| | | 3737 | | } |
| | | 3738 | | |
| | 0 | 3739 | | return LocateModuleManifest(null, null); |
| | | 3740 | | } |
| | | 3741 | | |
| | | 3742 | | /// <summary> |
| | | 3743 | | /// Builds arguments for direct foreground run mode on the dedicated service-host executable. |
| | | 3744 | | /// </summary> |
| | | 3745 | | /// <param name="runnerExecutablePath">Runner executable path.</param> |
| | | 3746 | | /// <param name="scriptPath">Absolute script path.</param> |
| | | 3747 | | /// <param name="moduleManifestPath">Absolute module manifest path.</param> |
| | | 3748 | | /// <param name="scriptArguments">Script arguments.</param> |
| | | 3749 | | /// <param name="discoverPowerShellHome">When true, pass --discover-pshome.</param> |
| | | 3750 | | /// <returns>Ordered argument list.</returns> |
| | | 3751 | | private static IReadOnlyList<string> BuildDedicatedServiceHostRunArguments( |
| | | 3752 | | string runnerExecutablePath, |
| | | 3753 | | string scriptPath, |
| | | 3754 | | string moduleManifestPath, |
| | | 3755 | | IReadOnlyList<string> scriptArguments, |
| | | 3756 | | bool discoverPowerShellHome) |
| | | 3757 | | { |
| | 0 | 3758 | | var arguments = new List<string>(12 + scriptArguments.Count) |
| | 0 | 3759 | | { |
| | 0 | 3760 | | "--runner-exe", |
| | 0 | 3761 | | Path.GetFullPath(runnerExecutablePath), |
| | 0 | 3762 | | "--run", |
| | 0 | 3763 | | Path.GetFullPath(scriptPath), |
| | 0 | 3764 | | "--kestrun-manifest", |
| | 0 | 3765 | | Path.GetFullPath(moduleManifestPath), |
| | 0 | 3766 | | }; |
| | | 3767 | | |
| | 0 | 3768 | | if (discoverPowerShellHome) |
| | | 3769 | | { |
| | 0 | 3770 | | arguments.Add("--discover-pshome"); |
| | | 3771 | | } |
| | | 3772 | | |
| | 0 | 3773 | | if (scriptArguments.Count > 0) |
| | | 3774 | | { |
| | 0 | 3775 | | arguments.Add("--arguments"); |
| | 0 | 3776 | | arguments.AddRange(scriptArguments); |
| | | 3777 | | } |
| | | 3778 | | |
| | 0 | 3779 | | return arguments; |
| | | 3780 | | } |
| | | 3781 | | |
| | | 3782 | | /// <summary> |
| | | 3783 | | /// Resolves whether service-host should auto-discover PSHOME for the selected manifest path. |
| | | 3784 | | /// </summary> |
| | | 3785 | | /// <param name="moduleManifestPath">Absolute path to Kestrun.psd1.</param> |
| | | 3786 | | /// <returns>True when --discover-pshome should be used.</returns> |
| | | 3787 | | private static bool ShouldDiscoverPowerShellHomeForManifest(string moduleManifestPath) |
| | | 3788 | | { |
| | 0 | 3789 | | var fullManifestPath = Path.GetFullPath(moduleManifestPath); |
| | 0 | 3790 | | var moduleDirectory = Path.GetDirectoryName(fullManifestPath); |
| | 0 | 3791 | | if (string.IsNullOrWhiteSpace(moduleDirectory)) |
| | | 3792 | | { |
| | 0 | 3793 | | return true; |
| | | 3794 | | } |
| | | 3795 | | |
| | 0 | 3796 | | var moduleRoot = Directory.GetParent(moduleDirectory); |
| | 0 | 3797 | | var serviceRoot = moduleRoot?.Parent?.FullName; |
| | 0 | 3798 | | if (string.IsNullOrWhiteSpace(serviceRoot)) |
| | | 3799 | | { |
| | 0 | 3800 | | return true; |
| | | 3801 | | } |
| | | 3802 | | |
| | 0 | 3803 | | var modulesDirectory = Path.Combine(serviceRoot, "Modules"); |
| | 0 | 3804 | | return !Directory.Exists(modulesDirectory); |
| | | 3805 | | } |
| | | 3806 | | |
| | | 3807 | | /// <summary> |
| | | 3808 | | /// Resolves the current executable path when available, otherwise falls back to the provided value. |
| | | 3809 | | /// </summary> |
| | | 3810 | | /// <param name="fallbackPath">Fallback path when current process path is unavailable.</param> |
| | | 3811 | | /// <returns>Absolute executable path.</returns> |
| | | 3812 | | private static string ResolveCurrentProcessPathOrFallback(string fallbackPath) |
| | 0 | 3813 | | => !string.IsNullOrWhiteSpace(Environment.ProcessPath) && File.Exists(Environment.ProcessPath) |
| | 0 | 3814 | | ? Path.GetFullPath(Environment.ProcessPath) |
| | 0 | 3815 | | : Path.GetFullPath(fallbackPath); |
| | | 3816 | | |
| | | 3817 | | /// <summary> |
| | | 3818 | | /// Parses runner-specific global options and returns arguments to pass into command parsing. |
| | | 3819 | | /// </summary> |
| | | 3820 | | /// <param name="args">Raw process arguments.</param> |
| | | 3821 | | /// <returns>Normalized argument set and global option flags.</returns> |
| | | 3822 | | private static GlobalOptions ParseGlobalOptions(string[] args) |
| | | 3823 | | { |
| | 4 | 3824 | | var commandArgs = new List<string>(args.Length); |
| | 4 | 3825 | | var skipGalleryCheck = false; |
| | 4 | 3826 | | var passthroughArguments = false; |
| | | 3827 | | |
| | 28 | 3828 | | foreach (var arg in args) |
| | | 3829 | | { |
| | 10 | 3830 | | if (!passthroughArguments && arg is "--arguments" or "--") |
| | | 3831 | | { |
| | 1 | 3832 | | passthroughArguments = true; |
| | 1 | 3833 | | commandArgs.Add(arg); |
| | 1 | 3834 | | continue; |
| | | 3835 | | } |
| | | 3836 | | |
| | 9 | 3837 | | if (!passthroughArguments && IsNoCheckOption(arg)) |
| | | 3838 | | { |
| | 1 | 3839 | | skipGalleryCheck = true; |
| | 1 | 3840 | | continue; |
| | | 3841 | | } |
| | | 3842 | | |
| | 8 | 3843 | | commandArgs.Add(arg); |
| | | 3844 | | } |
| | | 3845 | | |
| | 4 | 3846 | | return new GlobalOptions([.. commandArgs], skipGalleryCheck); |
| | | 3847 | | } |
| | | 3848 | | |
| | | 3849 | | /// <summary> |
| | | 3850 | | /// Determines whether the token disables gallery version checks. |
| | | 3851 | | /// </summary> |
| | | 3852 | | /// <param name="token">Argument token.</param> |
| | | 3853 | | /// <returns>True when the token disables gallery checks.</returns> |
| | | 3854 | | private static bool IsNoCheckOption(string token) |
| | 25 | 3855 | | => string.Equals(token, NoCheckOption, StringComparison.OrdinalIgnoreCase) |
| | 25 | 3856 | | || string.Equals(token, NoCheckAliasOption, StringComparison.OrdinalIgnoreCase); |
| | | 3857 | | |
| | | 3858 | | /// <summary> |
| | | 3859 | | /// Validates install preconditions for module install operations. |
| | | 3860 | | /// </summary> |
| | | 3861 | | /// <param name="moduleRoot">Root folder for module versions.</param> |
| | | 3862 | | /// <param name="scopeToken">Module scope token for messaging.</param> |
| | | 3863 | | /// <param name="errorText">Validation error details when install should not proceed.</param> |
| | | 3864 | | /// <returns>True when install can proceed.</returns> |
| | | 3865 | | private static bool TryValidateInstallAction(string moduleRoot, string scopeToken, out string errorText) |
| | | 3866 | | { |
| | 1 | 3867 | | errorText = string.Empty; |
| | 1 | 3868 | | if (GetInstalledModuleRecords(moduleRoot).Count == 0) |
| | | 3869 | | { |
| | 0 | 3870 | | return true; |
| | | 3871 | | } |
| | | 3872 | | |
| | 1 | 3873 | | errorText = $"{ModuleName} module is already installed in {scopeToken} scope. Use '{ProductName} module update' |
| | 1 | 3874 | | return false; |
| | | 3875 | | } |
| | | 3876 | | |
| | | 3877 | | /// <summary> |
| | | 3878 | | /// Validates update preconditions for module update operations. |
| | | 3879 | | /// </summary> |
| | | 3880 | | /// <param name="moduleRoot">Root folder for module versions.</param> |
| | | 3881 | | /// <param name="packageVersion">Resolved target package version.</param> |
| | | 3882 | | /// <param name="force">When true, overwrite is allowed.</param> |
| | | 3883 | | /// <param name="errorText">Validation error details when update should not proceed.</param> |
| | | 3884 | | /// <returns>True when update can proceed.</returns> |
| | | 3885 | | private static bool TryValidateUpdateAction(string moduleRoot, string packageVersion, bool force, out string errorTe |
| | | 3886 | | { |
| | 2 | 3887 | | errorText = string.Empty; |
| | 2 | 3888 | | if (force) |
| | | 3889 | | { |
| | 1 | 3890 | | return true; |
| | | 3891 | | } |
| | | 3892 | | |
| | 1 | 3893 | | var destinationModuleDirectory = Path.Combine(moduleRoot, packageVersion); |
| | 1 | 3894 | | if (!Directory.Exists(destinationModuleDirectory)) |
| | | 3895 | | { |
| | 0 | 3896 | | return true; |
| | | 3897 | | } |
| | | 3898 | | |
| | 1 | 3899 | | errorText = $"Module version '{packageVersion}' is already installed at '{destinationModuleDirectory}'. Use '{Pr |
| | 1 | 3900 | | return false; |
| | | 3901 | | } |
| | | 3902 | | |
| | | 3903 | | /// <summary> |
| | | 3904 | | /// Reads the package version from a nupkg file payload. |
| | | 3905 | | /// </summary> |
| | | 3906 | | /// <param name="packageBytes">Package bytes.</param> |
| | | 3907 | | /// <param name="packageVersion">Parsed package version.</param> |
| | | 3908 | | /// <returns>True when a version was discovered.</returns> |
| | | 3909 | | private static bool TryReadPackageVersion(byte[] packageBytes, out string packageVersion) |
| | | 3910 | | { |
| | 2 | 3911 | | packageVersion = string.Empty; |
| | | 3912 | | |
| | 2 | 3913 | | using var stream = new MemoryStream(packageBytes, writable: false); |
| | 2 | 3914 | | using var archive = new ZipArchive(stream, ZipArchiveMode.Read, leaveOpen: false); |
| | 4 | 3915 | | var nuspecEntry = archive.Entries.FirstOrDefault(static entry => entry.FullName.EndsWith(".nuspec", StringCompar |
| | 2 | 3916 | | if (nuspecEntry is null) |
| | | 3917 | | { |
| | 1 | 3918 | | return false; |
| | | 3919 | | } |
| | | 3920 | | |
| | 1 | 3921 | | using var reader = new StreamReader(nuspecEntry.Open(), Encoding.UTF8, detectEncodingFromByteOrderMarks: true); |
| | 1 | 3922 | | var nuspecText = reader.ReadToEnd(); |
| | 1 | 3923 | | if (string.IsNullOrWhiteSpace(nuspecText)) |
| | | 3924 | | { |
| | 0 | 3925 | | return false; |
| | | 3926 | | } |
| | | 3927 | | |
| | 1 | 3928 | | var document = XDocument.Parse(nuspecText); |
| | 1 | 3929 | | var versionElement = document.Descendants() |
| | 5 | 3930 | | .FirstOrDefault(static element => string.Equals(element.Name.LocalName, "version", StringComparison.OrdinalI |
| | 1 | 3931 | | if (versionElement is null) |
| | | 3932 | | { |
| | 0 | 3933 | | return false; |
| | | 3934 | | } |
| | | 3935 | | |
| | 1 | 3936 | | packageVersion = versionElement.Value.Trim(); |
| | 1 | 3937 | | return TryNormalizeModuleVersion(packageVersion, out packageVersion); |
| | 2 | 3938 | | } |
| | | 3939 | | |
| | | 3940 | | /// <summary> |
| | | 3941 | | /// Maps package entry paths to relative module payload paths. |
| | | 3942 | | /// </summary> |
| | | 3943 | | /// <param name="entryPath">Original package entry path.</param> |
| | | 3944 | | /// <param name="relativePath">Mapped relative payload path.</param> |
| | | 3945 | | /// <returns>True when the entry belongs to module payload content.</returns> |
| | | 3946 | | private static bool TryGetPackagePayloadPath(string entryPath, out string relativePath) |
| | | 3947 | | { |
| | 5 | 3948 | | relativePath = string.Empty; |
| | 5 | 3949 | | if (string.IsNullOrWhiteSpace(entryPath)) |
| | | 3950 | | { |
| | 0 | 3951 | | return false; |
| | | 3952 | | } |
| | | 3953 | | |
| | 5 | 3954 | | var normalizedPath = entryPath.Replace('\\', '/').TrimStart('/'); |
| | 5 | 3955 | | if (string.IsNullOrWhiteSpace(normalizedPath) || normalizedPath.EndsWith('/')) |
| | | 3956 | | { |
| | 0 | 3957 | | return false; |
| | | 3958 | | } |
| | | 3959 | | |
| | 5 | 3960 | | if (normalizedPath.Equals("[Content_Types].xml", StringComparison.OrdinalIgnoreCase) |
| | 5 | 3961 | | || normalizedPath.StartsWith("_rels/", StringComparison.OrdinalIgnoreCase) |
| | 5 | 3962 | | || normalizedPath.StartsWith("package/", StringComparison.OrdinalIgnoreCase) |
| | 5 | 3963 | | || normalizedPath.EndsWith(".nuspec", StringComparison.OrdinalIgnoreCase)) |
| | | 3964 | | { |
| | 1 | 3965 | | return false; |
| | | 3966 | | } |
| | | 3967 | | |
| | 4 | 3968 | | if (normalizedPath.StartsWith("tools/", StringComparison.OrdinalIgnoreCase)) |
| | | 3969 | | { |
| | 4 | 3970 | | normalizedPath = normalizedPath["tools/".Length..]; |
| | | 3971 | | } |
| | 0 | 3972 | | else if (normalizedPath.StartsWith("content/", StringComparison.OrdinalIgnoreCase)) |
| | | 3973 | | { |
| | 0 | 3974 | | normalizedPath = normalizedPath["content/".Length..]; |
| | | 3975 | | } |
| | 0 | 3976 | | else if (normalizedPath.StartsWith("contentFiles/any/any/", StringComparison.OrdinalIgnoreCase)) |
| | | 3977 | | { |
| | 0 | 3978 | | normalizedPath = normalizedPath["contentFiles/any/any/".Length..]; |
| | | 3979 | | } |
| | | 3980 | | |
| | 4 | 3981 | | relativePath = normalizedPath.TrimStart('/'); |
| | 4 | 3982 | | return !string.IsNullOrWhiteSpace(relativePath); |
| | | 3983 | | } |
| | | 3984 | | |
| | | 3985 | | /// <summary> |
| | | 3986 | | /// Copies all files recursively from one directory to another. |
| | | 3987 | | /// </summary> |
| | | 3988 | | /// <param name="sourceDirectory">Source directory.</param> |
| | | 3989 | | /// <param name="destinationDirectory">Destination directory.</param> |
| | | 3990 | | /// <param name="showProgress">When true, writes interactive progress bars.</param> |
| | | 3991 | | private static void CopyDirectoryContents(string sourceDirectory, string destinationDirectory, bool showProgress) |
| | 2 | 3992 | | => CopyDirectoryContents(sourceDirectory, destinationDirectory, showProgress, "Installing files", exclusionPatte |
| | | 3993 | | |
| | | 3994 | | /// <summary> |
| | | 3995 | | /// Copies all files recursively from one directory to another. |
| | | 3996 | | /// </summary> |
| | | 3997 | | /// <param name="sourceDirectory">Source directory.</param> |
| | | 3998 | | /// <param name="destinationDirectory">Destination directory.</param> |
| | | 3999 | | /// <param name="showProgress">When true, writes interactive progress bars.</param> |
| | | 4000 | | /// <param name="progressLabel">Progress bar label for file copy operations.</param> |
| | | 4001 | | /// <param name="exclusionPatterns">Optional wildcard patterns (relative to <paramref name="sourceDirectory"/>) for |
| | | 4002 | | private static void CopyDirectoryContents( |
| | | 4003 | | string sourceDirectory, |
| | | 4004 | | string destinationDirectory, |
| | | 4005 | | bool showProgress, |
| | | 4006 | | string progressLabel, |
| | | 4007 | | IReadOnlyList<string>? exclusionPatterns) |
| | | 4008 | | { |
| | 23 | 4009 | | _ = Directory.CreateDirectory(destinationDirectory); |
| | | 4010 | | |
| | 23 | 4011 | | var exclusionRegexes = BuildCopyExclusionRegexes(exclusionPatterns); |
| | 23 | 4012 | | var sourceFilePaths = Directory.EnumerateFiles(sourceDirectory, "*", SearchOption.AllDirectories) |
| | 35 | 4013 | | .Where(sourceFilePath => !ShouldExcludeCopyFile(sourceDirectory, sourceFilePath, exclusionRegexes)) |
| | 23 | 4014 | | .ToList(); |
| | 23 | 4015 | | using var copyProgress = showProgress |
| | 23 | 4016 | | ? new ConsoleProgressBar(progressLabel, sourceFilePaths.Count, FormatFileProgressDetail) |
| | 23 | 4017 | | : null; |
| | 23 | 4018 | | var copiedFiles = 0; |
| | 23 | 4019 | | copyProgress?.Report(0); |
| | | 4020 | | |
| | 116 | 4021 | | foreach (var sourceFilePath in sourceFilePaths) |
| | | 4022 | | { |
| | 35 | 4023 | | var relativePath = Path.GetRelativePath(sourceDirectory, sourceFilePath); |
| | 35 | 4024 | | var destinationFilePath = Path.Combine(destinationDirectory, relativePath); |
| | 35 | 4025 | | var destinationFileDirectory = Path.GetDirectoryName(destinationFilePath); |
| | 35 | 4026 | | if (!string.IsNullOrWhiteSpace(destinationFileDirectory)) |
| | | 4027 | | { |
| | 35 | 4028 | | _ = Directory.CreateDirectory(destinationFileDirectory); |
| | | 4029 | | } |
| | | 4030 | | |
| | 35 | 4031 | | File.Copy(sourceFilePath, destinationFilePath, overwrite: true); |
| | 35 | 4032 | | copiedFiles++; |
| | 35 | 4033 | | copyProgress?.Report(copiedFiles); |
| | | 4034 | | } |
| | | 4035 | | |
| | 23 | 4036 | | copyProgress?.Complete(copiedFiles); |
| | 23 | 4037 | | } |
| | | 4038 | | |
| | | 4039 | | /// <summary> |
| | | 4040 | | /// Determines whether a source file should be excluded from a directory copy operation. |
| | | 4041 | | /// </summary> |
| | | 4042 | | /// <param name="sourceDirectory">Source directory root.</param> |
| | | 4043 | | /// <param name="sourceFilePath">Absolute source file path.</param> |
| | | 4044 | | /// <param name="exclusionRegexes">Compiled exclusion regexes.</param> |
| | | 4045 | | /// <returns>True when the file should be excluded.</returns> |
| | | 4046 | | private static bool ShouldExcludeCopyFile(string sourceDirectory, string sourceFilePath, IReadOnlyList<Regex> exclus |
| | | 4047 | | { |
| | 35 | 4048 | | if (exclusionRegexes.Count == 0) |
| | | 4049 | | { |
| | 27 | 4050 | | return false; |
| | | 4051 | | } |
| | | 4052 | | |
| | 8 | 4053 | | var relativePath = NormalizeCopyPath(Path.GetRelativePath(sourceDirectory, sourceFilePath)); |
| | 32 | 4054 | | return exclusionRegexes.Any(regex => regex.IsMatch(relativePath)); |
| | | 4055 | | } |
| | | 4056 | | |
| | | 4057 | | /// <summary> |
| | | 4058 | | /// Compiles wildcard exclusion patterns used by directory copy operations. |
| | | 4059 | | /// </summary> |
| | | 4060 | | /// <param name="exclusionPatterns">Wildcard exclusion patterns.</param> |
| | | 4061 | | /// <returns>Compiled regex list for path matching.</returns> |
| | | 4062 | | private static List<Regex> BuildCopyExclusionRegexes(IReadOnlyList<string>? exclusionPatterns) |
| | | 4063 | | { |
| | 23 | 4064 | | if (exclusionPatterns is null || exclusionPatterns.Count == 0) |
| | | 4065 | | { |
| | 18 | 4066 | | return []; |
| | | 4067 | | } |
| | | 4068 | | |
| | 5 | 4069 | | var regexOptions = RegexOptions.Compiled | RegexOptions.CultureInvariant; |
| | 5 | 4070 | | if (OperatingSystem.IsWindows()) |
| | | 4071 | | { |
| | 0 | 4072 | | regexOptions |= RegexOptions.IgnoreCase; |
| | | 4073 | | } |
| | | 4074 | | |
| | 5 | 4075 | | var regexes = new List<Regex>(exclusionPatterns.Count); |
| | 40 | 4076 | | foreach (var exclusionPattern in exclusionPatterns) |
| | | 4077 | | { |
| | 15 | 4078 | | var normalizedPattern = NormalizeCopyPath(exclusionPattern); |
| | 15 | 4079 | | if (string.IsNullOrWhiteSpace(normalizedPattern)) |
| | | 4080 | | { |
| | | 4081 | | continue; |
| | | 4082 | | } |
| | | 4083 | | |
| | 15 | 4084 | | var regexPattern = $"^{Regex.Escape(normalizedPattern).Replace(@"\*", ".*").Replace(@"\?", ".")}$"; |
| | 15 | 4085 | | regexes.Add(new Regex(regexPattern, regexOptions, TimeSpan.FromMilliseconds(250))); |
| | | 4086 | | } |
| | | 4087 | | |
| | 5 | 4088 | | return regexes; |
| | | 4089 | | } |
| | | 4090 | | |
| | | 4091 | | /// <summary> |
| | | 4092 | | /// Normalizes a relative path for wildcard matching. |
| | | 4093 | | /// </summary> |
| | | 4094 | | /// <param name="relativePath">Relative path or wildcard pattern.</param> |
| | | 4095 | | /// <returns>Normalized slash-separated path without leading dot prefixes.</returns> |
| | | 4096 | | private static string NormalizeCopyPath(string relativePath) |
| | | 4097 | | { |
| | 23 | 4098 | | if (string.IsNullOrWhiteSpace(relativePath)) |
| | | 4099 | | { |
| | 0 | 4100 | | return string.Empty; |
| | | 4101 | | } |
| | | 4102 | | |
| | 23 | 4103 | | var normalizedPath = relativePath.Trim().Replace('\\', '/'); |
| | 23 | 4104 | | while (normalizedPath.StartsWith("./", StringComparison.Ordinal)) |
| | | 4105 | | { |
| | 0 | 4106 | | normalizedPath = normalizedPath[2..]; |
| | | 4107 | | } |
| | | 4108 | | |
| | 23 | 4109 | | return normalizedPath.TrimStart('/'); |
| | | 4110 | | } |
| | | 4111 | | |
| | | 4112 | | /// <summary> |
| | | 4113 | | /// Removes all installed module files and folders for the selected scope. |
| | | 4114 | | /// </summary> |
| | | 4115 | | /// <param name="moduleRoot">Module root directory to remove.</param> |
| | | 4116 | | /// <param name="showProgress">When true, writes interactive progress bars.</param> |
| | | 4117 | | /// <param name="errorText">Error details when removal fails.</param> |
| | | 4118 | | /// <returns>True when removal succeeds.</returns> |
| | | 4119 | | private static bool TryRemoveInstalledModule(string moduleRoot, bool showProgress, out string errorText) |
| | | 4120 | | { |
| | 2 | 4121 | | errorText = string.Empty; |
| | | 4122 | | |
| | 2 | 4123 | | if (!Directory.Exists(moduleRoot)) |
| | | 4124 | | { |
| | 1 | 4125 | | return true; |
| | | 4126 | | } |
| | | 4127 | | |
| | | 4128 | | try |
| | | 4129 | | { |
| | 1 | 4130 | | var filePaths = Directory.EnumerateFiles(moduleRoot, "*", SearchOption.AllDirectories).ToList(); |
| | 1 | 4131 | | using var fileProgress = showProgress |
| | 1 | 4132 | | ? new ConsoleProgressBar("Removing files", filePaths.Count, FormatFileProgressDetail) |
| | 1 | 4133 | | : null; |
| | 1 | 4134 | | var removedFiles = 0; |
| | 1 | 4135 | | fileProgress?.Report(0); |
| | | 4136 | | |
| | 6 | 4137 | | foreach (var filePath in filePaths) |
| | | 4138 | | { |
| | | 4139 | | try |
| | | 4140 | | { |
| | 2 | 4141 | | File.SetAttributes(filePath, FileAttributes.Normal); |
| | 2 | 4142 | | } |
| | 0 | 4143 | | catch |
| | | 4144 | | { |
| | | 4145 | | // Best-effort normalization; delete may still succeed without changing attributes. |
| | 0 | 4146 | | } |
| | | 4147 | | |
| | 2 | 4148 | | File.Delete(filePath); |
| | 2 | 4149 | | removedFiles++; |
| | 2 | 4150 | | fileProgress?.Report(removedFiles); |
| | | 4151 | | } |
| | | 4152 | | |
| | 1 | 4153 | | fileProgress?.Complete(removedFiles); |
| | | 4154 | | |
| | 1 | 4155 | | var directoryPaths = Directory.EnumerateDirectories(moduleRoot, "*", SearchOption.AllDirectories) |
| | 2 | 4156 | | .OrderByDescending(path => path.Length) |
| | 1 | 4157 | | .ToList(); |
| | | 4158 | | |
| | 1 | 4159 | | using var directoryProgress = showProgress |
| | 1 | 4160 | | ? new ConsoleProgressBar("Removing folders", directoryPaths.Count + 1, FormatFileProgressDetail) |
| | 1 | 4161 | | : null; |
| | 1 | 4162 | | var removedDirectories = 0; |
| | 1 | 4163 | | directoryProgress?.Report(0); |
| | | 4164 | | |
| | 6 | 4165 | | foreach (var directoryPath in directoryPaths) |
| | | 4166 | | { |
| | 2 | 4167 | | Directory.Delete(directoryPath, recursive: false); |
| | 2 | 4168 | | removedDirectories++; |
| | 2 | 4169 | | directoryProgress?.Report(removedDirectories); |
| | | 4170 | | } |
| | | 4171 | | |
| | 1 | 4172 | | Directory.Delete(moduleRoot, recursive: false); |
| | 1 | 4173 | | removedDirectories++; |
| | 1 | 4174 | | directoryProgress?.Report(removedDirectories); |
| | 1 | 4175 | | directoryProgress?.Complete(removedDirectories); |
| | | 4176 | | |
| | 1 | 4177 | | return true; |
| | | 4178 | | } |
| | 0 | 4179 | | catch (Exception ex) |
| | | 4180 | | { |
| | 0 | 4181 | | errorText = ex.Message; |
| | 0 | 4182 | | return false; |
| | | 4183 | | } |
| | 1 | 4184 | | } |
| | | 4185 | | |
| | | 4186 | | /// <summary> |
| | | 4187 | | /// Copies one stream into another while reporting transfer progress. |
| | | 4188 | | /// </summary> |
| | | 4189 | | /// <param name="source">Source stream.</param> |
| | | 4190 | | /// <param name="destination">Destination stream.</param> |
| | | 4191 | | /// <param name="progress">Optional progress reporter.</param> |
| | | 4192 | | private static void CopyStreamWithProgress(Stream source, Stream destination, ConsoleProgressBar? progress) |
| | | 4193 | | { |
| | 2 | 4194 | | var buffer = new byte[81920]; |
| | 2 | 4195 | | var totalCopied = 0L; |
| | 2 | 4196 | | progress?.Report(0); |
| | | 4197 | | |
| | 0 | 4198 | | while (true) |
| | | 4199 | | { |
| | 3 | 4200 | | var bytesRead = source.Read(buffer, 0, buffer.Length); |
| | 3 | 4201 | | if (bytesRead <= 0) |
| | | 4202 | | { |
| | | 4203 | | break; |
| | | 4204 | | } |
| | | 4205 | | |
| | 1 | 4206 | | destination.Write(buffer, 0, bytesRead); |
| | 1 | 4207 | | totalCopied += bytesRead; |
| | 1 | 4208 | | progress?.Report(totalCopied); |
| | | 4209 | | } |
| | | 4210 | | |
| | 2 | 4211 | | progress?.Complete(totalCopied); |
| | 2 | 4212 | | } |
| | | 4213 | | |
| | | 4214 | | /// <summary> |
| | | 4215 | | /// Formats byte transfer progress details. |
| | | 4216 | | /// </summary> |
| | | 4217 | | /// <param name="current">Current transferred bytes.</param> |
| | | 4218 | | /// <param name="total">Total bytes when known.</param> |
| | | 4219 | | /// <returns>Formatted progress text.</returns> |
| | | 4220 | | private static string FormatByteProgressDetail(long current, long? total) |
| | 1 | 4221 | | => total.HasValue |
| | 1 | 4222 | | ? $"{FormatByteSize(current)} / {FormatByteSize(total.Value)}" |
| | 1 | 4223 | | : FormatByteSize(current); |
| | | 4224 | | |
| | | 4225 | | /// <summary> |
| | | 4226 | | /// Formats file count progress details. |
| | | 4227 | | /// </summary> |
| | | 4228 | | /// <param name="current">Current processed file count.</param> |
| | | 4229 | | /// <param name="total">Total file count when known.</param> |
| | | 4230 | | /// <returns>Formatted progress text.</returns> |
| | | 4231 | | private static string FormatFileProgressDetail(long current, long? total) |
| | 1 | 4232 | | => total.HasValue |
| | 1 | 4233 | | ? $"{current}/{total.Value} files" |
| | 1 | 4234 | | : $"{current} files"; |
| | | 4235 | | |
| | | 4236 | | /// <summary> |
| | | 4237 | | /// Formats progress details for service bundle preparation steps. |
| | | 4238 | | /// </summary> |
| | | 4239 | | /// <param name="current">Current completed step number.</param> |
| | | 4240 | | /// <param name="total">Total step count.</param> |
| | | 4241 | | /// <returns>Formatted step progress detail.</returns> |
| | | 4242 | | private static string FormatServiceBundleStepProgressDetail(long current, long? total) |
| | | 4243 | | { |
| | 1 | 4244 | | var stepLabel = current switch |
| | 1 | 4245 | | { |
| | 0 | 4246 | | <= 0 => "initializing", |
| | 0 | 4247 | | 1 => "creating folders", |
| | 0 | 4248 | | 2 => "copying runtime", |
| | 1 | 4249 | | 3 => "copying module", |
| | 0 | 4250 | | _ => "copying script", |
| | 1 | 4251 | | }; |
| | | 4252 | | |
| | 1 | 4253 | | return total.HasValue |
| | 1 | 4254 | | ? $"step {Math.Min(current, total.Value)}/{total.Value} ({stepLabel})" |
| | 1 | 4255 | | : $"step {current} ({stepLabel})"; |
| | | 4256 | | } |
| | | 4257 | | |
| | | 4258 | | /// <summary> |
| | | 4259 | | /// Formats a byte value to a readable unit string. |
| | | 4260 | | /// </summary> |
| | | 4261 | | /// <param name="bytes">Byte count.</param> |
| | | 4262 | | /// <returns>Human-readable byte text.</returns> |
| | | 4263 | | private static string FormatByteSize(long bytes) |
| | | 4264 | | { |
| | 3 | 4265 | | var unitIndex = 0; |
| | 3 | 4266 | | var value = (double)Math.Max(0, bytes); |
| | 3 | 4267 | | var units = new[] { "B", "KB", "MB", "GB", "TB" }; |
| | | 4268 | | |
| | 6 | 4269 | | while (value >= 1024d && unitIndex < units.Length - 1) |
| | | 4270 | | { |
| | 3 | 4271 | | value /= 1024d; |
| | 3 | 4272 | | unitIndex++; |
| | | 4273 | | } |
| | | 4274 | | |
| | 3 | 4275 | | return unitIndex == 0 |
| | 3 | 4276 | | ? $"{bytes} {units[unitIndex]}" |
| | 3 | 4277 | | : $"{value:0.##} {units[unitIndex]}"; |
| | | 4278 | | } |
| | | 4279 | | |
| | | 4280 | | /// <summary> |
| | | 4281 | | /// Prints an update warning when a newer PowerShell Gallery version exists. |
| | | 4282 | | /// </summary> |
| | | 4283 | | /// <param name="moduleManifestPath">Resolved local module manifest path.</param> |
| | | 4284 | | /// <param name="logPath">Optional log path for warning output; when omitted, warning is written to stderr.</param> |
| | | 4285 | | private static void WarnIfNewerGalleryVersionExists(string moduleManifestPath, string? logPath = null) |
| | | 4286 | | { |
| | 0 | 4287 | | if (!TryGetLatestInstalledModuleVersionText(ModuleStorageScope.Local, out var installedVersion) |
| | 0 | 4288 | | && !TryGetLatestInstalledModuleVersionText(ModuleStorageScope.Global, out installedVersion) |
| | 0 | 4289 | | && !TryGetInstalledModuleVersionText(moduleManifestPath, out installedVersion)) |
| | | 4290 | | { |
| | 0 | 4291 | | return; |
| | | 4292 | | } |
| | | 4293 | | |
| | 0 | 4294 | | if (!TryGetLatestGalleryVersionString(out var galleryVersion, out _)) |
| | | 4295 | | { |
| | 0 | 4296 | | return; |
| | | 4297 | | } |
| | | 4298 | | |
| | 0 | 4299 | | if (CompareModuleVersionValues(galleryVersion, installedVersion) <= 0) |
| | | 4300 | | { |
| | 0 | 4301 | | return; |
| | | 4302 | | } |
| | | 4303 | | |
| | 0 | 4304 | | var warningMessage = |
| | 0 | 4305 | | $"WARNING: A newer {ModuleName} module is available on PowerShell Gallery ({galleryVersion}). " |
| | 0 | 4306 | | + $"Current version: {installedVersion}. Use '{ProductName} module update' or {NoCheckOption} to suppress th |
| | | 4307 | | |
| | 0 | 4308 | | WriteWarningToLogOrConsole(warningMessage, logPath); |
| | 0 | 4309 | | } |
| | | 4310 | | |
| | | 4311 | | /// <summary> |
| | | 4312 | | /// Writes a warning to a configured log file when available; otherwise stderr. |
| | | 4313 | | /// </summary> |
| | | 4314 | | /// <param name="message">Warning message.</param> |
| | | 4315 | | /// <param name="logPath">Optional log path.</param> |
| | | 4316 | | private static void WriteWarningToLogOrConsole(string message, string? logPath) |
| | | 4317 | | { |
| | 1 | 4318 | | switch (string.IsNullOrWhiteSpace(logPath)) |
| | | 4319 | | { |
| | | 4320 | | case true: |
| | 0 | 4321 | | Console.Error.WriteLine(message); |
| | 0 | 4322 | | return; |
| | | 4323 | | |
| | | 4324 | | case false: |
| | | 4325 | | try |
| | | 4326 | | { |
| | 1 | 4327 | | var resolvedPath = NormalizeServiceLogPath(logPath, defaultFileName: "kestrun-tool-service.log"); |
| | 1 | 4328 | | var directory = Path.GetDirectoryName(resolvedPath); |
| | 1 | 4329 | | if (!string.IsNullOrWhiteSpace(directory)) |
| | | 4330 | | { |
| | 1 | 4331 | | _ = Directory.CreateDirectory(directory); |
| | | 4332 | | } |
| | | 4333 | | |
| | 1 | 4334 | | File.AppendAllText(resolvedPath, $"{DateTime.UtcNow:O} {message}{Environment.NewLine}", Encoding.UTF |
| | 1 | 4335 | | return; |
| | | 4336 | | } |
| | 0 | 4337 | | catch |
| | | 4338 | | { |
| | 0 | 4339 | | Console.Error.WriteLine(message); |
| | 0 | 4340 | | return; |
| | | 4341 | | } |
| | | 4342 | | } |
| | 1 | 4343 | | } |
| | | 4344 | | |
| | | 4345 | | /// <summary> |
| | | 4346 | | /// Attempts to read the latest installed module version text from a selected scope. |
| | | 4347 | | /// </summary> |
| | | 4348 | | /// <param name="scope">Module storage scope.</param> |
| | | 4349 | | /// <param name="versionText">Installed semantic version text when available.</param> |
| | | 4350 | | /// <returns>True when an installed version was found in the scope.</returns> |
| | | 4351 | | private static bool TryGetLatestInstalledModuleVersionText(ModuleStorageScope scope, out string versionText) |
| | | 4352 | | { |
| | 0 | 4353 | | var modulePath = GetPowerShellModulePath(scope); |
| | 0 | 4354 | | var moduleRoot = Path.Combine(modulePath, ModuleName); |
| | 0 | 4355 | | return TryGetLatestInstalledModuleVersionTextFromModuleRoot(moduleRoot, out versionText); |
| | | 4356 | | } |
| | | 4357 | | |
| | | 4358 | | /// <summary> |
| | | 4359 | | /// Attempts to read the latest installed module version text from a specific module root path. |
| | | 4360 | | /// </summary> |
| | | 4361 | | /// <param name="moduleRoot">Root directory containing versioned module folders.</param> |
| | | 4362 | | /// <param name="versionText">Installed semantic version text when available.</param> |
| | | 4363 | | /// <returns>True when an installed version was found in the module root.</returns> |
| | | 4364 | | private static bool TryGetLatestInstalledModuleVersionTextFromModuleRoot(string moduleRoot, out string versionText) |
| | | 4365 | | { |
| | 1 | 4366 | | versionText = string.Empty; |
| | 1 | 4367 | | var records = GetInstalledModuleRecords(moduleRoot); |
| | 1 | 4368 | | if (records.Count == 0) |
| | | 4369 | | { |
| | 0 | 4370 | | return false; |
| | | 4371 | | } |
| | | 4372 | | |
| | 1 | 4373 | | versionText = records[0].Version; |
| | 1 | 4374 | | return !string.IsNullOrWhiteSpace(versionText); |
| | | 4375 | | } |
| | | 4376 | | |
| | | 4377 | | /// <summary> |
| | | 4378 | | /// Attempts to read the local Kestrun module semantic version from manifest metadata. |
| | | 4379 | | /// </summary> |
| | | 4380 | | /// <param name="moduleManifestPath">Path to Kestrun.psd1.</param> |
| | | 4381 | | /// <param name="versionText">Installed semantic version text when available.</param> |
| | | 4382 | | /// <returns>True when a version was read.</returns> |
| | | 4383 | | private static bool TryGetInstalledModuleVersionText(string moduleManifestPath, out string versionText) |
| | | 4384 | | { |
| | 1 | 4385 | | versionText = string.Empty; |
| | | 4386 | | |
| | 1 | 4387 | | if (TryReadModuleSemanticVersionFromManifest(moduleManifestPath, out var manifestVersionText)) |
| | | 4388 | | { |
| | 0 | 4389 | | versionText = manifestVersionText; |
| | 0 | 4390 | | return true; |
| | | 4391 | | } |
| | | 4392 | | |
| | 1 | 4393 | | var versionDirectory = Path.GetFileName(Path.GetDirectoryName(moduleManifestPath)); |
| | 1 | 4394 | | if (TryNormalizeModuleVersion(versionDirectory, out var normalizedVersionDirectory)) |
| | | 4395 | | { |
| | 1 | 4396 | | versionText = normalizedVersionDirectory; |
| | 1 | 4397 | | return true; |
| | | 4398 | | } |
| | | 4399 | | |
| | 0 | 4400 | | return false; |
| | | 4401 | | } |
| | | 4402 | | |
| | | 4403 | | /// <summary> |
| | | 4404 | | /// Attempts to read module semantic version (including prerelease) from a PowerShell module manifest file. |
| | | 4405 | | /// </summary> |
| | | 4406 | | /// <param name="manifestPath">Manifest path.</param> |
| | | 4407 | | /// <param name="versionText">Semantic version text when present.</param> |
| | | 4408 | | /// <returns>True when semantic version was discovered.</returns> |
| | | 4409 | | private static bool TryReadModuleSemanticVersionFromManifest(string manifestPath, out string versionText) |
| | | 4410 | | { |
| | 9 | 4411 | | versionText = string.Empty; |
| | 9 | 4412 | | if (!TryReadModuleVersionFromManifest(manifestPath, out var baseVersion)) |
| | | 4413 | | { |
| | 3 | 4414 | | return false; |
| | | 4415 | | } |
| | | 4416 | | |
| | 6 | 4417 | | var semanticVersion = baseVersion; |
| | | 4418 | | try |
| | | 4419 | | { |
| | 6 | 4420 | | var content = File.ReadAllText(manifestPath); |
| | 6 | 4421 | | var prereleaseMatch = ModulePrereleasePatternRegex.Match(content); |
| | 6 | 4422 | | if (prereleaseMatch.Success) |
| | | 4423 | | { |
| | 2 | 4424 | | var prereleaseValue = prereleaseMatch.Groups["value"].Value.Trim(); |
| | 2 | 4425 | | if (!string.IsNullOrWhiteSpace(prereleaseValue) |
| | 2 | 4426 | | && !baseVersion.Contains('-', StringComparison.Ordinal) |
| | 2 | 4427 | | && !baseVersion.Contains('+', StringComparison.Ordinal)) |
| | | 4428 | | { |
| | 2 | 4429 | | semanticVersion = $"{baseVersion}-{prereleaseValue}"; |
| | | 4430 | | } |
| | | 4431 | | } |
| | 6 | 4432 | | } |
| | 0 | 4433 | | catch |
| | | 4434 | | { |
| | | 4435 | | // Fall back to ModuleVersion when Prerelease inspection fails. |
| | 0 | 4436 | | } |
| | | 4437 | | |
| | 6 | 4438 | | versionText = semanticVersion; |
| | 6 | 4439 | | return !string.IsNullOrWhiteSpace(versionText); |
| | | 4440 | | } |
| | | 4441 | | |
| | | 4442 | | /// <summary> |
| | | 4443 | | /// Attempts to query the latest Kestrun module version string from PowerShell Gallery. |
| | | 4444 | | /// </summary> |
| | | 4445 | | /// <param name="version">Latest gallery version string when available.</param> |
| | | 4446 | | /// <param name="errorText">Error details when discovery fails.</param> |
| | | 4447 | | /// <returns>True when latest gallery version was discovered.</returns> |
| | | 4448 | | private static bool TryGetLatestGalleryVersionString(out string version, out string errorText) |
| | 0 | 4449 | | => TryGetLatestGalleryVersionStringFromClient(GalleryHttpClient, out version, out errorText); |
| | | 4450 | | |
| | | 4451 | | /// <summary> |
| | | 4452 | | /// Attempts to query the latest Kestrun module version string from PowerShell Gallery using the specified HTTP clie |
| | | 4453 | | /// </summary> |
| | | 4454 | | /// <param name="httpClient">HTTP client used for the gallery request.</param> |
| | | 4455 | | /// <param name="version">Latest gallery version string when available.</param> |
| | | 4456 | | /// <param name="errorText">Error details when discovery fails.</param> |
| | | 4457 | | /// <returns>True when latest gallery version was discovered.</returns> |
| | | 4458 | | private static bool TryGetLatestGalleryVersionStringFromClient(HttpClient httpClient, out string version, out string |
| | | 4459 | | { |
| | 1 | 4460 | | version = string.Empty; |
| | 1 | 4461 | | if (!TryGetGalleryModuleVersionsFromClient(httpClient, out var versions, out errorText)) |
| | | 4462 | | { |
| | 0 | 4463 | | return false; |
| | | 4464 | | } |
| | | 4465 | | |
| | 1 | 4466 | | var latestVersion = versions[0]; |
| | 6 | 4467 | | for (var index = 1; index < versions.Count; index++) |
| | | 4468 | | { |
| | 2 | 4469 | | if (CompareModuleVersionValues(versions[index], latestVersion) > 0) |
| | | 4470 | | { |
| | 1 | 4471 | | latestVersion = versions[index]; |
| | | 4472 | | } |
| | | 4473 | | } |
| | | 4474 | | |
| | 1 | 4475 | | version = latestVersion; |
| | 1 | 4476 | | return !string.IsNullOrWhiteSpace(version); |
| | | 4477 | | } |
| | | 4478 | | |
| | | 4479 | | /// <summary> |
| | | 4480 | | /// Queries all available Kestrun module versions from PowerShell Gallery. |
| | | 4481 | | /// </summary> |
| | | 4482 | | /// <param name="versions">Discovered gallery versions.</param> |
| | | 4483 | | /// <param name="errorText">Error details when discovery fails.</param> |
| | | 4484 | | /// <returns>True when at least one version was discovered.</returns> |
| | | 4485 | | private static bool TryGetGalleryModuleVersions(out List<string> versions, out string errorText) |
| | 0 | 4486 | | => TryGetGalleryModuleVersionsFromClient(GalleryHttpClient, out versions, out errorText); |
| | | 4487 | | |
| | | 4488 | | /// <summary> |
| | | 4489 | | /// Queries all available Kestrun module versions from PowerShell Gallery using the specified HTTP client. |
| | | 4490 | | /// </summary> |
| | | 4491 | | /// <param name="httpClient">HTTP client used for the gallery request.</param> |
| | | 4492 | | /// <param name="versions">Discovered gallery versions.</param> |
| | | 4493 | | /// <param name="errorText">Error details when discovery fails.</param> |
| | | 4494 | | /// <returns>True when at least one version was discovered.</returns> |
| | | 4495 | | private static bool TryGetGalleryModuleVersionsFromClient(HttpClient httpClient, out List<string> versions, out stri |
| | | 4496 | | { |
| | 3 | 4497 | | versions = []; |
| | 3 | 4498 | | errorText = string.Empty; |
| | | 4499 | | |
| | | 4500 | | try |
| | | 4501 | | { |
| | 3 | 4502 | | var requestUri = $"{PowerShellGalleryApiBaseUri}/FindPackagesById()?id='{Uri.EscapeDataString(ModuleName)}'" |
| | 3 | 4503 | | using var response = httpClient.GetAsync(requestUri).GetAwaiter().GetResult(); |
| | 3 | 4504 | | if (!response.IsSuccessStatusCode) |
| | | 4505 | | { |
| | 1 | 4506 | | var reason = string.IsNullOrWhiteSpace(response.ReasonPhrase) |
| | 1 | 4507 | | ? "Unknown error" |
| | 1 | 4508 | | : response.ReasonPhrase; |
| | 1 | 4509 | | errorText = $"PowerShell Gallery request failed with HTTP {(int)response.StatusCode} ({reason})."; |
| | 1 | 4510 | | return false; |
| | | 4511 | | } |
| | | 4512 | | |
| | 2 | 4513 | | var content = response.Content.ReadAsStringAsync().GetAwaiter().GetResult(); |
| | 2 | 4514 | | if (string.IsNullOrWhiteSpace(content)) |
| | | 4515 | | { |
| | 0 | 4516 | | errorText = "PowerShell Gallery response was empty."; |
| | 0 | 4517 | | return false; |
| | | 4518 | | } |
| | | 4519 | | |
| | 2 | 4520 | | return TryParseGalleryModuleVersions(content, out versions, out errorText); |
| | | 4521 | | } |
| | 0 | 4522 | | catch (Exception ex) |
| | | 4523 | | { |
| | 0 | 4524 | | errorText = ex.Message; |
| | 0 | 4525 | | return false; |
| | | 4526 | | } |
| | 3 | 4527 | | } |
| | | 4528 | | |
| | | 4529 | | /// <summary> |
| | | 4530 | | /// Parses gallery feed XML and extracts module versions. |
| | | 4531 | | /// </summary> |
| | | 4532 | | /// <param name="content">Gallery feed XML payload.</param> |
| | | 4533 | | /// <param name="versions">Discovered gallery versions.</param> |
| | | 4534 | | /// <param name="errorText">Error details when parsing fails.</param> |
| | | 4535 | | /// <returns>True when at least one version was discovered.</returns> |
| | | 4536 | | private static bool TryParseGalleryModuleVersions(string content, out List<string> versions, out string errorText) |
| | | 4537 | | { |
| | 3 | 4538 | | versions = []; |
| | 3 | 4539 | | errorText = string.Empty; |
| | | 4540 | | |
| | | 4541 | | try |
| | | 4542 | | { |
| | 3 | 4543 | | var document = XDocument.Parse(content); |
| | 2 | 4544 | | var discoveredVersions = document.Descendants() |
| | 16 | 4545 | | .Where(static element => string.Equals(element.Name.LocalName, "Version", StringComparison.OrdinalIgnore |
| | 7 | 4546 | | .Select(static element => element.Value.Trim()) |
| | 7 | 4547 | | .Where(static versionText => !string.IsNullOrWhiteSpace(versionText)) |
| | 2 | 4548 | | .Distinct(StringComparer.OrdinalIgnoreCase) |
| | 2 | 4549 | | .ToList(); |
| | | 4550 | | |
| | 2 | 4551 | | if (discoveredVersions.Count == 0) |
| | | 4552 | | { |
| | 0 | 4553 | | errorText = $"Module '{ModuleName}' was not found on PowerShell Gallery."; |
| | 0 | 4554 | | return false; |
| | | 4555 | | } |
| | | 4556 | | |
| | 2 | 4557 | | versions = discoveredVersions; |
| | 2 | 4558 | | return true; |
| | | 4559 | | } |
| | 1 | 4560 | | catch (Exception ex) |
| | | 4561 | | { |
| | 1 | 4562 | | errorText = ex.Message; |
| | 1 | 4563 | | return false; |
| | | 4564 | | } |
| | 3 | 4565 | | } |
| | | 4566 | | |
| | | 4567 | | /// <summary> |
| | | 4568 | | /// Creates the shared HTTP client used for PowerShell Gallery requests. |
| | | 4569 | | /// </summary> |
| | | 4570 | | /// <returns>Configured HTTP client instance.</returns> |
| | | 4571 | | private static HttpClient CreateGalleryHttpClient() |
| | | 4572 | | { |
| | 1 | 4573 | | var client = new HttpClient |
| | 1 | 4574 | | { |
| | 1 | 4575 | | Timeout = TimeSpan.FromSeconds(60), |
| | 1 | 4576 | | }; |
| | | 4577 | | |
| | 1 | 4578 | | client.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue(ProductName, "1.0")); |
| | 1 | 4579 | | return client; |
| | | 4580 | | } |
| | | 4581 | | |
| | | 4582 | | /// <summary> |
| | | 4583 | | /// Creates the shared HTTP client used for service content-root archive downloads. |
| | | 4584 | | /// </summary> |
| | | 4585 | | /// <returns>Configured HTTP client instance.</returns> |
| | | 4586 | | private static HttpClient CreateServiceContentRootHttpClient() |
| | | 4587 | | { |
| | 1 | 4588 | | var client = new HttpClient |
| | 1 | 4589 | | { |
| | 1 | 4590 | | Timeout = TimeSpan.FromMinutes(5), |
| | 1 | 4591 | | }; |
| | | 4592 | | |
| | 1 | 4593 | | client.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue(ProductName, "1.0")); |
| | 1 | 4594 | | return client; |
| | | 4595 | | } |
| | | 4596 | | |
| | | 4597 | | /// <summary> |
| | | 4598 | | /// Parses a module version value into a comparable <see cref="Version"/> instance. |
| | | 4599 | | /// </summary> |
| | | 4600 | | /// <param name="rawValue">Raw version string.</param> |
| | | 4601 | | /// <param name="version">Parsed version.</param> |
| | | 4602 | | /// <returns>True when parsing succeeds.</returns> |
| | | 4603 | | private static bool TryParseVersionValue(string? rawValue, out Version version) |
| | | 4604 | | { |
| | 16 | 4605 | | version = new Version(0, 0); |
| | 16 | 4606 | | if (string.IsNullOrWhiteSpace(rawValue)) |
| | | 4607 | | { |
| | 0 | 4608 | | return false; |
| | | 4609 | | } |
| | | 4610 | | |
| | 16 | 4611 | | var normalized = rawValue.Trim(); |
| | 16 | 4612 | | var suffixIndex = normalized.IndexOfAny(['-', '+']); |
| | 16 | 4613 | | if (suffixIndex >= 0) |
| | | 4614 | | { |
| | 6 | 4615 | | normalized = normalized[..suffixIndex]; |
| | | 4616 | | } |
| | | 4617 | | |
| | 16 | 4618 | | if (!Version.TryParse(normalized, out var parsedVersion) || parsedVersion is null) |
| | | 4619 | | { |
| | 1 | 4620 | | return false; |
| | | 4621 | | } |
| | | 4622 | | |
| | 15 | 4623 | | version = parsedVersion; |
| | 15 | 4624 | | return true; |
| | | 4625 | | } |
| | | 4626 | | |
| | | 4627 | | /// <summary> |
| | | 4628 | | /// Normalizes a module version value to the stable numeric folder format used by PowerShell module installs. |
| | | 4629 | | /// </summary> |
| | | 4630 | | /// <param name="rawValue">Raw version token that may include prerelease/build suffixes.</param> |
| | | 4631 | | /// <param name="normalizedVersion">Normalized numeric version text.</param> |
| | | 4632 | | /// <returns>True when normalization succeeds.</returns> |
| | | 4633 | | private static bool TryNormalizeModuleVersion(string? rawValue, out string normalizedVersion) |
| | | 4634 | | { |
| | 5 | 4635 | | normalizedVersion = string.Empty; |
| | 5 | 4636 | | if (!TryParseVersionValue(rawValue, out var parsedVersion)) |
| | | 4637 | | { |
| | 0 | 4638 | | return false; |
| | | 4639 | | } |
| | | 4640 | | |
| | 5 | 4641 | | normalizedVersion = parsedVersion.ToString(); |
| | 5 | 4642 | | return true; |
| | | 4643 | | } |
| | | 4644 | | |
| | | 4645 | | /// <summary> |
| | | 4646 | | /// Tries to parse a module storage scope token. |
| | | 4647 | | /// </summary> |
| | | 4648 | | /// <param name="scopeToken">Scope token.</param> |
| | | 4649 | | /// <param name="scope">Parsed scope value.</param> |
| | | 4650 | | /// <returns>True when parsing succeeds.</returns> |
| | | 4651 | | private static bool TryParseModuleScope(string? scopeToken, out ModuleStorageScope scope) |
| | | 4652 | | { |
| | 3 | 4653 | | scope = ModuleStorageScope.Local; |
| | 3 | 4654 | | if (string.IsNullOrWhiteSpace(scopeToken)) |
| | | 4655 | | { |
| | 0 | 4656 | | return false; |
| | | 4657 | | } |
| | | 4658 | | |
| | 3 | 4659 | | if (string.Equals(scopeToken, ModuleScopeLocalValue, StringComparison.OrdinalIgnoreCase)) |
| | | 4660 | | { |
| | 0 | 4661 | | scope = ModuleStorageScope.Local; |
| | 0 | 4662 | | return true; |
| | | 4663 | | } |
| | | 4664 | | |
| | 3 | 4665 | | if (string.Equals(scopeToken, ModuleScopeGlobalValue, StringComparison.OrdinalIgnoreCase)) |
| | | 4666 | | { |
| | 2 | 4667 | | scope = ModuleStorageScope.Global; |
| | 2 | 4668 | | return true; |
| | | 4669 | | } |
| | | 4670 | | |
| | 1 | 4671 | | return false; |
| | | 4672 | | } |
| | | 4673 | | |
| | | 4674 | | /// <summary> |
| | | 4675 | | /// Gets a stable scope token for messages and help text. |
| | | 4676 | | /// </summary> |
| | | 4677 | | /// <param name="scope">Module storage scope.</param> |
| | | 4678 | | /// <returns>Normalized scope token.</returns> |
| | | 4679 | | private static string GetScopeToken(ModuleStorageScope scope) |
| | 2 | 4680 | | => scope == ModuleStorageScope.Global ? ModuleScopeGlobalValue : ModuleScopeLocalValue; |
| | | 4681 | | |
| | | 4682 | | /// <summary> |
| | | 4683 | | /// Compares two module version strings. |
| | | 4684 | | /// </summary> |
| | | 4685 | | /// <param name="leftVersion">Left version.</param> |
| | | 4686 | | /// <param name="rightVersion">Right version.</param> |
| | | 4687 | | /// <returns>Comparison result compatible with <see cref="IComparer{T}"/>.</returns> |
| | | 4688 | | private static int CompareModuleVersionValues(string? leftVersion, string? rightVersion) |
| | | 4689 | | { |
| | 4 | 4690 | | if (ReferenceEquals(leftVersion, rightVersion)) |
| | | 4691 | | { |
| | 0 | 4692 | | return 0; |
| | | 4693 | | } |
| | | 4694 | | |
| | 4 | 4695 | | if (string.IsNullOrWhiteSpace(leftVersion)) |
| | | 4696 | | { |
| | 0 | 4697 | | return -1; |
| | | 4698 | | } |
| | | 4699 | | |
| | 4 | 4700 | | if (string.IsNullOrWhiteSpace(rightVersion)) |
| | | 4701 | | { |
| | 0 | 4702 | | return 1; |
| | | 4703 | | } |
| | | 4704 | | |
| | 4 | 4705 | | if (TryParseVersionValue(leftVersion, out var leftParsed) |
| | 4 | 4706 | | && TryParseVersionValue(rightVersion, out var rightParsed)) |
| | | 4707 | | { |
| | 4 | 4708 | | var comparison = leftParsed.CompareTo(rightParsed); |
| | 4 | 4709 | | if (comparison != 0) |
| | | 4710 | | { |
| | 3 | 4711 | | return comparison; |
| | | 4712 | | } |
| | | 4713 | | |
| | 1 | 4714 | | var leftHasPrerelease = HasPrereleaseSuffix(leftVersion); |
| | 1 | 4715 | | var rightHasPrerelease = HasPrereleaseSuffix(rightVersion); |
| | 1 | 4716 | | if (leftHasPrerelease != rightHasPrerelease) |
| | | 4717 | | { |
| | 0 | 4718 | | return leftHasPrerelease ? -1 : 1; |
| | | 4719 | | } |
| | | 4720 | | } |
| | | 4721 | | |
| | 1 | 4722 | | return string.Compare(leftVersion.Trim(), rightVersion.Trim(), StringComparison.OrdinalIgnoreCase); |
| | | 4723 | | } |
| | | 4724 | | |
| | | 4725 | | /// <summary> |
| | | 4726 | | /// Determines whether a module version string includes prerelease suffix data. |
| | | 4727 | | /// </summary> |
| | | 4728 | | /// <param name="versionText">Version string to inspect.</param> |
| | | 4729 | | /// <returns>True when prerelease or build suffix exists.</returns> |
| | | 4730 | | private static bool HasPrereleaseSuffix(string versionText) |
| | 2 | 4731 | | => versionText.Contains('-', StringComparison.Ordinal) || versionText.Contains('+', StringComparison.Ordinal); |
| | | 4732 | | |
| | | 4733 | | /// <summary> |
| | | 4734 | | /// Reads ModuleVersion from a PowerShell module manifest file. |
| | | 4735 | | /// </summary> |
| | | 4736 | | /// <param name="manifestPath">Manifest path.</param> |
| | | 4737 | | /// <param name="versionText">ModuleVersion text when present.</param> |
| | | 4738 | | /// <returns>True when ModuleVersion was discovered.</returns> |
| | | 4739 | | private static bool TryReadModuleVersionFromManifest(string manifestPath, out string versionText) |
| | | 4740 | | { |
| | 9 | 4741 | | versionText = string.Empty; |
| | | 4742 | | |
| | | 4743 | | try |
| | | 4744 | | { |
| | 9 | 4745 | | var content = File.ReadAllText(manifestPath); |
| | 9 | 4746 | | var match = ModuleVersionPatternRegex.Match(content); |
| | 9 | 4747 | | if (!match.Success) |
| | | 4748 | | { |
| | 3 | 4749 | | return false; |
| | | 4750 | | } |
| | | 4751 | | |
| | 6 | 4752 | | versionText = match.Groups["value"].Value.Trim(); |
| | 6 | 4753 | | return !string.IsNullOrWhiteSpace(versionText); |
| | | 4754 | | } |
| | 0 | 4755 | | catch |
| | | 4756 | | { |
| | 0 | 4757 | | return false; |
| | | 4758 | | } |
| | 9 | 4759 | | } |
| | | 4760 | | |
| | | 4761 | | /// <summary> |
| | | 4762 | | /// Enumerates installed module manifest records from the user module root. |
| | | 4763 | | /// </summary> |
| | | 4764 | | /// <param name="moduleRoot">Module root path.</param> |
| | | 4765 | | /// <returns>Installed module records sorted by version descending.</returns> |
| | | 4766 | | private static List<InstalledModuleRecord> GetInstalledModuleRecords(string moduleRoot) |
| | | 4767 | | { |
| | 4 | 4768 | | var records = new List<InstalledModuleRecord>(); |
| | 4 | 4769 | | if (!Directory.Exists(moduleRoot)) |
| | | 4770 | | { |
| | 0 | 4771 | | return records; |
| | | 4772 | | } |
| | | 4773 | | |
| | 4 | 4774 | | var seenManifestPaths = new HashSet<string>(StringComparer.OrdinalIgnoreCase); |
| | 16 | 4775 | | foreach (var manifestPath in Directory.EnumerateFiles(moduleRoot, ModuleManifestFileName, SearchOption.AllDirect |
| | | 4776 | | { |
| | 4 | 4777 | | if (!seenManifestPaths.Add(manifestPath)) |
| | | 4778 | | { |
| | | 4779 | | continue; |
| | | 4780 | | } |
| | | 4781 | | |
| | 4 | 4782 | | var versionDirectory = Path.GetFileName(Path.GetDirectoryName(manifestPath)); |
| | 4 | 4783 | | string? versionText = null; |
| | | 4784 | | |
| | 4 | 4785 | | if (TryReadModuleSemanticVersionFromManifest(manifestPath, out var manifestSemanticVersion)) |
| | | 4786 | | { |
| | 2 | 4787 | | versionText = manifestSemanticVersion; |
| | | 4788 | | } |
| | | 4789 | | |
| | 4 | 4790 | | if (string.IsNullOrWhiteSpace(versionText) |
| | 4 | 4791 | | && !string.IsNullOrWhiteSpace(versionDirectory) |
| | 4 | 4792 | | && TryNormalizeModuleVersion(versionDirectory, out var normalizedVersionDirectory)) |
| | | 4793 | | { |
| | 2 | 4794 | | versionText = normalizedVersionDirectory; |
| | | 4795 | | } |
| | | 4796 | | |
| | 4 | 4797 | | if (string.IsNullOrWhiteSpace(versionText)) |
| | | 4798 | | { |
| | 0 | 4799 | | versionText = versionDirectory; |
| | | 4800 | | } |
| | | 4801 | | |
| | 4 | 4802 | | if (string.IsNullOrWhiteSpace(versionText)) |
| | | 4803 | | { |
| | | 4804 | | continue; |
| | | 4805 | | } |
| | | 4806 | | |
| | 4 | 4807 | | records.Add(new InstalledModuleRecord(versionText, manifestPath)); |
| | | 4808 | | } |
| | | 4809 | | |
| | 4 | 4810 | | records.Sort(static (left, right) => CompareModuleVersionValues(right.Version, left.Version)); |
| | 4 | 4811 | | return records; |
| | | 4812 | | } |
| | | 4813 | | |
| | | 4814 | | /// <summary> |
| | | 4815 | | /// Gets the module storage path for a selected scope. |
| | | 4816 | | /// </summary> |
| | | 4817 | | /// <param name="scope">Module storage scope.</param> |
| | | 4818 | | /// <returns>Absolute module storage path.</returns> |
| | | 4819 | | private static string GetPowerShellModulePath(ModuleStorageScope scope) |
| | 0 | 4820 | | => scope == ModuleStorageScope.Global |
| | 0 | 4821 | | ? GetGlobalPowerShellModulePath() |
| | 0 | 4822 | | : GetDefaultPowerShellModulePath(); |
| | | 4823 | | |
| | | 4824 | | /// <summary> |
| | | 4825 | | /// Gets the default all-users PowerShell module path for the active OS. |
| | | 4826 | | /// </summary> |
| | | 4827 | | /// <returns>Absolute all-users module path.</returns> |
| | | 4828 | | private static string GetGlobalPowerShellModulePath() |
| | | 4829 | | { |
| | 0 | 4830 | | if (OperatingSystem.IsWindows()) |
| | | 4831 | | { |
| | 0 | 4832 | | var programFiles = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles); |
| | 0 | 4833 | | var root = string.IsNullOrWhiteSpace(programFiles) ? @"C:\Program Files" : programFiles; |
| | 0 | 4834 | | return Path.Combine(root, "PowerShell", "Modules"); |
| | | 4835 | | } |
| | | 4836 | | |
| | 0 | 4837 | | return "/usr/local/share/powershell/Modules"; |
| | | 4838 | | } |
| | | 4839 | | |
| | | 4840 | | /// <summary> |
| | | 4841 | | /// Gets the default current-user PowerShell module path for the active OS. |
| | | 4842 | | /// </summary> |
| | | 4843 | | /// <returns>Absolute module path.</returns> |
| | | 4844 | | private static string GetDefaultPowerShellModulePath() |
| | | 4845 | | { |
| | 1 | 4846 | | var userHome = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); |
| | 1 | 4847 | | if (OperatingSystem.IsWindows()) |
| | | 4848 | | { |
| | 0 | 4849 | | var documents = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments); |
| | 0 | 4850 | | var root = string.IsNullOrWhiteSpace(documents) ? userHome : documents; |
| | 0 | 4851 | | return Path.Combine(root, "PowerShell", "Modules"); |
| | | 4852 | | } |
| | | 4853 | | |
| | 1 | 4854 | | return Path.Combine(userHome, ".local", "share", "powershell", "Modules"); |
| | | 4855 | | } |
| | | 4856 | | |
| | | 4857 | | /// <summary> |
| | | 4858 | | /// Writes a consistent module-not-found message with remediation guidance. |
| | | 4859 | | /// </summary> |
| | | 4860 | | /// <param name="kestrunManifestPath">Optional explicit manifest path argument.</param> |
| | | 4861 | | /// <param name="kestrunFolder">Optional explicit module folder argument.</param> |
| | | 4862 | | /// <param name="writeLine">Output writer callback.</param> |
| | | 4863 | | private static void WriteModuleNotFoundMessage(string? kestrunManifestPath, string? kestrunFolder, Action<string> wr |
| | | 4864 | | { |
| | 9 | 4865 | | if (!string.IsNullOrWhiteSpace(kestrunManifestPath)) |
| | | 4866 | | { |
| | 6 | 4867 | | writeLine($"Unable to locate manifest file: {Path.GetFullPath(kestrunManifestPath)}"); |
| | | 4868 | | } |
| | 3 | 4869 | | else if (!string.IsNullOrWhiteSpace(kestrunFolder)) |
| | | 4870 | | { |
| | 1 | 4871 | | writeLine($"Unable to locate {ModuleManifestFileName} in folder: {Path.GetFullPath(kestrunFolder)}"); |
| | | 4872 | | } |
| | | 4873 | | else |
| | | 4874 | | { |
| | 2 | 4875 | | writeLine($"Unable to locate {ModuleManifestFileName} under the executable folder or PSModulePath."); |
| | | 4876 | | } |
| | | 4877 | | |
| | 9 | 4878 | | writeLine($"No {ModuleName} module was found. Use '{ProductName} module install' to install it from PowerShell G |
| | 9 | 4879 | | } |
| | | 4880 | | |
| | | 4881 | | /// <summary> |
| | | 4882 | | /// Tries to parse command-line arguments into a concrete command payload. |
| | | 4883 | | /// </summary> |
| | | 4884 | | /// <param name="args">Raw command-line arguments.</param> |
| | | 4885 | | /// <param name="parsedCommand">Parsed command payload.</param> |
| | | 4886 | | /// <param name="error">Error message when parsing fails.</param> |
| | | 4887 | | /// <returns>True when parsing succeeds.</returns> |
| | | 4888 | | private static bool TryParseArguments(string[] args, out ParsedCommand parsedCommand, out string error) |
| | | 4889 | | { |
| | 83 | 4890 | | parsedCommand = new ParsedCommand(CommandMode.Run, string.Empty, false, [], null, null, null, false, null, null, |
| | 83 | 4891 | | if (args.Length == 0) |
| | | 4892 | | { |
| | 0 | 4893 | | error = $"No command provided. Use '{ProductName} help' to list commands."; |
| | 0 | 4894 | | return false; |
| | | 4895 | | } |
| | | 4896 | | |
| | 83 | 4897 | | if (!TryParseLeadingKestrunOptions(args, out var commandTokenIndex, out var kestrunFolder, out var kestrunManife |
| | | 4898 | | { |
| | 0 | 4899 | | return false; |
| | | 4900 | | } |
| | | 4901 | | |
| | 83 | 4902 | | if (commandTokenIndex >= args.Length) |
| | | 4903 | | { |
| | 0 | 4904 | | error = $"No command provided. Use '{ProductName} help' to list commands."; |
| | 0 | 4905 | | return false; |
| | | 4906 | | } |
| | | 4907 | | |
| | 83 | 4908 | | return TryParseCommandFromToken(args, commandTokenIndex, kestrunFolder, kestrunManifestPath, out parsedCommand, |
| | | 4909 | | } |
| | | 4910 | | |
| | | 4911 | | /// <summary> |
| | | 4912 | | /// Parses leading global Kestrun options that may appear before the command token. |
| | | 4913 | | /// </summary> |
| | | 4914 | | /// <param name="args">Raw command-line arguments.</param> |
| | | 4915 | | /// <param name="commandTokenIndex">Index of the command token after global options.</param> |
| | | 4916 | | /// <param name="kestrunFolder">Optional module folder path supplied via global option.</param> |
| | | 4917 | | /// <param name="kestrunManifestPath">Optional module manifest path supplied via global option.</param> |
| | | 4918 | | /// <param name="error">Error message when a required option value is missing.</param> |
| | | 4919 | | /// <returns>True when leading options are parsed successfully.</returns> |
| | | 4920 | | private static bool TryParseLeadingKestrunOptions( |
| | | 4921 | | string[] args, |
| | | 4922 | | out int commandTokenIndex, |
| | | 4923 | | out string? kestrunFolder, |
| | | 4924 | | out string? kestrunManifestPath, |
| | | 4925 | | out string error) |
| | | 4926 | | { |
| | 85 | 4927 | | commandTokenIndex = 0; |
| | 85 | 4928 | | kestrunFolder = null; |
| | 85 | 4929 | | kestrunManifestPath = null; |
| | 85 | 4930 | | error = string.Empty; |
| | | 4931 | | |
| | 88 | 4932 | | while (commandTokenIndex < args.Length) |
| | | 4933 | | { |
| | 88 | 4934 | | var current = args[commandTokenIndex]; |
| | 88 | 4935 | | if (current is "--kestrun-folder" or "-k") |
| | | 4936 | | { |
| | 2 | 4937 | | if (!TryConsumeLeadingOptionValue(args, ref commandTokenIndex, "--kestrun-folder", out var folderValue, |
| | | 4938 | | { |
| | 0 | 4939 | | return false; |
| | | 4940 | | } |
| | | 4941 | | |
| | 2 | 4942 | | kestrunFolder = folderValue; |
| | 2 | 4943 | | continue; |
| | | 4944 | | } |
| | | 4945 | | |
| | 86 | 4946 | | if (current is "--kestrun-manifest" or "-m") |
| | | 4947 | | { |
| | 2 | 4948 | | if (!TryConsumeLeadingOptionValue(args, ref commandTokenIndex, "--kestrun-manifest", out var manifestVal |
| | | 4949 | | { |
| | 1 | 4950 | | return false; |
| | | 4951 | | } |
| | | 4952 | | |
| | 1 | 4953 | | kestrunManifestPath = manifestValue; |
| | | 4954 | | continue; |
| | | 4955 | | } |
| | | 4956 | | |
| | | 4957 | | break; |
| | | 4958 | | } |
| | | 4959 | | |
| | 84 | 4960 | | return true; |
| | | 4961 | | } |
| | | 4962 | | |
| | | 4963 | | /// <summary> |
| | | 4964 | | /// Consumes a global option value and advances the parse index. |
| | | 4965 | | /// </summary> |
| | | 4966 | | /// <param name="args">Raw command-line arguments.</param> |
| | | 4967 | | /// <param name="index">Current option index, advanced when consumption succeeds.</param> |
| | | 4968 | | /// <param name="optionName">Canonical option name used in diagnostics.</param> |
| | | 4969 | | /// <param name="value">Consumed option value.</param> |
| | | 4970 | | /// <param name="error">Error text when the value is missing.</param> |
| | | 4971 | | /// <returns>True when the option value is consumed.</returns> |
| | | 4972 | | private static bool TryConsumeLeadingOptionValue(string[] args, ref int index, string optionName, out string value, |
| | | 4973 | | { |
| | 4 | 4974 | | value = string.Empty; |
| | 4 | 4975 | | if (index + 1 >= args.Length) |
| | | 4976 | | { |
| | 1 | 4977 | | error = $"Missing value for {optionName}."; |
| | 1 | 4978 | | return false; |
| | | 4979 | | } |
| | | 4980 | | |
| | 3 | 4981 | | value = args[index + 1]; |
| | 3 | 4982 | | index += 2; |
| | 3 | 4983 | | error = string.Empty; |
| | 3 | 4984 | | return true; |
| | | 4985 | | } |
| | | 4986 | | |
| | | 4987 | | /// <summary> |
| | | 4988 | | /// Dispatches command parsing based on the selected command token. |
| | | 4989 | | /// </summary> |
| | | 4990 | | /// <param name="args">Raw command-line arguments.</param> |
| | | 4991 | | /// <param name="commandTokenIndex">Index of the command token.</param> |
| | | 4992 | | /// <param name="kestrunFolder">Optional module folder path.</param> |
| | | 4993 | | /// <param name="kestrunManifestPath">Optional module manifest path.</param> |
| | | 4994 | | /// <param name="parsedCommand">Parsed command payload.</param> |
| | | 4995 | | /// <param name="error">Error message when dispatch fails.</param> |
| | | 4996 | | /// <returns>True when command parsing succeeds.</returns> |
| | | 4997 | | private static bool TryParseCommandFromToken( |
| | | 4998 | | string[] args, |
| | | 4999 | | int commandTokenIndex, |
| | | 5000 | | string? kestrunFolder, |
| | | 5001 | | string? kestrunManifestPath, |
| | | 5002 | | out ParsedCommand parsedCommand, |
| | | 5003 | | out string error) |
| | | 5004 | | { |
| | 84 | 5005 | | var commandToken = args[commandTokenIndex]; |
| | 84 | 5006 | | if (string.Equals(commandToken, "run", StringComparison.OrdinalIgnoreCase)) |
| | | 5007 | | { |
| | 8 | 5008 | | return TryParseRunArguments(args, commandTokenIndex + 1, kestrunFolder, kestrunManifestPath, out parsedComma |
| | | 5009 | | } |
| | | 5010 | | |
| | 76 | 5011 | | if (string.Equals(commandToken, "service", StringComparison.OrdinalIgnoreCase)) |
| | | 5012 | | { |
| | 64 | 5013 | | return TryParseServiceArguments(args, commandTokenIndex + 1, kestrunFolder, kestrunManifestPath, out parsedC |
| | | 5014 | | } |
| | | 5015 | | |
| | 12 | 5016 | | if (string.Equals(commandToken, "module", StringComparison.OrdinalIgnoreCase)) |
| | | 5017 | | { |
| | 10 | 5018 | | return TryParseModuleArguments(args, commandTokenIndex + 1, out parsedCommand, out error); |
| | | 5019 | | } |
| | | 5020 | | |
| | 2 | 5021 | | parsedCommand = new ParsedCommand(CommandMode.Run, string.Empty, false, [], null, null, null, false, null, null, |
| | 2 | 5022 | | error = $"Unknown command: {commandToken}. Use '{ProductName} help' to list commands."; |
| | 2 | 5023 | | return false; |
| | | 5024 | | } |
| | | 5025 | | |
| | | 5026 | | /// <summary> |
| | | 5027 | | /// Handles help/info/version command routing before command parsing. |
| | | 5028 | | /// </summary> |
| | | 5029 | | /// <param name="args">Raw command-line arguments.</param> |
| | | 5030 | | /// <param name="exitCode">Exit code for the handled command.</param> |
| | | 5031 | | /// <returns>True when a meta command was handled.</returns> |
| | | 5032 | | private static bool TryHandleMetaCommands(string[] args, out int exitCode) |
| | | 5033 | | { |
| | 10 | 5034 | | exitCode = 0; |
| | 10 | 5035 | | var filtered = FilterGlobalOptions(args); |
| | 10 | 5036 | | if (filtered.Count == 0) |
| | | 5037 | | { |
| | 1 | 5038 | | PrintUsage(); |
| | 1 | 5039 | | return true; |
| | | 5040 | | } |
| | | 5041 | | |
| | 9 | 5042 | | if (IsHelpToken(filtered[0]) || string.Equals(filtered[0], "help", StringComparison.OrdinalIgnoreCase)) |
| | | 5043 | | { |
| | 4 | 5044 | | if (filtered.Count == 1) |
| | | 5045 | | { |
| | 2 | 5046 | | PrintUsage(); |
| | 2 | 5047 | | return true; |
| | | 5048 | | } |
| | | 5049 | | |
| | 2 | 5050 | | if (filtered.Count == 2 && TryGetHelpTopic(filtered[1], out var topic)) |
| | | 5051 | | { |
| | 1 | 5052 | | PrintHelpForTopic(topic); |
| | 1 | 5053 | | return true; |
| | | 5054 | | } |
| | | 5055 | | |
| | 1 | 5056 | | Console.Error.WriteLine("Unknown help topic. Use 'kestrun help' to list available topics."); |
| | 1 | 5057 | | exitCode = 2; |
| | 1 | 5058 | | return true; |
| | | 5059 | | } |
| | | 5060 | | |
| | 5 | 5061 | | if (filtered.Count == 2 |
| | 5 | 5062 | | && TryGetHelpTopic(filtered[0], out var commandTopic) |
| | 5 | 5063 | | && (IsHelpToken(filtered[1]) || string.Equals(filtered[1], "help", StringComparison.OrdinalIgnoreCase))) |
| | | 5064 | | { |
| | 1 | 5065 | | PrintHelpForTopic(commandTopic); |
| | 1 | 5066 | | return true; |
| | | 5067 | | } |
| | | 5068 | | |
| | 4 | 5069 | | if (filtered.Count == 1 && string.Equals(filtered[0], "version", StringComparison.OrdinalIgnoreCase)) |
| | | 5070 | | { |
| | 1 | 5071 | | PrintVersion(); |
| | 1 | 5072 | | return true; |
| | | 5073 | | } |
| | | 5074 | | |
| | 3 | 5075 | | if (filtered.Count == 1 && string.Equals(filtered[0], "info", StringComparison.OrdinalIgnoreCase)) |
| | | 5076 | | { |
| | 1 | 5077 | | PrintInfo(); |
| | 1 | 5078 | | return true; |
| | | 5079 | | } |
| | | 5080 | | |
| | 2 | 5081 | | return false; |
| | | 5082 | | } |
| | | 5083 | | |
| | | 5084 | | /// <summary> |
| | | 5085 | | /// Checks whether an argument token requests usage help. |
| | | 5086 | | /// </summary> |
| | | 5087 | | /// <param name="token">Command-line token to inspect.</param> |
| | | 5088 | | /// <returns>True when the token is a help switch.</returns> |
| | 10 | 5089 | | private static bool IsHelpToken(string token) => token is "-h" or "--help" or "/?"; |
| | | 5090 | | |
| | | 5091 | | /// <summary> |
| | | 5092 | | /// Tries to map a help topic token to a known command topic. |
| | | 5093 | | /// </summary> |
| | | 5094 | | /// <param name="token">Input help topic token.</param> |
| | | 5095 | | /// <param name="topic">Normalized topic when recognized.</param> |
| | | 5096 | | /// <returns>True when the topic is recognized.</returns> |
| | | 5097 | | private static bool TryGetHelpTopic(string token, out string topic) |
| | | 5098 | | { |
| | 3 | 5099 | | topic = token.ToLowerInvariant(); |
| | 3 | 5100 | | return topic is "run" or "service" or "module" or "info" or "version"; |
| | | 5101 | | } |
| | | 5102 | | |
| | | 5103 | | /// <summary> |
| | | 5104 | | /// Filters known global options injected by launchers from command-line args. |
| | | 5105 | | /// </summary> |
| | | 5106 | | /// <param name="args">Raw command-line arguments.</param> |
| | | 5107 | | /// <returns>Arguments without known global options and their values.</returns> |
| | | 5108 | | private static List<string> FilterGlobalOptions(string[] args) |
| | | 5109 | | { |
| | 11 | 5110 | | var filtered = new List<string>(args.Length); |
| | 50 | 5111 | | for (var index = 0; index < args.Length; index++) |
| | | 5112 | | { |
| | 14 | 5113 | | if (args[index] is "--kestrun-folder" or "-k" or "--kestrun-manifest" or "-m") |
| | | 5114 | | { |
| | 0 | 5115 | | index += 1; |
| | 0 | 5116 | | continue; |
| | | 5117 | | } |
| | | 5118 | | |
| | 14 | 5119 | | if (IsNoCheckOption(args[index])) |
| | | 5120 | | { |
| | | 5121 | | continue; |
| | | 5122 | | } |
| | | 5123 | | |
| | 13 | 5124 | | filtered.Add(args[index]); |
| | | 5125 | | } |
| | | 5126 | | |
| | 11 | 5127 | | return filtered; |
| | | 5128 | | } |
| | | 5129 | | |
| | | 5130 | | /// <summary> |
| | | 5131 | | /// Prints command usage and discovery hints. |
| | | 5132 | | /// </summary> |
| | | 5133 | | private static void PrintUsage() |
| | | 5134 | | { |
| | 5 | 5135 | | Console.WriteLine("Usage:"); |
| | 5 | 5136 | | Console.WriteLine(" kestrun <command> [options]"); |
| | 5 | 5137 | | Console.WriteLine(); |
| | 5 | 5138 | | Console.WriteLine("Global options:"); |
| | 5 | 5139 | | Console.WriteLine($" {NoCheckOption} Skip PowerShell Gallery update check warnings."); |
| | 5 | 5140 | | Console.WriteLine(); |
| | 5 | 5141 | | Console.WriteLine("Commands:"); |
| | 5 | 5142 | | Console.WriteLine(" run Run a PowerShell script (default script: ./Service.ps1)"); |
| | 5 | 5143 | | Console.WriteLine(" module Manage Kestrun module (install/update/remove/info)"); |
| | 5 | 5144 | | Console.WriteLine(" service Manage service lifecycle (install/update/remove/start/stop/query/info)"); |
| | 5 | 5145 | | Console.WriteLine(" info Show runtime/build diagnostics"); |
| | 5 | 5146 | | Console.WriteLine(" version Show tool version"); |
| | 5 | 5147 | | Console.WriteLine(); |
| | 5 | 5148 | | Console.WriteLine("Help topics:"); |
| | 5 | 5149 | | Console.WriteLine(" kestrun run help"); |
| | 5 | 5150 | | Console.WriteLine(" kestrun module help"); |
| | 5 | 5151 | | Console.WriteLine(" kestrun service help"); |
| | 5 | 5152 | | Console.WriteLine(" kestrun info help"); |
| | 5 | 5153 | | Console.WriteLine(" kestrun version help"); |
| | 5 | 5154 | | } |
| | | 5155 | | |
| | | 5156 | | /// <summary> |
| | | 5157 | | /// Prints detailed help for a specific topic. |
| | | 5158 | | /// </summary> |
| | | 5159 | | /// <param name="topic">Help topic.</param> |
| | | 5160 | | private static void PrintHelpForTopic(string topic) |
| | | 5161 | | { |
| | | 5162 | | switch (topic) |
| | | 5163 | | { |
| | | 5164 | | case "run": |
| | 3 | 5165 | | Console.WriteLine("Usage:"); |
| | 3 | 5166 | | Console.WriteLine(" kestrun [--nocheck] [--kestrun-folder <folder>] [--kestrun-manifest <path-to-Kestru |
| | 3 | 5167 | | Console.WriteLine(); |
| | 3 | 5168 | | Console.WriteLine("Options:"); |
| | 3 | 5169 | | Console.WriteLine(" --script <path> Optional named script path (alternative to positional < |
| | 3 | 5170 | | Console.WriteLine(" --kestrun-manifest <path> Use an explicit Kestrun.psd1 manifest file."); |
| | 3 | 5171 | | Console.WriteLine(" --arguments <args...> Pass remaining values to the script as script arguments |
| | 3 | 5172 | | Console.WriteLine(); |
| | 3 | 5173 | | Console.WriteLine("Notes:"); |
| | 3 | 5174 | | Console.WriteLine(" - If no script is provided, ./Service.ps1 is used."); |
| | 3 | 5175 | | Console.WriteLine(" - Script arguments must be passed after --arguments (or --)."); |
| | 3 | 5176 | | Console.WriteLine(" - Use --kestrun-manifest to pin a specific Kestrun.psd1 file."); |
| | 3 | 5177 | | Console.WriteLine($" - If {ModuleName} is missing, run '{ProductName} module install'."); |
| | 3 | 5178 | | break; |
| | | 5179 | | |
| | | 5180 | | case "module": |
| | 1 | 5181 | | Console.WriteLine("Usage:"); |
| | 1 | 5182 | | Console.WriteLine($" {ProductName} module install [{ModuleVersionOption} <version>] [{ModuleScopeOption |
| | 1 | 5183 | | Console.WriteLine($" {ProductName} module update [{ModuleVersionOption} <version>] [{ModuleScopeOption} |
| | 1 | 5184 | | Console.WriteLine($" {ProductName} module remove [{ModuleScopeOption} <{ModuleScopeLocalValue}|{ModuleS |
| | 1 | 5185 | | Console.WriteLine($" {ProductName} module info [{ModuleScopeOption} <{ModuleScopeLocalValue}|{ModuleSco |
| | 1 | 5186 | | Console.WriteLine(); |
| | 1 | 5187 | | Console.WriteLine("Options:"); |
| | 1 | 5188 | | Console.WriteLine($" {ModuleVersionOption} <version> Optional specific version for install/update. |
| | 1 | 5189 | | Console.WriteLine($" {ModuleScopeOption} <scope> Module storage scope: '{ModuleScopeLocalValue} |
| | 1 | 5190 | | Console.WriteLine($" {ModuleForceOption} Overwrite existing target version folder for u |
| | 1 | 5191 | | Console.WriteLine(); |
| | 1 | 5192 | | Console.WriteLine("Notes:"); |
| | 1 | 5193 | | Console.WriteLine($" - install: fails when Kestrun is already installed; use '{ProductName} module upda |
| | 1 | 5194 | | Console.WriteLine($" - update: updates to latest when no --version is provided and fails if the target |
| | 1 | 5195 | | Console.WriteLine(" - remove: removes all installed versions from the selected scope and shows deletion |
| | 1 | 5196 | | Console.WriteLine(" - info: shows installed module versions and latest Gallery version for the selected |
| | 1 | 5197 | | Console.WriteLine(" - Windows global scope for install/update/remove prompts for elevation (UAC) when n |
| | 1 | 5198 | | break; |
| | | 5199 | | |
| | | 5200 | | case "service": |
| | 1 | 5201 | | Console.WriteLine("Usage:"); |
| | 1 | 5202 | | Console.WriteLine(" kestrun [--nocheck] [--kestrun-manifest <path-to-Kestrun.psd1>] service install --p |
| | 1 | 5203 | | Console.WriteLine(" kestrun [--nocheck] service update --name <service-name> [--package <path-or-url-to |
| | 1 | 5204 | | Console.WriteLine(" kestrun service remove --name <service-name>"); |
| | 1 | 5205 | | Console.WriteLine(" kestrun service start --name <service-name> [--json | --raw]"); |
| | 1 | 5206 | | Console.WriteLine(" kestrun service stop --name <service-name> [--json | --raw]"); |
| | 1 | 5207 | | Console.WriteLine(" kestrun service query --name <service-name> [--json | --raw]"); |
| | 1 | 5208 | | Console.WriteLine(" kestrun service info [--name <service-name>] [--json]"); |
| | 1 | 5209 | | Console.WriteLine(); |
| | 1 | 5210 | | Console.WriteLine("Options (service install):"); |
| | 1 | 5211 | | Console.WriteLine(" --package <path-or-url> Required .krpack (zip) package containing Service.psd1 |
| | 1 | 5212 | | Console.WriteLine(" --content-root-checksum <h> Verify package checksum before extraction (hex string). |
| | 1 | 5213 | | Console.WriteLine(" --content-root-checksum-algorithm <name> Hash algorithm: md5, sha1, sha256, sha384 |
| | 1 | 5214 | | Console.WriteLine(" --content-root-bearer-token <token> Add Authorization: Bearer <token> for HTTP(S) |
| | 1 | 5215 | | Console.WriteLine(" --content-root-header <name:value> Add custom HTTP request header for HTTP(S) pack |
| | 1 | 5216 | | Console.WriteLine(" --content-root-ignore-certificate Ignore HTTPS certificate validation for package |
| | 1 | 5217 | | Console.WriteLine(" --deployment-root <folder> Override where per-service bundles are created (default |
| | 1 | 5218 | | Console.WriteLine(" --kestrun-manifest <path> Use an explicit Kestrun.psd1 manifest for the service r |
| | 1 | 5219 | | Console.WriteLine(" --service-log-path <path> Set service bootstrap/operation log file path."); |
| | 1 | 5220 | | Console.WriteLine(" --service-user <account> Run installed service/daemon under a specific OS accoun |
| | 1 | 5221 | | Console.WriteLine(" --service-password <secret> Password for --service-user on Windows service accounts |
| | 1 | 5222 | | Console.WriteLine(" --arguments <args...> Pass remaining values to the installed script."); |
| | 1 | 5223 | | Console.WriteLine(" --kestrun For service update: use repository module at src/PowerS |
| | 1 | 5224 | | Console.WriteLine(" --kestrun-module <path> For service update: module manifest path or folder to r |
| | 1 | 5225 | | Console.WriteLine(" --failback For service update: restore application/module from lat |
| | 1 | 5226 | | Console.WriteLine(" --json For service start/stop/query/info: output JSON instead |
| | 1 | 5227 | | Console.WriteLine(" --raw For service start/stop/query: output native OS command |
| | 1 | 5228 | | Console.WriteLine(); |
| | 1 | 5229 | | Console.WriteLine("Notes:"); |
| | 1 | 5230 | | Console.WriteLine(" - install registers the service/daemon but does not auto-start it."); |
| | 1 | 5231 | | Console.WriteLine(" - update fails when the service is running; stop it first."); |
| | 1 | 5232 | | Console.WriteLine(" - update requires at least one of --package or --kestrun-module/--kestrun-manifest |
| | 1 | 5233 | | Console.WriteLine(" - --kestrun updates bundled module only when repository module version is newer; ot |
| | 1 | 5234 | | Console.WriteLine(" - --failback restores from latest backup and fails when no backup is available."); |
| | 1 | 5235 | | Console.WriteLine(" - info without --name lists installed Kestrun services."); |
| | 1 | 5236 | | Console.WriteLine(" - Service name and entry point are read from Service.psd1 in the package."); |
| | 1 | 5237 | | Console.WriteLine(" - Service.psd1 requires FormatVersion='1.0', Name, EntryPoint, and Description."); |
| | 1 | 5238 | | Console.WriteLine(" - Package file must use .krpack extension and contain zip content."); |
| | 1 | 5239 | | Console.WriteLine(" - --content-root-checksum is validated against the package file before extraction." |
| | 1 | 5240 | | Console.WriteLine(" - --content-root-bearer-token is only used for HTTP(S) package URLs."); |
| | 1 | 5241 | | Console.WriteLine(" - --content-root-header is only used for HTTP(S) package URLs and can be supplied m |
| | 1 | 5242 | | Console.WriteLine(" - --content-root-ignore-certificate applies only to HTTPS package URLs and is insec |
| | 1 | 5243 | | Console.WriteLine(" - --deployment-root overrides the OS default bundle root used during install and re |
| | 1 | 5244 | | Console.WriteLine(" - --service-user enables platform account mapping: Windows service account, Linux s |
| | 1 | 5245 | | Console.WriteLine(" - install snapshots runtime/module/script plus dedicated service-host from Kestrun. |
| | 1 | 5246 | | Console.WriteLine(" - install shows progress bars during bundle staging in interactive terminals."); |
| | 1 | 5247 | | Console.WriteLine(" - bundle roots: Windows %ProgramData%\\Kestrun\\services; Linux /var/kestrun/servic |
| | 1 | 5248 | | Console.WriteLine(" - remove/start/stop/query require --name and do not accept script paths."); |
| | 1 | 5249 | | Console.WriteLine($" - Use '{ProductName} module install' before service install when {ModuleName} is n |
| | 1 | 5250 | | break; |
| | | 5251 | | |
| | | 5252 | | case "info": |
| | 1 | 5253 | | Console.WriteLine("Usage:"); |
| | 1 | 5254 | | Console.WriteLine(" kestrun info"); |
| | 1 | 5255 | | Console.WriteLine(); |
| | 1 | 5256 | | Console.WriteLine("Shows runtime and build diagnostics (framework, OS, architecture, and binary paths)." |
| | 1 | 5257 | | break; |
| | | 5258 | | |
| | | 5259 | | case "version": |
| | 1 | 5260 | | Console.WriteLine("Usage:"); |
| | 1 | 5261 | | Console.WriteLine(" kestrun version"); |
| | 1 | 5262 | | Console.WriteLine(); |
| | 1 | 5263 | | Console.WriteLine("Shows the kestrun tool version."); |
| | | 5264 | | break; |
| | | 5265 | | } |
| | 1 | 5266 | | } |
| | | 5267 | | |
| | | 5268 | | /// <summary> |
| | | 5269 | | /// Prints the KestrunTool version. |
| | | 5270 | | /// </summary> |
| | | 5271 | | private static void PrintVersion() |
| | | 5272 | | { |
| | 2 | 5273 | | var version = GetProductVersion(); |
| | 2 | 5274 | | Console.WriteLine($"{ProductName} {version}"); |
| | 2 | 5275 | | } |
| | | 5276 | | |
| | | 5277 | | /// <summary> |
| | | 5278 | | /// Prints diagnostic information about the KestrunTool build and runtime. |
| | | 5279 | | /// </summary> |
| | | 5280 | | private static void PrintInfo() |
| | | 5281 | | { |
| | 2 | 5282 | | var version = GetProductVersion(); |
| | 2 | 5283 | | var assembly = typeof(Program).Assembly; |
| | 2 | 5284 | | var informationalVersion = assembly.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVe |
| | | 5285 | | |
| | 2 | 5286 | | Console.WriteLine($"Product: {ProductName}"); |
| | 2 | 5287 | | Console.WriteLine($"Version: {version}"); |
| | 2 | 5288 | | Console.WriteLine($"InformationalVersion: {informationalVersion}"); |
| | 2 | 5289 | | Console.WriteLine($"Framework: {RuntimeInformation.FrameworkDescription}"); |
| | 2 | 5290 | | Console.WriteLine($"OS: {RuntimeInformation.OSDescription}"); |
| | 2 | 5291 | | Console.WriteLine($"OSArchitecture: {RuntimeInformation.OSArchitecture}"); |
| | 2 | 5292 | | Console.WriteLine($"ProcessArchitecture: {RuntimeInformation.ProcessArchitecture}"); |
| | 2 | 5293 | | Console.WriteLine($"ExecutableDirectory: {GetExecutableDirectory()}"); |
| | 2 | 5294 | | Console.WriteLine($"BaseDirectory: {Path.GetFullPath(AppContext.BaseDirectory)}"); |
| | 2 | 5295 | | } |
| | | 5296 | | |
| | | 5297 | | /// <summary> |
| | | 5298 | | /// Gets the product version from assembly metadata. |
| | | 5299 | | /// </summary> |
| | | 5300 | | /// <returns>Product version string.</returns> |
| | | 5301 | | private static string GetProductVersion() |
| | | 5302 | | { |
| | 4 | 5303 | | var assembly = typeof(Program).Assembly; |
| | 4 | 5304 | | var informationalVersion = assembly.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVe |
| | 4 | 5305 | | return !string.IsNullOrWhiteSpace(informationalVersion) ? |
| | 4 | 5306 | | informationalVersion : assembly.GetName().Version?.ToString() ?? "0.0.0"; |
| | | 5307 | | } |
| | | 5308 | | |
| | | 5309 | | /// <summary> |
| | | 5310 | | /// Locates Kestrun.psd1 without launching an external pwsh process. |
| | | 5311 | | /// </summary> |
| | | 5312 | | /// <param name="kestrunManifestPath">Optional explicit path to Kestrun.psd1.</param> |
| | | 5313 | | /// <param name="kestrunFolder">Optional folder containing Kestrun.psd1.</param> |
| | | 5314 | | /// <returns>Absolute manifest path when found; otherwise null.</returns> |
| | | 5315 | | private static string? LocateModuleManifest(string? kestrunManifestPath, string? kestrunFolder) |
| | | 5316 | | { |
| | 9 | 5317 | | if (!string.IsNullOrWhiteSpace(kestrunManifestPath)) |
| | | 5318 | | { |
| | 8 | 5319 | | var explicitPath = Path.GetFullPath(kestrunManifestPath); |
| | 8 | 5320 | | var explicitManifest = Directory.Exists(explicitPath) |
| | 8 | 5321 | | ? Path.Combine(explicitPath, ModuleManifestFileName) |
| | 8 | 5322 | | : explicitPath; |
| | 8 | 5323 | | return File.Exists(explicitManifest) ? explicitManifest : null; |
| | | 5324 | | } |
| | | 5325 | | |
| | 1 | 5326 | | if (!string.IsNullOrWhiteSpace(kestrunFolder)) |
| | | 5327 | | { |
| | 1 | 5328 | | var explicitFolder = Path.GetFullPath(kestrunFolder); |
| | 1 | 5329 | | var explicitCandidate = Path.Combine(explicitFolder, ModuleManifestFileName); |
| | 1 | 5330 | | return File.Exists(explicitCandidate) ? explicitCandidate : null; |
| | | 5331 | | } |
| | | 5332 | | |
| | 0 | 5333 | | foreach (var candidate in EnumerateExecutableManifestCandidates()) |
| | | 5334 | | { |
| | 0 | 5335 | | if (File.Exists(candidate)) |
| | | 5336 | | { |
| | 0 | 5337 | | return Path.GetFullPath(candidate); |
| | | 5338 | | } |
| | | 5339 | | } |
| | | 5340 | | |
| | 0 | 5341 | | foreach (var candidate in EnumerateModulePathManifestCandidates()) |
| | | 5342 | | { |
| | 0 | 5343 | | if (File.Exists(candidate)) |
| | | 5344 | | { |
| | 0 | 5345 | | return Path.GetFullPath(candidate); |
| | | 5346 | | } |
| | | 5347 | | } |
| | | 5348 | | |
| | 0 | 5349 | | return null; |
| | 0 | 5350 | | } |
| | | 5351 | | |
| | | 5352 | | /// <summary> |
| | | 5353 | | /// Enumerates candidate locations for Kestrun.psd1 under the executable folder. |
| | | 5354 | | /// </summary> |
| | | 5355 | | /// <returns>Absolute manifest path when found; otherwise null.</returns> |
| | | 5356 | | private static IEnumerable<string> EnumerateExecutableManifestCandidates() |
| | | 5357 | | { |
| | 0 | 5358 | | var executableDirectory = GetExecutableDirectory(); |
| | | 5359 | | |
| | 0 | 5360 | | yield return Path.Combine(executableDirectory, ModuleManifestFileName); |
| | 0 | 5361 | | yield return Path.Combine(executableDirectory, ModuleName, ModuleManifestFileName); |
| | | 5362 | | |
| | 0 | 5363 | | var baseDirectory = Path.GetFullPath(AppContext.BaseDirectory); |
| | 0 | 5364 | | if (!string.Equals(baseDirectory, executableDirectory, StringComparison.OrdinalIgnoreCase)) |
| | | 5365 | | { |
| | 0 | 5366 | | yield return Path.Combine(baseDirectory, ModuleManifestFileName); |
| | 0 | 5367 | | yield return Path.Combine(baseDirectory, ModuleName, ModuleManifestFileName); |
| | | 5368 | | } |
| | 0 | 5369 | | } |
| | | 5370 | | |
| | | 5371 | | /// <summary> |
| | | 5372 | | /// Gets the directory where the executable file is located. |
| | | 5373 | | /// </summary> |
| | | 5374 | | /// <returns>Absolute executable directory path.</returns> |
| | | 5375 | | private static string GetExecutableDirectory() |
| | | 5376 | | { |
| | 20 | 5377 | | var processPath = Environment.ProcessPath; |
| | 20 | 5378 | | if (!string.IsNullOrWhiteSpace(processPath)) |
| | | 5379 | | { |
| | 20 | 5380 | | var processDirectory = Path.GetDirectoryName(processPath); |
| | 20 | 5381 | | if (!string.IsNullOrWhiteSpace(processDirectory)) |
| | | 5382 | | { |
| | 20 | 5383 | | return Path.GetFullPath(processDirectory); |
| | | 5384 | | } |
| | | 5385 | | } |
| | | 5386 | | |
| | 0 | 5387 | | return Path.GetFullPath(AppContext.BaseDirectory); |
| | | 5388 | | } |
| | | 5389 | | |
| | | 5390 | | /// <summary> |
| | | 5391 | | /// Enumerates candidate manifest paths under PSModulePath entries. |
| | | 5392 | | /// </summary> |
| | | 5393 | | /// <returns>Potential manifest file paths from PSModulePath.</returns> |
| | | 5394 | | private static IEnumerable<string> EnumerateModulePathManifestCandidates() |
| | | 5395 | | { |
| | 0 | 5396 | | var moduleRoots = new HashSet<string>(StringComparer.OrdinalIgnoreCase); |
| | | 5397 | | |
| | 0 | 5398 | | var modulePathRaw = Environment.GetEnvironmentVariable("PSModulePath"); |
| | 0 | 5399 | | if (!string.IsNullOrWhiteSpace(modulePathRaw)) |
| | | 5400 | | { |
| | 0 | 5401 | | foreach (var root in modulePathRaw.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries | StringS |
| | | 5402 | | { |
| | 0 | 5403 | | if (!string.IsNullOrWhiteSpace(root)) |
| | | 5404 | | { |
| | 0 | 5405 | | _ = moduleRoots.Add(root); |
| | | 5406 | | } |
| | | 5407 | | } |
| | | 5408 | | } |
| | | 5409 | | |
| | | 5410 | | // Service and elevated contexts can have an incomplete PSModulePath. |
| | | 5411 | | // Always include the conventional user/global module roots as discovery fallbacks. |
| | 0 | 5412 | | _ = moduleRoots.Add(GetDefaultPowerShellModulePath()); |
| | 0 | 5413 | | _ = moduleRoots.Add(GetGlobalPowerShellModulePath()); |
| | | 5414 | | |
| | 0 | 5415 | | foreach (var root in moduleRoots) |
| | | 5416 | | { |
| | 0 | 5417 | | var moduleDirectory = Path.Combine(root, ModuleName); |
| | 0 | 5418 | | yield return Path.Combine(moduleDirectory, ModuleManifestFileName); |
| | | 5419 | | |
| | 0 | 5420 | | if (!Directory.Exists(moduleDirectory)) |
| | | 5421 | | { |
| | | 5422 | | continue; |
| | | 5423 | | } |
| | | 5424 | | |
| | 0 | 5425 | | var versionDirectories = Directory.EnumerateDirectories(moduleDirectory) |
| | | 5426 | | .OrderByDescending(path => path, StringComparer.OrdinalIgnoreCase); |
| | | 5427 | | |
| | 0 | 5428 | | foreach (var versionDirectory in versionDirectories) |
| | | 5429 | | { |
| | 0 | 5430 | | yield return Path.Combine(versionDirectory, ModuleManifestFileName); |
| | | 5431 | | } |
| | 0 | 5432 | | } |
| | 0 | 5433 | | } |
| | | 5434 | | } |