lyrebird 1.0.1 → 2.1.0

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: a2c22dc89d09728d25d5563a9db0c136b144865ddea516ea71bd1806a4bf0ce2
4
- data.tar.gz: 002da134ef786dd04b2f61a3b2f01a2fe43ec4e772081b9d76dd4b4f5788e839
3
+ metadata.gz: 763dc798f8c44d5fdf3b9782961fe0ea042ee6a593816a5fb9ac6f6359b284ef
4
+ data.tar.gz: fc30ca3c936e76b4b29fb55e0dec4528bd17c156c3bf95c6ba76e3b16b3c0b94
5
5
  SHA512:
6
- metadata.gz: dc069741583e5940e825df140b405fe9c5710632c38927d2fb0baa80c52a4dafcb06170ac72a82259b9708f651e83e2ca11ee1d758d0031c1502f2d068faba57
7
- data.tar.gz: 78a80065561c35f30b81a588a9584c173b044064a9b73266385a6708109f0b02952e0acbcbc1d6768d1750ea47ebdcbf346f42dbc7b9e151e0731a43316c995a
6
+ metadata.gz: 97b8ffd5d37a09be4cea80d33e51de9ac12aee670aa281907740c12d65c91af624649522a8511e4dbb3a15e237f422b0016dc22b00c8435abad460ce17279316
7
+ data.tar.gz: 44913f9701aaf25446db2ebfd81d2efd7ec887a3be13085bf48444bb7b7eee5724296840e7e3e8e7d67e489b9ef12e6f21882309e5fcd607d0320f3e349ae00c
data/README.md CHANGED
@@ -7,6 +7,7 @@ A Ruby gem for mimicking SAML Identity Provider (IdP) responses in test
7
7
  environments.
8
8
 
9
9
  ## Installation
10
+ Requires Ruby 3.2 or newer.
10
11
  ```ruby
11
12
  gem "lyrebird", group: :test
12
13
  ```
@@ -15,10 +16,6 @@ gem "lyrebird", group: :test
15
16
  ```ruby
16
17
  # test/integration/saml_test.rb
17
18
  class SAMLTest < ActionDispatch::IntegrationTest
18
- setup do
19
- @idp_cert = Lyrebird::Certificate.build
20
- end
21
-
22
19
  test "consume creates a session" do
23
20
  user = users(:alice)
24
21
 
@@ -28,7 +25,7 @@ class SAMLTest < ActionDispatch::IntegrationTest
28
25
  r.recipient = saml_consume_url
29
26
  r.audience = root_url
30
27
  r.name_id = user.email
31
- r.sign_with = @idp_cert
28
+ r.sign_with = Lyrebird::Certificate.default
32
29
 
33
30
  r.attributes do |a|
34
31
  a.email = user.email
@@ -93,8 +90,12 @@ end
93
90
  ### Getting the encoded response
94
91
  ```ruby
95
92
  response.mimic # Base64-encoded SAML response (for POST binding)
96
- response.document # Nokogiri::XML::Document for inspection
93
+ response.to_xml # the same response as plain XML (for reading)
94
+ response.document # the response as a Nokogiri document (for searching)
97
95
  ```
96
+ Use `response.to_xml`, not `document.to_xml`. Going through `document`
97
+ adds spaces and line breaks, which breaks the signature, and the SP
98
+ rejects the response.
98
99
 
99
100
  ### Signing
100
101
  Sign both the assertion and response with an IdP certificate:
@@ -117,6 +118,35 @@ response = Lyrebird::Response.build do |r|
117
118
  r.encrypt_with = sp_cert
118
119
  end
119
120
  ```
