diff --git a/README.md b/README.md index a09c339..4802d81 100644 --- a/README.md +++ b/README.md @@ -524,6 +524,7 @@ Git::Pkgs::Database.connect(repo_git_dir) Git::Pkgs::Models::DependencyChange.where(name: "rails").all ``` + ## Contributing Bug reports, feature requests, and pull requests are welcome. If you're unsure about a change, open an issue first to discuss it. diff --git a/docs/enrichment.md b/docs/enrichment.md new file mode 100644 index 0000000..e662d50 --- /dev/null +++ b/docs/enrichment.md @@ -0,0 +1,177 @@ +# Package Enrichment + +git-pkgs can fetch additional metadata about your dependencies from the [ecosyste.ms Packages API](https://packages.ecosyste.ms/). This powers the `outdated` and `licenses` commands. + +## outdated + +Show packages that have newer versions available in their registries. + +``` +$ git pkgs outdated +lodash 4.17.15 -> 4.17.21 (patch) +express 4.17.0 -> 4.19.2 (minor) +webpack 4.46.0 -> 5.90.3 (major) + +3 outdated packages: 1 major, 1 minor, 1 patch +``` + +Major updates are shown in red, minor in yellow, patch in cyan. + +### Options + +``` +-e, --ecosystem=NAME Filter by ecosystem +-r, --ref=REF Git ref to check (default: HEAD) +-f, --format=FORMAT Output format (text, json) + --major Show only major version updates + --minor Show only minor or major updates (skip patch) + --stateless Parse manifests directly without database +``` + +### Examples + +Show only major updates: + +``` +$ git pkgs outdated --major +webpack 4.46.0 -> 5.90.3 (major) +``` + +Check a specific release: + +``` +$ git pkgs outdated v1.0.0 +``` + +JSON output: + +``` +$ git pkgs outdated -f json +``` + +## licenses + +Show licenses for dependencies with optional compliance checks. + +``` +$ git pkgs licenses +lodash MIT (npm) +express MIT (npm) +request Apache-2.0 (npm) +``` + +### Options + +``` +-e, --ecosystem=NAME Filter by ecosystem +-r, --ref=REF Git ref to check (default: HEAD) +-f, --format=FORMAT Output format (text, json, csv) + --allow=LICENSES Comma-separated list of allowed licenses + --deny=LICENSES Comma-separated list of denied licenses + --permissive Only allow permissive licenses (MIT, Apache, BSD, etc.) + --copyleft Flag copyleft licenses (GPL, AGPL, etc.) + --unknown Flag packages with unknown/missing licenses + --group Group output by license + --stateless Parse manifests directly without database +``` + +### Compliance Checks + +Only allow permissive licenses: + +``` +$ git pkgs licenses --permissive +lodash MIT (npm) +express MIT (npm) +gpl-pkg GPL-3.0 (npm) [copyleft] + +1 license violation found +``` + +Explicit allow list: + +``` +$ git pkgs licenses --allow=MIT,Apache-2.0 +``` + +Deny specific licenses: + +``` +$ git pkgs licenses --deny=GPL-3.0,AGPL-3.0 +``` + +Flag packages with no license information: + +``` +$ git pkgs licenses --unknown +``` + +### Output Formats + +Group by license: + +``` +$ git pkgs licenses --group +MIT (45) + lodash + express + ... + +Apache-2.0 (12) + request + ... +``` + +CSV for spreadsheets: + +``` +$ git pkgs licenses -f csv > licenses.csv +``` + +JSON for scripting: + +``` +$ git pkgs licenses -f json +``` + +### Exit Codes + +The licenses command exits with code 1 if any violations are found. This makes it suitable for CI pipelines: + +```yaml +- run: git pkgs licenses --stateless --permissive +``` + +### License Categories + +Permissive licenses (allowed with `--permissive`): +MIT, Apache-2.0, BSD-2-Clause, BSD-3-Clause, ISC, Unlicense, CC0-1.0, 0BSD, WTFPL, Zlib, BSL-1.0 + +Copyleft licenses (flagged with `--copyleft` or `--permissive`): +GPL-2.0, GPL-3.0, LGPL-2.1, LGPL-3.0, AGPL-3.0, MPL-2.0 (and their variant identifiers) + +## Data Source + +Both commands fetch package metadata from [ecosyste.ms](https://packages.ecosyste.ms/), which aggregates data from npm, RubyGems, PyPI, Cargo, and other package registries. + +## Caching + +Package metadata is cached in the pkgs.sqlite3 database. Each package tracks when it was last enriched, and stale data (older than 24 hours) is automatically refreshed on the next query. + +The cache stores: +- Latest version number +- License (SPDX identifier) +- Description +- Homepage URL +- Repository URL + +## Stateless Mode + +Both commands support `--stateless` mode, which parses manifest files directly from git without requiring a database. This is useful in CI environments where you don't want to run `git pkgs init` first. + +``` +$ git pkgs outdated --stateless +$ git pkgs licenses --stateless --permissive +``` + +In stateless mode, package metadata is fetched fresh each time and cached only in memory for the duration of the command. diff --git a/docs/internals.md b/docs/internals.md index e31b157..07e45f3 100644 --- a/docs/internals.md +++ b/docs/internals.md @@ -10,7 +10,7 @@ The executable at [`exe/git-pkgs`](../exe/git-pkgs) loads [`lib/git/pkgs.rb`](.. [`Git::Pkgs::Database`](../lib/git/pkgs/database.rb) manages the SQLite connection using [Sequel](https://sequel.jeremyevans.net/) and [sqlite3](https://github.com/sparklemotion/sqlite3-ruby). It looks for the `GIT_PKGS_DB` environment variable first, then falls back to `.git/pkgs.sqlite3`. Schema migrations are versioned through a `schema_info` table. See [schema.md](schema.md) for the full schema. -The schema has nine tables. Six handle dependency tracking: +The schema has ten tables. Six handle dependency tracking: - `commits` holds commit metadata plus a flag indicating whether it changed dependencies - `branches` tracks which branches have been analyzed and their last processed SHA @@ -19,9 +19,10 @@ The schema has nine tables. Six handle dependency tracking: - `dependency_changes` records every add, modify, or remove event - `dependency_snapshots` stores full dependency state at intervals -Three more support vulnerability scanning: +Four more support vulnerability scanning and package enrichment: -- `packages` tracks which packages have been synced with OSV and when +- `packages` tracks package metadata, vulnerability sync status, and enrichment data +- `versions` stores per-version metadata (license, published date) for time-travel queries - `vulnerabilities` caches CVE/GHSA data fetched from OSV - `vulnerability_packages` maps which packages are affected by each vulnerability @@ -188,6 +189,42 @@ When scanning, git-pkgs: 6. Matches version ranges against actual versions 7. Excludes withdrawn vulnerabilities +## Package Enrichment + +The [`outdated`](../lib/git/pkgs/commands/outdated.rb) and [`licenses`](../lib/git/pkgs/commands/licenses.rb) commands fetch package metadata from the [ecosyste.ms Packages API](https://packages.ecosyste.ms/). + +### Ecosystems Client + +[`Git::Pkgs::EcosystemsClient`](../lib/git/pkgs/ecosystems_client.rb) wraps the ecosyste.ms REST API. It uses batch lookups (`POST /api/v1/packages/lookup`) to check up to 100 packages per request. The response includes latest version, license, description, and repository URL for each package. + +### Enrichment Caching + +Like vulnerability data, enrichment data is cached in the database. The `packages` table has an `enriched_at` timestamp. Packages are refreshed if their data is more than 24 hours old. The `Package#needs_enrichment?` method checks this threshold. + +When running `outdated` or `licenses`: + +1. Get dependencies at the target commit +2. Find or create package records for each purl +3. Check which packages need enrichment (never enriched or stale) +4. Batch query ecosyste.ms for those packages +5. Store the enrichment data via `Package#enrich_from_api` +6. Use the cached data for version comparison or license checking + +### Version Comparison + +The `outdated` command classifies updates as major, minor, or patch by comparing semver components. It handles the `v` prefix common in some ecosystems and pads partial versions (e.g., "1.2" becomes "1.2.0"). Updates are color-coded: red for major, yellow for minor, cyan for patch. + +### License Compliance + +The `licenses` command checks licenses against configured policies: + +- `--permissive` only allows common permissive licenses (MIT, Apache-2.0, BSD variants) +- `--copyleft` flags GPL, AGPL, and similar licenses +- `--allow` and `--deny` let you specify explicit lists +- `--unknown` flags packages with no license information + +The command exits with code 1 when violations are found, making it suitable for CI pipelines. + ## Models Sequel models live in [`lib/git/pkgs/models/`](../lib/git/pkgs/models/). They're straightforward except for a few convenience methods: diff --git a/docs/schema.md b/docs/schema.md index 218afbe..6a37419 100644 --- a/docs/schema.md +++ b/docs/schema.md @@ -125,6 +125,25 @@ Tracks packages for vulnerability sync status. Indexes: `purl` (unique) +### versions + +Stores per-version metadata for packages. + +| Column | Type | Description | +|--------|------|-------------| +| id | integer | Primary key | +| purl | string | Full versioned purl (e.g., "pkg:npm/lodash@4.17.21") | +| package_purl | string | Parent package purl (e.g., "pkg:npm/lodash") | +| license | string | License for this specific version | +| published_at | datetime | When this version was published | +| integrity | text | Integrity hash (e.g., SHA256) | +| source | string | Data source | +| enriched_at | datetime | When metadata was fetched | +| created_at | datetime | | +| updated_at | datetime | | + +Indexes: `purl` (unique), `package_purl` + ### vulnerabilities Caches vulnerability data from OSV. @@ -170,5 +189,7 @@ branches ──┬── branch_commits ──┬── commits │ └── last_analyzed_sha (references commits.sha) +packages ──── versions (via package_purl) + vulnerabilities ──── vulnerability_packages ``` diff --git a/lib/git/pkgs.rb b/lib/git/pkgs.rb index e1cd17b..dc323c4 100644 --- a/lib/git/pkgs.rb +++ b/lib/git/pkgs.rb @@ -10,7 +10,10 @@ require_relative "pkgs/analyzer" require_relative "pkgs/ecosystems" require_relative "pkgs/osv_client" +require_relative "pkgs/ecosystems_client" +require_relative "pkgs/spinner" +require_relative "pkgs/purl_helper" require_relative "pkgs/models/branch" require_relative "pkgs/models/branch_commit" require_relative "pkgs/models/commit" @@ -18,6 +21,7 @@ require_relative "pkgs/models/dependency_change" require_relative "pkgs/models/dependency_snapshot" require_relative "pkgs/models/package" +require_relative "pkgs/models/version" require_relative "pkgs/models/vulnerability" require_relative "pkgs/models/vulnerability_package" @@ -43,6 +47,8 @@ require_relative "pkgs/commands/diff_driver" require_relative "pkgs/commands/completions" require_relative "pkgs/commands/vulns" +require_relative "pkgs/commands/outdated" +require_relative "pkgs/commands/licenses" module Git module Pkgs diff --git a/lib/git/pkgs/analyzer.rb b/lib/git/pkgs/analyzer.rb index 8774d2c..412721c 100644 --- a/lib/git/pkgs/analyzer.rb +++ b/lib/git/pkgs/analyzer.rb @@ -33,7 +33,7 @@ class Analyzer REQUIRE Project.toml Manifest.toml shard.yml shard.lock elm-package.json elm_dependencies.json elm-stuff/exact-dependencies.json - haxelib.json + haxelib.json stack.yaml stack.yaml.lock action.yml action.yaml .github/workflows/*.yml .github/workflows/*.yaml Dockerfile docker-compose*.yml docker-compose*.yaml dvc.yaml vcpkg.json _generated-vcpkg-list.json diff --git a/lib/git/pkgs/cli.rb b/lib/git/pkgs/cli.rb index c551d48..8b6de3a 100644 --- a/lib/git/pkgs/cli.rb +++ b/lib/git/pkgs/cli.rb @@ -33,7 +33,9 @@ class CLI }, "Analysis" => { "stats" => "Show dependency statistics", - "stale" => "Show dependencies that haven't been updated" + "stale" => "Show dependencies that haven't been updated", + "outdated" => "Show packages with newer versions available", + "licenses" => "Show licenses for dependencies" }, "Security" => { "vulns" => "Scan for known vulnerabilities" @@ -42,7 +44,7 @@ class CLI COMMANDS = COMMAND_GROUPS.values.flat_map(&:keys).freeze COMMAND_DESCRIPTIONS = COMMAND_GROUPS.values.reduce({}, :merge).freeze - ALIASES = { "praise" => "blame", "outdated" => "stale" }.freeze + ALIASES = { "praise" => "blame" }.freeze def self.run(args) new(args).run diff --git a/lib/git/pkgs/commands/diff_driver.rb b/lib/git/pkgs/commands/diff_driver.rb index 6f3a4e7..1d43f87 100644 --- a/lib/git/pkgs/commands/diff_driver.rb +++ b/lib/git/pkgs/commands/diff_driver.rb @@ -24,18 +24,24 @@ class DiffDriver gems.locked glide.lock go.mod + go.sum + gradle.lockfile mix.lock npm-shrinkwrap.json package-lock.json packages.lock.json paket.lock + pdm.lock pnpm-lock.yaml poetry.lock project.assets.json pubspec.lock pylock.toml + renv.lock shard.lock + stack.yaml.lock uv.lock + verification-metadata.xml yarn.lock ].freeze diff --git a/lib/git/pkgs/commands/licenses.rb b/lib/git/pkgs/commands/licenses.rb new file mode 100644 index 0000000..17f438f --- /dev/null +++ b/lib/git/pkgs/commands/licenses.rb @@ -0,0 +1,378 @@ +# frozen_string_literal: true + +require "optparse" + +module Git + module Pkgs + module Commands + class Licenses + include Output + + PERMISSIVE = %w[ + MIT Apache-2.0 BSD-2-Clause BSD-3-Clause ISC Unlicense CC0-1.0 + 0BSD WTFPL Zlib BSL-1.0 + ].freeze + + COPYLEFT = %w[ + GPL-2.0 GPL-3.0 LGPL-2.1 LGPL-3.0 AGPL-3.0 MPL-2.0 + GPL-2.0-only GPL-2.0-or-later GPL-3.0-only GPL-3.0-or-later + LGPL-2.1-only LGPL-2.1-or-later LGPL-3.0-only LGPL-3.0-or-later + AGPL-3.0-only AGPL-3.0-or-later + ].freeze + + def self.description + "Show licenses for dependencies" + end + + def initialize(args) + @args = args.dup + @options = parse_options + end + + def parse_options + options = { allow: [], deny: [] } + + parser = OptionParser.new do |opts| + opts.banner = "Usage: git pkgs licenses [options]" + opts.separator "" + opts.separator "Show licenses for dependencies with optional compliance checks." + opts.separator "" + opts.separator "Options:" + + opts.on("-e", "--ecosystem=NAME", "Filter by ecosystem") do |v| + options[:ecosystem] = v + end + + opts.on("-r", "--ref=REF", "Git ref to check (default: HEAD)") do |v| + options[:ref] = v + end + + opts.on("-f", "--format=FORMAT", "Output format (text, json, csv)") do |v| + options[:format] = v + end + + opts.on("--allow=LICENSES", "Comma-separated list of allowed licenses") do |v| + options[:allow] = v.split(",").map(&:strip) + end + + opts.on("--deny=LICENSES", "Comma-separated list of denied licenses") do |v| + options[:deny] = v.split(",").map(&:strip) + end + + opts.on("--permissive", "Only allow permissive licenses (MIT, Apache, BSD, etc.)") do + options[:permissive] = true + end + + opts.on("--copyleft", "Flag copyleft licenses (GPL, AGPL, etc.)") do + options[:copyleft] = true + end + + opts.on("--unknown", "Flag packages with unknown/missing licenses") do + options[:unknown] = true + end + + opts.on("--group", "Group output by license") do + options[:group] = true + end + + opts.on("--stateless", "Parse manifests directly without database") do + options[:stateless] = true + end + + opts.on("-h", "--help", "Show this help") do + puts opts + exit + end + end + + parser.parse!(@args) + options + end + + def run + repo = Repository.new + use_stateless = @options[:stateless] || !Database.exists?(repo.git_dir) + + if use_stateless + Database.connect_memory + deps = get_dependencies_stateless(repo) + else + Database.connect(repo.git_dir) + deps = get_dependencies_with_database(repo) + end + + if deps.empty? + empty_result "No dependencies found" + return + end + + if @options[:ecosystem] + deps = deps.select { |d| d[:ecosystem].downcase == @options[:ecosystem].downcase } + end + + deps = Analyzer.pair_manifests_with_lockfiles(deps) + + if deps.empty? + empty_result "No dependencies found" + return + end + + packages = deps.map do |dep| + purl = PurlHelper.build_purl(ecosystem: dep[:ecosystem], name: dep[:name]).to_s + { + purl: purl, + name: dep[:name], + ecosystem: dep[:ecosystem], + version: dep[:requirement], + manifest_path: dep[:manifest_path] + } + end.uniq { |p| p[:purl] } + + enrich_packages(packages.map { |p| p[:purl] }) + + packages.each do |pkg| + db_pkg = Models::Package.first(purl: pkg[:purl]) + pkg[:license] = db_pkg&.license + pkg[:violation] = check_violation(pkg[:license]) + end + + violations = packages.select { |p| p[:violation] } + + case @options[:format] + when "json" + output_json(packages, violations) + when "csv" + output_csv(packages) + else + if @options[:group] + output_grouped(packages, violations) + else + output_text(packages, violations) + end + end + + exit 1 if violations.any? + end + + def check_violation(license) + return "unknown" if @options[:unknown] && (license.nil? || license.empty?) + + return nil if license.nil? || license.empty? + + if @options[:permissive] + return "copyleft" if COPYLEFT.any? { |l| license_matches?(license, l) } + return "not-permissive" unless PERMISSIVE.any? { |l| license_matches?(license, l) } + end + + if @options[:copyleft] + return "copyleft" if COPYLEFT.any? { |l| license_matches?(license, l) } + end + + if @options[:allow].any? + return "not-allowed" unless @options[:allow].any? { |l| license_matches?(license, l) } + end + + if @options[:deny].any? + return "denied" if @options[:deny].any? { |l| license_matches?(license, l) } + end + + nil + end + + def license_matches?(license, pattern) + license.downcase.include?(pattern.downcase) + end + + def enrich_packages(purls) + packages_by_purl = {} + purls.each do |purl| + parsed = Purl::PackageURL.parse(purl) + ecosystem = PurlHelper::ECOSYSTEM_TO_PURL_TYPE.invert[parsed.type] || parsed.type + pkg = Models::Package.find_or_create_by_purl( + purl: purl, + ecosystem: ecosystem, + name: parsed.name + ) + packages_by_purl[purl] = pkg + end + + stale_purls = packages_by_purl.select { |_, pkg| pkg.needs_enrichment? }.keys + return if stale_purls.empty? + + client = EcosystemsClient.new + begin + results = Spinner.with_spinner("Fetching package metadata...") do + client.bulk_lookup(stale_purls) + end + results.each do |purl, data| + packages_by_purl[purl]&.enrich_from_api(data) + end + rescue EcosystemsClient::ApiError => e + $stderr.puts "Warning: Could not fetch package data: #{e.message}" unless Git::Pkgs.quiet + end + end + + def output_text(packages, violations) + max_name = packages.map { |p| p[:name].length }.max || 20 + max_license = packages.map { |p| (p[:license] || "").length }.max || 10 + max_license = [max_license, 20].min + + packages.sort_by { |p| [p[:license] || "zzz", p[:name]] }.each do |pkg| + name = pkg[:name].ljust(max_name) + license = (pkg[:license] || "unknown").ljust(max_license)[0, max_license] + ecosystem = pkg[:ecosystem] + + line = "#{name} #{license} (#{ecosystem})" + + colored = if pkg[:violation] + Color.red("#{line} [#{pkg[:violation]}]") + else + line + end + + puts colored + end + + output_summary(packages, violations) + end + + def output_grouped(packages, violations) + by_license = packages.group_by { |p| p[:license] || "unknown" } + + by_license.sort_by { |license, _| license.downcase }.each do |license, pkgs| + has_violation = pkgs.any? { |p| p[:violation] } + header = "#{license} (#{pkgs.size})" + puts has_violation ? Color.red(header) : Color.bold(header) + + pkgs.sort_by { |p| p[:name] }.each do |pkg| + puts " #{pkg[:name]}" + end + puts "" + end + + output_summary(packages, violations) + end + + def output_summary(packages, violations) + return unless violations.any? + + puts "" + puts Color.red("#{violations.size} license violation#{"s" if violations.size != 1} found") + end + + def output_json(packages, violations) + require "json" + puts JSON.pretty_generate({ + packages: packages, + summary: { + total: packages.size, + violations: violations.size, + by_license: packages.group_by { |p| p[:license] || "unknown" }.transform_values(&:size) + } + }) + end + + def output_csv(packages) + puts "name,ecosystem,version,license,violation" + packages.sort_by { |p| p[:name] }.each do |pkg| + puts [ + pkg[:name], + pkg[:ecosystem], + pkg[:version], + pkg[:license] || "", + pkg[:violation] || "" + ].map { |v| csv_escape(v) }.join(",") + end + end + + def csv_escape(value) + if value.to_s.include?(",") || value.to_s.include?('"') + "\"#{value.to_s.gsub('"', '""')}\"" + else + value.to_s + end + end + + def get_dependencies_stateless(repo) + ref = @options[:ref] || "HEAD" + commit_sha = repo.rev_parse(ref) + rugged_commit = repo.lookup(commit_sha) + + error "Could not resolve '#{ref}'" unless rugged_commit + + analyzer = Analyzer.new(repo) + analyzer.dependencies_at_commit(rugged_commit) + end + + def get_dependencies_with_database(repo) + ref = @options[:ref] || "HEAD" + commit_sha = repo.rev_parse(ref) + target_commit = Models::Commit.first(sha: commit_sha) + + return get_dependencies_stateless(repo) unless target_commit + + branch_name = repo.default_branch + branch = Models::Branch.first(name: branch_name) + return [] unless branch + + compute_dependencies_at_commit(target_commit, branch) + end + + def compute_dependencies_at_commit(target_commit, branch) + snapshot_commit = branch.commits_dataset + .join(:dependency_snapshots, commit_id: :id) + .where { Sequel[:commits][:committed_at] <= target_commit.committed_at } + .order(Sequel.desc(Sequel[:commits][:committed_at])) + .distinct + .first + + deps = {} + if snapshot_commit + snapshot_commit.dependency_snapshots.each do |s| + key = [s.manifest.path, s.name] + deps[key] = { + manifest_path: s.manifest.path, + manifest_kind: s.manifest.kind, + name: s.name, + ecosystem: s.ecosystem, + requirement: s.requirement, + dependency_type: s.dependency_type + } + end + end + + if snapshot_commit && snapshot_commit.id != target_commit.id + commit_ids = branch.commits_dataset.select_map(Sequel[:commits][:id]) + changes = Models::DependencyChange + .join(:commits, id: :commit_id) + .where(Sequel[:commits][:id] => commit_ids) + .where { Sequel[:commits][:committed_at] > snapshot_commit.committed_at } + .where { Sequel[:commits][:committed_at] <= target_commit.committed_at } + .order(Sequel[:commits][:committed_at]) + .eager(:manifest) + .all + + changes.each do |change| + key = [change.manifest.path, change.name] + case change.change_type + when "added", "modified" + deps[key] = { + manifest_path: change.manifest.path, + manifest_kind: change.manifest.kind, + name: change.name, + ecosystem: change.ecosystem, + requirement: change.requirement, + dependency_type: change.dependency_type + } + when "removed" + deps.delete(key) + end + end + end + + deps.values + end + end + end + end +end diff --git a/lib/git/pkgs/commands/outdated.rb b/lib/git/pkgs/commands/outdated.rb new file mode 100644 index 0000000..acd50f5 --- /dev/null +++ b/lib/git/pkgs/commands/outdated.rb @@ -0,0 +1,312 @@ +# frozen_string_literal: true + +require "optparse" + +module Git + module Pkgs + module Commands + class Outdated + include Output + + def self.description + "Show packages with newer versions available" + end + + def initialize(args) + @args = args.dup + @options = parse_options + end + + def parse_options + options = {} + + parser = OptionParser.new do |opts| + opts.banner = "Usage: git pkgs outdated [options]" + opts.separator "" + opts.separator "Show packages that have newer versions available in their registries." + opts.separator "" + opts.separator "Options:" + + opts.on("-e", "--ecosystem=NAME", "Filter by ecosystem") do |v| + options[:ecosystem] = v + end + + opts.on("-r", "--ref=REF", "Git ref to check (default: HEAD)") do |v| + options[:ref] = v + end + + opts.on("-f", "--format=FORMAT", "Output format (text, json)") do |v| + options[:format] = v + end + + opts.on("--major", "Show only major version updates") do + options[:major_only] = true + end + + opts.on("--minor", "Show only minor or major updates (skip patch)") do + options[:minor_only] = true + end + + opts.on("--stateless", "Parse manifests directly without database") do + options[:stateless] = true + end + + opts.on("-h", "--help", "Show this help") do + puts opts + exit + end + end + + parser.parse!(@args) + options + end + + def run + repo = Repository.new + use_stateless = @options[:stateless] || !Database.exists?(repo.git_dir) + + if use_stateless + Database.connect_memory + deps = get_dependencies_stateless(repo) + else + Database.connect(repo.git_dir) + deps = get_dependencies_with_database(repo) + end + + if deps.empty? + empty_result "No dependencies found" + return + end + + if @options[:ecosystem] + deps = deps.select { |d| d[:ecosystem].downcase == @options[:ecosystem].downcase } + end + + deps_with_versions = Analyzer.lockfile_dependencies(deps).select do |dep| + dep[:requirement] && !dep[:requirement].match?(/[<>=~^]/) + end + + if deps_with_versions.empty? + empty_result "No dependencies with pinned versions found" + return + end + + packages_to_check = deps_with_versions.map do |dep| + purl = PurlHelper.build_purl(ecosystem: dep[:ecosystem], name: dep[:name]).to_s + { + purl: purl, + name: dep[:name], + ecosystem: dep[:ecosystem], + current_version: dep[:requirement], + manifest_path: dep[:manifest_path] + } + end.uniq { |p| p[:purl] } + + enrich_packages(packages_to_check.map { |p| p[:purl] }) + + outdated = [] + packages_to_check.each do |pkg| + db_pkg = Models::Package.first(purl: pkg[:purl]) + next unless db_pkg&.latest_version + + latest = db_pkg.latest_version + current = pkg[:current_version] + + next if current == latest + + update_type = classify_update(current, latest) + next if @options[:major_only] && update_type != :major + next if @options[:minor_only] && update_type == :patch + + outdated << pkg.merge( + latest_version: latest, + update_type: update_type + ) + end + + if outdated.empty? + puts "All packages are up to date" + return + end + + type_order = { major: 0, minor: 1, patch: 2, unknown: 3 } + outdated.sort_by! { |o| [type_order[o[:update_type]], o[:name]] } + + if @options[:format] == "json" + require "json" + puts JSON.pretty_generate(outdated) + else + output_text(outdated) + end + end + + def enrich_packages(purls) + packages_by_purl = {} + purls.each do |purl| + parsed = Purl::PackageURL.parse(purl) + ecosystem = PurlHelper::ECOSYSTEM_TO_PURL_TYPE.invert[parsed.type] || parsed.type + pkg = Models::Package.find_or_create_by_purl( + purl: purl, + ecosystem: ecosystem, + name: parsed.name + ) + packages_by_purl[purl] = pkg + end + + stale_purls = packages_by_purl.select { |_, pkg| pkg.needs_enrichment? }.keys + return if stale_purls.empty? + + client = EcosystemsClient.new + begin + results = Spinner.with_spinner("Fetching package metadata...") do + client.bulk_lookup(stale_purls) + end + results.each do |purl, data| + packages_by_purl[purl]&.enrich_from_api(data) + end + rescue EcosystemsClient::ApiError => e + $stderr.puts "Warning: Could not fetch package data: #{e.message}" unless Git::Pkgs.quiet + end + end + + def classify_update(current, latest) + current_parts = parse_version(current) + latest_parts = parse_version(latest) + + return :unknown if current_parts.nil? || latest_parts.nil? + + if latest_parts[0] > current_parts[0] + :major + elsif latest_parts[1] > current_parts[1] + :minor + elsif latest_parts[2] > current_parts[2] + :patch + else + :unknown + end + end + + def parse_version(version) + cleaned = version.to_s.sub(/^v/i, "") + parts = cleaned.split(".").first(3).map { |p| p.to_i } + return nil if parts.empty? + + parts + [0] * (3 - parts.length) + end + + def output_text(outdated) + max_name = outdated.map { |o| o[:name].length }.max || 20 + max_current = outdated.map { |o| o[:current_version].length }.max || 10 + max_latest = outdated.map { |o| o[:latest_version].length }.max || 10 + + outdated.each do |pkg| + name = pkg[:name].ljust(max_name) + current = pkg[:current_version].ljust(max_current) + latest = pkg[:latest_version].ljust(max_latest) + update = pkg[:update_type].to_s + + line = "#{name} #{current} -> #{latest} (#{update})" + + colored = case pkg[:update_type] + when :major then Color.red(line) + when :minor then Color.yellow(line) + when :patch then Color.cyan(line) + else line + end + + puts colored + end + + puts "" + summary = "#{outdated.size} outdated package#{"s" if outdated.size != 1}" + by_type = outdated.group_by { |o| o[:update_type] } + parts = [] + parts << "#{by_type[:major].size} major" if by_type[:major]&.any? + parts << "#{by_type[:minor].size} minor" if by_type[:minor]&.any? + parts << "#{by_type[:patch].size} patch" if by_type[:patch]&.any? + puts "#{summary}: #{parts.join(", ")}" if parts.any? + end + + def get_dependencies_stateless(repo) + ref = @options[:ref] || "HEAD" + commit_sha = repo.rev_parse(ref) + rugged_commit = repo.lookup(commit_sha) + + error "Could not resolve '#{ref}'" unless rugged_commit + + analyzer = Analyzer.new(repo) + analyzer.dependencies_at_commit(rugged_commit) + end + + def get_dependencies_with_database(repo) + ref = @options[:ref] || "HEAD" + commit_sha = repo.rev_parse(ref) + target_commit = Models::Commit.first(sha: commit_sha) + + return get_dependencies_stateless(repo) unless target_commit + + branch_name = repo.default_branch + branch = Models::Branch.first(name: branch_name) + return [] unless branch + + compute_dependencies_at_commit(target_commit, branch) + end + + def compute_dependencies_at_commit(target_commit, branch) + snapshot_commit = branch.commits_dataset + .join(:dependency_snapshots, commit_id: :id) + .where { Sequel[:commits][:committed_at] <= target_commit.committed_at } + .order(Sequel.desc(Sequel[:commits][:committed_at])) + .distinct + .first + + deps = {} + if snapshot_commit + snapshot_commit.dependency_snapshots.each do |s| + key = [s.manifest.path, s.name] + deps[key] = { + manifest_path: s.manifest.path, + manifest_kind: s.manifest.kind, + name: s.name, + ecosystem: s.ecosystem, + requirement: s.requirement, + dependency_type: s.dependency_type + } + end + end + + if snapshot_commit && snapshot_commit.id != target_commit.id + commit_ids = branch.commits_dataset.select_map(Sequel[:commits][:id]) + changes = Models::DependencyChange + .join(:commits, id: :commit_id) + .where(Sequel[:commits][:id] => commit_ids) + .where { Sequel[:commits][:committed_at] > snapshot_commit.committed_at } + .where { Sequel[:commits][:committed_at] <= target_commit.committed_at } + .order(Sequel[:commits][:committed_at]) + .eager(:manifest) + .all + + changes.each do |change| + key = [change.manifest.path, change.name] + case change.change_type + when "added", "modified" + deps[key] = { + manifest_path: change.manifest.path, + manifest_kind: change.manifest.kind, + name: change.name, + ecosystem: change.ecosystem, + requirement: change.requirement, + dependency_type: change.dependency_type + } + when "removed" + deps.delete(key) + end + end + end + + deps.values + end + end + end + end +end diff --git a/lib/git/pkgs/commands/vulns/base.rb b/lib/git/pkgs/commands/vulns/base.rb index 035834e..c20c826 100644 --- a/lib/git/pkgs/commands/vulns/base.rb +++ b/lib/git/pkgs/commands/vulns/base.rb @@ -143,7 +143,9 @@ def sync_packages(packages) client = OsvClient.new results = begin - client.query_batch(packages.map { |p| p.slice(:ecosystem, :name, :version) }) + Spinner.with_spinner("Checking vulnerabilities...") do + client.query_batch(packages.map { |p| p.slice(:ecosystem, :name, :version) }) + end rescue OsvClient::ApiError => e error "Failed to query OSV API: #{e.message}" end @@ -176,20 +178,22 @@ def ensure_vulns_synced return if packages_to_sync.empty? client = OsvClient.new - packages_to_sync.each_slice(100) do |batch| - queries = batch.map do |pkg| - osv_ecosystem = Ecosystems.to_osv(pkg.ecosystem) - next unless osv_ecosystem + Spinner.with_spinner("Syncing vulnerability data...") do + packages_to_sync.each_slice(100) do |batch| + queries = batch.map do |pkg| + osv_ecosystem = Ecosystems.to_osv(pkg.ecosystem) + next unless osv_ecosystem - { ecosystem: osv_ecosystem, name: pkg.name } - end.compact + { ecosystem: osv_ecosystem, name: pkg.name } + end.compact - results = client.query_batch(queries) - fetch_vulnerability_details(client, results) + results = client.query_batch(queries) + fetch_vulnerability_details(client, results) - batch.each do |pkg| - purl = Ecosystems.generate_purl(pkg.ecosystem, pkg.name) - mark_package_synced(purl, pkg.ecosystem, pkg.name) if purl + batch.each do |pkg| + purl = Ecosystems.generate_purl(pkg.ecosystem, pkg.name) + mark_package_synced(purl, pkg.ecosystem, pkg.name) if purl + end end end end diff --git a/lib/git/pkgs/commands/vulns/sync.rb b/lib/git/pkgs/commands/vulns/sync.rb index a2ce4d4..f2d5d2c 100644 --- a/lib/git/pkgs/commands/vulns/sync.rb +++ b/lib/git/pkgs/commands/vulns/sync.rb @@ -66,36 +66,38 @@ def run synced = 0 vuln_count = 0 - packages_to_sync.each_slice(100) do |batch| - queries = batch.map do |pkg| - osv_ecosystem = Ecosystems.to_osv(pkg.ecosystem) - next unless osv_ecosystem - - { ecosystem: osv_ecosystem, name: pkg.name } - end.compact - - results = client.query_batch(queries) - - # Collect all unique vuln IDs from this batch to fetch full details - vuln_ids = results.flatten.map { |v| v["id"] }.uniq - - # Fetch full vulnerability details and create records - vuln_ids.each do |vuln_id| - existing = Models::Vulnerability.first(id: vuln_id) - next if existing&.vulnerability_packages&.any? && !@options[:refresh] - - begin - full_vuln = client.get_vulnerability(vuln_id) - Models::Vulnerability.from_osv(full_vuln) - vuln_count += 1 - rescue OsvClient::ApiError - # Skip vulnerabilities we can't fetch + Spinner.with_spinner("Fetching from OSV...") do + packages_to_sync.each_slice(100) do |batch| + queries = batch.map do |pkg| + osv_ecosystem = Ecosystems.to_osv(pkg.ecosystem) + next unless osv_ecosystem + + { ecosystem: osv_ecosystem, name: pkg.name } + end.compact + + results = client.query_batch(queries) + + # Collect all unique vuln IDs from this batch to fetch full details + vuln_ids = results.flatten.map { |v| v["id"] }.uniq + + # Fetch full vulnerability details and create records + vuln_ids.each do |vuln_id| + existing = Models::Vulnerability.first(id: vuln_id) + next if existing&.vulnerability_packages&.any? && !@options[:refresh] + + begin + full_vuln = client.get_vulnerability(vuln_id) + Models::Vulnerability.from_osv(full_vuln) + vuln_count += 1 + rescue OsvClient::ApiError + # Skip vulnerabilities we can't fetch + end end - end - batch.each do |pkg| - pkg.mark_vulns_synced - synced += 1 + batch.each do |pkg| + pkg.mark_vulns_synced + synced += 1 + end end end diff --git a/lib/git/pkgs/database.rb b/lib/git/pkgs/database.rb index de9982c..4e1dee9 100644 --- a/lib/git/pkgs/database.rb +++ b/lib/git/pkgs/database.rb @@ -15,7 +15,7 @@ module Git module Pkgs class Database DB_FILE = "pkgs.sqlite3" - SCHEMA_VERSION = 2 + SCHEMA_VERSION = 3 class << self attr_accessor :db @@ -84,6 +84,7 @@ def self.refresh_models Git::Pkgs::Models::DependencyChange, Git::Pkgs::Models::DependencySnapshot, Git::Pkgs::Models::Package, + Git::Pkgs::Models::Version, Git::Pkgs::Models::Vulnerability, Git::Pkgs::Models::VulnerabilityPackage ].each do |model| @@ -201,6 +202,21 @@ def self.create_schema(with_indexes: true) index [:ecosystem, :name] end + @db.create_table?(:versions) do + primary_key :id + String :purl, null: false + String :package_purl, null: false + String :license + DateTime :published_at + String :integrity, text: true + String :source + DateTime :enriched_at + DateTime :created_at + DateTime :updated_at + index :purl, unique: true + index :package_purl + end + # Core vulnerability data (one row per CVE/GHSA) @db.create_table?(:vulnerabilities) do String :id, primary_key: true # CVE-2024-1234, GHSA-xxxx, etc. diff --git a/lib/git/pkgs/ecosystems_client.rb b/lib/git/pkgs/ecosystems_client.rb new file mode 100644 index 0000000..38415fb --- /dev/null +++ b/lib/git/pkgs/ecosystems_client.rb @@ -0,0 +1,96 @@ +# frozen_string_literal: true + +require "net/http" +require "json" +require "uri" + +module Git + module Pkgs + # Client for the ecosyste.ms Packages API. + # https://packages.ecosyste.ms/docs + class EcosystemsClient + API_BASE = "https://packages.ecosyste.ms/api/v1" + BATCH_SIZE = 100 # Max purls per batch request + + class Error < StandardError; end + class ApiError < Error; end + + def initialize + @http_clients = {} + end + + # Batch lookup packages by purl. + # + # @param purls [Array] array of package URLs (e.g., "pkg:gem/rails") + # @return [Hash] hash keyed by purl with package data + def bulk_lookup(purls) + return {} if purls.empty? + + results = {} + + purls.each_slice(BATCH_SIZE) do |batch| + response = post("/packages/bulk_lookup", { purls: batch }) + (response || []).each do |pkg| + results[pkg["purl"]] = pkg if pkg["purl"] + end + end + + results + end + + # Lookup a single package by purl. + # + # @param purl [String] package URL + # @return [Hash, nil] package data or nil if not found + def lookup(purl) + results = bulk_lookup([purl]) + results[purl] + end + + private + + def post(path, payload) + uri = URI("#{API_BASE}#{path}") + request = Net::HTTP::Post.new(uri) + request["Content-Type"] = "application/json" + request["Accept"] = "application/json" + request.body = JSON.generate(payload) + + execute_request(uri, request) + end + + def execute_request(uri, request) + http = http_client(uri) + response = http.request(request) + + case response + when Net::HTTPSuccess + JSON.parse(response.body) + when Net::HTTPNotFound + nil + else + raise ApiError, "ecosyste.ms API error: #{response.code} #{response.message}" + end + rescue JSON::ParserError => e + raise ApiError, "Invalid JSON response from ecosyste.ms API: #{e.message}" + rescue Net::OpenTimeout, Net::ReadTimeout => e + raise ApiError, "ecosyste.ms API timeout: #{e.message}" + rescue SocketError, Errno::ECONNREFUSED => e + raise ApiError, "ecosyste.ms API connection error: #{e.message}" + rescue OpenSSL::SSL::SSLError => e + raise ApiError, "ecosyste.ms API SSL error: #{e.message}" + end + + def http_client(uri) + key = "#{uri.host}:#{uri.port}" + @http_clients[key] ||= begin + http = Net::HTTP.new(uri.host, uri.port) + http.use_ssl = uri.scheme == "https" + http.open_timeout = 10 + http.read_timeout = 30 + http + end + end + end + end +end diff --git a/lib/git/pkgs/models/dependency_change.rb b/lib/git/pkgs/models/dependency_change.rb index 43a7a91..84d51ed 100644 --- a/lib/git/pkgs/models/dependency_change.rb +++ b/lib/git/pkgs/models/dependency_change.rb @@ -28,6 +28,14 @@ def for_platform(platform) where(ecosystem: platform) end end + + def purl(with_version: true) + version = nil + if with_version && manifest&.kind == "lockfile" + version = requirement + end + PurlHelper.build_purl(ecosystem: ecosystem, name: name, version: version) + end end end end diff --git a/lib/git/pkgs/models/dependency_snapshot.rb b/lib/git/pkgs/models/dependency_snapshot.rb index ef57759..2538a9d 100644 --- a/lib/git/pkgs/models/dependency_snapshot.rb +++ b/lib/git/pkgs/models/dependency_snapshot.rb @@ -29,6 +29,14 @@ def self.current_for_branch(branch) where(commit: commit) end + + def purl(with_version: true) + version = nil + if with_version && manifest&.kind == "lockfile" + version = requirement + end + PurlHelper.build_purl(ecosystem: ecosystem, name: name, version: version) + end end end end diff --git a/lib/git/pkgs/models/package.rb b/lib/git/pkgs/models/package.rb index 6f48d75..fd7c29d 100644 --- a/lib/git/pkgs/models/package.rb +++ b/lib/git/pkgs/models/package.rb @@ -6,6 +6,8 @@ module Models class Package < Sequel::Model STALE_THRESHOLD = 86400 # 24 hours + one_to_many :versions, key: :package_purl, primary_key: :purl + dataset_module do def by_ecosystem(ecosystem) where(ecosystem: ecosystem) @@ -18,6 +20,30 @@ def needs_vuln_sync def synced where { vulns_synced_at >= Time.now - STALE_THRESHOLD } end + + def needs_enrichment + where(enriched_at: nil).or { enriched_at < Time.now - STALE_THRESHOLD } + end + + def enriched + where { enriched_at >= Time.now - STALE_THRESHOLD } + end + end + + def parsed_purl + @parsed_purl ||= Purl.parse(purl) + end + + def registry_url + parsed_purl.registry_url + end + + def enriched? + !enriched_at.nil? + end + + def needs_enrichment? + enriched_at.nil? || enriched_at < Time.now - STALE_THRESHOLD end def needs_vuln_sync? @@ -28,6 +54,18 @@ def mark_vulns_synced update(vulns_synced_at: Time.now) end + # Update package with data from ecosyste.ms API + def enrich_from_api(data) + update( + latest_version: data["latest_release_number"], + license: (data["normalized_licenses"] || []).first, + description: data["description"], + homepage: data["homepage"], + repository_url: data["repository_url"], + enriched_at: Time.now + ) + end + def vulnerabilities osv_ecosystem = Ecosystems.to_osv(ecosystem) return [] unless osv_ecosystem diff --git a/lib/git/pkgs/models/version.rb b/lib/git/pkgs/models/version.rb new file mode 100644 index 0000000..6766839 --- /dev/null +++ b/lib/git/pkgs/models/version.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module Git + module Pkgs + module Models + class Version < Sequel::Model + many_to_one :package, key: :package_purl, primary_key: :purl + + def parsed_purl + @parsed_purl ||= Purl.parse(purl) + end + + def version_string + parsed_purl.version + end + + def registry_url + parsed_purl.registry_url + end + + def enriched? + !enriched_at.nil? + end + end + end + end +end diff --git a/lib/git/pkgs/purl_helper.rb b/lib/git/pkgs/purl_helper.rb new file mode 100644 index 0000000..08a3cbe --- /dev/null +++ b/lib/git/pkgs/purl_helper.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +require "purl" + +module Git + module Pkgs + module PurlHelper + # Mapping from Bibliothecary/ecosyste.ms ecosystem names to PURL types + # Source: https://packages.ecosyste.ms/api/v1/registries/ + ECOSYSTEM_TO_PURL_TYPE = { + "npm" => "npm", + "go" => "golang", + "docker" => "docker", + "pypi" => "pypi", + "nuget" => "nuget", + "maven" => "maven", + "packagist" => "composer", + "cargo" => "cargo", + "rubygems" => "gem", + "cocoapods" => "cocoapods", + "pub" => "pub", + "bower" => "bower", + "cpan" => "cpan", + "alpine" => "alpine", + "actions" => "githubactions", + "cran" => "cran", + "clojars" => "clojars", + "conda" => "conda", + "hex" => "hex", + "hackage" => "hackage", + "julia" => "julia", + "swiftpm" => "swift", + "openvsx" => "openvsx", + "spack" => "spack", + "homebrew" => "brew", + "puppet" => "puppet", + "deno" => "deno", + "elm" => "elm", + "vcpkg" => "vcpkg", + "racket" => "racket", + "bioconductor" => "bioconductor", + "carthage" => "carthage", + "elpa" => "melpa" + }.freeze + + def self.purl_type_for(ecosystem) + ECOSYSTEM_TO_PURL_TYPE.fetch(ecosystem, ecosystem) + end + + def self.build_purl(ecosystem:, name:, version: nil) + type = purl_type_for(ecosystem) + Purl::PackageURL.new(type: type, name: name, version: version) + end + end + end +end diff --git a/lib/git/pkgs/spinner.rb b/lib/git/pkgs/spinner.rb new file mode 100644 index 0000000..ea1a4f6 --- /dev/null +++ b/lib/git/pkgs/spinner.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +module Git + module Pkgs + class Spinner + FRAMES = %w[⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏].freeze + INTERVAL = 0.08 + + def initialize(message) + @message = message + @running = false + @thread = nil + end + + def start + return unless $stdout.tty? && !Git::Pkgs.quiet + + @running = true + @frame_index = 0 + @thread = Thread.new do + while @running + print "\r#{FRAMES[@frame_index]} #{@message}" + @frame_index = (@frame_index + 1) % FRAMES.length + sleep INTERVAL + end + end + end + + def stop + return unless @thread + + @running = false + @thread.join + print "\r#{" " * (@message.length + 3)}\r" + end + + def self.with_spinner(message) + spinner = new(message) + spinner.start + yield + ensure + spinner.stop + end + end + end +end diff --git a/test/git/pkgs/test_ecosystems_client.rb b/test/git/pkgs/test_ecosystems_client.rb new file mode 100644 index 0000000..705b7da --- /dev/null +++ b/test/git/pkgs/test_ecosystems_client.rb @@ -0,0 +1,119 @@ +# frozen_string_literal: true + +require "test_helper" +require "webmock/minitest" + +class Git::Pkgs::TestEcosystemsClient < Minitest::Test + def setup + @client = Git::Pkgs::EcosystemsClient.new + WebMock.disable_net_connect! + end + + def teardown + WebMock.allow_net_connect! + end + + def test_bulk_lookup_returns_packages_by_purl + stub_request(:post, "https://packages.ecosyste.ms/api/v1/packages/bulk_lookup") + .with(body: { purls: ["pkg:gem/rails", "pkg:npm/lodash"] }.to_json) + .to_return( + status: 200, + body: [ + { + "purl" => "pkg:gem/rails", + "name" => "rails", + "ecosystem" => "rubygems", + "latest_release_number" => "7.1.0", + "normalized_licenses" => ["MIT"] + }, + { + "purl" => "pkg:npm/lodash", + "name" => "lodash", + "ecosystem" => "npm", + "latest_release_number" => "4.17.21", + "normalized_licenses" => ["MIT"] + } + ].to_json, + headers: { "Content-Type" => "application/json" } + ) + + results = @client.bulk_lookup(["pkg:gem/rails", "pkg:npm/lodash"]) + + assert_equal 2, results.size + assert_equal "7.1.0", results["pkg:gem/rails"]["latest_release_number"] + assert_equal "4.17.21", results["pkg:npm/lodash"]["latest_release_number"] + end + + def test_bulk_lookup_empty_input + results = @client.bulk_lookup([]) + assert_equal({}, results) + end + + def test_bulk_lookup_batches_large_requests + # Create 150 purls to trigger batching (max 100 per request) + purls = (1..150).map { |i| "pkg:npm/package-#{i}" } + + # First batch of 100 + stub_request(:post, "https://packages.ecosyste.ms/api/v1/packages/bulk_lookup") + .with { |req| JSON.parse(req.body)["purls"].size == 100 } + .to_return( + status: 200, + body: (1..100).map { |i| { "purl" => "pkg:npm/package-#{i}", "name" => "package-#{i}" } }.to_json, + headers: { "Content-Type" => "application/json" } + ) + + # Second batch of 50 + stub_request(:post, "https://packages.ecosyste.ms/api/v1/packages/bulk_lookup") + .with { |req| JSON.parse(req.body)["purls"].size == 50 } + .to_return( + status: 200, + body: (101..150).map { |i| { "purl" => "pkg:npm/package-#{i}", "name" => "package-#{i}" } }.to_json, + headers: { "Content-Type" => "application/json" } + ) + + results = @client.bulk_lookup(purls) + + assert_equal 150, results.size + end + + def test_lookup_single_package + stub_request(:post, "https://packages.ecosyste.ms/api/v1/packages/bulk_lookup") + .with(body: { purls: ["pkg:gem/rails"] }.to_json) + .to_return( + status: 200, + body: [{ "purl" => "pkg:gem/rails", "latest_release_number" => "7.1.0" }].to_json, + headers: { "Content-Type" => "application/json" } + ) + + result = @client.lookup("pkg:gem/rails") + + assert_equal "7.1.0", result["latest_release_number"] + end + + def test_lookup_returns_nil_for_not_found + stub_request(:post, "https://packages.ecosyste.ms/api/v1/packages/bulk_lookup") + .to_return(status: 404) + + result = @client.lookup("pkg:gem/nonexistent") + + assert_nil result + end + + def test_api_error_on_failure + stub_request(:post, "https://packages.ecosyste.ms/api/v1/packages/bulk_lookup") + .to_return(status: 500, body: "Internal Server Error") + + assert_raises(Git::Pkgs::EcosystemsClient::ApiError) do + @client.bulk_lookup(["pkg:gem/rails"]) + end + end + + def test_api_error_on_timeout + stub_request(:post, "https://packages.ecosyste.ms/api/v1/packages/bulk_lookup") + .to_timeout + + assert_raises(Git::Pkgs::EcosystemsClient::ApiError) do + @client.bulk_lookup(["pkg:gem/rails"]) + end + end +end diff --git a/test/git/pkgs/test_licenses_command.rb b/test/git/pkgs/test_licenses_command.rb new file mode 100644 index 0000000..61a35b0 --- /dev/null +++ b/test/git/pkgs/test_licenses_command.rb @@ -0,0 +1,248 @@ +# frozen_string_literal: true + +require "test_helper" +require "webmock/minitest" + +class Git::Pkgs::TestLicensesCommand < Minitest::Test + include TestHelpers + + def setup + create_test_repo + add_file("README.md", "# Test") + commit("Initial commit") + + @git_dir = File.join(@test_dir, ".git") + WebMock.disable_net_connect! + end + + def teardown + cleanup_test_repo + WebMock.allow_net_connect! + end + + def test_check_violation_permissive_allows_mit + cmd = Git::Pkgs::Commands::Licenses.new(["--permissive"]) + + # Override options to test directly + cmd.instance_variable_set(:@options, { permissive: true, allow: [], deny: [] }) + + assert_nil cmd.check_violation("MIT") + assert_nil cmd.check_violation("Apache-2.0") + assert_nil cmd.check_violation("BSD-3-Clause") + end + + def test_check_violation_permissive_flags_gpl + cmd = Git::Pkgs::Commands::Licenses.new([]) + cmd.instance_variable_set(:@options, { permissive: true, allow: [], deny: [] }) + + assert_equal "copyleft", cmd.check_violation("GPL-3.0") + assert_equal "copyleft", cmd.check_violation("AGPL-3.0") + assert_equal "copyleft", cmd.check_violation("LGPL-2.1") + end + + def test_check_violation_deny_list + cmd = Git::Pkgs::Commands::Licenses.new([]) + cmd.instance_variable_set(:@options, { deny: ["GPL-3.0", "AGPL-3.0"], allow: [] }) + + assert_equal "denied", cmd.check_violation("GPL-3.0") + assert_equal "denied", cmd.check_violation("AGPL-3.0") + assert_nil cmd.check_violation("MIT") + end + + def test_check_violation_allow_list + cmd = Git::Pkgs::Commands::Licenses.new([]) + cmd.instance_variable_set(:@options, { allow: ["MIT", "Apache-2.0"], deny: [] }) + + assert_nil cmd.check_violation("MIT") + assert_nil cmd.check_violation("Apache-2.0") + assert_equal "not-allowed", cmd.check_violation("GPL-3.0") + assert_equal "not-allowed", cmd.check_violation("BSD-3-Clause") + end + + def test_check_violation_unknown_license + cmd = Git::Pkgs::Commands::Licenses.new([]) + cmd.instance_variable_set(:@options, { unknown: true, allow: [], deny: [] }) + + assert_equal "unknown", cmd.check_violation(nil) + assert_equal "unknown", cmd.check_violation("") + assert_nil cmd.check_violation("MIT") + end + + def test_check_violation_copyleft_flag + cmd = Git::Pkgs::Commands::Licenses.new([]) + cmd.instance_variable_set(:@options, { copyleft: true, allow: [], deny: [] }) + + assert_equal "copyleft", cmd.check_violation("GPL-3.0") + assert_equal "copyleft", cmd.check_violation("MPL-2.0") + assert_nil cmd.check_violation("MIT") + end + + def test_licenses_stateless_basic + add_file("package-lock.json", <<~JSON) + { + "name": "test-project", + "lockfileVersion": 2, + "packages": { + "node_modules/lodash": { + "version": "4.17.21" + }, + "node_modules/express": { + "version": "4.18.0" + } + } + } + JSON + commit("Add package-lock.json") + + stub_request(:post, "https://packages.ecosyste.ms/api/v1/packages/bulk_lookup") + .to_return( + status: 200, + body: [ + { "purl" => "pkg:npm/lodash", "normalized_licenses" => ["MIT"] }, + { "purl" => "pkg:npm/express", "normalized_licenses" => ["MIT"] } + ].to_json, + headers: { "Content-Type" => "application/json" } + ) + + output = Dir.chdir(@test_dir) do + capture_io do + Git::Pkgs::Commands::Licenses.new(["--stateless"]).run + end.first + end + + assert_match(/lodash/, output) + assert_match(/express/, output) + assert_match(/MIT/, output) + end + + def test_licenses_json_format + add_file("package-lock.json", <<~JSON) + { + "name": "test-project", + "lockfileVersion": 2, + "packages": { + "node_modules/lodash": { + "version": "4.17.21" + } + } + } + JSON + commit("Add package-lock.json") + + stub_request(:post, "https://packages.ecosyste.ms/api/v1/packages/bulk_lookup") + .to_return( + status: 200, + body: [{ "purl" => "pkg:npm/lodash", "normalized_licenses" => ["MIT"] }].to_json, + headers: { "Content-Type" => "application/json" } + ) + + output = Dir.chdir(@test_dir) do + capture_io do + Git::Pkgs::Commands::Licenses.new(["--stateless", "-f", "json"]).run + end.first + end + + json = JSON.parse(output) + assert json["packages"] + assert json["summary"] + assert_equal 1, json["summary"]["total"] + end + + def test_licenses_csv_format + add_file("package-lock.json", <<~JSON) + { + "name": "test-project", + "lockfileVersion": 2, + "packages": { + "node_modules/lodash": { + "version": "4.17.21" + } + } + } + JSON + commit("Add package-lock.json") + + stub_request(:post, "https://packages.ecosyste.ms/api/v1/packages/bulk_lookup") + .to_return( + status: 200, + body: [{ "purl" => "pkg:npm/lodash", "normalized_licenses" => ["MIT"] }].to_json, + headers: { "Content-Type" => "application/json" } + ) + + output = Dir.chdir(@test_dir) do + capture_io do + Git::Pkgs::Commands::Licenses.new(["--stateless", "-f", "csv"]).run + end.first + end + + lines = output.strip.split("\n") + assert_equal "name,ecosystem,version,license,violation", lines.first + assert_match(/lodash,npm,4\.17\.21,MIT/, lines[1]) + end + + def test_licenses_exits_nonzero_on_violation + add_file("package-lock.json", <<~JSON) + { + "name": "test-project", + "lockfileVersion": 2, + "packages": { + "node_modules/gpl-package": { + "version": "1.0.0" + } + } + } + JSON + commit("Add package-lock.json") + + stub_request(:post, "https://packages.ecosyste.ms/api/v1/packages/bulk_lookup") + .to_return( + status: 200, + body: [{ "purl" => "pkg:npm/gpl-package", "normalized_licenses" => ["GPL-3.0"] }].to_json, + headers: { "Content-Type" => "application/json" } + ) + + assert_raises(SystemExit) do + Dir.chdir(@test_dir) do + capture_io do + Git::Pkgs::Commands::Licenses.new(["--stateless", "--permissive"]).run + end + end + end + end + + def test_licenses_grouped_output + add_file("package-lock.json", <<~JSON) + { + "name": "test-project", + "lockfileVersion": 2, + "packages": { + "node_modules/lodash": { "version": "4.17.21" }, + "node_modules/express": { "version": "4.18.0" }, + "node_modules/request": { "version": "2.88.0" } + } + } + JSON + commit("Add package-lock.json") + + stub_request(:post, "https://packages.ecosyste.ms/api/v1/packages/bulk_lookup") + .to_return( + status: 200, + body: [ + { "purl" => "pkg:npm/lodash", "normalized_licenses" => ["MIT"] }, + { "purl" => "pkg:npm/express", "normalized_licenses" => ["MIT"] }, + { "purl" => "pkg:npm/request", "normalized_licenses" => ["Apache-2.0"] } + ].to_json, + headers: { "Content-Type" => "application/json" } + ) + + output = Dir.chdir(@test_dir) do + capture_io do + Git::Pkgs::Commands::Licenses.new(["--stateless", "--group"]).run + end.first + end + + # Should show MIT (2) and Apache-2.0 (1) as groups + assert_match(/MIT \(2\)/, output) + assert_match(/Apache-2\.0 \(1\)/, output) + end +end diff --git a/test/git/pkgs/test_models.rb b/test/git/pkgs/test_models.rb index b421be4..e0315bd 100644 --- a/test/git/pkgs/test_models.rb +++ b/test/git/pkgs/test_models.rb @@ -111,4 +111,152 @@ def test_branch_commit_associations assert_includes branch.commits, commit assert_includes commit.branches, branch end + + def test_dependency_change_purl_from_lockfile + repo = Git::Pkgs::Repository.new(@test_dir) + rugged_commit = repo.walk("main").first + commit = Git::Pkgs::Models::Commit.find_or_create_from_rugged(rugged_commit) + + manifest = Git::Pkgs::Models::Manifest.find_or_create( + path: "Gemfile.lock", + ecosystem: "rubygems", + kind: "lockfile" + ) + + change = Git::Pkgs::Models::DependencyChange.create( + commit: commit, + manifest: manifest, + name: "rails", + ecosystem: "rubygems", + change_type: "added", + requirement: "7.0.0" + ) + + assert_equal "pkg:gem/rails@7.0.0", change.purl.to_s + assert_equal "pkg:gem/rails", change.purl(with_version: false).to_s + end + + def test_dependency_change_purl_from_manifest_omits_version + repo = Git::Pkgs::Repository.new(@test_dir) + rugged_commit = repo.walk("main").first + commit = Git::Pkgs::Models::Commit.find_or_create_from_rugged(rugged_commit) + + manifest = Git::Pkgs::Models::Manifest.find_or_create( + path: "Gemfile", + ecosystem: "rubygems", + kind: "manifest" + ) + + change = Git::Pkgs::Models::DependencyChange.create( + commit: commit, + manifest: manifest, + name: "rails", + ecosystem: "rubygems", + change_type: "added", + requirement: "~> 7.0" + ) + + assert_equal "pkg:gem/rails", change.purl.to_s + end + + def test_dependency_snapshot_purl_from_lockfile + repo = Git::Pkgs::Repository.new(@test_dir) + rugged_commit = repo.walk("main").first + commit = Git::Pkgs::Models::Commit.find_or_create_from_rugged(rugged_commit) + + manifest = Git::Pkgs::Models::Manifest.find_or_create( + path: "package-lock.json", + ecosystem: "npm", + kind: "lockfile" + ) + + snapshot = Git::Pkgs::Models::DependencySnapshot.create( + commit: commit, + manifest: manifest, + name: "lodash", + ecosystem: "npm", + requirement: "4.17.21" + ) + + assert_equal "pkg:npm/lodash@4.17.21", snapshot.purl.to_s + assert_equal "pkg:npm/lodash", snapshot.purl(with_version: false).to_s + end + + def test_package_creation + package = Git::Pkgs::Models::Package.create( + purl: "pkg:gem/rails", + ecosystem: "rubygems", + name: "rails", + latest_version: "7.1.0", + license: "MIT", + description: "Full-stack web framework", + source: "ecosystems" + ) + + assert_equal "pkg:gem/rails", package.purl + assert_equal "7.1.0", package.latest_version + assert_equal "MIT", package.license + assert_equal "ecosystems", package.source + end + + def test_package_parsed_purl + package = Git::Pkgs::Models::Package.create(purl: "pkg:gem/rails", ecosystem: "rubygems", name: "rails") + + assert_equal "gem", package.parsed_purl.type + assert_equal "rails", package.parsed_purl.name + end + + def test_package_enriched + package = Git::Pkgs::Models::Package.create(purl: "pkg:gem/rails", ecosystem: "rubygems", name: "rails") + refute package.enriched? + + package.update(enriched_at: Time.now) + assert package.enriched? + end + + def test_version_creation + Git::Pkgs::Models::Package.create(purl: "pkg:gem/rails", ecosystem: "rubygems", name: "rails") + + version = Git::Pkgs::Models::Version.create( + purl: "pkg:gem/rails@7.0.0", + package_purl: "pkg:gem/rails", + license: "MIT", + published_at: Time.parse("2021-12-15"), + integrity: "sha256:abc123", + source: "ecosystems" + ) + + assert_equal "pkg:gem/rails@7.0.0", version.purl + assert_equal "pkg:gem/rails", version.package_purl + assert_equal "7.0.0", version.version_string + end + + def test_version_belongs_to_package + package = Git::Pkgs::Models::Package.create(purl: "pkg:gem/rails", ecosystem: "rubygems", name: "rails") + + version = Git::Pkgs::Models::Version.create( + purl: "pkg:gem/rails@7.0.0", + package_purl: "pkg:gem/rails" + ) + + assert_equal package.id, version.package.id + assert_includes package.versions.map(&:id), version.id + end + + def test_package_purl_uniqueness + Git::Pkgs::Models::Package.create(purl: "pkg:gem/rails", ecosystem: "rubygems", name: "rails") + + assert_raises(Sequel::UniqueConstraintViolation) do + Git::Pkgs::Models::Package.create(purl: "pkg:gem/rails", ecosystem: "rubygems", name: "rails") + end + end + + def test_version_purl_uniqueness + Git::Pkgs::Models::Package.create(purl: "pkg:gem/rails", ecosystem: "rubygems", name: "rails") + Git::Pkgs::Models::Version.create(purl: "pkg:gem/rails@7.0.0", package_purl: "pkg:gem/rails") + + assert_raises(Sequel::UniqueConstraintViolation) do + Git::Pkgs::Models::Version.create(purl: "pkg:gem/rails@7.0.0", package_purl: "pkg:gem/rails") + end + end end diff --git a/test/git/pkgs/test_outdated_command.rb b/test/git/pkgs/test_outdated_command.rb new file mode 100644 index 0000000..7660cef --- /dev/null +++ b/test/git/pkgs/test_outdated_command.rb @@ -0,0 +1,216 @@ +# frozen_string_literal: true + +require "test_helper" +require "webmock/minitest" + +class Git::Pkgs::TestOutdatedCommand < Minitest::Test + include TestHelpers + + def setup + create_test_repo + add_file("README.md", "# Test") + commit("Initial commit") + + @git_dir = File.join(@test_dir, ".git") + WebMock.disable_net_connect! + end + + def teardown + cleanup_test_repo + WebMock.allow_net_connect! + end + + def test_classify_update_major + cmd = Git::Pkgs::Commands::Outdated.new([]) + + assert_equal :major, cmd.classify_update("1.0.0", "2.0.0") + assert_equal :major, cmd.classify_update("1.2.3", "2.0.0") + assert_equal :major, cmd.classify_update("0.9.9", "1.0.0") + end + + def test_classify_update_minor + cmd = Git::Pkgs::Commands::Outdated.new([]) + + assert_equal :minor, cmd.classify_update("1.0.0", "1.1.0") + assert_equal :minor, cmd.classify_update("1.0.0", "1.5.0") + assert_equal :minor, cmd.classify_update("2.3.4", "2.5.0") + end + + def test_classify_update_patch + cmd = Git::Pkgs::Commands::Outdated.new([]) + + assert_equal :patch, cmd.classify_update("1.0.0", "1.0.1") + assert_equal :patch, cmd.classify_update("1.2.3", "1.2.5") + assert_equal :patch, cmd.classify_update("2.0.0", "2.0.99") + end + + def test_classify_update_with_v_prefix + cmd = Git::Pkgs::Commands::Outdated.new([]) + + assert_equal :major, cmd.classify_update("v1.0.0", "v2.0.0") + assert_equal :minor, cmd.classify_update("v1.0.0", "1.1.0") + end + + def test_classify_update_unknown_format + cmd = Git::Pkgs::Commands::Outdated.new([]) + + assert_equal :unknown, cmd.classify_update("abc", "def") + assert_equal :unknown, cmd.classify_update("", "1.0.0") + end + + def test_parse_version + cmd = Git::Pkgs::Commands::Outdated.new([]) + + assert_equal [1, 2, 3], cmd.parse_version("1.2.3") + assert_equal [1, 0, 0], cmd.parse_version("1") + assert_equal [1, 2, 0], cmd.parse_version("1.2") + assert_equal [1, 0, 0], cmd.parse_version("v1.0.0") + end + + def test_outdated_stateless_with_lockfile + add_file("package-lock.json", <<~JSON) + { + "name": "test-project", + "lockfileVersion": 2, + "packages": { + "node_modules/lodash": { + "version": "4.17.15" + }, + "node_modules/express": { + "version": "4.18.0" + } + } + } + JSON + commit("Add package-lock.json") + + # Stub the ecosystems API + stub_request(:post, "https://packages.ecosyste.ms/api/v1/packages/bulk_lookup") + .to_return( + status: 200, + body: [ + { + "purl" => "pkg:npm/lodash", + "latest_release_number" => "4.17.21" + }, + { + "purl" => "pkg:npm/express", + "latest_release_number" => "4.18.0" + } + ].to_json, + headers: { "Content-Type" => "application/json" } + ) + + output = Dir.chdir(@test_dir) do + capture_io do + Git::Pkgs::Commands::Outdated.new(["--stateless"]).run + end.first + end + + assert_match(/lodash/, output) + assert_match(/4\.17\.15/, output) + assert_match(/4\.17\.21/, output) + refute_match(/express.*->/, output) # express is up to date + end + + def test_outdated_major_only_filter + add_file("package-lock.json", <<~JSON) + { + "name": "test-project", + "lockfileVersion": 2, + "packages": { + "node_modules/lodash": { + "version": "3.0.0" + }, + "node_modules/express": { + "version": "4.17.0" + } + } + } + JSON + commit("Add package-lock.json") + + stub_request(:post, "https://packages.ecosyste.ms/api/v1/packages/bulk_lookup") + .to_return( + status: 200, + body: [ + { "purl" => "pkg:npm/lodash", "latest_release_number" => "4.17.21" }, + { "purl" => "pkg:npm/express", "latest_release_number" => "4.18.0" } + ].to_json, + headers: { "Content-Type" => "application/json" } + ) + + output = Dir.chdir(@test_dir) do + capture_io do + Git::Pkgs::Commands::Outdated.new(["--stateless", "--major"]).run + end.first + end + + assert_match(/lodash/, output) # major update (3 -> 4) + refute_match(/express/, output) # minor update, should be filtered + end + + def test_outdated_json_format + add_file("package-lock.json", <<~JSON) + { + "name": "test-project", + "lockfileVersion": 2, + "packages": { + "node_modules/lodash": { + "version": "4.17.15" + } + } + } + JSON + commit("Add package-lock.json") + + stub_request(:post, "https://packages.ecosyste.ms/api/v1/packages/bulk_lookup") + .to_return( + status: 200, + body: [{ "purl" => "pkg:npm/lodash", "latest_release_number" => "4.17.21" }].to_json, + headers: { "Content-Type" => "application/json" } + ) + + output = Dir.chdir(@test_dir) do + capture_io do + Git::Pkgs::Commands::Outdated.new(["--stateless", "-f", "json"]).run + end.first + end + + json = JSON.parse(output) + assert_equal 1, json.size + assert_equal "lodash", json.first["name"] + assert_equal "4.17.15", json.first["current_version"] + assert_equal "4.17.21", json.first["latest_version"] + end + + def test_outdated_all_up_to_date + add_file("package-lock.json", <<~JSON) + { + "name": "test-project", + "lockfileVersion": 2, + "packages": { + "node_modules/lodash": { + "version": "4.17.21" + } + } + } + JSON + commit("Add package-lock.json") + + stub_request(:post, "https://packages.ecosyste.ms/api/v1/packages/bulk_lookup") + .to_return( + status: 200, + body: [{ "purl" => "pkg:npm/lodash", "latest_release_number" => "4.17.21" }].to_json, + headers: { "Content-Type" => "application/json" } + ) + + output = Dir.chdir(@test_dir) do + capture_io do + Git::Pkgs::Commands::Outdated.new(["--stateless"]).run + end.first + end + + assert_match(/up to date/, output) + end +end diff --git a/test/git/pkgs/test_package.rb b/test/git/pkgs/test_package.rb index 274d7ca..cc655cb 100644 --- a/test/git/pkgs/test_package.rb +++ b/test/git/pkgs/test_package.rb @@ -184,4 +184,91 @@ def test_by_ecosystem_scope assert_equal 1, gem_pkgs.count assert_equal "rails", gem_pkgs.first.name end + + def test_needs_enrichment_when_never_enriched + pkg = Git::Pkgs::Models::Package.create( + purl: "pkg:npm/lodash", + ecosystem: "npm", + name: "lodash", + enriched_at: nil + ) + + assert pkg.needs_enrichment? + end + + def test_needs_enrichment_when_stale + pkg = Git::Pkgs::Models::Package.create( + purl: "pkg:npm/lodash", + ecosystem: "npm", + name: "lodash", + enriched_at: Time.now - 100_000 + ) + + assert pkg.needs_enrichment? + end + + def test_needs_enrichment_when_fresh + pkg = Git::Pkgs::Models::Package.create( + purl: "pkg:npm/lodash", + ecosystem: "npm", + name: "lodash", + enriched_at: Time.now + ) + + refute pkg.needs_enrichment? + end + + def test_enrich_from_api + pkg = Git::Pkgs::Models::Package.create( + purl: "pkg:npm/lodash", + ecosystem: "npm", + name: "lodash" + ) + + api_data = { + "latest_release_number" => "4.17.21", + "normalized_licenses" => ["MIT"], + "description" => "Lodash modular utilities", + "homepage" => "https://lodash.com/", + "repository_url" => "https://github.com/lodash/lodash" + } + + pkg.enrich_from_api(api_data) + pkg.refresh + + assert_equal "4.17.21", pkg.latest_version + assert_equal "MIT", pkg.license + assert_equal "Lodash modular utilities", pkg.description + assert_equal "https://lodash.com/", pkg.homepage + assert_equal "https://github.com/lodash/lodash", pkg.repository_url + refute_nil pkg.enriched_at + end + + def test_needs_enrichment_scope + Git::Pkgs::Models::Package.create( + purl: "pkg:npm/stale", + ecosystem: "npm", + name: "stale", + enriched_at: Time.now - 100_000 + ) + + Git::Pkgs::Models::Package.create( + purl: "pkg:npm/fresh", + ecosystem: "npm", + name: "fresh", + enriched_at: Time.now + ) + + Git::Pkgs::Models::Package.create( + purl: "pkg:npm/never", + ecosystem: "npm", + name: "never", + enriched_at: nil + ) + + needs_enrichment = Git::Pkgs::Models::Package.needs_enrichment + assert_equal 2, needs_enrichment.count + purls = needs_enrichment.map(&:purl).sort + assert_equal ["pkg:npm/never", "pkg:npm/stale"], purls + end end diff --git a/test/git/pkgs/test_purl_helper.rb b/test/git/pkgs/test_purl_helper.rb new file mode 100644 index 0000000..a31044a --- /dev/null +++ b/test/git/pkgs/test_purl_helper.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +require "test_helper" + +class Git::Pkgs::TestPurlHelper < Minitest::Test + def test_purl_type_for_rubygems + assert_equal "gem", Git::Pkgs::PurlHelper.purl_type_for("rubygems") + end + + def test_purl_type_for_npm + assert_equal "npm", Git::Pkgs::PurlHelper.purl_type_for("npm") + end + + def test_purl_type_for_go + assert_equal "golang", Git::Pkgs::PurlHelper.purl_type_for("go") + end + + def test_purl_type_for_packagist + assert_equal "composer", Git::Pkgs::PurlHelper.purl_type_for("packagist") + end + + def test_purl_type_for_unknown_falls_back_to_ecosystem + assert_equal "unknown", Git::Pkgs::PurlHelper.purl_type_for("unknown") + end + + def test_build_purl_without_version + purl = Git::Pkgs::PurlHelper.build_purl(ecosystem: "rubygems", name: "rails") + assert_equal "pkg:gem/rails", purl.to_s + end + + def test_build_purl_with_version + purl = Git::Pkgs::PurlHelper.build_purl(ecosystem: "rubygems", name: "rails", version: "7.0.0") + assert_equal "pkg:gem/rails@7.0.0", purl.to_s + end + + def test_build_purl_for_npm + purl = Git::Pkgs::PurlHelper.build_purl(ecosystem: "npm", name: "lodash", version: "4.17.21") + assert_equal "pkg:npm/lodash@4.17.21", purl.to_s + end + + def test_build_purl_for_go + purl = Git::Pkgs::PurlHelper.build_purl(ecosystem: "go", name: "github.com/gorilla/mux", version: "1.8.0") + assert_equal "golang", purl.type + assert_equal "github.com/gorilla/mux", purl.name + assert_equal "1.8.0", purl.version + end +end