diff --git a/src/Shared/Contracts/IBaseProjectManager.cs b/src/Shared/Contracts/IBaseProjectManager.cs index d2eaf23f..e4fb463c 100644 --- a/src/Shared/Contracts/IBaseProjectManager.cs +++ b/src/Shared/Contracts/IBaseProjectManager.cs @@ -128,6 +128,14 @@ public interface IBaseProjectManager /// public JsonElement? GetVersionElement(JsonDocument contentJSON, Version version); + /// + /// Gets the JSON element for a package version using string comparison (for non-standard formats like "1.0.2010022026"). + /// + /// + /// + /// + public JsonElement? GetVersionElement(JsonDocument contentJSON, String version); + /// /// Gets all the versions of a package /// diff --git a/src/Shared/PackageManagers/BaseProjectManager.cs b/src/Shared/PackageManagers/BaseProjectManager.cs index 6ca840d9..48852aa9 100644 --- a/src/Shared/PackageManagers/BaseProjectManager.cs +++ b/src/Shared/PackageManagers/BaseProjectManager.cs @@ -404,6 +404,13 @@ public virtual async Task DetailedPackageVersionExistsAsync(P /// public virtual JsonElement? GetVersionElement(JsonDocument contentJSON, Version version) + { + string typeName = GetType().Name; + throw new NotImplementedException($"{typeName} does not implement GetVersions."); + } + + /// + public virtual JsonElement? GetVersionElement(JsonDocument contentJSON, string version) { string typeName = GetType().Name; throw new NotImplementedException($"{typeName} does not implement GetVersions."); diff --git a/src/Shared/PackageManagers/NPMProjectManager.cs b/src/Shared/PackageManagers/NPMProjectManager.cs index 520d2e24..54bcc778 100644 --- a/src/Shared/PackageManagers/NPMProjectManager.cs +++ b/src/Shared/PackageManagers/NPMProjectManager.cs @@ -246,16 +246,127 @@ public override async Task> EnumerateVersionsAsync(PackageUR } /// - /// Gets the latest version of the package + /// Helper method to find the latest version name, preferring dist-tags if available, + /// otherwise falling back to time-based comparison + /// + /// + /// The latest version name + private string? GetLatestVersionName(JsonDocument contentJSON) + { + if (contentJSON is null) { return null; } + + JsonElement root = contentJSON.RootElement; + + try + { + // Try to get the "latest" version from dist-tags first + if (root.TryGetProperty("dist-tags", out JsonElement distTags) && + distTags.TryGetProperty("latest", out JsonElement latestElement)) + { + string? latestFromDistTag = latestElement.GetString(); + if (!string.IsNullOrWhiteSpace(latestFromDistTag)) + { + return latestFromDistTag; + } + } + } + catch (Exception ex) + { + Logger.Debug("Error getting latest version from dist-tags: {0}", ex.Message); + } + + // Fallback to time-based comparison if dist-tags is empty or not available + return GetLatestVersionNameByTime(contentJSON); + } + + /// + /// Helper method to find the latest version name based on published time + /// + /// + /// The latest version name by published time + private string? GetLatestVersionNameByTime(JsonDocument contentJSON) + { + if (contentJSON is null) { return null; } + + JsonElement root = contentJSON.RootElement; + string? latestVersionByTime = null; + DateTime? latestTime = null; + + try + { + // Get the time metadata for all versions + if (!root.TryGetProperty("time", out JsonElement timeElement)) + { + return null; + } + + // Get all versions + if (!root.TryGetProperty("versions", out JsonElement versionsElement)) + { + return null; + } + + // Iterate through all versions and find the one with the latest publish time + foreach (JsonProperty versionProperty in versionsElement.EnumerateObject()) + { + string versionName = versionProperty.Name; + + // Get the publish time for this version + if (timeElement.TryGetProperty(versionName, out JsonElement versionTime)) + { + try + { + string? timeString = versionTime.GetString(); + if (!string.IsNullOrEmpty(timeString) && DateTime.TryParse(timeString, out DateTime publishTime)) + { + if (latestTime is null || publishTime > latestTime) + { + latestTime = publishTime; + latestVersionByTime = versionName; + } + } + } + catch (Exception ex) + { + Logger.Debug("Error parsing publish time for version {0}: {1}", versionName, ex.Message); + } + } + } + + return latestVersionByTime; + } + catch (Exception ex) + { + Logger.Debug("Error getting latest version name by time: {0}", ex.Message); + } + + return null; + } + + /// + /// Gets the latest version element based on dist-tags if available, otherwise by published time /// /// /// public JsonElement? GetLatestVersionElement(JsonDocument contentJSON) { - List versions = GetVersions(contentJSON); - Version? maxVersion = GetLatestVersion(versions); - if (maxVersion is null) { return null; } - return GetVersionElement(contentJSON, maxVersion); + if (contentJSON is null) { return null; } + + try + { + string? latestVersion = GetLatestVersionName(contentJSON); + + if (!string.IsNullOrEmpty(latestVersion)) + { + return GetVersionElement(contentJSON, latestVersion); + } + } + catch (Exception ex) + { + Logger.Debug("Error getting latest version element: {0}", ex.Message); + } + + return null; } /// @@ -303,8 +414,8 @@ public override Uri GetPackageAbsoluteUri(PackageURL purl) metadata.ApiPackageUri = $"{ENV_NPM_API_ENDPOINT}/{metadata.Name}"; metadata.CreatedTime = ParseCreatedTime(contentJSON); - List versions = GetVersions(contentJSON); - Version? latestVersion = GetLatestVersion(versions); + // Get the latest version, preferring dist-tags if available, otherwise using time-based comparison + string? latestVersion = GetLatestVersionName(contentJSON); if (purl.Version != null) { @@ -313,14 +424,13 @@ public override Uri GetPackageAbsoluteUri(PackageURL purl) } else { - metadata.PackageVersion = latestVersion is null ? purl.Version : latestVersion?.ToString(); + metadata.PackageVersion = latestVersion ?? purl.Version; } // if we found any version at all, get the information if (metadata.PackageVersion != null) { - Version versionToGet = new(metadata.PackageVersion); - JsonElement? versionElement = GetVersionElement(contentJSON, versionToGet); + JsonElement? versionElement = GetVersionElement(contentJSON, metadata.PackageVersion); metadata.UploadTime = ParseUploadTime(contentJSON, metadata.PackageVersion); if (versionElement != null) @@ -478,7 +588,7 @@ is JsonElement.ArrayEnumerator enumeratorElement && if (latestVersion is not null) { - metadata.LatestPackageVersion = latestVersion.ToString(); + metadata.LatestPackageVersion = latestVersion; } return metadata; @@ -521,6 +631,12 @@ is JsonElement.ArrayEnumerator enumeratorElement && } public override JsonElement? GetVersionElement(JsonDocument? contentJSON, Version version) + { + return GetVersionElement(contentJSON, version.ToString()); + } + + + public override JsonElement? GetVersionElement(JsonDocument? contentJSON, string version) { if (contentJSON is null) { return null; } JsonElement root = contentJSON.RootElement; @@ -530,9 +646,9 @@ is JsonElement.ArrayEnumerator enumeratorElement && JsonElement versionsJSON = root.GetProperty("versions"); foreach (JsonProperty versionProperty in versionsJSON.EnumerateObject()) { - if (string.Equals(versionProperty.Name, version.ToString(), StringComparison.InvariantCultureIgnoreCase)) + if (string.Equals(versionProperty.Name, version, StringComparison.InvariantCultureIgnoreCase)) { - return versionsJSON.GetProperty(version.ToString()); + return versionsJSON.GetProperty(version); } } } @@ -542,26 +658,6 @@ is JsonElement.ArrayEnumerator enumeratorElement && return null; } - public override List GetVersions(JsonDocument? contentJSON) - { - List allVersions = new(); - if (contentJSON is null) { return allVersions; } - - JsonElement root = contentJSON.RootElement; - try - { - JsonElement versions = root.GetProperty("versions"); - foreach (JsonProperty version in versions.EnumerateObject()) - { - allVersions.Add(new Version(version.Name)); - } - } - catch (KeyNotFoundException) { return allVersions; } - catch (InvalidOperationException) { return allVersions; } - - return allVersions; - } - public override async Task DetailedPackageExistsAsync(PackageURL purl, bool useCache = true) { Logger.Trace("DetailedPackageExists {0}", purl?.ToString()); @@ -788,7 +884,7 @@ protected async Task> SearchRepoUrlsInPackageMeta // TODO: If the latest version JSONElement doesnt have the repo infor, should we search all elements // on that chance that one of them might have it? JsonElement? versionJSON = string.IsNullOrEmpty(purl?.Version) ? GetLatestVersionElement(contentJSON) : - GetVersionElement(contentJSON, new Version(purl.Version)); + GetVersionElement(contentJSON, purl.Version); if (versionJSON is JsonElement notNullVersionJSON) { diff --git a/src/oss-tests/ProjectManagerTests/NPMProjectManagerTests.cs b/src/oss-tests/ProjectManagerTests/NPMProjectManagerTests.cs index 80abfb6a..b585f0dc 100644 --- a/src/oss-tests/ProjectManagerTests/NPMProjectManagerTests.cs +++ b/src/oss-tests/ProjectManagerTests/NPMProjectManagerTests.cs @@ -84,6 +84,7 @@ public class NPMProjectManagerTests { "https://registry.npmjs.org/%40somosme/webflowutils", Resources.unpublishedpackage_json }, { "https://registry.npmjs.org/%40angular/core", Resources.angular_core_json }, { "https://registry.npmjs.org/%40achievementify/client", Resources.achievementify_client_json }, + { "https://registry.npmjs.org/%40adguard/dnr-rulesets", Resources.adguard_dnr_rulesets_json }, { "https://registry.npmjs.org/ds-modal", Resources.ds_modal_json }, { "https://registry.npmjs.org/monorepolint", Resources.monorepolint_json }, { "https://registry.npmjs.org/rly-cli", Resources.rly_cli_json }, @@ -414,6 +415,48 @@ public async Task GetPackagesFromOwnerAsyncSucceeds_Async(string owner, string e packages.Select(p => p.ToString()).Should().Contain(expectedPackage); } + [Fact] + public async Task GetVersionElement_WithNonStandardVersionFormatAsync() + { + // Test non-standard version format like "4.0.20260218200111" (can't be parsed as SemVer but can be compared as strings) + PackageURL purl = new("pkg:npm/%40adguard/dnr-rulesets"); + string? content = await _projectManager.Object.GetMetadataAsync(purl, useCache: false); + JsonDocument contentJSON = JsonDocument.Parse(content); + + JsonElement? versionElement = _projectManager.Object.GetVersionElement(contentJSON, "4.0.20260218200111"); + + Assert.NotNull(versionElement); + Assert.Equal("4.0.20260218200111", versionElement?.GetProperty("version").GetString()); + } + + [Theory] + [InlineData("pkg:npm/lodash", "4.17.21")] + [InlineData("pkg:npm/%40angular/core", "13.2.6")] + [InlineData("pkg:npm/%40adguard/dnr-rulesets", "4.0.20260218200111")] + public async Task GetLatestVersionElement_WithValidJsonDocument_ReturnsLatestVersionElement(string purlString, string expectedLatestVersion) + { + // Test that GetLatestVersionElement returns the version element with the latest publish time + PackageURL purl = new(purlString); + string? content = await _projectManager.Object.GetMetadataAsync(purl, useCache: false); + JsonDocument contentJSON = JsonDocument.Parse(content); + + JsonElement? latestVersionElement = _projectManager.Object.GetLatestVersionElement(contentJSON); + + Assert.NotNull(latestVersionElement); + + string? latestVersion = latestVersionElement?.GetProperty("version").GetString(); + Assert.Equal(expectedLatestVersion, latestVersion); + } + + [Fact] + public void GetLatestVersionElement_WithNullJsonDocument_ReturnsNull() + { + // Test that GetLatestVersionElement returns null when given a null document + JsonElement? result = _projectManager.Object.GetLatestVersionElement(null); + + Assert.Null(result); + } + private static void MockHttpFetchResponse( HttpStatusCode statusCode, string url, diff --git a/src/oss-tests/Properties/Resources.Designer.cs b/src/oss-tests/Properties/Resources.Designer.cs index a2ec0d81..f1060a44 100644 --- a/src/oss-tests/Properties/Resources.Designer.cs +++ b/src/oss-tests/Properties/Resources.Designer.cs @@ -10,8 +10,8 @@ namespace oss { using System; - - + + /// /// A strongly-typed resource class, for looking up localized strings, etc. /// @@ -69,6 +69,15 @@ internal static string achievementify_client_json { } } + /// + /// Looks up a localized string similar to {"_id":"@adguard/dnr-rulesets","name":"@adguard/dnr-rulesets","dist-tags":{"latest":"4.0.20260218200111"},"versions":{"4.0.20260218200111":{"name":"@adguard/dnr-rulesets","version":"4.0.20260218200111","_id":"@adguard/dnr-rulesets@4.0.20260218200111","_npmVersion":"10.0.0","_npmUser":{"name":"adguard","email":"support@adguard.com"},"description":"AdGuard DNS filtering rules"},"4.0.20260217190530":{"name":"@adguard/dnr-rulesets","version":"4.0.20260217190530","_id":"@adguard/dnr-rulesets@4.0.20260217190530", [rest of string was truncated]";. + /// + internal static string adguard_dnr_rulesets_json { + get { + return ResourceManager.GetString("adguard_dnr_rulesets.json", resourceCulture); + } + } + /// /// Looks up a localized string similar to {"_id":"@angular/core","_rev":"660-3fe4d5b1b9e632c7d96cdc1e1005b13c","name":"@angular/core","dist-tags":{"latest":"13.2.6","v4-lts":"4.4.7","v5-lts":"5.2.11","next":"13.3.0-rc.0","v7-lts":"7.2.15","v6-lts":"6.1.10","v9-lts":"9.1.13","v8-lts":"8.2.14","v10-lts":"10.2.5","v11-lts":"11.2.14","v12-lts":"12.2.16"},"versions":{"0.0.0-0":{"name":"@angular/core","version":"0.0.0-0","description":"","main":"index.js","jsnext:main":"esm/index.js","typings":"index.d.ts","author":{"name":"angular"},"license":"MIT","pee [rest of string was tru.... /// @@ -327,20 +336,20 @@ internal static string maven_core_1_0_0_alpha2_aar { return ResourceManager.GetString("maven_core_1.0.0_alpha2_aar", resourceCulture); } } - + /// - /// Looks up a localized string similar to <!DOCTYPE html> - ///<html lang="en"> - /// - ///<head> - /// <script nonce="8DQ1A3FbR-RCf6afH0_tuQ">if (window.location.search.substring(1) !== "full=true") { // do not redirect if querystring is ?full=true - /// if (navigator.userAgent.match(/i(Phone|Pad)|Android|Blackberry|WebOs/i)) { // detect mobile browser - /// window.location.replace("m_index.html"); // redirect if mobile browser detected - /// } - /// }</script> - /// - /// <title>Google's Maven Repository</title> - /// <meta charset="utf-8"> + /// Looks up a localized string similar to <!DOCTYPE html> + ///<html lang="en"> + /// + ///<head> + /// <script nonce="8DQ1A3FbR-RCf6afH0_tuQ">if (window.location.search.substring(1) !== "full=true") { // do not redirect if querystring is ?full=true + /// if (navigator.userAgent.match(/i(Phone|Pad)|Android|Blackberry|WebOs/i)) { // detect mobile browser + /// window.location.replace("m_index.html"); // redirect if mobile browser detected + /// } + /// }</script> + /// + /// <title>Google's Maven Repository</title> + /// <meta charset="utf-8"> /// <meta name="viewport" conte [rest of string was truncated]";. /// internal static string maven_core_1_0_0_alpha2_html { @@ -348,17 +357,17 @@ internal static string maven_core_1_0_0_alpha2_html { return ResourceManager.GetString("maven_core_1.0.0_alpha2_html", resourceCulture); } } - + /// /// Looks up a localized string similar to <?xml version="1.0" encoding="UTF-8"?> - ///<project xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd" xmlns="http://maven.apache.org/POM/4.0.0" - /// xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"> + ///<project xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd" xmlns="http://maven.apache.org/POM/4.0.0" + /// xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"> /// <modelVersion>4.0.0</modelVersion> - /// <groupId>android.arch.core</groupId> - /// <artifactId>core</artifactId> - /// <version>1.0.0-alpha2</version> + /// <groupId>android.arch.core</groupId> + /// <artifactId>core</artifactId> + /// <version>1.0.0-alpha2</version> /// <packaging>aar</packaging> - /// <url>https://developer.android.com/topic/libraries/architecture/index.html</url> + /// <url>https://developer.android.com/topic/libraries/architecture/index.html</url> /// <incepti [rest of string was truncated]";. /// internal static string maven_core_1_0_0_alpha2_pom { @@ -465,20 +474,20 @@ internal static string maven_cose_20230908_javadoc_jar { return ResourceManager.GetString("maven_cose_20230908_javadoc_jar", resourceCulture); } } - + /// - /// Looks up a localized string similar to <project xmlns="http://maven.apache.org/POM/4.0.0" - /// xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" - /// xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> - /// <modelVersion>4.0.0</modelVersion> - /// <groupId>com.google.cose</groupId> - /// <artifactId>cose</artifactId> - /// <version>20230908</version> - /// <dependencies> - /// <dependency> - /// <groupId>co.nstant.in</groupId> - /// <artifactId>cbor</artifactId> - /// <version>0.9</version> + /// Looks up a localized string similar to <project xmlns="http://maven.apache.org/POM/4.0.0" + /// xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + /// xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> + /// <modelVersion>4.0.0</modelVersion> + /// <groupId>com.google.cose</groupId> + /// <artifactId>cose</artifactId> + /// <version>20230908</version> + /// <dependencies> + /// <dependency> + /// <groupId>co.nstant.in</groupId> + /// <artifactId>cbor</artifactId> + /// <version>0.9</version> /// </dependency> /// <dep [rest of string was truncated]";. /// @@ -822,7 +831,7 @@ internal static string razorengine_4_2_3_beta1_metadata_json { return ResourceManager.GetString("razorengine.4.2.3-beta1.metadata.json", resourceCulture); } } - + /// /// Looks up a localized string similar to {"@id":"https://api.nuget.org/v3/registration5-gz-semver2/razorengine/index.json","@type":["catalog:CatalogRoot","PackageRegistration","catalog:Permalink"],"commitId":"b44e2827-f5db-4ba2-bb8c-5e538ad0e7b1","commitTimeStamp":"2022-03-11T23:18:21.9196828+00:00","count":2,"items":[{"@id":"https://api.nuget.org/v3/registration5-gz-semver2/razorengine/index.json#page/2.1.0/4.1.5-beta1","@type":"catalog:CatalogPage","commitId":"b44e2827-f5db-4ba2-bb8c-5e538ad0e7b1","commitTimeStamp":"2022-03-11T23:18:21.9196828+0 [rest of string was truncated]";. /// @@ -856,7 +865,7 @@ internal static string razorengine_latest_metadata_json { return ResourceManager.GetString("razorengine.latest.metadata.json", resourceCulture); } } - + /// /// Looks up a localized string similar to ["2.1.0","3.0.0","3.0.3","3.0.4","3.0.5","3.0.6","3.0.7","3.0.8","3.1.0","3.2.0","3.3.0","3.4.0","3.4.1","3.4.2","3.5.0-beta1","3.5.0-beta2","3.5.0-beta3","3.5.0","3.5.1","3.5.2","3.5.3","3.6.0","3.6.1","3.6.2","3.6.3-beta1","3.6.3-beta2","3.6.3","3.6.4","3.6.5-beta1","3.6.5","3.6.6-beta1","3.6.6-beta2","3.6.6","3.7.0-beta1","3.7.0","3.7.1-alpha1","3.7.2","3.7.3","3.7.4","3.7.5-beta1","3.7.5-beta2","3.7.5","3.7.6","3.7.7","3.8.0","3.8.1","3.8.2","3.9.0","3.9.1","3.9.2","3.9.3","3.10.0","3.10.... /// diff --git a/src/oss-tests/Properties/Resources.resx b/src/oss-tests/Properties/Resources.resx index 755547ef..f7f0f81c 100644 --- a/src/oss-tests/Properties/Resources.resx +++ b/src/oss-tests/Properties/Resources.resx @@ -124,6 +124,9 @@ ..\TestData\NPM\achievementify_client.json;System.String, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + ..\TestData\NPM\adguard_dnr_rulesets.json;System.String, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + ..\TestData\NPM\ds-modal.json;System.String, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 diff --git a/src/oss-tests/TestData/NPM/adguard_dnr_rulesets.json b/src/oss-tests/TestData/NPM/adguard_dnr_rulesets.json new file mode 100644 index 00000000..60970abb --- /dev/null +++ b/src/oss-tests/TestData/NPM/adguard_dnr_rulesets.json @@ -0,0 +1 @@ +{"_id":"@adguard/dnr-rulesets","name":"@adguard/dnr-rulesets","dist-tags":{"latest":"4.0.20260218200111"},"versions":{"4.0.20260218200111":{"name":"@adguard/dnr-rulesets","version":"4.0.20260218200111","_id":"@adguard/dnr-rulesets@4.0.20260218200111","_npmVersion":"10.0.0","_npmUser":{"name":"adguard","email":"support@adguard.com"},"description":"AdGuard DNS filtering rules"},"4.0.20260217190530":{"name":"@adguard/dnr-rulesets","version":"4.0.20260217190530","_id":"@adguard/dnr-rulesets@4.0.20260217190530","description":"AdGuard DNS filtering rules"}},"time":{"created":"2026-02-01T00:00:00.000Z","4.0.20260218200111":"2026-02-18T20:01:11.000Z","4.0.20260217190530":"2026-02-17T19:05:30.000Z","modified":"2026-02-18T20:01:11.000Z"}}