// 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 Newtonsoft.Json.Linq; using System; using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Linq; using System.Net; using System.Net.Http; using System.Net.Http.Headers; using System.Text; using System.Text.Json; using System.Threading; using System.Xml; using OpenTap.Repository.Client; using OpenTap.Authentication; namespace OpenTap.Package { /// /// Implements a IPackageRepository that queries a server for OpenTAP packages via http/https. /// public class HttpPackageRepository : IPackageRepository, IPackageDownloadProgress, IQueryPrereleases { // from Stream.cs: pick a value that is the largest multiple of 4096 that is still smaller than the large object heap threshold (85K). private const int _DefaultCopyBufferSize = 81920; private static TraceSource log = Log.CreateSource("HttpPackageRepository"); private HttpClient client; private static HttpClient GetHttpClient() { var httpClient = AuthenticationSettings.Current.GetClient(null, true); httpClient.DefaultRequestHeaders.Add(HttpRequestHeader.Accept.ToString(), "application/xml"); return httpClient; } /// /// If true, most warnings will be logged as debug messages /// public bool IsSilent; private SemanticVersion _version; bool IsInError() => _version == null && nextUpdateAt > DateTime.Now; /// /// Get or set the version of the repository /// public SemanticVersion Version { get { if (_version == null) CheckRepoApiVersion(); return _version; } } /// /// Initialize a http repository with the given URL /// /// public HttpPackageRepository(string url) { if (!url.StartsWith("http")) url = "https://" + url; Url = url.TrimEnd('/'); UpdateId = Installation.Current.Id; RepoClient = GetAuthenticatedClient(new Uri(Url, UriKind.Absolute)); } internal static RepoClient GetAuthenticatedClient(Uri uri) { // In case of relative URLs, it should have been resolved to an absolute URI in DetermineRepositoryType. // As of writing this comment, there are no execution paths where this is not an absolute URI. if (!uri.IsAbsoluteUri) throw new Exception($"Uri must be absolute"); var repoClient = new RepoClient(uri.AbsoluteUri); // This is kind of a hack. AuthenticationSettings does some work to set up User-Agent headers. // Here we just copy the headers from the authentication client to the repo client. var httpClient = AuthenticationSettings.Current.GetClient(); foreach (var agent in httpClient.DefaultRequestHeaders.GetValues("User-Agent")) { repoClient.HttpClient.DefaultRequestHeaders.Add("User-Agent", agent); } // Manually transfer any bearer tokens from the authentication client to the repo client. foreach (var token in AuthenticationSettings.Current.Tokens) { if (token.Domain == uri.Authority) { repoClient.AddAuthentication(new BearerTokenAuthentication(token.AccessToken)); } } return repoClient; } Action IPackageDownloadProgress.OnProgressUpdate { get; set; } void DoDownloadPackage(PackageDef package, FileStream fileStream, CancellationToken cancellationToken) { var totalSize = -1L; // this retry loop is to robustly to download the package even if the connection is intermittently lost // to test, try // - Switching network interface // - Entering into airplane mode // - Enabling / disabling a VPN connection // It should try to continue for a while unless the cancellation token signals to stop. int maxRetries = 60; for (int retry = 0; retry < maxRetries; retry++) { cancellationToken.ThrowIfCancellationRequested(); bool transient = false; try { var range = RangeHeaderValue.Parse($"bytes={fileStream.Position}-"); var path = $"/Packages/{package.Name}"; // Download the package using var responseStream = RepoClient.DownloadObjectRange(path, package.Version?.ToString(), package.Architecture.ToString(), package.OS, range, cancellationToken); // In case the download is interrupted, we will assume the error is transient if we got an initial response. transient = true; if (totalSize < 0) totalSize = responseStream.Length; var task = responseStream.CopyToAsync(fileStream, _DefaultCopyBufferSize, cancellationToken); ConsoleUtils.ReportProgressTillEnd(task, $"Downloading {package.Name}", () => fileStream.Position, () => totalSize, (header, pos, len) => { ConsoleUtils.printProgress(header, pos, len); (this as IPackageDownloadProgress).OnProgressUpdate?.Invoke(header, pos, len); }); break; } catch (Exception ex) when (ex is IOException || ex is HttpRequestException || transient) { cancellationToken.WaitHandle.WaitOne(TimeSpan.FromSeconds(1)); } catch (Exception) { if (cancellationToken.IsCancellationRequested == false) log.Error($"Failed to download package {package.Name} from {Url}."); throw; } } } private string downloadPackagesString(string args, string data = null, string contentType = null, string accept = null) { try { HttpRequestMessage httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, Url + args); httpRequestMessage.Headers.Add("Accept", accept ?? "application/xml"); httpRequestMessage.Headers.Add("OpenTAP", PluginManager.GetOpenTapAssembly().SemanticVersion.ToString()); if (data != null) { httpRequestMessage.Method = HttpMethod.Post; httpRequestMessage.Content = new StringContent(data); } using var httpClient = GetHttpClient(); var response = httpClient.SendAsync(httpRequestMessage).GetAwaiter().GetResult(); var xmlText = response.Content.ReadAsStringAsync().GetAwaiter().GetResult(); return xmlText; } catch (Exception ex) { if (ex is WebException) { CheckRepoApiVersion(); if (IsInError()) log.Warning("Unable to connect to: {0}", Url); } var exception = new WebException("Error communicating with repository at '" + defaultUrl + "'.", ex); if (!IsSilent) log.Warning("Error communicating with repository at '{0}' : {1}.", defaultUrl, ex.Message); else log.Debug(exception); throw; } } // The value indicates the next time at which the repo should be tried connected to. DateTime nextUpdateAt = DateTime.MinValue; static readonly TimeSpan updateRepoVersionHoldOff = TimeSpan.FromSeconds(60); readonly object updateVersionLock = new object(); private void CheckRepoApiVersion() { lock (updateVersionLock) { if (IsInError()) return; try { var version = RepoClient.Version(CancellationToken.None); if (SemanticVersion.TryParse(version, out _version) == false) throw new NotSupportedException($"The repository '{defaultUrl}' is not supported."); } catch { // suppress } nextUpdateAt = DateTime.Now + updateRepoVersionHoldOff; } } PackageDef[] PackagesFromXml(string xmlText) { try { if (string.IsNullOrEmpty(xmlText) || xmlText == "null") return Array.Empty(); using var stream = new MemoryStream(Encoding.UTF8.GetBytes(xmlText)); var packages = PackageDef.ManyFromXml(stream).ToArray(); packages.ForEach(p => { p.PackageSource = new HttpRepositoryPackageDefSource { RepositoryUrl = Url }; }); return packages; } catch (XmlException ex) { if (!IsSilent) log.Warning("Invalid xml from package repository at '{0}'.", defaultUrl); else log.Debug("Invalid xml from package repository at '{0}'.", defaultUrl); log.Debug(ex); log.Debug("Redirected url '{0}'", Url); log.Debug(xmlText); } catch (Exception ex) { if (!IsSilent) log.Warning("Error reading from package repository at '{0}'.", defaultUrl); else log.Debug("Error reading from package repository at '{0}'.", defaultUrl); log.Debug(ex); log.Debug("Redirected url '{0}'", Url); } return new PackageDef[0]; } private PackageDef[] ConvertToPackageDef(IPackageIdentifier[] packages) { return packages.Select(p => new PackageDef() { Name = p.Name, Version = p.Version, Architecture = p.Architecture, OS = p.OS }).ToArray(); } private IPackageIdentifier[] CheckCompatibleWith(IPackageIdentifier[] compatibleWith) { if (compatibleWith == null) return null; var list = compatibleWith.ToList(); var openTap = compatibleWith.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(); } #region IPackageRepository Implementation /// /// Get the URL of the repository /// public string Url { get; set; } private string defaultUrl => Url; private RepoClient RepoClient { get; } /// /// Download a package to a specific destination /// /// /// /// public void DownloadPackage(IPackageIdentifier package, string destination, CancellationToken cancellationToken) { var tmpPath = destination + "." + Guid.NewGuid().ToString(); //Use DeleteOnClose to auto-magically remove the file when the stream or application is closed. using (var tmpFile = new FileStream(tmpPath, FileMode.Create, FileAccess.ReadWrite, FileShare.Delete | FileShare.Read, 4096, FileOptions.DeleteOnClose)) { try { var packageDef = package as PackageDef ?? new PackageDef { Name = package.Name, Version = package.Version, Architecture = package.Architecture, OS = package.OS }; DoDownloadPackage(packageDef, tmpFile, cancellationToken); if (cancellationToken.IsCancellationRequested == false) { tmpFile.Flush(); File.Delete(destination); File.Copy(tmpFile.Name, destination); } } catch (Exception) { if (cancellationToken.IsCancellationRequested == false) log.Warning("Download failed."); throw; } finally { File.Delete(tmpFile.Name); } } } /// /// Get the names of the available packages in the repository. Unlisted packages are not included /// /// /// /// /// public string[] GetPackageNames(CancellationToken cancellationToken, params IPackageIdentifier[] compatibleWith) { if (IsInError()) return Array.Empty(); var parameters = GetQueryParameters(includeUnlisted: false, distinctName: true); var packages = RepoClient.Query(parameters, cancellationToken, "Name"); return packages.Select(p => p["Name"] as string).ToArray(); } internal static Dictionary GetQueryParameters( string type = "TapPackage", string directory = "/Packages/", string name = null, string os = null, string @class = null, bool distinctName = false, bool includeUnlisted = true, VersionSpecifier version = null, CpuArchitecture architecture = CpuArchitecture.Unspecified) { var parameters = new Dictionary(); if (type != null) parameters["type"] = type; if (directory != null) parameters["directory"] = directory; if (includeUnlisted == false) parameters["IsUnlisted"] = false; if (name != null) parameters["name"] = name; if (version != null) parameters["version"] = version.ToString(); if (architecture != CpuArchitecture.Unspecified && architecture != CpuArchitecture.AnyCPU) parameters["architecture"] = architecture.ToString(); if (os != null) parameters["os"] = os; if (@class != null) parameters["class"] = @class; if (distinctName) parameters["distinctName"] = true; return parameters; } /// /// Get the names of the available packages in the repository with the specified class /// /// /// public string[] GetPackageNames(string @class, CancellationToken cancellationToken, params IPackageIdentifier[] compatibleWith) { if (IsInError()) return Array.Empty(); var parameters = GetQueryParameters(@class: @class, distinctName: true); var packages = RepoClient.Query(parameters, cancellationToken, "Name"); return packages.Select(p => p["Name"] as string).ToArray(); } /// /// Get the available versions of packages with name 'packageName' and optionally compatible with a list of packages /// /// /// /// /// public PackageVersion[] GetPackageVersions(string packageName, CancellationToken cancellationToken, params IPackageIdentifier[] compatibleWith) { // force update version to check for errors. CheckRepoApiVersion(); if (IsInError()) { log.Warning("Unable to connect to: {0}", Url); return Array.Empty(); } var parameters = GetQueryParameters(name: packageName); var packageVersions = RepoClient.Query(parameters, cancellationToken, "Name", "IsUnlisted", "Version", "OS", "Architecture", "LicenseRequired", "Date"); return packageVersions.Select(PackageVersion.FromDictionary).ToArray(); } /// /// Get the available versions of packages matching 'package' and optionally compatible with a list of packages /// /// /// /// /// public PackageDef[] GetPackages(PackageSpecifier package, CancellationToken cancellationToken, params IPackageIdentifier[] compatibleWith) { if (IsInError()) return Array.Empty(); var parameters = GetQueryParameters(name: package.Name, version: package.Version, os: package.OS, architecture: package.Architecture); var packages = RepoClient.Query(parameters, cancellationToken, "PackageDef"); return packages.Select(p => p["PackageDef"] as string).Where(xml => !string.IsNullOrWhiteSpace(xml)) .Select(xml => { using var ms = new MemoryStream(Encoding.UTF8.GetBytes(xml)); var pkg = PackageDef.FromXml(ms); // For historical reasons, the repository includes the repository URL of a package as part of the response. // This was done to support scenarios where package metadata is hosted on the repository, // but the actual package is stored elsewhere. This was never actually implemented, and likely wont. // In prior versions, the repository did not store its own URL as the package source. Instead, // it stored whatever URL the upload client used to address the repository by. For this reason, // some packages have their package source stored as 'http://...' instead of https. It is also // possible that the url could be stored as some local dns name, or as a raw IP. // Instead of relying on the source specified by the repository, we should simply override the URL // with the URL we just queried for the package, which will work in all cases. pkg.PackageSource = new HttpRepositoryPackageDefSource { RepositoryUrl = Url }; return pkg; }) .ToArray(); } /// /// Get Client ID /// public string UpdateId; private string PackageNameHash(string packageName) { using (System.Security.Cryptography.SHA256 algo = System.Security.Cryptography.SHA256.Create()) { byte[] hash = algo.ComputeHash(Encoding.UTF8.GetBytes(packageName)); return BitConverter.ToString(hash).Replace("-", ""); } } /// /// Query the repository for updated versions of specified packages /// /// /// /// public PackageDef[] CheckForUpdates(IPackageIdentifier[] packages, CancellationToken cancellationToken) { if (IsInError()) return Array.Empty(); List latestPackages = new List(); bool tempSilent = IsSilent; IsSilent = true; try { string response; using (Stream stream = new MemoryStream()) { PackageDef.SaveManyTo(stream, packages.Select(p => new PackageDef() { Name = PackageNameHash(p.Name), Version = p.Version, Architecture = p.Architecture, OS = p.OS })); stream.Seek(0, 0); string data = new StreamReader(stream).ReadToEnd(); string arg = $"/3.0/CheckForUpdates?name={UpdateId}"; response = downloadPackagesString(arg, data); cancellationToken.ThrowIfCancellationRequested(); } if (response != null) latestPackages = PackagesFromXml(response).ToList(); cancellationToken.ThrowIfCancellationRequested(); } catch { log.Debug("Could not check for updates from package repository at '{0}'.", defaultUrl); } IsSilent = tempSilent; return latestPackages.ToArray(); } #endregion /// /// Send the GraphQL query string to the repository. /// /// A GraphQL query string /// A JObject containing the GraphQL response [Obsolete("Please use the repository client instead: https://www.nuget.org/packages/OpenTAP.Repository.Client")] public JObject Query(string query) { var response = downloadPackagesString($"/3.1/query", query, "application/json", "application/json"); var json = JObject.Parse(response); return json; } /// /// Send the GraphQL query string to the repository. /// /// A GraphQL query string /// A JSON string containing the GraphQL response [Obsolete("Please use the repository client instead: https://www.nuget.org/packages/OpenTAP.Repository.Client")] public string QueryGraphQL(string query) => downloadPackagesString($"/3.1/query", query, "application/json", "application/json"); /// Creates a display friendly string of this. public override string ToString() => $"[HttpPackageRepository: {Url}]"; PackageDependencyGraph IQueryPrereleases.QueryPrereleases(string os, CpuArchitecture deploymentInstallationArchitecture, string preRelease, string name) { string repoUrl = Url; var sw = Stopwatch.StartNew(); var httpClient = HttpPackageRepository.GetAuthenticatedClient(new Uri(repoUrl, UriKind.Absolute)).HttpClient; httpClient.DefaultRequestHeaders.Add("WWW-Authenticate", "token"); httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); var parameters = HttpPackageRepository.GetQueryParameters( version: VersionSpecifier.TryParse(preRelease, out var spec) ? spec : VersionSpecifier.AnyRelease, os: os, architecture: deploymentInstallationArchitecture, name: name); string maybeQuote(object o) { if (o is string s) return $"\"{s}\""; return o.ToString(); } var parameterString = string.Join(", ", parameters.Select(kvp => $"{kvp.Key}: {maybeQuote(kvp.Value)}")); var graphqlQuery = $@"query Query {{ objects({parameterString}) {{ name version dependencies {{ name version }} }} }}"; var postTo = repoUrl.TrimEnd('/') + "/4.0/query"; var req = new HttpRequestMessage(HttpMethod.Post, postTo); req.Content = new StringContent(graphqlQuery, Encoding.UTF8, "application/json"); req.Headers.Add("Accept", "application/json"); var res = httpClient.SendAsync(req).Result; if (res.IsSuccessStatusCode) { var graph = new PackageDependencyGraph(); var packages = res.Content.ReadAsStringAsync().Result; { var json = JsonDocument.Parse(packages); var data = json.RootElement.GetProperty("data"); var objects = data.GetProperty("objects"); // The result will be wrapped in { "data": { "objects": ... } } // We need to unwrap it a bit // json = json.RootElement.GetProperty() graph.LoadFromJson(objects); } log.Debug(sw, "{1} -> found {0} packages.", graph.Count, JsonSerializer.Serialize(parameters)); return graph; } if (res.StatusCode == HttpStatusCode.Unauthorized) { if (res.Headers.TryGetValues("WWW-Authenticate", out var authErrors)) { // This is usually a single value similar to: // Token error=invalid_token, error_description=Invalid User Token. var errors = authErrors.ToArray(); foreach (var err in errors) { if (err.Contains("error_description=")) { var errstring = err.Split('=').LastOrDefault(); if (errstring != null) throw new PackageQueryException($"Unauthorized: {errstring}"); } } // This should not happen, but to be safe var fallback = string.Join("\n", errors); throw new PackageQueryException($"Unauthorized: {fallback}"); } } throw new PackageQueryException($"{(int)res.StatusCode} {res.StatusCode}."); } } }