pray-cli 1.9.0 → 1.9.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 +4 -4
- data/CHANGELOG.md +14 -1
- data/bin/pray +0 -1
- data/lib/pray/archive.rb +18 -16
- data/lib/pray/archive_unpack.rb +68 -0
- data/lib/pray/http_body.rb +34 -0
- data/lib/pray/manifest.rb +17 -1
- data/lib/pray/manifest_constraint.rb +67 -0
- data/lib/pray/manifest_formatter.rb +10 -6
- data/lib/pray/path_safety.rb +34 -0
- 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 +2 -2
- data/lib/pray/resolve.rb +6 -6
- 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/version.rb +2 -2
- data/lib/pray.rb +6 -0
- metadata +9 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 85c1a7462b69a935d6e9a83d1489c43a445e63190dcebb5481744de051bf562a
|
|
4
|
+
data.tar.gz: 778142b6ea1ac16ae0762dc9015a30f20ca06cfb3abcb715fa37eea2620a05ff
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 7199ad20e75fd83ad98c97d0bbf9f97ae4c6d91bc2e2a8dca0cf4b14339da30a7396d8b076e0f789d8f17f0334b2b10853c8298e429705ef99ccaccc02b88f09
|
|
7
|
+
data.tar.gz: 19ea0fb475fdfeabc7ab7729e40d2228eba70be9f52becd2923d9b2c770a31fc146215aa2fa65b04c2e14733114c672f67a906e364841df027fedbcd9ca6f61f
|
data/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,19 @@
|
|
|
1
1
|
# CHANGELOG
|
|
2
2
|
|
|
3
|
-
##
|
|
3
|
+
## 1.9.2 (2026-09-01)
|
|
4
|
+
|
|
5
|
+
- Fix `.praypkg` unpack for git and registry installs when the Ruby process forces UTF-8 internal encoding.
|
|
6
|
+
- Treat empty or corrupt `.pray/cache/registry` directories as not ready so the next install unpacks again.
|
|
7
|
+
- Reject archive members that escape the package root or exceed size limits; unpack through staging into cache.
|
|
8
|
+
- Resolve packages from a matching source namespace or sole source without an explicit `source:`.
|
|
9
|
+
- Move login sessions to the user Pray home with owner-only permissions and migrate legacy repository sessions.
|
|
10
|
+
- Reject manifest paths outside the project; require registry hashes and recheck cached package trees.
|
|
11
|
+
- Bound server requests, headers, connections, and timeouts; expose a readiness endpoint.
|
|
12
|
+
- Cap registry downloads at 64 MiB; unpack `.praypkg` tar members without system tar; reject absolute artifact URLs.
|
|
13
|
+
|
|
14
|
+
## 1.9.1 (2026-08-29)
|
|
15
|
+
|
|
16
|
+
- Rewrite every matching `pray` line when a package constraint is updated, keeping indent and extra keywords.
|
|
4
17
|
|
|
5
18
|
## 1.7.0 (2026-07-29)
|
|
6
19
|
|
data/bin/pray
CHANGED
data/lib/pray/archive.rb
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
require "json"
|
|
4
4
|
require "open3"
|
|
5
5
|
require "fileutils"
|
|
6
|
-
|
|
6
|
+
require_relative "archive_unpack"
|
|
7
7
|
|
|
8
8
|
module Pray
|
|
9
9
|
module Archive
|
|
@@ -23,15 +23,20 @@ module Pray
|
|
|
23
23
|
File.binwrite(destination, File.binread(File.join(package.root, file)))
|
|
24
24
|
end
|
|
25
25
|
|
|
26
|
-
|
|
27
|
-
|
|
26
|
+
with_binary_process_encoding do
|
|
27
|
+
tar_bytes, status = Open3.capture2(
|
|
28
|
+
{"COPYFILE_DISABLE" => "1"},
|
|
29
|
+
"tar", "-cf", "-", "-C", staging, "."
|
|
30
|
+
)
|
|
31
|
+
raise Error.integrity("failed to build package tar archive") unless status.success?
|
|
28
32
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
+
zstd_bytes, status = Open3.capture2("zstd", "-q", "-c", stdin_data: tar_bytes)
|
|
34
|
+
unless status.success?
|
|
35
|
+
raise Error.unsupported("zstd is required to build package archives")
|
|
36
|
+
end
|
|
33
37
|
|
|
34
|
-
|
|
38
|
+
zstd_bytes
|
|
39
|
+
end
|
|
35
40
|
end
|
|
36
41
|
end
|
|
37
42
|
|
|
@@ -41,14 +46,7 @@ module Pray
|
|
|
41
46
|
end
|
|
42
47
|
|
|
43
48
|
def unpack_praypkg(artifact_bytes, output_directory)
|
|
44
|
-
|
|
45
|
-
tar_bytes, status = Open3.capture2("zstd", "-d", "-q", "-c", stdin_data: artifact_bytes)
|
|
46
|
-
unless status.success?
|
|
47
|
-
raise Error.unsupported("zstd is required to unpack package archives")
|
|
48
|
-
end
|
|
49
|
-
|
|
50
|
-
_stdout, _stderr, status = Open3.capture3("tar", "-xf", "-", "-C", output_directory, stdin_data: tar_bytes)
|
|
51
|
-
raise Error.integrity("failed to unpack package archive") unless status.success?
|
|
49
|
+
ArchiveUnpack.unpack_praypkg(artifact_bytes, output_directory)
|
|
52
50
|
end
|
|
53
51
|
|
|
54
52
|
def package_archive_path(package_name, version)
|
|
@@ -64,5 +62,9 @@ module Pray
|
|
|
64
62
|
"exports" => package.selected_exports
|
|
65
63
|
)
|
|
66
64
|
end
|
|
65
|
+
|
|
66
|
+
def with_binary_process_encoding(&block)
|
|
67
|
+
ArchiveUnpack.with_binary_process_encoding(&block)
|
|
68
|
+
end
|
|
67
69
|
end
|
|
68
70
|
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "find"
|
|
6
|
+
require_relative "path_safety"
|
|
7
|
+
require_relative "resource_limits"
|
|
8
|
+
require_relative "tar_validation"
|
|
9
|
+
|
|
10
|
+
module Pray
|
|
11
|
+
module ArchiveUnpack
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
def unpack_praypkg(artifact_bytes, output_directory)
|
|
15
|
+
if artifact_bytes.bytesize > ResourceLimits::MAX_ARCHIVE_TOTAL_BYTES
|
|
16
|
+
raise Error.integrity(
|
|
17
|
+
"package archive exceeds #{ResourceLimits::MAX_ARCHIVE_TOTAL_BYTES} bytes"
|
|
18
|
+
)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
tar_bytes = decompress_zstd(artifact_bytes)
|
|
22
|
+
if tar_bytes.bytesize > ResourceLimits::MAX_ARCHIVE_TOTAL_BYTES
|
|
23
|
+
raise Error.integrity(
|
|
24
|
+
"package archive exceeds #{ResourceLimits::MAX_ARCHIVE_TOTAL_BYTES} decompressed bytes"
|
|
25
|
+
)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
TarValidation.extract!(tar_bytes, output_directory)
|
|
29
|
+
reject_unsafe_extracted_tree!(output_directory)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def with_binary_process_encoding
|
|
33
|
+
previous = Encoding.default_internal
|
|
34
|
+
verbose = $VERBOSE
|
|
35
|
+
$VERBOSE = nil
|
|
36
|
+
Encoding.default_internal = nil
|
|
37
|
+
yield
|
|
38
|
+
ensure
|
|
39
|
+
Encoding.default_internal = previous
|
|
40
|
+
$VERBOSE = verbose
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def decompress_zstd(artifact_bytes)
|
|
44
|
+
with_binary_process_encoding do
|
|
45
|
+
tar_bytes, status = Open3.capture2("zstd", "-d", "-q", "-c", stdin_data: artifact_bytes)
|
|
46
|
+
unless status.success?
|
|
47
|
+
raise Error.unsupported("zstd is required to unpack package archives")
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
tar_bytes
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def reject_unsafe_extracted_tree!(output_directory)
|
|
55
|
+
Find.find(output_directory) do |path|
|
|
56
|
+
next if path == output_directory
|
|
57
|
+
next if File.directory?(path)
|
|
58
|
+
|
|
59
|
+
if File.symlink?(path)
|
|
60
|
+
raise Error.integrity("unsupported package archive entry type")
|
|
61
|
+
end
|
|
62
|
+
unless PathSafety.path_under_root?(output_directory, path)
|
|
63
|
+
raise Error.integrity("package path escapes package root: #{path}")
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "resource_limits"
|
|
4
|
+
|
|
5
|
+
module Pray
|
|
6
|
+
module HttpBody
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def reject_oversized_content_length!(content_length, max_bytes = ResourceLimits::MAX_HTTP_RESPONSE_BYTES)
|
|
10
|
+
return if content_length.nil?
|
|
11
|
+
return if content_length <= max_bytes
|
|
12
|
+
|
|
13
|
+
raise Error.resolution("HTTP response exceeds #{max_bytes} bytes")
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def append_chunk!(body, chunk, max_bytes = ResourceLimits::MAX_HTTP_RESPONSE_BYTES)
|
|
17
|
+
body << chunk
|
|
18
|
+
return body if body.bytesize <= max_bytes
|
|
19
|
+
|
|
20
|
+
raise Error.resolution("HTTP response exceeds #{max_bytes} bytes")
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def read_response!(response, max_bytes = ResourceLimits::MAX_HTTP_RESPONSE_BYTES)
|
|
24
|
+
length = response["content-length"]
|
|
25
|
+
parsed = length && Integer(length, exception: false)
|
|
26
|
+
reject_oversized_content_length!(parsed, max_bytes)
|
|
27
|
+
body = +"".b
|
|
28
|
+
response.read_body do |chunk|
|
|
29
|
+
append_chunk!(body, chunk, max_bytes)
|
|
30
|
+
end
|
|
31
|
+
body
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
data/lib/pray/manifest.rb
CHANGED
|
@@ -5,6 +5,7 @@ require_relative "manifest_formatter"
|
|
|
5
5
|
require_relative "manifest_parser_helpers"
|
|
6
6
|
require_relative "manifest_parser_blocks"
|
|
7
7
|
require_relative "manifest_parser"
|
|
8
|
+
require_relative "path_safety"
|
|
8
9
|
|
|
9
10
|
module Pray
|
|
10
11
|
RenderPolicy = Struct.new(
|
|
@@ -118,7 +119,22 @@ module Pray
|
|
|
118
119
|
|
|
119
120
|
def parse_manifest(text)
|
|
120
121
|
lines = Literal.prepare_parser_lines(text)
|
|
121
|
-
BlockParser.new(lines).parse_root
|
|
122
|
+
manifest = BlockParser.new(lines).parse_root
|
|
123
|
+
validate_manifest_paths!(manifest)
|
|
124
|
+
manifest
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def validate_manifest_paths!(manifest)
|
|
128
|
+
manifest.targets.each do |target|
|
|
129
|
+
(target.outputs + target.skills + target.commands + target.rules).each do |path|
|
|
130
|
+
PathSafety.validate_project_relative_path!(path)
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
manifest.packages.each do |package|
|
|
134
|
+
PathSafety.validate_project_relative_path!(package.path) if package.path
|
|
135
|
+
PathSafety.validate_project_relative_path!(package.file) if package.file
|
|
136
|
+
end
|
|
137
|
+
manifest.local.each { |local| PathSafety.validate_project_relative_path!(local.path) }
|
|
122
138
|
end
|
|
123
139
|
end
|
|
124
140
|
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pray
|
|
4
|
+
module ManifestMethods
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
PACKAGE_KEYWORDS = %w[pray use include agent package].freeze
|
|
8
|
+
|
|
9
|
+
def rewrite_constraint_on_line(line, constraint)
|
|
10
|
+
indent = line[/\A\s*/]
|
|
11
|
+
trimmed = line.lstrip
|
|
12
|
+
after_keyword = skip_package_keyword(trimmed)
|
|
13
|
+
raise Error.manifest("package declaration is missing a keyword") unless after_keyword
|
|
14
|
+
|
|
15
|
+
after_keyword = after_keyword.lstrip
|
|
16
|
+
parsed_name = parse_quoted(after_keyword)
|
|
17
|
+
raise Error.manifest("package declaration is missing a quoted name") unless parsed_name
|
|
18
|
+
|
|
19
|
+
name, after_name = parsed_name
|
|
20
|
+
quoted_constraint = "\"#{constraint}\""
|
|
21
|
+
keyword_and_name = trimmed[0, trimmed.length - after_name.length]
|
|
22
|
+
remainder = after_name.lstrip
|
|
23
|
+
return "#{indent}#{keyword_and_name}, #{quoted_constraint}" if remainder.empty?
|
|
24
|
+
unless remainder.start_with?(",")
|
|
25
|
+
raise Error.manifest("package #{name} declaration is missing a comma after the name")
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
after_comma = remainder[1..].lstrip
|
|
29
|
+
if after_comma.start_with?("\"", "'")
|
|
30
|
+
parsed_constraint = parse_quoted(after_comma)
|
|
31
|
+
unless parsed_constraint
|
|
32
|
+
raise Error.manifest("package #{name} declaration has an unclosed constraint")
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
return "#{indent}#{keyword_and_name}, #{quoted_constraint}#{parsed_constraint[1]}"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
"#{indent}#{keyword_and_name}, #{quoted_constraint}, #{after_comma}"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def skip_package_keyword(input)
|
|
42
|
+
PACKAGE_KEYWORDS.each do |keyword|
|
|
43
|
+
next unless input.start_with?(keyword)
|
|
44
|
+
|
|
45
|
+
rest = input[keyword.length..]
|
|
46
|
+
next_character = rest[0]
|
|
47
|
+
whitespace_or_quote = next_character.nil? ||
|
|
48
|
+
next_character.match?(/\s/) ||
|
|
49
|
+
next_character == "\"" ||
|
|
50
|
+
next_character == "'"
|
|
51
|
+
return rest if whitespace_or_quote
|
|
52
|
+
end
|
|
53
|
+
nil
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def parse_quoted(input)
|
|
57
|
+
quote = input[0]
|
|
58
|
+
return unless quote == "\"" || quote == "'"
|
|
59
|
+
|
|
60
|
+
rest = input[1..]
|
|
61
|
+
ending = rest.index(quote)
|
|
62
|
+
return unless ending
|
|
63
|
+
|
|
64
|
+
[rest[0, ending], rest[(ending + 1)..]]
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "manifest_constraint"
|
|
4
|
+
|
|
3
5
|
module Pray
|
|
4
6
|
module ManifestMethods
|
|
5
7
|
module_function
|
|
@@ -37,13 +39,15 @@ module Pray
|
|
|
37
39
|
"package \"#{name}\"", "package '#{name}'"
|
|
38
40
|
]
|
|
39
41
|
lines = text.lines.map(&:chomp)
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
raise Error.manifest("package #{name} not found in manifest") unless index
|
|
42
|
+
replaced = 0
|
|
43
|
+
lines.each_index do |index|
|
|
44
|
+
trimmed = lines[index].lstrip
|
|
45
|
+
next unless prefixes.any? { |prefix| trimmed.start_with?(prefix) }
|
|
45
46
|
|
|
46
|
-
|
|
47
|
+
lines[index] = rewrite_constraint_on_line(lines[index], package.constraint)
|
|
48
|
+
replaced += 1
|
|
49
|
+
end
|
|
50
|
+
raise Error.manifest("package #{name} not found in manifest") if replaced.zero?
|
|
47
51
|
output = lines.join("\n")
|
|
48
52
|
output += "\n" if text.end_with?("\n") && !output.end_with?("\n")
|
|
49
53
|
output
|
data/lib/pray/path_safety.rb
CHANGED
|
@@ -37,5 +37,39 @@ module Pray
|
|
|
37
37
|
|
|
38
38
|
cleaned
|
|
39
39
|
end
|
|
40
|
+
|
|
41
|
+
def validate_archive_member_path!(path)
|
|
42
|
+
cleaned = path.to_s.delete_prefix("./")
|
|
43
|
+
if cleaned.empty? || Pathname.new(cleaned).absolute? || cleaned.start_with?("/")
|
|
44
|
+
raise Error.integrity("package path must be relative: #{path}")
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
cleaned.split("/").each do |part|
|
|
48
|
+
next if part.empty? || part == "."
|
|
49
|
+
|
|
50
|
+
if part == ".." || part.include?("\0")
|
|
51
|
+
raise Error.integrity("package path escapes package root: #{path}")
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
cleaned
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def validate_project_relative_path!(value)
|
|
59
|
+
path = value.to_s.strip
|
|
60
|
+
windows_absolute = path.match?(/\A(?:[A-Za-z]:[\\\/]|[\\\/]{2})/)
|
|
61
|
+
if path.empty? || Pathname.new(path).absolute? || windows_absolute
|
|
62
|
+
raise Error.manifest("project path must be repository-relative: #{value}")
|
|
63
|
+
end
|
|
64
|
+
parts = path.tr("\\", "/").split("/")
|
|
65
|
+
if parts.include?("..") || path.include?("\0")
|
|
66
|
+
raise Error.manifest("project path escapes repository root: #{value}")
|
|
67
|
+
end
|
|
68
|
+
if parts.all? { |part| part.empty? || part == "." }
|
|
69
|
+
raise Error.manifest("project path must be repository-relative: #{value}")
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
path
|
|
73
|
+
end
|
|
40
74
|
end
|
|
41
75
|
end
|
data/lib/pray/registry.rb
CHANGED
|
@@ -6,6 +6,7 @@ require "uri"
|
|
|
6
6
|
require "fileutils"
|
|
7
7
|
require "pathname"
|
|
8
8
|
require_relative "path_safety"
|
|
9
|
+
require_relative "http_body"
|
|
9
10
|
|
|
10
11
|
module Pray
|
|
11
12
|
RegistryPackageVersion = Struct.new(
|
|
@@ -33,12 +34,15 @@ module Pray
|
|
|
33
34
|
)
|
|
34
35
|
|
|
35
36
|
module Registry
|
|
37
|
+
extend RegistryIntegrity
|
|
38
|
+
|
|
36
39
|
module_function
|
|
37
40
|
|
|
38
41
|
def resolve_registry_package_root(project_root, source_url, declaration, preferred_version: nil, offline: false)
|
|
39
42
|
metadata = fetch_package_metadata(source_url, declaration.name)
|
|
40
43
|
registry_latest_version = registry_latest_version_label(metadata)
|
|
41
44
|
selected = select_package_version(metadata, declaration.constraint, preferred_version)
|
|
45
|
+
require_integrity_fields!(declaration.name, selected)
|
|
42
46
|
cache_directory = registry_cache_directory(
|
|
43
47
|
project_root,
|
|
44
48
|
source_url,
|
|
@@ -57,11 +61,10 @@ module Pray
|
|
|
57
61
|
|
|
58
62
|
raise Error.resolution(offline_package_error(declaration.name, selected.version)) if offline
|
|
59
63
|
|
|
60
|
-
FileUtils.rm_rf(cache_directory) if File.exist?(cache_directory)
|
|
61
|
-
FileUtils.mkdir_p(cache_directory)
|
|
62
|
-
|
|
63
64
|
artifact_bytes = read_artifact_bytes(source_url, selected.artifact)
|
|
64
|
-
|
|
65
|
+
RegistryInstall.install_artifact_to_cache(
|
|
66
|
+
cache_directory, declaration, selected, artifact_bytes, source_url: source_url
|
|
67
|
+
)
|
|
65
68
|
|
|
66
69
|
RegistryPackageResolution.new(
|
|
67
70
|
root: cache_directory,
|
|
@@ -83,6 +86,7 @@ module Pray
|
|
|
83
86
|
metadata = parse_metadata(File.read(metadata_path, encoding: "UTF-8"))
|
|
84
87
|
registry_latest_version = registry_latest_version_label(metadata)
|
|
85
88
|
selected = select_package_version(metadata, declaration.constraint, preferred_version)
|
|
89
|
+
require_integrity_fields!(declaration.name, selected)
|
|
86
90
|
cache_directory = registry_cache_directory(
|
|
87
91
|
project_root,
|
|
88
92
|
source_key,
|
|
@@ -101,11 +105,10 @@ module Pray
|
|
|
101
105
|
|
|
102
106
|
raise Error.resolution(offline_package_error(declaration.name, selected.version)) if offline
|
|
103
107
|
|
|
104
|
-
FileUtils.rm_rf(cache_directory) if File.exist?(cache_directory)
|
|
105
|
-
FileUtils.mkdir_p(cache_directory)
|
|
106
|
-
|
|
107
108
|
artifact_bytes = read_local_artifact_bytes(source_root, selected.artifact)
|
|
108
|
-
|
|
109
|
+
RegistryInstall.install_artifact_to_cache(
|
|
110
|
+
cache_directory, declaration, selected, artifact_bytes, source_url: source_root
|
|
111
|
+
)
|
|
109
112
|
|
|
110
113
|
RegistryPackageResolution.new(
|
|
111
114
|
root: cache_directory,
|
|
@@ -177,51 +180,13 @@ module Pray
|
|
|
177
180
|
Gem::Version.new(left) <=> Gem::Version.new(right)
|
|
178
181
|
end
|
|
179
182
|
|
|
180
|
-
def validate_and_unpack(cache_directory, declaration, selected, artifact_bytes, source_url: nil)
|
|
181
|
-
if selected.artifact_hash
|
|
182
|
-
artifact_hash = Hashing.sha256_prefixed(artifact_bytes)
|
|
183
|
-
if artifact_hash != selected.artifact_hash
|
|
184
|
-
raise Error.integrity(
|
|
185
|
-
"package artifact hash mismatch for #{declaration.name} #{selected.version}"
|
|
186
|
-
)
|
|
187
|
-
end
|
|
188
|
-
end
|
|
189
|
-
|
|
190
|
-
verify_registry_signature!(declaration, selected, artifact_bytes)
|
|
191
|
-
Trust.verify_publisher_fingerprint!(source_url, selected) if source_url
|
|
192
|
-
|
|
193
|
-
Archive.unpack_praypkg(artifact_bytes, cache_directory)
|
|
194
|
-
spec_path = Resolve.find_prayspec_file(cache_directory)
|
|
195
|
-
spec = Pray.parse_package_spec(File.read(spec_path)).canonicalized
|
|
196
|
-
if spec.name != declaration.name
|
|
197
|
-
raise Error.resolution(
|
|
198
|
-
"package path #{cache_directory.inspect} declares #{spec.name.inspect}, expected #{declaration.name.inspect}"
|
|
199
|
-
)
|
|
200
|
-
end
|
|
201
|
-
if spec.version != selected.version
|
|
202
|
-
raise Error.resolution(
|
|
203
|
-
"package #{declaration.name} version #{spec.version} does not match registry version #{selected.version}"
|
|
204
|
-
)
|
|
205
|
-
end
|
|
206
|
-
if selected.tree_hash
|
|
207
|
-
actual_tree_hash = spec.tree_hash_for_root(cache_directory)
|
|
208
|
-
if actual_tree_hash != selected.tree_hash
|
|
209
|
-
raise Error.integrity(
|
|
210
|
-
"package tree hash mismatch for #{declaration.name} #{selected.version}"
|
|
211
|
-
)
|
|
212
|
-
end
|
|
213
|
-
end
|
|
214
|
-
end
|
|
215
|
-
|
|
216
183
|
def read_local_artifact_bytes(source_root, artifact)
|
|
217
|
-
if artifact.start_with?("http://", "https://")
|
|
218
|
-
return http_get(artifact)
|
|
219
|
-
end
|
|
220
184
|
if artifact.start_with?("file://")
|
|
221
185
|
path = PathSafety.join_under_root(source_root, artifact.delete_prefix("file://"))
|
|
222
186
|
raise Error.resolution("package artifact path escapes distribution root") unless path
|
|
223
187
|
return File.binread(path)
|
|
224
188
|
end
|
|
189
|
+
reject_absolute_artifact!(artifact)
|
|
225
190
|
|
|
226
191
|
path = PathSafety.join_under_root(source_root, artifact)
|
|
227
192
|
raise Error.resolution("package artifact path escapes distribution root") unless path
|
|
@@ -233,17 +198,14 @@ module Pray
|
|
|
233
198
|
def read_artifact_bytes(source_url, artifact)
|
|
234
199
|
return read_local_artifact_bytes(source_url, artifact) if local_source?(source_url)
|
|
235
200
|
|
|
201
|
+
reject_absolute_artifact!(artifact)
|
|
236
202
|
http_get(join_url(source_url, artifact))
|
|
237
203
|
end
|
|
238
204
|
|
|
239
|
-
def
|
|
240
|
-
return
|
|
205
|
+
def reject_absolute_artifact!(artifact)
|
|
206
|
+
return unless artifact.match?(%r{\A[a-z][a-z0-9+.-]*:}i)
|
|
241
207
|
|
|
242
|
-
|
|
243
|
-
spec = Pray.parse_package_spec(File.read(spec_path)).canonicalized
|
|
244
|
-
spec.version == selected.version
|
|
245
|
-
rescue Errno::ENOENT, Errno::ENOTDIR, SystemCallError
|
|
246
|
-
false
|
|
208
|
+
raise Error.integrity("remote artifact path must be relative: #{artifact}")
|
|
247
209
|
end
|
|
248
210
|
|
|
249
211
|
def registry_metadata_path(source_root, package_name)
|
|
@@ -301,12 +263,7 @@ module Pray
|
|
|
301
263
|
|
|
302
264
|
def http_get(url)
|
|
303
265
|
uri = URI(url)
|
|
304
|
-
|
|
305
|
-
unless response.is_a?(Net::HTTPSuccess)
|
|
306
|
-
raise Error.resolution("HTTP request failed for #{url}: #{response.code}")
|
|
307
|
-
end
|
|
308
|
-
|
|
309
|
-
response.body
|
|
266
|
+
bounded_http_exchange(uri, Net::HTTP::Get.new(uri), "HTTP request failed for #{url}")
|
|
310
267
|
end
|
|
311
268
|
|
|
312
269
|
def http_put(url, content_type, body)
|
|
@@ -314,12 +271,7 @@ module Pray
|
|
|
314
271
|
request = Net::HTTP::Put.new(uri)
|
|
315
272
|
request["Content-Type"] = content_type
|
|
316
273
|
request.body = body
|
|
317
|
-
|
|
318
|
-
unless response.is_a?(Net::HTTPSuccess)
|
|
319
|
-
raise Error.resolution("HTTP upload failed for #{url}: #{response.code}")
|
|
320
|
-
end
|
|
321
|
-
|
|
322
|
-
response.body
|
|
274
|
+
bounded_http_exchange(uri, request, "HTTP upload failed for #{url}")
|
|
323
275
|
end
|
|
324
276
|
|
|
325
277
|
def http_post(url, content_type, body)
|
|
@@ -327,12 +279,23 @@ module Pray
|
|
|
327
279
|
request = Net::HTTP::Post.new(uri)
|
|
328
280
|
request["Content-Type"] = content_type
|
|
329
281
|
request.body = body
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
282
|
+
bounded_http_exchange(uri, request, "HTTP request failed for #{url}")
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
def bounded_http_exchange(uri, request, failure_message)
|
|
286
|
+
body = nil
|
|
287
|
+
code = nil
|
|
288
|
+
success = false
|
|
289
|
+
http_request(uri) do |http|
|
|
290
|
+
http.request(request) do |response|
|
|
291
|
+
success = response.is_a?(Net::HTTPSuccess)
|
|
292
|
+
code = response.code
|
|
293
|
+
body = HttpBody.read_response!(response)
|
|
294
|
+
end
|
|
333
295
|
end
|
|
296
|
+
raise Error.resolution("#{failure_message}: #{code}") unless success
|
|
334
297
|
|
|
335
|
-
|
|
298
|
+
body
|
|
336
299
|
end
|
|
337
300
|
|
|
338
301
|
def http_request(uri)
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
|
|
5
|
+
module Pray
|
|
6
|
+
module RegistryInstall
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def install_artifact_to_cache(cache_directory, declaration, selected, artifact_bytes, source_url: nil)
|
|
10
|
+
staging_directory = "#{cache_directory}.staging"
|
|
11
|
+
FileUtils.rm_rf(staging_directory)
|
|
12
|
+
FileUtils.mkdir_p(staging_directory)
|
|
13
|
+
unpacked_directory = File.join(staging_directory, "unpacked")
|
|
14
|
+
FileUtils.mkdir_p(unpacked_directory)
|
|
15
|
+
|
|
16
|
+
begin
|
|
17
|
+
Registry.validate_and_unpack(
|
|
18
|
+
unpacked_directory, declaration, selected, artifact_bytes, source_url: source_url
|
|
19
|
+
)
|
|
20
|
+
FileUtils.rm_rf(cache_directory) if File.exist?(cache_directory)
|
|
21
|
+
FileUtils.mv(unpacked_directory, cache_directory)
|
|
22
|
+
rescue
|
|
23
|
+
FileUtils.rm_rf(staging_directory)
|
|
24
|
+
raise
|
|
25
|
+
else
|
|
26
|
+
FileUtils.rm_rf(staging_directory)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pray
|
|
4
|
+
module RegistryIntegrity
|
|
5
|
+
def validate_and_unpack(cache_directory, declaration, selected, artifact_bytes, source_url: nil)
|
|
6
|
+
require_integrity_fields!(declaration.name, selected)
|
|
7
|
+
artifact_hash = Hashing.sha256_prefixed(artifact_bytes)
|
|
8
|
+
if artifact_hash != selected.artifact_hash
|
|
9
|
+
raise Error.integrity(
|
|
10
|
+
"package artifact hash mismatch for #{declaration.name} #{selected.version}"
|
|
11
|
+
)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
verify_registry_signature!(declaration, selected, artifact_bytes)
|
|
15
|
+
Trust.verify_publisher_fingerprint!(source_url, selected) if source_url
|
|
16
|
+
Archive.unpack_praypkg(artifact_bytes, cache_directory)
|
|
17
|
+
spec_path = Resolve.find_prayspec_file(cache_directory)
|
|
18
|
+
spec = Pray.parse_package_spec(File.read(spec_path)).canonicalized
|
|
19
|
+
validate_package_identity!(cache_directory, declaration, selected, spec)
|
|
20
|
+
actual_tree_hash = spec.tree_hash_for_root(cache_directory)
|
|
21
|
+
if actual_tree_hash != selected.tree_hash
|
|
22
|
+
raise Error.integrity(
|
|
23
|
+
"package tree hash mismatch for #{declaration.name} #{selected.version}"
|
|
24
|
+
)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def require_integrity_fields!(package_name, selected)
|
|
29
|
+
if selected.artifact_hash.to_s.empty?
|
|
30
|
+
raise Error.integrity("package #{package_name} #{selected.version} is missing artifact_hash")
|
|
31
|
+
end
|
|
32
|
+
if selected.tree_hash.to_s.empty?
|
|
33
|
+
raise Error.integrity("package #{package_name} #{selected.version} is missing tree_hash")
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def cache_ready?(cache_directory, selected)
|
|
38
|
+
return false unless File.directory?(cache_directory)
|
|
39
|
+
|
|
40
|
+
spec_path = Resolve.find_prayspec_file(cache_directory)
|
|
41
|
+
spec = Pray.parse_package_spec(File.read(spec_path)).canonicalized
|
|
42
|
+
spec.version == selected.version && !selected.tree_hash.to_s.empty? &&
|
|
43
|
+
spec.tree_hash_for_root(cache_directory) == selected.tree_hash
|
|
44
|
+
rescue Error, SystemCallError
|
|
45
|
+
false
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def validate_package_identity!(cache_directory, declaration, selected, spec)
|
|
51
|
+
if spec.name != declaration.name
|
|
52
|
+
raise Error.resolution(
|
|
53
|
+
"package path #{cache_directory.inspect} declares #{spec.name.inspect}, expected #{declaration.name.inspect}"
|
|
54
|
+
)
|
|
55
|
+
end
|
|
56
|
+
return if spec.version == selected.version
|
|
57
|
+
|
|
58
|
+
raise Error.resolution(
|
|
59
|
+
"package #{declaration.name} version #{spec.version} does not match registry version #{selected.version}"
|
|
60
|
+
)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
data/lib/pray/render.rb
CHANGED
|
@@ -18,7 +18,6 @@ module Pray
|
|
|
18
18
|
project.manifest.targets.each do |target|
|
|
19
19
|
output = target.outputs.first
|
|
20
20
|
next unless output
|
|
21
|
-
|
|
22
21
|
rendered << render_target(project, target, output)
|
|
23
22
|
end
|
|
24
23
|
rendered
|
|
@@ -26,6 +25,7 @@ module Pray
|
|
|
26
25
|
|
|
27
26
|
def write_rendered_targets(project, rendered)
|
|
28
27
|
rendered.each do |target|
|
|
28
|
+
PathSafety.validate_project_relative_path!(target.path)
|
|
29
29
|
path = File.join(project.project_root, target.path)
|
|
30
30
|
FileUtils.mkdir_p(File.dirname(path))
|
|
31
31
|
File.write(path, target.content)
|
|
@@ -36,6 +36,7 @@ module Pray
|
|
|
36
36
|
def materialize_provisioned_exports(project)
|
|
37
37
|
symbols = project.manifest.symbols || {}
|
|
38
38
|
planned_provisioned_files(project).each do |file|
|
|
39
|
+
PathSafety.validate_project_relative_path!(file.path)
|
|
39
40
|
destination = File.join(project.project_root, file.path)
|
|
40
41
|
FileUtils.mkdir_p(File.dirname(destination))
|
|
41
42
|
write_provisioned_file(file.source, destination, symbols)
|
|
@@ -76,7 +77,6 @@ module Pray
|
|
|
76
77
|
if target.scoped && target.mode == "compose"
|
|
77
78
|
return render_scoped_compose(project, target, output)
|
|
78
79
|
end
|
|
79
|
-
|
|
80
80
|
render_legacy_compose(project, target, output)
|
|
81
81
|
end
|
|
82
82
|
|
data/lib/pray/resolve.rb
CHANGED
|
@@ -187,16 +187,16 @@ module Pray
|
|
|
187
187
|
return [File.expand_path(local_path, project_root), nil]
|
|
188
188
|
end
|
|
189
189
|
return [File.expand_path(declaration.path, project_root), nil] if declaration.path
|
|
190
|
+
source_name = ResolveSource.implied_source_name(declaration, sources)
|
|
191
|
+
if source_name
|
|
192
|
+
source = sources[source_name]
|
|
193
|
+
raise Error.resolution("unknown source: #{source_name}") unless source
|
|
190
194
|
|
|
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])
|
|
195
|
+
if (local_path = user_config.local.source[source_name])
|
|
196
196
|
source_root = File.expand_path(local_path, project_root)
|
|
197
197
|
resolved = Registry.resolve_local_registry_package_root(
|
|
198
198
|
project_root,
|
|
199
|
-
"local:#{
|
|
199
|
+
"local:#{source_name}",
|
|
200
200
|
source_root,
|
|
201
201
|
declaration,
|
|
202
202
|
preferred_version: lockfile_preferred_version(lockfile, declaration.name),
|
|
@@ -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
|
data/lib/pray/session.rb
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
4
|
require "fileutils"
|
|
5
|
+
require_relative "trust"
|
|
5
6
|
|
|
6
7
|
module Pray
|
|
7
8
|
SessionFile = Struct.new(
|
|
@@ -12,13 +13,14 @@ module Pray
|
|
|
12
13
|
module Session
|
|
13
14
|
module_function
|
|
14
15
|
|
|
15
|
-
def session_file_path(
|
|
16
|
-
File.join(
|
|
16
|
+
def session_file_path(_root)
|
|
17
|
+
File.join(Trust.trust_home, "session.json")
|
|
17
18
|
end
|
|
18
19
|
|
|
19
20
|
def persist(root, session)
|
|
20
21
|
path = session_file_path(root)
|
|
21
22
|
FileUtils.mkdir_p(File.dirname(path))
|
|
23
|
+
migrate_legacy_session(root, path)
|
|
22
24
|
sessions = load_sessions(path)
|
|
23
25
|
existing = sessions.find { |entry| entry.server_url == session.server_url }
|
|
24
26
|
if existing
|
|
@@ -34,12 +36,14 @@ module Pray
|
|
|
34
36
|
else
|
|
35
37
|
{"sessions" => sessions.map { |entry| session_to_hash(entry) }}
|
|
36
38
|
end
|
|
37
|
-
|
|
39
|
+
write_document(path, document)
|
|
38
40
|
session
|
|
39
41
|
end
|
|
40
42
|
|
|
41
43
|
def load_latest(root)
|
|
42
|
-
|
|
44
|
+
path = session_file_path(root)
|
|
45
|
+
migrate_legacy_session(root, path)
|
|
46
|
+
sessions = load_sessions(path)
|
|
43
47
|
sessions.reverse.find { |session| !session.email.to_s.strip.empty? }
|
|
44
48
|
end
|
|
45
49
|
|
|
@@ -86,5 +90,33 @@ module Pray
|
|
|
86
90
|
signer_fingerprint: entry["signer_fingerprint"]
|
|
87
91
|
)
|
|
88
92
|
end
|
|
93
|
+
|
|
94
|
+
def migrate_legacy_session(root, path)
|
|
95
|
+
legacy_path = File.join(root, ".pray", "session.json")
|
|
96
|
+
return unless File.file?(legacy_path)
|
|
97
|
+
return if File.expand_path(legacy_path) == File.expand_path(path)
|
|
98
|
+
|
|
99
|
+
sessions = load_sessions(path)
|
|
100
|
+
load_sessions(legacy_path).each do |legacy|
|
|
101
|
+
sessions << legacy unless sessions.any? { |entry| entry.server_url == legacy.server_url }
|
|
102
|
+
end
|
|
103
|
+
document = (sessions.length == 1) ? session_to_hash(sessions.first) : {
|
|
104
|
+
"sessions" => sessions.map { |entry| session_to_hash(entry) }
|
|
105
|
+
}
|
|
106
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
107
|
+
write_document(path, document)
|
|
108
|
+
File.delete(legacy_path)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def write_document(path, document)
|
|
112
|
+
temporary_path = "#{path}.tmp-#{Process.pid}"
|
|
113
|
+
File.open(temporary_path, File::WRONLY | File::CREAT | File::TRUNC, 0o600) do |file|
|
|
114
|
+
file.write("#{JSON.pretty_generate(document)}\n")
|
|
115
|
+
file.flush
|
|
116
|
+
file.fsync
|
|
117
|
+
end
|
|
118
|
+
File.chmod(0o600, temporary_path)
|
|
119
|
+
File.rename(temporary_path, path)
|
|
120
|
+
end
|
|
89
121
|
end
|
|
90
122
|
end
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require_relative "path_safety"
|
|
5
|
+
require_relative "resource_limits"
|
|
6
|
+
|
|
7
|
+
module Pray
|
|
8
|
+
module TarValidation
|
|
9
|
+
BLOCK_BYTES = 512
|
|
10
|
+
State = Struct.new(:paths, :entry_count, :total_bytes, :pending_path, keyword_init: true)
|
|
11
|
+
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
def extract!(bytes, output_directory)
|
|
15
|
+
FileUtils.mkdir_p(output_directory)
|
|
16
|
+
validate!(bytes, output_directory: output_directory)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def validate!(bytes, output_directory: nil)
|
|
20
|
+
offset = 0
|
|
21
|
+
state = State.new(paths: {}, entry_count: 0, total_bytes: 0)
|
|
22
|
+
|
|
23
|
+
while offset + BLOCK_BYTES <= bytes.bytesize
|
|
24
|
+
header = bytes.byteslice(offset, BLOCK_BYTES)
|
|
25
|
+
return if header.bytes.all?(&:zero?)
|
|
26
|
+
|
|
27
|
+
verify_checksum!(header)
|
|
28
|
+
size = parse_octal(header.byteslice(124, 12), "entry size")
|
|
29
|
+
data_start = offset + BLOCK_BYTES
|
|
30
|
+
data_end = data_start + size
|
|
31
|
+
raise Error.integrity("truncated package archive entry") if data_end > bytes.bytesize
|
|
32
|
+
|
|
33
|
+
process_entry(header, bytes.byteslice(data_start, size), size, state, output_directory)
|
|
34
|
+
offset = data_start + ((size + BLOCK_BYTES - 1) / BLOCK_BYTES * BLOCK_BYTES)
|
|
35
|
+
end
|
|
36
|
+
raise Error.integrity("package archive is missing its end marker")
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def process_entry(header, data, size, state, output_directory)
|
|
40
|
+
type = header.byteslice(156, 1)
|
|
41
|
+
if type == "x"
|
|
42
|
+
state.pending_path = pax_path(data)
|
|
43
|
+
return
|
|
44
|
+
end
|
|
45
|
+
if type == "L"
|
|
46
|
+
state.pending_path = c_string(data).delete_suffix("\n")
|
|
47
|
+
return
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
raw_path = state.pending_path || header_path(header)
|
|
51
|
+
state.pending_path = nil
|
|
52
|
+
return if type == "5" && [".", "./"].include?(raw_path)
|
|
53
|
+
|
|
54
|
+
path = PathSafety.validate_archive_member_path!(raw_path)
|
|
55
|
+
return if File.basename(path).start_with?("._")
|
|
56
|
+
if type == "5"
|
|
57
|
+
write_extracted_directory(output_directory, path) if output_directory
|
|
58
|
+
return
|
|
59
|
+
end
|
|
60
|
+
validate_regular_entry!(type, path, size, state)
|
|
61
|
+
write_extracted_file(output_directory, path, data) if output_directory
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def validate_regular_entry!(type, path, size, state)
|
|
65
|
+
raise Error.integrity("unsupported package archive entry type") unless ["0", "\0"].include?(type)
|
|
66
|
+
|
|
67
|
+
state.entry_count += 1
|
|
68
|
+
if state.entry_count > ResourceLimits::MAX_ARCHIVE_ENTRIES
|
|
69
|
+
raise Error.integrity("package archive exceeds #{ResourceLimits::MAX_ARCHIVE_ENTRIES} entries")
|
|
70
|
+
end
|
|
71
|
+
raise Error.integrity("duplicate package archive path: #{path}") if state.paths.key?(path)
|
|
72
|
+
|
|
73
|
+
state.paths[path] = true
|
|
74
|
+
if size > ResourceLimits::MAX_ARCHIVE_ENTRY_BYTES
|
|
75
|
+
raise Error.integrity("package archive entry exceeds #{ResourceLimits::MAX_ARCHIVE_ENTRY_BYTES} bytes: #{path}")
|
|
76
|
+
end
|
|
77
|
+
state.total_bytes += size
|
|
78
|
+
if state.total_bytes > ResourceLimits::MAX_ARCHIVE_TOTAL_BYTES
|
|
79
|
+
raise Error.integrity("package archive exceeds #{ResourceLimits::MAX_ARCHIVE_TOTAL_BYTES} decompressed bytes")
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def write_extracted_directory(output_directory, path)
|
|
84
|
+
FileUtils.mkdir_p(extracted_destination(output_directory, path))
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def write_extracted_file(output_directory, path, data)
|
|
88
|
+
destination = extracted_destination(output_directory, path)
|
|
89
|
+
FileUtils.mkdir_p(File.dirname(destination))
|
|
90
|
+
File.open(destination, File::WRONLY | File::CREAT | File::EXCL | File::BINARY) do |file|
|
|
91
|
+
file.write(data)
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def extracted_destination(output_directory, path)
|
|
96
|
+
destination = File.join(output_directory, path)
|
|
97
|
+
unless PathSafety.path_under_root?(output_directory, destination)
|
|
98
|
+
raise Error.integrity("package path escapes package root: #{path}")
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
destination
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def verify_checksum!(header)
|
|
105
|
+
stored = parse_octal(header.byteslice(148, 6), "checksum")
|
|
106
|
+
sum = 0
|
|
107
|
+
header.bytes.each_with_index do |byte, index|
|
|
108
|
+
sum += (index >= 148 && index < 156) ? 32 : byte
|
|
109
|
+
end
|
|
110
|
+
return if sum == stored
|
|
111
|
+
|
|
112
|
+
raise Error.integrity("invalid package archive checksum")
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def header_path(header)
|
|
116
|
+
name = c_string(header.byteslice(0, 100))
|
|
117
|
+
prefix = c_string(header.byteslice(345, 155))
|
|
118
|
+
prefix.empty? ? name : "#{prefix}/#{name}"
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def c_string(bytes)
|
|
122
|
+
bytes.to_s.split("\0", 2).first.to_s.force_encoding(Encoding::UTF_8).tap do |text|
|
|
123
|
+
raise Error.integrity("invalid package archive path encoding") unless text.valid_encoding?
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def parse_octal(bytes, label)
|
|
128
|
+
value = c_string(bytes).strip
|
|
129
|
+
raise Error.integrity("invalid package archive #{label}") unless value.match?(/\A[0-7]+\z/)
|
|
130
|
+
|
|
131
|
+
value.to_i(8)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def pax_path(data)
|
|
135
|
+
cursor = 0
|
|
136
|
+
path = nil
|
|
137
|
+
while cursor < data.bytesize
|
|
138
|
+
separator = data.index(" ".b, cursor)
|
|
139
|
+
length = separator && Integer(data.byteslice(cursor, separator - cursor), exception: false)
|
|
140
|
+
record_end = cursor + length.to_i
|
|
141
|
+
if !length || length <= 0 || record_end > data.bytesize
|
|
142
|
+
raise Error.integrity("invalid package archive pax header")
|
|
143
|
+
end
|
|
144
|
+
record = data.byteslice(separator + 1, record_end - separator - 2)
|
|
145
|
+
equals = record.index("=".b)
|
|
146
|
+
if equals && record.byteslice(0, equals) == "path"
|
|
147
|
+
value = record.byteslice(equals + 1, record.bytesize - equals - 1).force_encoding(Encoding::UTF_8)
|
|
148
|
+
raise Error.integrity("invalid package archive path encoding") unless value.valid_encoding?
|
|
149
|
+
|
|
150
|
+
path = value
|
|
151
|
+
end
|
|
152
|
+
cursor = record_end
|
|
153
|
+
end
|
|
154
|
+
path
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
data/lib/pray/version.rb
CHANGED
data/lib/pray.rb
CHANGED
|
@@ -19,12 +19,18 @@ require_relative "pray/dotenv"
|
|
|
19
19
|
require_relative "pray/project_context"
|
|
20
20
|
require_relative "pray/environment"
|
|
21
21
|
require_relative "pray/resolve_context"
|
|
22
|
+
require_relative "pray/resolve_source"
|
|
22
23
|
require_relative "pray/invocation"
|
|
23
24
|
require_relative "pray/resolve"
|
|
24
25
|
require_relative "pray/render"
|
|
25
26
|
require_relative "pray/verify_position"
|
|
26
27
|
require_relative "pray/verify"
|
|
28
|
+
require_relative "pray/resource_limits"
|
|
29
|
+
require_relative "pray/http_body"
|
|
30
|
+
require_relative "pray/registry_install"
|
|
31
|
+
require_relative "pray/registry_integrity"
|
|
27
32
|
require_relative "pray/registry"
|
|
33
|
+
require_relative "pray/archive_unpack"
|
|
28
34
|
require_relative "pray/archive"
|
|
29
35
|
require_relative "pray/plan"
|
|
30
36
|
require_relative "pray/git_sources"
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: pray-cli
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.9.
|
|
4
|
+
version: 1.9.2
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Andrei Makarov
|
|
@@ -97,6 +97,7 @@ files:
|
|
|
97
97
|
- lib/pray-cli.rb
|
|
98
98
|
- lib/pray.rb
|
|
99
99
|
- lib/pray/archive.rb
|
|
100
|
+
- lib/pray/archive_unpack.rb
|
|
100
101
|
- lib/pray/auth_client.rb
|
|
101
102
|
- lib/pray/cli.rb
|
|
102
103
|
- lib/pray/cli/commands/auth.rb
|
|
@@ -123,11 +124,13 @@ files:
|
|
|
123
124
|
- lib/pray/format_serialize.rb
|
|
124
125
|
- lib/pray/git_sources.rb
|
|
125
126
|
- lib/pray/hashing.rb
|
|
127
|
+
- lib/pray/http_body.rb
|
|
126
128
|
- lib/pray/invocation.rb
|
|
127
129
|
- lib/pray/literal.rb
|
|
128
130
|
- lib/pray/lockfile.rb
|
|
129
131
|
- lib/pray/lockfile_serialize.rb
|
|
130
132
|
- lib/pray/manifest.rb
|
|
133
|
+
- lib/pray/manifest_constraint.rb
|
|
131
134
|
- lib/pray/manifest_formatter.rb
|
|
132
135
|
- lib/pray/manifest_json.rb
|
|
133
136
|
- lib/pray/manifest_parser.rb
|
|
@@ -140,9 +143,13 @@ files:
|
|
|
140
143
|
- lib/pray/project_context.rb
|
|
141
144
|
- lib/pray/publish.rb
|
|
142
145
|
- lib/pray/registry.rb
|
|
146
|
+
- lib/pray/registry_install.rb
|
|
147
|
+
- lib/pray/registry_integrity.rb
|
|
143
148
|
- lib/pray/render.rb
|
|
144
149
|
- lib/pray/resolve.rb
|
|
145
150
|
- lib/pray/resolve_context.rb
|
|
151
|
+
- lib/pray/resolve_source.rb
|
|
152
|
+
- lib/pray/resource_limits.rb
|
|
146
153
|
- lib/pray/serve.rb
|
|
147
154
|
- lib/pray/serve_federation.rb
|
|
148
155
|
- lib/pray/session.rb
|
|
@@ -150,6 +157,7 @@ files:
|
|
|
150
157
|
- lib/pray/statement_surface.rb
|
|
151
158
|
- lib/pray/substitute.rb
|
|
152
159
|
- lib/pray/sync.rb
|
|
160
|
+
- lib/pray/tar_validation.rb
|
|
153
161
|
- lib/pray/terminal.rb
|
|
154
162
|
- lib/pray/trust.rb
|
|
155
163
|
- lib/pray/trust_feed.rb
|