diff --git a/README.md b/README.md index 3ca92c5..78c65a1 100644 --- a/README.md +++ b/README.md @@ -10,10 +10,37 @@ This repository currently targets `.NET 10` and includes the WinForms Configurat Bentley Configuration Explorer was migrated to .NET 10 and dropped usage of the insecure BinaryFormatter. Any binary configuration files (*.bcf) from old versions need to be re-created from the workspace files going forward. +JSON workspace files use an explicit allowlist of persisted model and collection types. Unknown `$type` metadata is rejected without resolving or loading the named assembly. Object references are preserved for compatibility with existing JSON `.bcf` files, and JSON nesting is limited to 128 levels. Do not open untrusted `.bcf` files with older builds that lack this restriction. + +Saved file graphs are validated before activation and before File History renders them. Child relationships must form a forest with unique ownership, consistent depths, at most 10,000 files, and at most 64 child levels. Normal parent/workspace back-references remain supported. Include processing uses the same file-count/depth limits and stops active include cycles using normalized, case-insensitive paths. Files may still be included again after their earlier processing completes. Limit failures record an error and abort processing. + +Reload reads product metadata from embedded string resources using a data-only PE reader. It never loads the product DLL or invokes its code, even when the workspace supplies the executable path. Non-string resources are not deserialized. Missing or invalid DLLs are skipped; product metadata implemented only by computed getters is no longer evaluated. + +"Open in Editor" tries Notepad++, Visual Studio Code, then Windows Notepad. Only executable paths in their standard Program Files or per-user Programs installation directories are considered; Notepad is located in the Windows system directory. Editor lookup never uses `PATH` or the current directory. Files are passed as separate arguments to the editor, never launched through file associations or executed directly. Nonstandard or portable editor installations are not automatically discovered. + +Spreadsheet XML exports encode cell values, column headers, and worksheet names as literal text/attributes. Workspace values cannot add spreadsheet markup or formula attributes. + ## Build requirements - Visual Studio with .NET 10 SDK and Windows desktop development tools +## Persistence regression checks + +From a Visual Studio Developer PowerShell, build and run the dependency-free regression runner against the real application: + +```powershell +msbuild tests/WorkspacePersistence/WorkspacePersistence.csproj /restore /p:Configuration=Debug /p:Platform=x64 +dotnet tests/WorkspacePersistence/bin/x64/Debug/net10.0-windows/WorkspacePersistence.dll +``` + +An optional argument supplies a malicious `.bcf` fixture to verify rejection. Run malicious fixtures only against the patched build. The checks cover existing-format compatibility, save/load round-trips, shared and circular references, unauthorized types at all three ArrayList entry points, assembly spoofing, and excessive nesting. + +Reload checks also use a temporary product DLL with a marker-writing initializer to verify that opening and reprocessing a saved workspace reads string metadata without loading or executing that DLL. Non-string resources, short versions, missing/corrupt images, and the public metadata accessors are covered as well. + +Editor checks inspect launch commands without opening editor windows, including line arguments, executable-looking filenames, and lookalike editor executables in an untrusted current directory and `PATH`. + +Additional checks cover child-file cycles, shared-child amplification, actual reference-graph depth, file-count limits, XML/formula injection in both spreadsheet exporters, and real parser processing of self-includes, mutual cycles, deep include chains, and legitimate repeated includes. No Excel application is launched. + ## Recommended project direction The recommended future split is: diff --git a/WorkspaceCFG.sln b/WorkspaceCFG.sln index 37c3137..c49186d 100644 --- a/WorkspaceCFG.sln +++ b/WorkspaceCFG.sln @@ -1,4 +1,4 @@ - + Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 18 VisualStudioVersion = 18.7.11925.98 @@ -10,26 +10,51 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution .editorconfig = .editorconfig EndProjectSection EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05-4346-4AA6-1389-037BE0695223}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WorkspacePersistence", "tests\WorkspacePersistence\WorkspacePersistence.csproj", "{14624ADD-507F-412C-A7AF-DE1993A8C841}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 Release|Any CPU = Release|Any CPU Release|x64 = Release|x64 + Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {D6AEB0D2-FCAC-0DA5-0398-811207F078F8}.Debug|Any CPU.ActiveCfg = Debug|x64 {D6AEB0D2-FCAC-0DA5-0398-811207F078F8}.Debug|Any CPU.Build.0 = Debug|x64 {D6AEB0D2-FCAC-0DA5-0398-811207F078F8}.Debug|x64.ActiveCfg = Debug|x64 {D6AEB0D2-FCAC-0DA5-0398-811207F078F8}.Debug|x64.Build.0 = Debug|x64 + {D6AEB0D2-FCAC-0DA5-0398-811207F078F8}.Debug|x86.ActiveCfg = Debug|Any CPU + {D6AEB0D2-FCAC-0DA5-0398-811207F078F8}.Debug|x86.Build.0 = Debug|Any CPU {D6AEB0D2-FCAC-0DA5-0398-811207F078F8}.Release|Any CPU.ActiveCfg = Release|x64 {D6AEB0D2-FCAC-0DA5-0398-811207F078F8}.Release|Any CPU.Build.0 = Release|x64 {D6AEB0D2-FCAC-0DA5-0398-811207F078F8}.Release|x64.ActiveCfg = Release|x64 {D6AEB0D2-FCAC-0DA5-0398-811207F078F8}.Release|x64.Build.0 = Release|x64 + {D6AEB0D2-FCAC-0DA5-0398-811207F078F8}.Release|x86.ActiveCfg = Release|Any CPU + {D6AEB0D2-FCAC-0DA5-0398-811207F078F8}.Release|x86.Build.0 = Release|Any CPU + {14624ADD-507F-412C-A7AF-DE1993A8C841}.Debug|Any CPU.ActiveCfg = Debug|x64 + {14624ADD-507F-412C-A7AF-DE1993A8C841}.Debug|Any CPU.Build.0 = Debug|x64 + {14624ADD-507F-412C-A7AF-DE1993A8C841}.Debug|x64.ActiveCfg = Debug|x64 + {14624ADD-507F-412C-A7AF-DE1993A8C841}.Debug|x64.Build.0 = Debug|x64 + {14624ADD-507F-412C-A7AF-DE1993A8C841}.Debug|x86.ActiveCfg = Debug|x64 + {14624ADD-507F-412C-A7AF-DE1993A8C841}.Debug|x86.Build.0 = Debug|x64 + {14624ADD-507F-412C-A7AF-DE1993A8C841}.Release|Any CPU.ActiveCfg = Release|x64 + {14624ADD-507F-412C-A7AF-DE1993A8C841}.Release|Any CPU.Build.0 = Release|x64 + {14624ADD-507F-412C-A7AF-DE1993A8C841}.Release|x64.ActiveCfg = Release|x64 + {14624ADD-507F-412C-A7AF-DE1993A8C841}.Release|x64.Build.0 = Release|x64 + {14624ADD-507F-412C-A7AF-DE1993A8C841}.Release|x86.ActiveCfg = Release|x64 + {14624ADD-507F-412C-A7AF-DE1993A8C841}.Release|x86.Build.0 = Release|x64 EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {14624ADD-507F-412C-A7AF-DE1993A8C841} = {0AB3BF05-4346-4AA6-1389-037BE0695223} + EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {0C06F610-419D-4EB9-BF92-9780FE09A2FF} EndGlobalSection diff --git a/WorkspaceCFG/CFGConfiguration.cs b/WorkspaceCFG/CFGConfiguration.cs index aaf81f4..e7cbb81 100644 --- a/WorkspaceCFG/CFGConfiguration.cs +++ b/WorkspaceCFG/CFGConfiguration.cs @@ -22,11 +22,60 @@ namespace WorkspaceCFG [JsonObject(MemberSerialization.Fields)] public class CFGConfiguration { + internal const int MaxFileDepth = 64; + internal const int MaxConfigurationFiles = 10000; + + internal void ValidateFileGraph() + { + if (CFGFiles == null || CFGFiles.Count > MaxConfigurationFiles) + throw new JsonSerializationException("Invalid workspace file graph size."); + + var files = new HashSet(); + foreach (object entry in CFGFiles) + { + if (entry is not CFGFile file || file.ChildFiles == null || !files.Add(file)) + throw new JsonSerializationException("Invalid workspace file graph entry."); + } + + var children = new HashSet(); + foreach (CFGFile file in files) + { + foreach (object entry in file.ChildFiles) + { + if (entry is not CFGFile child || !files.Contains(child) || !children.Add(child)) + throw new JsonSerializationException("Invalid or repeated workspace child file."); + } + } + + var pending = new Stack<(CFGFile File, int Depth)>(); + foreach (CFGFile file in files) + { + if (!children.Contains(file)) + pending.Push((file, 0)); + } + + int visited = 0; + while (pending.Count > 0) + { + var current = pending.Pop(); + if (current.Depth > MaxFileDepth || current.File.Depth != current.Depth) + throw new JsonSerializationException("Invalid workspace file depth."); + + visited++; + foreach (CFGFile child in current.File.ChildFiles) + pending.Push((child, current.Depth + 1)); + } + + if (visited != files.Count) + throw new JsonSerializationException("Cyclic workspace child-file graph."); + } // --------------------------------------------------------------------------------------- // Private Members // ---------------+---------------+---------------+---------------+---------------+------- private int _passCount; + [JsonIgnore] + private HashSet _activeFilePaths; // --------------------------------------------------------------------------------------- // Public Members @@ -76,11 +125,11 @@ public CFGConfiguration() // ---------------+---------------+---------------+---------------+---------------+------- ~CFGConfiguration() { - Variables.Clear(); + Variables?.Clear(); Variables = null; - CFGFiles.Clear(); + CFGFiles?.Clear(); CFGFiles = null; - CFGEvents.Clear(); + CFGEvents?.Clear(); CFGEvents = null; } @@ -370,53 +419,49 @@ private void Set_PRODUCT() string dirLocation = Path.GetDirectoryName(exePath); if (string.IsNullOrEmpty(dirLocation)) return; - Type classType = null; - System.Reflection.Assembly productAssembly; try { - // Load the assembly from the specified path. - productAssembly = System.Reflection.Assembly.LoadFile( + var productInfo = ProductMetadata.Read( Path.Combine(dirLocation, "Bentley.PowerPlatform.FeatureAspects.dll")); - classType = productAssembly.GetType("Bentley.PowerPlatform.Resx"); + if (productInfo.Count > 0) + SetProductInfo(productInfo); } catch (Exception ex) { Debug.WriteLine($" [ERROR] {GetType().Name}.{System.Reflection.MethodBase.GetCurrentMethod().Name} 1 : {ex.Message}"); - // This happens when we are trying to load a 32-bit dll. } - - if (classType is null) - { - return; - } - - SetProductInfo(ref classType); } } public void SetProductInfo(ref Type classType) + { + if (classType != null) + SetProductInfo(ProductMetadata.Read(classType.Assembly.Location)); + } + + private void SetProductInfo(Dictionary productInfo) { var msv = new CFGEnums.MSVersion(); object property_value; // TODO: This is not returning the proper variable. // productConfigVarName - > _USTN_PRODUCT_CONFIGVARNAME - property_value = GetPropertyInfoValue(ref classType, "ProductNames_ConfigVarName"); + property_value = productInfo.GetValueOrDefault("ProductNames_ConfigVarName"); if (property_value is null) property_value = ""; msv.productConfigVarName = property_value.ToString(); RedefineVariable("_USTN_PRODUCT_CONFIGVARNAME", property_value.ToString(), -1, false, true, "TXT_MsgVariableCreatedFromProductConfigVarName"); // productDirectoryName - > _USTN_PRODUCT_DIRNAME - property_value = GetPropertyInfoValue(ref classType, "ProductNames_DirectoryName"); + property_value = productInfo.GetValueOrDefault("ProductNames_DirectoryName"); if (property_value is null) property_value = ""; msv.productDirectoryName = property_value.ToString(); RedefineVariable("_USTN_PRODUCT_DIRNAME", property_value.ToString(), -1, false, true, "TXT_MsgVariableCreatedFromProductDirectoryName"); // productFileBaseName - > _USTN_PRODUCT_FILEBASENAME - property_value = GetPropertyInfoValue(ref classType, "ProductNames_FileBaseName"); + property_value = productInfo.GetValueOrDefault("ProductNames_FileBaseName"); if (property_value is null) property_value = ""; msv.productFileBaseName = property_value.ToString(); @@ -424,35 +469,35 @@ public void SetProductInfo(ref Type classType) // productFullMarketingName -> _USTN_PRODUCT_FILETYPENAME - property_value = GetPropertyInfoValue(ref classType, "ProductNames_FileTypeName"); + property_value = productInfo.GetValueOrDefault("ProductNames_FileTypeName"); if (property_value is null) property_value = ""; msv.productFileTypeName = property_value.ToString(); RedefineVariable("_USTN_PRODUCT_FILETYPENAME", property_value.ToString(), -1, false, true, "TXT_MsgVariableCreatedFromProductFileTypeName"); // productFullMarketingName -> _USTN_PRODUCT_FULLMARKETINGNAME - property_value = GetPropertyInfoValue(ref classType, "ProductNames_FullMarketingName"); + property_value = productInfo.GetValueOrDefault("ProductNames_FullMarketingName"); if (property_value is null) property_value = ""; msv.productFullMarketingName = property_value.ToString(); RedefineVariable("_USTN_PRODUCT_FULLMARKETINGNAME", property_value.ToString(), -1, false, true, "TXT_MsgVariableCreatedFromProductFullMarketingName"); // productGroupName -> _USTN_PRODUCT_ONE_GROUPNAME - property_value = GetPropertyInfoValue(ref classType, "ProductNames_GroupName"); + property_value = productInfo.GetValueOrDefault("ProductNames_GroupName"); if (property_value is null) property_value = ""; msv.productGroupName = property_value.ToString(); RedefineVariable("_USTN_PRODUCT_ONE_GROUPNAME", property_value.ToString(), -1, false, true, "TXT_MsgVariableCreatedFromProductGroupName"); // productProductName -> _USTN_PRODUCT_NAME - property_value = GetPropertyInfoValue(ref classType, "ProductNames_ProductName"); + property_value = productInfo.GetValueOrDefault("ProductNames_ProductName"); if (property_value is null) property_value = ""; msv.productProductName = property_value.ToString(); RedefineVariable("_USTN_PRODUCT_NAME", property_value.ToString(), -1, false, true, "TXT_MsgVariableCreatedFromName"); // productShortName -> _USTN_PRODUCT_SHORTNAME, _USTN_PRODUCT_HELPFILENAME, _USTN_HELPNAMESPACE - property_value = GetPropertyInfoValue(ref classType, "ProductNames_ProductShortName"); + property_value = productInfo.GetValueOrDefault("ProductNames_ProductShortName"); if (property_value is null) property_value = ""; msv.productProductShortName = property_value.ToString(); @@ -462,40 +507,48 @@ public void SetProductInfo(ref Type classType) RedefineVariable("_ENGINENAME", property_value.ToString(), -1, false, true, "TXT_MsgVariableCreatedFromProductShortName"); // productShortMarketingName -> _USTN_PRODUCT_SHORTMARKETINGNAME - property_value = GetPropertyInfoValue(ref classType, "ProductNames_ShortMarketingName"); + property_value = productInfo.GetValueOrDefault("ProductNames_ShortMarketingName"); if (property_value is null) property_value = ""; msv.productShortMarketingName = property_value.ToString(); RedefineVariable("_USTN_PRODUCT_SHORTMARKETINGNAME", property_value.ToString(), -1, false, true, "TXT_MsgVariableCreatedFromProductShortMarketingName"); // productSubGroupName -> _USTN_PRODUCT_ONE_SUBGROUPNAME - property_value = GetPropertyInfoValue(ref classType, "ProductNames_SubGroupName"); + property_value = productInfo.GetValueOrDefault("ProductNames_SubGroupName"); if (property_value is null) property_value = ""; msv.productSubGroupName = property_value.ToString(); RedefineVariable("_USTN_PRODUCT_ONE_SUBGROUPNAME", property_value.ToString(), -1, false, true, "TXT_MsgVariableCreatedFromProductSubGroupName"); // productTitlebarName -> _USTN_PRODUCT_TITLEBARNAME - property_value = GetPropertyInfoValue(ref classType, "ProductNames_TitleBarName"); + property_value = productInfo.GetValueOrDefault("ProductNames_TitleBarName"); if (property_value is null) property_value = ""; msv.productTitlebarName = property_value.ToString(); RedefineVariable("_USTN_PRODUCT_TITLEBARNAME", property_value.ToString(), -1, false, true, "TXT_MsgVariableCreatedFromProductTitleBarName"); // productCopyrightYears -> _USTN_PRODUCT_COPYRIGHTYEARS - property_value = GetPropertyInfoValue(ref classType, "CopyrightYears"); + property_value = productInfo.GetValueOrDefault("CopyrightYears"); if (property_value is null) property_value = ""; msv.productCopyrightYears = property_value.ToString(); RedefineVariable("_USTN_PRODUCT_COPYRIGHTYEARS", property_value.ToString(), -1, false, true, "TXT_MsgVariableCreatedFromProductCopyRightYears"); // version -> _USTN_PRODUCT_VERSION_GENERATION, _USTN_PRODUCT_VERSION_MAJOR, _USTN_PRODUCT_VERSION_MINOR, _USTN_PRODUCT_VERSION_BUILD - property_value = GetPropertyInfoValue(ref classType, "ProductVersion"); - if (property_value is null) - property_value = ApplicationData.Version; + property_value = productInfo.GetValueOrDefault("ProductVersion"); + if (!Version.TryParse(property_value?.ToString(), out Version productVersion) && + !Version.TryParse(ApplicationData.Version, out productVersion)) + productVersion = new Version(0, 0, 0, 0); + property_value = productVersion.ToString(); msv.version = property_value.ToString(); //string[] version = Strings.Split(property_value.ToString(), ".", Compare: CompareMethod.Text); - string[] version = property_value.ToString().Split('.'); + string[] version = + { + productVersion.Major.ToString(CultureInfo.InvariantCulture), + productVersion.Minor.ToString(CultureInfo.InvariantCulture), + Math.Max(0, productVersion.Build).ToString(CultureInfo.InvariantCulture), + Math.Max(0, productVersion.Revision).ToString(CultureInfo.InvariantCulture) + }; RedefineVariable("_USTN_PRODUCT_VERSION_GENERATION", version[0], -1, false, true, "TXT_MsgVariableCreatedFromProduct_Version"); RedefineVariable("_USTN_PRODUCT_VERSION_MAJOR", version[1], -1, false, true, "TXT_MsgVariableCreatedFromProduct_Version"); RedefineVariable("_USTN_PRODUCT_VERSION_MINOR", version[2], -1, false, true, "TXT_MsgVariableCreatedFromProduct_Version"); @@ -516,7 +569,7 @@ public void SetProductInfo(ref Type classType) } // _MICROSTATION - if (ApplicationData.Name.Contains("MicroStation")) + if (ApplicationData.Name?.Contains("MicroStation") == true) { RedefineVariable("_MICROSTATION", 1.ToString(), -1, false, true, ""); } @@ -524,16 +577,8 @@ public void SetProductInfo(ref Type classType) public object GetPropertyInfoValue(ref Type classType, string name) { - object property_value = null; - - if (classType != null) - { - var propertyInfo = classType.GetProperty(name); - var getMethodInfo = propertyInfo.GetGetMethod(); - property_value = getMethodInfo.Invoke(null, null); - } - - return property_value; + return classType == null ? null : + ProductMetadata.Read(classType.Assembly.Location).GetValueOrDefault(name); } private void Set_USTN_LocalUserTempPathOld() @@ -969,6 +1014,40 @@ public void ProcessConfiguration() // @description: Begins processing a configuration file // ---------------+---------------+---------------+---------------+---------------+------- public void ProcessFile(string path, CFGFile ParentFile, int level = -999) + { + if (AbortProcessing || string.IsNullOrWhiteSpace(path)) + return; + + path = Path.GetFullPath(path); + _activeFilePaths ??= new HashSet(StringComparer.OrdinalIgnoreCase); + string rejection = null; + if (_activeFilePaths.Contains(path)) + rejection = "Recursive configuration include: " + path; + else if (_activeFilePaths.Count > MaxFileDepth || ParentFile?.Depth >= MaxFileDepth) + rejection = "Configuration include depth limit exceeded: " + path; + else if (CFGFiles.Count >= MaxConfigurationFiles) + rejection = "Configuration file processing limit exceeded: " + path; + + if (rejection != null) + { + AbortProcessing = true; + var eventType = CFGEnums.CFGEventType.cfgError; + AddEvent(ref eventType, ref rejection, file: ParentFile); + return; + } + + _activeFilePaths.Add(path); + try + { + ProcessFileCore(path, ParentFile, level); + } + finally + { + _activeFilePaths.Remove(path); + } + } + + private void ProcessFileCore(string path, CFGFile ParentFile, int level) { // Create a new cfg file object CFGFile cFile; @@ -994,13 +1073,6 @@ public void ProcessFile(string path, CFGFile ParentFile, int level = -999) this.AddEvent(ref argetype, ref argdescription, line: argline, file: argfile, variable: argvariable, varname: argvarname); } - // Removed to allow recursive processing of .cfg files. V8i did not allow recurcive files, but CONNECT does. - // Check to see if the file has been processed already - // If HasFileBeenProcessed(path) Then - // AddEvent(CFGEventType.cfgMessage, String.Format(CEResource.TXT_MsgIncludeIgnoredAlreadyProcessed, path)) - // Exit Sub - // End If - // Check to see if file exists if (File.Exists(path) == false) { diff --git a/WorkspaceCFG/CFGFile.cs b/WorkspaceCFG/CFGFile.cs index 6f8a3b0..25efdb2 100644 --- a/WorkspaceCFG/CFGFile.cs +++ b/WorkspaceCFG/CFGFile.cs @@ -80,14 +80,14 @@ public CFGFile(ref CFGConfiguration wrkspc) ~CFGFile() { ParentConfiguration = null; - ChildFiles.Clear(); + ChildFiles?.Clear(); ChildFiles = null; ParentFile = null; _ifStack = null; - _parentList.Clear(); + _parentList?.Clear(); _parentList = null; _currentLine = null; - Lines.Clear(); + Lines?.Clear(); Lines = null; } diff --git a/WorkspaceCFG/CFGLine.cs b/WorkspaceCFG/CFGLine.cs index dd98033..3f681b7 100644 --- a/WorkspaceCFG/CFGLine.cs +++ b/WorkspaceCFG/CFGLine.cs @@ -80,7 +80,7 @@ public CFGLine() ~CFGLine() { Text = null; - Tokens.Clear(); + Tokens?.Clear(); Tokens = null; ParentFile = null; } diff --git a/WorkspaceCFG/EditorLauncher.cs b/WorkspaceCFG/EditorLauncher.cs new file mode 100644 index 0000000..8f1a2b8 --- /dev/null +++ b/WorkspaceCFG/EditorLauncher.cs @@ -0,0 +1,89 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Diagnostics; +using System.Globalization; +using System.IO; + +namespace WorkspaceCFG +{ + internal static class EditorLauncher + { + internal static bool TryOpen(string filename, int line) + { + foreach (ProcessStartInfo startInfo in GetStartInfos(filename, line)) + { + if (!File.Exists(startInfo.FileName)) + continue; + + try + { + using Process process = Process.Start(startInfo); + if (process != null) + return true; + } + catch (Win32Exception exception) + { + Debug.WriteLine($" [ERROR] EditorLauncher: {exception.Message}"); + } + catch (InvalidOperationException exception) + { + Debug.WriteLine($" [ERROR] EditorLauncher: {exception.Message}"); + } + } + return false; + } + + internal static IEnumerable GetStartInfos(string filename, int line) + { + string fullPath = Path.GetFullPath(filename); + string[] installationRoots = + { + Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), + Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86), + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Programs") + }; + + foreach (string editor in new[] { "Notepad++", "Microsoft VS Code" }) + { + foreach (string root in installationRoots) + { + if (!Path.IsPathFullyQualified(root)) + continue; + + string executable = editor == "Notepad++" ? "notepad++.exe" : "Code.exe"; + var startInfo = CreateStartInfo(Path.Combine(root, editor, executable)); + if (editor == "Notepad++") + { + if (line > 0) + startInfo.ArgumentList.Add("-n" + line.ToString(CultureInfo.InvariantCulture)); + startInfo.ArgumentList.Add(fullPath); + } + else + { + startInfo.ArgumentList.Add("--goto"); + startInfo.ArgumentList.Add(fullPath + ":" + Math.Max(1, line).ToString(CultureInfo.InvariantCulture)); + } + yield return startInfo; + } + } + + if (Path.IsPathFullyQualified(Environment.SystemDirectory)) + { + var startInfo = CreateStartInfo(Path.Combine(Environment.SystemDirectory, "notepad.exe")); + startInfo.ArgumentList.Add(fullPath); + yield return startInfo; + } + } + + private static ProcessStartInfo CreateStartInfo(string executable) + { + return new ProcessStartInfo + { + FileName = executable, + UseShellExecute = false, + WorkingDirectory = Path.GetDirectoryName(executable) + }; + } + } +} \ No newline at end of file diff --git a/WorkspaceCFG/ExcelExporter.cs b/WorkspaceCFG/ExcelExporter.cs index 18dfdde..6fba073 100644 --- a/WorkspaceCFG/ExcelExporter.cs +++ b/WorkspaceCFG/ExcelExporter.cs @@ -4,6 +4,7 @@ // --------------------------------------------------------------------------------------------- using System.Drawing; using System.IO; +using System.Xml.Linq; namespace WorkspaceCFG { @@ -78,10 +79,9 @@ public static void DataGridExport(System.Windows.Forms.DataGridView DG1, System. } } - private static string LimitLength(ref string str) + private static string XmlText(string value) { - // Remove ", >, < from the string - return str.Replace("\"", "").Replace(">", "").Replace("<", ""); + return new XText(value ?? "").ToString(); } private static void WriteCells(ref StreamWriter streamWriter, System.Windows.Forms.DataGridView grdView) @@ -111,9 +111,7 @@ private static void WriteCells(ref StreamWriter streamWriter, System.Windows.For // Check for null cell and change it to empty to avoid error if (string.IsNullOrEmpty(cellText)) cellText = ""; - string localLimitLength() { string argstr = cellText.ToString(); var ret = LimitLength(ref argstr); return ret; } - - streamWriter.WriteLine(string.Format(" " + "{0}", localLimitLength())); + streamWriter.WriteLine(string.Format(" " + "{0}", XmlText(cellText))); } } streamWriter.WriteLine(" "); @@ -143,11 +141,11 @@ private static void WriteCells(ref StreamWriter streamWriter, System.Windows.For { if (DG1.Columns[intCol].Visible) { - cellText = DG1[intCol, i].Value.ToString(); + cellText = DG1[intCol, i].Value?.ToString(); // Check for null cell and change it to empty to avoid error if (string.IsNullOrEmpty(cellText)) cellText = ""; - streamWriter.WriteLine(string.Format(" " + "{0}", cellText.ToString())); + streamWriter.WriteLine(string.Format(" " + "{0}", XmlText(cellText))); } } @@ -158,11 +156,11 @@ private static void WriteCells(ref StreamWriter streamWriter, System.Windows.For { if (DG2.Columns[intCol].Visible) { - cellText = DG2[intCol, i].Value.ToString(); + cellText = DG2[intCol, i].Value?.ToString(); // Check for null cell and change it to empty to avoid error if (string.IsNullOrEmpty(cellText)) cellText = ""; - streamWriter.WriteLine(string.Format(" " + "{0}", cellText.ToString())); + streamWriter.WriteLine(string.Format(" " + "{0}", XmlText(cellText))); } } @@ -185,7 +183,7 @@ private static void WriteColumns(ref StreamWriter streamWriter, System.Windows.F streamWriter.WriteLine(" "); for (int i = 0, loopTo1 = grdView.Columns.Count - 1; i <= loopTo1; i++) - streamWriter.WriteLine(string.Format(" {0}", grdView.Columns[i].HeaderText)); + streamWriter.WriteLine(string.Format(" {0}", XmlText(grdView.Columns[i].HeaderText))); streamWriter.WriteLine(" "); } @@ -221,14 +219,14 @@ private static void WriteColumns(ref StreamWriter streamWriter, System.Windows.F { if (DG1.Columns[i].Visible) { - streamWriter.WriteLine(string.Format(" {0}", DG1.Columns[i].HeaderText)); + streamWriter.WriteLine(string.Format(" {0}", XmlText(DG1.Columns[i].HeaderText))); } } for (int i = 0, loopTo3 = DG1.Columns.Count - 1; i <= loopTo3; i++) { if (DG2.Columns[i].Visible) { - streamWriter.WriteLine(string.Format(" {0}", DG2.Columns[i].HeaderText)); + streamWriter.WriteLine(string.Format(" {0}", XmlText(DG2.Columns[i].HeaderText))); } } streamWriter.WriteLine(" "); @@ -247,7 +245,7 @@ private static void WriteHeader(ref StreamWriter streamWriter, ref string SheetN WriteStyles(ref streamWriter); // Write the worksheet contents - streamWriter.WriteLine(""); + streamWriter.WriteLine(""); streamWriter.WriteLine(" "); } diff --git a/WorkspaceCFG/FileHistory.cs b/WorkspaceCFG/FileHistory.cs index 996cef5..cac4a0b 100644 --- a/WorkspaceCFG/FileHistory.cs +++ b/WorkspaceCFG/FileHistory.cs @@ -146,6 +146,8 @@ private void BuildTree() if (_workspace is null) return; + _workspace.ValidateFileGraph(); + { var withBlock = TreeView1; withBlock.BeginUpdate(); diff --git a/WorkspaceCFG/ProductMetadata.cs b/WorkspaceCFG/ProductMetadata.cs new file mode 100644 index 0000000..c6cfe4f --- /dev/null +++ b/WorkspaceCFG/ProductMetadata.cs @@ -0,0 +1,62 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection.Metadata; +using System.Reflection.PortableExecutable; +using System.Resources; + +namespace WorkspaceCFG +{ + internal static class ProductMetadata + { + internal static Dictionary Read(string path) + { + var values = new Dictionary(StringComparer.Ordinal); + using var stream = File.OpenRead(path); + using var image = new PEReader(stream); + if (!image.HasMetadata || image.PEHeaders.CorHeader.ResourcesDirectory.Size == 0) + return values; + + MetadataReader metadata = image.GetMetadataReader(); + var directory = image.PEHeaders.CorHeader.ResourcesDirectory; + var resources = image.GetSectionData(directory.RelativeVirtualAddress) + .GetContent(0, directory.Size).ToArray(); + foreach (ManifestResourceHandle handle in metadata.ManifestResources) + { + ManifestResource resource = metadata.GetManifestResource(handle); + if (!resource.Implementation.IsNil || + !metadata.GetString(resource.Name).EndsWith(".resources", StringComparison.Ordinal)) + continue; + + using var resourceStream = new MemoryStream(resources, false); + resourceStream.Position = checked((int)resource.Offset); + using var reader = new BinaryReader(resourceStream); + int length = reader.ReadInt32(); + if (length < 0 || length > resourceStream.Length - resourceStream.Position) + throw new BadImageFormatException("Invalid embedded resource length."); + + using var data = new MemoryStream(reader.ReadBytes(length), false); + using var resourceReader = new ResourceReader(data); + IDictionaryEnumerator entries = resourceReader.GetEnumerator(); + while (entries.MoveNext()) + { + string name = (string)entries.Key; + if (!name.StartsWith("ProductNames_", StringComparison.Ordinal) && + name != "ProductVersion" && name != "CopyrightYears") + continue; + + resourceReader.GetResourceData(name, out string type, out byte[] bytes); + if (type != "ResourceTypeCode.String") + continue; + + using var valueStream = new MemoryStream(bytes, false); + using var valueReader = new BinaryReader(valueStream); + values[name] = valueReader.ReadString(); + } + } + return values; + } + } +} \ No newline at end of file diff --git a/WorkspaceCFG/Utilities.cs b/WorkspaceCFG/Utilities.cs index 68c3c50..7826eb7 100644 --- a/WorkspaceCFG/Utilities.cs +++ b/WorkspaceCFG/Utilities.cs @@ -195,7 +195,7 @@ public static void OpenContainingFolder(ref string filename) } // --------------------------------------------------------------------------------------- - // @description: 'Opens a file in notepad++ or else the system's default text editor + // @description: 'Opens a file in an explicitly supported text editor // 'Setting warn = true will warn the user if the file isn't found // 'Line will attempt to open the file at a specific line number // ---------------+---------------+---------------+---------------+---------------+------- @@ -210,16 +210,8 @@ public static void OpenInEditor(ref string Filename, bool warn = true, int line return; } - try - { - Process.Start("notepad++.exe", $"-n{line.ToString().Trim()} \"{Filename}\""); - System.Threading.Thread.Sleep(200); - } - catch - { - Process.Start(Filename); - System.Threading.Thread.Sleep(200); - } + if (!EditorLauncher.TryOpen(Filename, line)) + Debug.WriteLine(" [ERROR] Utilities.OpenInEditor: No supported text editor could be started."); } catch (Exception ex) { diff --git a/WorkspaceCFG/WorkspaceExport.cs b/WorkspaceCFG/WorkspaceExport.cs index a57b6df..35d26f7 100644 --- a/WorkspaceCFG/WorkspaceExport.cs +++ b/WorkspaceCFG/WorkspaceExport.cs @@ -3,6 +3,8 @@ // See COPYRIGHT.md in the repository root for full copyright notice // --------------------------------------------------------------------------------------------- using System; +using System.Collections; +using System.Collections.Generic; using System.Diagnostics; using System.Globalization; using System.IO; @@ -10,6 +12,7 @@ using System.Runtime.InteropServices; using System.Text; using Newtonsoft.Json; +using Newtonsoft.Json.Serialization; using WorkspaceCFG.My.Resources; namespace WorkspaceCFG @@ -25,10 +28,46 @@ public static class WorkspaceExport private static readonly JsonSerializerSettings WorkspaceJsonSettings = new JsonSerializerSettings { TypeNameHandling = TypeNameHandling.Auto, + SerializationBinder = new WorkspaceSerializationBinder(), PreserveReferencesHandling = PreserveReferencesHandling.All, + MaxDepth = 128, Formatting = Formatting.None }; + private sealed class WorkspaceSerializationBinder : ISerializationBinder + { + private static readonly Type[] AllowedTypes = + { + typeof(CFGConfiguration), typeof(CFGEnums.Application), typeof(CFGFile), + typeof(CFGEvent), typeof(CFGLine), typeof(CFGVariable), typeof(CFGToken), + typeof(ArrayList), typeof(Dictionary), + typeof(List), typeof(List), typeof(List), + typeof(string[]) + }; + + public Type BindToType(string assemblyName, string typeName) + { + foreach (Type allowedType in AllowedTypes) + { + if (typeName == allowedType.FullName && + (assemblyName == allowedType.Assembly.GetName().Name || + assemblyName == allowedType.Assembly.FullName)) + return allowedType; + } + + throw new JsonSerializationException("Type is not permitted in a workspace file."); + } + + public void BindToName(Type serializedType, out string assemblyName, out string typeName) + { + if (!AllowedTypes.Contains(serializedType)) + throw new JsonSerializationException("Type is not permitted in a workspace file."); + + assemblyName = serializedType.Assembly.GetName().Name; + typeName = serializedType.FullName; + } + } + // --------------------------------------------------------------------------------------- // @description: Saves a workspace instance to a file // ---------------+---------------+---------------+---------------+---------------+------- @@ -62,7 +101,11 @@ public static void LoadConfiguration(ref string path, ref CFGConfiguration works try { string json = File.ReadAllText(path); - workspace = JsonConvert.DeserializeObject(json, WorkspaceJsonSettings); + CFGConfiguration loaded = JsonConvert.DeserializeObject(json, WorkspaceJsonSettings); + if (loaded == null) + throw new JsonSerializationException("Workspace must not be null."); + loaded.ValidateFileGraph(); + workspace = loaded; } catch (Exception ex) { diff --git a/tests/WorkspacePersistence/ProductMetadata.resx b/tests/WorkspacePersistence/ProductMetadata.resx new file mode 100644 index 0000000..4101012 --- /dev/null +++ b/tests/WorkspacePersistence/ProductMetadata.resx @@ -0,0 +1,12 @@ + + + text/microsoft-resx + 2.0 + System.Resources.ResXResourceReader, System.Windows.Forms + System.Resources.ResXResourceWriter, System.Windows.Forms + RESOURCE_PRODUCT + RegressionProduct + Resource Product + 10.2 + 123 + \ No newline at end of file diff --git a/tests/WorkspacePersistence/Program.cs b/tests/WorkspacePersistence/Program.cs new file mode 100644 index 0000000..03a2085 --- /dev/null +++ b/tests/WorkspacePersistence/Program.cs @@ -0,0 +1,495 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Windows.Forms; +using System.Xml.Linq; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using WorkspaceCFG; + +internal static class Program +{ + private static readonly JsonSerializerSettings Settings = + (JsonSerializerSettings)typeof(WorkspaceExport) + .GetField("WorkspaceJsonSettings", BindingFlags.NonPublic | BindingFlags.Static) + .GetValue(null); + + [STAThread] + private static int Main(string[] args) + { + string directory = Path.Combine(Path.GetTempPath(), "BCE-persistence-" + Guid.NewGuid()); + Directory.CreateDirectory(directory); + try + { + string path = Path.Combine(directory, "workspace.bcf"); + bool silent = true; + CFGConfiguration original = CreateWorkspace(); + WorkspaceExport.SaveWorkspace(ref path, ref original, ref silent); + Assert(File.Exists(path), "Workspace save failed."); + AssertGraph(Load(path)); + Console.WriteLine("PASS: save/load preserves model types, values, cycles and shared references"); + + string legacy = JsonConvert.SerializeObject(CreateWorkspace(), new JsonSerializerSettings + { + TypeNameHandling = TypeNameHandling.Auto, + PreserveReferencesHandling = PreserveReferencesHandling.All + }); + File.WriteAllText(path, legacy); + AssertGraph(Load(path)); + Console.WriteLine("PASS: existing JSON workspace format remains readable"); + + AssertInvalidFileGraphs(directory); + + string deniedType = typeof(ConstructionProbe).AssemblyQualifiedName; + var probe = new JObject { ["$type"] = deniedType }; + foreach (string member in new[] { "CFGFiles", "CFGEvents", "ChildFiles" }) + { + JObject document = Wrap(member, probe); + AssertRejected(document.ToString(), path, "not permitted"); + } + Assert(ConstructionProbe.Created == 0, "Unauthorized constructor was executed."); + Console.WriteLine("PASS: unauthorized constructors blocked at every ArrayList entry point"); + + foreach (string typeName in new[] + { + "System.Windows.Data.ObjectDataProvider, PresentationFramework", + "System.Diagnostics.Process, System.Diagnostics.Process", + "WorkspaceCFG.MainForm, ConfigurationExplorer", + "WorkspaceCFG.CFGFile, UntrustedAssembly", + "System.Collections.Generic.List`1[[System.Diagnostics.Process, System.Diagnostics.Process]], System.Private.CoreLib" + }) + { + AssertRejected(new JObject { ["$type"] = typeName }.ToString(), path, "not permitted"); + } + Console.WriteLine("PASS: gadgets, non-model application types, assembly spoofing and generic types rejected"); + + var deeplyNested = new JObject(); + JObject current = deeplyNested; + for (int depth = 0; depth < 140; depth++) + { + var next = new JObject(); + current["nested"] = next; + current = next; + } + AssertRejected(deeplyNested.ToString(Formatting.None), path, "MaxDepth"); + Console.WriteLine("PASS: excessive JSON nesting rejected"); + + if (args.Length > 0) + { + AssertRejected(File.ReadAllText(args[0]), path, "not permitted"); + Console.WriteLine("PASS: supplied .bcf payload rejected by serializer and real file loader"); + } + AssertSafeReload(directory); + AssertSafeEditors(directory); + AssertSafeSpreadsheetExport(directory); + AssertSafeIncludes(directory); + GC.Collect(); + GC.WaitForPendingFinalizers(); + Console.WriteLine("PASS: partially deserialized models can be finalized safely"); + return 0; + } + catch (Exception exception) + { + Console.Error.WriteLine(exception); + return 1; + } + finally + { + FileWatcher.ClearWatches(); + Directory.Delete(directory, true); + } + } + + private static void AssertSafeEditors(string directory) + { + string filename = Path.Combine(directory, "document with spaces --reuse-window.exe"); + File.WriteAllText(filename, "This file must only be passed to an editor."); + foreach (string name in new[] { "notepad++.exe", "Code.exe", "notepad.exe" }) + File.WriteAllText(Path.Combine(directory, name), "Untrusted editor lookalike"); + + string previousDirectory = Environment.CurrentDirectory; + string previousPath = Environment.GetEnvironmentVariable("PATH"); + try + { + Environment.CurrentDirectory = directory; + Environment.SetEnvironmentVariable("PATH", directory); + Type launcher = typeof(WorkspaceExport).Assembly.GetType("WorkspaceCFG.EditorLauncher", true); + MethodInfo getStartInfos = launcher.GetMethod("GetStartInfos", BindingFlags.NonPublic | BindingFlags.Static); + ProcessStartInfo[] candidates = ((IEnumerable)getStartInfos.Invoke(null, + new object[] { Path.GetFileName(filename), 42 })).ToArray(); + Assert(candidates.Length > 0, "No editor candidates were provided."); + string[] supported = { "notepad++.exe", "Code.exe", "notepad.exe" }; + int previousPreference = -1; + foreach (ProcessStartInfo candidate in candidates) + { + string executable = Path.GetFileName(candidate.FileName); + int preference = Array.FindIndex(supported, name => + name.Equals(executable, StringComparison.OrdinalIgnoreCase)); + Assert(preference >= previousPreference && preference >= 0, "Unexpected editor or preference order."); + previousPreference = preference; + Assert(Path.IsPathFullyQualified(candidate.FileName), "Editor uses executable search-path lookup."); + Assert(!candidate.FileName.StartsWith(directory, StringComparison.OrdinalIgnoreCase), + "An editor was discovered in the untrusted working directory or PATH."); + Assert(!candidate.UseShellExecute && candidate.Arguments == "", "Editor uses shell or string arguments."); + Assert(candidate.WorkingDirectory == Path.GetDirectoryName(candidate.FileName), + "Editor inherits the untrusted working directory."); + Assert(!string.Equals(candidate.FileName, filename, StringComparison.OrdinalIgnoreCase), + "Document was selected as the executable."); + string[] expectedArguments = preference switch + { + 0 => new[] { "-n42", filename }, + 1 => new[] { "--goto", filename + ":42" }, + _ => new[] { filename } + }; + Assert(candidate.ArgumentList.SequenceEqual(expectedArguments), "Document or line arguments changed."); + } + Assert(candidates[^1].FileName.Equals(Path.Combine(Environment.SystemDirectory, "notepad.exe"), + StringComparison.OrdinalIgnoreCase), "Fallback is not the Windows text editor."); + Console.WriteLine("PASS: editor allowlist uses absolute installation paths, structured arguments and safe preference order"); + Console.WriteLine("PASS: executable-looking documents and editor lookalikes on PATH are never selected as commands"); + } + finally + { + Environment.CurrentDirectory = previousDirectory; + Environment.SetEnvironmentVariable("PATH", previousPath); + } + } + + private static void AssertSafeSpreadsheetExport(string directory) + { + string injection = "injected"; + string special = "& < > \" ' =1+1"; + using var first = new DataGridView { AllowUserToAddRows = false }; + using var second = new DataGridView { AllowUserToAddRows = false }; + first.Columns.Add("value", special); + second.Columns.Add("value", injection); + first.Rows.Add(injection); + second.Rows.Add(special); + string sheetName = "Review\" ss:Injected=\"true & < >"; + string path = Path.Combine(directory, "spreadsheet.xml"); + foreach (bool combined in new[] { false, true }) + { + if (combined) + ExcelExporter.DataGridExport(first, second, ref sheetName, path); + else + ExcelExporter.DataGridExport(first, ref sheetName, path); + + XDocument document = XDocument.Load(path); + XNamespace spreadsheet = "urn:schemas-microsoft-com:office:spreadsheet"; + XElement worksheet = document.Descendants(spreadsheet + "Worksheet").Single(); + Assert((string)worksheet.Attribute(spreadsheet + "Name") == sheetName && worksheet.Attributes().Count() == 1, + "Worksheet name was interpreted as XML attributes."); + XElement[] cells = document.Descendants(spreadsheet + "Cell").ToArray(); + Assert(cells.Length == (combined ? 4 : 2), "Cell text injected extra spreadsheet cells."); + Assert(!document.Descendants().Attributes().Any(attribute => attribute.Name.LocalName == "Formula"), + "Cell text was interpreted as a formula attribute."); + string[] values = cells.Select(cell => cell.Element(spreadsheet + "Data").Value).ToArray(); + Assert(values.Contains(injection) && values.Contains(special), "Export did not preserve literal cell/header text."); + Assert(cells.All(cell => (string)cell.Element(spreadsheet + "Data").Attribute(spreadsheet + "Type") == "String"), + "Untrusted values were not exported as strings."); + } + Console.WriteLine("PASS: both spreadsheet exports preserve literal text and reject XML/formula injection"); + } + + private static void AssertSafeIncludes(string directory) + { + string self = Path.Combine(directory, "self.cfg"); + File.WriteAllText(self, "%include SELF.cfg"); + var workspace = new CFGConfiguration(); + workspace.ProcessFile(Path.Combine(directory, ".", "self.cfg"), null); + Assert(workspace.AbortProcessing && workspace.CFGFiles.Count == 1 && workspace.ErrorCount > 0, + "Self-include was not stopped with a recorded error. Abort=" + workspace.AbortProcessing + + ", Files=" + workspace.CFGFiles.Count + ", Errors=" + workspace.ErrorCount + ", Events=" + + string.Join("; ", workspace.CFGEvents.Cast().Select(cfgEvent => cfgEvent.Description))); + + string first = Path.Combine(directory, "mutual-first.cfg"); + string second = Path.Combine(directory, "mutual-second.cfg"); + File.WriteAllText(first, "%include mutual-second.cfg"); + File.WriteAllText(second, "%include mutual-first.cfg"); + workspace = new CFGConfiguration(); + workspace.ProcessFile(first, null); + Assert(workspace.AbortProcessing && workspace.CFGFiles.Count == 2, "Mutual include cycle was not stopped."); + Console.WriteLine("PASS: normalized self-includes and mutual include cycles stop with bounded processing"); + + string leaf = Path.Combine(directory, "leaf.cfg"); + File.WriteAllText(leaf, "INCLUDE_TEST = safe"); + workspace.AbortProcessing = false; + workspace.ProcessFile(leaf, null); + Assert(!workspace.AbortProcessing && workspace.GetVariable("INCLUDE_TEST")?.Value == "safe", + "An aborted include chain left stale active paths."); + string repeat = Path.Combine(directory, "repeat.cfg"); + File.WriteAllText(repeat, "%include leaf.cfg" + Environment.NewLine + "%include leaf.cfg"); + workspace = new CFGConfiguration(); + workspace.ProcessFile(repeat, null); + Assert(!workspace.AbortProcessing && workspace.CFGFiles.Count == 3, "Legitimate repeated includes were blocked."); + Console.WriteLine("PASS: completed files can be included again and active include state unwinds after abort"); + + for (int depth = 0; depth <= 65; depth++) + { + string contents = depth == 65 ? "DEPTH_TEST = safe" : "%include depth-" + (depth + 1) + ".cfg"; + File.WriteAllText(Path.Combine(directory, "depth-" + depth + ".cfg"), contents); + } + workspace = new CFGConfiguration(); + workspace.ProcessFile(Path.Combine(directory, "depth-0.cfg"), null); + Assert(workspace.AbortProcessing && workspace.CFGFiles.Count == 65, "Include depth limit was not enforced."); + + workspace = new CFGConfiguration(); + for (int count = 0; count < 10000; count++) + workspace.CFGFiles.Add(new CFGFile(ref workspace)); + workspace.ProcessFile(leaf, null); + Assert(workspace.AbortProcessing && workspace.CFGFiles.Count == 10000, "Total file-processing limit was not enforced."); + Console.WriteLine("PASS: include depth and total file-processing budgets are enforced"); + } + + private static void AssertSafeReload(string directory) + { + string productDirectory = Path.Combine(directory, "untrusted-product"); + Directory.CreateDirectory(productDirectory); + string productDll = Path.Combine(productDirectory, "Bentley.PowerPlatform.FeatureAspects.dll"); + File.Copy(typeof(Program).Assembly.Location, productDll); + string marker = Path.Combine(directory, "unexpected-code-execution.txt"); + string previousMarker = Environment.GetEnvironmentVariable("BCE_RELOAD_MARKER"); + CultureInfo previousCulture = CultureInfo.CurrentCulture; + Environment.SetEnvironmentVariable("BCE_RELOAD_MARKER", marker); + CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo("en-US"); + try + { + var original = new CFGConfiguration { DisableEventRecording = true }; + original.ApplicationData.Name = "MicroStation Regression"; + original.ApplicationData.EXEPath = Path.Combine(productDirectory, "MicroStation.exe"); + original.ApplicationData.Version = "10.0.0.0"; + original.ApplicationData.StartupCfgPath = Path.Combine(directory, "startup.cfg"); + original.ApplicationData.ConfigurationRoot = directory; + File.WriteAllText(original.ApplicationData.StartupCfgPath, "RELOAD_TEST = safe"); + string path = Path.Combine(directory, "reload.bcf"); + bool silent = true; + WorkspaceExport.SaveWorkspace(ref path, ref original, ref silent); + + CFGConfiguration workspace = Load(path); + workspace.ProcessConfiguration(); + Assert(workspace.GetVariable("_USTN_PRODUCT_CONFIGVARNAME")?.Value == "RESOURCE_PRODUCT", + "Reload did not read product string resources."); + Assert(workspace.GetVariable("_USTN_PRODUCT_NAME")?.Value == "Resource Product", + "Product name resource lost."); + Assert(workspace.GetVariable("_USTN_PRODUCT_FILETYPENAME")?.Value == "", + "Non-string resource was not skipped."); + Assert(workspace.GetVariable("_USTN_PRODUCT_VERSION_MAJOR")?.Value == "2" && + workspace.GetVariable("_USTN_PRODUCT_VERSION_BUILD")?.Value == "0", + "Short product version was not handled safely."); + Assert(!AppDomain.CurrentDomain.GetAssemblies().Any(assembly => + !assembly.IsDynamic && string.Equals(assembly.Location, productDll, StringComparison.OrdinalIgnoreCase)), + "Reload loaded the untrusted product assembly."); + Assert(!File.Exists(marker), "Reload executed an untrusted static initializer or getter."); + Console.WriteLine("PASS: .bcf reload reads only string metadata without loading or executing the product DLL"); + + Type productType = typeof(Bentley.PowerPlatform.Resx); + Assert((string)workspace.GetPropertyInfoValue(ref productType, "ProductNames_ConfigVarName") == "RESOURCE_PRODUCT", + "Public metadata accessor did not use resource data."); + workspace.SetProductInfo(ref productType); + Assert(!File.Exists(marker), "Public metadata API executed a product getter."); + Console.WriteLine("PASS: public product metadata APIs do not invoke getters"); + + File.WriteAllText(productDll, "not a managed PE image"); + workspace = Load(path); + workspace.ProcessConfiguration(); + Assert(workspace.GetVariable("_USTN_PRODUCT_NAME") == null, + "Corrupt product image supplied metadata."); + File.Delete(productDll); + workspace = Load(path); + workspace.ProcessConfiguration(); + Assert(workspace.GetVariable("_USTN_PRODUCT_NAME") == null && !File.Exists(marker), + "Missing product image was not handled safely."); + Console.WriteLine("PASS: corrupt and missing product DLLs are skipped safely"); + } + finally + { + Environment.SetEnvironmentVariable("BCE_RELOAD_MARKER", previousMarker); + CultureInfo.CurrentCulture = previousCulture; + } + } + + private static CFGConfiguration CreateWorkspace() + { + var workspace = new CFGConfiguration { Description = "Regression workspace" }; + var parent = new CFGFile(ref workspace) { Name = "parent.cfg" }; + var child = new CFGFile(ref workspace) { Name = "child.cfg", ParentFile = parent, Depth = 1 }; + parent.ChildFiles.Add(child); + workspace.CFGFiles.Add(parent); + workspace.CFGFiles.Add(child); + var line = new CFGLine { ParentFile = child, Text = "TEST = value", LineNumber = 1 }; + line.Tokens.Add(new CFGToken { Value = "TEST" }); + child.Lines.Add(line); + var variable = new CFGVariable(workspace) { Name = "TEST", Value = "value", FinalExpansion = "value" }; + variable.Args[0] = "argument"; + variable.Values[0] = "value"; + workspace.Variables.Add(variable.Name, variable); + workspace.CFGEvents.Add(new CFGEvent { ParentFile = child, ParentLine = line, Variable = variable }); + return workspace; + } + + private static void AssertGraph(CFGConfiguration workspace) + { + Assert(workspace != null && workspace.Description == "Regression workspace", "Workspace did not load."); + var parent = (CFGFile)workspace.CFGFiles[0]; + var child = (CFGFile)workspace.CFGFiles[1]; + var cfgEvent = (CFGEvent)workspace.CFGEvents[0]; + Assert(ReferenceEquals(parent.ChildFiles[0], child), "Shared child reference lost."); + Assert(ReferenceEquals(child.ParentFile, parent), "Parent file cycle lost."); + Assert(ReferenceEquals(child.ParentConfiguration, workspace), "Workspace cycle lost."); + Assert(ReferenceEquals(child.Lines[0].ParentFile, child), "Line cycle lost."); + Assert(ReferenceEquals(cfgEvent.ParentFile, child), "Event file reference lost."); + Assert(ReferenceEquals(cfgEvent.ParentLine, child.Lines[0]), "Event line reference lost."); + CFGVariable variable = workspace.Variables["TEST"]; + Assert(ReferenceEquals(cfgEvent.Variable, variable), "Event variable reference lost."); + Assert(ReferenceEquals(variable.ParentConfiguration, workspace), "Variable cycle lost."); + Assert(variable.Value == "value" && variable.Args[0] == "argument" && variable.Values[0] == "value", + "Variable values or arrays lost."); + } + + private static JObject Wrap(string member, JObject item) + { + var collection = new JObject { ["$values"] = new JArray(item.DeepClone()) }; + if (member != "ChildFiles") + return new JObject { [member] = collection }; + + return new JObject + { + ["CFGFiles"] = new JObject + { + ["$values"] = new JArray(new JObject + { + ["$type"] = typeof(CFGFile).AssemblyQualifiedName, + ["ChildFiles"] = collection + }) + } + }; + } + + private static void AssertInvalidFileGraphs(string directory) + { + string path = Path.Combine(directory, "invalid-graph.bcf"); + var cyclic = new CFGConfiguration(); + var root = new CFGFile(ref cyclic) { Name = "self.cfg" }; + cyclic.CFGFiles.Add(root); + root.ChildFiles.Add(root); + AssertGraphRejected(cyclic, path); + + var mutual = CreateWorkspace(); + ((CFGFile)mutual.CFGFiles[1]).ChildFiles.Add(mutual.CFGFiles[0]); + AssertGraphRejected(mutual, path); + + var repeated = CreateWorkspace(); + var parent = (CFGFile)repeated.CFGFiles[0]; + parent.ChildFiles.Add(parent.ChildFiles[0]); + AssertGraphRejected(repeated, path); + + var inconsistent = CreateWorkspace(); + ((CFGFile)inconsistent.CFGFiles[1]).Depth = int.MaxValue; + AssertGraphRejected(inconsistent, path); + + var wrongType = CreateWorkspace(); + ((CFGFile)wrongType.CFGFiles[0]).ChildFiles.Add(new CFGEvent()); + AssertGraphRejected(wrongType, path); + Console.WriteLine("PASS: cyclic, repeated, incorrectly typed and inconsistent-depth file graphs rejected before activation"); + + CFGConfiguration acceptedDepth = CreateFileChain(64); + File.WriteAllText(path, JsonConvert.SerializeObject(acceptedDepth, Settings)); + Assert(Load(path)?.CFGFiles.Count == 65, "A valid graph at the depth boundary was rejected."); + AssertGraphRejected(CreateFileChain(65), path); + + var oversized = CreateWorkspace(); + while (oversized.CFGFiles.Count <= 10000) + oversized.CFGFiles.Add(new CFGFile(ref oversized)); + AssertGraphRejected(oversized, path); + Console.WriteLine("PASS: shallow JSON reference graphs obey file-count and actual child-depth limits"); + } + + private static CFGConfiguration CreateFileChain(int maximumDepth) + { + var workspace = new CFGConfiguration(); + CFGFile child = null; + for (int depth = maximumDepth; depth >= 0; depth--) + { + var file = new CFGFile(ref workspace) { Name = "depth-" + depth + ".cfg", Depth = depth }; + if (child != null) + file.ChildFiles.Add(child); + workspace.CFGFiles.Add(file); + child = file; + } + return workspace; + } + + private static void AssertGraphRejected(CFGConfiguration invalid, string path) + { + File.WriteAllText(path, JsonConvert.SerializeObject(invalid, Settings)); + CFGConfiguration existing = CreateWorkspace(); + CFGConfiguration loaded = existing; + bool silent = true; + WorkspaceExport.LoadConfiguration(ref path, ref loaded, ref silent); + Assert(ReferenceEquals(existing, loaded), "Invalid file graph replaced the current workspace."); + } + + private static void AssertRejected(string json, string path, string expectedError) + { + bool rejected = false; + try + { + JsonConvert.DeserializeObject(json, Settings); + } + catch (JsonException exception) + { + rejected = exception.ToString().Contains(expectedError, StringComparison.OrdinalIgnoreCase); + } + Assert(rejected, "Serializer did not reject the input for the expected reason: " + expectedError); + File.WriteAllText(path, json); + CFGConfiguration existing = CreateWorkspace(); + CFGConfiguration loaded = existing; + bool silent = true; + WorkspaceExport.LoadConfiguration(ref path, ref loaded, ref silent); + Assert(ReferenceEquals(existing, loaded), "Rejected input replaced the current workspace."); + } + + private static CFGConfiguration Load(string path) + { + CFGConfiguration workspace = null; + bool silent = true; + WorkspaceExport.LoadConfiguration(ref path, ref workspace, ref silent); + return workspace; + } + + private static void Assert(bool condition, string message) + { + if (!condition) + throw new InvalidOperationException(message); + } + + public sealed class ConstructionProbe + { + public static int Created; + + public ConstructionProbe() + { + Created++; + } + } +} + +namespace Bentley.PowerPlatform +{ + public static class Resx + { + static Resx() + { + string marker = Environment.GetEnvironmentVariable("BCE_RELOAD_MARKER"); + if (!string.IsNullOrEmpty(marker)) + File.WriteAllText(marker, "Unexpected execution of product code"); + } + + public static string ProductNames_ConfigVarName => "EXECUTED_GETTER"; + public static string ProductVersion => "99.99.99.99"; + } +} \ No newline at end of file diff --git a/tests/WorkspacePersistence/WorkspacePersistence.csproj b/tests/WorkspacePersistence/WorkspacePersistence.csproj new file mode 100644 index 0000000..04d879d --- /dev/null +++ b/tests/WorkspacePersistence/WorkspacePersistence.csproj @@ -0,0 +1,13 @@ + + + Exe + net10.0-windows + true + x64 + x64 + true + + + + + \ No newline at end of file