Commit 709c5061 authored by Ethan Reesor's avatar Ethan Reesor

Improve Go module version processing

- Clean up pseudo-version logic
- Batch and memoize fetching blobs for a version
  + Fixes Gitaly N+1
- Remove archive generation from GoProxy (into GoModuleVersion)
- Test module finder for path traversal
- Also, add GitLab version to GoProxy API details
parent 1a0ec496
...@@ -10,6 +10,7 @@ module Packages ...@@ -10,6 +10,7 @@ module Packages
attr_reader :project, :module_name attr_reader :project, :module_name
def initialize(project, module_name) def initialize(project, module_name)
module_name = CGI.unescape(module_name)
module_name = Pathname.new(module_name).cleanpath.to_s module_name = Pathname.new(module_name).cleanpath.to_s
@project = project @project = project
......
...@@ -51,7 +51,7 @@ module Packages ...@@ -51,7 +51,7 @@ module Packages
def find_pseudo_version(str) def find_pseudo_version(str)
semver = parse_semver(str) semver = parse_semver(str)
raise ArgumentError.new 'target is not a pseudo-version' unless semver && PSEUDO_VERSION_REGEX.match?(str) raise ArgumentError.new 'target is not a pseudo-version' unless pseudo_version?(semver)
# valid pseudo-versions are # valid pseudo-versions are
# vX.0.0-yyyymmddhhmmss-sha1337beef0, when no earlier tagged commit exists for X # vX.0.0-yyyymmddhhmmss-sha1337beef0, when no earlier tagged commit exists for X
......
...@@ -37,10 +37,25 @@ class Packages::GoModuleVersion ...@@ -37,10 +37,25 @@ class Packages::GoModuleVersion
@name || @ref&.name @name || @ref&.name
end end
def full_name
"#{mod.name}@#{name || commit.sha}"
end
def gomod def gomod
@gomod ||= blob_at(@mod.path + '/go.mod') @gomod ||= blob_at(@mod.path + '/go.mod')
end end
def archive
suffix_len = @mod.path == '' ? 0 : @mod.path.length + 1
Zip::OutputStream.write_buffer do |zip|
files.each do |file|
zip.put_next_entry "#{full_name}/#{file.path[suffix_len...]}"
zip.write blob_at(file.path)
end
end
end
def files def files
return @files if defined?(@files) return @files if defined?(@files)
...@@ -51,10 +66,22 @@ class Packages::GoModuleVersion ...@@ -51,10 +66,22 @@ class Packages::GoModuleVersion
end end
def blob_at(path) def blob_at(path)
@mod.project.repository.blob_at(@commit.sha, path)&.data return if path.nil? || path.empty?
path = path[1..] if path.start_with? '/'
blobs.find { |x| x.path == path }&.data
end end
def valid? def valid?
@mod.path_valid?(major) && @mod.gomod_valid?(gomod) @mod.path_valid?(major) && @mod.gomod_valid?(gomod)
end end
private
def blobs
return @blobs if defined?(@blobs)
@blobs = @mod.project.repository.batch_blobs(files.map { |x| [@commit.sha, x.path] })
end
end end
...@@ -66,7 +66,7 @@ module API ...@@ -66,7 +66,7 @@ module API
namespace ':id/packages/go/*module_name/@v' do namespace ':id/packages/go/*module_name/@v' do
desc 'Get all tagged versions for a given Go module' do desc 'Get all tagged versions for a given Go module' do
detail 'See `go help goproxy`, GET $GOPROXY/<module>/@v/list' detail 'See `go help goproxy`, GET $GOPROXY/<module>/@v/list. This feature was introduced in GitLab 13.0.'
end end
get 'list' do get 'list' do
mod = find_module mod = find_module
...@@ -76,7 +76,7 @@ module API ...@@ -76,7 +76,7 @@ module API
end end
desc 'Get information about the given module version' do desc 'Get information about the given module version' do
detail 'See `go help goproxy`, GET $GOPROXY/<module>/@v/<version>.info' detail 'See `go help goproxy`, GET $GOPROXY/<module>/@v/<version>.info. This feature was introduced in GitLab 13.0.'
success EE::API::Entities::GoModuleVersion success EE::API::Entities::GoModuleVersion
end end
params do params do
...@@ -89,7 +89,7 @@ module API ...@@ -89,7 +89,7 @@ module API
end end
desc 'Get the module file of the given module version' do desc 'Get the module file of the given module version' do
detail 'See `go help goproxy`, GET $GOPROXY/<module>/@v/<version>.mod' detail 'See `go help goproxy`, GET $GOPROXY/<module>/@v/<version>.mod. This feature was introduced in GitLab 13.0.'
end end
params do params do
requires :module_version, type: String, desc: 'Module version' requires :module_version, type: String, desc: 'Module version'
...@@ -102,7 +102,7 @@ module API ...@@ -102,7 +102,7 @@ module API
end end
desc 'Get a zip of the source of the given module version' do desc 'Get a zip of the source of the given module version' do
detail 'See `go help goproxy`, GET $GOPROXY/<module>/@v/<version>.zip' detail 'See `go help goproxy`, GET $GOPROXY/<module>/@v/<version>.zip. This feature was introduced in GitLab 13.0.'
end end
params do params do
requires :module_version, type: String, desc: 'Module version' requires :module_version, type: String, desc: 'Module version'
...@@ -110,21 +110,13 @@ module API ...@@ -110,21 +110,13 @@ module API
get ':module_version.zip', requirements: MODULE_VERSION_REQUIREMENTS do get ':module_version.zip', requirements: MODULE_VERSION_REQUIREMENTS do
ver = find_version ver = find_version
suffix_len = ver.mod.path == '' ? 0 : ver.mod.path.length + 1 # TODO: Content-Type should be application/zip, see #214876
s = Zip::OutputStream.write_buffer do |zip|
ver.files.each do |file|
zip.put_next_entry "#{ver.mod.name}@#{ver.name}/#{file.path[suffix_len...]}"
zip.write ver.blob_at(file.path)
end
end
header['Content-Disposition'] = ActionDispatch::Http::ContentDisposition.format(disposition: 'attachment', filename: ver.name + '.zip') header['Content-Disposition'] = ActionDispatch::Http::ContentDisposition.format(disposition: 'attachment', filename: ver.name + '.zip')
header['Content-Transfer-Encoding'] = 'binary' header['Content-Transfer-Encoding'] = 'binary'
content_type 'text/plain' content_type 'text/plain'
# content_type 'application/zip' # content_type 'application/zip'
status :ok status :ok
body s.string body ver.archive.string
end end
end end
end end
......
...@@ -5,12 +5,6 @@ module API ...@@ -5,12 +5,6 @@ module API
module Packages module Packages
module Go module Go
module ModuleHelpers module ModuleHelpers
# basic semver regex
SEMVER_REGEX = /v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([-.a-z0-9]+))?(?:\+([-.a-z0-9]+))?/i.freeze
# semver, but the prerelease component follows a specific format
PSEUDO_VERSION_REGEX = /^v\d+\.(0\.0-|\d+\.\d+-([^+]*\.)?0\.)\d{14}-[A-Za-z0-9]+(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$/i.freeze
def case_encode(str) def case_encode(str)
str.gsub(/A-Z/) { |s| "!#{s.downcase}"} str.gsub(/A-Z/) { |s| "!#{s.downcase}"}
end end
...@@ -25,8 +19,29 @@ module API ...@@ -25,8 +19,29 @@ module API
::Packages::SemVer.match?(tag.name, prefixed: true) ::Packages::SemVer.match?(tag.name, prefixed: true)
end end
def pseudo_version?(str) def pseudo_version?(version)
::Packages::SemVer.match?(str, prefixed: true) && PSEUDO_VERSION_REGEX.match?(str) return false unless version
if version.is_a? String
version = parse_semver version
return false unless version
end
pre = version.prerelease
# valid pseudo-versions are
# vX.0.0-yyyymmddhhmmss-sha1337beef0, when no earlier tagged commit exists for X
# vX.Y.Z-pre.0.yyyymmddhhmmss-sha1337beef0, when most recent prior tag is vX.Y.Z-pre
# vX.Y.(Z+1)-0.yyyymmddhhmmss-sha1337beef0, when most recent prior tag is vX.Y.Z
if version.minor != 0 || version.patch != 0
m = /\A(.*\.)?0\./.freeze.match pre
return false unless m
pre = pre[m[0].length..]
end
/\A\d{14}-[A-Za-z0-9]+\z/.freeze.match? pre
end end
def parse_semver(str) def parse_semver(str)
......
...@@ -19,7 +19,7 @@ FactoryBot.define do ...@@ -19,7 +19,7 @@ FactoryBot.define do
new(p.mod, p.type, p.commit, name: p.name, semver: s, ref: p.ref) new(p.mod, p.type, p.commit, name: p.name, semver: s, ref: p.ref)
end end
mod { go_module } mod { create :go_module }
type { :commit } type { :commit }
commit { raise ArgumentError.new("commit is required") } commit { raise ArgumentError.new("commit is required") }
name { nil } name { nil }
......
...@@ -7,6 +7,6 @@ FactoryBot.define do ...@@ -7,6 +7,6 @@ FactoryBot.define do
project project
path { '' } path { '' }
name { "#{Settings.build_gitlab_go_url}/#{project.full_path}#{path.empty? ? '' : path}" } name { "#{Settings.build_gitlab_go_url}/#{project.full_path}#{path.empty? ? '' : '/'}#{path}" }
end end
end end
...@@ -5,10 +5,25 @@ require 'spec_helper' ...@@ -5,10 +5,25 @@ require 'spec_helper'
describe Packages::Go::ModuleFinder do describe Packages::Go::ModuleFinder do
let_it_be(:project) { create :project } let_it_be(:project) { create :project }
let_it_be(:other_project) { create :project } let_it_be(:other_project) { create :project }
let(:finder) { described_class.new project, module_name }
shared_examples 'an invalid path' do
describe '#module_name' do
it 'returns the expected name' do
expect(finder.module_name).to eq(expected_name)
end
end
describe '#execute' do
it 'returns nil' do
expect(finder.execute).to be_nil
end
end
end
describe '#execute' do describe '#execute' do
context 'with module name equal to project name' do context 'with module name equal to project name' do
let(:finder) { described_class.new(project, base_url(project)) } let(:module_name) { base_url(project) }
it 'returns a module with empty path' do it 'returns a module with empty path' do
mod = finder.execute mod = finder.execute
...@@ -18,7 +33,7 @@ describe Packages::Go::ModuleFinder do ...@@ -18,7 +33,7 @@ describe Packages::Go::ModuleFinder do
end end
context 'with module name starting with project name and slash' do context 'with module name starting with project name and slash' do
let(:finder) { described_class.new(project, base_url(project) + '/mod') } let(:module_name) { base_url(project) + '/mod' }
it 'returns a module with non-empty path' do it 'returns a module with non-empty path' do
mod = finder.execute mod = finder.execute
...@@ -28,7 +43,7 @@ describe Packages::Go::ModuleFinder do ...@@ -28,7 +43,7 @@ describe Packages::Go::ModuleFinder do
end end
context 'with a module name not equal to and not starting with project name' do context 'with a module name not equal to and not starting with project name' do
let(:finder) { described_class.new(project, base_url(other_project)) } let(:module_name) { base_url(other_project) }
it 'returns nil' do it 'returns nil' do
expect(finder.execute).to be_nil expect(finder.execute).to be_nil
...@@ -36,6 +51,27 @@ describe Packages::Go::ModuleFinder do ...@@ -36,6 +51,27 @@ describe Packages::Go::ModuleFinder do
end end
end end
context 'with relative path component' do
it_behaves_like 'an invalid path' do
let(:module_name) { base_url(project) + '/../xyz' }
let(:expected_name) { base_url(project.namespace) + '/xyz' }
end
end
context 'with a URL encoded relative path component' do
it_behaves_like 'an invalid path' do
let(:module_name) { base_url(project) + '/%2E%2E%2Fxyz' }
let(:expected_name) { base_url(project.namespace) + '/xyz' }
end
end
context 'with many relative path components' do
it_behaves_like 'an invalid path' do
let(:module_name) { base_url(project) + ('/..' * 10) + '/xyz' }
let(:expected_name) { ('../' * 7) + 'xyz' }
end
end
def base_url(project) def base_url(project)
"#{Settings.build_gitlab_go_url}/#{project.full_path}" "#{Settings.build_gitlab_go_url}/#{project.full_path}"
end end
......
...@@ -39,19 +39,19 @@ describe Packages::Go::VersionFinder do ...@@ -39,19 +39,19 @@ describe Packages::Go::VersionFinder do
end end
context 'for the package' do context 'for the package' do
let(:mod) { create :go_module, project: project, path: '/pkg' } let(:mod) { create :go_module, project: project, path: 'pkg' }
it_behaves_like '#execute' it_behaves_like '#execute'
end end
context 'for the submodule' do context 'for the submodule' do
let(:mod) { create :go_module, project: project, path: '/mod' } let(:mod) { create :go_module, project: project, path: 'mod' }
it_behaves_like '#execute', 'v1.0.3' it_behaves_like '#execute', 'v1.0.3'
end end
context 'for the root module v2' do context 'for the root module v2' do
let(:mod) { create :go_module, project: project, path: '/v2' } let(:mod) { create :go_module, project: project, path: 'v2' }
it_behaves_like '#execute', 'v2.0.0' it_behaves_like '#execute', 'v2.0.0'
end end
......
...@@ -17,6 +17,28 @@ describe Packages::GoModuleVersion, type: :model do ...@@ -17,6 +17,28 @@ describe Packages::GoModuleVersion, type: :model do
create :go_module_commit, :files, project: project, tag: 'v2.0.0', files: { 'v2/x.go' => "package a\n" } create :go_module_commit, :files, project: project, tag: 'v2.0.0', files: { 'v2/x.go' => "package a\n" }
end end
shared_examples '#files' do |desc, *entries|
it "returns #{desc}" do
actual = version.files.map { |x| x.path }.to_set
expect(actual).to eq(entries.to_set)
end
end
shared_examples '#archive' do |desc, *entries|
it "returns an archive of #{desc}" do
expected = entries.map { |e| "#{version.full_name}/#{e}" }.to_set
actual = Set[]
Zip::InputStream.open(StringIO.new(version.archive.string)) do |zip|
while (entry = zip.get_next_entry)
actual.add(entry.name)
end
end
expect(actual).to eq(expected)
end
end
describe '#name' do describe '#name' do
context 'with ref and name specified' do context 'with ref and name specified' do
let_it_be(:version) { create :go_module_version, mod: mod, name: 'foobar', commit: project.repository.head_commit, ref: project.repository.find_tag('v1.0.0') } let_it_be(:version) { create :go_module_version, mod: mod, name: 'foobar', commit: project.repository.head_commit, ref: project.repository.find_tag('v1.0.0') }
...@@ -50,20 +72,42 @@ describe Packages::GoModuleVersion, type: :model do ...@@ -50,20 +72,42 @@ describe Packages::GoModuleVersion, type: :model do
context 'with a root module' do context 'with a root module' do
context 'with an empty module path' do context 'with an empty module path' do
let_it_be(:version) { create :go_module_version, :tagged, mod: mod, name: 'v1.0.2' } let_it_be(:version) { create :go_module_version, :tagged, mod: mod, name: 'v1.0.2' }
it('returns all the files') { expect(version.files.map { |x| x.path }.to_set).to eq(Set['README.md', 'go.mod', 'a.go', 'pkg/b.go']) } it_behaves_like '#files', 'all the files', 'README.md', 'go.mod', 'a.go', 'pkg/b.go'
end
end
context 'with a root module and a submodule' do
context 'with an empty module path' do
let_it_be(:version) { create :go_module_version, :tagged, mod: mod, name: 'v1.0.3' }
it_behaves_like '#files', 'files excluding the submodule', 'README.md', 'go.mod', 'a.go', 'pkg/b.go'
end
context 'with the submodule\'s path' do
let_it_be(:mod) { create :go_module, project: project, path: 'mod' }
let_it_be(:version) { create :go_module_version, :tagged, mod: mod, name: 'v1.0.3' }
it_behaves_like '#files', 'the submodule\'s files', 'mod/go.mod', 'mod/a.go'
end
end
end
describe '#archive' do
context 'with a root module' do
context 'with an empty module path' do
let_it_be(:version) { create :go_module_version, :tagged, mod: mod, name: 'v1.0.2' }
it_behaves_like '#archive', 'all the files', 'README.md', 'go.mod', 'a.go', 'pkg/b.go'
end end
end end
context 'with a root module and a submodule' do context 'with a root module and a submodule' do
context 'with an empty module path' do context 'with an empty module path' do
let_it_be(:version) { create :go_module_version, :tagged, mod: mod, name: 'v1.0.3' } let_it_be(:version) { create :go_module_version, :tagged, mod: mod, name: 'v1.0.3' }
it('returns files excluding the submodule') { expect(version.files.map { |x| x.path }.to_set).to eq(Set['README.md', 'go.mod', 'a.go', 'pkg/b.go']) } it_behaves_like '#archive', 'files excluding the submodule', 'README.md', 'go.mod', 'a.go', 'pkg/b.go'
end end
context 'with the submodule\'s path' do context 'with the submodule\'s path' do
let_it_be(:mod) { create :go_module, project: project, path: 'mod' } let_it_be(:mod) { create :go_module, project: project, path: 'mod' }
let_it_be(:version) { create :go_module_version, :tagged, mod: mod, name: 'v1.0.3' } let_it_be(:version) { create :go_module_version, :tagged, mod: mod, name: 'v1.0.3' }
it('returns the submodule\'s files') { expect(version.files.map { |x| x.path }.to_set).to eq(Set['mod/go.mod', 'mod/a.go']) } it_behaves_like '#archive', 'the submodule\'s files', 'go.mod', 'a.go'
end end
end end
end end
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment