docker_distribution 0.1.1 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fc6f4c883560213fd738e4f724ec846985b89a54daeae499d302320ad17e91c1
4
- data.tar.gz: 6a1cb6d5549de71c1882db7752df770f79acbe733a9416a2e956e4b74cb98998
3
+ metadata.gz: df29a563df5c3905dee99b74d6b1dfef79175c73a27ad016e553391cfa026ed7
4
+ data.tar.gz: c4bcaf72d7525f20042b5cc91a22e6f8ac18692cd31f7744dfca6d0012929201
5
5
  SHA512:
6
- metadata.gz: 8bfe97167c18b19eca3abe3245bf864ce1f1ae745e4df99f63e5bb0d1b45db6f14ee6ec3519c8566a343c1f3f9e3737867b146842464db093eaa915f20779ccb
7
- data.tar.gz: 43706fbd4f9afead0036f4b3871f41e817b79824b7295a489e61bda5c0e0038d5bbe94c8ad633b536b41b88a8ab55876912515b45b7402444d716d98b7951fc8
6
+ metadata.gz: 6ab3c059d0149abaf42364f194b37923e33316fae3e5395991f8ecf9a462ac69245387cea1dcbafa58ad043e05202cca32cea3e580e67f1476a1b67b6fae6d3c
7
+ data.tar.gz: dce9c44d2c73b1b5982d1909de563227c21f8e2d499916f3d29cfb28d6c8a0796c189107131601ae7d0a6217d5bb4a0294266d9b3cce160ac917cd1e2b77d6b0
data/README.md ADDED
@@ -0,0 +1,108 @@
1
+ # DockerDistribution
2
+
3
+ Implementation of OCI Distribution Specification on Ruby. Implementation copied from Golang version which used in Docker Cli and Kubectl Cli.
4
+
5
+ [OCI Distribution Specification](https://github.com/opencontainers/distribution-spec)
6
+
7
+ [Golang implementation](https://github.com/docker/distribution/tree/main/reference)
8
+
9
+
10
+ ## Installation
11
+
12
+ ```
13
+ gem install docker_distribution
14
+ ```
15
+
16
+ ## Usage
17
+
18
+ ### Reference
19
+ * Add description from implementation comments
20
+ ```
21
+ DockerDistribution::Reference.parse(image_name)
22
+ DockerDistribution::Reference.parse_named(str)
23
+ DockerDistribution::Reference.with_name(name)
24
+ DockerDistribution::Reference.with_tag(named, tag)
25
+ DockerDistribution::Reference.with_digest(named, digest)
26
+ DockerDistribution::Reference.split_domain(name)
27
+ DockerDistribution::Reference.split_hostname(named)
28
+ ```
29
+
30
+ ### Normalize
31
+ * Add description from implementation comments
32
+ ```
33
+ DockerDistribution::Normalize.parse_normalized_named(str)
34
+ DockerDistribution::Normalize.parse_docker_ref(ref)
35
+ DockerDistribution::Normalize.split_docker_domain(name)
36
+ DockerDistribution::Normalize.familiarize_name(repo)
37
+ DockerDistribution::Normalize.tag_name_only(ref)
38
+ DockerDistribution::Normalize.parse_any_reference(ref)
39
+ DockerDistribution::Normalize.parse_any_reference_with_set(ref, digest_set)
40
+ ```
41
+
42
+ ### Regexp
43
+ * Add description from implementation comments
44
+ ```
45
+ DockerDistribution::Regexp.alpha_numeric
46
+ DockerDistribution::Regexp.separator
47
+ DockerDistribution::Regexp.name_component
48
+ DockerDistribution::Regexp.domain_name_component
49
+ DockerDistribution::Regexp.ipv6address
50
+ DockerDistribution::Regexp.domain_name
51
+ DockerDistribution::Regexp.host
52
+ DockerDistribution::Regexp.domain
53
+ DockerDistribution::Regexp.domain_regexp
54
+ DockerDistribution::Regexp.tag
55
+ DockerDistribution::Regexp.tag_regexp
56
+ DockerDistribution::Regexp.anchored_tag
57
+ DockerDistribution::Regexp.anchored_tag_regexp
58
+ DockerDistribution::Regexp.digest_pat
59
+ DockerDistribution::Regexp.digest_regexp
60
+ DockerDistribution::Regexp.anchored_digest
61
+ DockerDistribution::Regexp.anchored_digest_regexp
62
+ DockerDistribution::Regexp.name_pat
63
+ DockerDistribution::Regexp.name_regexp
64
+ DockerDistribution::Regexp.anchored_name
65
+ DockerDistribution::Regexp.anchored_name_regexp
66
+ DockerDistribution::Regexp.reference_pat
67
+ DockerDistribution::Regexp.reference_regexp
68
+ DockerDistribution::Regexp.identifier
69
+ DockerDistribution::Regexp.identifier_regexp
70
+ DockerDistribution::Regexp.short_identifier
71
+ DockerDistribution::Regexp.short_identifier_regexp
72
+ DockerDistribution::Regexp.anchored_identifier
73
+ DockerDistribution::Regexp.anchored_identifier_regexp
74
+ DockerDistribution::Regexp.anchored_short_identifier
75
+ DockerDistribution::Regexp.anchored_short_identifier_regexp
76
+ ```
77
+
78
+ ### Digest
79
+ * Add description from implementation comments
80
+ ```
81
+ DockerDistribution::Digest.parse(digest_string)
82
+ ```
83
+
84
+ ## Examples
85
+ ...
86
+
87
+ More examples in tests
88
+
89
+ ## Development
90
+ ```
91
+ docker-compose build gem
92
+ docker-compose run --rm gem bash
93
+ bundle install
94
+ ...
95
+ make test
96
+ make lint
97
+ ```
98
+
99
+ ## Release
100
+ 1. [Read this](https://guides.rubygems.org/publishing/)
101
+ 2. Make sure you logged in into Rubygems
102
+ 3. Update version accoring [semver](https://semver.org/)
103
+ 4. `gem build docker_distribution.gemspec`
104
+ 5. `gem push docker_distribution-[version].gem`
105
+
106
+ ## Todo
107
+ - Finish readme description / examples / installation guide / development guide / examples / ruby version support
108
+ - Add correct workflow in github to run tests and linters in docker env
data/Rakefile ADDED
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rake/testtask"
5
+
6
+ Rake::TestTask.new(:test) do |t|
7
+ t.libs << "test"
8
+ t.libs << "lib"
9
+ t.test_files = FileList["test/**/test_*.rb"]
10
+ end
11
+
12
+ require "rubocop/rake_task"
13
+
14
+ RuboCop::RakeTask.new
15
+
16
+ task default: %i[test rubocop]
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DockerDistribution
4
+ class CanonicalReference
5
+ extend ::Forwardable
6
+ def_delegators :repository, :name, :domain, :path
7
+
8
+ attr_accessor :repository, :digest
9
+
10
+ def initialize(repository, digest)
11
+ @repository = repository
12
+ @digest = digest
13
+ end
14
+
15
+ def to_s
16
+ [repository.name, digest].join("@")
17
+ end
18
+
19
+ def to_h
20
+ {
21
+ repository: name,
22
+ domain: domain,
23
+ path: path,
24
+ digest: digest
25
+ }
26
+ end
27
+
28
+ def familiar
29
+ self.class.new(Normalize.familiarize_name(self), digest)
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module DockerDistribution
6
+ class Digest
7
+ DEFAULT_ALGORITHM = "sha256"
8
+ attr_accessor :digest_string, :encoded, :algorithm_type
9
+
10
+ # Parse parses digest_string and returns the validated digest object. An error will
11
+ # be raised if the format is invalid.
12
+ def self.parse!(digest_string)
13
+ dgst = new(digest_string)
14
+ dgst.validate!
15
+ dgst
16
+ end
17
+
18
+ def initialize(digest_string)
19
+ @digest_string = digest_string
20
+ @algorithm_type, @encoded = digest_string.index(":") ? digest_string.split(":") : [DEFAULT_ALGORITHM, digest_string]
21
+ end
22
+
23
+ def digest
24
+ [algorithm_type, encoded].compact.join(":")
25
+ end
26
+
27
+ def to_s
28
+ digest
29
+ end
30
+
31
+ # rubocop:disable Metrics/AbcSize
32
+ def validate!
33
+ index = digest_string.index(":")
34
+ raise DigestInvalidFormat if index.nil? || index + 1 == digest_string.length
35
+
36
+ raise DigestInvalidLength if algorithm.block_length != encoded.length
37
+
38
+ return true if Regexp.anchored_encoded_regexp(algorithm_type.upcase).match?(encoded)
39
+
40
+ raise DigestInvalidFormat
41
+ end
42
+ # rubocop:enable Metrics/AbcSize
43
+
44
+ def algorithm
45
+ send(:Digest, algorithm_type.upcase).new
46
+ rescue NameError, LoadError
47
+ raise DigestUnsupported
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DockerDistribution
4
+ class DigestReference
5
+ attr_accessor :digest
6
+
7
+ def initialize(digest)
8
+ @digest = digest
9
+ end
10
+
11
+ def to_s
12
+ digest
13
+ end
14
+
15
+ def to_h
16
+ { digest: digest }
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module DockerDistribution
6
+ class DigestSet
7
+ attr_writer :entries
8
+
9
+ def initialize(digest_strings)
10
+ @entries = digest_strings.map { |digest_string| Digest.new(digest_string) }
11
+ end
12
+
13
+ def entries
14
+ @entries ||= []
15
+ end
16
+
17
+ def add(digest_string)
18
+ @entries << Digest.new(digest_string)
19
+ end
20
+
21
+ def lookup!(digest_string)
22
+ raise DigestNotFound if entries.length.zero?
23
+
24
+ search_func, encoded, alg = parse!(digest_string)
25
+
26
+ entry = search(search_func, encoded, alg)
27
+ entry.digest
28
+ end
29
+
30
+ private
31
+
32
+ # rubocop:disable Metrics/MethodLength, Metrics/AbcSize
33
+ def parse!(digest_string)
34
+ dgst = Digest.parse!(digest_string)
35
+ alg = dgst.algorithm_type
36
+ encoded = dgst.encoded
37
+ search_func = lambda do |i|
38
+ return entries[i].algorithm_type >= dgst.algorithm_type if entries[i].encoded == encoded
39
+
40
+ entries[i].encoded >= encoded
41
+ end
42
+ [search_func, encoded, alg]
43
+ rescue DigestInvalidFormat
44
+ alg = Digest::DEFAULT_ALGORITHM
45
+ encoded = digest_string
46
+ search_func = lambda do |i|
47
+ entries[i].encoded >= digest_string
48
+ end
49
+ [search_func, encoded, alg]
50
+ end
51
+ # rubocop:enable Metrics/MethodLength, Metrics/AbcSize
52
+
53
+ # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
54
+ def search(search_func, encoded, alg)
55
+ idx = entries.each_with_index.filter_map { |_el, i| i if search_func.call(i) }.first || entries.length
56
+ raise DigestNotFound if idx == entries.length || !check_short_match(entries[idx].algorithm_type, entries[idx].encoded, alg.to_s,
57
+ encoded)
58
+ return entries[idx] if entries[idx].algorithm_type == alg && entries[idx].encoded == encoded
59
+
60
+ raise DigestAmbiguous if idx + 1 < entries.length && check_short_match(entries[idx + 1].algorithm_type, entries[idx + 1].encoded,
61
+ alg.to_s, encoded)
62
+
63
+ entries[idx]
64
+ end
65
+ # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
66
+
67
+ # rubocop:disable Metrics/CyclomaticComplexity
68
+ def check_short_match(alg, encoded, short_alg, short_encoded)
69
+ if encoded.length == short_encoded.length
70
+ return false if encoded != short_encoded
71
+
72
+ return false if short_alg.length.positive? && alg != short_alg
73
+ end
74
+
75
+ return false unless encoded.start_with?(short_encoded)
76
+
77
+ return false if short_alg.length.positive? && alg != short_alg
78
+
79
+ true
80
+ end
81
+ # rubocop:enable Metrics/CyclomaticComplexity
82
+ end
83
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DockerDistribution
4
+ # NameEmpty is raised for empty, invalid repository names.
5
+ class NameEmpty < StandardError; end
6
+ # ReferenceInvalidFormat represents an error while trying to parse a string as a reference.
7
+ class ReferenceInvalidFormat < StandardError; end
8
+ # TagInvalidFormat represents an error while trying to parse a string as a tag.
9
+ class TagInvalidFormat < StandardError; end
10
+ # DigestError raised when something wrong with Digest
11
+ class DigestError < StandardError; end
12
+ # DigestInvalidLength raised when digest has invalid length.
13
+ class DigestInvalidLength < DigestError; end
14
+ # DigestInvalidFormat raised when digest format invalid.
15
+ class DigestInvalidFormat < DigestError; end
16
+ # DigestUnsupported raised when the digest algorithm is unsupported.
17
+ class DigestUnsupported < DigestError; end
18
+ # DigestNotFound raised when the digest set not found
19
+ class DigestNotFound < DigestError; end
20
+ # DigestAmbiguous raised when the digest set has two same digests
21
+ class DigestAmbiguous < DigestError; end
22
+ # NameContainsUppercase is raised for invalid repository names that contain uppercase characters.
23
+ class NameContainsUppercase < StandardError; end
24
+ # NameTooLong is raised when a repository name is longer than 255 symbols.
25
+ class NameTooLong < StandardError; end
26
+ # NameNotCanonical is raised when a name is not canonical.
27
+ class NameNotCanonical < StandardError; end
28
+ # # ParseNormalizedNamedError is raised when a parse normalized error failed
29
+ class ParseNormalizedNamedError < StandardError; end
30
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DockerDistribution
4
+ class Helpers
5
+ class << self
6
+ def familiar_name(named)
7
+ return named.familiar.name if named.respond_to?(:familiar)
8
+
9
+ named.name
10
+ end
11
+
12
+ def familiar_string(named)
13
+ return named.familiar.to_s if named.respond_to?(:familiar)
14
+
15
+ named.to_s
16
+ end
17
+
18
+ def familiar_match(pattern, ref)
19
+ string_match = File.fnmatch(pattern, familiar_string(ref), File::FNM_PATHNAME)
20
+ name_match = File.fnmatch(pattern, familiar_name(ref), File::FNM_PATHNAME)
21
+
22
+ string_match || name_match
23
+ end
24
+
25
+ def tagged?(ref)
26
+ ref.respond_to?(:tag)
27
+ end
28
+
29
+ def canonical?(ref)
30
+ ref.respond_to?(:digest)
31
+ end
32
+
33
+ def named?(ref)
34
+ ref.respond_to?(:name)
35
+ end
36
+
37
+ def familiar?(ref)
38
+ ref.respond_to?(:familiar)
39
+ end
40
+
41
+ def name_only?(ref)
42
+ named?(ref) && !tagged?(ref) && !canonical?(ref)
43
+ end
44
+
45
+ def normalized_named?(ref)
46
+ named?(ref) && familiar?(ref)
47
+ end
48
+
49
+ def from(str, index)
50
+ str[index..]
51
+ end
52
+
53
+ def to(str, index)
54
+ str[0...index]
55
+ end
56
+
57
+ def empty?(object)
58
+ object == "" || object.nil?
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DockerDistribution
4
+ class Normalize
5
+ LEGACY_DEFAULT_DOMAIN = "index.docker.io"
6
+ DEFAULT_DOMAIN = "docker.io"
7
+ OFFICIAL_REPO_NAME = "library"
8
+ DEFAULT_TAG = "latest"
9
+
10
+ class << self
11
+ # ParseNormalizedNamed parses a string into a named reference
12
+ # transforming a familiar name from Docker UI to a fully
13
+ # qualified reference. If the value may be an identifier
14
+ # use ParseAnyReference.
15
+ def parse_normalized_named(str)
16
+ raise ParseNormalizedNamedError if Regexp.anchored_identifier_regexp.match?(str)
17
+
18
+ domain, remainder = split_docker_domain(str)
19
+
20
+ tag_sep = remainder.index(":") || -1
21
+ remote_name = tag_sep > -1 ? Helpers.to(remainder, tag_sep) : remainder
22
+ raise ParseNormalizedNamedError if remote_name != remote_name.downcase
23
+
24
+ ref = DockerDistribution::Reference.parse("#{domain}/#{remainder}")
25
+ raise ParseNormalizedNamedError if Helpers.empty?(ref)
26
+
27
+ ref
28
+ end
29
+
30
+ # ParseDockerRef normalizes the image reference following the docker convention. This is added
31
+ # mainly for backward compatibility.
32
+ # The reference returned can only be either tagged or digested. For reference contains both tag
33
+ # and digest, the function returns digested reference, e.g. docker.io/library/busybox:latest@
34
+ # sha256:7cc4b5aefd1d0cadf8d97d4350462ba51c694ebca145b08d7d41b41acc8db5aa will be returned as
35
+ # docker.io/library/busybox@sha256:7cc4b5aefd1d0cadf8d97d4350462ba51c694ebca145b08d7d41b41acc8db5aa.
36
+ def parse_docker_ref(ref)
37
+ named = parse_normalized_named(ref)
38
+ if Helpers.tagged?(named) && Helpers.canonical?(named)
39
+ new_named = Reference.with_name(named.name)
40
+ new_canonical = Reference.with_digest(new_named, named.digest)
41
+ return new_canonical
42
+ end
43
+
44
+ tag_name_only(named)
45
+ end
46
+
47
+ # splitDockerDomain splits a repository name to domain and remote name string.
48
+ # If no valid domain is found, the default domain is used. Repository name
49
+ # needs to be already validated before.
50
+ # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
51
+ def split_docker_domain(name)
52
+ i = name.index("/") || -1
53
+
54
+ start_part = Helpers.to(name, i)
55
+ end_part = Helpers.from(name, i + 1)
56
+
57
+ if (i == -1 || [".", ":"].none? do |sep|
58
+ start_part.include?(sep)
59
+ end) && start_part != "localhost" && start_part.downcase == start_part
60
+ domain = DEFAULT_DOMAIN
61
+ remainder = name
62
+ else
63
+ domain = start_part
64
+ remainder = end_part
65
+ end
66
+
67
+ domain = DEFAULT_DOMAIN if domain == LEGACY_DEFAULT_DOMAIN
68
+ remainder = [OFFICIAL_REPO_NAME, remainder].join("/") if domain == DEFAULT_DOMAIN && !remainder.include?("/")
69
+
70
+ [domain, remainder]
71
+ end
72
+ # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
73
+
74
+ # familiarizeName returns a shortened version of the name familiar
75
+ # to to the Docker UI. Familiar names have the default domain
76
+ # "docker.io" and "library/" repository prefix removed.
77
+ # For example, "docker.io/library/redis" will have the familiar
78
+ # name "redis" and "docker.io/dmcgowan/myapp" will be "dmcgowan/myapp".
79
+ # Returns a familiarized named only reference.
80
+ def familiarize_name(repo)
81
+ familiar_repo = Repository.new(repo.domain, repo.path)
82
+
83
+ if familiar_repo.domain == DEFAULT_DOMAIN
84
+ familiar_repo.domain = nil
85
+ parts = familiar_repo.path.split("/")
86
+ familiar_repo.path = parts[1] if parts.length == 2 && parts[0] == OFFICIAL_REPO_NAME
87
+ end
88
+ familiar_repo
89
+ end
90
+
91
+ # TagNameOnly adds the default tag "latest" to a reference if it only has
92
+ # a repo name.
93
+ def tag_name_only(ref)
94
+ return Reference.with_tag(ref, DEFAULT_TAG) if Helpers.name_only?(ref)
95
+
96
+ ref
97
+ end
98
+
99
+ # ParseAnyReference parses a reference string as a possible identifier,
100
+ # full digest, or familiar name.
101
+ def parse_any_reference(ref)
102
+ return DigestReference.new("sha256:#{ref}") if Regexp.anchored_identifier_regexp.match?(ref)
103
+
104
+ digest = Digest.parse!(ref)
105
+ DigestReference.new(digest.digest)
106
+ rescue DigestError
107
+ parse_normalized_named(ref)
108
+ end
109
+
110
+ # ParseAnyReferenceWithSet parses a reference string as a possible short
111
+ # identifier to be matched in a digest set, a full digest, or familiar name.
112
+ def parse_any_reference_with_set(ref, digest_set)
113
+ if Regexp.anchored_short_identifier_regexp.match?(ref)
114
+ dgst = digest_set.lookup!(ref)
115
+ return DigestReference.new(dgst) if dgst
116
+ else
117
+ dgst = Digest.parse!(ref)
118
+ DigestReference.new(dgst.digest)
119
+ end
120
+ rescue DigestError
121
+ parse_normalized_named(ref)
122
+ end
123
+ end
124
+ end
125
+ end
@@ -0,0 +1,178 @@
1
+ # frozen_string_literal: true
2
+
3
+ # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity, Naming/MethodParameterName
4
+ module DockerDistribution
5
+ class Reference
6
+ MAX_NAME_TOTAL_LENGTH = 255
7
+ extend ::Forwardable
8
+ def_delegators :repository, :name, :domain, :path
9
+
10
+ attr_accessor :repository, :tag, :digest
11
+
12
+ def initialize(repository = nil, tag = nil, digest = nil)
13
+ @repository = repository
14
+ @tag = tag
15
+ @digest = digest
16
+ end
17
+
18
+ def to_s
19
+ [name, ":", tag, "@", digest].join("")
20
+ end
21
+
22
+ def to_h
23
+ {
24
+ repository: name,
25
+ domain: domain,
26
+ path: path,
27
+ tag: tag,
28
+ digest: digest
29
+ }
30
+ end
31
+
32
+ def familiar
33
+ self.class.new(Normalize.familiarize_name(self), tag, digest)
34
+ end
35
+
36
+ # Parse parses s and returns a syntactically valid Reference.
37
+ # If an error was encountered it is returned, along with a nil Reference.
38
+ # NOTE: Parse will not handle short digests.
39
+ def self.parse(s)
40
+ matches = Regexp.reference_regexp.match(s)
41
+ if matches.nil?
42
+ raise NameEmpty if Helpers.empty?(s)
43
+ raise NameContainsUppercase unless Regexp.reference_regexp.match(s.downcase).nil?
44
+
45
+ raise ReferenceInvalidFormat
46
+ end
47
+
48
+ match_results = matches.to_a
49
+ raise NameTooLong if match_results[1] && match_results[1].length > MAX_NAME_TOTAL_LENGTH
50
+
51
+ repo = Repository.new
52
+ name_match = Regexp.anchored_name_regexp.match(match_results[1])
53
+ name_match_results = name_match&.to_a
54
+
55
+ if name_match_results && name_match_results.length == 3
56
+ repo.domain = name_match_results[1]
57
+ repo.path = name_match_results[2]
58
+ else
59
+ repo.domain = nil
60
+ repo.path = name_match_results[1]
61
+ end
62
+
63
+ ref = Reference.new(repo, match_results[2])
64
+
65
+ unless Helpers.empty?(match_results[3])
66
+ digest = Digest.parse!(match_results[3])
67
+ ref.digest = digest.digest
68
+ end
69
+
70
+ r = get_best_reference_type(ref)
71
+ raise NameEmpty if r.nil?
72
+
73
+ r
74
+ end
75
+
76
+ # ParseNamed parses s and returns a syntactically valid reference implementing
77
+ # the Named interface. The reference must have a name and be in the canonical
78
+ # form, otherwise an error is returned.
79
+ # If an error was encountered it is returned, along with a nil Reference.
80
+ # NOTE: ParseNamed will not handle short digests.
81
+ def self.parse_named(str)
82
+ named = Normalize.parse_normalized_named(str)
83
+ raise NameNotCanonical if named.to_s != str
84
+
85
+ named
86
+ end
87
+
88
+ # WithName returns a named object representing the given string. If the input
89
+ # is invalid ErrReferenceInvalidFormat will be returned.
90
+ def self.with_name(name)
91
+ raise NameEmpty if Helpers.empty?(name)
92
+ raise NameTooLong if name.length > MAX_NAME_TOTAL_LENGTH
93
+
94
+ match = Regexp.anchored_name_regexp.match(name)
95
+ raise ReferenceInvalidFormat if match.nil? || match.to_a.length != 3
96
+
97
+ match_result = match.to_a
98
+ Repository.new(match_result[1], match_result[2])
99
+ end
100
+
101
+ # WithTag combines the name from "name" and the tag from "tag" to form a
102
+ # reference incorporating both the name and the tag.
103
+ def self.with_tag(named, tag)
104
+ match = Regexp.anchored_tag_regexp.match(tag)
105
+ raise TagInvalidFormat if match.nil?
106
+
107
+ repo = Repository.new
108
+ if named.is_a?(Repository)
109
+ repo.domain = named.domain
110
+ repo.path = named.path
111
+ else
112
+ repo.path = named.name
113
+ end
114
+
115
+ return Reference.new(repo, tag, named.digest) if named.is_a?(CanonicalReference)
116
+
117
+ TaggedReference.new(repo, tag)
118
+ end
119
+
120
+ # WithDigest combines the name from "name" and the digest from "digest" to form
121
+ # a reference incorporating both the name and the digest.
122
+ def self.with_digest(named, digest)
123
+ match = Regexp.anchored_digest_regexp.match(digest)
124
+ raise DigestInvalidFormat if match.nil?
125
+
126
+ repo = Repository.new
127
+ if named.is_a?(Repository)
128
+ repo.domain = named.domain
129
+ repo.path = named.path
130
+ else
131
+ repo.path = named.name
132
+ end
133
+
134
+ return Reference.new(repo, named.tag, digest) if named.is_a?(TaggedReference)
135
+
136
+ CanonicalReference.new(repo, digest)
137
+ end
138
+
139
+ # TrimNamed removes any tag or digest from the named reference.
140
+ def self.trim_named(ref)
141
+ domain, path = split_hostname(ref)
142
+ new Repository(domain, path)
143
+ end
144
+
145
+ def self.get_best_reference_type(ref)
146
+ if Helpers.empty?(ref.name)
147
+ return DigestReference.new(ref.digest) unless Helpers.empty?(ref.digest)
148
+
149
+ return nil
150
+ end
151
+
152
+ if Helpers.empty?(ref.tag)
153
+ return CanonicalReference.new(ref.repository, ref.digest) unless Helpers.empty?(ref.digest)
154
+
155
+ return ref.repository
156
+ end
157
+
158
+ return TaggedReference.new(ref.repository, ref.tag) if Helpers.empty?(ref.digest)
159
+
160
+ ref
161
+ end
162
+
163
+ def self.split_domain(name)
164
+ match = Regexp.anchored_name_regexp.match(name)
165
+ return [nil, name] if match && match.to_a.length != 3
166
+
167
+ match_results = match.to_a
168
+ [match_results[1], match_results[2]]
169
+ end
170
+
171
+ def self.split_hostname(named)
172
+ return [named.domain, named.path] if named.is_a?(Repository)
173
+
174
+ split_domain(name)
175
+ end
176
+ end
177
+ end
178
+ # rubocop:enable all
@@ -0,0 +1,226 @@
1
+ # frozen_string_literal: true
2
+
3
+ # rubocop:disable Metrics/ClassLength
4
+ module DockerDistribution
5
+ # Representation of regxexps we will use
6
+ class Regexp
7
+ class << self
8
+ # alphaNumeric defines the alpha numeric atom, typically a
9
+ # component of names. This only allows lower case characters and digits.
10
+ def alpha_numeric
11
+ "[a-z0-9]+"
12
+ end
13
+
14
+ # separator defines the separators allowed to be embedded in name
15
+ # components. This allow one period, one or two underscore and multiple
16
+ # dashes. Repeated dashes and underscores are intentionally treated
17
+ # differently. In order to support valid hostnames as name components,
18
+ # supporting repeated dash was added. Additionally double underscore is
19
+ # now allowed as a separator to loosen the restriction for previously
20
+ # supported names.
21
+ def separator
22
+ "(?:[._]|__|[-]*)"
23
+ end
24
+
25
+ # nameComponent restricts registry path component names to start
26
+ # with at least one letter or number, with following parts able to be
27
+ # separated by one period, one or two underscore and multiple dashes.
28
+ def name_component
29
+ expression(alpha_numeric, repeated(separator, alpha_numeric))
30
+ end
31
+
32
+ # domainNameComponent restricts the registry domain component of a
33
+ # repository name to start with a component as defined by DomainRegexp.
34
+ def domain_name_component
35
+ "(?:[a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])"
36
+ end
37
+
38
+ # ipv6address are enclosed between square brackets and may be represented
39
+ # in many ways, see rfc5952. Only IPv6 in compressed or uncompressed format
40
+ # are allowed, IPv6 zone identifiers (rfc6874) or Special addresses such as
41
+ # IPv4-Mapped are deliberately excluded.
42
+ def ipv6address
43
+ expression(literal("["), "(?:[a-fA-F0-9:]+)", literal("]"))
44
+ end
45
+
46
+ # domainName defines the structure of potential domain components
47
+ # that may be part of image names. This is purposely a subset of what is
48
+ # allowed by DNS to ensure backwards compatibility with Docker image
49
+ # names. This includes IPv4 addresses on decimal format.
50
+ def domain_name
51
+ expression(domain_name_component, repeated(literal("."), domain_name_component))
52
+ end
53
+
54
+ # host defines the structure of potential domains based on the URI
55
+ # Host subcomponent on rfc3986. It may be a subset of DNS domain name,
56
+ # or an IPv4 address in decimal format, or an IPv6 address between square
57
+ # brackets (excluding zone identifiers as defined by rfc6874 or special
58
+ # addresses such as IPv4-Mapped).
59
+ def host
60
+ "(?:#{domain_name}|#{ipv6address})"
61
+ end
62
+
63
+ # allowed by the URI Host subcomponent on rfc3986 to ensure backwards
64
+ # compatibility with Docker image names.
65
+ def domain
66
+ expression(host, optional(literal(":"), "[0-9]+"))
67
+ end
68
+
69
+ # DomainRegexp defines the structure of potential domain components that may be part of image names.
70
+ # This is purposely a subset of what is allowed by DNS to ensure backwards compatibility with Docker image names.
71
+ def domain_regexp
72
+ @domain_regexp ||= ::Regexp.new(domain)
73
+ end
74
+
75
+ def tag
76
+ "[\\w][\\w.-]{0,127}"
77
+ end
78
+
79
+ # TagRegexp matches valid tag names
80
+ def tag_regexp
81
+ ::Regexp.new(tag)
82
+ end
83
+
84
+ def anchored_tag
85
+ anchored(tag)
86
+ end
87
+
88
+ # anchoredTagRegexp matches valid tag names, anchored at the start and end of the matched string.
89
+ def anchored_tag_regexp
90
+ ::Regexp.new(anchored_tag)
91
+ end
92
+
93
+ def digest_pat
94
+ "[A-Za-z][A-Za-z0-9]*(?:[-_+.][A-Za-z][A-Za-z0-9]*)*[:][[:xdigit:]]{32,}"
95
+ end
96
+
97
+ # DigestRegexp matches valid digests.
98
+ def digest_regexp
99
+ ::Regexp.new(digest_pat)
100
+ end
101
+
102
+ def anchored_digest
103
+ anchored(digest_pat)
104
+ end
105
+
106
+ # anchoredDigestRegexp matches valid digests, anchored at the start and end of the matched string.
107
+ def anchored_digest_regexp
108
+ ::Regexp.new(anchored_digest)
109
+ end
110
+
111
+ def name_pat
112
+ expression(optional(domain, literal("/")), name_component, repeated(literal("/"), name_component))
113
+ end
114
+
115
+ # NameRegexp is the format for the name component of references. The
116
+ # regexp has capturing groups for the domain and name part omitting
117
+ # the separating forward slash from either.
118
+ def name_regexp
119
+ ::Regexp.new(name_pat)
120
+ end
121
+
122
+ def anchored_name
123
+ anchored(optional(capture(domain), literal("/")), capture(name_component, repeated(literal("/"), name_component)))
124
+ end
125
+
126
+ # anchoredNameRegexp is used to parse a name value, capturing the domain and trailing components.
127
+ def anchored_name_regexp
128
+ ::Regexp.new(anchored_name)
129
+ end
130
+
131
+ def reference_pat
132
+ anchored(capture(name_pat), optional(literal(":"), capture(tag)), optional(literal("@"), capture(digest_pat)))
133
+ end
134
+
135
+ # ReferenceRegexp is the full supported format of a reference. The regexp
136
+ # is anchored and has capturing groups for name, tag, and digest components.
137
+ def reference_regexp
138
+ ::Regexp.new(reference_pat)
139
+ end
140
+
141
+ def identifier
142
+ "([a-f0-9]{64})"
143
+ end
144
+
145
+ # IdentifierRegexp is the format for string identifier used as a content addressable identifier using sha256.
146
+ # These identifiers are like digests without the algorithm, since sha256 is used.
147
+ def identifier_regexp
148
+ ::Regexp.new(identifier)
149
+ end
150
+
151
+ def short_identifier
152
+ "([a-f0-9]{6,64})"
153
+ end
154
+
155
+ # ShortIdentifierRegexp is the format used to represent a prefix of an identifier.
156
+ # A prefix may be used to match a sha256 identifier within a list of trusted identifiers.
157
+ def short_identifier_regexp
158
+ ::Regexp.new(short_identifier)
159
+ end
160
+
161
+ def anchored_identifier
162
+ anchored(identifier)
163
+ end
164
+
165
+ # anchoredIdentifierRegexp is used to check or match an # identifier value, anchored at start and end of string.
166
+ def anchored_identifier_regexp
167
+ ::Regexp.new(anchored_identifier)
168
+ end
169
+
170
+ def anchored_short_identifier
171
+ anchored(short_identifier)
172
+ end
173
+
174
+ # anchoredShortIdentifierRegexp is used to check if a value is a possible identifier prefix, anchored at start and end of string.
175
+ def anchored_short_identifier_regexp
176
+ ::Regexp.new(anchored_short_identifier)
177
+ end
178
+
179
+ def anchored_encoded_regexp(type)
180
+ map = {
181
+ SHA256: ::Regexp.new("^[a-f0-9]{64}$"),
182
+ SHA384: ::Regexp.new("^[a-f0-9]{96}$"),
183
+ SHA512: ::Regexp.new("^[a-f0-9]{128}$")
184
+ }
185
+ map[type.to_sym]
186
+ end
187
+
188
+ # literal compiles s into a literal regular expression, escaping any regexp reserved characters.
189
+ def literal(str)
190
+ re = ::Regexp.new(::Regexp.quote(str))
191
+ re.source
192
+ end
193
+
194
+ # expression defines a full expression, where each regular expression must follow the previous.
195
+ def expression(*res)
196
+ res.join("")
197
+ end
198
+
199
+ # group wraps the regexp in a non-capturing group.
200
+ def group(*res)
201
+ "(?:#{expression(*res)})"
202
+ end
203
+
204
+ # optional wraps the expression in a non-capturing group and makes the production optional.
205
+ def optional(*res)
206
+ "#{group(expression(*res))}?"
207
+ end
208
+
209
+ # anchored anchors the regular expression by adding start and end delimiters.
210
+ def anchored(*res)
211
+ "^#{expression(*res)}$"
212
+ end
213
+
214
+ # repeated wraps the regexp in a non-capturing group to get zero or more matches.
215
+ def repeated(*res)
216
+ group("#{group(expression(*res))}*")
217
+ end
218
+
219
+ # capture wraps the expression in a capturing group.
220
+ def capture(*res)
221
+ "(#{expression(*res)})"
222
+ end
223
+ end
224
+ end
225
+ end
226
+ # rubocop:enable Metrics/ClassLength
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DockerDistribution
4
+ class Repository
5
+ attr_accessor :domain, :path
6
+
7
+ def initialize(domain = nil, path = nil)
8
+ @domain = domain
9
+ @path = path
10
+ end
11
+
12
+ def name
13
+ return path if Helpers.empty?(domain)
14
+
15
+ [domain, path].join("/")
16
+ end
17
+
18
+ def to_s
19
+ name
20
+ end
21
+
22
+ def to_h
23
+ {
24
+ repository: name,
25
+ domain: domain,
26
+ path: path
27
+ }
28
+ end
29
+
30
+ def familiar
31
+ Normalize.familiarize_name(self)
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DockerDistribution
4
+ class TaggedReference
5
+ extend ::Forwardable
6
+ def_delegators :repository, :name, :domain, :path
7
+
8
+ attr_accessor :repository, :tag
9
+
10
+ def initialize(repository = nil, tag = nil)
11
+ @repository = repository
12
+ @tag = tag
13
+ end
14
+
15
+ def to_s
16
+ [@repository.name, tag].join(":")
17
+ end
18
+
19
+ def to_h
20
+ {
21
+ repository: name,
22
+ domain: domain,
23
+ path: path,
24
+ tag: tag
25
+ }
26
+ end
27
+
28
+ def familiar
29
+ self.class.new(Normalize.familiarize_name(self), tag)
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DockerDistribution
4
+ VERSION = "0.1.2"
5
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Grammar
4
+ #
5
+ # reference := name [ ":" tag ] [ "@" digest ]
6
+ # name := [domain '/'] path-component ['/' path-component]*
7
+ # domain := host [':' port-number]
8
+ # host := domain-name | IPv4address | \[ IPv6address \] ; rfc3986 appendix-A
9
+ # domain-name := domain-component ['.' domain-component]*
10
+ # domain-component := /([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])/
11
+ # port-number := /[0-9]+/
12
+ # path-component := alpha-numeric [separator alpha-numeric]*
13
+ # alpha-numeric := /[a-z0-9]+/
14
+ # separator := /[_.]|__|[-]*/
15
+ #
16
+ # tag := /[\w][\w.-]{0,127}/
17
+ #
18
+ # digest := digest-algorithm ":" digest-hex
19
+ # digest-algorithm := digest-algorithm-component [ digest-algorithm-separator digest-algorithm-component ]*
20
+ # digest-algorithm-separator := /[+.-_]/
21
+ # digest-algorithm-component := /[A-Za-z][A-Za-z0-9]*/
22
+ # digest-hex := /[0-9a-fA-F]{32,}/ ; At least 128 bit digest value
23
+ #
24
+ # identifier := /[a-f0-9]{64}/
25
+ # short-identifier := /[a-f0-9]{6,64}/
26
+
27
+ require "forwardable"
28
+
29
+ require_relative "docker_distribution/version"
30
+ require_relative "docker_distribution/errors"
31
+ require_relative "docker_distribution/helpers"
32
+
33
+ require_relative "docker_distribution/digest"
34
+ require_relative "docker_distribution/digest_set"
35
+
36
+ require_relative "docker_distribution/regexp"
37
+ require_relative "docker_distribution/repository"
38
+ require_relative "docker_distribution/reference"
39
+ require_relative "docker_distribution/tagged_reference"
40
+ require_relative "docker_distribution/canonical_reference"
41
+ require_relative "docker_distribution/digest_reference"
42
+ require_relative "docker_distribution/normalize"
43
+
44
+ # Package reference provides a general type to represent any way of referencing images within the registry.
45
+ # Its main purpose is to abstract tags and digests (content-addressable hash).
46
+ module DockerDistribution
47
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: docker_distribution
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.1
4
+ version: 0.1.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Artem Petrov
@@ -44,7 +44,22 @@ email:
44
44
  executables: []
45
45
  extensions: []
46
46
  extra_rdoc_files: []
47
- files: []
47
+ files:
48
+ - README.md
49
+ - Rakefile
50
+ - lib/docker_distribution.rb
51
+ - lib/docker_distribution/canonical_reference.rb
52
+ - lib/docker_distribution/digest.rb
53
+ - lib/docker_distribution/digest_reference.rb
54
+ - lib/docker_distribution/digest_set.rb
55
+ - lib/docker_distribution/errors.rb
56
+ - lib/docker_distribution/helpers.rb
57
+ - lib/docker_distribution/normalize.rb
58
+ - lib/docker_distribution/reference.rb
59
+ - lib/docker_distribution/regexp.rb
60
+ - lib/docker_distribution/repository.rb
61
+ - lib/docker_distribution/tagged_reference.rb
62
+ - lib/docker_distribution/version.rb
48
63
  homepage: https://github.com/artempartos/docker_distribution
49
64
  licenses:
50
65
  - MIT