diff --git a/Engine/Commands/InvokeScriptAnalyzerCommand.cs b/Engine/Commands/InvokeScriptAnalyzerCommand.cs index 9a097cb81..bbaf6fcab 100644 --- a/Engine/Commands/InvokeScriptAnalyzerCommand.cs +++ b/Engine/Commands/InvokeScriptAnalyzerCommand.cs @@ -25,6 +25,7 @@ using System.Threading.Tasks; using System.Collections.Concurrent; using System.Threading; +using System.Management.Automation.Runspaces; namespace Microsoft.Windows.PowerShell.ScriptAnalyzer.Commands { @@ -44,7 +45,7 @@ public class InvokeScriptAnalyzerCommand : PSCmdlet, IOutputWriter [Parameter(Position = 0, ParameterSetName = "File", Mandatory = true, - ValueFromPipeline = true, + ValueFromPipeline = true, ValueFromPipelineByPropertyName = true)] [ValidateNotNull] [Alias("PSPath")] @@ -188,6 +189,16 @@ public object Settings private bool stopProcessing; + /// + /// Resolve DSC resource dependency + /// + [Parameter(Mandatory = false)] + public SwitchParameter SaveDscResourceDependency + { + get { return saveDscResourceDependency; } + set { saveDscResourceDependency = value; } + } + private bool saveDscResourceDependency; #endregion Parameters #region Overrides @@ -227,18 +238,22 @@ protected override void ProcessRecord() return; } - if (String.Equals(this.ParameterSetName, "File", StringComparison.OrdinalIgnoreCase)) + // TODO Support dependency resolution for analyzing script definitions + if (saveDscResourceDependency) { - // throws Item Not Found Exception - Collection paths = this.SessionState.Path.GetResolvedPSPathFromPSPath(path); - foreach (PathInfo p in paths) + using (var rsp = RunspaceFactory.CreateRunspace()) { - ProcessPathOrScriptDefinition(this.SessionState.Path.GetUnresolvedProviderPathFromPSPath(p.Path)); + rsp.Open(); + using (var moduleHandler = new ModuleDependencyHandler(rsp)) + { + ScriptAnalyzer.Instance.ModuleHandler = moduleHandler; + ProcessInput(); + } } } - else if (String.Equals(this.ParameterSetName, "ScriptDefinition", StringComparison.OrdinalIgnoreCase)) + else { - ProcessPathOrScriptDefinition(scriptDefinition); + ProcessInput(); } } @@ -257,30 +272,38 @@ protected override void StopProcessing() #endregion #region Methods - - private void ProcessPathOrScriptDefinition(string pathOrScriptDefinition) + private void ProcessInput() { IEnumerable diagnosticsList = Enumerable.Empty(); - if (String.Equals(this.ParameterSetName, "File", StringComparison.OrdinalIgnoreCase)) { - diagnosticsList = ScriptAnalyzer.Instance.AnalyzePath(pathOrScriptDefinition, this.recurse); + // throws Item Not Found Exception + Collection paths = this.SessionState.Path.GetResolvedPSPathFromPSPath(path); + foreach (PathInfo p in paths) + { + diagnosticsList = ScriptAnalyzer.Instance.AnalyzePath( + this.SessionState.Path.GetUnresolvedProviderPathFromPSPath(p.Path), + this.recurse); + WriteToOutput(diagnosticsList); + } } else if (String.Equals(this.ParameterSetName, "ScriptDefinition", StringComparison.OrdinalIgnoreCase)) { - diagnosticsList = ScriptAnalyzer.Instance.AnalyzeScriptDefinition(pathOrScriptDefinition); + diagnosticsList = ScriptAnalyzer.Instance.AnalyzeScriptDefinition(scriptDefinition); + WriteToOutput(diagnosticsList); } + } - //Output through loggers + private void WriteToOutput(IEnumerable diagnosticRecords) + { foreach (ILogger logger in ScriptAnalyzer.Instance.Loggers) { - foreach (DiagnosticRecord diagnostic in diagnosticsList) + foreach (DiagnosticRecord diagnostic in diagnosticRecords) { logger.LogObject(diagnostic, this); } } } - #endregion } } \ No newline at end of file diff --git a/Engine/Generic/ModuleDependencyHandler.cs b/Engine/Generic/ModuleDependencyHandler.cs new file mode 100644 index 000000000..1494816a1 --- /dev/null +++ b/Engine/Generic/ModuleDependencyHandler.cs @@ -0,0 +1,507 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.IO; +using System.Linq; +using System.Management.Automation; +using System.Management.Automation.Language; +using System.Management.Automation.Runspaces; + +namespace Microsoft.Windows.PowerShell.ScriptAnalyzer.Generic +{ + // TODO Use runspace pool + // TODO Support for verbose mode + public class ModuleDependencyHandler : IDisposable + { + #region Private Variables + private Runspace runspace; + private string moduleRepository; + private string tempPath; // path to the user temporary directory + private string tempModulePath; // path to temp directory containing modules + Dictionary modulesFound; + private string localAppdataPath; + private string pssaAppDataPath; + private const string symLinkName = "TempModuleDir"; + private const string tempPrefix = "PSSAModules-"; + private string symLinkPath; + private string oldPSModulePath; + private string curPSModulePath; + #endregion Private Variables + + #region Properties + /// + /// Path where the object stores the modules + /// + public string TempModulePath + { + get { return tempModulePath; } + } + + /// + /// Temporary path of the current user scope + /// + public string TempPath + { + get + { + return tempPath; + } + // it must be set only during initialization + private set + { + tempPath + = string.IsNullOrWhiteSpace(value) + ? Path.GetTempPath() + : value; + } + + } + + /// + /// Local App Data path + /// + public string LocalAppDataPath + { + get + { + return localAppdataPath; + } + private set + { + localAppdataPath + = string.IsNullOrWhiteSpace(value) + ? Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData) + : value; + } + } + + /// + /// Module Respository + /// By default it is PSGallery + /// + public string ModuleRepository + { + get + { + return moduleRepository; + } + set + { + moduleRepository + = string.IsNullOrWhiteSpace(value) + ? "PSGallery" + : value; + } + } + + /// + /// Local App data of PSSScriptAnalyzer + /// + public string PSSAAppDataPath + { + get + { + return pssaAppDataPath; + } + } + + /// + /// Module Paths + /// + public string PSModulePath + { + get { return curPSModulePath; } + } + + /// + /// Runspace in which the object invokes powershell cmdlets + /// + public Runspace Runspace + { + get { return runspace; } + set { runspace = value; } + } + + + #endregion Properties + + #region Private Methods + private static void ThrowIfNull(T obj, string name) + { + if (obj == null) + { + throw new ArgumentNullException(name); + } + } + + private void SetupPSSAAppData() + { + // check if pssa exists in local appdata + if (Directory.Exists(pssaAppDataPath)) + { + // check if there is a link + if (File.Exists(symLinkPath)) + { + tempModulePath = GetTempModulePath(symLinkPath); + + // check if the temp dir exists + if (Directory.Exists(tempModulePath)) + { + return; + } + } + } + else + { + Directory.CreateDirectory(pssaAppDataPath); + } + SetupTempDir(); + } + + private bool IsModulePresentInTempModulePath(string moduleName) + { + foreach (var dir in Directory.EnumerateDirectories(TempModulePath)) + { + if (moduleName.Equals(dir, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + return false; + } + + private void SetupTempDir() + { + tempModulePath = GetPSSATempDirPath(); + Directory.CreateDirectory(tempModulePath); + File.WriteAllLines(symLinkPath, new string[] { tempModulePath }); + } + + private string GetPSSATempDirPath() + { + string path; + do + { + path = Path.Combine( + tempPath, + tempPrefix + Path.GetFileNameWithoutExtension(Path.GetRandomFileName())); + } while (Directory.Exists(path)); + return path; + } + + // Return the first line of the file + private string GetTempModulePath(string symLinkPath) + { + string line; + using (var fileStream = new StreamReader(symLinkPath)) + { + line = fileStream.ReadLine(); + } + return line; + } + + private void SaveModule(PSObject module) + { + ThrowIfNull(module, "module"); + + // TODO validate module + using (var ps = System.Management.Automation.PowerShell.Create()) + { + ps.Runspace = runspace; + ps.AddCommand("Save-Module") + .AddParameter("Path", tempModulePath) + .AddParameter("InputObject", module); + ps.Invoke(); + } + } + + private void SetupPSModulePath() + { + oldPSModulePath = Environment.GetEnvironmentVariable("PSModulePath"); + curPSModulePath = oldPSModulePath + ";" + tempModulePath; + Environment.SetEnvironmentVariable("PSModulePath", curPSModulePath, EnvironmentVariableTarget.Process); + } + + private void RestorePSModulePath() + { + Environment.SetEnvironmentVariable("PSModulePath", oldPSModulePath, EnvironmentVariableTarget.Process); + } + #endregion Private Methods + + #region Public Methods + + /// + /// Creates an instance of the ModuleDependencyHandler class + /// + /// Runspace in which the instance runs powershell cmdlets to find and save modules + /// Name of the repository from where to download the modules. By default it is PSGallery. This should be a registered repository. + /// Path to the user scoped temporary directory + /// Path to the local app data directory + public ModuleDependencyHandler( + Runspace runspace, + string moduleRepository = null, + string tempPath = null, + string localAppDataPath = null) + { + + ThrowIfNull(runspace, "runspace"); + if (runspace.RunspaceStateInfo.State != RunspaceState.Opened) + { + throw new ArgumentException(string.Format( + "Runspace state cannot be in {0} state. It must be in Opened state", + runspace.RunspaceStateInfo.State.ToString())); + } + Runspace = runspace; + + // TODO should set PSSA environment variables outside this class + // Should be set in ScriptAnalyzer class + // and then passed into modulehandler + TempPath = tempPath; + LocalAppDataPath = localAppDataPath; + ModuleRepository = moduleRepository; + pssaAppDataPath = Path.Combine( + LocalAppDataPath, + string.IsNullOrWhiteSpace(pssaAppDataPath) + ? "PSScriptAnalyzer" + : pssaAppDataPath); + + modulesFound = new Dictionary(); + + // TODO Add PSSA Version in the path + symLinkPath = Path.Combine(pssaAppDataPath, symLinkName); + SetupPSSAAppData(); + SetupPSModulePath(); + + } + + /// + /// Encapsulates Find-Module + /// + /// Name of the module + /// A PSObject if it finds the modules otherwise returns null + public PSObject FindModule(string moduleName) + { + ThrowIfNull(moduleName, "moduleName"); + moduleName = moduleName.ToLower(); + if (modulesFound.ContainsKey(moduleName)) + { + return modulesFound[moduleName]; + } + Collection modules = null; + using (var ps = System.Management.Automation.PowerShell.Create()) + { + ps.Runspace = runspace; + ps.AddCommand("Find-Module", true) + .AddParameter("Name", moduleName) + .AddParameter("Repository", moduleRepository); + modules = ps.Invoke(); + } + if (modules == null) + { + return null; + } + var module = modules.FirstOrDefault(); + if (module == null ) + { + return null; + } + modulesFound.Add(moduleName, module); + return module; + } + + /// + /// SaveModule version that doesn't throw + /// + /// Name of the module + /// True if it can save a module otherwise false. + public bool TrySaveModule(string moduleName) + { + try + { + SaveModule(moduleName); + return true; + } + catch + { + // log exception to verbose + return false; + } + } + + /// + /// Encapsulates Save-Module cmdlet + /// + /// Name of the module + public void SaveModule(string moduleName) + { + ThrowIfNull(moduleName, "moduleName"); + if (IsModulePresentInTempModulePath(moduleName)) + { + return; + } + using (var ps = System.Management.Automation.PowerShell.Create()) + { + ps.Runspace = runspace; + ps.AddCommand("Save-Module") + .AddParameter("Path", tempModulePath) + .AddParameter("Name", moduleName) + .AddParameter("Repository", moduleRepository) + .AddParameter("Force"); + ps.Invoke(); + } + } + + /// + /// Encapsulates Get-Module to check the availability of the module on the system + /// + /// + /// True indicating the presence of the module, otherwise false + public bool IsModuleAvailable(string moduleName) + { + ThrowIfNull(moduleName, "moduleName"); + IEnumerable availableModules; + using (var ps = System.Management.Automation.PowerShell.Create()) + { + ps.Runspace = runspace; + availableModules = ps.AddCommand("Get-Module") + .AddParameter("Name", moduleName) + .AddParameter("ListAvailable") + .Invoke(); + } + return availableModules != null ? availableModules.Any() : false; + } + + /// + /// Extracts out the module names from the error extent that are not available + /// + /// This handles the following case. + /// Import-DSCResourceModule -ModuleName ModulePresent,ModuleAbsent + /// + /// ModulePresent is present in PSModulePath whereas ModuleAbsent is not. + /// But the error exent coverts the entire extent and hence we need to check + /// which module is actually not present so as to be downloaded + /// + /// + /// + /// An enumeration over the module names that are not available + public IEnumerable GetUnavailableModuleNameFromErrorExtent(ParseError error, ScriptBlockAst ast) + { + ThrowIfNull(error, "error"); + ThrowIfNull(ast, "ast"); + var moduleNames = ModuleDependencyHandler.GetModuleNameFromErrorExtent(error, ast); + if (moduleNames == null) + { + return null; + } + var unavailableModules = new List(); + foreach (var moduleName in moduleNames) + { + if (!IsModuleAvailable(moduleName)) + { + unavailableModules.Add(moduleName); + } + } + //return moduleNames.Where(x => !IsModuleAvailable(x)); + return unavailableModules; + } + + /// + /// Get the module name from the error extent + /// + /// If a parser encounters Import-DSCResource -ModuleName SomeModule + /// and if SomeModule is not present in any of the PSModulePaths, the + /// parser throws ModuleNotFoundDuringParse Error. We correlate the + /// error message with extent to extract the module name as the error + /// record doesn't provide direct access to the missing module name. + /// + /// Parse error + /// AST of the script that contians the parse error + /// The name of the module that caused the parser to throw the error. Returns null if it cannot extract the module name. + public static IEnumerable GetModuleNameFromErrorExtent(ParseError error, ScriptBlockAst ast) + { + ThrowIfNull(error, "error"); + ThrowIfNull(ast, "ast"); + var statement = ast.Find(x => x.Extent.Equals(error.Extent), true); + var dynamicKywdAst = statement as DynamicKeywordStatementAst; + if (dynamicKywdAst == null) + { + return null; + } + // check if the command name is import-dscmodule + // right now we handle only the following forms + // 1. Import-DSCResourceModule -ModuleName somemodule + // 2. Import-DSCResourceModule -ModuleName somemodule1,somemodule2 + if (dynamicKywdAst.CommandElements.Count < 3) + { + return null; + } + + var dscKeywordAst = dynamicKywdAst.CommandElements[0] as StringConstantExpressionAst; + if (dscKeywordAst == null || !dscKeywordAst.Value.Equals("Import-DscResource", StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + // find a parameter named modulename + int k; + for (k = 1; k < dynamicKywdAst.CommandElements.Count; k++) + { + var paramAst = dynamicKywdAst.CommandElements[1] as CommandParameterAst; + // TODO match the initial letters only + if (paramAst == null || !paramAst.ParameterName.Equals("ModuleName", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + break; + } + + if (k == dynamicKywdAst.CommandElements.Count) + { + // cannot find modulename + return null; + } + var modules = new List(); + + // k < count - 1, because only -ModuleName throws parse error and hence not possible + var paramValAst = dynamicKywdAst.CommandElements[++k]; + + // import-dscresource -ModuleName module1 + var paramValStrConstExprAst = paramValAst as StringConstantExpressionAst; + if (paramValStrConstExprAst != null) + { + modules.Add(paramValStrConstExprAst.Value); + return modules; + } + + // import-dscresource -ModuleName module1,module2 + var paramValArrLtrlAst = paramValAst as ArrayLiteralAst; + if (paramValArrLtrlAst != null) + { + foreach (var elem in paramValArrLtrlAst.Elements) + { + var elemStrConstExprAst = elem as StringConstantExpressionAst; + if (elemStrConstExprAst != null) + { + modules.Add(elemStrConstExprAst.Value); + } + } + if (modules.Count == 0) + { + return null; + } + return modules; + } + return null; + } + + /// + /// Disposes the runspace and restores the PSModulePath. + /// + public void Dispose() + { + RestorePSModulePath(); + } + + #endregion Public Methods + } +} \ No newline at end of file diff --git a/Engine/ScriptAnalyzer.cs b/Engine/ScriptAnalyzer.cs index 8dd1fb82b..1f8e239c1 100644 --- a/Engine/ScriptAnalyzer.cs +++ b/Engine/ScriptAnalyzer.cs @@ -45,14 +45,12 @@ public sealed class ScriptAnalyzer List includeRegexList; List excludeRegexList; bool suppressedOnly; - + ModuleDependencyHandler moduleHandler; #endregion #region Singleton private static object syncRoot = new Object(); - private static ScriptAnalyzer instance; - public static ScriptAnalyzer Instance { get @@ -88,6 +86,17 @@ public static ScriptAnalyzer Instance public IEnumerable DSCResourceRules { get; private set; } internal List ExternalRules { get; set; } + public ModuleDependencyHandler ModuleHandler { + get + { + return moduleHandler; + } + + internal set + { + moduleHandler = value; + } + } #endregion @@ -97,9 +106,9 @@ public static ScriptAnalyzer Instance /// Initialize : Initializes default rules, loggers and helper. /// internal void Initialize( - TCmdlet cmdlet, - string[] customizedRulePath = null, - string[] includeRuleNames = null, + TCmdlet cmdlet, + string[] customizedRulePath = null, + string[] includeRuleNames = null, string[] excludeRuleNames = null, string[] severity = null, bool includeDefaultRules = false, @@ -110,7 +119,7 @@ internal void Initialize( { throw new ArgumentNullException("cmdlet"); } - + this.Initialize( cmdlet, cmdlet.SessionState.Path, @@ -127,10 +136,10 @@ internal void Initialize( /// Initialize : Initializes default rules, loggers and helper. /// public void Initialize( - Runspace runspace, - IOutputWriter outputWriter, - string[] customizedRulePath = null, - string[] includeRuleNames = null, + Runspace runspace, + IOutputWriter outputWriter, + string[] customizedRulePath = null, + string[] includeRuleNames = null, string[] excludeRuleNames = null, string[] severity = null, bool includeDefaultRules = false, @@ -204,7 +213,7 @@ internal bool ParseProfile(object profileObject, PathIntrinsics path, IOutputWri hasError = ParseProfileString(profile, path, writer, severityList, includeRuleList, excludeRuleList); } } - + if (hasError) { return false; @@ -269,7 +278,7 @@ private bool ParseProfileHashtable(Hashtable profile, PathIntrinsics path, IOutp hasError = true; continue; } - + // checks whether it falls into list of valid keys if (!validKeys.Contains(key)) { @@ -325,7 +334,7 @@ private bool ParseProfileHashtable(Hashtable profile, PathIntrinsics path, IOutp } AddProfileItem(key, values, severityList, includeRuleList, excludeRuleList); - + } return hasError; @@ -459,10 +468,10 @@ private bool ParseProfileString(string profile, PathIntrinsics path, IOutputWrit } private void Initialize( - IOutputWriter outputWriter, - PathIntrinsics path, - CommandInvocationIntrinsics invokeCommand, - string[] customizedRulePath, + IOutputWriter outputWriter, + PathIntrinsics path, + CommandInvocationIntrinsics invokeCommand, + string[] customizedRulePath, string[] includeRuleNames, string[] excludeRuleNames, string[] severity, @@ -475,6 +484,8 @@ private void Initialize( throw new ArgumentNullException("outputWriter"); } + this.moduleHandler = null; + this.outputWriter = outputWriter; #region Verifies rule extensions and loggers path @@ -576,7 +587,7 @@ private void Initialize( { this.outputWriter.ThrowTerminatingError( new ErrorRecord( - ex, + ex, ex.HResult.ToString("X", CultureInfo.CurrentCulture), ErrorCategory.NotSpecified, this)); } @@ -727,7 +738,7 @@ public IEnumerable GetRule(string[] moduleNames, string[] ruleNames) if (null != ScriptRules) { rules = ScriptRules.Union(TokenRules).Union(DSCResourceRules); - } + } // Gets PowerShell Rules. if (moduleNames != null) @@ -781,7 +792,7 @@ private List GetExternalRule(string[] moduleNames) { shortModuleName = loadedModules.First().Name; } - + // Invokes Get-Command and Get-Help for each functions in the module. posh.Commands.Clear(); posh.AddCommand("Get-Command").AddParameter("Module", shortModuleName); @@ -801,7 +812,7 @@ private List GetExternalRule(string[] moduleNames) item.Name.EndsWith("token", StringComparison.OrdinalIgnoreCase)); } catch - { + { } //Only add functions that are defined as rules. @@ -811,7 +822,7 @@ private List GetExternalRule(string[] moduleNames) // using Update-Help. This results in an interactive prompt - which we cannot handle // Workaround to prevent Update-Help from running is to set the following reg key // HKLM:\Software\Microsoft\PowerShell\DisablePromptToUpdateHelp - // OR execute Update-Help in an elevated admin mode before running ScriptAnalyzer + // OR execute Update-Help in an elevated admin mode before running ScriptAnalyzer Collection helpContent = null; try { @@ -831,11 +842,11 @@ private List GetExternalRule(string[] moduleNames) dynamic description = helpContent[0].Properties["Description"]; if (null != description && null != description.Value && description.Value.GetType().IsArray) - { + { desc = description.Value[0].Text; } } - + rules.Add(new ExternalRule(funcInfo.Name, funcInfo.Name, desc, param.Name, param.ParameterType.FullName, funcInfo.ModuleName, funcInfo.Module.Path)); } @@ -980,7 +991,7 @@ internal IEnumerable GetExternalRecord(Ast ast, Token[] token, // DiagnosticRecord may not be correctly returned from external rule. try - { + { severity = (DiagnosticSeverity)Enum.Parse(typeof(DiagnosticSeverity), psobject.Properties["Severity"].Value.ToString()); message = psobject.Properties["Message"].Value.ToString(); extent = (IScriptExtent)psobject.Properties["Extent"].Value; @@ -1028,11 +1039,11 @@ public Dictionary> CheckRuleExtension(string[] path, PathIn string resolvedPath = string.Empty; - // Users may provide a valid module path or name, + // Users may provide a valid module path or name, // We have to identify the childPath is really a directory or just a module name. // You can also consider following two commands. // Get-ScriptAnalyzerRule -RuleExtension "ContosoAnalyzerRules" - // Get-ScriptAnalyzerRule -RuleExtension "%USERPROFILE%\WindowsPowerShell\Modules\ContosoAnalyzerRules" + // Get-ScriptAnalyzerRule -RuleExtension "%USERPROFILE%\WindowsPowerShell\Modules\ContosoAnalyzerRules" if (Path.GetDirectoryName(childPath) == string.Empty) { resolvedPath = childPath; @@ -1041,21 +1052,21 @@ public Dictionary> CheckRuleExtension(string[] path, PathIn { resolvedPath = basePath .GetResolvedPSPathFromPSPath(childPath).First().ToString(); - } - + } + // Import the module - InitialSessionState state = InitialSessionState.CreateDefault2(); + InitialSessionState state = InitialSessionState.CreateDefault2(); using (System.Management.Automation.PowerShell posh = System.Management.Automation.PowerShell.Create(state)) - { + { posh.AddCommand("Import-Module").AddArgument(resolvedPath).AddParameter("PassThru"); Collection loadedModules = posh.Invoke(); - if (loadedModules != null + if (loadedModules != null && loadedModules.Count > 0 && loadedModules.First().ExportedFunctions.Count > 0) - { - validModPaths.Add(resolvedPath); - } + { + validModPaths.Add(resolvedPath); + } } } catch @@ -1134,14 +1145,14 @@ public Dictionary> CheckRuleExtension(string[] path, PathIn } #endregion - + /// /// Analyzes a script file or a directory containing script files. /// /// The path of the file or directory to analyze. /// - /// If true, recursively searches the given file path and analyzes any + /// If true, recursively searches the given file path and analyzes any /// script files that are found. /// /// An enumeration of DiagnosticRecords that were found by rules. @@ -1155,18 +1166,17 @@ public IEnumerable AnalyzePath(string path, bool searchRecursi new ErrorRecord( new FileNotFoundException(), string.Format(CultureInfo.CurrentCulture, Strings.FileNotFound, path), - ErrorCategory.InvalidArgument, + ErrorCategory.InvalidArgument, this)); } - // Precreate the list of script file paths to analyze. This + // Create in advance the list of script file paths to analyze. This // is an optimization over doing the whole operation at once // and calling .Concat on IEnumerables to join results. this.BuildScriptPathList(path, searchRecursively, scriptFilePaths); - foreach (string scriptFilePath in scriptFilePaths) { - // Yield each record in the result so that the + // Yield each record in the result so that the // caller can pull them one at a time foreach (var diagnosticRecord in this.AnalyzeFile(scriptFilePath)) { @@ -1219,8 +1229,8 @@ public IEnumerable AnalyzeScriptDefinition(string scriptDefini } private void BuildScriptPathList( - string path, - bool searchRecursively, + string path, + bool searchRecursively, IList scriptFilePaths) { const string ps1Suffix = ".ps1"; @@ -1266,13 +1276,14 @@ private void BuildScriptPathList( { this.outputWriter.WriteError( new ErrorRecord( - new FileNotFoundException(), - string.Format(CultureInfo.CurrentCulture, Strings.FileNotFound, path), - ErrorCategory.InvalidArgument, + new FileNotFoundException(), + string.Format(CultureInfo.CurrentCulture, Strings.FileNotFound, path), + ErrorCategory.InvalidArgument, this)); } } + private IEnumerable AnalyzeFile(string filePath) { ScriptBlockAst scriptAst = null; @@ -1297,6 +1308,28 @@ private IEnumerable AnalyzeFile(string filePath) return null; } + bool parseAgain = false; + if (moduleHandler != null && errors != null && errors.Length > 0) + { + foreach (ParseError error in errors.Where(IsModuleNotFoundError)) + { + var moduleNames = moduleHandler.GetUnavailableModuleNameFromErrorExtent(error, scriptAst); + if (moduleNames != null) + { + parseAgain |= moduleNames.Any(x => moduleHandler.TrySaveModule(x)); + } + } + } + + //try parsing again + //var oldDefault = Runspace.DefaultRunspace; + //Runspace.DefaultRunspace = moduleHandler.Runspace; + if (parseAgain) + { + scriptAst = Parser.ParseFile(filePath, out scriptTokens, out errors); + } + + //Runspace.DefaultRunspace = oldDefault; if (errors != null && errors.Length > 0) { foreach (ParseError error in errors) @@ -1310,7 +1343,6 @@ private IEnumerable AnalyzeFile(string filePath) { string manyParseErrorMessage = String.Format(CultureInfo.CurrentCulture, Strings.ParserErrorMessage, System.IO.Path.GetFileName(filePath)); this.outputWriter.WriteError(new ErrorRecord(new ParseException(manyParseErrorMessage), manyParseErrorMessage, ErrorCategory.ParserError, filePath)); - return new List(); } } @@ -1327,19 +1359,25 @@ private IEnumerable AnalyzeFile(string filePath) return this.AnalyzeSyntaxTree(scriptAst, scriptTokens, filePath); } + private bool IsModuleNotFoundError(ParseError error) + { + return error.ErrorId != null + && error.ErrorId.Equals("ModuleNotFoundDuringParse", StringComparison.OrdinalIgnoreCase); + } + private bool IsSeverityAllowed(IEnumerable allowedSeverities, IRule rule) { - return severity == null - || (allowedSeverities != null - && rule != null - && HasGetSeverity(rule) + return severity == null + || (allowedSeverities != null + && rule != null + && HasGetSeverity(rule) && allowedSeverities.Contains((uint)rule.GetSeverity())); } IEnumerable GetAllowedSeveritiesInInt() { - return severity != null - ? severity.Select(item => (uint)Enum.Parse(typeof(DiagnosticSeverity), item, true)) + return severity != null + ? severity.Select(item => (uint)Enum.Parse(typeof(DiagnosticSeverity), item, true)) : null; } @@ -1420,8 +1458,8 @@ private Tuple, List> SuppressRule( /// /// An enumeration of DiagnosticRecords that were found by rules. public IEnumerable AnalyzeSyntaxTree( - ScriptBlockAst scriptAst, - Token[] scriptTokens, + ScriptBlockAst scriptAst, + Token[] scriptTokens, string filePath) { Dictionary> ruleSuppressions = new Dictionary>(); @@ -1463,7 +1501,7 @@ public IEnumerable AnalyzeSyntaxTree( Helper.Instance.Tokens = scriptTokens; } - + #region Run ScriptRules //Trim down to the leaf element of the filePath and pass it to Diagnostic Record string fileName = filePathIsNullOrWhiteSpace ? String.Empty : System.IO.Path.GetFileName(filePath); @@ -1495,8 +1533,8 @@ public IEnumerable AnalyzeSyntaxTree( List suppressRuleErrors; var ruleRecords = scriptRule.AnalyzeScript(scriptAst, scriptAst.Extent.File).ToList(); var records = Helper.Instance.SuppressRule( - scriptRule.GetName(), - ruleSuppressions, + scriptRule.GetName(), + ruleSuppressions, ruleRecords, out suppressRuleErrors); result.AddRange(suppressRuleErrors); diff --git a/Engine/ScriptAnalyzerEngine.csproj b/Engine/ScriptAnalyzerEngine.csproj index 84442e131..abf8b7b6c 100644 --- a/Engine/ScriptAnalyzerEngine.csproj +++ b/Engine/ScriptAnalyzerEngine.csproj @@ -70,6 +70,7 @@ + diff --git a/Tests/Engine/InvokeScriptAnalyzer.tests.ps1 b/Tests/Engine/InvokeScriptAnalyzer.tests.ps1 index 953a4f6c1..bbdf62dda 100644 --- a/Tests/Engine/InvokeScriptAnalyzer.tests.ps1 +++ b/Tests/Engine/InvokeScriptAnalyzer.tests.ps1 @@ -19,7 +19,7 @@ Describe "Test available parameters" { It "has a Path parameter" { $params.ContainsKey("Path") | Should Be $true } - + It "accepts string" { $params["Path"].ParameterType.FullName | Should Be "System.String" } @@ -29,8 +29,8 @@ Describe "Test available parameters" { It "has a ScriptDefinition parameter" { $params.ContainsKey("ScriptDefinition") | Should Be $true } - - It "accepts string" { + + It "accepts string" { $params["ScriptDefinition"].ParameterType.FullName | Should Be "System.String" } } @@ -69,6 +69,19 @@ Describe "Test available parameters" { } } + if (!$testingLibraryUsage) + { + Context "SaveDSCResourceDependency parameter" { + It "has the parameter" { + $params.ContainsKey("SaveDscResourceDependency") | Should Be $true + } + + It "is a switch parameter" { + $params["SaveDscResourceDependency"].ParameterType.FullName | Should Be "System.Management.Automation.SwitchParameter" + } + } + } + Context "It has 2 parameter sets: File and ScriptDefinition" { It "Has 2 parameter sets" { $sa.ParameterSets.Count | Should Be 2 @@ -144,14 +157,14 @@ Describe "Test Path" { if (!$testingLibraryUsage) { #There is probably a more concise way to do this but for now we will settle for this! - Function GetFreeDrive ($freeDriveLen) { + Function GetFreeDrive ($freeDriveLen) { $ordA = 65 $ordZ = 90 $freeDrive = "" $freeDriveName = "" do{ $freeDriveName = (1..$freeDriveLen | %{[char](Get-Random -Maximum $ordZ -Minimum $ordA)}) -join '' - $freeDrive = $freeDriveName + ":" + $freeDrive = $freeDriveName + ":" }while(Test-Path $freeDrive) $freeDrive, $freeDriveName } @@ -180,7 +193,7 @@ Describe "Test Path" { Context "When given a directory" { $withoutPathWithDirectory = Invoke-ScriptAnalyzer -Recurse $directory\RecursionDirectoryTest $withPathWithDirectory = Invoke-ScriptAnalyzer -Recurse -Path $directory\RecursionDirectoryTest - + It "Has the same count as without Path parameter"{ $withoutPathWithDirectory.Count -eq $withPathWithDirectory.Count | Should Be $true } @@ -206,7 +219,7 @@ Describe "Test ExcludeRule" { It "excludes 3 rules" { $noViolations = Invoke-ScriptAnalyzer $directory\..\Rules\BadCmdlet.ps1 -ExcludeRule $rules | Where-Object {$rules -contains $_.RuleName} - $noViolations.Count | Should Be 0 + $noViolations.Count | Should Be 0 } } @@ -329,13 +342,13 @@ Describe "Test CustomizedRulePath" { It "When supplied with a collection of paths" { $customizedRulePath = Invoke-ScriptAnalyzer $directory\TestScript.ps1 -CustomRulePath ("$directory\CommunityAnalyzerRules", "$directory\SampleRule", "$directory\SampleRule\SampleRule2") $customizedRulePath.Count | Should Be 3 - } + } } Context "When used incorrectly" { - It "file cannot be found" { + It "file cannot be found" { try { Invoke-ScriptAnalyzer $directory\TestScript.ps1 -CustomRulePath "Invalid CustomRulePath" @@ -343,9 +356,9 @@ Describe "Test CustomizedRulePath" { catch { if (-not $testingLibraryUsage) - { - $Error[0].FullyQualifiedErrorId | should match "PathNotFound,Microsoft.Windows.PowerShell.ScriptAnalyzer.Commands.InvokeScriptAnalyzerCommand" - } + { + $Error[0].FullyQualifiedErrorId | should match "PathNotFound,Microsoft.Windows.PowerShell.ScriptAnalyzer.Commands.InvokeScriptAnalyzerCommand" + } } } } diff --git a/Tests/Engine/MissingDSCResource.ps1 b/Tests/Engine/MissingDSCResource.ps1 new file mode 100644 index 000000000..c61a94cc5 --- /dev/null +++ b/Tests/Engine/MissingDSCResource.ps1 @@ -0,0 +1,4 @@ +Configuration SomeConfiguration +{ + Import-DscResource -ModuleName MyDSCResource +} \ No newline at end of file diff --git a/Tests/Engine/ModuleDependencyHandler.tests.ps1 b/Tests/Engine/ModuleDependencyHandler.tests.ps1 new file mode 100644 index 000000000..225fe8082 --- /dev/null +++ b/Tests/Engine/ModuleDependencyHandler.tests.ps1 @@ -0,0 +1,162 @@ +if (!(Get-Module PSScriptAnalyzer) -and !$testingLibraryUsage) +{ + Import-Module PSScriptAnalyzer +} + +if ($testingLibraryUsage) +{ + return +} + +$directory = Split-Path -Parent $MyInvocation.MyCommand.Path +$violationFileName = 'MissingDSCResource.ps1' +$violationFilePath = Join-Path $directory $violationFileName + +Describe "Resolve DSC Resource Dependency" { + + Function Test-EnvironmentVariables($oldEnv) + { + $newEnv = Get-Item Env:\* | Sort-Object -Property Key + $newEnv.Count | Should Be $oldEnv.Count + foreach ($index in 1..$newEnv.Count) + { + $newEnv[$index].Key | Should Be $oldEnv[$index].Key + $newEnv[$index].Value | Should Be $oldEnv[$index].Value + } + } + + Context "Module handler class" { + $moduleHandlerType = [Microsoft.Windows.PowerShell.ScriptAnalyzer.Generic.ModuleDependencyHandler] + $oldEnvVars = Get-Item Env:\* | Sort-Object -Property Key + $oldPSModulePath = $env:PSModulePath + It "Sets defaults correctly" { + $rsp = [runspacefactory]::CreateRunspace() + $rsp.Open() + $depHandler = $moduleHandlerType::new($rsp) + + $expectedPath = [System.IO.Path]::GetTempPath() + $depHandler.TempPath | Should Be $expectedPath + + $expectedLocalAppDataPath = [System.Environment]::GetFolderPath([System.Environment+SpecialFolder]::LocalApplicationData) + $depHandler.LocalAppDataPath | Should Be $expectedLocalAppDataPath + + $expectedModuleRepository = "PSGallery" + $depHandler.ModuleRepository | Should Be $expectedModuleRepository + + $expectedPssaAppDataPath = Join-Path $depHandler.LocalAppDataPath "PSScriptAnalyzer" + $depHandler.PSSAAppDataPath | Should Be $expectedPssaAppDataPath + + $expectedPSModulePath = $oldPSModulePath + [System.IO.Path]::PathSeparator + $depHandler.TempModulePath + $env:PSModulePath | Should Be $expectedPSModulePath + + $depHandler.Dispose() + $rsp.Dispose() + } + + It "Keeps the environment variables unchanged" { + Test-EnvironmentVariables($oldEnvVars) + } + + It "Throws if runspace is null" { + {$moduleHandlerType::new($null)} | Should Throw + } + + It "Throws if runspace is not opened" { + $rsp = [runspacefactory]::CreateRunspace() + {$moduleHandlerType::new($rsp)} | Should Throw + $rsp.Dispose() + } + + It "Extracts 1 module name" { + $sb = @" +{Configuration SomeConfiguration +{ + Import-DscResource -ModuleName SomeDscModule1 +}} +"@ + $tokens = $null + $parseError = $null + $ast = [System.Management.Automation.Language.Parser]::ParseInput($sb, [ref]$tokens, [ref]$parseError) + $resultModuleNames = $moduleHandlerType::GetModuleNameFromErrorExtent($parseError[0], $ast).ToArray() + $resultModuleNames[0] | Should Be 'SomeDscModule1' + } + + It "Extracts more than 1 module names" { + $sb = @" +{Configuration SomeConfiguration +{ + Import-DscResource -ModuleName SomeDscModule1,SomeDscModule2,SomeDscModule3 +}} +"@ + $tokens = $null + $parseError = $null + $ast = [System.Management.Automation.Language.Parser]::ParseInput($sb, [ref]$tokens, [ref]$parseError) + $resultModuleNames = $moduleHandlerType::GetModuleNameFromErrorExtent($parseError[0], $ast).ToArray() + $resultModuleNames[0] | Should Be 'SomeDscModule1' + $resultModuleNames[1] | Should Be 'SomeDscModule2' + $resultModuleNames[2] | Should Be 'SomeDscModule3' + } + } + + Context "Invoke-ScriptAnalyzer without switch" { + It "Has parse errors" { + $dr = Invoke-ScriptAnalyzer -Path $violationFilePath -ErrorVariable parseErrors -ErrorAction SilentlyContinue + $parseErrors.Count | Should Be 1 + } + } + + Context "Invoke-ScriptAnalyzer without switch but with module in temp path" { + $oldEnvVars = Get-Item Env:\* | Sort-Object -Property Key + $moduleName = "MyDscResource" + $modulePath = Join-Path (Join-Path (Join-Path (Split-Path $directory) "Rules") "DSCResources") $moduleName + # Save the current environment variables + $oldLocalAppDataPath = $env:LOCALAPPDATA + $oldTempPath = $env:TEMP + $oldPSModulePath = $env:PSModulePath + + # set the environment variables + $tempPath = Join-Path $oldTempPath ([guid]::NewGUID()).ToString() + $newLocalAppDataPath = Join-Path $tempPath "LocalAppData" + $newTempPath = Join-Path $tempPath "Temp" + $env:LOCALAPPDATA = $newLocalAppDataPath + $env:TEMP = $newTempPath + + # create the temporary directories + New-Item -Type Directory -Path $newLocalAppDataPath + New-Item -Type Directory -Path $newTempPath + + # create and dispose module dependency handler object + # to setup the temporary module + $rsp = [runspacefactory]::CreateRunspace() + $rsp.Open() + $depHandler = [Microsoft.Windows.PowerShell.ScriptAnalyzer.Generic.ModuleDependencyHandler]::new($rsp) + $pssaAppDataPath = $depHandler.PSSAAppDataPath + $tempModulePath = $depHandler.TempModulePath + $rsp.Dispose() + $depHandler.Dispose() + + # copy myresource module to the temporary location + # we could let the module dependency handler download it from psgallery + Copy-Item -Recurse -Path $modulePath -Destination $tempModulePath + + It "Doesn't have parse errors" { + # invoke script analyzer + $dr = Invoke-ScriptAnalyzer -Path $violationFilePath -ErrorVariable parseErrors -ErrorAction SilentlyContinue + $dr.Count | Should Be 0 + } + + It "Keeps PSModulePath unchanged before and after invocation" { + $dr = Invoke-ScriptAnalyzer -Path $violationFilePath -ErrorVariable parseErrors -ErrorAction SilentlyContinue + $env:PSModulePath | Should Be $oldPSModulePath + } + #restore environment variables and clean up temporary location + $env:LOCALAPPDATA = $oldLocalAppDataPath + $env:TEMP = $oldTempPath + Remove-Item -Recurse -Path $tempModulePath -Force + Remove-Item -Recurse -Path $tempPath -Force + + It "Keeps the environment variables unchanged" { + Test-EnvironmentVariables($oldEnvVars) + } + } +} \ No newline at end of file