pray-cli 1.6.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.
Files changed (71) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +47 -0
  3. data/LICENSE.md +21 -0
  4. data/README.md +125 -0
  5. data/SECURITY.md +28 -0
  6. data/bin/polyrun +108 -0
  7. data/bin/pray +26 -0
  8. data/lib/pray/archive.rb +68 -0
  9. data/lib/pray/auth_client.rb +97 -0
  10. data/lib/pray/cli/commands/auth.rb +42 -0
  11. data/lib/pray/cli/commands/distribution.rb +17 -0
  12. data/lib/pray/cli/commands/init.rb +62 -0
  13. data/lib/pray/cli/commands/meta.rb +15 -0
  14. data/lib/pray/cli/commands/packages.rb +107 -0
  15. data/lib/pray/cli/commands/trust.rb +73 -0
  16. data/lib/pray/cli/commands/workflow.rb +126 -0
  17. data/lib/pray/cli/help.rb +168 -0
  18. data/lib/pray/cli/helpers.rb +166 -0
  19. data/lib/pray/cli/parse.rb +136 -0
  20. data/lib/pray/cli/parse_auth.rb +91 -0
  21. data/lib/pray/cli/parse_trust.rb +152 -0
  22. data/lib/pray/cli/suggest.rb +57 -0
  23. data/lib/pray/cli.rb +117 -0
  24. data/lib/pray/confess.rb +113 -0
  25. data/lib/pray/config.rb +52 -0
  26. data/lib/pray/constraint.rb +80 -0
  27. data/lib/pray/destination.rb +158 -0
  28. data/lib/pray/dotenv.rb +46 -0
  29. data/lib/pray/environment.rb +41 -0
  30. data/lib/pray/error.rb +55 -0
  31. data/lib/pray/format_manifest.rb +255 -0
  32. data/lib/pray/format_serialize.rb +135 -0
  33. data/lib/pray/git_sources.rb +228 -0
  34. data/lib/pray/hashing.rb +59 -0
  35. data/lib/pray/invocation.rb +110 -0
  36. data/lib/pray/literal.rb +382 -0
  37. data/lib/pray/lockfile.rb +259 -0
  38. data/lib/pray/lockfile_serialize.rb +125 -0
  39. data/lib/pray/manifest.rb +126 -0
  40. data/lib/pray/manifest_formatter.rb +56 -0
  41. data/lib/pray/manifest_json.rb +128 -0
  42. data/lib/pray/manifest_parser.rb +152 -0
  43. data/lib/pray/manifest_parser_blocks.rb +216 -0
  44. data/lib/pray/manifest_parser_helpers.rb +208 -0
  45. data/lib/pray/materialize.rb +120 -0
  46. data/lib/pray/package_spec.rb +245 -0
  47. data/lib/pray/path_safety.rb +41 -0
  48. data/lib/pray/plan.rb +83 -0
  49. data/lib/pray/project_context.rb +67 -0
  50. data/lib/pray/publish.rb +162 -0
  51. data/lib/pray/registry.rb +355 -0
  52. data/lib/pray/render.rb +360 -0
  53. data/lib/pray/resolve.rb +396 -0
  54. data/lib/pray/resolve_context.rb +19 -0
  55. data/lib/pray/serve.rb +130 -0
  56. data/lib/pray/serve_federation.rb +109 -0
  57. data/lib/pray/session.rb +90 -0
  58. data/lib/pray/ssh_agent.rb +115 -0
  59. data/lib/pray/statement_surface.rb +262 -0
  60. data/lib/pray/substitute.rb +50 -0
  61. data/lib/pray/sync.rb +219 -0
  62. data/lib/pray/terminal.rb +30 -0
  63. data/lib/pray/trust.rb +201 -0
  64. data/lib/pray/trust_feed.rb +110 -0
  65. data/lib/pray/trust_ops.rb +202 -0
  66. data/lib/pray/verify.rb +257 -0
  67. data/lib/pray/version.rb +6 -0
  68. data/lib/pray-cli.rb +4 -0
  69. data/lib/pray.rb +46 -0
  70. data/pray-cli.gemspec +48 -0
  71. metadata +187 -0
