using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; using OpenTap.Package.Ipc; using Tap.Shared; namespace OpenTap.Package { /// /// Represents an OpenTAP installation in a specific directory. /// public class Installation { static TraceSource log = Log.CreateSource("Installation"); /// /// Path to the installation /// public string Directory { get; } /// /// Get a unique identifier for this OpenTAP installation. /// The identifier is computed from hashing a uniquely generated machine ID combined with the hash of the installation directory. /// public string Id { get { if(string.IsNullOrWhiteSpace(id)) id = $"{MurMurHash3.Hash(GetMachineId()):X8}{MurMurHash3.Hash(Directory):X8}"; return id; } } private string id { get; set; } internal static string GetMachineId() { var idPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData, Environment.SpecialFolderOption.Create), "OpenTap", "OpenTapGeneratedId"); string id = default(Guid).ToString(); // 00000000-0000-0000-0000-000000000000 try { if (File.Exists(idPath)) { if (Guid.TryParse(File.ReadAllText(idPath), out Guid parsedGuid)) // In the assumable rare case that a user tampers with the OpenTapGeneratedId file. { id = parsedGuid.ToString(); return id; } } id = Guid.NewGuid().ToString(); if (System.IO.Directory.Exists(Path.GetDirectoryName(idPath)) == false) System.IO.Directory.CreateDirectory(Path.GetDirectoryName(idPath)); File.WriteAllText(idPath, id); } catch (Exception e) { log.Error("Failed to read machine ID. See debug messages for more information"); log.Debug(e); } return id; } /// /// Initialize an instance of a OpenTAP installation. /// /// public Installation(string directory) { this.Directory = directory ?? throw new ArgumentNullException(nameof(directory)); } /// /// Check if it is an installation folder that contains packages other than system-wide packages /// public bool IsInstallationFolder => GetPackages().Any(x => x.IsSystemWide() == false); private static Installation current; /// /// Get the installation of the currently running tap process /// public static Installation Current => current ??= new Installation(ExecutorClient.ExeDir); /// Target installation architecture. This could be anything as 32-bit is supported on 64bit systems. internal CpuArchitecture Architecture => GetOpenTapPackage()?.Architecture ?? ArchitectureHelper.GuessBaseArchitecture; /// The target installation OS, should be either Windows, MacOS or Linux. internal string OS { get { if (OperatingSystem.Current == OperatingSystem.Windows) return "Windows"; if (OperatingSystem.Current == OperatingSystem.MacOS) return "MacOS"; return "Linux"; } } /// /// Invalidate cached package list. This should only be called if changes have been made to the installation by circumventing OpenTAP APIs. /// public void Invalidate() { fileMap.Clear(); // Force GetPackages() to repopulate packages next time it is called invalidate = true; } bool invalidate; readonly ConcurrentDictionary fileMap = new ConcurrentDictionary(); /// /// Get the installed package which provides the file specified by the string. /// If multiple packages provide the file the package is chosen arbitrarily. /// /// An absolute or relative path to the file /// public PackageDef FindPackageContainingFile(string file) { InvalidateIfChanged(); try { var invalid = Path.GetInvalidPathChars(); if (file.Any(ch => invalid.Contains(ch))) return null; var name = Path.GetFileName(file); invalid = Path.GetInvalidFileNameChars(); if (name.Any(ch => invalid.Contains(ch))) return null; // The path API is not 100% consistent. In some circumstances 'GetFullPath' will // still throw even if there are no illegal characters in the filename or path name. _ = Path.GetFullPath(file); } catch { // This means the filename is invalid on some way not covered by Path.GetInvalidPathChars. // This is fine, and it definitely means the file is not contained in a package return null; } var installDir = ExecutorClient.ExeDir; // Compute the absolute path in order to ensure the file exists, and normalize the path so it matches the format in package.xml files var abs = Path.IsPathRooted(file) ? Path.GetFullPath(file) // If the path is rooted, use the full path : Path.GetFullPath(Path.Combine(installDir, file)); // otherwise, append the relative path to the install dir // abs must be contained within installDir if (abs.Length <= installDir.Length) return null; // Path case sensitivity is file system dependent. Typically Windows and MacOS will be case-insensitive, // and Linux will be case-sensitive, but not always. TODO: check if path file system is case sensitive var stringComparer = OperatingSystem.Current == OperatingSystem.Linux ? StringComparison.Ordinal : StringComparison.OrdinalIgnoreCase; // Ensure the file is in a subdirectory of the installation. Otherwise it is not contained in a package. if (!abs.StartsWith(installDir, stringComparer)) return null; // Compute the relative path and normalize directory separators var relative = abs.Substring(installDir.Length + 1).Replace('\\', '/'); // Fully initialize fileMap as needed whenever it is cleared if (fileMap.Count == 0) { foreach (var package in GetPackages()) { foreach (var packageFile in package.Files) { var fileName = packageFile.FileName.Replace('\\', '/'); fileMap.TryAdd(fileName, package); } } } if (fileMap.TryGetValue(relative, out var result)) return result; return null; } /// /// Get the installed package which provides the type specified by pluginType. /// If multiple packages provide the type the package is chosen arbitrarily. /// /// /// public PackageDef FindPackageContainingType(ITypeData pluginType) { var source = TypeData.GetTypeDataSource(pluginType); if (source == null) return null; // sourceFile is normally a .DLL, but may also be other things, e.g .py-file. var sourceFile = source.Location; if (string.IsNullOrWhiteSpace(sourceFile)) return null; var assemblyPath = Path.GetFullPath(sourceFile); var installPath = Path.GetFullPath(Directory); // The assembly must be rooted in the installation if (assemblyPath.StartsWith(installPath) == false) return null; return FindPackageContainingFile(assemblyPath); } private Dictionary packageCache; private long previousChangeId = -1; // Keeps track of which warnings about duplicate packages has been emitted. static readonly HashSet duplicateLogWarningsEmitted = new HashSet(); /// /// Invalidate caches if the installation has changed. /// private void InvalidateIfChanged() { long changeId = IsolatedPackageAction.GetChangeId(Directory); if (changeId != previousChangeId) { Invalidate(); previousChangeId = changeId; } } /// /// Returns package definition list of installed packages in the TAP installation defined in the constructor, and system-wide packages. /// Results are cached, and Invalidate must be called if changes to the installation are made by circumventing OpenTAP APIs. /// public List GetPackages() => GetPackagesLookup().Values.ToList(); /// /// Returns package definition list of installed packages in the TAP installation defined in the constructor, and system-wide packages. /// Results are cached, and Invalidate must be called if changes to the installation are made by circumventing OpenTAP APIs. /// public List GetPackages(bool validOnly) => GetPackagesLookup().Values.Where(pkg => pkg.IsValid()).ToList(); /// Finds an installed package by name. Returns null if the package was not found. public PackageDef FindPackage(string name) => GetPackagesLookup().TryGetValue(name, out var package) ? package : null; /// /// Returns package definition list of installed packages in the TAP installation defined in the constructor, and system-wide packages. /// Results are cached, and Invalidate must be called if changes to the installation are made by circumventing OpenTAP APIs. /// /// Dictionary GetPackagesLookup() { InvalidateIfChanged(); if (packageCache == null || invalidate) { Dictionary plugins = new Dictionary(); List duplicatePlugins = new List(); List package_files = new List(); // Add normal package from OpenTAP folder package_files.AddRange(PackageDef.GetPackageMetadataFilesInTapInstallation(Directory)); string normalizePath(string s) { return Path.GetFullPath(s) .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) .ToUpperInvariant(); } // Add system wide packages if (normalizePath(Directory) != normalizePath(PackageDef.SystemWideInstallationDirectory)) package_files.AddRange(PackageDef.GetSystemWidePackages()); foreach (var file in package_files) { var package = installedPackageMemorizer.Invoke(file); if (package == null) continue; #pragma warning disable 618 package.Location = file; #pragma warning restore 618 package.PackageSource = new InstalledPackageDefSource { Installation = this, PackageDefFilePath = file }; if (!plugins.ContainsKey(package.Name)) { plugins.Add(package.Name, package); } else { duplicatePlugins.Add(package); } } foreach (var p in duplicatePlugins.GroupBy(p => p.Name)) { lock (warningsLock) { if (duplicateLogWarningsEmitted.Add(p.Key)) log.Warning( $"Duplicate {p.Key} packages detected. Consider removing some of the duplicate package definitions:\n" + $"{string.Join("\n", p.Append(plugins[p.Key]).Select(x => " - " + ((InstalledPackageDefSource)x.PackageSource).PackageDefFilePath))}"); } } invalidate = false; packageCache = plugins; } return packageCache; } private static object warningsLock = new object(); /// /// Get a package definition of OpenTAP engine. /// /// public PackageDef GetOpenTapPackage() { if (GetPackagesLookup().TryGetValue("OpenTAP", out var opentap)) return opentap; return null; } /// /// Memorizer which returns null when a cyclic memorizer call is detected. /// This prevents ugly and misleading error messages from occurring during /// calls to Installation.Current.GetPackages() from ITypeDataProvider implementations /// class IgnoreCyclicCallMemorizer : Memorizer { public IgnoreCyclicCallMemorizer(Func func) : base(null, func) { } public override T2 OnCyclicCallDetected(T1 key) { return default; } } static IMemorizer installedPackageMemorizer = new IgnoreCyclicCallMemorizer(loadPackageDef) { Validator = file => new FileInfo(file).LastWriteTimeUtc.Ticks }; static PackageDef loadPackageDef(string file) { try { using (var f = File.Open(file, FileMode.Open, FileAccess.Read, FileShare.Read)) return PackageDef.FromXml(f); } catch (Exception e) { log.Warning("Unable to read package file '{0}'. Moving it to '.broken'", file); log.Debug(e); var brokenfile = file + ".broken"; if (File.Exists(brokenfile)) File.Delete(brokenfile); File.Move(file, brokenfile); } return null; } #region Package Change IPC /// /// Maintains a running number that increments whenever a plugin is installed. /// private class ChangeId : SharedState { public ChangeId(string dir) : base(".package_definitions_change_ID", dir) { } public long GetChangeId() { return Read(0); } public void SetChangeId(long value) { Write(0, value); } public static async Task WaitForChange() { var changeId = new ChangeId(Path.GetDirectoryName(typeof(SharedState).Assembly.Location)); var id = changeId.GetChangeId(); while (changeId.GetChangeId() == id) await Task.Delay(500); } public static void WaitForChangeBlocking() { var changeId = new ChangeId(Path.GetDirectoryName(typeof(SharedState).Assembly.Location)); var id = changeId.GetChangeId(); while (changeId.GetChangeId() == id) Thread.Sleep(500); } } internal void AnnouncePackageChange() { using (var changeId = new ChangeId(this.Directory)) changeId.SetChangeId(changeId.GetChangeId() + 1); } private bool IsMonitoringPackageChange = false; private void MonitorPackageChange() { if (!IsMonitoringPackageChange) { IsMonitoringPackageChange = true; TapThread.Start(() => { while (true) { ChangeId.WaitForChangeBlocking(); PackageChanged(); } }); } } private Action PackageChanged; /// /// Event invoked when a package is installed/uninstalled from this installation. /// public event Action PackageChangedEvent { add { MonitorPackageChange(); PackageChanged += value; } remove { PackageChanged -= value; } } #endregion } }