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