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
}
}