Skip to content

Commit

Permalink
feat: add support for comparing CRAN versions (google#656)
Browse files Browse the repository at this point in the history
Part of google#642

See G-Rath/osv-detector#235 for the journey I
went on with R for this
  • Loading branch information
G-Rath committed Nov 21, 2023
1 parent 0e0d6fd commit eb862fd
Show file tree
Hide file tree
Showing 7 changed files with 344 additions and 0 deletions.
8 changes: 8 additions & 0 deletions internal/semantic/compare_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
57 changes: 57 additions & 0 deletions internal/semantic/fixtures/cran-versions-generated.txt
Original file line number Diff line number Diff line change
@@ -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
34 changes: 34 additions & 0 deletions internal/semantic/fixtures/cran-versions.txt
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions internal/semantic/parse.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
6 changes: 6 additions & 0 deletions internal/semantic/parse_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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)
}
Expand Down
54 changes: 54 additions & 0 deletions internal/semantic/version-cran.go
Original file line number Diff line number Diff line change
@@ -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}
}
183 changes: 183 additions & 0 deletions scripts/generators/generate-cran-versions.R
Original file line number Diff line number Diff line change
@@ -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)
}

0 comments on commit eb862fd

Please sign in to comment.