121
+ The assertion is signed first and then encrypted, and the response is
122
+ signed over the ciphertext.
123
+
124
+ ### Testing rejection paths
125
+ Build deliberately invalid responses the SP should reject:
126
+ ```ruby
127
+ # Expired (NotOnOrAfter in the past)
128
+ response = Lyrebird::Response.build do |r|
129
+ r.sign_with = idp_cert
130
+ r.valid_for = -10
131
+ end
132
+
133
+ # Not yet valid (NotBefore in the future)
134
+ response = Lyrebird::Response.build do |r|
135
+ r.sign_with = idp_cert
136
+ r.not_before = Time.now.utc + 600
137
+ end
138
+
139
+ # Wrong audience
140
+ response = Lyrebird::Response.build do |r|
141
+ r.sign_with = idp_cert
142
+ r.audience = "https://wrong.example.com"
143
+ end
144
+
145
+ # Untrusted signature (a certificate the SP does not know)
146
+ response = Lyrebird::Response.build do |r|
147
+ r.sign_with = Lyrebird::Certificate.build
148
+ end
149
+ ```
120
150
 
121
151
  ### NameID Formats
122
152
  ```ruby
@@ -139,13 +169,19 @@ Lyrebird.configure do |d|
139
169
  d.attributes = { role: "user" }
140
170
  end
141
171
  ```
142
- Defaults are frozen after configuration for thread safety.
172
+ Defaults are frozen after configuration for thread safety, so
173
+ `configure` can only be called once.
143
174
 
144
175
  ## Certificate
145
176
  Generates and manages X.509 certificates for signing SAML responses.
146
177
 
147
178
  ### Building a certificate
179
+ Key generation is slow, so `Lyrebird::Certificate.default` generates one
180
+ certificate and reuses it.
148
181
  ```ruby
182
+ # Generated once and reused
183
+ cert = Lyrebird::Certificate.default
184
+
149
185
  # With defaults
150
186
  cert = Lyrebird::Certificate.build
151
187
 
@@ -169,10 +205,11 @@ cert = Lyrebird::Certificate.load(
169
205
 
170
206
  ### Using a certificate
171
207
  ```ruby
