net-imap 0.3.7 → 0.5.6
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 +4 -4
- data/BSDL +22 -0
- data/COPYING +56 -0
- data/Gemfile +14 -0
- data/LICENSE.txt +3 -22
- data/README.md +25 -8
- data/Rakefile +0 -7
- data/docs/styles.css +72 -23
- data/lib/net/imap/authenticators.rb +26 -57
- data/lib/net/imap/command_data.rb +74 -54
- data/lib/net/imap/config/attr_accessors.rb +75 -0
- data/lib/net/imap/config/attr_inheritance.rb +90 -0
- data/lib/net/imap/config/attr_type_coercion.rb +61 -0
- data/lib/net/imap/config.rb +470 -0
- data/lib/net/imap/data_encoding.rb +18 -6
- data/lib/net/imap/data_lite.rb +226 -0
- data/lib/net/imap/deprecated_client_options.rb +142 -0
- data/lib/net/imap/errors.rb +27 -1
- data/lib/net/imap/esearch_result.rb +180 -0
- data/lib/net/imap/fetch_data.rb +597 -0
- data/lib/net/imap/flags.rb +1 -1
- data/lib/net/imap/response_data.rb +250 -440
- data/lib/net/imap/response_parser/parser_utils.rb +245 -0
- data/lib/net/imap/response_parser.rb +1867 -1184
- data/lib/net/imap/sasl/anonymous_authenticator.rb +69 -0
- data/lib/net/imap/sasl/authentication_exchange.rb +139 -0
- data/lib/net/imap/sasl/authenticators.rb +122 -0
- data/lib/net/imap/sasl/client_adapter.rb +123 -0
- data/lib/net/imap/{authenticators/cram_md5.rb → sasl/cram_md5_authenticator.rb} +24 -14
- data/lib/net/imap/sasl/digest_md5_authenticator.rb +342 -0
- data/lib/net/imap/sasl/external_authenticator.rb +83 -0
- data/lib/net/imap/sasl/gs2_header.rb +80 -0
- data/lib/net/imap/{authenticators/login.rb → sasl/login_authenticator.rb} +28 -18
- data/lib/net/imap/sasl/oauthbearer_authenticator.rb +199 -0
- data/lib/net/imap/sasl/plain_authenticator.rb +101 -0
- data/lib/net/imap/sasl/protocol_adapters.rb +101 -0
- data/lib/net/imap/sasl/scram_algorithm.rb +58 -0
- data/lib/net/imap/sasl/scram_authenticator.rb +287 -0
- data/lib/net/imap/sasl/stringprep.rb +6 -66
- data/lib/net/imap/sasl/xoauth2_authenticator.rb +106 -0
- data/lib/net/imap/sasl.rb +148 -44
- data/lib/net/imap/sasl_adapter.rb +20 -0
- data/lib/net/imap/search_result.rb +146 -0
- data/lib/net/imap/sequence_set.rb +1565 -0
- data/lib/net/imap/stringprep/nameprep.rb +70 -0
- data/lib/net/imap/stringprep/saslprep.rb +69 -0
- data/lib/net/imap/stringprep/saslprep_tables.rb +96 -0
- data/lib/net/imap/stringprep/tables.rb +146 -0
- data/lib/net/imap/stringprep/trace.rb +85 -0
- data/lib/net/imap/stringprep.rb +159 -0
- data/lib/net/imap/uidplus_data.rb +244 -0
- data/lib/net/imap/vanished_data.rb +56 -0
- data/lib/net/imap.rb +2090 -823
- data/net-imap.gemspec +7 -8
- data/rakelib/benchmarks.rake +91 -0
- data/rakelib/rfcs.rake +2 -0
- data/rakelib/saslprep.rake +4 -4
- data/rakelib/string_prep_tables_generator.rb +84 -60
- data/sample/net-imap.rb +167 -0
- metadata +45 -49
- data/.github/dependabot.yml +0 -6
- data/.github/workflows/test.yml +0 -38
- data/.gitignore +0 -10
- data/benchmarks/stringprep.yml +0 -65
- data/benchmarks/table-regexps.yml +0 -39
- data/lib/net/imap/authenticators/digest_md5.rb +0 -115
- data/lib/net/imap/authenticators/plain.rb +0 -41
- data/lib/net/imap/authenticators/xoauth2.rb +0 -20
- data/lib/net/imap/sasl/saslprep.rb +0 -55
- data/lib/net/imap/sasl/saslprep_tables.rb +0 -98
- data/lib/net/imap/sasl/stringprep_tables.rb +0 -153
data/lib/net/imap/sasl.rb
CHANGED
@@ -5,13 +5,12 @@ module Net
|
|
5
5
|
|
6
6
|
# Pluggable authentication mechanisms for protocols which support SASL
|
7
7
|
# (Simple Authentication and Security Layer), such as IMAP4, SMTP, LDAP, and
|
8
|
-
# XMPP. {RFC-4422}[https://
|
9
|
-
# common SASL framework
|
10
|
-
#
|
11
|
-
#
|
12
|
-
#
|
13
|
-
#
|
14
|
-
# between protocols and mechanisms as illustrated in the following diagram."
|
8
|
+
# XMPP. {RFC-4422}[https://www.rfc-editor.org/rfc/rfc4422] specifies the
|
9
|
+
# common \SASL framework:
|
10
|
+
# >>>
|
11
|
+
# SASL is conceptually a framework that provides an abstraction layer
|
12
|
+
# between protocols and mechanisms as illustrated in the following
|
13
|
+
# diagram.
|
15
14
|
#
|
16
15
|
# SMTP LDAP XMPP Other protocols ...
|
17
16
|
# \ | | /
|
@@ -21,58 +20,163 @@ module Net
|
|
21
20
|
# / | | \
|
22
21
|
# EXTERNAL GSSAPI PLAIN Other mechanisms ...
|
23
22
|
#
|
23
|
+
# Net::IMAP uses SASL via the Net::IMAP#authenticate method.
|
24
|
+
#
|
25
|
+
# == Mechanisms
|
26
|
+
#
|
27
|
+
# Each mechanism has different properties and requirements. Please consult
|
28
|
+
# the documentation for the specific mechanisms you are using:
|
29
|
+
#
|
30
|
+
# +ANONYMOUS+::
|
31
|
+
# See AnonymousAuthenticator.
|
32
|
+
#
|
33
|
+
# Allows the user to gain access to public services or resources without
|
34
|
+
# authenticating or disclosing an identity.
|
35
|
+
#
|
36
|
+
# +EXTERNAL+::
|
37
|
+
# See ExternalAuthenticator.
|
38
|
+
#
|
39
|
+
# Authenticates using already established credentials, such as a TLS
|
40
|
+
# certificate or IPSec.
|
41
|
+
#
|
42
|
+
# +OAUTHBEARER+::
|
43
|
+
# See OAuthBearerAuthenticator.
|
44
|
+
#
|
45
|
+
# Login using an OAuth2 Bearer token. This is the standard mechanism
|
46
|
+
# for using OAuth2 with \SASL, but it is not yet deployed as widely as
|
47
|
+
# +XOAUTH2+.
|
48
|
+
#
|
49
|
+
# +PLAIN+::
|
50
|
+
# See PlainAuthenticator.
|
51
|
+
#
|
52
|
+
# Login using clear-text username and password.
|
53
|
+
#
|
54
|
+
# +SCRAM-SHA-1+::
|
55
|
+
# +SCRAM-SHA-256+::
|
56
|
+
# See ScramAuthenticator.
|
57
|
+
#
|
58
|
+
# Login by username and password. The password is not sent to the
|
59
|
+
# server but is used in a salted challenge/response exchange.
|
60
|
+
# +SCRAM-SHA-1+ and +SCRAM-SHA-256+ are directly supported by
|
61
|
+
# Net::IMAP::SASL. New authenticators can easily be added for any other
|
62
|
+
# <tt>SCRAM-*</tt> mechanism if the digest algorithm is supported by
|
63
|
+
# OpenSSL::Digest.
|
64
|
+
#
|
65
|
+
# +XOAUTH2+::
|
66
|
+
# See XOAuth2Authenticator.
|
67
|
+
#
|
68
|
+
# Login using a username and an OAuth2 access token. Non-standard and
|
69
|
+
# obsoleted by +OAUTHBEARER+, but widely supported.
|
70
|
+
#
|
71
|
+
# See the {SASL mechanism
|
72
|
+
# registry}[https://www.iana.org/assignments/sasl-mechanisms/sasl-mechanisms.xhtml]
|
73
|
+
# for a list of all SASL mechanisms and their specifications. To register
|
74
|
+
# new authenticators, see Authenticators.
|
75
|
+
#
|
76
|
+
# === Deprecated mechanisms
|
77
|
+
#
|
78
|
+
# <em>Obsolete mechanisms should be avoided, but are still available for
|
79
|
+
# backwards compatibility.</em>
|
80
|
+
#
|
81
|
+
# >>>
|
82
|
+
# For +DIGEST-MD5+ see DigestMD5Authenticator.
|
83
|
+
#
|
84
|
+
# For +LOGIN+, see LoginAuthenticator.
|
85
|
+
#
|
86
|
+
# For +CRAM-MD5+, see CramMD5Authenticator.
|
87
|
+
#
|
88
|
+
# <em>Using a deprecated mechanism will print a warning.</em>
|
89
|
+
#
|
24
90
|
module SASL
|
91
|
+
# Exception class for any client error detected during the authentication
|
92
|
+
# exchange.
|
93
|
+
#
|
94
|
+
# When the _server_ reports an authentication failure, it will respond
|
95
|
+
# with a protocol specific error instead, e.g: +BAD+ or +NO+ in IMAP.
|
96
|
+
#
|
97
|
+
# When the client encounters any error, it *must* consider the
|
98
|
+
# authentication exchange to be unsuccessful and it might need to drop the
|
99
|
+
# connection. For example, if the server reports that the authentication
|
100
|
+
# exchange was successful or the protocol does not allow additional
|
101
|
+
# authentication attempts.
|
102
|
+
Error = Class.new(StandardError)
|
25
103
|
|
26
|
-
#
|
104
|
+
# Indicates an authentication exchange that will be or has been canceled
|
105
|
+
# by the client, not due to any error or failure during processing.
|
106
|
+
AuthenticationCanceled = Class.new(Error)
|
27
107
|
|
28
|
-
|
29
|
-
|
108
|
+
# Indicates an error when processing a server challenge, e.g: an invalid
|
109
|
+
# or unparsable challenge. An underlying exception may be available as
|
110
|
+
# the exception's #cause.
|
111
|
+
AuthenticationError = Class.new(Error)
|
30
112
|
|
31
|
-
#
|
32
|
-
#
|
33
|
-
|
34
|
-
attr_reader :string, :profile
|
113
|
+
# Indicates that authentication cannot proceed because one of the server's
|
114
|
+
# messages has not passed integrity checks.
|
115
|
+
AuthenticationFailed = Class.new(Error)
|
35
116
|
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
end
|
117
|
+
# Indicates that authentication cannot proceed because the server ended
|
118
|
+
# authentication prematurely.
|
119
|
+
class AuthenticationIncomplete < AuthenticationFailed
|
120
|
+
# The success response from the server
|
121
|
+
attr_reader :response
|
42
122
|
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
attr_reader :table
|
47
|
-
|
48
|
-
def initialize(table, *args, **kwargs)
|
49
|
-
@table = -table.to_str
|
50
|
-
details = (title = StringPrep::TABLE_TITLES[table]) ?
|
51
|
-
"%s [%s]" % [title, table] : table
|
52
|
-
message = "String contains a prohibited codepoint: %s" % [details]
|
53
|
-
super(message, *args, **kwargs)
|
123
|
+
def initialize(response, message = "authentication ended prematurely")
|
124
|
+
super(message)
|
125
|
+
@response = response
|
54
126
|
end
|
55
127
|
end
|
56
128
|
|
57
|
-
#
|
58
|
-
|
59
|
-
|
129
|
+
# autoloading to avoid loading all of the regexps when they aren't used.
|
130
|
+
sasl_stringprep_rb = File.expand_path("sasl/stringprep", __dir__)
|
131
|
+
autoload :StringPrep, sasl_stringprep_rb
|
132
|
+
autoload :SASLprep, sasl_stringprep_rb
|
133
|
+
autoload :StringPrepError, sasl_stringprep_rb
|
134
|
+
autoload :ProhibitedCodepoint, sasl_stringprep_rb
|
135
|
+
autoload :BidiStringError, sasl_stringprep_rb
|
136
|
+
|
137
|
+
sasl_dir = File.expand_path("sasl", __dir__)
|
138
|
+
autoload :AuthenticationExchange, "#{sasl_dir}/authentication_exchange"
|
139
|
+
autoload :ClientAdapter, "#{sasl_dir}/client_adapter"
|
140
|
+
autoload :ProtocolAdapters, "#{sasl_dir}/protocol_adapters"
|
141
|
+
|
142
|
+
autoload :Authenticators, "#{sasl_dir}/authenticators"
|
143
|
+
autoload :GS2Header, "#{sasl_dir}/gs2_header"
|
144
|
+
autoload :ScramAlgorithm, "#{sasl_dir}/scram_algorithm"
|
145
|
+
|
146
|
+
autoload :AnonymousAuthenticator, "#{sasl_dir}/anonymous_authenticator"
|
147
|
+
autoload :ExternalAuthenticator, "#{sasl_dir}/external_authenticator"
|
148
|
+
autoload :OAuthBearerAuthenticator, "#{sasl_dir}/oauthbearer_authenticator"
|
149
|
+
autoload :PlainAuthenticator, "#{sasl_dir}/plain_authenticator"
|
150
|
+
autoload :ScramAuthenticator, "#{sasl_dir}/scram_authenticator"
|
151
|
+
autoload :ScramSHA1Authenticator, "#{sasl_dir}/scram_authenticator"
|
152
|
+
autoload :ScramSHA256Authenticator, "#{sasl_dir}/scram_authenticator"
|
153
|
+
autoload :XOAuth2Authenticator, "#{sasl_dir}/xoauth2_authenticator"
|
154
|
+
|
155
|
+
autoload :CramMD5Authenticator, "#{sasl_dir}/cram_md5_authenticator"
|
156
|
+
autoload :DigestMD5Authenticator, "#{sasl_dir}/digest_md5_authenticator"
|
157
|
+
autoload :LoginAuthenticator, "#{sasl_dir}/login_authenticator"
|
158
|
+
|
159
|
+
# Returns the default global SASL::Authenticators instance.
|
160
|
+
def self.authenticators; @authenticators ||= Authenticators.new end
|
161
|
+
|
162
|
+
# Creates a new SASL authenticator, using SASL::Authenticators#new.
|
163
|
+
#
|
164
|
+
# +registry+ defaults to SASL.authenticators. All other arguments are
|
165
|
+
# forwarded to to <tt>registry.new</tt>.
|
166
|
+
def self.authenticator(*args, registry: authenticators, **kwargs, &block)
|
167
|
+
registry.new(*args, **kwargs, &block)
|
60
168
|
end
|
61
169
|
|
62
|
-
|
63
|
-
|
64
|
-
|
65
|
-
|
66
|
-
extend self
|
170
|
+
# Delegates to ::authenticators. See Authenticators#add_authenticator.
|
171
|
+
def self.add_authenticator(...) authenticators.add_authenticator(...) end
|
172
|
+
|
173
|
+
module_function
|
67
174
|
|
68
|
-
# See SASLprep#saslprep.
|
175
|
+
# See Net::IMAP::StringPrep::SASLprep#saslprep.
|
69
176
|
def saslprep(string, **opts)
|
70
|
-
SASLprep.saslprep(string, **opts)
|
177
|
+
Net::IMAP::StringPrep::SASLprep.saslprep(string, **opts)
|
71
178
|
end
|
72
179
|
|
73
180
|
end
|
74
181
|
end
|
75
|
-
|
76
182
|
end
|
77
|
-
|
78
|
-
Net::IMAP.extend Net::IMAP::SASL
|
@@ -0,0 +1,20 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Net
|
4
|
+
class IMAP
|
5
|
+
|
6
|
+
# Experimental
|
7
|
+
class SASLAdapter < SASL::ClientAdapter
|
8
|
+
include SASL::ProtocolAdapters::IMAP
|
9
|
+
|
10
|
+
RESPONSE_ERRORS = [NoResponseError, BadResponseError, ByeResponseError]
|
11
|
+
.freeze
|
12
|
+
|
13
|
+
def response_errors; RESPONSE_ERRORS end
|
14
|
+
def sasl_ir_capable?; client.capable?("SASL-IR") end
|
15
|
+
def drop_connection; client.logout! end
|
16
|
+
def drop_connection!; client.disconnect end
|
17
|
+
end
|
18
|
+
|
19
|
+
end
|
20
|
+
end
|
@@ -0,0 +1,146 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Net
|
4
|
+
class IMAP
|
5
|
+
|
6
|
+
# An array of sequence numbers returned by Net::IMAP#search, or unique
|
7
|
+
# identifiers returned by Net::IMAP#uid_search.
|
8
|
+
#
|
9
|
+
# For backward compatibility, SearchResult inherits from Array.
|
10
|
+
class SearchResult < Array
|
11
|
+
|
12
|
+
# Returns a SearchResult populated with the given +seq_nums+.
|
13
|
+
#
|
14
|
+
# Net::IMAP::SearchResult[1, 3, 5, modseq: 9]
|
15
|
+
# # => Net::IMAP::SearchResult[1, 3, 5, modseq: 9]
|
16
|
+
def self.[](*seq_nums, modseq: nil)
|
17
|
+
new(seq_nums, modseq: modseq)
|
18
|
+
end
|
19
|
+
|
20
|
+
# A modification sequence number, as described by the +CONDSTORE+
|
21
|
+
# extension in {[RFC7162
|
22
|
+
# §3.1.6]}[https://www.rfc-editor.org/rfc/rfc7162.html#section-3.1.6].
|
23
|
+
attr_reader :modseq
|
24
|
+
|
25
|
+
# Returns a SearchResult populated with the given +seq_nums+.
|
26
|
+
#
|
27
|
+
# Net::IMAP::SearchResult.new([1, 3, 5], modseq: 9)
|
28
|
+
# # => Net::IMAP::SearchResult[1, 3, 5, modseq: 9]
|
29
|
+
def initialize(seq_nums, modseq: nil)
|
30
|
+
super(seq_nums.to_ary.map { Integer _1 })
|
31
|
+
@modseq = Integer modseq if modseq
|
32
|
+
end
|
33
|
+
|
34
|
+
# Returns whether +other+ is a SearchResult with the same values and the
|
35
|
+
# same #modseq. The order of numbers is irrelevant.
|
36
|
+
#
|
37
|
+
# Net::IMAP::SearchResult[123, 456, modseq: 789] ==
|
38
|
+
# Net::IMAP::SearchResult[123, 456, modseq: 789]
|
39
|
+
# # => true
|
40
|
+
# Net::IMAP::SearchResult[123, 456, modseq: 789] ==
|
41
|
+
# Net::IMAP::SearchResult[456, 123, modseq: 789]
|
42
|
+
# # => true
|
43
|
+
#
|
44
|
+
# Net::IMAP::SearchResult[123, 456, modseq: 789] ==
|
45
|
+
# Net::IMAP::SearchResult[987, 654, modseq: 789]
|
46
|
+
# # => false
|
47
|
+
# Net::IMAP::SearchResult[123, 456, modseq: 789] ==
|
48
|
+
# Net::IMAP::SearchResult[1, 2, 3, modseq: 9999]
|
49
|
+
# # => false
|
50
|
+
#
|
51
|
+
# SearchResult can be compared directly with Array, if #modseq is nil and
|
52
|
+
# the array is sorted.
|
53
|
+
#
|
54
|
+
# Net::IMAP::SearchResult[9, 8, 6, 4, 1] == [1, 4, 6, 8, 9] # => true
|
55
|
+
# Net::IMAP::SearchResult[3, 5, 7, modseq: 99] == [3, 5, 7] # => false
|
56
|
+
#
|
57
|
+
# Note that Array#== does require matching order and ignores #modseq.
|
58
|
+
#
|
59
|
+
# [9, 8, 6, 4, 1] == Net::IMAP::SearchResult[1, 4, 6, 8, 9] # => false
|
60
|
+
# [3, 5, 7] == Net::IMAP::SearchResult[3, 5, 7, modseq: 99] # => true
|
61
|
+
#
|
62
|
+
def ==(other)
|
63
|
+
(modseq ?
|
64
|
+
other.is_a?(self.class) && modseq == other.modseq :
|
65
|
+
other.is_a?(Array)) &&
|
66
|
+
size == other.size &&
|
67
|
+
sort == other.sort
|
68
|
+
end
|
69
|
+
|
70
|
+
# Hash equality. Unlike #==, order will be taken into account.
|
71
|
+
def hash
|
72
|
+
return super if modseq.nil?
|
73
|
+
[super, self.class, modseq].hash
|
74
|
+
end
|
75
|
+
|
76
|
+
# Hash equality. Unlike #==, order will be taken into account.
|
77
|
+
def eql?(other)
|
78
|
+
return super if modseq.nil?
|
79
|
+
self.class == other.class && hash == other.hash
|
80
|
+
end
|
81
|
+
|
82
|
+
# Returns a string that represents the SearchResult.
|
83
|
+
#
|
84
|
+
# Net::IMAP::SearchResult[123, 456, 789].inspect
|
85
|
+
# # => "[123, 456, 789]"
|
86
|
+
#
|
87
|
+
# Net::IMAP::SearchResult[543, 210, 678, modseq: 2048].inspect
|
88
|
+
# # => "Net::IMAP::SearchResult[543, 210, 678, modseq: 2048]"
|
89
|
+
#
|
90
|
+
def inspect
|
91
|
+
return super if modseq.nil?
|
92
|
+
"%s[%s, modseq: %p]" % [self.class, join(", "), modseq]
|
93
|
+
end
|
94
|
+
|
95
|
+
# Returns a string that follows the formal \IMAP syntax.
|
96
|
+
#
|
97
|
+
# data = Net::IMAP::SearchResult[2, 8, 32, 128, 256, 512]
|
98
|
+
# data.to_s # => "* SEARCH 2 8 32 128 256 512"
|
99
|
+
# data.to_s("SEARCH") # => "* SEARCH 2 8 32 128 256 512"
|
100
|
+
# data.to_s("SORT") # => "* SORT 2 8 32 128 256 512"
|
101
|
+
# data.to_s(nil) # => "2 8 32 128 256 512"
|
102
|
+
#
|
103
|
+
# data = Net::IMAP::SearchResult[1, 3, 16, 1024, modseq: 2048]
|
104
|
+
# data.to_s # => "* SEARCH 1 3 16 1024 (MODSEQ 2048)"
|
105
|
+
# data.to_s("SORT") # => "* SORT 1 3 16 1024 (MODSEQ 2048)"
|
106
|
+
# data.to_s(nil) # => "1 3 16 1024 (MODSEQ 2048)"
|
107
|
+
#
|
108
|
+
def to_s(type = "SEARCH")
|
109
|
+
str = +""
|
110
|
+
str << "* %s " % [type.to_str] unless type.nil?
|
111
|
+
str << join(" ")
|
112
|
+
str << " (MODSEQ %d)" % [modseq] if modseq
|
113
|
+
-str
|
114
|
+
end
|
115
|
+
|
116
|
+
# Converts the SearchResult into a SequenceSet.
|
117
|
+
#
|
118
|
+
# Net::IMAP::SearchResult[9, 1, 2, 4, 10, 12, 3, modseq: 123_456]
|
119
|
+
# .to_sequence_set
|
120
|
+
# # => Net::IMAP::SequenceSet["1:4,9:10,12"]
|
121
|
+
def to_sequence_set; SequenceSet[*self] end
|
122
|
+
|
123
|
+
def pretty_print(pp)
|
124
|
+
return super if modseq.nil?
|
125
|
+
pp.text self.class.name + "["
|
126
|
+
pp.group_sub do
|
127
|
+
pp.nest(2) do
|
128
|
+
pp.breakable ""
|
129
|
+
each do |num|
|
130
|
+
pp.pp num
|
131
|
+
pp.text ","
|
132
|
+
pp.fill_breakable
|
133
|
+
end
|
134
|
+
pp.breakable ""
|
135
|
+
pp.text "modseq: "
|
136
|
+
pp.pp modseq
|
137
|
+
end
|
138
|
+
pp.breakable ""
|
139
|
+
pp.text "]"
|
140
|
+
end
|
141
|
+
end
|
142
|
+
|
143
|
+
end
|
144
|
+
|
145
|
+
end
|
146
|
+
end
|