pray-cli 1.9.1 → 1.10.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 +4 -4
- data/CHANGELOG.md +25 -0
- data/bin/pray +0 -1
- data/lib/pray/archive.rb +18 -16
- data/lib/pray/archive_unpack.rb +68 -0
- data/lib/pray/cli/commands/workflow.rb +1 -1
- data/lib/pray/cli/helpers.rb +2 -1
- data/lib/pray/compose_dest.rb +59 -0
- data/lib/pray/format_serialize.rb +6 -1
- data/lib/pray/http_body.rb +34 -0
- data/lib/pray/lockfile.rb +32 -6
- data/lib/pray/lockfile_serialize.rb +15 -0
- data/lib/pray/manifest.rb +26 -9
- data/lib/pray/manifest_json.rb +4 -4
- data/lib/pray/manifest_parser.rb +1 -0
- data/lib/pray/manifest_parser_blocks.rb +5 -2
- data/lib/pray/manifest_parser_helpers.rb +34 -5
- data/lib/pray/materialize.rb +5 -3
- data/lib/pray/package_spec.rb +19 -0
- data/lib/pray/path_safety.rb +43 -0
- data/lib/pray/plan.rb +5 -11
- data/lib/pray/registry.rb +33 -70
- data/lib/pray/registry_install.rb +30 -0
- data/lib/pray/registry_integrity.rb +63 -0
- data/lib/pray/render.rb +43 -65
- data/lib/pray/render_content.rb +36 -0
- data/lib/pray/render_dest.rb +215 -0
- data/lib/pray/render_layout.rb +21 -0
- data/lib/pray/render_patch.rb +107 -0
- data/lib/pray/resolve.rb +11 -54
- data/lib/pray/resolve_exports.rb +79 -0
- data/lib/pray/resolve_source.rb +28 -0
- data/lib/pray/resource_limits.rb +14 -0
- data/lib/pray/serve.rb +46 -4
- data/lib/pray/session.rb +36 -4
- data/lib/pray/tar_validation.rb +157 -0
- data/lib/pray/verify.rb +2 -0
- data/lib/pray/verify_provisioned.rb +55 -0
- data/lib/pray/version.rb +2 -2
- data/lib/pray.rb +12 -0
- metadata +15 -1
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "pathname"
|
|
5
|
+
|
|
6
|
+
module Pray
|
|
7
|
+
module RenderDest
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def materialize(project, previous_lockfile = nil)
|
|
11
|
+
planned = Render.planned_provisioned_files(project)
|
|
12
|
+
previous = previous_map(previous_lockfile)
|
|
13
|
+
planned_paths = planned.map(&:path).to_h { |path| [path, true] }
|
|
14
|
+
planned.each { |file| write_leaf(project, file, previous) }
|
|
15
|
+
prune_dropped(project, previous_lockfile, planned_paths) if previous_lockfile
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def provisioned_records(project)
|
|
19
|
+
symbols = project.manifest.symbols || {}
|
|
20
|
+
Render.planned_provisioned_files(project).map do |file|
|
|
21
|
+
expected = Render.expected_provisioned_bytes(file.source, symbols)
|
|
22
|
+
ProvisionedFileRecord.new(
|
|
23
|
+
path: file.path.to_s.tr("\\", "/"),
|
|
24
|
+
content_hash: Hashing.sha256_prefixed(expected.b),
|
|
25
|
+
package: file.package,
|
|
26
|
+
export: file.export
|
|
27
|
+
)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def fail_if_symlink!(path, display)
|
|
32
|
+
return unless File.symlink?(path)
|
|
33
|
+
|
|
34
|
+
raise Error.render("refusing to write `#{display}` because it is a symbolic link")
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def previous_map(lockfile)
|
|
38
|
+
return {} unless lockfile
|
|
39
|
+
|
|
40
|
+
Array(lockfile.provisioned).to_h { |record| [record.path, record] }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def destination_status(project, file, previous_lockfile = nil)
|
|
44
|
+
PathSafety.validate_destination_path!(file.path)
|
|
45
|
+
ensure_safe_destination_ancestors!(project.project_root, file.path, file.path)
|
|
46
|
+
destination = File.join(project.project_root, file.path)
|
|
47
|
+
expected = Render.expected_provisioned_bytes(file.source, project.manifest.symbols || {})
|
|
48
|
+
record = previous_map(previous_lockfile)[file.path.to_s.tr("\\", "/")]
|
|
49
|
+
classify_destination(destination, file.path, expected, record)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def write_leaf(project, file, previous)
|
|
53
|
+
PathSafety.validate_destination_path!(file.path)
|
|
54
|
+
ensure_safe_destination_ancestors!(project.project_root, file.path, file.path)
|
|
55
|
+
destination = File.join(project.project_root, file.path)
|
|
56
|
+
expected = Render.expected_provisioned_bytes(file.source, project.manifest.symbols || {})
|
|
57
|
+
record = previous[file.path.to_s.tr("\\", "/")]
|
|
58
|
+
status = classify_destination(destination, file.path, expected, record)
|
|
59
|
+
if status == :write
|
|
60
|
+
FileUtils.mkdir_p(File.dirname(destination))
|
|
61
|
+
ensure_safe_destination_ancestors!(project.project_root, file.path, file.path)
|
|
62
|
+
return create_bytes(destination, file.path, expected)
|
|
63
|
+
end
|
|
64
|
+
return if status == :unchanged
|
|
65
|
+
|
|
66
|
+
unless record
|
|
67
|
+
raise Error.render("missing lock ownership for `#{file.path}`")
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
ensure_safe_destination_ancestors!(project.project_root, file.path, file.path)
|
|
71
|
+
update_bytes(destination, file.path, expected, record.content_hash)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def classify_destination(destination, display, expected, record)
|
|
75
|
+
kind = destination_kind(destination)
|
|
76
|
+
return :write if kind == :missing
|
|
77
|
+
|
|
78
|
+
fail_if_symlink!(destination, display)
|
|
79
|
+
unless kind == :regular
|
|
80
|
+
raise Error.render("refusing to write `#{display}`; destination is not a regular file")
|
|
81
|
+
end
|
|
82
|
+
on_disk = read_regular_bytes(destination, display)
|
|
83
|
+
return :unchanged if on_disk.b == expected.b
|
|
84
|
+
return :update if record && Hashing.sha256_prefixed(on_disk) == record.content_hash
|
|
85
|
+
|
|
86
|
+
if record
|
|
87
|
+
raise Error.render("refusing to overwrite `#{display}`; it was provisioned and then edited")
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
raise Error.render(
|
|
91
|
+
"refusing to overwrite `#{display}`; it already exists and is not the expected provisioned file"
|
|
92
|
+
)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def prune_dropped(project, previous, planned_paths)
|
|
96
|
+
Array(previous.provisioned).each do |record|
|
|
97
|
+
PathSafety.validate_destination_path!(record.path)
|
|
98
|
+
next if planned_paths[record.path]
|
|
99
|
+
|
|
100
|
+
destination = File.join(project.project_root, record.path)
|
|
101
|
+
ensure_safe_destination_ancestors!(project.project_root, record.path, record.path)
|
|
102
|
+
next unless destination_kind(destination) == :regular
|
|
103
|
+
|
|
104
|
+
on_disk = read_regular_bytes(destination, record.path)
|
|
105
|
+
if Hashing.sha256_prefixed(on_disk) == record.content_hash
|
|
106
|
+
ensure_safe_destination_ancestors!(project.project_root, record.path, record.path)
|
|
107
|
+
File.delete(destination)
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def layout_rendered_content(path, display, fresh)
|
|
113
|
+
kind = destination_kind(path)
|
|
114
|
+
return fresh if kind == :missing
|
|
115
|
+
|
|
116
|
+
fail_if_symlink!(path, display)
|
|
117
|
+
unless kind == :regular
|
|
118
|
+
raise Error.render("refusing to write `#{display}`; destination is not a regular file")
|
|
119
|
+
end
|
|
120
|
+
RenderPatch.patch_rendered_content(decode_utf8(read_regular_bytes(path, display), display), fresh)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def write_rendered_content(path, display, fresh)
|
|
124
|
+
return create_bytes(path, display, fresh) if destination_kind(path) == :missing
|
|
125
|
+
|
|
126
|
+
open_regular(path, display, File::RDWR) do |file|
|
|
127
|
+
existing = decode_utf8(file.read, display)
|
|
128
|
+
content = RenderPatch.patch_rendered_content(existing, fresh)
|
|
129
|
+
file.rewind
|
|
130
|
+
file.truncate(0)
|
|
131
|
+
file.write(content)
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def ensure_safe_destination_ancestors!(project_root, relative_path, display)
|
|
136
|
+
parent = File.dirname(relative_path.to_s.tr("\\", "/"))
|
|
137
|
+
return if parent == "."
|
|
138
|
+
|
|
139
|
+
current = project_root
|
|
140
|
+
parent.split("/").each do |component|
|
|
141
|
+
next if component.empty? || component == "."
|
|
142
|
+
|
|
143
|
+
current = File.join(current, component)
|
|
144
|
+
metadata = File.lstat(current)
|
|
145
|
+
if metadata.symlink?
|
|
146
|
+
raise Error.render(
|
|
147
|
+
"refusing to write `#{display}` because a destination parent is a symbolic link"
|
|
148
|
+
)
|
|
149
|
+
end
|
|
150
|
+
unless metadata.directory?
|
|
151
|
+
raise Error.render(
|
|
152
|
+
"refusing to write `#{display}`; a destination parent is not a directory"
|
|
153
|
+
)
|
|
154
|
+
end
|
|
155
|
+
rescue Errno::ENOENT
|
|
156
|
+
next
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def create_bytes(path, display, bytes)
|
|
161
|
+
open_path(path, display, File::WRONLY | File::CREAT | File::EXCL) do |file|
|
|
162
|
+
file.write(bytes)
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def update_bytes(path, display, bytes, authorized_hash)
|
|
167
|
+
open_regular(path, display, File::RDWR) do |file|
|
|
168
|
+
on_disk = file.read
|
|
169
|
+
return if on_disk.b == bytes.b
|
|
170
|
+
|
|
171
|
+
unless Hashing.sha256_prefixed(on_disk) == authorized_hash
|
|
172
|
+
raise Error.render("refusing to overwrite `#{display}`; it was provisioned and then edited")
|
|
173
|
+
end
|
|
174
|
+
file.rewind
|
|
175
|
+
file.truncate(0)
|
|
176
|
+
file.write(bytes)
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def read_regular_bytes(path, display)
|
|
181
|
+
open_regular(path, display, File::RDONLY) { |file| file.read }
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def decode_utf8(bytes, display)
|
|
185
|
+
text = bytes.dup.force_encoding(Encoding::UTF_8)
|
|
186
|
+
return text if text.valid_encoding?
|
|
187
|
+
|
|
188
|
+
raise Error.render("rendered destination `#{display}` is not valid UTF-8")
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def open_regular(path, display, flags)
|
|
192
|
+
open_path(path, display, flags) do |file|
|
|
193
|
+
unless file.stat.file?
|
|
194
|
+
raise Error.render("refusing to write `#{display}`; destination is not a regular file")
|
|
195
|
+
end
|
|
196
|
+
yield file
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def open_path(path, display, flags)
|
|
201
|
+
no_follow = File.const_defined?(:NOFOLLOW) ? File::NOFOLLOW : 0
|
|
202
|
+
File.open(path, flags | no_follow) { |file| yield file }
|
|
203
|
+
rescue Errno::ELOOP
|
|
204
|
+
raise Error.render("refusing to write `#{display}` because it is a symbolic link")
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def destination_kind(path)
|
|
208
|
+
return :symlink if File.symlink?(path)
|
|
209
|
+
return :missing unless File.exist?(path)
|
|
210
|
+
return :regular if File.file?(path)
|
|
211
|
+
|
|
212
|
+
:other
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pray
|
|
4
|
+
module Render
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def layout_rendered_targets(project, rendered)
|
|
8
|
+
rendered.map do |target|
|
|
9
|
+
PathSafety.validate_destination_path!(target.path)
|
|
10
|
+
RenderDest.ensure_safe_destination_ancestors!(project.project_root, target.path, target.path)
|
|
11
|
+
path = File.join(project.project_root, target.path)
|
|
12
|
+
content = RenderDest.layout_rendered_content(path, target.path, target.content)
|
|
13
|
+
RenderedTarget.new(
|
|
14
|
+
path: target.path,
|
|
15
|
+
content: content,
|
|
16
|
+
managed_spans: RenderPatch.relocate_managed_spans(content, target.managed_spans)
|
|
17
|
+
)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pray
|
|
4
|
+
module RenderPatch
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def patch_rendered_content(existing, fresh)
|
|
8
|
+
existing_segments = split_segments(existing)
|
|
9
|
+
fresh_segments = split_segments(fresh)
|
|
10
|
+
fresh_managed = fresh_segments.filter_map do |segment|
|
|
11
|
+
[segment[:id], segment[:body]] if segment[:kind] == :managed
|
|
12
|
+
end.to_h
|
|
13
|
+
overlap = existing_segments.any? do |segment|
|
|
14
|
+
segment[:kind] == :managed && fresh_managed.key?(segment[:id])
|
|
15
|
+
end
|
|
16
|
+
return fresh unless overlap
|
|
17
|
+
|
|
18
|
+
used = {}
|
|
19
|
+
output = existing_segments.map do |segment|
|
|
20
|
+
next segment[:text] if segment[:kind] == :text
|
|
21
|
+
|
|
22
|
+
used[segment[:id]] = true
|
|
23
|
+
managed_segment(segment[:id], fresh_managed.fetch(segment[:id], segment[:body]))
|
|
24
|
+
end.join
|
|
25
|
+
fresh_segments.each do |segment|
|
|
26
|
+
next unless segment[:kind] == :managed
|
|
27
|
+
next if used[segment[:id]]
|
|
28
|
+
|
|
29
|
+
output << managed_segment(segment[:id], segment[:body])
|
|
30
|
+
end
|
|
31
|
+
output.end_with?("\n") ? output : "#{output}\n"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def relocate_managed_spans(content, spans)
|
|
35
|
+
positions = marker_positions(lines_of(content))
|
|
36
|
+
spans.map do |span|
|
|
37
|
+
position = positions[span.id]
|
|
38
|
+
next span unless position
|
|
39
|
+
|
|
40
|
+
span.dup.tap do |relocated|
|
|
41
|
+
relocated.open_line = position[0]
|
|
42
|
+
relocated.close_line = position[1]
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def split_segments(content)
|
|
48
|
+
lines = lines_of(content)
|
|
49
|
+
segments = []
|
|
50
|
+
text = +""
|
|
51
|
+
index = 0
|
|
52
|
+
while index < lines.length
|
|
53
|
+
identifier = marker_id(lines[index])
|
|
54
|
+
close = find_closing_marker(lines, index + 1, identifier) if identifier
|
|
55
|
+
if identifier && close
|
|
56
|
+
segments << {kind: :text, text: text} unless text.empty?
|
|
57
|
+
text = +""
|
|
58
|
+
body_lines = lines[(index + 1)...close]
|
|
59
|
+
body = body_lines.empty? ? "" : "#{body_lines.join("\n")}\n"
|
|
60
|
+
segments << {kind: :managed, id: identifier, body: body}
|
|
61
|
+
index = close + 1
|
|
62
|
+
else
|
|
63
|
+
text << "#{lines[index]}\n"
|
|
64
|
+
index += 1
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
segments << {kind: :text, text: text} unless text.empty?
|
|
68
|
+
segments
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def lines_of(content)
|
|
72
|
+
content.lines(chomp: true)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def find_closing_marker(lines, start, identifier)
|
|
76
|
+
(start...lines.length).find { |index| marker_id(lines[index]) == identifier }
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def marker_positions(lines)
|
|
80
|
+
positions = {}
|
|
81
|
+
active = nil
|
|
82
|
+
lines.each_with_index do |line, index|
|
|
83
|
+
identifier = marker_id(line)
|
|
84
|
+
next unless identifier&.match?(/\A[a-z0-9]+\z/)
|
|
85
|
+
|
|
86
|
+
if active.nil?
|
|
87
|
+
active = [identifier, index + 1]
|
|
88
|
+
elsif active[0] == identifier
|
|
89
|
+
positions[identifier] = [active[1], index + 1]
|
|
90
|
+
active = nil
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
positions
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def marker_id(line)
|
|
97
|
+
match = line.strip.match(/\A<!-- pray:(.+) -->\z/)
|
|
98
|
+
identifier = match && match[1]
|
|
99
|
+
identifier unless identifier == "0 ignore-comments"
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def managed_segment(identifier, body)
|
|
103
|
+
content = body.empty? ? "" : "#{body.sub(/\n+\z/, "")}\n"
|
|
104
|
+
"<!-- pray:#{identifier} -->\n#{content}<!-- pray:#{identifier} -->\n"
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
data/lib/pray/resolve.rb
CHANGED
|
@@ -87,6 +87,7 @@ module Pray
|
|
|
87
87
|
errors << "#{declaration.name}: #{error.message}"
|
|
88
88
|
end
|
|
89
89
|
raise Error.resolution(errors.join("\n")) unless errors.empty?
|
|
90
|
+
PackageSpec.warn_resolved_deprecations(packages)
|
|
90
91
|
|
|
91
92
|
local_files = []
|
|
92
93
|
local_errors = []
|
|
@@ -187,16 +188,16 @@ module Pray
|
|
|
187
188
|
return [File.expand_path(local_path, project_root), nil]
|
|
188
189
|
end
|
|
189
190
|
return [File.expand_path(declaration.path, project_root), nil] if declaration.path
|
|
191
|
+
source_name = ResolveSource.implied_source_name(declaration, sources)
|
|
192
|
+
if source_name
|
|
193
|
+
source = sources[source_name]
|
|
194
|
+
raise Error.resolution("unknown source: #{source_name}") unless source
|
|
190
195
|
|
|
191
|
-
|
|
192
|
-
source = sources[declaration.source]
|
|
193
|
-
raise Error.resolution("unknown source: #{declaration.source}") unless source
|
|
194
|
-
|
|
195
|
-
if (local_path = user_config.local.source[declaration.source])
|
|
196
|
+
if (local_path = user_config.local.source[source_name])
|
|
196
197
|
source_root = File.expand_path(local_path, project_root)
|
|
197
198
|
resolved = Registry.resolve_local_registry_package_root(
|
|
198
199
|
project_root,
|
|
199
|
-
"local:#{
|
|
200
|
+
"local:#{source_name}",
|
|
200
201
|
source_root,
|
|
201
202
|
declaration,
|
|
202
203
|
preferred_version: lockfile_preferred_version(lockfile, declaration.name),
|
|
@@ -298,40 +299,11 @@ module Pray
|
|
|
298
299
|
end
|
|
299
300
|
|
|
300
301
|
def select_exports(declaration, spec)
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
unless spec.exports.key?(export)
|
|
304
|
-
raise Error.resolution("package #{declaration.name} does not export #{export}")
|
|
305
|
-
end
|
|
306
|
-
end
|
|
307
|
-
return declaration.exports
|
|
308
|
-
end
|
|
309
|
-
|
|
310
|
-
roles = declaration.roles || []
|
|
311
|
-
return spec.exports.keys.sort if roles.empty? && declaration.file.nil?
|
|
312
|
-
|
|
313
|
-
effective_roles = roles.dup
|
|
314
|
-
effective_roles << "file" if declaration.file && !effective_roles.include?("file")
|
|
302
|
+
ResolveExports.select_exports(declaration, spec)
|
|
303
|
+
end
|
|
315
304
|
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
compatible = spec.exports.filter_map do |name, export|
|
|
319
|
-
name if Destination.export_kind_matches_role?(export.kind, role)
|
|
320
|
-
end
|
|
321
|
-
case compatible.length
|
|
322
|
-
when 1
|
|
323
|
-
selected << compatible.first unless selected.include?(compatible.first)
|
|
324
|
-
when 0
|
|
325
|
-
raise Error.resolution(
|
|
326
|
-
"package #{declaration.name} has no export compatible with #{role}"
|
|
327
|
-
)
|
|
328
|
-
else
|
|
329
|
-
raise Error.resolution(
|
|
330
|
-
"package #{declaration.name} has multiple exports compatible with #{role}; set export: \"name\""
|
|
331
|
-
)
|
|
332
|
-
end
|
|
333
|
-
end
|
|
334
|
-
selected
|
|
305
|
+
def load_export_bodies(file_bytes, spec, selected_exports)
|
|
306
|
+
ResolveExports.load_export_bodies(file_bytes, spec, selected_exports)
|
|
335
307
|
end
|
|
336
308
|
|
|
337
309
|
def load_package_file_bytes(root, spec)
|
|
@@ -346,21 +318,6 @@ module Pray
|
|
|
346
318
|
file_bytes
|
|
347
319
|
end
|
|
348
320
|
|
|
349
|
-
def load_export_bodies(file_bytes, spec, selected_exports)
|
|
350
|
-
export_bodies = {}
|
|
351
|
-
selected_exports.each do |export_name|
|
|
352
|
-
entry = spec.exports[export_name]
|
|
353
|
-
raise Error.resolution("package #{spec.name} is missing export #{export_name}") unless entry
|
|
354
|
-
next unless entry.kind == "fragment"
|
|
355
|
-
|
|
356
|
-
bytes = file_bytes[entry.path]
|
|
357
|
-
raise Error.integrity("package file missing for export #{export_name}: #{entry.path}") unless bytes
|
|
358
|
-
|
|
359
|
-
export_bodies[export_name] = Hashing.normalize_line_endings(bytes.force_encoding(Encoding::UTF_8))
|
|
360
|
-
end
|
|
361
|
-
export_bodies
|
|
362
|
-
end
|
|
363
|
-
|
|
364
321
|
def build_skill_file_index(spec)
|
|
365
322
|
index = {}
|
|
366
323
|
spec.exports.each do |export_name, export|
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pray
|
|
4
|
+
module ResolveExports
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def select_exports(declaration, spec)
|
|
8
|
+
unless declaration.exports.empty?
|
|
9
|
+
declaration.exports.each do |export|
|
|
10
|
+
unless spec.exports.key?(export)
|
|
11
|
+
raise Error.resolution("package #{declaration.name} does not export #{export}")
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
return declaration.exports
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
roles = declaration.roles || []
|
|
18
|
+
return spec.exports.keys.sort if roles.empty? && declaration.file.nil?
|
|
19
|
+
|
|
20
|
+
effective_roles = roles.dup
|
|
21
|
+
effective_roles << "file" if declaration.file && !effective_roles.include?("file")
|
|
22
|
+
|
|
23
|
+
selected = []
|
|
24
|
+
effective_roles.each do |role|
|
|
25
|
+
compatible = if role == "fragment"
|
|
26
|
+
fragment_role_exports(spec)
|
|
27
|
+
else
|
|
28
|
+
spec.exports.filter_map do |name, export|
|
|
29
|
+
name if Destination.export_kind_matches_role?(export.kind, role)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
case compatible.length
|
|
33
|
+
when 1
|
|
34
|
+
selected << compatible.first unless selected.include?(compatible.first)
|
|
35
|
+
when 0
|
|
36
|
+
raise Error.resolution(
|
|
37
|
+
"package #{declaration.name} has no export compatible with #{role}"
|
|
38
|
+
)
|
|
39
|
+
else
|
|
40
|
+
raise Error.resolution(
|
|
41
|
+
"package #{declaration.name} has multiple exports compatible with #{role}; set export: \"name\""
|
|
42
|
+
)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
selected
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def load_export_bodies(file_bytes, spec, selected_exports)
|
|
49
|
+
export_bodies = {}
|
|
50
|
+
selected_exports.each do |export_name|
|
|
51
|
+
entry = spec.exports[export_name]
|
|
52
|
+
raise Error.resolution("package #{spec.name} is missing export #{export_name}") unless entry
|
|
53
|
+
next unless %w[fragment file].include?(entry.kind)
|
|
54
|
+
|
|
55
|
+
bytes = file_bytes[entry.path]
|
|
56
|
+
unless bytes
|
|
57
|
+
raise Error.integrity("package file missing for export #{export_name}: #{entry.path}")
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
text = bytes.dup.force_encoding(Encoding::UTF_8)
|
|
61
|
+
unless text.valid_encoding?
|
|
62
|
+
if entry.kind == "fragment"
|
|
63
|
+
raise Error.integrity("package file is not valid utf-8 for export #{export_name}")
|
|
64
|
+
end
|
|
65
|
+
next
|
|
66
|
+
end
|
|
67
|
+
export_bodies[export_name] = Hashing.normalize_line_endings(text)
|
|
68
|
+
end
|
|
69
|
+
export_bodies
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def fragment_role_exports(spec)
|
|
73
|
+
fragments = spec.exports.filter_map { |name, export| name if export.kind == "fragment" }
|
|
74
|
+
return fragments unless fragments.empty?
|
|
75
|
+
|
|
76
|
+
spec.exports.filter_map { |name, export| name if export.kind == "file" }
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pray
|
|
4
|
+
module ResolveSource
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def implied_source_name(declaration, sources)
|
|
8
|
+
return declaration.source if declaration.source
|
|
9
|
+
|
|
10
|
+
namespace = package_namespace(declaration.name)
|
|
11
|
+
return namespace if namespace && sources.key?(namespace)
|
|
12
|
+
|
|
13
|
+
case sources.length
|
|
14
|
+
when 0 then nil
|
|
15
|
+
when 1 then sources.keys.first
|
|
16
|
+
else
|
|
17
|
+
raise Error.resolution(
|
|
18
|
+
"package #{declaration.name} requires source: when multiple sources are declared " \
|
|
19
|
+
"and the package namespace does not match a source"
|
|
20
|
+
)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def package_namespace(name)
|
|
25
|
+
name.include?("/") ? name.split("/", 2).first : nil
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pray
|
|
4
|
+
module ResourceLimits
|
|
5
|
+
MAX_ARCHIVE_ENTRIES = 10_000
|
|
6
|
+
MAX_ARCHIVE_ENTRY_BYTES = 32 * 1024 * 1024
|
|
7
|
+
MAX_ARCHIVE_TOTAL_BYTES = 64 * 1024 * 1024
|
|
8
|
+
MAX_HTTP_RESPONSE_BYTES = 64 * 1024 * 1024
|
|
9
|
+
MAX_SERVE_BODY_BYTES = 16 * 1024 * 1024
|
|
10
|
+
MAX_SERVE_HEADER_BYTES = 64 * 1024
|
|
11
|
+
MAX_SERVE_CONCURRENT_CONNECTIONS = 32
|
|
12
|
+
SERVE_SOCKET_TIMEOUT_SECONDS = 30
|
|
13
|
+
end
|
|
14
|
+
end
|
data/lib/pray/serve.rb
CHANGED
|
@@ -4,12 +4,14 @@ require "socket"
|
|
|
4
4
|
require "fileutils"
|
|
5
5
|
require "pathname"
|
|
6
6
|
require "json"
|
|
7
|
+
require "timeout"
|
|
7
8
|
require_relative "path_safety"
|
|
9
|
+
require_relative "resource_limits"
|
|
8
10
|
require_relative "serve_federation"
|
|
9
11
|
|
|
10
12
|
module Pray
|
|
11
13
|
module Serve
|
|
12
|
-
DEFAULT_MAX_CONNECTIONS =
|
|
14
|
+
DEFAULT_MAX_CONNECTIONS = ResourceLimits::MAX_SERVE_CONCURRENT_CONNECTIONS
|
|
13
15
|
|
|
14
16
|
module_function
|
|
15
17
|
|
|
@@ -39,7 +41,14 @@ module Pray
|
|
|
39
41
|
end
|
|
40
42
|
|
|
41
43
|
def serve_connection(root, socket, connection_slots)
|
|
42
|
-
|
|
44
|
+
Timeout.timeout(ResourceLimits::SERVE_SOCKET_TIMEOUT_SECONDS) do
|
|
45
|
+
handle_connection(root, socket)
|
|
46
|
+
end
|
|
47
|
+
rescue Timeout::Error
|
|
48
|
+
socket.print(request_timeout) unless socket.closed?
|
|
49
|
+
rescue Error => error
|
|
50
|
+
response = error.message.include?("request body exceeds") ? payload_too_large : bad_request
|
|
51
|
+
socket.print(response) unless socket.closed?
|
|
43
52
|
ensure
|
|
44
53
|
connection_slots << true unless connection_slots.closed?
|
|
45
54
|
socket.close unless socket.closed?
|
|
@@ -51,7 +60,9 @@ module Pray
|
|
|
51
60
|
|
|
52
61
|
method, path, = request_line.split
|
|
53
62
|
headers = read_headers(socket)
|
|
54
|
-
body_length = headers
|
|
63
|
+
body_length = Integer(headers.fetch("content-length", "0"), exception: false)
|
|
64
|
+
raise Error.parse("request", "invalid content length") unless body_length
|
|
65
|
+
validate_body_length!(body_length)
|
|
55
66
|
body = body_length.positive? ? socket.read(body_length) : ""
|
|
56
67
|
|
|
57
68
|
response = dispatch_request(root, method, path, body)
|
|
@@ -60,9 +71,14 @@ module Pray
|
|
|
60
71
|
|
|
61
72
|
def read_headers(socket)
|
|
62
73
|
headers = {}
|
|
74
|
+
header_bytes = 0
|
|
63
75
|
loop do
|
|
64
76
|
line = socket.gets
|
|
65
77
|
break if line.nil? || line.strip.empty?
|
|
78
|
+
header_bytes += line.bytesize
|
|
79
|
+
if header_bytes > ResourceLimits::MAX_SERVE_HEADER_BYTES
|
|
80
|
+
raise Error.parse("request", "request headers exceed server limit")
|
|
81
|
+
end
|
|
66
82
|
|
|
67
83
|
name, value = line.split(":", 2)
|
|
68
84
|
headers[name.strip.downcase] = value.strip if name && value
|
|
@@ -70,10 +86,20 @@ module Pray
|
|
|
70
86
|
headers
|
|
71
87
|
end
|
|
72
88
|
|
|
89
|
+
def validate_body_length!(body_length)
|
|
90
|
+
return if body_length <= ResourceLimits::MAX_SERVE_BODY_BYTES
|
|
91
|
+
|
|
92
|
+
raise Error.unsupported(
|
|
93
|
+
"request body exceeds #{ResourceLimits::MAX_SERVE_BODY_BYTES} bytes"
|
|
94
|
+
)
|
|
95
|
+
end
|
|
96
|
+
|
|
73
97
|
def dispatch_request(root, method, path, body = "")
|
|
74
98
|
path = path.split("?", 2).first
|
|
75
99
|
|
|
76
100
|
case [method, path]
|
|
101
|
+
when ["GET", "/health"]
|
|
102
|
+
return ok_response("text/plain", "ok")
|
|
77
103
|
when ["GET", "/.well-known/pray-federation.json"]
|
|
78
104
|
return ServeFederation.discovery_response(root)
|
|
79
105
|
when ["GET", "/v1/sync/index"]
|
|
@@ -89,12 +115,13 @@ module Pray
|
|
|
89
115
|
return not_found unless method == "GET"
|
|
90
116
|
|
|
91
117
|
if path == "/"
|
|
92
|
-
return html_response("<h1>Pray distribution</h1
|
|
118
|
+
return html_response("<h1>Pray distribution</h1>")
|
|
93
119
|
end
|
|
94
120
|
|
|
95
121
|
file_path = PathSafety.join_under_root(root, path.delete_prefix("/"))
|
|
96
122
|
return not_found unless file_path
|
|
97
123
|
return not_found unless File.file?(file_path)
|
|
124
|
+
return payload_too_large if File.size(file_path) > ResourceLimits::MAX_HTTP_RESPONSE_BYTES
|
|
98
125
|
|
|
99
126
|
content_type = content_type_for(file_path)
|
|
100
127
|
file_body = File.binread(file_path)
|
|
@@ -126,5 +153,20 @@ module Pray
|
|
|
126
153
|
body = "too many connections"
|
|
127
154
|
"HTTP/1.1 503 Service Unavailable\r\nContent-Type: text/plain\r\nContent-Length: #{body.bytesize}\r\nConnection: close\r\n\r\n#{body}"
|
|
128
155
|
end
|
|
156
|
+
|
|
157
|
+
def request_timeout
|
|
158
|
+
body = "request timed out"
|
|
159
|
+
"HTTP/1.1 408 Request Timeout\r\nContent-Type: text/plain\r\nContent-Length: #{body.bytesize}\r\nConnection: close\r\n\r\n#{body}"
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def payload_too_large
|
|
163
|
+
body = "request exceeds server limit"
|
|
164
|
+
"HTTP/1.1 413 Payload Too Large\r\nContent-Type: text/plain\r\nContent-Length: #{body.bytesize}\r\nConnection: close\r\n\r\n#{body}"
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def bad_request
|
|
168
|
+
body = "bad request"
|
|
169
|
+
"HTTP/1.1 400 Bad Request\r\nContent-Type: text/plain\r\nContent-Length: #{body.bytesize}\r\nConnection: close\r\n\r\n#{body}"
|
|
170
|
+
end
|
|
129
171
|
end
|
|
130
172
|
end
|