diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index d49d965..9b48fad 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -20,7 +20,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - ruby-version: ['3.0', '3.1', '3.2', '3.3'] + ruby-version: ['3.2', '3.3', '3.4', '4.0'] steps: - name: checkout uses: actions/checkout@v1 diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index b046874..6fff185 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -17,7 +17,7 @@ jobs: uses: actions/checkout@v1 - uses: ruby/setup-ruby@v1 with: - ruby-version: 3.3 + ruby-version: 3.4 - name: test run: make build test BUILD=local - name: report gemfile name diff --git a/.github/workflows/rubocop.yml b/.github/workflows/rubocop.yml index b5913b4..8b1b07d 100644 --- a/.github/workflows/rubocop.yml +++ b/.github/workflows/rubocop.yml @@ -11,7 +11,7 @@ jobs: - name: Set up Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.3 + ruby-version: 3.4 - name: Run rubocop diff --git a/.github/workflows/spec.yml b/.github/workflows/spec.yml index c502ba5..a7ec433 100644 --- a/.github/workflows/spec.yml +++ b/.github/workflows/spec.yml @@ -14,7 +14,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - ruby-version: ['3.0', '3.1', '3.2', '3.3'] + ruby-version: ['3.2', '3.3', '3.4', '4.0'] registry-version: ['v1', 'v2'] steps: - name: Setup ruby diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 02a4761..55d5b7c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -14,8 +14,31 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - ruby-version: ['3.0', '3.1', '3.2', '3.3'] - registry-version: ['v1', 'v2'] + include: + - ruby-version: '3.2' + fixture-schema: 'v1' + registry-image: 'registry:2.8.3' + - ruby-version: '3.2' + fixture-schema: 'v2' + registry-image: 'registry' + - ruby-version: '3.3' + fixture-schema: 'v1' + registry-image: 'registry:2.8.3' + - ruby-version: '3.3' + fixture-schema: 'v2' + registry-image: 'registry' + - ruby-version: '3.4' + fixture-schema: 'v1' + registry-image: 'registry:2.8.3' + - ruby-version: '3.4' + fixture-schema: 'v2' + registry-image: 'registry' + - ruby-version: '4.0' + fixture-schema: 'v1' + registry-image: 'registry:2.8.3' + - ruby-version: '4.0' + fixture-schema: 'v2' + registry-image: 'registry' steps: - name: Setup ruby uses: ruby/setup-ruby@v1 @@ -25,14 +48,18 @@ jobs: uses: actions/checkout@v1 - name: Start containers + env: + REGISTRY_IMAGE: ${{ matrix.registry-image }} run: docker compose -f "docker-compose.yml" up -d --build - name: bundle install run: bundle install - name: Run tests - run: REGISTRY=http://localhost:5000 VERSION=${{ matrix.registry-version }} ruby ./test/test.rb + run: REGISTRY=http://localhost:5000 VERSION=${{ matrix.fixture-schema }} ruby ./test/test.rb - name: Stop containers if: always() + env: + REGISTRY_IMAGE: ${{ matrix.registry-image }} run: docker compose -f "docker-compose.yml" down diff --git a/CHANGELOG.md b/CHANGELOG.md index 35ba2de..bad0bda 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,16 @@ +## v1.19.0, 19 March 2026 + +- Replace the `rest-client` transport with Faraday while keeping `http_options` + compatibility for proxy, timeout, SSL, and mTLS settings +- Follow redirects for blob downloads and tag writes without forwarding + authorization headers across hosts +- Retry manifest requests with the legacy schema-v1 Accept header when newer + registries return HTTP 500 for legacy manifests +- Raise `DockerRegistry2::RegistryHTTPException` for unexpected HTTP errors + instead of attempting to parse error responses as registry payloads +- Update the development and CI matrix to Ruby 3.2 through 4.0, and pin + schema-v1 integration coverage to `registry:2.8.3` + ## v1.7.1, 13 July 2019 - Add `application/json` to the list of acceptable response formats from @@ -39,4 +52,3 @@ - Move `ping` call from `DockerRegistry2::Registry.new` to `DockerRegistry2.connect`, to allow a registry to be initialized without a ping. - diff --git a/Dockerfile b/Dockerfile index 0dc21ae..b717694 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # build container -ARG IMG=ruby:3.1.1-alpine +ARG IMG=ruby:3.4.9-alpine FROM ${IMG} AS build RUN apk --update add make diff --git a/Gemfile.lock b/Gemfile.lock index 579a3a0..a360fbb 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -2,86 +2,100 @@ PATH remote: . specs: docker_registry2 (1.18.2) - rest-client (>= 1.8.0) + faraday (>= 2.0) + faraday-follow_redirects + faraday-net_http GEM remote: https://rubygems.org/ specs: - addressable (2.8.7) - public_suffix (>= 2.0.2, < 7.0) - ast (2.4.2) - bigdecimal (3.1.8) - crack (1.0.0) + addressable (2.8.9) + public_suffix (>= 2.0.2, < 8.0) + ast (2.4.3) + base64 (0.3.0) + benchmark (0.5.0) + bigdecimal (4.0.1) + crack (1.0.1) bigdecimal rexml - diff-lcs (1.5.1) - domain_name (0.6.20240107) - hashdiff (1.1.0) - http-accept (1.7.0) - http-cookie (1.0.6) - domain_name (~> 0.5) - json (2.7.2) - language_server-protocol (3.17.0.3) - mime-types (3.5.2) - mime-types-data (~> 3.2015) - mime-types-data (3.2024.0702) - netrc (0.11.0) - parallel (1.25.1) - parser (3.3.4.0) + diff-lcs (1.6.2) + faraday (2.14.1) + faraday-net_http (>= 2.0, < 3.5) + json + logger + faraday-follow_redirects (0.5.0) + faraday (>= 1, < 3) + faraday-net_http (3.4.2) + net-http (~> 0.5) + hashdiff (1.2.1) + json (2.19.1) + json-schema (6.2.0) + addressable (~> 2.8) + bigdecimal (>= 3.1, < 5) + language_server-protocol (3.17.0.5) + lint_roller (1.1.0) + logger (1.7.0) + mcp (0.8.0) + json-schema (>= 4.1) + net-http (0.9.1) + uri (>= 0.11.1) + parallel (1.27.0) + parser (3.3.10.2) ast (~> 2.4.1) racc - public_suffix (6.0.1) + prism (1.9.0) + public_suffix (7.0.5) racc (1.8.1) rainbow (3.1.1) - rake (13.2.1) - regexp_parser (2.9.2) - rest-client (2.1.0) - http-accept (>= 1.7.0, < 2.0) - http-cookie (>= 1.0.2, < 2.0) - mime-types (>= 1.16, < 4.0) - netrc (~> 0.8) - rexml (3.3.6) - strscan - rspec (3.13.0) + rake (13.3.1) + regexp_parser (2.11.3) + rexml (3.4.4) + rspec (3.13.2) rspec-core (~> 3.13.0) rspec-expectations (~> 3.13.0) rspec-mocks (~> 3.13.0) - rspec-core (3.13.0) + rspec-core (3.13.6) rspec-support (~> 3.13.0) - rspec-expectations (3.13.1) + rspec-expectations (3.13.5) diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.13.0) - rspec-mocks (3.13.1) + rspec-mocks (3.13.8) diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.13.0) - rspec-support (3.13.1) - rubocop (1.65.1) + rspec-support (3.13.7) + rubocop (1.85.1) json (~> 2.3) - language_server-protocol (>= 3.17.0) + language_server-protocol (~> 3.17.0.2) + lint_roller (~> 1.1.0) + mcp (~> 0.6) parallel (~> 1.10) parser (>= 3.3.0.2) rainbow (>= 2.2.2, < 4.0) - regexp_parser (>= 2.4, < 3.0) - rexml (>= 3.2.5, < 4.0) - rubocop-ast (>= 1.31.1, < 2.0) + regexp_parser (>= 2.9.3, < 3.0) + rubocop-ast (>= 1.49.0, < 2.0) ruby-progressbar (~> 1.7) - unicode-display_width (>= 2.4.0, < 3.0) - rubocop-ast (1.31.3) - parser (>= 3.3.1.0) + unicode-display_width (>= 2.4.0, < 4.0) + rubocop-ast (1.49.1) + parser (>= 3.3.7.2) + prism (~> 1.7) ruby-progressbar (1.13.0) - strscan (3.1.0) - unicode-display_width (2.5.0) - vcr (6.2.0) - webmock (3.23.1) + unicode-display_width (3.2.0) + unicode-emoji (~> 4.1) + unicode-emoji (4.2.0) + uri (1.1.1) + vcr (6.4.0) + webmock (3.26.2) addressable (>= 2.8.0) crack (>= 0.3.2) hashdiff (>= 0.4.0, < 2.0.0) PLATFORMS - arm64-darwin-22 + ruby x86_64-linux DEPENDENCIES + base64 + benchmark bundler docker_registry2! rake (~> 13.0) @@ -90,5 +104,48 @@ DEPENDENCIES vcr (~> 6) webmock +CHECKSUMS + addressable (2.8.9) sha256=cc154fcbe689711808a43601dee7b980238ce54368d23e127421753e46895485 + ast (2.4.3) sha256=954615157c1d6a382bc27d690d973195e79db7f55e9765ac7c481c60bdb4d383 + base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b + benchmark (0.5.0) sha256=465df122341aedcb81a2a24b4d3bd19b6c67c1530713fd533f3ff034e419236c + bigdecimal (4.0.1) sha256=8b07d3d065a9f921c80ceaea7c9d4ae596697295b584c296fe599dd0ad01c4a7 + crack (1.0.1) sha256=ff4a10390cd31d66440b7524eb1841874db86201d5b70032028553130b6d4c7e + diff-lcs (1.6.2) sha256=9ae0d2cba7d4df3075fe8cd8602a8604993efc0dfa934cff568969efb1909962 + docker_registry2 (1.18.2) + faraday (2.14.1) sha256=a43cceedc1e39d188f4d2cdd360a8aaa6a11da0c407052e426ba8d3fb42ef61c + faraday-follow_redirects (0.5.0) sha256=5cde93c894b30943a5d2b93c2fe9284216a6b756f7af406a1e55f211d97d10ad + faraday-net_http (3.4.2) sha256=f147758260d3526939bf57ecf911682f94926a3666502e24c69992765875906c + hashdiff (1.2.1) sha256=9c079dbc513dfc8833ab59c0c2d8f230fa28499cc5efb4b8dd276cf931457cd1 + json (2.19.1) sha256=dd94fdc59e48bff85913829a32350b3148156bc4fd2a95a2568a78b11344082d + json-schema (6.2.0) sha256=e8bff46ed845a22c1ab2bd0d7eccf831c01fe23bb3920caa4c74db4306813666 + language_server-protocol (3.17.0.5) sha256=fd1e39a51a28bf3eec959379985a72e296e9f9acfce46f6a79d31ca8760803cc + lint_roller (1.1.0) sha256=2c0c845b632a7d172cb849cc90c1bce937a28c5c8ccccb50dfd46a485003cc87 + logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203 + mcp (0.8.0) sha256=ae8bd146bb8e168852866fd26f805f52744f6326afb3211e073f78a95e0c34fb + net-http (0.9.1) sha256=25ba0b67c63e89df626ed8fac771d0ad24ad151a858af2cc8e6a716ca4336996 + parallel (1.27.0) sha256=4ac151e1806b755fb4e2dc2332cbf0e54f2e24ba821ff2d3dcf86bf6dc4ae130 + parser (3.3.10.2) sha256=6f60c84aa4bdcedb6d1a2434b738fe8a8136807b6adc8f7f53b97da9bc4e9357 + prism (1.9.0) sha256=7b530c6a9f92c24300014919c9dcbc055bf4cdf51ec30aed099b06cd6674ef85 + public_suffix (7.0.5) sha256=1a8bb08f1bbea19228d3bed6e5ed908d1cb4f7c2726d18bd9cadf60bc676f623 + racc (1.8.1) sha256=4a7f6929691dbec8b5209a0b373bc2614882b55fc5d2e447a21aaa691303d62f + rainbow (3.1.1) sha256=039491aa3a89f42efa1d6dec2fc4e62ede96eb6acd95e52f1ad581182b79bc6a + rake (13.3.1) sha256=8c9e89d09f66a26a01264e7e3480ec0607f0c497a861ef16063604b1b08eb19c + regexp_parser (2.11.3) sha256=ca13f381a173b7a93450e53459075c9b76a10433caadcb2f1180f2c741fc55a4 + rexml (3.4.4) sha256=19e0a2c3425dfbf2d4fc1189747bdb2f849b6c5e74180401b15734bc97b5d142 + rspec (3.13.2) sha256=206284a08ad798e61f86d7ca3e376718d52c0bc944626b2349266f239f820587 + rspec-core (3.13.6) sha256=a8823c6411667b60a8bca135364351dda34cd55e44ff94c4be4633b37d828b2d + rspec-expectations (3.13.5) sha256=33a4d3a1d95060aea4c94e9f237030a8f9eae5615e9bd85718fe3a09e4b58836 + rspec-mocks (3.13.8) sha256=086ad3d3d17533f4237643de0b5c42f04b66348c28bf6b9c2d3f4a3b01af1d47 + rspec-support (3.13.7) sha256=0640e5570872aafefd79867901deeeeb40b0c9875a36b983d85f54fb7381c47c + rubocop (1.85.1) sha256=3dbcf9e961baa4c376eeeb2a03913dca5e3987033b04d38fa538aa1e7406cc77 + rubocop-ast (1.49.1) sha256=4412f3ee70f6fe4546cc489548e0f6fcf76cafcfa80fa03af67098ffed755035 + ruby-progressbar (1.13.0) sha256=80fc9c47a9b640d6834e0dc7b3c94c9df37f08cb072b7761e4a71e22cff29b33 + unicode-display_width (3.2.0) sha256=0cdd96b5681a5949cdbc2c55e7b420facae74c4aaf9a9815eee1087cb1853c42 + unicode-emoji (4.2.0) sha256=519e69150f75652e40bf736106cfbc8f0f73aa3fb6a65afe62fefa7f80b0f80f + uri (1.1.1) sha256=379fa58d27ffb1387eaada68c749d1426738bd0f654d812fcc07e7568f5c57c6 + vcr (6.4.0) sha256=077ac92cc16efc5904eb90492a18153b5e6ca5398046d8a249a7c96a9ea24ae6 + webmock (3.26.2) sha256=774556f2ea6371846cca68c01769b2eac0d134492d21f6d0ab5dd643965a4c90 + BUNDLED WITH - 2.2.3 + 4.0.8 diff --git a/README.md b/README.md index 87d7656..241e563 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,7 @@ opts = { open_timeout: 2, read_timeout: 5 } reg = DockerRegistry2.connect("https://my.registy.corp.com", opts) ``` -Your may pass extra options for RestClient::Request.execute through `http_options` : +You may pass extra Faraday connection options through `http_options`: ```ruby opts = { http_options: { proxy: 'http://proxy.example.com:8080/' } } diff --git a/docker-compose.yml b/docker-compose.yml index 597b3b3..7f46474 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,7 +3,7 @@ version: '2.4' services: registry: restart: always - image: registry + image: ${REGISTRY_IMAGE:-registry:2.8.3} ports: - 5000:5000 environment: diff --git a/docker_registry2.gemspec b/docker_registry2.gemspec index 547771d..e9ba853 100644 --- a/docker_registry2.gemspec +++ b/docker_registry2.gemspec @@ -24,6 +24,8 @@ Gem::Specification.new do |spec| spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) } spec.require_paths = ['lib'] + spec.add_development_dependency 'base64' + spec.add_development_dependency 'benchmark' spec.add_development_dependency 'bundler' spec.add_development_dependency 'rake', '~> 13.0' spec.add_development_dependency 'rspec', '~> 3' @@ -31,6 +33,8 @@ Gem::Specification.new do |spec| spec.add_development_dependency 'vcr', '~> 6' spec.add_development_dependency 'webmock' - spec.add_dependency 'rest-client', '>= 1.8.0' + spec.add_dependency 'faraday', '>= 2.0' + spec.add_dependency 'faraday-follow_redirects' + spec.add_dependency 'faraday-net_http' spec.metadata['rubygems_mfa_required'] = 'true' end diff --git a/lib/registry/exceptions.rb b/lib/registry/exceptions.rb index 8a8b491..d7db12b 100644 --- a/lib/registry/exceptions.rb +++ b/lib/registry/exceptions.rb @@ -30,4 +30,7 @@ class NotFound < DockerRegistry2::Exception class InvalidMethod < DockerRegistry2::Exception end + + class RegistryHTTPException < DockerRegistry2::Exception + end end diff --git a/lib/registry/registry.rb b/lib/registry/registry.rb index 38fb650..cc1848d 100644 --- a/lib/registry/registry.rb +++ b/lib/registry/registry.rb @@ -1,10 +1,16 @@ # frozen_string_literal: true require 'fileutils' -require 'rest-client' +require 'base64' +require 'faraday' +require 'faraday/follow_redirects' +require 'faraday/net_http' require 'json' +require 'openssl' module DockerRegistry2 + Response = Struct.new(:body, :headers, :code, :request_url, keyword_init: true) + class Registry # rubocop:disable Metrics/ClassLength # @param [#to_s] base_uri Docker registry base URI # @param [Hash] options Client options @@ -14,21 +20,21 @@ class Registry # rubocop:disable Metrics/ClassLength # It is ignored if http_options[:open_timeout] is also specified. # @option options [#to_s] :read_timeout Time to wait for data from a registry. # It is ignored if http_options[:read_timeout] is also specified. - # @option options [Hash] :http_options Extra options for RestClient::Request.execute. + # @option options [Hash] :http_options Extra options for Faraday connection/request setup. def initialize(uri, options = {}) @uri = URI.parse(uri) - @base_uri = +"#{@uri.scheme}://#{@uri.host}:#{@uri.port}#{@uri.path}" + @base_uri = "#{@uri.scheme}://#{@uri.host}:#{@uri.port}#{@uri.path}" # `URI.join("https://example.com/foo/bar", "v2")` drops `bar` in the base URL. A trailing slash prevents that. @base_uri << '/' unless @base_uri.end_with? '/' @user = options[:user] @password = options[:password] @http_options = options[:http_options] || {} - @http_options[:open_timeout] ||= options[:open_timeout] || 2 - @http_options[:read_timeout] ||= options[:read_timeout] || 5 + apply_timeout_defaults(options) + @connection = nil end - def doget(url) - doreq 'get', url + def doget(url, accept: nil) + doreq 'get', url, nil, nil, accept: accept end def doput(url, payload = nil) @@ -56,14 +62,14 @@ def paginate_doget(url) # The next URL in the Link header may be relative to the request URL, or absolute. # URI.join handles both cases nicely. - url = URI.join(response.request.url, next_url) + url = URI.join(response.request_url, next_url) end end def search(query = '') all_repos = [] paginate_doget('v2/_catalog') do |response| - repos = JSON.parse(response)['repositories'] + repos = JSON.parse(response.body)['repositories'] repos.select! { |repo| repo.match?(/#{query}/) } unless query.empty? all_repos += repos end @@ -81,7 +87,7 @@ def tags(repo, count = nil, last = '', withHashes = false, auto_paginate: false) response = doget "v2/#{repo}/tags/list#{query_vars}" # parse the response - resp = JSON.parse response + resp = JSON.parse response.body # parse out next page link if necessary resp['last'] = last(response.headers[:link]) if response.headers[:link] @@ -108,7 +114,7 @@ def tags(repo, count = nil, last = '', withHashes = false, auto_paginate: false) def manifest(repo, tag) # first get the manifest - response = doget "v2/#{repo}/manifests/#{tag}" + response = doget_with_legacy_fallback("v2/#{repo}/manifests/#{tag}") parsed = JSON.parse response.body manifest = DockerRegistry2::Manifest[parsed] manifest.body = response.body @@ -276,105 +282,36 @@ def manifest_sum(manifest) private - def doreq(type, url, stream = nil, payload = nil) - begin - block = if stream.nil? - nil - else - proc { |response| - response.read_body do |chunk| - stream.write chunk - end - } - end - response = RestClient::Request.execute(@http_options.merge( - method: type, - url: URI.join(@base_uri, url).to_s, - headers: headers(payload: payload), - block_response: block, - payload: payload - )) - rescue SocketError - raise DockerRegistry2::RegistryUnknownException - rescue RestClient::NotFound - raise DockerRegistry2::NotFound, "Image not found at #{@uri.host}" - rescue RestClient::Unauthorized => e - header = e.response.headers[:www_authenticate] - method = header.to_s.downcase.split[0] - case method - when 'basic' - response = do_basic_req(type, url, stream, payload) - when 'bearer' - response = do_bearer_req(type, url, header, stream, payload) - else - raise DockerRegistry2::RegistryUnknownException - end - end - response - end - - def do_basic_req(type, url, stream = nil, payload = nil) - begin - block = if stream.nil? - nil - else - proc { |response| - response.read_body do |chunk| - stream.write chunk - end - } - end - response = RestClient::Request.execute(@http_options.merge( - method: type, - url: URI.join(@base_uri, url).to_s, - user: @user, - password: @password, - headers: headers(payload: payload), - block_response: block, - payload: payload - )) - rescue SocketError + def doreq(type, url, stream = nil, payload = nil, **request_options) + response = perform_request(type, url, payload: payload, stream: stream, **request_options) + return handle_error_response(response, unauthorized_exception: DockerRegistry2::RegistryAuthenticationException) unless response.code == 401 + + header = response.headers[:www_authenticate] + method = header.to_s.downcase.split[0] + case method + when 'basic' + do_basic_req(type, url, stream, payload, **request_options) + when 'bearer' + do_bearer_req(type, url, header, stream: stream, payload: payload, **request_options) + else raise DockerRegistry2::RegistryUnknownException - rescue RestClient::Unauthorized - raise DockerRegistry2::RegistryAuthenticationException - rescue RestClient::MethodNotAllowed - raise DockerRegistry2::InvalidMethod - rescue RestClient::NotFound => e - raise DockerRegistry2::NotFound, e end - response end - def do_bearer_req(type, url, header, stream = false, payload = nil) - token = authenticate_bearer(header) - begin - block = if stream.nil? - nil - else - proc { |response| - response.read_body do |chunk| - stream.write chunk - end - } - end - response = RestClient::Request.execute(@http_options.merge( - method: type, - url: URI.join(@base_uri, url).to_s, - headers: headers(payload: payload, bearer_token: token), - block_response: block, - payload: payload - )) - rescue SocketError - raise DockerRegistry2::RegistryUnknownException - rescue RestClient::Unauthorized - raise DockerRegistry2::RegistryAuthenticationException - rescue RestClient::MethodNotAllowed - raise DockerRegistry2::InvalidMethod - rescue RestClient::NotFound => e - raise DockerRegistry2::NotFound, e - end + def do_basic_req(type, url, stream = nil, payload = nil, **request_options) + response = perform_request(type, url, payload: payload, stream: stream, auth: :basic, **request_options) + handle_error_response(response, unauthorized_exception: DockerRegistry2::RegistryAuthenticationException) + end - response + def do_bearer_req(type, url, header, request_options = {}) + token = authenticate_bearer(header) + response = perform_request(type, url, + payload: request_options[:payload], + stream: request_options[:stream], + auth: :bearer, + bearer_token: token, + **request_options.except(:payload, :stream)) + handle_error_response(response, unauthorized_exception: DockerRegistry2::RegistryAuthenticationException) end def authenticate_bearer(header) @@ -384,26 +321,16 @@ def authenticate_bearer(header) target[:params][:account] = @user if defined? @user && !@user.to_s.strip.empty? # authenticate against the realm uri = URI.parse(target[:realm]) - begin - response = RestClient::Request.execute(@http_options.merge( - method: :get, - url: uri.to_s, headers: { params: target[:params] }, - user: @user, - password: @password - )) - rescue RestClient::Unauthorized, RestClient::Forbidden - # bad authentication - raise DockerRegistry2::RegistryAuthenticationException - rescue RestClient::NotFound => e - raise DockerRegistry2::NotFound, e - end + response = perform_absolute_request(:get, uri.to_s, params: target[:params], auth: :basic) + handle_error_response(response, + unauthorized_exception: DockerRegistry2::RegistryAuthenticationException, + forbidden_exception: DockerRegistry2::RegistryAuthenticationException) # now save the web token - result = JSON.parse(response) + result = JSON.parse(response.body) result['token'] || result['access_token'] end def split_auth_header(header = '') - h = {} h = { params: {} } header.scan(/(\w+)="([^"]+)"/) do |entry| case entry[0] @@ -416,20 +343,213 @@ def split_auth_header(header = '') h end - def headers(payload: nil, bearer_token: nil) + def headers(payload: nil, bearer_token: nil, accept: nil) headers = {} headers['Authorization'] = "Bearer #{bearer_token}" unless bearer_token.nil? - if payload.nil? - headers['Accept'] = - %w[application/vnd.docker.distribution.manifest.v2+json - application/vnd.docker.distribution.manifest.list.v2+json - application/vnd.oci.image.manifest.v1+json - application/vnd.oci.image.index.v1+json - application/json].join(',') - end + headers['Accept'] = accept || default_accept_header if payload.nil? headers['Content-Type'] = 'application/vnd.docker.distribution.manifest.v2+json' unless payload.nil? headers end + + def default_accept_header + %w[application/vnd.docker.distribution.manifest.v2+json + application/vnd.docker.distribution.manifest.list.v2+json + application/vnd.oci.image.manifest.v1+json + application/vnd.oci.image.index.v1+json + application/json].join(',') + end + + def legacy_manifest_accept_header + %w[application/vnd.docker.distribution.manifest.v2+json + application/vnd.docker.distribution.manifest.list.v2+json + application/vnd.docker.distribution.manifest.v1+prettyjws + application/json].join(',') + end + + def connection + @connection ||= build_connection(@base_uri) + end + + def build_connection(base_url) + Faraday.new(base_url, **connection_options) do |faraday| + faraday.response :follow_redirects, + limit: 5, + standards_compliant: true + faraday.adapter :net_http + end + end + + def connection_options + options = symbolize_keys(@http_options).dup + options.delete(:open_timeout) + options.delete(:read_timeout) + + ssl = normalize_ssl_options(options) + request = request_options(options.delete(:request)) + + options[:ssl] = ssl unless ssl.empty? + options[:request] = request unless request.empty? + options + end + + def request_options(request = nil) + options = symbolize_keys(request || {}) + options[:open_timeout] ||= @http_options[:open_timeout] || @http_options['open_timeout'] + options[:timeout] ||= @http_options[:read_timeout] || @http_options['read_timeout'] + options + end + + def normalize_ssl_options(options) + ssl = symbolize_keys(options.delete(:ssl) || {}) + normalize_legacy_verify_ssl!(ssl, options) + ssl_aliases.each do |target_key, source_keys| + source_keys.each do |source_key| + next if source_key == :verify_ssl + next unless options.key?(source_key) + + ssl[target_key] = options.delete(source_key) + end + end + normalize_legacy_client_cert_paths!(ssl) + ssl + end + + def ssl_aliases + { + version: %i[ssl_version], + ca_file: %i[ca_file ssl_ca_file], + ca_path: %i[ca_path ssl_ca_path], + cert_store: %i[cert_store ssl_cert_store], + client_cert: %i[client_cert ssl_client_cert], + client_key: %i[client_key ssl_client_key], + verify_mode: %i[verify_mode] + } + end + + def apply_timeout_defaults(options) + @http_options[:open_timeout] = options[:open_timeout] || 2 unless @http_options.key?(:open_timeout) || @http_options.key?('open_timeout') + @http_options[:read_timeout] = options[:read_timeout] || 5 unless @http_options.key?(:read_timeout) || @http_options.key?('read_timeout') + end + + def normalize_legacy_verify_ssl!(ssl, options) + return unless options.key?(:verify_ssl) + + verify_ssl = options.delete(:verify_ssl) + if verify_ssl.is_a?(Numeric) + ssl[:verify_mode] = verify_ssl + else + ssl[:verify] = verify_ssl + end + end + + def normalize_legacy_client_cert_paths!(ssl) + ssl[:client_cert] = load_client_certificate(ssl[:client_cert]) if ssl[:client_cert].is_a?(String) + ssl[:client_key] = load_client_key(ssl[:client_key]) if ssl[:client_key].is_a?(String) + end + + def load_client_certificate(path) + OpenSSL::X509::Certificate.new(File.read(path)) + end + + def load_client_key(path) + OpenSSL::PKey.read(File.read(path)) + end + + def symbolize_keys(hash) + hash.transform_keys { |key| key.respond_to?(:to_sym) ? key.to_sym : key } + end + + def perform_request(type, url, request_options = {}) + perform(connection, type, url, request_options) + end + + def perform_absolute_request(type, url, request_options = {}) + uri = URI.parse(url) + absolute_connection = build_connection("#{uri.scheme}://#{uri.host}:#{uri.port}") + request_url = uri.request_uri + perform(absolute_connection, type, request_url, request_options) + end + + def perform(conn, type, url, request_options = {}) + request_headers = headers(payload: request_options[:payload], + bearer_token: request_options[:bearer_token], + accept: request_options[:accept]) + response = conn.run_request(type.to_sym, url, request_options[:payload], request_headers) do |request| + request.params.update(request_options[:params]) if request_options[:params] + request.options.on_data = stream_handler(request_options[:stream]) if request_options[:stream] + apply_auth!(request, request_options[:auth]) + end + + normalize_response(response, stream: request_options[:stream]) + rescue Faraday::SSLError + raise DockerRegistry2::RegistrySSLException + rescue Faraday::TimeoutError, Faraday::ConnectionFailed, SocketError + raise DockerRegistry2::RegistryUnknownException + end + + def apply_auth!(request, auth) + case auth + when :basic + return if @user.to_s.empty? && @password.to_s.empty? + + token = Base64.strict_encode64([@user, @password].join(':')) + request.headers['Authorization'] = "Basic #{token}" + when nil, :bearer + nil + else + raise ArgumentError, "Unsupported auth strategy: #{auth}" + end + end + + def stream_handler(stream) + proc do |chunk, _overall_received_bytes, env| + status = env.status.to_i + stream.write(chunk) if status >= 200 && status < 300 + end + end + + def normalize_response(response, stream: nil) + DockerRegistry2::Response.new( + body: stream.nil? ? response.body : nil, + headers: normalize_headers(response.headers), + code: response.status, + request_url: response.env.url.to_s + ) + end + + def normalize_headers(raw_headers) + headers = {} + raw_headers.each do |key, value| + normalized_key = key.to_s.tr('-', '_').downcase.to_sym + headers[normalized_key] = value + end + headers + end + + def handle_error_response(response, unauthorized_exception:, forbidden_exception: nil) + case response.code + when 200..299 + response + when 401 + raise unauthorized_exception + when 403 + raise(forbidden_exception || DockerRegistry2::RegistryAuthorizationException) + when 404 + raise DockerRegistry2::NotFound, "Image not found at #{@uri.host}" + when 405 + raise DockerRegistry2::InvalidMethod + else + raise DockerRegistry2::RegistryHTTPException, "Registry request failed with status #{response.code}" + end + end + + def doget_with_legacy_fallback(url) + doget(url) + rescue DockerRegistry2::RegistryHTTPException => e + raise e unless e.message.include?('status 500') + + doget(url, accept: legacy_manifest_accept_header) + end end end diff --git a/lib/registry/version.rb b/lib/registry/version.rb index ac3c37a..2f392aa 100644 --- a/lib/registry/version.rb +++ b/lib/registry/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module DockerRegistry2 - VERSION = '1.18.2' + VERSION = '1.19.0' end diff --git a/spec/docker_registry2_spec.rb b/spec/docker_registry2_spec.rb index 91f8fbf..9749764 100644 --- a/spec/docker_registry2_spec.rb +++ b/spec/docker_registry2_spec.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require 'tmpdir' +require 'openssl' require_relative '../lib/docker_registry2' RSpec.describe DockerRegistry2 do @@ -82,6 +84,42 @@ it { expect(archs).to match_array(%w[amd64 arm64]) } end + it 'retries manifest requests with the legacy Docker accept header after a 500 response' do + manifest_url = 'http://localhost:5000/v2/hello-world-v1/manifests/latest' + manifest_body = { + 'schemaVersion' => 1, + 'name' => 'hello-world-v1', + 'tag' => 'latest', + 'fsLayers' => [{ 'blobSum' => 'sha256:abc123' }] + }.to_json + modern_accept = [ + 'application/vnd.docker.distribution.manifest.v2+json', + 'application/vnd.docker.distribution.manifest.list.v2+json', + 'application/vnd.oci.image.manifest.v1+json', + 'application/vnd.oci.image.index.v1+json', + 'application/json' + ].join(',') + legacy_accept = [ + 'application/vnd.docker.distribution.manifest.v2+json', + 'application/vnd.docker.distribution.manifest.list.v2+json', + 'application/vnd.docker.distribution.manifest.v1+prettyjws', + 'application/json' + ].join(',') + + stub_request(:get, manifest_url) + .with(headers: { 'Accept' => modern_accept }) + .to_return(status: 500, body: 'registry error') + stub_request(:get, manifest_url) + .with(headers: { 'Accept' => legacy_accept }) + .to_return(status: 200, body: manifest_body, headers: { 'Content-Type' => 'application/json' }) + + manifest = VCR.turned_off { connected_object.manifest('hello-world-v1', 'latest') } + + expect(manifest['schemaVersion']).to eq(1) + expect(a_request(:get, manifest_url).with(headers: { 'Accept' => modern_accept })).to have_been_made.once + expect(a_request(:get, manifest_url).with(headers: { 'Accept' => legacy_accept })).to have_been_made.once + end + context 'Docker registry without path' do let(:uri) { 'https://example.com' } let(:registry) { DockerRegistry2::Registry.new(uri) } @@ -174,4 +212,223 @@ end end end + + describe '#blob' do + let(:uri) { 'https://registry.example.com' } + let(:registry) { DockerRegistry2::Registry.new(uri, user: 'me', password: 'secret') } + let(:blob_url) { "#{uri}/v2/private/repo/blobs/sha256:abc123" } + let(:auth_header) { "Basic #{Base64.strict_encode64('me:secret')}" } + + it 'does not write the unauthenticated challenge body into streamed blob files' do + stub_request(:get, blob_url) + .to_return( + status: 401, + body: 'auth required', + headers: { 'WWW-Authenticate' => 'Basic realm="Registry"' } + ).then + .to_return( + status: 200, + body: 'real blob data', + headers: { 'Content-Length' => '14' } + ) + + Dir.mktmpdir do |dir| + path = File.join(dir, 'blob.bin') + + registry.blob('private/repo', 'sha256:abc123', path) + + expect(File.binread(path)).to eq('real blob data') + end + + expect(a_request(:get, blob_url).with(headers: { 'Authorization' => auth_header })).to have_been_made.once + end + + it 'follows redirected blob downloads' do + redirected_url = 'https://storage.example.com/downloads/sha256:abc123' + + stub_request(:get, blob_url) + .to_return(status: 307, headers: { 'Location' => redirected_url }) + stub_request(:get, redirected_url) + .to_return(status: 200, body: 'redirected blob data') + + blob = VCR.turned_off { registry.blob('private/repo', 'sha256:abc123') } + + expect(blob.body).to eq('redirected blob data') + end + + it 'drops authorization on cross-host redirects' do + redirected_url = 'https://canonical.example.com/downloads/sha256:abc123' + + stub_request(:get, blob_url) + .to_return( + status: 401, + body: 'auth required', + headers: { 'WWW-Authenticate' => 'Basic realm="Registry"' } + ).then + .to_return(status: 307, headers: { 'Location' => redirected_url }) + stub_request(:get, blob_url) + .with(headers: { 'Authorization' => auth_header }) + .to_return(status: 307, headers: { 'Location' => redirected_url }) + stub_request(:get, redirected_url) + .to_return(status: 200, body: 'redirected blob data') + + blob = VCR.turned_off { registry.blob('private/repo', 'sha256:abc123') } + + expect(blob.body).to eq('redirected blob data') + expect(a_request(:get, redirected_url).with(headers: { 'Authorization' => auth_header })).not_to have_been_made + end + end + + describe '#tag' do + let(:uri) { 'https://registry.example.com' } + let(:registry) { DockerRegistry2::Registry.new(uri) } + let(:manifest_url) { "#{uri}/v2/source/repo/manifests/latest" } + let(:redirected_tag_url) { 'https://canonical.example.com/v2/destination/repo/manifests/release' } + let(:tag_url) { "#{uri}/v2/destination/repo/manifests/release" } + let(:manifest_body) { { 'schemaVersion' => 2, 'config' => { 'digest' => 'sha256:abc123' } }.to_json } + + it 'preserves PUT when following redirects for tag writes' do + stub_request(:get, manifest_url) + .to_return(status: 200, body: manifest_body, headers: { 'Content-Type' => 'application/json' }) + + stub_request(:put, tag_url) + .to_return(status: 301, headers: { 'Location' => redirected_tag_url }) + + stub_request(:put, redirected_tag_url) + .with(body: manifest_body) + .to_return(status: 201, body: '') + + registry.tag('source/repo', 'latest', 'destination/repo', 'release') + + expect(a_request(:put, redirected_tag_url).with(body: manifest_body)).to have_been_made.once + expect(a_request(:get, redirected_tag_url)).not_to have_been_made + end + end + + describe 'unexpected HTTP errors' do + let(:uri) { 'https://registry.example.com' } + let(:registry) { DockerRegistry2::Registry.new(uri) } + + it 'raises for server errors instead of parsing the response body' do + stub_request(:get, "#{uri}/v2/_catalog") + .to_return(status: 500, body: 'upstream error') + + expect { registry.search('hello-world') } + .to raise_error(DockerRegistry2::RegistryHTTPException, 'Registry request failed with status 500') + end + + it 'raises for rate-limited tag requests' do + stub_request(:get, "#{uri}/v2/private/repo/tags/list") + .to_return(status: 429, body: 'slow down') + + expect { registry.tags('private/repo') } + .to raise_error(DockerRegistry2::RegistryHTTPException, 'Registry request failed with status 429') + end + end + + describe 'http_options compatibility' do + let(:tls_version) { 'TLSv1_2' } + + let(:registry) do + DockerRegistry2::Registry.new( + 'https://registry.example.com', + open_timeout: 2, + read_timeout: 5, + http_options: { + 'proxy' => 'http://proxy.example.com:8080', + 'headers' => { 'X-Test' => '1' }, + 'params' => { 'ns' => 'team' }, + 'verify_ssl' => false, + 'ssl_version' => tls_version, + 'ssl_ca_file' => '/tmp/ca.pem', + 'ssl_ca_path' => '/tmp/certs', + 'ssl_cert_store' => 'custom-store', + 'request' => { 'timeout' => 15 } + } + ) + end + + it 'preserves caller-supplied Faraday connection options' do + options = registry.send(:connection_options) + + expect(options[:proxy]).to eq('http://proxy.example.com:8080') + expect(options[:headers]).to eq('X-Test' => '1') + expect(options[:params]).to eq('ns' => 'team') + end + + it 'maps legacy top-level ssl options into Faraday ssl settings' do + options = registry.send(:connection_options) + + expect(options[:ssl]).to include( + verify: false, + version: tls_version, + ca_file: '/tmp/ca.pem', + ca_path: '/tmp/certs', + cert_store: 'custom-store' + ) + end + + it 'maps numeric verify_ssl modes to verify_mode instead of boolean verify' do + numeric_verify_registry = DockerRegistry2::Registry.new( + 'https://registry.example.com', + http_options: { 'verify_ssl' => OpenSSL::SSL::VERIFY_NONE } + ) + + options = numeric_verify_registry.send(:connection_options) + + expect(options[:ssl]).to include(verify_mode: OpenSSL::SSL::VERIFY_NONE) + expect(options[:ssl]).not_to have_key(:verify) + end + + it 'merges request timeouts without discarding caller request options' do + options = registry.send(:connection_options) + + expect(options[:request]).to include(timeout: 15, open_timeout: 2) + end + + it 'preserves string-keyed timeout overrides from http_options' do + string_timeout_registry = DockerRegistry2::Registry.new( + 'https://registry.example.com', + http_options: { 'open_timeout' => 10, 'read_timeout' => 20 } + ) + + options = string_timeout_registry.send(:connection_options) + + expect(options[:request]).to include(open_timeout: 10, timeout: 20) + end + + it 'loads legacy mTLS file paths into OpenSSL objects' do + key = OpenSSL::PKey::RSA.new(2048) + name = OpenSSL::X509::Name.parse('/CN=registry.example.com') + cert = OpenSSL::X509::Certificate.new + cert.version = 2 + cert.serial = 1 + cert.subject = name + cert.issuer = name + cert.public_key = key.public_key + cert.not_before = Time.now + cert.not_after = Time.now + 3600 + cert.sign(key, OpenSSL::Digest.new('SHA256')) + + Dir.mktmpdir do |dir| + cert_path = File.join(dir, 'client.crt') + key_path = File.join(dir, 'client.key') + File.write(cert_path, cert.to_pem) + File.write(key_path, key.to_pem) + + mtls_registry = DockerRegistry2::Registry.new( + 'https://registry.example.com', + http_options: { + 'ssl_client_cert' => cert_path, + 'ssl_client_key' => key_path + } + ) + + options = mtls_registry.send(:connection_options) + + expect(options[:ssl][:client_cert]).to be_a(OpenSSL::X509::Certificate) + expect(options[:ssl][:client_key]).to be_a(OpenSSL::PKey::RSA) + end + end + end end diff --git a/test.sh b/test.sh index 5ed9b1d..6143f1c 100755 --- a/test.sh +++ b/test.sh @@ -54,7 +54,7 @@ run_tests() { TASK=$1 NETNAME=registry-test -IMG=ruby:3.1.1-alpine +IMG=ruby:3.4.9-alpine case $TASK in testonly)