From 1e3a0b2ca63b25898ee9a0e909c1da00b37c37c4 Mon Sep 17 00:00:00 2001 From: Radamanthus Batnag Date: Wed, 20 Aug 2025 20:32:21 +0800 Subject: [PATCH] MR changes --- app/models/packages/cargo.rb | 17 ++ app/models/packages/cargo/metadatum.rb | 41 +++ app/models/packages/cargo/package.rb | 69 +++++ app/models/packages/package.rb | 6 +- app/models/project.rb | 14 + .../cargo_package_index_content.json | 63 +++++ db/docs/packages_cargo_metadata.yml | 12 + db/docs/packages_packages.yml | 1 + ...7_add_cargo_max_file_size_to_plan_limit.rb | 14 + ...0053_add_cargo_metadata_table_and_index.rb | 34 +++ db/schema_migrations/20250715181137 | 1 + db/schema_migrations/20250729100053 | 1 + db/structure.sql | 28 +- doc/api/graphql/reference/_index.md | 1 + lib/gitlab/regex/packages.rb | 8 + locale/gitlab.pot | 3 + spec/factories/packages/cargo/metadata.rb | 26 ++ spec/factories/packages/cargo/packages.rb | 13 + .../types/packages/package_type_enum_spec.rb | 2 +- spec/models/packages/cargo/metadatum_spec.rb | 84 ++++++ spec/models/packages/cargo/package_spec.rb | 258 ++++++++++++++++++ spec/models/packages/cargo_spec.rb | 49 ++++ .../services/packages_shared_examples.rb | 1 + 23 files changed, 742 insertions(+), 4 deletions(-) create mode 100644 app/models/packages/cargo.rb create mode 100644 app/models/packages/cargo/metadatum.rb create mode 100644 app/models/packages/cargo/package.rb create mode 100644 app/validators/json_schemas/cargo_package_index_content.json create mode 100644 db/docs/packages_cargo_metadata.yml create mode 100644 db/migrate/20250715181137_add_cargo_max_file_size_to_plan_limit.rb create mode 100644 db/migrate/20250729100053_add_cargo_metadata_table_and_index.rb create mode 100644 db/schema_migrations/20250715181137 create mode 100644 db/schema_migrations/20250729100053 create mode 100644 spec/factories/packages/cargo/metadata.rb create mode 100644 spec/factories/packages/cargo/packages.rb create mode 100644 spec/models/packages/cargo/metadatum_spec.rb create mode 100644 spec/models/packages/cargo/package_spec.rb create mode 100644 spec/models/packages/cargo_spec.rb diff --git a/app/models/packages/cargo.rb b/app/models/packages/cargo.rb new file mode 100644 index 00000000000000..a3480edf297f34 --- /dev/null +++ b/app/models/packages/cargo.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Packages + module Cargo + def self.table_name_prefix + 'packages_cargo_' + end + + def self.normalize_name(package_name) + package_name.downcase.tr('_', '-') + end + + def self.normalize_version(package_version) + package_version.sub(/\+.*\z/, '') + end + end +end diff --git a/app/models/packages/cargo/metadatum.rb b/app/models/packages/cargo/metadatum.rb new file mode 100644 index 00000000000000..d335785d2c1314 --- /dev/null +++ b/app/models/packages/cargo/metadatum.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +module Packages + module Cargo + class Metadatum < ApplicationRecord + self.primary_key = :package_id + + belongs_to :package, class_name: 'Packages::Cargo::Package', inverse_of: :cargo_metadatum + belongs_to :project + + validates :package, presence: true + validates :index_content, + json_schema: { filename: 'cargo_package_index_content', detail_errors: true, size_limit: 64.kilobytes } + + validates :normalized_name, + presence: true, + length: { maximum: 64 }, + format: { with: Gitlab::Regex.cargo_package_normalized_name_regex, + message: 'must contain only lowercase letters, numbers, and hyphens' } + + validates :normalized_version, + presence: true, + length: { maximum: 255 }, + format: { with: Gitlab::Regex.semver_regex, message: 'must be a valid semantic version' } + + validates :project_id, uniqueness: { + scope: [:normalized_name, :normalized_version], + message: 'already has a package with this normalized name and version' + } + + before_validation :set_normalized_values, unless: -> { normalized_name? && normalized_version? } + + private + + def set_normalized_values + self.normalized_name = Packages::Cargo.normalize_name(package.name) if package&.name + self.normalized_version = Packages::Cargo.normalize_version(package.version) if package&.version + end + end + end +end diff --git a/app/models/packages/cargo/package.rb b/app/models/packages/cargo/package.rb new file mode 100644 index 00000000000000..7e4b46261ba145 --- /dev/null +++ b/app/models/packages/cargo/package.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +module Packages + module Cargo + class Package < Packages::Package + RESERVED_NAMES = %w[ + nul con prn aux com1 com2 com3 com4 com5 com6 com7 com8 com9 + lpt1 lpt2 lpt3 lpt4 lpt5 lpt6 lpt7 lpt8 lpt9 + ].freeze + + self.allow_legacy_sti_class = true + + has_one :cargo_metadatum, inverse_of: :package, class_name: 'Packages::Cargo::Metadatum' + + validates :version, format: { + with: Gitlab::Regex.semver_regex, + message: Gitlab::Regex.semver_regex_message + } + + validates :name, format: { + with: Gitlab::Regex.cargo_package_name_regex, + message: 'must be a valid cargo package name' + } + + validate :cargo_reserved_name + validate :cargo_package_version_already_taken + + scope :with_normalized_cargo_name, ->(name) do + normalized_name = Packages::Cargo.normalize_name(name) + joins(:cargo_metadatum).where(packages_cargo_metadata: { normalized_name: normalized_name }) + end + + scope :with_normalized_cargo_version, ->(version) do + normalized_version = Packages::Cargo.normalize_version(version) if version + joins(:cargo_metadatum).where(packages_cargo_metadata: { normalized_version: normalized_version }) + end + + def self.cargo_package_already_taken?(project_id, package_name, package_version) + normalized_name = Packages::Cargo.normalize_name(package_name) + normalized_version = Packages::Cargo.normalize_version(package_version) if package_version + + Packages::Cargo::Package + .joins(:cargo_metadatum) + .where( + project_id: project_id, + packages_cargo_metadata: { + normalized_name: normalized_name, + normalized_version: normalized_version + } + ) + .not_pending_destruction + .exists? + end + + def cargo_reserved_name + return unless name.present? + + errors.add(:name, _('is reserved and cannot be used')) if RESERVED_NAMES.include?(name.downcase) + end + + def cargo_package_version_already_taken + return unless project && name && version + return unless self.class.cargo_package_already_taken?(project_id, name, version) + + errors.add(:base, _('Package already exists')) + end + end + end +end diff --git a/app/models/packages/package.rb b/app/models/packages/package.rb index 275f36b202aec7..bb3d2362fcad13 100644 --- a/app/models/packages/package.rb +++ b/app/models/packages/package.rb @@ -29,7 +29,8 @@ class Package < ApplicationRecord helm: 11, terraform_module: 12, rpm: 13, - ml_model: 14 + ml_model: 14, + cargo: 15 } enum :status, { default: 0, hidden: 1, processing: 2, error: 3, pending_destruction: 4, deprecated: 5 } @@ -142,7 +143,8 @@ def self.inheritance_column_to_class_map terraform_module: 'Packages::TerraformModule::Package', nuget: 'Packages::Nuget::Package', npm: 'Packages::Npm::Package', - maven: 'Packages::Maven::Package' + maven: 'Packages::Maven::Package', + cargo: 'Packages::Cargo::Package' }.freeze end diff --git a/app/models/project.rb b/app/models/project.rb index 762e65f3d7fc67..e695041e12f304 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -3296,6 +3296,20 @@ def related_group_ids ids end + # TODO: remove this with the rollout of + # https://gitlab.com/gitlab-org/gitlab/-/issues/558119 + def package_already_taken?(package_name, package_version, package_type:) + Packages::Package.with_name(package_name) + .with_version(package_version) + .with_package_type(package_type) + .not_pending_destruction + .for_projects( + root_ancestor.all_projects + .id_not_in(id) + .select(:id) + ).exists? + end + def default_branch_or_main return default_branch if default_branch diff --git a/app/validators/json_schemas/cargo_package_index_content.json b/app/validators/json_schemas/cargo_package_index_content.json new file mode 100644 index 00000000000000..34307cfd0bcfb3 --- /dev/null +++ b/app/validators/json_schemas/cargo_package_index_content.json @@ -0,0 +1,63 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Cargo Package Index Content Schema", + "type": "object", + "required": [ + "name", + "vers", + "deps", + "cksum" + ], + "properties": { + "name": { + "type": "string" + }, + "vers": { + "type": "string" + }, + "deps": { + "type": "array" + }, + "cksum": { + "type": "string" + }, + "features": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "features2": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "yanked": { + "type": "boolean" + }, + "links": { + "type": [ + "string", + "null" + ] + }, + "v": { + "type": "integer", + "enum": [ + 1, + 2 + ] + }, + "rust_version": { + "type": "string" + } + }, + "additionalProperties": false +} diff --git a/db/docs/packages_cargo_metadata.yml b/db/docs/packages_cargo_metadata.yml new file mode 100644 index 00000000000000..4d3a604c055c11 --- /dev/null +++ b/db/docs/packages_cargo_metadata.yml @@ -0,0 +1,12 @@ +--- +table_name: packages_cargo_metadata +classes: +- Packages::Cargo::Metadatum +feature_categories: +- package_registry +description: Cargo package metadata +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/197846 +milestone: '18.3' +gitlab_schema: gitlab_main_cell +sharding_key: + project_id: projects diff --git a/db/docs/packages_packages.yml b/db/docs/packages_packages.yml index 3548d025fc3229..d3a240cbf0e691 100644 --- a/db/docs/packages_packages.yml +++ b/db/docs/packages_packages.yml @@ -1,6 +1,7 @@ --- table_name: packages_packages classes: +- Packages::Cargo::Package - Packages::Composer::Package - Packages::Conan::Package - Packages::Debian::Package diff --git a/db/migrate/20250715181137_add_cargo_max_file_size_to_plan_limit.rb b/db/migrate/20250715181137_add_cargo_max_file_size_to_plan_limit.rb new file mode 100644 index 00000000000000..d3a329b7fc3061 --- /dev/null +++ b/db/migrate/20250715181137_add_cargo_max_file_size_to_plan_limit.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class AddCargoMaxFileSizeToPlanLimit < Gitlab::Database::Migration[2.3] + milestone '18.3' + disable_ddl_transaction! + + def up + add_column :plan_limits, :cargo_max_file_size, :bigint, default: 5.gigabytes, null: false + end + + def down + remove_column :plan_limits, :cargo_max_file_size, if_exists: true + end +end diff --git a/db/migrate/20250729100053_add_cargo_metadata_table_and_index.rb b/db/migrate/20250729100053_add_cargo_metadata_table_and_index.rb new file mode 100644 index 00000000000000..6ee6c027cd5f25 --- /dev/null +++ b/db/migrate/20250729100053_add_cargo_metadata_table_and_index.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +class AddCargoMetadataTableAndIndex < Gitlab::Database::Migration[2.3] + milestone '18.3' + disable_ddl_transaction! + + INDEX_NAME = 'index_cargo_metadata_on_project_normalized_name_version' + + def up + create_table :packages_cargo_metadata, id: false do |t| + t.references :package, primary_key: true, index: true, default: nil, + foreign_key: { to_table: :packages_packages, on_delete: :cascade }, type: :bigint + t.jsonb :index_content + t.references :project, foreign_key: { on_delete: :cascade }, index: false, null: false + t.text :normalized_name, limit: 64 + t.text :normalized_version, limit: 255 + + t.timestamps_with_timezone null: false + end + + add_concurrent_index :packages_cargo_metadata, + [:project_id, :normalized_name, :normalized_version], + unique: true, + name: INDEX_NAME + end + + def down + remove_concurrent_index :packages_cargo_metadata, + [:project_id, :normalized_name, :normalized_version], + name: INDEX_NAME + + drop_table :packages_cargo_metadata + end +end diff --git a/db/schema_migrations/20250715181137 b/db/schema_migrations/20250715181137 new file mode 100644 index 00000000000000..98bbf2e75e1a90 --- /dev/null +++ b/db/schema_migrations/20250715181137 @@ -0,0 +1 @@ +3a8e0ffa7ef27aeb6d8355879269b3bdda24d99cdf280482113921e535fef78f \ No newline at end of file diff --git a/db/schema_migrations/20250729100053 b/db/schema_migrations/20250729100053 new file mode 100644 index 00000000000000..d5fb52dbc61270 --- /dev/null +++ b/db/schema_migrations/20250729100053 @@ -0,0 +1 @@ +cdfcb59744395326a5554fa0e9e9b1f9b78e522f4a55ff6f542769e4d4e7e4d6 \ No newline at end of file diff --git a/db/structure.sql b/db/structure.sql index 739ba0424b4f4a..6d123e2bd6928c 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -20452,6 +20452,18 @@ CREATE SEQUENCE packages_build_infos_id_seq ALTER SEQUENCE packages_build_infos_id_seq OWNED BY packages_build_infos.id; +CREATE TABLE packages_cargo_metadata ( + package_id bigint NOT NULL, + index_content jsonb, + project_id bigint NOT NULL, + normalized_name text, + normalized_version text, + created_at timestamp with time zone NOT NULL, + updated_at timestamp with time zone NOT NULL, + CONSTRAINT check_245ce00e05 CHECK ((char_length(normalized_name) <= 64)), + CONSTRAINT check_de6f67d97b CHECK ((char_length(normalized_version) <= 255)) +); + CREATE TABLE packages_cleanup_policies ( created_at timestamp with time zone NOT NULL, updated_at timestamp with time zone NOT NULL, @@ -21658,7 +21670,8 @@ CREATE TABLE plan_limits ( import_placeholder_user_limit_tier_2 integer DEFAULT 0 NOT NULL, import_placeholder_user_limit_tier_3 integer DEFAULT 0 NOT NULL, import_placeholder_user_limit_tier_4 integer DEFAULT 0 NOT NULL, - ci_max_artifact_size_slsa_provenance_statement bigint DEFAULT 0 NOT NULL + ci_max_artifact_size_slsa_provenance_statement bigint DEFAULT 0 NOT NULL, + cargo_max_file_size bigint DEFAULT '5368709120'::bigint NOT NULL ); CREATE SEQUENCE plan_limits_id_seq @@ -32901,6 +32914,9 @@ ALTER TABLE ONLY p_sent_notifications ALTER TABLE ONLY packages_build_infos ADD CONSTRAINT packages_build_infos_pkey PRIMARY KEY (id); +ALTER TABLE ONLY packages_cargo_metadata + ADD CONSTRAINT packages_cargo_metadata_pkey PRIMARY KEY (package_id); + ALTER TABLE ONLY packages_cleanup_policies ADD CONSTRAINT packages_cleanup_policies_pkey PRIMARY KEY (project_id); @@ -37307,6 +37323,8 @@ CREATE INDEX index_bulk_imports_on_user_id ON bulk_imports USING btree (user_id) CREATE INDEX index_ca_enabled_incomplete_aggregation_stages_on_last_run_at ON analytics_cycle_analytics_stage_aggregations USING btree (last_run_at NULLS FIRST) WHERE ((last_completed_at IS NULL) AND (enabled = true)); +CREATE UNIQUE INDEX index_cargo_metadata_on_project_normalized_name_version ON packages_cargo_metadata USING btree (project_id, normalized_name, normalized_version); + CREATE INDEX index_catalog_resource_components_on_catalog_resource_id ON catalog_resource_components USING btree (catalog_resource_id); CREATE INDEX index_catalog_resource_components_on_project_id ON catalog_resource_components USING btree (project_id); @@ -39579,6 +39597,8 @@ CREATE INDEX index_packages_build_infos_package_id_id ON packages_build_infos US CREATE INDEX index_packages_build_infos_package_id_pipeline_id_id ON packages_build_infos USING btree (package_id, pipeline_id, id); +CREATE INDEX index_packages_cargo_metadata_on_package_id ON packages_cargo_metadata USING btree (package_id); + CREATE UNIQUE INDEX index_packages_composer_metadata_on_package_id_and_target_sha ON packages_composer_metadata USING btree (package_id, target_sha); CREATE INDEX index_packages_composer_metadata_on_project_id ON packages_composer_metadata USING btree (project_id); @@ -48306,6 +48326,9 @@ ALTER TABLE ONLY epic_user_mentions ALTER TABLE ONLY approver_groups ADD CONSTRAINT fk_rails_1cdcbd7723 FOREIGN KEY (group_id) REFERENCES namespaces(id) ON DELETE CASCADE; +ALTER TABLE ONLY packages_cargo_metadata + ADD CONSTRAINT fk_rails_1dd80bdb24 FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE; + ALTER TABLE ONLY project_ci_feature_usages ADD CONSTRAINT fk_rails_1deedbf64b FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE; @@ -49101,6 +49124,9 @@ ALTER TABLE p_knowledge_graph_enabled_namespaces ALTER TABLE ONLY security_policies ADD CONSTRAINT fk_rails_802ceea0c8 FOREIGN KEY (security_orchestration_policy_configuration_id) REFERENCES security_orchestration_policy_configurations(id) ON DELETE CASCADE; +ALTER TABLE ONLY packages_cargo_metadata + ADD CONSTRAINT fk_rails_804c0f995d FOREIGN KEY (package_id) REFERENCES packages_packages(id) ON DELETE CASCADE; + ALTER TABLE incident_management_pending_issue_escalations ADD CONSTRAINT fk_rails_8069e80242 FOREIGN KEY (namespace_id) REFERENCES namespaces(id) ON DELETE CASCADE; diff --git a/doc/api/graphql/reference/_index.md b/doc/api/graphql/reference/_index.md index 3e25179b342733..f2f8329d4be7eb 100644 --- a/doc/api/graphql/reference/_index.md +++ b/doc/api/graphql/reference/_index.md @@ -48572,6 +48572,7 @@ Values for sorting package. | Value | Description | | ----- | ----------- | +| `CARGO` | Packages from the Cargo package manager. | | `COMPOSER` | Packages from the Composer package manager. | | `CONAN` | Packages from the Conan package manager. | | `DEBIAN` | Packages from the Debian package manager. | diff --git a/lib/gitlab/regex/packages.rb b/lib/gitlab/regex/packages.rb index 481d9804f51966..8aad1848886b4e 100644 --- a/lib/gitlab/regex/packages.rb +++ b/lib/gitlab/regex/packages.rb @@ -15,6 +15,14 @@ module Packages API_PATH_REGEX = %r{^/api/v\d+/(projects/[^/]+/|groups?/[^/]+/-/)?packages/[A-Za-z]+} + def cargo_package_name_regex + @cargo_package_name_regex ||= /\A[a-zA-Z][a-zA-Z0-9\-_]{0,63}\z/ + end + + def cargo_package_normalized_name_regex + @cargo_package_normalized_name_regex ||= /\A[a-z0-9-]+\z/ + end + def conan_package_reference_regex @conan_package_reference_regex ||= %r{\A[A-Za-z0-9]+\z} end diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 0a28f572b1ee4a..c997a1ad47103b 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -76669,6 +76669,9 @@ msgstr "" msgid "is read-only" msgstr "" +msgid "is reserved and cannot be used" +msgstr "" + msgid "is too large. Maximum size allowed is %{size}" msgstr "" diff --git a/spec/factories/packages/cargo/metadata.rb b/spec/factories/packages/cargo/metadata.rb new file mode 100644 index 00000000000000..25719901634e22 --- /dev/null +++ b/spec/factories/packages/cargo/metadata.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :cargo_metadatum, class: 'Packages::Cargo::Metadatum' do + package { association(:cargo_package) } + project { package.project } + + normalized_name { package.name&.downcase&.tr('_', '-') } + normalized_version { package.version&.sub(/\+.*\z/, '') } + + index_content do + { + name: package.name, + deps: [ + { + name: "dep_1", + req: "^0.6" + } + ], + vers: "0.1.0", + cksum: "1234567890", + v: 2 + } + end + end +end diff --git a/spec/factories/packages/cargo/packages.rb b/spec/factories/packages/cargo/packages.rb new file mode 100644 index 00000000000000..102e47f20e3aba --- /dev/null +++ b/spec/factories/packages/cargo/packages.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :cargo_package, class: 'Packages::Cargo::Package', parent: :package do + sequence(:name) { |n| "cargo-package-#{n}" } + sequence(:version) { |n| "1.0.#{n}" } + package_type { :cargo } + + trait :with_metadatum do + cargo_metadatum { association(:cargo_metadatum, package: instance) } + end + end +end diff --git a/spec/graphql/types/packages/package_type_enum_spec.rb b/spec/graphql/types/packages/package_type_enum_spec.rb index 027ce660679a94..8d499085f9beeb 100644 --- a/spec/graphql/types/packages/package_type_enum_spec.rb +++ b/spec/graphql/types/packages/package_type_enum_spec.rb @@ -4,6 +4,6 @@ RSpec.describe GitlabSchema.types['PackageTypeEnum'] do it 'exposes all package types' do - expect(described_class.values.keys).to contain_exactly(*%w[MAVEN NPM CONAN NUGET PYPI COMPOSER GENERIC GOLANG DEBIAN RUBYGEMS HELM TERRAFORM_MODULE RPM ML_MODEL]) + expect(described_class.values.keys).to contain_exactly(*%w[CARGO MAVEN NPM CONAN NUGET PYPI COMPOSER GENERIC GOLANG DEBIAN RUBYGEMS HELM TERRAFORM_MODULE RPM ML_MODEL]) end end diff --git a/spec/models/packages/cargo/metadatum_spec.rb b/spec/models/packages/cargo/metadatum_spec.rb new file mode 100644 index 00000000000000..bee89aaeeb0240 --- /dev/null +++ b/spec/models/packages/cargo/metadatum_spec.rb @@ -0,0 +1,84 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Packages::Cargo::Metadatum, feature_category: :package_registry do + describe 'relationships' do + it { is_expected.to belong_to(:package) } + end + + describe 'validations' do + it { is_expected.to validate_presence_of(:package) } + it { is_expected.to validate_presence_of(:normalized_name) } + it { is_expected.to validate_presence_of(:normalized_version) } + it { is_expected.to validate_length_of(:normalized_name).is_at_most(64) } + it { is_expected.to validate_length_of(:normalized_version).is_at_most(255) } + end + + describe 'uniqueness validation' do + let_it_be(:project) { create(:project) } + let_it_be(:existing_package) { create(:cargo_package, project: project, name: 'test-package', version: '1.0.0') } + let_it_be(:existing_metadatum) { create(:cargo_metadatum, package: existing_package) } + + context 'when creating a new metadatum with different normalized name' do + let(:new_package) { create(:cargo_package, project: project, name: 'different-package', version: '1.0.0') } + let(:new_metadatum) { build(:cargo_metadatum, package: new_package) } + + it 'is valid' do + expect(new_metadatum).to be_valid + end + end + + context 'when creating a new metadatum with different normalized version' do + let(:new_package) { create(:cargo_package, project: project, name: 'test-package', version: '2.0.0') } + let(:new_metadatum) { build(:cargo_metadatum, package: new_package) } + + it 'is valid' do + expect(new_metadatum).to be_valid + end + end + + context 'when creating a new metadatum in different project' do + let_it_be(:other_project) { create(:project) } + let(:new_package) { create(:cargo_package, project: other_project, name: 'test-package', version: '1.0.0') } + let(:new_metadatum) { build(:cargo_metadatum, package: new_package) } + + it 'is valid' do + expect(new_metadatum).to be_valid + end + end + end + + describe 'automatic normalization' do + let(:package) { create(:cargo_package, name: 'My_Package_123', version: '1.0.0+build123') } + let(:metadatum) { build(:cargo_metadatum, package: package) } + + it 'automatically sets normalized_name from package name' do + metadatum.valid? + expect(metadatum.normalized_name).to eq('my-package-123') + end + + it 'automatically sets normalized_version from package version' do + metadatum.valid? + expect(metadatum.normalized_version).to eq('1.0.0') + end + end + + describe 'index_json', :aggregate_failures do + let(:valid_json) do + { 'name' => 'foo', 'vers' => '0.1.0', 'deps' => [], + 'cksum' => 'd867001db0e2b6e0496f9fac96930e2d42233ecd3ca0413e0753d4c7695d289c', 'v' => 2 } + end + + it { is_expected.to allow_value(valid_json).for(:index_content) } + end + + describe 'index_content is invalid when extra field is present' do + let(:invalid_json) do + { 'name' => 'foo', 'vers' => '0.1.0', 'deps' => [], + 'cksum' => 'd867001db0e2b6e0496f9fac96930e2d42233ecd3ca0413e0753d4c7695d289c', 'v' => 2, 'bad' => 'bad' } + end + + it { is_expected.not_to allow_value(invalid_json).for(:index_content) } + end +end diff --git a/spec/models/packages/cargo/package_spec.rb b/spec/models/packages/cargo/package_spec.rb new file mode 100644 index 00000000000000..708d49b1588d51 --- /dev/null +++ b/spec/models/packages/cargo/package_spec.rb @@ -0,0 +1,258 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Packages::Cargo::Package, feature_category: :package_registry do + describe 'relationships' do + it { is_expected.to have_one(:cargo_metadatum).inverse_of(:package) } + end + + describe 'validations' do + describe '#name' do + subject { build_stubbed(:cargo_package) } + + it 'allows accepted values' do + is_expected.to allow_values('cargo-package', 'cargo-package123', 'cargo-package_123', + 'cargo-package-123').for(:name) + end + + it 'does not allow special characters' do + is_expected.not_to allow_values('cargo-package!', 'cargo-package@', 'cargo-package#', + 'cargo-package%').for(:name) + end + + it 'does not allow names to start with non-alphabetic character' do + is_expected.not_to allow_values('1cargo-package', '_cargo-package').for(:name) + end + + it 'does not allow names with more than 64 characters' do + is_expected.not_to allow_value('a' * 65).for(:name) + end + + it 'does not allow reserved names' do + is_expected.not_to allow_values('nul', 'con', 'prn', 'aux', 'com1', 'com2', 'com3', 'com4').for(:name) + end + end + + describe '.with_normalized_cargo_name' do + let_it_be(:cargo_package) { create(:cargo_package, name: 'Foo-bAr_BAZ_buz') } + let_it_be(:cargo_metadatum) { create(:cargo_metadatum, package: cargo_package) } + + subject(:packages_with_normalized_name) { described_class.with_normalized_cargo_name('foo-bar-baz-buz') } + + it { is_expected.to match_array([cargo_package]) } + + context 'when package has no metadatum' do + let_it_be(:cargo_package_without_metadatum) { create(:cargo_package, name: 'Foo-bAr_BAZ_buz') } + + it 'does not include packages without metadatum' do + expect(packages_with_normalized_name).not_to include(cargo_package_without_metadatum) + end + end + end + + describe '.with_normalized_cargo_version' do + let_it_be(:cargo_package) { create(:cargo_package, version: '1.0.0+build999') } + let_it_be(:cargo_metadatum) { create(:cargo_metadatum, package: cargo_package) } + + subject(:packages_with_normalized_version) { described_class.with_normalized_cargo_version('1.0.0') } + + it { is_expected.to match_array([cargo_package]) } + + context 'when package has no metadatum' do + let_it_be(:cargo_package_without_metadatum) { create(:cargo_package, version: '1.0.0+build999') } + + it 'does not include packages without metadatum' do + expect(packages_with_normalized_version).not_to include(cargo_package_without_metadatum) + end + end + end + + context 'for package uniqueness' do + let_it_be(:existing_package) { create(:cargo_package, version: '1.0.0') } + let_it_be(:existing_metadatum) { create(:cargo_metadatum, package: existing_package) } + + context 'when name and version are the same' do + let(:new_package) do + build(:cargo_package, project: existing_package.project, name: existing_package.name, + version: existing_package.version) + end + + it 'is invalid' do + expect(new_package).not_to be_valid + end + end + + context 'when version is the same but name is different' do + let(:new_package) do + build(:cargo_package, project: existing_package.project, version: existing_package.version) + end + + it 'is valid' do + expect(new_package).to be_valid + end + end + + context 'when name is the same but version is different' do + let(:new_package) do + build(:cargo_package, project: existing_package.project, name: existing_package.name) + end + + it 'is valid' do + expect(new_package).to be_valid + end + end + + context 'when name and normalized version are the same (existing package has no build metadata)' do + let(:new_package) do + build(:cargo_package, project: existing_package.project, name: existing_package.name, + version: '1.0.0+build999') + end + + it 'is invalid because build metadata is ignored in uniqueness' do + expect(new_package).not_to be_valid + expect(new_package.errors.to_a).to include('Package already exists') + end + end + + context 'when name and normalized version are the same (existing package has build metadata)' do + let_it_be(:existing_package_with_build_metadata) { create(:cargo_package, version: '1.0.0+build999') } + let_it_be(:existing_metadatum_with_build_metadata) do + create(:cargo_metadatum, package: existing_package_with_build_metadata) + end + + let(:new_package) do + build(:cargo_package, project: existing_package_with_build_metadata.project, + name: existing_package_with_build_metadata.name, + version: '1.0.0') + end + + it 'is invalid because build metadata is ignored in uniqueness' do + expect(new_package).not_to be_valid + expect(new_package.errors.to_a).to include('Package already exists') + end + end + + context 'when name and normalized version are the same (both packages have build metadata)' do + let_it_be(:existing_package_with_build_metadata) { create(:cargo_package, version: '1.0.0+build999') } + let_it_be(:existing_metadatum_with_build_metadata) do + create(:cargo_metadatum, package: existing_package_with_build_metadata) + end + + let(:new_package) do + build(:cargo_package, project: existing_package_with_build_metadata.project, + name: existing_package_with_build_metadata.name, + version: '1.0.0+build123') + end + + it 'is invalid because build metadata is ignored in uniqueness' do + expect(new_package).not_to be_valid + expect(new_package.errors.to_a).to include('Package already exists') + end + end + + context 'when normalized name and normalized version are same' do + let_it_be(:existing_package_with_build_metadata) do + create(:cargo_package, name: 'foo-bar', version: '1.0.0+build999') + end + + let_it_be(:existing_metadatum_with_build_metadata) do + create(:cargo_metadatum, package: existing_package_with_build_metadata) + end + + let(:new_package) do + build(:cargo_package, project: existing_package_with_build_metadata.project, + name: 'foo_bar', + version: '1.0.0+build123') + end + + it 'is invalid' do + expect(new_package).not_to be_valid + expect(new_package.errors.to_a).to include('Package already exists') + end + end + + context 'when package has no metadatum' do + let_it_be(:existing_package_without_metadatum) { create(:cargo_package, name: 'foo-bar', version: '1.0.0') } + + let(:new_package) do + build(:cargo_package, project: existing_package_without_metadatum.project, name: 'foo_bar', + version: '1.0.0+build123') + end + + it 'is valid because uniqueness check requires metadatum' do + expect(new_package).to be_valid + end + end + end + + describe '#version' do + it_behaves_like 'validating version to be SemVer compliant for', :cargo_package + end + end + + describe '.cargo_package_already_taken?' do + let_it_be(:project) { create(:project) } + let(:package_name) { 'test-package' } + let(:package_version) { '1.0.0+build123' } + + context 'when package exists with same normalized name and version' do + let!(:existing_package) do + create(:cargo_package, project: project, name: 'test_package', version: '1.0.0+build456') + end + + let!(:existing_metadatum) { create(:cargo_metadatum, package: existing_package) } + + it 'returns true' do + expect(described_class.cargo_package_already_taken?(project.id, package_name, package_version)).to be true + end + end + + context 'when package exists with different normalized name' do + let!(:existing_package) do + create(:cargo_package, project: project, name: 'different-package', version: '1.0.0+build456') + end + + let!(:existing_metadatum) { create(:cargo_metadatum, package: existing_package) } + + it 'returns false' do + expect(described_class.cargo_package_already_taken?(project.id, package_name, package_version)).to be false + end + end + + context 'when package exists with different normalized version' do + let!(:existing_package) do + create(:cargo_package, project: project, name: 'test_package', version: '2.0.0+build456') + end + + let!(:existing_metadatum) { create(:cargo_metadatum, package: existing_package) } + + it 'returns false' do + expect(described_class.cargo_package_already_taken?(project.id, package_name, package_version)).to be false + end + end + + context 'when package exists in different project' do + let_it_be(:other_project) { create(:project) } + let!(:existing_package) do + create(:cargo_package, project: other_project, name: 'test_package', version: '1.0.0+build456') + end + + let!(:existing_metadatum) { create(:cargo_metadatum, package: existing_package) } + + it 'returns false' do + expect(described_class.cargo_package_already_taken?(project.id, package_name, package_version)).to be false + end + end + + context 'when package has no metadatum' do + let!(:existing_package) do + create(:cargo_package, project: project, name: 'test_package', version: '1.0.0+build456') + end + + it 'returns false' do + expect(described_class.cargo_package_already_taken?(project.id, package_name, package_version)).to be false + end + end + end +end diff --git a/spec/models/packages/cargo_spec.rb b/spec/models/packages/cargo_spec.rb new file mode 100644 index 00000000000000..5c692bd5b7ba70 --- /dev/null +++ b/spec/models/packages/cargo_spec.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Packages::Cargo, feature_category: :package_registry do + describe '.normalize_name' do + using RSpec::Parameterized::TableSyntax + + where(:input, :expected) do + 'MyPackage' | 'mypackage' + 'my_package' | 'my-package' + 'my_package_name' | 'my-package-name' + 'My_Package_Name' | 'my-package-name' + 'my_package_123' | 'my-package-123' + 'my-package-name' | 'my-package-name' + '' | '' + 'A' | 'a' + 'my__package' | 'my--package' + end + + with_them do + it 'converts to lowercase and replaces underscores with hyphens' do + expect(described_class.normalize_name(input)).to eq(expected) + end + end + end + + describe '.normalize_version' do + using RSpec::Parameterized::TableSyntax + + where(:input, :expected) do + '1.0.0+build123' | '1.0.0' + '1.0.0' | '1.0.0' + '1.0.0+git.abc123.build456' | '1.0.0' + '1.0.0-alpha.1+build123' | '1.0.0-alpha.1' + '1.0.0+build+123' | '1.0.0' + '1.0.0+build.123.abc' | '1.0.0' + '' | '' + '+build123' | '' + '1.0.0+build123+extra456' | '1.0.0' + end + + with_them do + it 'removes build metadata from version' do + expect(described_class.normalize_version(input)).to eq(expected) + end + end + end +end diff --git a/spec/support/shared_examples/services/packages_shared_examples.rb b/spec/support/shared_examples/services/packages_shared_examples.rb index c6ac864e2220f4..627df2cd14a2ee 100644 --- a/spec/support/shared_examples/services/packages_shared_examples.rb +++ b/spec/support/shared_examples/services/packages_shared_examples.rb @@ -217,6 +217,7 @@ def group_filter_url(filter, param) let_it_be(:package12) { create(:terraform_module_package, project: project) } let_it_be(:package13) { create(:rpm_package, project: project) } let_it_be(:package14) { create(:ml_model_package, project: project) } + let_it_be(:package15) { create(:cargo_package, project: project) } Packages::Package.package_types.keys.each do |package_type| context "for package type #{package_type}" do -- GitLab