// Copyright Keysight Technologies 2012-2019
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at http://mozilla.org/MPL/2.0/.
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.IO.Compression;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using Tap.Shared;
namespace OpenTap.Package
{
///
/// Implements a IPackageRepository that queries a local directory for OpenTAP packages.
///
public class FilePackageRepository : IPackageRepository, IPackageDownloadProgress
{
#pragma warning disable 1591 // TODO: Add XML Comments in this file, then remove this
private static TraceSource log = Log.CreateSource("FilePackageRepository");
internal const string TapPluginCache = ".PackageCache";
static readonly object cacheLock = new object();
static readonly object loadLock = new object();
private List allFiles = new List();
private PackageDef[] allPackages;
///
/// Constructs a FilePackageRepository for a directory
///
/// Relative or absolute path or URI to a directory or a file. If file, the repository will be the directory containing the file
/// Path is not a valid file package repository
public FilePackageRepository(string path)
{
// if path is the path root, for example C: and the path is not "/".
// then we need to add a '\' so "C:" becomes "C:\".
// On other systems (Linux, mac, .. where path == "/") we do nothing.
if (Path.IsPathRooted(path) && path !="/" && Path.GetPathRoot(path) == path)
{
path = Path.GetPathRoot(path).TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar;
}
if (Uri.TryCreate(path, UriKind.RelativeOrAbsolute, out Uri uri))
{
// This is true for UNC paths on Windows
if (uri.IsAbsoluteUri && uri.HostNameType is UriHostNameType.Dns or UriHostNameType.IPv4 or UriHostNameType.IPv6)
{
AbsolutePath = uri.LocalPath;
Url = uri.AbsoluteUri.TrimEnd(new[] { '\\', '/' });;
}
else
{
string absolutePath = null;
if (uri.IsAbsoluteUri)
{
if (uri.Scheme != Uri.UriSchemeFile)
throw new NotSupportedException($"Scheme {uri.Scheme} is not supported as a file package repository ({path}).");
absolutePath = uri.AbsolutePath;
}
else
{
absolutePath = Path.GetFullPath(path);
}
if (File.Exists(absolutePath))
AbsolutePath = Path.GetFullPath(Path.GetDirectoryName(absolutePath));
else
AbsolutePath = Path.GetFullPath(absolutePath);
AbsolutePath = Uri.UnescapeDataString(AbsolutePath);
Url = new Uri(AbsolutePath).AbsoluteUri;
}
}
else
throw new NotSupportedException($"{path} is not supported as a file package repository.");
}
public void Reset()
{
allPackages = null;
LoadPath(new CancellationToken());
}
private void LoadPath(CancellationToken cancellationToken)
{
if (allPackages != null)
return;
if (File.Exists(AbsolutePath) || Directory.Exists(AbsolutePath) == false)
{
allPackages = Array.Empty();
if (AbsolutePath.TrimEnd('/', '\\') != PackageDef.SystemWideInstallationDirectory.TrimEnd('/', '\\')) // Let's ignore this error if the repo is the system wide directory.
throw new DirectoryNotFoundException($"File package repository directory not found at: {Url}");
return;
}
lock (loadLock)
{
if (allPackages != null)
return;
allFiles = GetAllFiles(AbsolutePath, cancellationToken);
cancellationToken.ThrowIfCancellationRequested();
allPackages = GetAllPackages(allFiles).ToArray();
// the following code tries to delete unused cache files
// It loops through all files and checks if they are a .PackageCache file
// if they are and they are not used by any current repository, delete it.
var caches = PackageManagerSettings.Current.Repositories.Select(x => x.Url)
.Append(PackageCacheHelper.PackageCacheDirectory)
.Select(p => GetCache(p).CacheFileName)
.ToHashSet();
foreach (var file in allFiles)
{
var filename = Path.GetFileName(file);
if (filename.StartsWith(TapPluginCache) == false) continue;
if (caches.Contains(filename)) continue;
try
{
File.Delete(file);
}
catch
{
// This is fine
}
}
}
}
Action IPackageDownloadProgress.OnProgressUpdate { get; set; }
internal string AbsolutePath;
#region IPackageRepository Implementation
public string Url { get; set; }
public void DownloadPackage(IPackageIdentifier package, string destination, CancellationToken cancellationToken)
{
PackageDef packageDef = null;
// If the requested package is a file we do not want to start searching the entire repo.
if (package is PackageDef def && File.Exists((def.PackageSource as FilePackageDefSource)?.PackageFilePath))
{
log.Debug("Downloading file without searching repository.");
packageDef = def;
}
else
LoadPath(cancellationToken);
if (packageDef == null)
packageDef = allPackages.FirstOrDefault(p => p.Equals(package));
bool finished = false;
try
{
var packageFilePath = (packageDef?.PackageSource as FilePackageDefSource)?.PackageFilePath;
if (packageDef == null || packageFilePath == null)
throw new Exception($"Could not download '{package.Name}', because it does not exists");
if (PathUtils.AreEqual(packageFilePath, destination))
{
finished = true;
return; // No reason to copy..
}
if (Path.GetExtension(packageFilePath).ToLower() == ".tappackages") // If package is a .TapPackages file, unpack it.
{
cancellationToken.ThrowIfCancellationRequested();
var packagesFiles = PluginInstaller.UnpackPackage(packageFilePath, Path.GetTempPath());
string path = null;
foreach (var packageFile in packagesFiles)
{
var internalPackage = PackageDef.FromPackage(packageFile);
if (internalPackage.Name == packageDef.Name && internalPackage.Version == packageDef.Version)
{
path = packageFile;
break;
}
}
if (string.IsNullOrEmpty(path) == false)
{
cancellationToken.ThrowIfCancellationRequested();
FileCopy(path, destination);
finished = true;
}
foreach (var packageFile in packagesFiles)
{
if (File.Exists(packageFile))
File.Delete(packageFile);
}
}
else
{
cancellationToken.ThrowIfCancellationRequested();
FileCopy(packageFilePath, destination);
finished = true;
}
}
catch (Exception ex)
{
if (!(ex.InnerException is TaskCanceledException))
{
throw;
}
}
finally
{
if ((!finished || cancellationToken.IsCancellationRequested) && File.Exists(destination))
File.Delete(destination);
}
}
// Copying files can be very slow if it is from a network location.
// this file-copy action copies and notifies of progress.
void FileCopy(string source, string destination)
{
var tmpDestination = destination + ".part-" + Guid.NewGuid();
using (FileLock.Create(destination + ".lock"))
{
if (File.Exists(destination) && PathUtils.CompareFiles(source, destination))
return;
using (var readStream = File.OpenRead(source))
using (var writeStream = File.OpenWrite(tmpDestination))
{
var task = Task.Run(() => readStream.CopyTo(writeStream));
ConsoleUtils.ReportProgressTillEnd(task, $"Copying {source} to {destination}",
() => writeStream.Position,
() => readStream.Length,
(header, pos, len) =>
{
ConsoleUtils.printProgress(header, pos, len);
(this as IPackageDownloadProgress).OnProgressUpdate?.Invoke(header, pos, len);
});
}
File.Delete(destination);
// on most operative systems the same folder would be on the same disk, so this is a no-op.
try
{
File.Move(tmpDestination, destination);
}
catch
{
}
}
}
public string[] GetPackageNames(CancellationToken cancellationToken, params IPackageIdentifier[] compatibleWith)
{
LoadPath(cancellationToken);
if (this.allPackages == null || this.allFiles == null) return null;
var packages = this.allPackages.ToList();
// Check if package dependencies are compatible
compatibleWith = CheckCompatibleWith(compatibleWith);
if (compatibleWith != null)
packages = packages.Where(p => p.Dependencies.All(d => compatibleWith.All(r => IsCompatible(d, r)))).ToList();
return packages
.Select(p => p.Name)
.Distinct()
.ToArray();
}
public string[] GetPackageNames(string @class, CancellationToken cancellationToken, params IPackageIdentifier[] compatibleWith)
{
LoadPath(cancellationToken);
if (this.allPackages == null || this.allFiles == null) return null;
var packages = this.allPackages.Where(p => p.Class == @class).ToList();
// Check if package dependencies are compatible
compatibleWith = CheckCompatibleWith(compatibleWith);
if (compatibleWith != null)
packages = packages.Where(p => p.Dependencies.All(d => compatibleWith.All(r => IsCompatible(d, r)))).ToList();
return packages
.Select(p => p.Name)
.Distinct()
.ToArray();
}
public PackageVersion[] GetPackageVersions(string packageName, CancellationToken cancellationToken, params IPackageIdentifier[] compatibleWith)
{
LoadPath(cancellationToken);
if (this.allPackages == null || this.allFiles == null) return null;
var packages = this.allPackages.ToList();
// Check if package dependencies are compatible
compatibleWith = CheckCompatibleWith(compatibleWith);
if (compatibleWith != null)
packages = packages.Where(p => p.Dependencies.All(d => compatibleWith.All(r => IsCompatible(d, r)))).ToList();
return packages
.Where(p => p.Name == packageName)
.Select(p => new PackageVersion(packageName, p.Version, p.OS, p.Architecture, p.Date,
p.Files.Where(f => string.IsNullOrWhiteSpace(f.LicenseRequired) == false).Select(f => f.LicenseRequired).ToList()))
.Distinct()
.ToArray();
}
public PackageDef[] GetAllPackages(CancellationToken cancellationToken)
{
LoadPath(cancellationToken);
return allPackages;
}
public PackageDef[] GetPackages(PackageSpecifier pid, CancellationToken cancellationToken, params IPackageIdentifier[] compatibleWith)
{
LoadPath(cancellationToken);
if (this.allPackages == null || this.allFiles == null) return null;
// Filter packages
var openTapIdentifier = new PackageIdentifier("OpenTAP", PluginManager.GetOpenTapAssembly().SemanticVersion.ToString(), CpuArchitecture.Unspecified, null);
var packages = new List();
compatibleWith = CheckCompatibleWith(compatibleWith);
foreach (var package in allPackages)
{
if (string.IsNullOrWhiteSpace(pid.Name) == false && (pid.Name != package.Name))
continue;
if (!pid.Version.IsCompatible(package.Version))
continue;
if (package.IsPlatformCompatible(pid.Architecture, pid.OS) == false)
continue;
// Check if package dependencies are compatible
if (package.Dependencies.All(d => compatibleWith.All(r => IsCompatible(d, r))) == false)
continue;
packages.Add(package);
}
// If we should not check compatibility, take the one that is most compatible.
if (compatibleWith?.Length == 0)
{
List> filteredPackages = new List>();
foreach (var item in packages)
{
filteredPackages.Add(Tuple.Create(item.Dependencies.Count(d => IsCompatible(d, openTapIdentifier)), item));
}
// Find most compatible packages
packages = filteredPackages
.OrderByDescending(p => (double) p.Item1 / p.Item2.Dependencies.Count)
.ThenByDescending(p => p.Item2.Version).Select(p => p.Item2).ToList();
}
// Select the latest of each packagename left
return packages
.GroupBy(p => p.Name).Select(g => g.FirstOrDefault(x => x.Architecture == pid.Architecture) ?? g.First())
.ToArray();
}
public PackageDef[] CheckForUpdates(IPackageIdentifier[] packages, CancellationToken cancellationToken)
{
LoadPath(cancellationToken);
if (allPackages == null || allFiles == null) return null;
List latestPackages = new List();
var openTapIdentifier = new PackageIdentifier("OpenTAP", PluginManager.GetOpenTapAssembly().SemanticVersion.ToString(), CpuArchitecture.Unspecified, null);
// Find updated packages
foreach (var packageIdentifier in packages)
{
if (packageIdentifier == null)
continue;
var package = new PackageIdentifier(packageIdentifier);
// Try finding a OpenTAP package
var latest = allPackages
.Where(p => package?.Name == p?.Name)
.Where(p => string.IsNullOrWhiteSpace(p?.Version?.PreRelease)) // Only suggest upgrading to released versions
.Where(p => p.Dependencies.All(dep => IsCompatible(dep, openTapIdentifier))).FirstOrDefault(p => p.Version != null && p.Version.CompareTo(package.Version) > 0);
if (latest != null)
latestPackages.Add(latest);
}
return latestPackages.ToArray();
}
#endregion
#region file system
private void CreatePackageCache(IEnumerable packages, FileRepositoryCache cache)
{
string currentDir = FileSystemHelper.GetCurrentInstallationDirectory();
// Delete existing cache
List caches = Directory.GetFiles(currentDir, $"{TapPluginCache}.{cache.Hash}*").ToList();
caches.ForEach(File.Delete);
string fullPath = Path.Combine(currentDir, cache.CacheFileName);
// Serialize all packages and Save cache
using(var f = File.OpenWrite(fullPath))
{
using (var gz = new GZipStream(f, CompressionLevel.Optimal, leaveOpen: true))
{
PackageDef.SaveManyTo(gz, packages);
}
}
}
private List GetAllFiles(string path, CancellationToken cancellationToken)
{
var result = new List();
var dirs = new Queue();
dirs.Enqueue(new DirectoryInfo(path));
while (dirs.Any() && cancellationToken.IsCancellationRequested == false)
{
var dir = dirs.Dequeue();
try
{
var content = dir.EnumerateDirectories();
foreach (var subDir in content)
{
dirs.Enqueue(subDir);
}
result.AddRange(dir.EnumerateFiles().Select(f => f.FullName));
}
catch (Exception)
{
log.Debug($"Access to path {dir.FullName} denied. Ignoring.");
}
}
return result;
}
private PackageDef[] loadPackagesFromFile(IEnumerable allFiles)
{
var allPackages = new List();
var packagesFiles = allFiles.Where(f => f.Extension.ToLower() == ".tappackages").ToHashSet();
// Deserializer .TapPackages files
Parallel.ForEach(packagesFiles, packagesFile =>
{
List packages;
try
{
packages = PackageDef.FromPackages(packagesFile.FullName);
if (packages == null) return;
}
catch (Exception e)
{
log.Error($"Could not unpackage '{packagesFile.FullName}'");
log.Debug(e);
return;
}
packages.ForEach(p =>
{
#pragma warning disable 618
p.Location = packagesFile.FullName;
#pragma warning restore 618
p.PackageSource = new FileRepositoryPackageDefSource
{
RepositoryUrl = Url,
PackageFilePath = packagesFile.FullName
};
});
lock (allPackages)
{
allPackages.AddRange(packages);
}
});
// Deserialize all regular packages
Parallel.ForEach(allFiles, pluginFile =>
{
if (packagesFiles.Contains(pluginFile))
return;
PackageDef package;
try
{
package = PackageDef.FromPackage(pluginFile.FullName);
if (package == null) return;
}
catch
{
return;
}
package.PackageSource = new FileRepositoryPackageDefSource
{
RepositoryUrl = Url,
PackageFilePath = pluginFile.FullName
};
lock (allPackages)
{
allPackages.Add(package);
}
});
return allPackages.ToArray();
}
private List GetAllPackages(List allFiles)
{
// Get cache
var cache = GetCache();
// Find TapPackages in repo
var allFileInfos = allFiles.Select(f => new FileInfo(f)).Where(f => f.Extension.ToLower() == ".tapplugin" || f.Extension.ToLower() == ".tappackage" || f.Extension.ToLower() == ".tappackages").ToList();
cache.CachePackageCount = allFileInfos.Count;
List allPackages = null;
lock (cacheLock)
{
try
{
if (File.Exists(cache.CacheFileName))
{
if (allFileInfos.Count == cache.CachePackageCount)
{
var sw = Stopwatch.StartNew();
// Load cache
using (var str = File.OpenRead(cache.CacheFileName))
allPackages = PackageDef.ManyFromXml(new GZipStream(str, CompressionMode.Decompress)).ToList();
log.Debug(sw, "Loading cache: {0}", cache.CacheFileName);
// Check if any files has been replaced
if (allPackages.Any(p => !allFiles.Any(f =>
{
var packageFilePath = (p.PackageSource as FileRepositoryPackageDefSource)?.PackageFilePath;
return string.IsNullOrWhiteSpace(packageFilePath) == false && PathUtils.AreEqual(f, Path.GetFullPath(packageFilePath));
})))
allPackages = null;
// Check if the cache is the newest file
if (allPackages != null && allFileInfos.Any() && (allFileInfos.Max(f => f.LastWriteTimeUtc) > new FileInfo(cache.CacheFileName).LastWriteTimeUtc))
allPackages = null;
}
}
}
catch (Exception ex)
{
log.Warning("Error while reading package cache from '{0}'. Rebuilding cache.", cache.CacheFileName);
log.Debug(ex);
}
if (allPackages == null)
{
// Get all packages
var sw = Stopwatch.StartNew();
allPackages = loadPackagesFromFile(allFileInfos).ToList();
cache.CachePackageCount = allFileInfos.Count;
// Create cache
try
{
CreatePackageCache(allPackages, cache);
log.Debug(sw, "Rebuilding cache: {0}", cache.CacheFileName);
}
catch (Exception ex)
{
// This can fail if the package cache is in use.
// For example if another thread or process is loading the cache while we are trying to write it.
// This is not that serious, as the cache will just be regenerated later.
log.Debug($"Unable to update package cache: '{ex.Message}'");
log.Debug(ex);
}
}
}
// Order packages
allPackages = allPackages.OrderByDescending(p => p.Version).ToList();
return allPackages;
}
#endregion
#region helper
private static bool IsCompatible(PackageDependency dep, IPackageIdentifier packageIdentifier)
{
try
{
if (dep.Name == packageIdentifier.Name)
{
return dep.Version.IsCompatible(packageIdentifier.Version);
}
}
catch
{
log.Warning("Dependency '{0}' is not compatible with '{1}'.", dep.Name, packageIdentifier.Name);
throw;
}
return true;
}
private IPackageIdentifier[] CheckCompatibleWith(IPackageIdentifier[] compatibleWith)
{
var list = compatibleWith?.ToList();
var openTap = list?.FirstOrDefault(p => p.Name == "OpenTAP");
if (openTap != null)
{
list.AddRange(new []
{
new PackageIdentifier("Tap", openTap.Version, openTap.Architecture, openTap.OS),
new PackageIdentifier("TAP Base", openTap.Version, openTap.Architecture, openTap.OS)
});
}
return list?.ToArray();
}
private FileRepositoryCache GetCache(string url = null)
{
var hash = String.Format("{0:X8}", MurMurHash3.Hash(url ?? Url));
var files = Directory.GetFiles(FileSystemHelper.GetCurrentInstallationDirectory(), $"{TapPluginCache}*");
var filePath = files.FirstOrDefault(f => f.Contains(hash));
if (File.Exists(filePath))
{
var matches = Regex.Split(Path.GetFileName(filePath), "\\.");
if (int.TryParse(matches[3], out int count))
return new FileRepositoryCache() {Hash = hash, CachePackageCount = count};
}
return new FileRepositoryCache() {Hash = hash};
}
#endregion
/// Creates a display friendly string of this.
public override string ToString() => $"[FilePackageRepository: {Url}]";
}
}