diff --git a/internal/semantic/compare_test.go b/internal/semantic/compare_test.go index de200248641..99ed53b4156 100644 --- a/internal/semantic/compare_test.go +++ b/internal/semantic/compare_test.go @@ -217,6 +217,14 @@ func TestVersion_Compare_Ecosystems(t *testing.T) { name: "Debian", file: "debian-versions-generated.txt", }, + { + name: "CRAN", + file: "cran-versions.txt", + }, + { + name: "CRAN", + file: "cran-versions-generated.txt", + }, } for _, tt := range tests { tt := tt diff --git a/internal/semantic/fixtures/cran-versions-generated.txt b/internal/semantic/fixtures/cran-versions-generated.txt new file mode 100644 index 00000000000..5ec1f923b9a --- /dev/null +++ b/internal/semantic/fixtures/cran-versions-generated.txt @@ -0,0 +1,57 @@ +0.1.0 < 0.1.1 +0.1.1 < 1.0.0 +1.0.0 < 1.1.0 +1.1.0 < 1.2.0 +1.2.0 < 1.4.1 +0.9.12 < 0.9.13 +0.9.13 < 0.9.14 +0.9.14 < 0.9.15 +0.9.15 < 0.9.16 +0.9.16 < 0.9.17 +0.9.17 < 0.9.18 +0.9.18 < 0.9.19 +0.9.19 < 0.9.20 +0.9.20 < 0.9.21 +0.9.21 < 0.9.22 +0.9.22 < 1.0 +1.0 < 1.1 +1.1 < 1.2 +1.2 < 1.3 +1.3 < 1.4 +1.4 < 1.5 +1.5 < 1.6 +1.6 < 1.6.1 +1.6.1 < 1.7.0 +1.7.0 < 1.7.1 +1.7.1 < 1.7.2 +1.7.2 < 1.7.3 +1.7.3 < 1.8.0 +1.8.0 < 1.8.1 +1.8.1 < 1.8.2 +1.8.2 < 1.8.3 +1.8.3 < 1.8.4 +1.8.4 < 1.8.5 +1.8.5 < 1.8.6 +1.8.6 < 1.8.7 +0.7.1 < 1.0.0 +1.0.0 < 1.0.1 +1.0.1 < 1.1.1 +1.1.1 < 1.1.2 +1.1.2 < 1.2.1 +1.2.1 < 1.2.2 +0.1.1 < 0.2.0 +0.2.0 < 0.2.1 +0.2.1 < 1.0.0 +0.2 < 0.4 +0.4 < 0.5 +0.5 < 0.6 +0.6 < 0.7 +0.7 < 0.8 +0.8 < 0.9 +0.9 < 1.0 +1.2 < 1.4 +1.6 < 1.7 +1.7 < 1.8 +1.8 < 1.8.0 +1.8.1 < 1.9 +1.9 < 1.9.0 diff --git a/internal/semantic/fixtures/cran-versions.txt b/internal/semantic/fixtures/cran-versions.txt new file mode 100644 index 00000000000..1a8d08b608d --- /dev/null +++ b/internal/semantic/fixtures/cran-versions.txt @@ -0,0 +1,34 @@ +0.01 < 0.1-0 +0.01.0 = 0.1-0 +0.9 < 0.75 + +1.0-0 < 1.1-0 + +# --- +0.1.0.0 < 0.2.0 +0.1.0 < 0.2.0.0 +0.1.0 < 0.1.1.0 +0.1.0 < 0.1.0.0 +0.1.0 > 0.0.1.0 + +# https://cran.r-project.org/src/contrib/Archive/abctools/ +0.1-2 < 0.2-2 +0.2 < 0.3-2 +1.0 < 1.0.1 +1.0.2 < 1.0.3 + +# https://cran.r-project.org/src/contrib/Archive/AntibodyTiters/ +0.1.4 < 0.1.18 + +# https://cran.r-project.org/src/contrib/Archive/AdaptGauss/ +1.0 < 1.1.0 +1.5 < 1.5.4 + +# https://cran.r-project.org/src/contrib/Archive/AcceptanceSampling/ +0.1-1 < 0.1-4 +1.0-0 < 1.0-1 + +# https://cran.r-project.org/src/contrib/Archive/DHARMa/ +0.1.0 < 0.1.0.0 +0.3.2.0 > 0.3.2 +0.3.2.0 < 0.3.3.0 diff --git a/internal/semantic/parse.go b/internal/semantic/parse.go index 78f2f83421b..e19ec609878 100644 --- a/internal/semantic/parse.go +++ b/internal/semantic/parse.go @@ -44,6 +44,8 @@ func Parse(str string, ecosystem Ecosystem) (Version, error) { return parseSemverVersion(str), nil case "ConanCenter": return parseSemverVersion(str), nil + case "CRAN": + return parseCRANVersion(str), nil } return nil, fmt.Errorf("%w %s", ErrUnsupportedEcosystem, ecosystem) diff --git a/internal/semantic/parse_test.go b/internal/semantic/parse_test.go index 6072aca7990..8fcea3f8ded 100644 --- a/internal/semantic/parse_test.go +++ b/internal/semantic/parse_test.go @@ -13,6 +13,9 @@ func TestParse(t *testing.T) { ecosystems := lockfile.KnownEcosystems() + // todo: remove once CRAN is supported by lockfile + ecosystems = append(ecosystems, "CRAN") + for _, ecosystem := range ecosystems { _, err := semantic.Parse("", ecosystem) @@ -33,6 +36,9 @@ func TestMustParse(t *testing.T) { ecosystems := lockfile.KnownEcosystems() + // todo: remove once CRAN is supported by lockfile + ecosystems = append(ecosystems, "CRAN") + for _, ecosystem := range ecosystems { semantic.MustParse("", ecosystem) } diff --git a/internal/semantic/version-cran.go b/internal/semantic/version-cran.go new file mode 100644 index 00000000000..19fa37c6e14 --- /dev/null +++ b/internal/semantic/version-cran.go @@ -0,0 +1,54 @@ +package semantic + +import ( + "math/big" + "strings" +) + +// CRANVersion is the representation of a version of a package that is held +// in the CRAN ecosystem (https://cran.r-project.org/). +// +// A version is a sequence of at least two non-negative integers separated by +// either a period or a dash. +// +// See https://astrostatistics.psu.edu/su07/R/html/base/html/package_version.html +type CRANVersion struct { + components Components +} + +func (v CRANVersion) Compare(w CRANVersion) int { + if diff := v.components.Cmp(w.components); diff != 0 { + return diff + } + + // versions are only equal if they also have the same number of components, + // otherwise the longer one is considered greater + if len(v.components) == len(w.components) { + return 0 + } + + if len(v.components) > len(w.components) { + return 1 + } + + return -1 +} + +func (v CRANVersion) CompareStr(str string) int { + return v.Compare(parseCRANVersion(str)) +} + +func parseCRANVersion(str string) CRANVersion { + // dashes and periods have the same weight, so we can just normalize to periods + parts := strings.Split(strings.ReplaceAll(str, "-", "."), ".") + + components := make(Components, 0, len(parts)) + + for _, s := range parts { + v, _ := new(big.Int).SetString(s, 10) + + components = append(components, v) + } + + return CRANVersion{components} +} diff --git a/scripts/generators/generate-cran-versions.R b/scripts/generators/generate-cran-versions.R new file mode 100755 index 00000000000..10013dd57b1 --- /dev/null +++ b/scripts/generators/generate-cran-versions.R @@ -0,0 +1,183 @@ +#!/usr/bin/env Rscript + +install.packages("jsonlite", repos = 'https://cran.r-project.org') + +library(utils) +library(jsonlite) + +# An array of version comparisons that are known to be unsupported and so +# should be commented out in the generated fixture. +# +# Generally this is because the native implementation has a suspected bug +# that causes the comparison to return incorrect results, and so supporting +# such comparisons in the detector would in fact be wrong. +UNSUPPORTED_COMPARISONS <- c() + +download_cran_db <- function() { + url <- "https://osv-vulnerabilities.storage.googleapis.com/CRAN/all.zip" + dest <- "cran-db.zip" + download.file(url, dest, method = "auto") +} + +extract_packages_with_versions <- function(osvs) { + result <- list() + + for (osv in osvs) { + for (affected in osv$affected) { + package <- affected$package$name + + if (!(package %in% names(result))) { + result[[package]] <- list() + } + + for (version in affected$versions) { + tryCatch( + { + as.package_version(version) + result[[package]] <- c(result[[package]], version) + }, + error = function(e) { + cat(sprintf("skipping invalid version %s for %s\n", version, package)) + } + ) + } + } + } + + # deduplicate and sort the versions for each package + for (package in names(result)) { + result[[package]] <- sort(numeric_version(unique(result[[package]]))) + } + + return(result) +} + +is_unsupported_comparison <- function(line) { + line %in% UNSUPPORTED_COMPARISONS +} + +uncomment <- function(line) { + if (startsWith(line, "#")) { + return(substr(line, 2, nchar(line))) + } + if (startsWith(line, "//")) { + return(substr(line, 3, nchar(line))) + } + return(line) +} + +compare <- function(v1, relate, v2) { + ops <- list('<' = function(result) result < 0, + '=' = function(result) result == 0, + '>' = function(result) result > 0) + + return(ops[[relate]](compareVersion(v1, v2))) +} + +compare_versions <- function(lines, select="all") { + has_any_failed <- FALSE + + for (line in lines) { + line <- trimws(line) + + if (line == "" || grepl("^#", line) || grepl("^//", line)) { + maybe_unsupported <- trimws(uncomment(line)) + + if (is_unsupported_comparison(maybe_unsupported)) { + cat(sprintf("\033[96mS\033[0m: \033[93m%s\033[0m\n", maybe_unsupported)) + } + next + } + + parts <- strsplit(trimws(line), " ")[[1]] + v1 <- parts[1] + op <- parts[2] + v2 <- parts[3] + + r <- compare(v1, op, v2) + + if (!r) { + has_any_failed <- TRUE + } + + if (select == "failures" && r) { + next + } + + if (select == "successes" && !r) { + next + } + + color <- ifelse(r, '\033[92m', '\033[91m') + rs <- ifelse(r, "T", "F") + cat(sprintf("%s%s\033[0m: \033[93m%s\033[0m\n", color, rs, line)) + } + return(has_any_failed) +} + +compare_versions_in_file <- function(filepath, select="all") { + lines <- readLines(filepath) + return(compare_versions(lines, select)) +} + +generate_version_compares <- function(versions) { + comparisons <- character() + + for (i in seq_along(versions)) { + if (i == 1) { + next + } + + comparison <- sprintf("%s < %s", versions[i - 1], versions[i]) + + if (is_unsupported_comparison(trimws(comparison))) { + comparison <- paste("#", comparison) + } + + comparisons <- c(comparisons, comparison) + } + + return(comparisons) +} + +generate_package_compares <- function(packages) { + comparisons <- character() + + for (package in names(packages)) { + versions <- packages[[package]] + comparisons <- c(comparisons, generate_version_compares(versions)) + } + + # return unique comparisons + return(unique(comparisons)) +} + +fetch_packages_versions <- function() { + download_cran_db() + osvs <- list() + + with_zip <- unzip("cran-db.zip", list = TRUE) + + for (fname in with_zip$Name) { + osv <- jsonlite::fromJSON(unzip("cran-db.zip", files = fname, exdir = tempdir()), simplifyDataFrame = FALSE) + osvs <- c(osvs, list(osv)) + } + + return(extract_packages_with_versions(osvs)) +} + +outfile <- "internal/semantic/fixtures/cran-versions-generated.txt" + +packs <- fetch_packages_versions() +writeLines(generate_package_compares(packs), outfile, sep = "\n") +cat("\n") + +# set this to either "failures" or "successes" to only have those comparison results +# printed; setting it to anything else will have all comparison results printed +show <- Sys.getenv("VERSION_GENERATOR_PRINT", "failures") + +did_any_fail <- compare_versions_in_file(outfile, show) + +if (did_any_fail) { + q(status = 1) +}