From ed17d0974b8bbb4f4e38602c74a2bb870a566638 Mon Sep 17 00:00:00 2001 From: samatstarion Date: Sun, 31 May 2026 20:29:29 +0200 Subject: [PATCH 1/3] [Fix] make VersionChecker URL/timeout configurable, improve logging, and parse tags safely; fixes #33 --- .../Services/VersionCheckerTestFixture.cs | 127 ++++++++++++++++++ ECoreNetto.Tools/Services/VersionChecker.cs | 75 +++++++++-- 2 files changed, 194 insertions(+), 8 deletions(-) diff --git a/ECoreNetto.Tools.Tests/Services/VersionCheckerTestFixture.cs b/ECoreNetto.Tools.Tests/Services/VersionCheckerTestFixture.cs index a8ba633..314f767 100644 --- a/ECoreNetto.Tools.Tests/Services/VersionCheckerTestFixture.cs +++ b/ECoreNetto.Tools.Tests/Services/VersionCheckerTestFixture.cs @@ -93,6 +93,72 @@ public async Task Verify_that_when_cancelled_exception_is_thrown() await Assert.ThatAsync(() => checker.ExecuteAsync(cts.Token), Throws.TypeOf()); } + [Test] + public void Verify_that_default_url_and_timeout_are_used_when_not_configured() + { + Assert.Multiple(() => + { + Assert.That(this.versionChecker.ReleasesUrl, Is.EqualTo(VersionChecker.DefaultReleasesUrl)); + Assert.That(this.versionChecker.Timeout, Is.EqualTo(TimeSpan.FromSeconds(2))); + }); + } + + [Test] + public void Verify_that_url_and_timeout_are_configurable() + { + var checker = new VersionChecker(this.httpClientFactory, this.loggerFactory, + "https://example.test/releases/latest", TimeSpan.FromSeconds(5)); + + Assert.Multiple(() => + { + Assert.That(checker.ReleasesUrl, Is.EqualTo("https://example.test/releases/latest")); + Assert.That(checker.Timeout, Is.EqualTo(TimeSpan.FromSeconds(5))); + }); + } + + [Test] + public async Task Verify_that_QueryLatestReleaseAsync_uses_the_configured_url() + { + var recordingHandler = new RecordingHandler(); + var factory = new StubHttpClientFactory(new HttpClient(recordingHandler)); + + var checker = new VersionChecker(factory, this.loggerFactory, "https://example.test/releases/latest"); + + await checker.QueryLatestReleaseAsync(new CancellationTokenSource().Token); + + Assert.That(recordingHandler.LastRequestUri?.ToString(), Is.EqualTo("https://example.test/releases/latest")); + } + + [Test] + public async Task Verify_that_ExecuteAsync_handles_a_malformed_tag_name_gracefully() + { + var factory = new StubHttpClientFactory(new HttpClient(new TagNameHandler("not-a-version"))); + + var checker = new VersionChecker(factory, this.loggerFactory); + + await Assert.ThatAsync(() => checker.ExecuteAsync(new CancellationTokenSource().Token), Throws.Nothing); + } + + [Test] + public async Task Verify_that_ExecuteAsync_handles_an_empty_tag_name() + { + var factory = new StubHttpClientFactory(new HttpClient(new TagNameHandler(""))); + + var checker = new VersionChecker(factory, this.loggerFactory); + + await Assert.ThatAsync(() => checker.ExecuteAsync(new CancellationTokenSource().Token), Throws.Nothing); + } + + [Test] + public async Task Verify_that_ExecuteAsync_handles_a_v_prefixed_tag_name() + { + var factory = new StubHttpClientFactory(new HttpClient(new TagNameHandler("v9.9.9"))); + + var checker = new VersionChecker(factory, this.loggerFactory); + + await Assert.ThatAsync(() => checker.ExecuteAsync(new CancellationTokenSource().Token), Throws.Nothing); + } + /// /// Very simple IHttpClientFactory used just for tests. /// It always returns the HttpClient passed in the constructor. @@ -153,5 +219,66 @@ protected override Task SendAsync(HttpRequestMessage reques throw new TaskCanceledException(); } } + + /// + /// An that always returns the provided . + /// + private sealed class StubHttpClientFactory : IHttpClientFactory + { + private readonly HttpClient client; + + public StubHttpClientFactory(HttpClient client) + { + this.client = client; + } + + public HttpClient CreateClient(string name) + { + return this.client; + } + } + + /// + /// A handler that records the requested and returns a successful release payload. + /// + private sealed class RecordingHandler : HttpMessageHandler + { + public Uri? LastRequestUri { get; private set; } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + this.LastRequestUri = request.RequestUri; + + const string json = "{\"tag_name\":\"1.2.3\",\"body\":\"notes\",\"html_url\":\"https://example.com\"}"; + + return Task.FromResult(new HttpResponseMessage(System.Net.HttpStatusCode.OK) + { + Content = new StringContent(json) + }); + } + } + + /// + /// A handler that returns a release payload with a configurable tag name. + /// + private sealed class TagNameHandler : HttpMessageHandler + { + private readonly string tagName; + + public TagNameHandler(string tagName) + { + this.tagName = tagName; + } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var json = $"{{\"tag_name\":\"{this.tagName}\",\"body\":\"notes\",\"html_url\":\"https://example.com\"}}"; + + return Task.FromResult(new HttpResponseMessage(System.Net.HttpStatusCode.OK) + { + Content = new StringContent(json) + }); + } + } } } diff --git a/ECoreNetto.Tools/Services/VersionChecker.cs b/ECoreNetto.Tools/Services/VersionChecker.cs index daedebc..3002cfa 100644 --- a/ECoreNetto.Tools/Services/VersionChecker.cs +++ b/ECoreNetto.Tools/Services/VersionChecker.cs @@ -47,6 +47,11 @@ public class VersionChecker : IVersionChecker /// private readonly IHttpClientFactory httpClientFactory; + /// + /// The default GitHub API URL used to query the latest release + /// + public const string DefaultReleasesUrl = "https://api.github.com/repos/STARIONGROUP/EcoreNetto/releases/latest"; + /// /// Initializes a new instance of the /// @@ -56,12 +61,30 @@ public class VersionChecker : IVersionChecker /// /// The (injected) used to set up logging /// - public VersionChecker(IHttpClientFactory httpClientFactory, ILoggerFactory? loggerFactory = null) + /// + /// The GitHub API URL used to query the latest release. When null, is used. + /// + /// + /// The timeout applied to the HTTP request. When null, a default of 2 seconds is used. + /// + public VersionChecker(IHttpClientFactory httpClientFactory, ILoggerFactory? loggerFactory = null, string? releasesUrl = null, TimeSpan? timeout = null) { this.httpClientFactory = httpClientFactory; this.logger = loggerFactory == null ? NullLogger.Instance : loggerFactory.CreateLogger(); + this.ReleasesUrl = releasesUrl ?? DefaultReleasesUrl; + this.Timeout = timeout ?? TimeSpan.FromSeconds(2); } + /// + /// Gets the GitHub API URL used to query the latest release + /// + public string ReleasesUrl { get; } + + /// + /// Gets the timeout applied to the HTTP request + /// + public TimeSpan Timeout { get; } + /// /// Checks for the lastest release /// @@ -82,7 +105,12 @@ public async Task ExecuteAsync(CancellationToken cancellationToken) if (payload != null) { var currentVersion = Assembly.GetExecutingAssembly().GetName().Version; - var publishedVersion = new Version(payload.TagName); + + if (!TryParseVersion(payload.TagName, out var publishedVersion)) + { + this.logger.LogWarning("Unable to parse the published version '{TagName}' returned by the GitHub API", payload.TagName); + return; + } if (currentVersion < publishedVersion) { @@ -120,15 +148,13 @@ public async Task ExecuteAsync(CancellationToken cancellationToken) public async Task QueryLatestReleaseAsync(CancellationToken cancellationToken) { var httpClient = this.httpClientFactory.CreateClient(); - httpClient.Timeout = TimeSpan.FromSeconds(2); - - const string requestUrl = "https://api.github.com/repos/STARIONGROUP/EcoreNetto/releases/latest"; + httpClient.Timeout = this.Timeout; try { httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("ECoreNetto.Tools"); - var response = await httpClient.GetAsync(requestUrl, cancellationToken); + var response = await httpClient.GetAsync(this.ReleasesUrl, cancellationToken); if (response.IsSuccessStatusCode) { @@ -140,14 +166,47 @@ public async Task ExecuteAsync(CancellationToken cancellationToken) } catch (TaskCanceledException taskCanceledException) { - this.logger.LogWarning(taskCanceledException, "Contacting the GitGub API at {Url} timed out", requestUrl); + this.logger.LogWarning(taskCanceledException, "Contacting the GitHub API at {Url} timed out", this.ReleasesUrl); } catch (Exception ex) { - this.logger.LogError(ex, ""); + this.logger.LogError(ex, "An error occurred while querying the latest release from the GitHub API at {Url}", this.ReleasesUrl); } return null; } + + /// + /// Attempts to parse the version from a GitHub release tag name, tolerating a leading + /// 'v'/'V' prefix and any SemVer pre-release or build-metadata suffix (e.g. '-beta', '+build'). + /// + /// + /// The raw tag name returned by the GitHub API + /// + /// + /// The parsed when parsing succeeds; otherwise null. + /// + /// + /// true when the tag name could be parsed into a , false otherwise. + /// + private static bool TryParseVersion(string? tagName, out Version? version) + { + version = null; + + if (string.IsNullOrWhiteSpace(tagName)) + { + return false; + } + + var candidate = tagName!.Trim().TrimStart('v', 'V'); + + var suffixIndex = candidate.IndexOfAny(new[] { '-', '+' }); + if (suffixIndex >= 0) + { + candidate = candidate.Substring(0, suffixIndex); + } + + return Version.TryParse(candidate, out version); + } } } From 98ebdc2c55d1f4308d53abc8212b308e76a92fa8 Mon Sep 17 00:00:00 2001 From: samatstarion Date: Sun, 31 May 2026 20:41:08 +0200 Subject: [PATCH 2/3] Replaced the per-call char[] allocation in VersionChecker.TryParseVersion with a cached SearchValues --- ECoreNetto.Tools/Services/VersionChecker.cs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/ECoreNetto.Tools/Services/VersionChecker.cs b/ECoreNetto.Tools/Services/VersionChecker.cs index 3002cfa..54bc435 100644 --- a/ECoreNetto.Tools/Services/VersionChecker.cs +++ b/ECoreNetto.Tools/Services/VersionChecker.cs @@ -21,6 +21,7 @@ namespace ECoreNetto.Tools.Services { using System; + using System.Buffers; using System.Net.Http; using System.Reflection; using System.Text.Json; @@ -52,6 +53,12 @@ public class VersionChecker : IVersionChecker /// public const string DefaultReleasesUrl = "https://api.github.com/repos/STARIONGROUP/EcoreNetto/releases/latest"; + /// + /// The cached of the SemVer pre-release / build-metadata separators + /// used when trimming a tag name before version parsing. + /// + private static readonly SearchValues SemVerSuffixSeparators = SearchValues.Create("-+"); + /// /// Initializes a new instance of the /// @@ -200,7 +207,7 @@ private static bool TryParseVersion(string? tagName, out Version? version) var candidate = tagName!.Trim().TrimStart('v', 'V'); - var suffixIndex = candidate.IndexOfAny(new[] { '-', '+' }); + var suffixIndex = candidate.AsSpan().IndexOfAny(SemVerSuffixSeparators); if (suffixIndex >= 0) { candidate = candidate.Substring(0, suffixIndex); From de166b64abb3ba00b635aadb111e507232633b95 Mon Sep 17 00:00:00 2001 From: samatstarion Date: Sun, 31 May 2026 20:51:43 +0200 Subject: [PATCH 3/3] improvement --- ECoreNetto/ModelElement/EObject.cs | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/ECoreNetto/ModelElement/EObject.cs b/ECoreNetto/ModelElement/EObject.cs index 59e4983..b5a26ce 100644 --- a/ECoreNetto/ModelElement/EObject.cs +++ b/ECoreNetto/ModelElement/EObject.cs @@ -377,6 +377,12 @@ private void ReadChildNodes(XmlNode reader) } } + /// + /// The names of the reference attributes whose values may contain implicit references + /// that need to be rewritten to point at the current top package. + /// + private static readonly string[] ReferenceAttributes = { "eType", "eSuperTypes", "eOpposite" }; + /// /// Process the attribute value and rewrite if required. /// @@ -391,9 +397,7 @@ private void ReadChildNodes(XmlNode reader) /// private string ProcessAttributeValue(string attributeName, string attributeValue) { - var referenceAttributes = new[] { "eType", "eSuperTypes", "eOpposite" }; - - if (!referenceAttributes.Contains(attributeName)) + if (!ReferenceAttributes.Contains(attributeName)) { // nothing to do return attributeValue;