data/lib/pray/sync.rb ADDED
@@ -0,0 +1,219 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "fileutils"
5
+ require "uri"
6
+
7
+ module Pray
8
+ module Sync
9
+ module_function
10
+
11
+ def synchronize_registry(root, peer_sources)
12
+ root = File.expand_path(root)
13
+ if peer_sources.empty?
14
+ raise Error.unsupported("no federation peers configured")
15
+ end
16
+
17
+ peer_sources.each do |peer|
18
+ if peer.start_with?("pray+ssh://", "ssh+pray://")
19
+ raise Error.unsupported("pray_ssh sync peers are not implemented yet in pray-cli Ruby")
20
+ end
21
+ end
22
+
23
+ known_peers = load_known_peers(root)
24
+ pending = peer_sources.dup
25
+ discovered = peer_sources.to_set
26
+ peer_sources.each { |url| upsert_known_peer(known_peers, {"name" => url, "url" => url, "public" => false}) }
27
+
28
+ package_versions = {}
29
+ peer_count = 0
30
+
31
+ while (peer_source = pending.shift)
32
+ next unless discovered.delete?(peer_source)
33
+
34
+ peer_count += 1
35
+ discovery = fetch_json("#{trim_slash(peer_source)}/.well-known/pray-federation.json")
36
+ unless discovery["spec"] == "pray-federation-v1"
37
+ raise Error.resolution("peer #{peer_source} does not speak the pray federation protocol")
38
+ end
39
+
40
+ Array(discovery["peers"]).each do |peer|
41
+ peer = normalize_peer(peer)
42
+ next if peer["url"] == peer_source
43
+
44
+ upsert_known_peer(known_peers, peer)
45
+ next if pending.include?(peer["url"]) || discovered.include?(peer["url"])
46
+
47
+ pending << peer["url"]
48
+ discovered << peer["url"]
49
+ end
50
+
51
+ index = fetch_json("#{trim_slash(peer_source)}/v1/sync/index")
52
+ unless index["spec"] == "prayfile-distribution-1"
53
+ raise Error.resolution(
54
+ "peer #{peer_source} returned unsupported registry index spec: #{index["spec"]}"
55
+ )
56
+ end
57
+
58
+ Array(index["packages"]).each do |summary|
59
+ name = summary["name"]
60
+ metadata = fetch_json("#{trim_slash(peer_source)}/v1/sync/package/#{name}")
61
+ unless metadata["name"] == name
62
+ raise Error.resolution(
63
+ "peer #{peer_source} returned mismatched package metadata for #{name}"
64
+ )
65
+ end
66
+ sync_package(root, peer_source, metadata, package_versions)
67
+ end
68
+ end
69
+
70
+ write_known_peers(root, known_peers)
71
+ write_local_index(root, package_versions)
72
+ {peers: peer_count, packages: package_versions.length, known_peers: known_peers.length}
73
+ end
74
+
75
+ def load_sync_peers(root)
76
+ path = File.join(root, "v1", "peers.json")
77
+ unless File.file?(path)
78
+ raise Error.unsupported("no federation peers configured")
79
+ end
80
+
81
+ Array(JSON.parse(File.read(path))).map { |peer| normalize_peer(peer) }
82
+ rescue JSON::ParserError => error
83
+ raise Error.parse("peer list", error.message)
84
+ end
85
+
86
+ def sync_package(root, peer_source, metadata, package_versions)
87
+ name = metadata["name"]
88
+ package_versions[name] ||= load_local_versions(root, name)
89
+
90
+ Array(metadata["versions"]).each do |version_data|
91
+ version = registry_version_from_transport(version_data)
92
+ existing = package_versions[name][version.version]
93
+ if existing
94
+ if same_identity?(existing, version)
95
+ package_versions[name][version.version] = existing
96
+ next
97
+ end
98
+ raise Error.integrity(
99
+ "conflicting metadata for package #{name} version #{version.version}"
100
+ )
101
+ end
102
+
103
+ artifact_hash = version.artifact_hash
104
+ unless artifact_hash
105
+ raise Error.integrity("federation package #{name} #{version.version} is missing an artifact hash")
106
+ end
107
+
108
+ bytes = fetch_bytes(peer_source, version.artifact)
109
+ computed = Hashing.sha256_prefixed(bytes)
110
+ if computed != artifact_hash
111
+ raise Error.integrity("artifact hash mismatch for #{name} #{version.version}")
112
+ end
113
+
114
+ write_artifact(root, version.artifact, bytes)
115
+ package_versions[name][version.version] = version
116
+ end
117
+ end
118
+
119
+ def registry_version_from_transport(data)
120
+ signer = data.dig("publisher", "id") || data["signer"]
121
+ fingerprint = data.dig("publisher", "key_fingerprint") || data["signer_fingerprint"]
122
+ signature = data.dig("signature", "value") || data["signature"]
123
+ RegistryPackageVersion.new(
124
+ version: data["version"].to_s,
125
+ artifact: data["artifact"].to_s,
126
+ artifact_hash: data["artifact_hash"].to_s,
127
+ tree_hash: data["tree_hash"],
128
+ yanked: data.fetch("yanked", false),
129
+ targets: Array(data["targets"]),
130
+ exports: Array(data["exports"]),
131
+ signer: signer,
132
+ signer_fingerprint: fingerprint,
133
+ published_at: data["published_at"],
134
+ signature: signature.is_a?(Hash) ? signature["value"] : signature
135
+ )
136
+ end
137
+
138
+ def same_identity?(left, right)
139
+ left.artifact_hash == right.artifact_hash && left.tree_hash == right.tree_hash
140
+ end
141
+
142
+ def load_local_versions(root, package_name)
143
+ path = Publish.registry_metadata_path(root, package_name)
144
+ metadata = Publish.load_registry_package_metadata(path, package_name)
145
+ metadata.versions.to_h { |version| [version.version, version] }
146
+ end
147
+
148
+ def write_local_index(root, package_versions)
149
+ package_versions.each do |name, versions|
150
+ metadata = RegistryPackageMetadata.new(name: name, versions: versions.values)
151
+ Publish.write_registry_package_metadata(
152
+ Publish.registry_metadata_path(root, name), metadata
153
+ )
154
+ end
155
+ index = Publish.load_registry_index(root)
156
+ names = index.packages.to_set
157
+ package_versions.each_key { |name| names << name }
158
+ index.packages = names.sort
159
+ Publish.write_registry_index(root, index)
160
+ end
161
+
162
+ def write_artifact(root, artifact_path, bytes)
163
+ relative = PathSafety.sanitize_relative_path(artifact_path)
164
+ path = File.join(root, relative)
165
+ FileUtils.mkdir_p(File.dirname(path))
166
+ File.binwrite(path, bytes)
167
+ end
168
+
169
+ def load_known_peers(root)
170
+ path = File.join(root, "v1", "peers.json")
171
+ return [] unless File.file?(path)
172
+
173
+ Array(JSON.parse(File.read(path))).map { |peer| normalize_peer(peer) }
174
+ rescue JSON::ParserError
175
+ []
176
+ end
177
+
178
+ def write_known_peers(root, peers)
179
+ path = File.join(root, "v1", "peers.json")
180
+ FileUtils.mkdir_p(File.dirname(path))
181
+ File.write(path, JSON.pretty_generate(peers))
182
+ end
183
+
184
+ def upsert_known_peer(peers, peer)
185
+ existing = peers.find { |entry| entry["url"] == peer["url"] }
186
+ if existing
187
+ existing.merge!(peer)
188
+ else
189
+ peers << peer
190
+ end
191
+ end
192
+
193
+ def normalize_peer(peer)
194
+ url = peer["url"].to_s
195
+ raise Error.parse("peer list", "peer url is required") if url.empty?
196
+
197
+ {"name" => peer["name"].to_s.empty? ? url : peer["name"], "url" => url,
198
+ "public" => !!peer["public"]}
199
+ end
200
+
201
+ def fetch_json(url)
202
+ JSON.parse(Registry.http_get(url))
203
+ rescue JSON::ParserError => error
204
+ raise Error.parse("federation response", error.message)
205
+ end
206
+
207
+ def fetch_bytes(peer_source, artifact)
208
+ if artifact.start_with?("http://", "https://")
209
+ return Registry.http_get(artifact).b
210
+ end
211
+
212
+ Registry.http_get("#{trim_slash(peer_source)}/#{artifact.delete_prefix("/")}").b
213
+ end
214
+
215
+ def trim_slash(value)
216
+ value.to_s.sub(%r{/+\z}, "")
217
+ end
218
+ end
219
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pray
4
+ module Terminal
5
+ module_function
6
+
7
+ def color_enabled?
8
+ return false if env_truthy?("PRAY_NO_COLOR") || no_color_requested?
9
+
10
+ term = ENV.fetch("TERM", nil)
11
+ term.nil? || term != "dumb"
12
+ end
13
+
14
+ def no_color_requested?
15
+ value = ENV.fetch("NO_COLOR", nil)
16
+ !value.nil? && !value.empty?
17
+ end
18
+
19
+ def no_input_requested?
20
+ env_truthy?("PRAY_NO_INPUT")
21
+ end
22
+
23
+ def env_truthy?(name)
24
+ value = ENV.fetch(name, nil)
25
+ return false if value.nil?
26
+
27
+ %w[1 true yes on].include?(value.strip.downcase)
28
+ end
29
+ end
30
+ end
data/lib/pray/trust.rb ADDED
@@ -0,0 +1,201 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "toml-rb"
4
+ require "fileutils"
5
+
6
+ module Pray
7
+ TrustRule = Struct.new(
8
+ :match_prefix, :allow, :require_signed_commit,
9
+ :allowed_signing_keys, :allowed_host_keys, :allowed_publishers
10
+ ) do
11
+ def initialize(
12
+ match_prefix: nil, allow: true, require_signed_commit: false,
13
+ allowed_signing_keys: [], allowed_host_keys: [], allowed_publishers: []
14
+ )
15
+ super
16
+ end
17
+ end
18
+
19
+ TrustPolicy = Struct.new(:default_rule, :rules) do
20
+ def initialize(default_rule: TrustRule.new, rules: [])
21
+ super
22
+ end
23
+ end
24
+
25
+ CompromisedKeyEntry = Struct.new(:key, :reason, :reference, :reported_at, keyword_init: true)
26
+
27
+ module Trust
28
+ module_function
29
+
30
+ def trust_home
31
+ return ENV["PRAY_HOME"] if ENV["PRAY_HOME"]
32
+
33
+ home = ENV["HOME"]
34
+ raise Error.manifest("HOME is not set; set PRAY_HOME to configure trust policy") unless home
35
+
36
+ File.join(home, ".pray")
37
+ end
38
+
39
+ def trust_policy_path(home = trust_home)
40
+ File.join(home, "trust.toml")
41
+ end
42
+
43
+ def load_policy(home = trust_home)
44
+ path = trust_policy_path(home)
45
+ return nil unless File.file?(path)
46
+
47
+ parse_policy(TomlRB.load_file(path))
48
+ rescue TomlRB::ParseError => error
49
+ raise Error.parse("trust policy", "#{path}: #{error.message}")
50
+ end
51
+
52
+ def load_policy_or_default(home = trust_home)
53
+ load_policy(home) || TrustPolicy.new
54
+ end
55
+
56
+ def save_policy(policy, home = trust_home)
57
+ path = trust_policy_path(home)
58
+ FileUtils.mkdir_p(File.dirname(path))
59
+ File.write(path, "#{format_policy(policy)}\n")
60
+ end
61
+
62
+ def parse_policy(data)
63
+ TrustPolicy.new(
64
+ default_rule: parse_rule(data["default"] || {}),
65
+ rules: Array(data["rules"]).map { |entry| parse_rule(entry) }
66
+ )
67
+ end
68
+
69
+ def parse_rule(data)
70
+ TrustRule.new(
71
+ match_prefix: data["match_prefix"],
72
+ allow: data.fetch("allow", true),
73
+ require_signed_commit: data.fetch("require_signed_commit", false),
74
+ allowed_signing_keys: Array(data["allowed_signing_keys"]),
75
+ allowed_host_keys: Array(data["allowed_host_keys"]),
76
+ allowed_publishers: Array(data["allowed_publishers"])
77
+ )
78
+ end
79
+
80
+ def best_rule(policy, source_url)
81
+ selected = nil
82
+ selected_length = 0
83
+ policy.rules.each do |rule|
84
+ prefix = rule.match_prefix
85
+ next if prefix.nil? || prefix.empty?
86
+ next unless source_url.start_with?(prefix)
87
+ next unless prefix.length > selected_length
88
+
89
+ selected = rule
90
+ selected_length = prefix.length
91
+ end
92
+ selected || policy.default_rule
93
+ end
94
+
95
+ def mutable_rule_for_match_prefix!(policy, match_prefix)
96
+ existing = policy.rules.find { |rule| rule.match_prefix == match_prefix }
97
+ return existing if existing
98
+
99
+ rule = TrustRule.new(match_prefix: match_prefix)
100
+ policy.rules << rule
101
+ rule
102
+ end
103
+
104
+ def normalize_key(value)
105
+ value.strip.upcase
106
+ end
107
+
108
+ def fingerprint_matches?(allowed, candidate)
109
+ normalize_key(allowed) == candidate || candidate.end_with?(normalize_key(allowed))
110
+ end
111
+
112
+ def prepare_source_host_keys(sources)
113
+ policy = load_policy_or_default
114
+ sources.each_with_object({}) do |source, host_keys|
115
+ next unless source.kind == "pray_ssh"
116
+
117
+ rule = best_rule(policy, source.url)
118
+ fingerprint = rule.allowed_host_keys.first
119
+ host_keys[source.name] = fingerprint if fingerprint
120
+ end
121
+ end
122
+
123
+ def verify_publisher_fingerprint!(source_url, selected)
124
+ return unless selected.signature
125
+ return unless selected.signer_fingerprint
126
+
127
+ policy = load_policy_or_default
128
+ rule = best_rule(policy, source_url)
129
+ return if rule.allowed_publishers.empty?
130
+
131
+ normalized = normalize_key(selected.signer_fingerprint)
132
+ trusted = rule.allowed_publishers.any? do |publisher|
133
+ fingerprint_matches?(publisher, normalized)
134
+ end
135
+ return if trusted
136
+
137
+ raise Error.integrity(
138
+ "publisher fingerprint #{selected.signer_fingerprint} is not trusted for #{source_url}"
139
+ )
140
+ end
141
+
142
+ def format_policy(policy)
143
+ lines = ["[default]"]
144
+ lines.concat(format_rule_lines(policy.default_rule))
145
+ policy.rules.each do |rule|
146
+ lines << ""
147
+ lines << "[[rules]]"
148
+ lines << "match_prefix = #{rule.match_prefix.inspect}" if rule.match_prefix
149
+ lines.concat(format_rule_lines(rule))
150
+ end
151
+ lines.join("\n")
152
+ end
153
+
154
+ def format_rule_lines(rule)
155
+ [
156
+ "allow = #{rule.allow}",
157
+ "require_signed_commit = #{rule.require_signed_commit}",
158
+ "allowed_signing_keys = #{toml_array(rule.allowed_signing_keys)}",
159
+ "allowed_host_keys = #{toml_array(rule.allowed_host_keys)}",
160
+ "allowed_publishers = #{toml_array(rule.allowed_publishers)}"
161
+ ]
162
+ end
163
+
164
+ def toml_array(values)
165
+ "[#{values.map(&:inspect).join(", ")}]"
166
+ end
167
+
168
+ def format_rule_block(scope, rule)
169
+ lines = [
170
+ scope,
171
+ " allow: #{rule.allow}",
172
+ " require_signed_commit: #{rule.require_signed_commit}",
173
+ format_keyed_list("allowed_signing_keys", rule.allowed_signing_keys),
174
+ format_keyed_list("allowed_host_keys", rule.allowed_host_keys),
175
+ format_keyed_list("allowed_publishers", rule.allowed_publishers)
176
+ ]
177
+ "#{lines.join("\n")}\n"
178
+ end
179
+
180
+ def format_keyed_list(name, values)
181
+ return " #{name}: []" if values.empty?
182
+
183
+ [" #{name}:", *values.map { |value| " - #{value}" }].join("\n")
184
+ end
185
+
186
+ def append_missing!(target, keys)
187
+ added = 0
188
+ keys.each do |key|
189
+ normalized = normalize_key(key)
190
+ next if normalized.empty?
191
+ next if target.any? { |existing| normalize_key(existing) == normalized }
192
+
193
+ target << normalized
194
+ added += 1
195
+ end
196
+ added
197
+ end
198
+ end
199
+ end
200
+
201
+ require_relative "trust_ops"
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+
6
+ module Pray
7
+ module Trust
8
+ DEFAULT_COMPROMISED_KEYS_SOURCE =
9
+ "https://raw.githubusercontent.com/bmx-rs/trust-lists/main/compromised-keys.toml"
10
+
11
+ module_function
12
+
13
+ def check_compromised(source = nil, home: trust_home)
14
+ source_description, body = fetch_compromised_feed(source)
15
+ entries = parse_compromised_feed(body, source_description)
16
+ [source_description, compromised_hits(home, entries)]
17
+ end
18
+
19
+ def parse_compromised_feed(body, source_hint)
20
+ if source_hint.downcase.end_with?(".txt")
21
+ parse_compromised_txt(body)
22
+ else
23
+ parse_compromised_toml(body)
24
+ end
25
+ end
26
+
27
+ def fetch_compromised_feed(source)
28
+ url = source || DEFAULT_COMPROMISED_KEYS_SOURCE
29
+ if url.start_with?("http://", "https://")
30
+ uri = URI(url)
31
+ response = Net::HTTP.start(
32
+ uri.hostname, uri.port, use_ssl: uri.scheme == "https",
33
+ open_timeout: 10, read_timeout: 30
34
+ ) { |http| http.get(uri.request_uri) }
35
+ unless response.is_a?(Net::HTTPSuccess)
36
+ raise Error.resolution("HTTP request failed for #{url}: #{response.code}")
37
+ end
38
+
39
+ return [url, response.body]
40
+ end
41
+
42
+ path = File.expand_path(url)
43
+ [path, File.read(path)]
44
+ end
45
+
46
+ def parse_compromised_toml(body)
47
+ data = TomlRB.parse(body)
48
+ Array(data["keys"]).filter_map do |entry|
49
+ key = normalize_key(entry["value"].to_s)
50
+ next if key.empty?
51
+
52
+ CompromisedKeyEntry.new(
53
+ key: key, reason: entry["reason"],
54
+ reference: entry["reference"], reported_at: entry["reported_at"]
55
+ )
56
+ end
57
+ rescue TomlRB::ParseError
58
+ []
59
+ end
60
+
61
+ def parse_compromised_txt(body)
62
+ body.each_line.filter_map do |raw_line|
63
+ line = raw_line.strip
64
+ next if line.empty? || line.start_with?("#")
65
+
66
+ head, reason = line.split("#", 2)
67
+ key = normalize_key(head.to_s.split(/\s+/).first.to_s)
68
+ next if key.empty?
69
+
70
+ CompromisedKeyEntry.new(
71
+ key: key,
72
+ reason: reason&.strip&.empty? ? nil : reason&.strip,
73
+ reference: nil, reported_at: nil
74
+ )
75
+ end
76
+ end
77
+
78
+ def compromised_hits(home, entries)
79
+ trusted = trusted_keys_by_scope(home)
80
+ return [] if trusted.empty?
81
+
82
+ by_key = entries.group_by(&:key)
83
+ trusted.filter_map do |key, scopes|
84
+ matches = by_key[key]
85
+ next unless matches
86
+
87
+ [key, scopes, matches]
88
+ end
89
+ end
90
+
91
+ def trusted_keys_by_scope(home)
92
+ policy = load_policy(home)
93
+ return {} unless policy
94
+
95
+ output = Hash.new { |hash, key| hash[key] = [] }
96
+ policy.default_rule.allowed_signing_keys.each do |key|
97
+ normalized = normalize_key(key)
98
+ output[normalized] << "global/default" unless normalized.empty?
99
+ end
100
+ policy.rules.each do |rule|
101
+ scope = "local:#{rule.match_prefix || "-"}"
102
+ rule.allowed_signing_keys.each do |key|
103
+ normalized = normalize_key(key)
104
+ output[normalized] << scope unless normalized.empty?
105
+ end
106
+ end
107
+ output
108
+ end
109
+ end
110
+ end