using System.IO.Compression; using System.Management.Automation.Language; using System.Text.RegularExpressions; using LANCommander.SDK; using LANCommander.Server.Models; namespace LANCommander.Server.Services { public sealed class ModuleService(SettingsProvider settingsProvider) { private static readonly Regex FunctionNamePattern = new(@"^[A-Za-z][A-Za-z0-9]*-[A-Za-z][A-Za-z0-9]*$", RegexOptions.Compiled); // Standard set of approved PowerShell verbs (Get-Verb), grouped by verb group. private static readonly IReadOnlyList ApprovedVerbGroups = new[] { new VerbGroup("Common", new[] { "Add", "Clear", "Close", "Copy", "Enter", "Exit", "Find", "Format", "Get", "Hide", "Join", "Lock", "Move", "New", "Open", "Optimize", "Pop", "Push", "Redo", "Remove", "Rename", "Reset", "Resize", "Search", "Select", "Set", "Show", "Skip", "Split", "Step", "Switch", "Undo", "Unlock", "Watch", }), new VerbGroup("Communications", new[] { "Connect", "Disconnect", "Read", "Receive", "Send", "Write", }), new VerbGroup("Data", new[] { "Backup", "Checkpoint", "Compare", "Compress", "Convert", "ConvertFrom", "ConvertTo", "Dismount", "Edit", "Expand", "Export", "Group", "Import", "Initialize", "Limit", "Merge", "Mount", "Out", "Publish", "Restore", "Save", "Sync", "Unpublish", "Update", }), new VerbGroup("Diagnostic", new[] { "Debug", "Measure", "Ping", "Repair", "Resolve", "Test", "Trace", }), new VerbGroup("Lifecycle", new[] { "Approve", "Assert", "Build", "Complete", "Confirm", "Deny", "Deploy", "Disable", "Enable", "Install", "Invoke", "Register", "Request", "Restart", "Resume", "Start", "Stop", "Submit", "Suspend", "Uninstall", "Unregister", "Wait", }), new VerbGroup("Security", new[] { "Block", "Grant", "Protect", "Revoke", "Unblock", "Unprotect", }), new VerbGroup("Other", new[] { "Use", }), }; private static readonly HashSet ApprovedVerbs = new(ApprovedVerbGroups.SelectMany(g => g.Verbs), StringComparer.OrdinalIgnoreCase); private string GetStoragePath() { var storagePath = settingsProvider.CurrentValue.Server.Scripts.Modules.StoragePath; if (string.IsNullOrWhiteSpace(storagePath)) { storagePath = AppPaths.GetConfigPath("Modules"); settingsProvider.Update(s => { s.Server.Scripts.Modules.StoragePath = storagePath; }); } storagePath = AppPaths.ResolveStorageLocationPath(storagePath); if (!Directory.Exists(storagePath)) Directory.CreateDirectory(storagePath); return storagePath; } private string GetModuleDirectory(string name) => Path.Combine(GetStoragePath(), name); private static string GetManifestPath(string moduleDirectory, string name) => Path.Combine(moduleDirectory, $"{name}.psd1"); private static string GetLoaderPath(string moduleDirectory, string name) => Path.Combine(moduleDirectory, $"{name}.psm1"); private static string GetVisibilityDirectory(string moduleDirectory, FunctionVisibility visibility) => Path.Combine(moduleDirectory, visibility == FunctionVisibility.Public ? "Public" : "Private"); public IEnumerable GetModules() { var storagePath = GetStoragePath(); return Directory .GetDirectories(storagePath) .Select(d => GetModule(Path.GetFileName(d))) .Where(m => m != null) .OrderBy(m => m.Name); } public Module GetModule(string name) { var moduleDirectory = GetModuleDirectory(name); if (!Directory.Exists(moduleDirectory)) return null; var manifestPath = GetManifestPath(moduleDirectory, name); var module = new Module { Name = name, Manifest = File.Exists(manifestPath) ? File.ReadAllText(manifestPath) : string.Empty, Functions = new List(), }; foreach (var visibility in new[] { FunctionVisibility.Public, FunctionVisibility.Private }) { var directory = GetVisibilityDirectory(moduleDirectory, visibility); if (!Directory.Exists(directory)) continue; foreach (var file in Directory.GetFiles(directory, "*.ps1").OrderBy(f => f)) { module.Functions.Add(new ModuleFunction { Name = Path.GetFileNameWithoutExtension(file), Visibility = visibility, Content = File.ReadAllText(file), }); } } return module; } public bool ModuleExists(string name) => Directory.Exists(GetModuleDirectory(name)); public Module CreateScaffold(string name) { return new Module { Name = name, Manifest = GetDefaultManifest(name), Functions = new List { new() { Name = "Get-Example", Visibility = FunctionVisibility.Public, Content = GetDefaultFunctionContent("Get-Example"), }, }, }; } public void SaveModule(Module module) { var moduleDirectory = GetModuleDirectory(module.Name); if (!Directory.Exists(moduleDirectory)) Directory.CreateDirectory(moduleDirectory); File.WriteAllText(GetManifestPath(moduleDirectory, module.Name), module.Manifest ?? string.Empty); File.WriteAllText(GetLoaderPath(moduleDirectory, module.Name), GetLoaderScript()); var desiredFiles = new HashSet(StringComparer.OrdinalIgnoreCase); foreach (var visibility in new[] { FunctionVisibility.Public, FunctionVisibility.Private }) { var directory = GetVisibilityDirectory(moduleDirectory, visibility); Directory.CreateDirectory(directory); foreach (var function in module.Functions.Where(f => f.Visibility == visibility)) { if (string.IsNullOrWhiteSpace(function.Name)) continue; var path = Path.Combine(directory, $"{function.Name}.ps1"); File.WriteAllText(path, function.Content ?? string.Empty); desiredFiles.Add(path); } } // Prune function files that were removed or moved between Public/Private foreach (var visibility in new[] { FunctionVisibility.Public, FunctionVisibility.Private }) { var directory = GetVisibilityDirectory(moduleDirectory, visibility); if (!Directory.Exists(directory)) continue; foreach (var file in Directory.GetFiles(directory, "*.ps1")) { if (!desiredFiles.Contains(file)) File.Delete(file); } } } public void RenameModule(string oldName, string newName) { if (string.Equals(oldName, newName, StringComparison.Ordinal)) return; var module = GetModule(oldName); if (module == null) return; DeleteModule(oldName); module.Name = newName; SaveModule(module); } public void DeleteModule(string name) { var moduleDirectory = GetModuleDirectory(name); if (Directory.Exists(moduleDirectory)) Directory.Delete(moduleDirectory, true); } public string GetModulesArchive() { var storagePath = GetStoragePath(); var archivePath = Path.Combine(Path.GetTempPath(), $"LANCommander.Modules.{Guid.NewGuid()}.zip"); ZipFile.CreateFromDirectory(storagePath, archivePath, CompressionLevel.Optimal, false); return archivePath; } public ModuleManifest ParseManifest(string manifest) { var result = new ModuleManifest(); if (string.IsNullOrWhiteSpace(manifest)) return result; var ast = Parser.ParseInput(manifest, out _, out _); var hashtable = ast .Find(a => a is HashtableAst, false) as HashtableAst; if (hashtable == null) return result; foreach (var pair in hashtable.KeyValuePairs) { if (pair.Item1 is not StringConstantExpressionAst keyAst) continue; var value = GetScalarString(pair.Item2); if (value == null) continue; switch (keyAst.Value) { case "RootModule": result.RootModule = value; break; case "ModuleVersion": result.ModuleVersion = value; break; case "GUID": result.Guid = value; break; case "Author": result.Author = value; break; case "CompanyName": result.CompanyName = value; break; case "Copyright": result.Copyright = value; break; case "Description": result.Description = value; break; case "PowerShellVersion": result.PowerShellVersion = value; break; } } return result; } public string GenerateManifest(ModuleManifest manifest, IEnumerable functionNames, IEnumerable aliasNames) { var functions = functionNames?.Distinct().OrderBy(n => n).ToList() ?? new List(); var aliases = aliasNames?.Distinct().OrderBy(n => n).ToList() ?? new List(); var builder = new System.Text.StringBuilder(); builder.Append("@{\n"); builder.Append($" RootModule = '{Escape(manifest.RootModule)}'\n"); builder.Append($" ModuleVersion = '{Escape(manifest.ModuleVersion)}'\n"); builder.Append($" GUID = '{Escape(manifest.Guid)}'\n"); if (!string.IsNullOrWhiteSpace(manifest.Author)) builder.Append($" Author = '{Escape(manifest.Author)}'\n"); if (!string.IsNullOrWhiteSpace(manifest.CompanyName)) builder.Append($" CompanyName = '{Escape(manifest.CompanyName)}'\n"); if (!string.IsNullOrWhiteSpace(manifest.Copyright)) builder.Append($" Copyright = '{Escape(manifest.Copyright)}'\n"); if (!string.IsNullOrWhiteSpace(manifest.Description)) builder.Append($" Description = '{Escape(manifest.Description)}'\n"); if (!string.IsNullOrWhiteSpace(manifest.PowerShellVersion)) builder.Append($" PowerShellVersion = '{Escape(manifest.PowerShellVersion)}'\n"); builder.Append($" FunctionsToExport = {FormatArray(functions)}\n"); builder.Append(" CmdletsToExport = @()\n"); builder.Append(" VariablesToExport = @()\n"); builder.Append($" AliasesToExport = {FormatArray(aliases)}\n"); builder.Append("}\n"); return builder.ToString(); } public IReadOnlyList GetExportedFunctions(Module module) { if (module?.Functions == null) return []; return module.Functions .Where(f => f.Visibility == FunctionVisibility.Public && !string.IsNullOrWhiteSpace(f.Name)) .Select(f => f.Name) .Distinct() .OrderBy(n => n) .ToList(); } public IReadOnlyList GetExportedAliases(Module module) { if (module?.Functions == null) return []; var aliases = new List(); foreach (var function in module.Functions) aliases.AddRange(GetAliasesFromScript(function.Content)); return aliases.Distinct().OrderBy(a => a).ToList(); } public IEnumerable<(string Name, string Synopsis, string Module)> GetPublicFunctionCompletions() { foreach (var module in GetModules()) { foreach (var function in module.Functions.Where(f => f.Visibility == FunctionVisibility.Public && !string.IsNullOrWhiteSpace(f.Name))) { yield return (function.Name, ExtractSynopsis(function.Content), module.Name); } } } private static string ExtractSynopsis(string content) { if (string.IsNullOrWhiteSpace(content)) return null; var match = Regex.Match(content, @"\.SYNOPSIS\s*\r?\n\s*(?.+)"); return match.Success ? match.Groups["synopsis"].Value.Trim() : null; } public IReadOnlyList GetApprovedVerbGroups() => ApprovedVerbGroups .Select(g => new VerbGroup(g.Name, g.Verbs.OrderBy(v => v, StringComparer.OrdinalIgnoreCase).ToArray())) .ToList(); public bool IsValidFunctionName(string name) => !string.IsNullOrWhiteSpace(name) && FunctionNamePattern.IsMatch(name); public bool IsApprovedVerb(string name) { if (string.IsNullOrWhiteSpace(name)) return false; var dash = name.IndexOf('-'); if (dash <= 0) return false; return ApprovedVerbs.Contains(name.Substring(0, dash)); } private static IEnumerable GetAliasesFromScript(string script) { if (string.IsNullOrWhiteSpace(script)) yield break; var ast = Parser.ParseInput(script, out _, out _); foreach (var command in ast.FindAll(a => a is CommandAst, true).Cast()) { var name = command.GetCommandName(); if (!string.Equals(name, "Set-Alias", StringComparison.OrdinalIgnoreCase) && !string.Equals(name, "New-Alias", StringComparison.OrdinalIgnoreCase)) continue; var elements = command.CommandElements; for (var i = 1; i < elements.Count; i++) { if (elements[i] is CommandParameterAst parameter && string.Equals(parameter.ParameterName, "Name", StringComparison.OrdinalIgnoreCase) && i + 1 < elements.Count) { var value = GetScalarString(elements[i + 1]); if (value != null) yield return value; break; } if (elements[i] is StringConstantExpressionAst positional && i == 1) { yield return positional.Value; break; } } } } private static string GetScalarString(Ast valueAst) { return valueAst switch { StringConstantExpressionAst s => s.Value, ExpandableStringExpressionAst e => e.Value, ConstantExpressionAst c => c.Value?.ToString(), _ => null, }; } private static string FormatArray(IReadOnlyList values) { if (values.Count == 0) return "@()"; return "@(" + string.Join(", ", values.Select(v => $"'{Escape(v)}'")) + ")"; } private static string Escape(string value) => (value ?? string.Empty).Replace("'", "''"); private static string GetDefaultManifest(string name) => $"@{{\n RootModule = '{name}.psm1'\n ModuleVersion = '1.0.0'\n GUID = '{Guid.NewGuid()}'\n FunctionsToExport = '*'\n}}\n"; private static string GetDefaultFunctionContent(string name) => $"function {name} {{\n [CmdletBinding()]\n param()\n\n \"Hello from a LANCommander module\"\n}}\n"; // Loader that dot-sources every function file and exports only the public ones. private static string GetLoaderScript() => "$Public = @(Get-ChildItem -Path \"$PSScriptRoot\\Public\\*.ps1\" -ErrorAction SilentlyContinue)\n" + "$Private = @(Get-ChildItem -Path \"$PSScriptRoot\\Private\\*.ps1\" -ErrorAction SilentlyContinue)\n" + "\n" + "foreach ($file in @($Public + $Private)) {\n" + " try { . $file.FullName }\n" + " catch { Write-Error \"Failed to import function $($file.FullName): $_\" }\n" + "}\n" + "\n" + "Export-ModuleMember -Function $Public.BaseName\n"; } }