172
- cert.key # OpenSSL::PKey::RSA private key
173
- cert.x509 # OpenSSL::X509::Certificate object
174
- cert.key_pem # PEM-encoded private key
175
- cert.x509_pem # PEM-encoded certificate
176
- cert.base64 # Base64-encoded certificate (for SAML metadata)
177
- cert.fingerprint # SHA256 fingerprint
208
+ cert.key # OpenSSL::PKey::RSA private key
209
+ cert.x509 # OpenSSL::X509::Certificate object
210
+ cert.key_pem # PEM-encoded private key
211
+ cert.x509_pem # PEM-encoded certificate
212
+ cert.base64 # Base64-encoded certificate (for SAML metadata)
213
+ cert.fingerprint # SHA256 fingerprint, colon-separated
214
+ cert.fingerprint(:sha1) # SHA1 fingerprint
178
215
  ```
@@ -20,8 +20,8 @@ module Lyrebird
20
20
  @name_id_format = name_id_format
21
21
  @recipient = recipient
22
22
  @in_response_to = in_response_to
23
- @not_before = not_before || @issue_instant
24
- @not_on_or_after = @issue_instant + valid_for
23
+ @not_before = (not_before || @issue_instant).to_time.getutc
24
+ @not_on_or_after = [@issue_instant, @not_before].max + valid_for
25
25
  @audience = audience
26
26
  @authn_context = authn_context
27
27
  @attributes = attributes
@@ -121,12 +121,14 @@ module Lyrebird
121
121
  as.namespace = ns
122
122
 
123
123
  @attributes.each do |name, values|
124
+ list = values.is_a?(Enumerable) ? values.to_a : [values].compact
125
+
124
126
  as.add_child(doc.create_element("Attribute")).tap do |a|
125
127
  a.namespace = ns
126
128
  a["Name"] = name
127
129
  a["NameFormat"] = ATTR_NAME_FORMAT
128
130
 
129
- Array(values).each do |value|
131
+ list.each do |value|
130
132
  a.add_child(doc.create_element("AttributeValue")).tap do |av|
131
133
  av.namespace = ns
132
134
  av.content = value
@@ -2,10 +2,21 @@
2
2
 
3
3
  module Lyrebird
4
4
  class Certificate
5
+ LOCK = Mutex.new
6
+
7
+ EXTENSIONS = {
8
+ "basicConstraints" => "CA:FALSE",
9
+ "keyUsage" => "digitalSignature,keyEncipherment"
10
+ }.freeze
11
+
5
12
  attr_reader :key, :x509
6
13
 
14
+ def self.default
15
+ LOCK.synchronize { @default ||= build }
16
+ end
17
+
7
18
  def self.build(**kwargs)
8
- config = OpenStruct.new(kwargs)
19
+ config = Options.new(kwargs)
9
20
  yield config if block_given?
10
21
  new(**config.to_h)
11
22
  end
@@ -18,7 +29,7 @@ module Lyrebird
18
29
 
19
30
  def initialize(
20
31
  bits: 2048,
21
- cn: nil,
32
+ cn: "lyrebird",
22
33
  o: nil,
23
34
  valid_for: 365,
24
35
  valid_until: nil,
@@ -41,12 +52,13 @@ module Lyrebird
41
52
  @x509.to_pem
42
53
  end
43
54
 
44
- def fingerprint
45
- OpenSSL::Digest::SHA256.hexdigest(@x509.to_der)
55
+ def fingerprint(algorithm = :sha256)
56
+ digest = OpenSSL::Digest.new(algorithm.to_s)
57
+ digest.hexdigest(@x509.to_der).upcase.scan(/../).join(":")
46
58
  end
47
59
 
48
60
  def base64
49
- Base64.strict_encode64(@x509.to_der)
61
+ [@x509.to_der].pack("m0")
50
62
  end
51
63
 
52
64
  private
@@ -62,7 +74,12 @@ module Lyrebird
62
74
  c.issuer = c.subject
63
75
  c.not_before = now
64
76
  c.not_after = @valid_until || now + (@valid_for * 86_400)
65
- c.sign(@key, OpenSSL::Digest::SHA256.new)
77
+
78
+ EXTENSIONS.each do |oid, value|
79
+ c.add_extension(OpenSSL::X509::Extension.new(oid, value, true))
80
+ end
81
+
82
+ c.sign(@key, OpenSSL::Digest.new("SHA256"))
66
83
  end
67
84
  end
68
85
 
@@ -49,7 +49,6 @@ module Lyrebird
49
49
 
50
50
  def build_encrypted_key
51
51
  @doc.create_element("EncryptedKey").tap do |ek|
52
- ek.add_namespace_definition("xenc", XMLENC_NS)
53
52
  ek.namespace = @xenc
54
53
 
55
54
  ek.add_child(@doc.create_element("EncryptionMethod")).tap do |em|
@@ -71,7 +70,7 @@ module Lyrebird
71
70
 
72
71
  cd.add_child(@doc.create_element("CipherValue")).tap do |cv|
73
72
  cv.namespace = @xenc
74
- cv.content = Base64.strict_encode64(encrypted_aes_key)
73
+ cv.content = [encrypted_aes_key].pack("m0")
75
74
  end
76
75
  end
77
76
  end
@@ -81,14 +80,15 @@ module Lyrebird
81
80
  cipher.encrypt
82
81
  cipher.key = @aes_key
83
82
  iv = cipher.random_iv
84
- ciphertext = cipher.update(@element.to_xml) + cipher.final
83
+ plaintext = @element.to_xml(save_with: 0)
84
+ ciphertext = cipher.update(plaintext) + cipher.final
85
85
 
86
86
  @doc.create_element("CipherData").tap do |cd|
87
87
  cd.namespace = @xenc
88
88
 
89
89
  cd.add_child(@doc.create_element("CipherValue")).tap do |cv|
90
90
  cv.namespace = @xenc
91
- cv.content = Base64.strict_encode64(iv + ciphertext)
91
+ cv.content = [iv + ciphertext].pack("m0")
92
92
  end
93
93
  end
94
94
  end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lyrebird
4
+ class Options
5
+ def initialize(values = {})
6
+ @values = values.transform_keys(&:to_sym)
7
+ end
8
+
9
+ def to_h
10
+ @values
11
+ end
12
+
13
+ def [](key)
14
+ @values[key.to_sym]
15
+ end
16
+
17
+ def []=(key, value)
18
+ @values[key.to_sym] = value
19
+ end
20
+
21
+ private
22
+
23
+ def method_missing(name, *args)
24
+ key = name.to_s
25
+
26
+ if key.end_with?("=")
27
+ @values[key.chomp("=").to_sym] = args.first
28
+ elsif @values.key?(name)
29
+ @values[name]
30
+ else
31
+ super
32
+ end
33
+ end
34
+
35
+ def respond_to_missing?(name, include_private = false)
36
+ name.to_s.end_with?("=") || @values.key?(name) || super
37
+ end
38
+ end
39
+ end
@@ -2,13 +2,18 @@
2
2
 
3
3
  module Lyrebird
4
4
  class Response
5
- def self.build(**kwargs)
6
- config = OpenStruct.new(kwargs)
5
+ class Config < Options
6
+ def attributes(&block)
7
+ current = self[:attributes] || {}
8
+ return current unless block
7
9
 
8
- config.define_singleton_method(:attributes) do |&block|
9
- self.attributes = OpenStruct.new.tap(&block).to_h
10
+ fresh = Options.new.tap(&block).to_h
11
+ self[:attributes] = current.transform_keys(&:to_sym).merge(fresh)
10
12
  end
13
+ end
11
14
 
15
+ def self.build(**kwargs)
16
+ config = Config.new(kwargs)
12
17
  yield config if block_given?
13
18
  new(**config.to_h)
14
19
  end
@@ -35,7 +40,11 @@ module Lyrebird
35
40
  end
36
41
 
37
42
  def mimic
38
- Base64.strict_encode64(document.to_xml(save_with: 0))
43
+ [to_xml].pack("m0")
44
+ end
45
+
46
+ def to_xml
47
+ document.to_xml(save_with: 0)
39
48
  end
40
49
 
41
50
  def document
@@ -66,13 +75,12 @@ module Lyrebird
66
75
  end
67
76
 
68
77
  r.add_child(build_status(doc))
69
- r.add_child(build_assertion_element(doc))
78
+ r.add_child(build_assertion_element)
70
79
  end
71
80
  end
72
81
 
73
- def build_assertion_element(doc)
74
- assertion_doc = @assertion.document
75
- element = assertion_doc.root
82
+ def build_assertion_element
83
+ element = @assertion.document.root
76
84
 
77
85
  if @encrypt_with
78
86
  Signature.new(element, @sign_with).sign! if @sign_with
@@ -11,7 +11,9 @@ module Lyrebird
11
11
  end
12
12
 
13
13
  def sign!
14
- issuer = @element.at_xpath("saml:Issuer", "saml" => SAML_ASSERTION_NS)
14
+ ns = { "saml" => SAML_ASSERTION_NS, "ds" => XMLDSIG_NS }
15
+ @element.at_xpath("ds:Signature", ns)&.remove
16
+ issuer = @element.at_xpath("saml:Issuer", ns)
15
17
  issuer.add_next_sibling(build_signature)
16
18
  populate_signature_value
17
19
  self
@@ -33,7 +35,6 @@ module Lyrebird
33
35
  def build_signed_info
34
36
  @doc.create_element("SignedInfo").tap do |si|
35
37
  @signed_info = si
36
- si.add_namespace_definition("ds", XMLDSIG_NS)
37
38
  si.namespace = @ds
38
39
 
39
40
  si.add_child(@doc.create_element("CanonicalizationMethod")).tap do |cm|
@@ -59,8 +60,8 @@ module Lyrebird
59
60
 
60
61
  def populate_signature_value
61
62
  canonical = @signed_info.canonicalize(C14N_EXCLUSIVE)
62
- sig = @certificate.key.sign("SHA256", canonical)
63
- @signature_value.content = Base64.strict_encode64(sig)
63
+ sig = @certificate.key.sign(OpenSSL::Digest.new("SHA256"), canonical)
64
+ @signature_value.content = [sig].pack("m0")
64
65
  end
65
66
 
66
67
  def build_key_info
@@ -115,8 +116,8 @@ module Lyrebird
115
116
 
116
117
  def compute_digest
117
118
  canonical = @element.canonicalize(C14N_EXCLUSIVE)
118
- digest = OpenSSL::Digest::SHA256.digest(canonical)
119
- Base64.strict_encode64(digest)
119
+ digest = OpenSSL::Digest.new("SHA256").digest(canonical)
120
+ [digest].pack("m0")
120
121
  end
121
122
  end
122
123
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Lyrebird
4
- VERSION = "1.0.1"
4
+ VERSION = "2.1.0"
5
5
  end
data/lib/lyrebird.rb CHANGED
@@ -1,9 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "base64"
4
3
  require "nokogiri"
5
4
  require "openssl"
6
- require "ostruct"
7
5
  require "securerandom"
8
6
  require "time"
9
7
 
@@ -13,6 +11,7 @@ require_relative "lyrebird/defaults"
13
11
  require_relative "lyrebird/encryption"
14
12
  require_relative "lyrebird/id"
15
13
  require_relative "lyrebird/namespaces"
14
+ require_relative "lyrebird/options"
16
15
  require_relative "lyrebird/response"
17
16
  require_relative "lyrebird/signature"
18
17
  require_relative "lyrebird/version"
metadata CHANGED
@@ -1,51 +1,37 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: lyrebird
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.1
4
+ version: 2.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Josh
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-05-01 00:00:00.000000000 Z
11
+ date: 2026-08-28 00:00:00.000000000 Z
12
12
  dependencies:
13
- - !ruby/object:Gem::Dependency
14
- name: base64
15
- requirement: !ruby/object:Gem::Requirement
16
- requirements:
17
- - - ">="
18
- - !ruby/object:Gem::Version
19
- version: '0'
20
- type: :runtime
21
- prerelease: false
22
- version_requirements: !ruby/object:Gem::Requirement
23
- requirements:
24
- - - ">="
25
- - !ruby/object:Gem::Version
26
- version: '0'
27
13
  - !ruby/object:Gem::Dependency
28
14
  name: nokogiri
29
15
  requirement: !ruby/object:Gem::Requirement
30
16
  requirements:
31
17
  - - ">="
32
18
  - !ruby/object:Gem::Version
33
- version: '0'
19
+ version: '1.14'
34
20
  type: :runtime
35
21
  prerelease: false
36
22
  version_requirements: !ruby/object:Gem::Requirement
37
23
  requirements:
38
24
  - - ">="
39
25
  - !ruby/object:Gem::Version
40
- version: '0'
26
+ version: '1.14'
41
27
  - !ruby/object:Gem::Dependency
42
- name: ostruct
28
+ name: base64
43
29
  requirement: !ruby/object:Gem::Requirement
44
30
  requirements:
45
31
  - - ">="
46
32
  - !ruby/object:Gem::Version
47
33
  version: '0'
48
- type: :runtime
34
+ type: :development
49
35
  prerelease: false
50
36
  version_requirements: !ruby/object:Gem::Requirement
51
37
  requirements:
@@ -114,11 +100,8 @@ executables: []
114
100
  extensions: []
115
101
  extra_rdoc_files: []
116
102
  files:
117
- - ".github/workflows/ci.yml"
118
- - ".github/workflows/publish.yml"
119
103
  - LICENSE.md
120
104
  - README.md
121
- - Rakefile
122
105
  - lib/lyrebird.rb
123
106
  - lib/lyrebird/assertion.rb
124
107
  - lib/lyrebird/certificate.rb
@@ -126,25 +109,17 @@ files:
126
109
  - lib/lyrebird/encryption.rb
127
110
  - lib/lyrebird/id.rb
128
111
  - lib/lyrebird/namespaces.rb
112
+ - lib/lyrebird/options.rb
129
113
  - lib/lyrebird/response.rb
130
114
  - lib/lyrebird/signature.rb
131
115
  - lib/lyrebird/version.rb
132
- - lyrebird.gemspec
133
- - sig/lyrebird.rbs
134
- - test/lyrebird/assertion_test.rb
135
- - test/lyrebird/certificate_test.rb
136
- - test/lyrebird/defaults_test.rb
137
- - test/lyrebird/encryption_test.rb
138
- - test/lyrebird/id_test.rb
139
- - test/lyrebird/integration_test.rb
140
- - test/lyrebird/namespaces_test.rb
141
- - test/lyrebird/response_test.rb
142
- - test/lyrebird/signature_test.rb
143
- - test/test_helper.rb
144
116
  homepage: https://github.com/simplesaml/lyrebird
145
117
  licenses:
146
118
  - MIT
147
- metadata: {}
119
+ metadata:
120
+ source_code_uri: https://github.com/simplesaml/lyrebird
121
+ changelog_uri: https://github.com/simplesaml/lyrebird/releases
122
+ bug_tracker_uri: https://github.com/simplesaml/lyrebird/issues
148
123
  post_install_message:
149
124
  rdoc_options: []
150
125
  require_paths:
@@ -1,25 +0,0 @@
1
- name: CI
2
-
3
- on:
4
- push:
5
- branches: [main]
6
- pull_request:
7
- branches: [main]
8
-
9
- jobs:
10
- test:
11
- runs-on: ubuntu-latest
12
- strategy:
13
- fail-fast: false
14
- matrix:
15
- ruby-version: ["3.2", "3.3", "3.4", "4.0"]
16
-
17
- steps:
18
- - uses: actions/checkout@v4
19
- - name: Set up Ruby ${{ matrix.ruby-version }}
20
- uses: ruby/setup-ruby@v1
21
- with:
22
- ruby-version: ${{ matrix.ruby-version }}
23
- bundler-cache: true
24
- - name: Run tests
25
- run: bundle exec rake test
@@ -1,20 +0,0 @@
1
- name: Publish
2
-
3
- on:
4
- release:
5
- types: [published]
6
-
7
- jobs:
8
- publish:
9
- runs-on: ubuntu-latest
10
- steps:
11
- - uses: actions/checkout@v4
12
- - uses: ruby/setup-ruby@v1
13
- with:
14
- ruby-version: "3.2"
15
- - name: Build gem
16
- run: gem build lyrebird.gemspec
17
- - name: Publish to RubyGems
18
- run: gem push lyrebird-*.gem
19
- env:
20
- GEM_HOST_API_KEY: ${{ secrets.RUBYGEMS_API_KEY }}
data/Rakefile DELETED
@@ -1,11 +0,0 @@
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.push("test")
8
- t.test_files = FileList["test/**/*_test.rb"]
9
- end
10
-
11
- task default: :test
data/lyrebird.gemspec DELETED
@@ -1,33 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "lib/lyrebird/version"
4
-
5
- Gem::Specification.new do |spec|
6
- spec.name = "lyrebird"
7
- spec.version = Lyrebird::VERSION
8
- spec.authors = ["Josh"]
9
-
10
- spec.summary = "Mimics SAML Identity Provider (IdP) responses for testing"
11
- spec.homepage = "https://github.com/simplesaml/lyrebird"
12
- spec.license = "MIT"
13
- spec.required_ruby_version = ">= 3.2.0"
14
-
15
- spec.files = `git ls-files -z`.split("\x0")
16
- spec.files.delete("Gemfile")
17
- spec.files.delete(".gitignore")
18
- spec.files.reject! { |f| f.start_with?("bin/") }
19
-
20
- spec.require_paths = ["lib"]
21
-
22
- spec.add_dependency "base64"
23
- spec.add_dependency "nokogiri"
24
- spec.add_dependency "ostruct"
25
-
26
- # ruby-saml's gemspec has a conditional dependency on logger for Ruby >= 3.4,
27
- # but it was evaluated at gem build time (on Ruby < 3.4), so the published gem
28
- # doesn't include it.
29
- spec.add_development_dependency "logger"
30
- spec.add_development_dependency "minitest"
31
- spec.add_development_dependency "rake"
32
- spec.add_development_dependency "ruby-saml"
33
- end
data/sig/lyrebird.rbs DELETED
@@ -1,4 +0,0 @@
1
- module Lyrebird
2
- VERSION: String
3
- # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
- end