thuban 0.3.0 → 0.4.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 +12 -0
- data/README.md +150 -7
- data/lib/thuban/pack_reader.rb +216 -0
- data/lib/thuban/remote/credentials.rb +209 -0
- data/lib/thuban/remote/fetch.rb +287 -0
- data/lib/thuban/remote/http.rb +233 -0
- data/lib/thuban/remote/local.rb +55 -0
- data/lib/thuban/remote/protocol.rb +91 -0
- data/lib/thuban/remote/push.rb +250 -0
- data/lib/thuban/remote/repository.rb +223 -0
- data/lib/thuban/remote/ssh.rb +248 -0
- data/lib/thuban/remote.rb +27 -0
- data/lib/thuban/version.rb +1 -1
- data/lib/thuban.rb +10 -0
- data/sig/thuban.rbs +65 -0
- metadata +14 -4
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Thuban
|
|
4
|
+
module Remote
|
|
5
|
+
class Connection
|
|
6
|
+
MAX_FETCH_RESPONSE_SIZE = Pack::MAX_PACK_SIZE
|
|
7
|
+
MAX_NEGOTIATION_OIDS = 10_000
|
|
8
|
+
|
|
9
|
+
def fetch(repository, wants:, haves: [], depth: nil, filter: nil)
|
|
10
|
+
ensure_open
|
|
11
|
+
raise TypeError, "expected Thuban::Repository" unless repository.is_a?(Repository)
|
|
12
|
+
depth = Protocol.validate_depth(depth)
|
|
13
|
+
filter = Protocol.validate_filter(filter)
|
|
14
|
+
|
|
15
|
+
wants = oid_list(wants, "wants", empty: false)
|
|
16
|
+
haves = oid_list(haves, "haves", empty: true)
|
|
17
|
+
raise ArgumentError, "have object not found" unless haves.all? { |oid| repository.odb.exist?(oid) }
|
|
18
|
+
|
|
19
|
+
refs unless @protocol_version
|
|
20
|
+
shallow = read_shallow(repository)
|
|
21
|
+
validate_fetch_capabilities(depth, filter, shallow)
|
|
22
|
+
body = if @protocol_version == 2
|
|
23
|
+
v2_fetch_request(wants, haves, shallow, depth, filter, progress: block_given?)
|
|
24
|
+
else
|
|
25
|
+
v0_fetch_request(wants, haves, shallow, depth, filter, progress: block_given?)
|
|
26
|
+
end
|
|
27
|
+
Tempfile.create(["thuban-fetch-response-", ".bin"]) do |response|
|
|
28
|
+
response.binmode
|
|
29
|
+
request_to(response, :post, "/git-upload-pack", body: body,
|
|
30
|
+
headers: {"Content-Type" => "application/x-git-upload-pack-request", "Accept" => "application/x-git-upload-pack-result",
|
|
31
|
+
"Git-Protocol" => "version=2"}, content_type: "application/x-git-upload-pack-result",
|
|
32
|
+
limit: MAX_FETCH_RESPONSE_SIZE)
|
|
33
|
+
response.flush
|
|
34
|
+
response.rewind
|
|
35
|
+
Tempfile.create(["thuban-fetch-pack-", ".pack"]) do |pack|
|
|
36
|
+
pack.binmode
|
|
37
|
+
changes = extract_fetch_response(response, pack, allow_shallow: depth || !shallow.empty?) do |event|
|
|
38
|
+
yield event if block_given?
|
|
39
|
+
end
|
|
40
|
+
pack.flush
|
|
41
|
+
pack.rewind
|
|
42
|
+
ensure_open
|
|
43
|
+
received = Pack.read_stream(pack, repository.odb) do |current, total|
|
|
44
|
+
ensure_open
|
|
45
|
+
yield Progress.new(phase: :pack, current: current, total: total, bytes: pack.size) if block_given?
|
|
46
|
+
end
|
|
47
|
+
ensure_open
|
|
48
|
+
raise TransportError, "fetch did not provide requested objects" unless wants.all? { |oid| repository.odb.exist?(oid) }
|
|
49
|
+
update_shallow(repository, shallow, changes)
|
|
50
|
+
|
|
51
|
+
received
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def oid_list(value, name, empty:)
|
|
59
|
+
raise TypeError, "#{name} must be enumerable" unless value.respond_to?(:each)
|
|
60
|
+
|
|
61
|
+
result = []
|
|
62
|
+
seen = {}
|
|
63
|
+
count = 0
|
|
64
|
+
value.each do |oid|
|
|
65
|
+
count += 1
|
|
66
|
+
raise ArgumentError, "too many #{name}" if count > MAX_NEGOTIATION_OIDS
|
|
67
|
+
|
|
68
|
+
oid = Protocol.validate_oid(oid)
|
|
69
|
+
result << oid unless seen[oid]
|
|
70
|
+
seen[oid] = true
|
|
71
|
+
end
|
|
72
|
+
raise ArgumentError, "at least one want is required" if !empty && result.empty?
|
|
73
|
+
|
|
74
|
+
result
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def validate_fetch_capabilities(depth, filter, shallow)
|
|
78
|
+
if @protocol_version == 2
|
|
79
|
+
capability = @capabilities.find { |line| line.split("=", 2).first == "fetch" }
|
|
80
|
+
raise TransportError, "server does not support fetch" unless capability
|
|
81
|
+
|
|
82
|
+
features = capability.split("=", 2).last.to_s.split
|
|
83
|
+
else
|
|
84
|
+
features = @capabilities
|
|
85
|
+
end
|
|
86
|
+
raise TransportError, "server does not support shallow fetch" if (depth || !shallow.empty?) && !features.include?("shallow")
|
|
87
|
+
raise TransportError, "server does not support partial fetch" if filter && !features.include?("filter")
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def v2_fetch_request(wants, haves, shallow, depth, filter, progress:)
|
|
91
|
+
lines = ["ofs-delta"]
|
|
92
|
+
lines << "no-progress" unless progress
|
|
93
|
+
lines.concat(shallow.map { |oid| "shallow #{oid}" })
|
|
94
|
+
lines << "deepen #{depth}" if depth
|
|
95
|
+
lines << "filter #{filter}" if filter
|
|
96
|
+
lines.concat(wants.map { |oid| "want #{oid}" })
|
|
97
|
+
lines.concat(haves.map { |oid| "have #{oid}" })
|
|
98
|
+
lines << "done"
|
|
99
|
+
Protocol.packet("command=fetch\n") + Protocol.delimiter +
|
|
100
|
+
lines.map { |line| Protocol.packet("#{line}\n") }.join + Protocol.flush
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def v0_fetch_request(wants, haves, shallow, depth, filter, progress:)
|
|
104
|
+
requested = [(!progress && "no-progress"), "side-band-64k", "ofs-delta", (filter && "filter")].compact
|
|
105
|
+
capabilities = requested.select { |capability| @capabilities.include?(capability) }
|
|
106
|
+
lines = wants.each_with_index.map do |oid, index|
|
|
107
|
+
suffix = index.zero? && !capabilities.empty? ? " #{capabilities.join(' ')}" : ""
|
|
108
|
+
Protocol.packet("want #{oid}#{suffix}\n")
|
|
109
|
+
end
|
|
110
|
+
lines.concat(shallow.map { |oid| Protocol.packet("shallow #{oid}\n") })
|
|
111
|
+
lines << Protocol.packet("deepen #{depth}\n") if depth
|
|
112
|
+
lines << Protocol.packet("filter #{filter}\n") if filter
|
|
113
|
+
lines.join + Protocol.flush + haves.map { |oid| Protocol.packet("have #{oid}\n") }.join +
|
|
114
|
+
Protocol.packet("done\n")
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def extract_fetch_response(input, output, allow_shallow: true)
|
|
118
|
+
reader = Protocol::Reader.new(input, max_bytes: MAX_FETCH_RESPONSE_SIZE)
|
|
119
|
+
packfile = @protocol_version.zero?
|
|
120
|
+
received = false
|
|
121
|
+
raw = false
|
|
122
|
+
terminated = false
|
|
123
|
+
section = nil
|
|
124
|
+
section_index = -1
|
|
125
|
+
shallow = []
|
|
126
|
+
unshallow = []
|
|
127
|
+
loop do
|
|
128
|
+
ensure_open
|
|
129
|
+
if packfile && raw_pack?(input)
|
|
130
|
+
copy_raw_pack(input, output) { |event| yield event if block_given? }
|
|
131
|
+
received = true
|
|
132
|
+
raw = true
|
|
133
|
+
break
|
|
134
|
+
end
|
|
135
|
+
packet = reader.read
|
|
136
|
+
break if packet.nil?
|
|
137
|
+
if packet == Protocol::RESPONSE_END
|
|
138
|
+
terminated = true
|
|
139
|
+
break
|
|
140
|
+
end
|
|
141
|
+
if packet == Protocol::FLUSH
|
|
142
|
+
if received || @protocol_version == 2
|
|
143
|
+
terminated = true
|
|
144
|
+
break
|
|
145
|
+
end
|
|
146
|
+
next
|
|
147
|
+
end
|
|
148
|
+
if packet == Protocol::DELIMITER
|
|
149
|
+
raise TransportError, "invalid fetch response delimiter" unless @protocol_version == 2 && section && section != "packfile"
|
|
150
|
+
|
|
151
|
+
section = nil
|
|
152
|
+
next
|
|
153
|
+
end
|
|
154
|
+
raise TransportError, "invalid fetch response" unless packet.is_a?(String)
|
|
155
|
+
raise TransportError, safe_message(packet.delete_prefix("ERR ")) if packet.start_with?("ERR ")
|
|
156
|
+
|
|
157
|
+
if @protocol_version == 2 && %w[acknowledgments shallow-info packfile].include?(packet.chomp)
|
|
158
|
+
next_section = packet.chomp
|
|
159
|
+
next_index = %w[acknowledgments shallow-info packfile].index(next_section)
|
|
160
|
+
raise TransportError, "invalid fetch response section" unless section.nil? && next_index > section_index
|
|
161
|
+
|
|
162
|
+
section = next_section
|
|
163
|
+
section_index = next_index
|
|
164
|
+
packfile = section == "packfile"
|
|
165
|
+
elsif packet.start_with?("shallow ", "unshallow ")
|
|
166
|
+
valid_section = @protocol_version.zero? || section == "shallow-info"
|
|
167
|
+
raise TransportError, "unexpected shallow response" unless allow_shallow && valid_section
|
|
168
|
+
|
|
169
|
+
state, oid = packet.chomp.split(" ", 2)
|
|
170
|
+
oid = Protocol.validate_oid(oid)
|
|
171
|
+
(state == "shallow" ? shallow : unshallow) << oid
|
|
172
|
+
elsif acknowledgment?(packet)
|
|
173
|
+
raise TransportError, "unexpected acknowledgment" unless @protocol_version.zero? || section == "acknowledgments"
|
|
174
|
+
elsif packfile
|
|
175
|
+
received = append_sideband(packet, output) { |event| yield event if block_given? } || received
|
|
176
|
+
else
|
|
177
|
+
raise TransportError, "unexpected fetch response section"
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
raise TransportError, "fetch response did not contain a pack" unless received
|
|
181
|
+
raise TransportError, "truncated fetch response" unless raw || terminated
|
|
182
|
+
raise TransportError, "fetch response continued after its terminator" if !raw && reader.read
|
|
183
|
+
raise TransportError, "contradictory shallow response" unless (shallow & unshallow).empty?
|
|
184
|
+
|
|
185
|
+
{shallow: shallow.uniq, unshallow: unshallow.uniq}
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def acknowledgment?(packet)
|
|
189
|
+
return true if ["acknowledgments\n", "NAK\n", "ready\n"].include?(packet)
|
|
190
|
+
return false unless packet.start_with?("ACK ")
|
|
191
|
+
|
|
192
|
+
oid, state = packet.chomp.delete_prefix("ACK ").split(" ", 2)
|
|
193
|
+
Protocol.validate_oid(oid)
|
|
194
|
+
raise TransportError, "invalid ACK status" if state && !%w[continue common ready].include?(state)
|
|
195
|
+
|
|
196
|
+
true
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def append_sideband(packet, output)
|
|
200
|
+
band = packet.getbyte(0)
|
|
201
|
+
data = packet.byteslice(1..).to_s
|
|
202
|
+
case band
|
|
203
|
+
when 1
|
|
204
|
+
output.write(data)
|
|
205
|
+
yield Progress.new(phase: :pack, current: nil, total: nil, bytes: output.pos) if block_given? && !data.empty?
|
|
206
|
+
!data.empty?
|
|
207
|
+
when 2
|
|
208
|
+
yield Progress.new(phase: :remote, current: nil, total: nil, bytes: data.bytesize) if block_given?
|
|
209
|
+
false
|
|
210
|
+
when 3
|
|
211
|
+
raise TransportError, "remote error: #{safe_message(data)}"
|
|
212
|
+
else
|
|
213
|
+
raise TransportError, "invalid sideband channel"
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def raw_pack?(input)
|
|
218
|
+
position = input.pos
|
|
219
|
+
prefix = input.read(4)
|
|
220
|
+
input.seek(position, IO::SEEK_SET)
|
|
221
|
+
prefix == "PACK"
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def copy_raw_pack(input, output)
|
|
225
|
+
while (chunk = input.read(65_536))
|
|
226
|
+
ensure_open
|
|
227
|
+
output.write(chunk)
|
|
228
|
+
yield Progress.new(phase: :pack, current: nil, total: nil, bytes: output.pos) if block_given?
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def read_shallow(repository)
|
|
233
|
+
path = File.join(repository.common_dir, "shallow")
|
|
234
|
+
return [] unless File.exist?(path) || File.symlink?(path)
|
|
235
|
+
raise CorruptObject, "unsafe shallow file" unless File.file?(path) && !File.symlink?(path)
|
|
236
|
+
|
|
237
|
+
lines = File.readlines(path, chomp: true, encoding: Encoding::BINARY)
|
|
238
|
+
raise CorruptObject, "too many shallow boundaries" if lines.length > MAX_NEGOTIATION_OIDS
|
|
239
|
+
lines.map { |line| Protocol.validate_oid(line) }.uniq
|
|
240
|
+
rescue TransportError => error
|
|
241
|
+
raise CorruptObject, "invalid shallow boundary", cause: error
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def update_shallow(repository, original, changes)
|
|
245
|
+
additions = changes.fetch(:shallow)
|
|
246
|
+
removals = changes.fetch(:unshallow)
|
|
247
|
+
return if additions.empty? && removals.empty?
|
|
248
|
+
raise TransportError, "server unshallowed an unknown boundary" unless (removals - original - additions).empty?
|
|
249
|
+
|
|
250
|
+
boundaries = ((original + additions).uniq - removals).sort
|
|
251
|
+
boundaries.each do |oid|
|
|
252
|
+
raise TransportError, "shallow boundary object is missing" unless repository.odb.exist?(oid)
|
|
253
|
+
type, = repository.odb.read(oid)
|
|
254
|
+
raise TransportError, "shallow boundary is not a commit" unless type == "commit"
|
|
255
|
+
end
|
|
256
|
+
path = File.join(repository.common_dir, "shallow")
|
|
257
|
+
raise CorruptObject, "unsafe shallow file" if File.symlink?(path) || File.symlink?(path + ".lock")
|
|
258
|
+
lock = File.open(path + ".lock", File::WRONLY | File::CREAT | File::EXCL | File::BINARY, 0o644)
|
|
259
|
+
raise RefLockError, "shallow boundaries changed during fetch" unless read_shallow(repository) == original
|
|
260
|
+
|
|
261
|
+
if boundaries.empty?
|
|
262
|
+
lock.close
|
|
263
|
+
File.unlink(path) if File.file?(path)
|
|
264
|
+
else
|
|
265
|
+
lock.write(boundaries.map { |oid| "#{oid}\n" }.join)
|
|
266
|
+
lock.flush
|
|
267
|
+
lock.fsync
|
|
268
|
+
lock.close
|
|
269
|
+
File.rename(lock.path, path)
|
|
270
|
+
end
|
|
271
|
+
rescue Errno::EEXIST
|
|
272
|
+
raise RefLockError, "shallow file is locked"
|
|
273
|
+
ensure
|
|
274
|
+
lock&.close unless lock&.closed?
|
|
275
|
+
File.unlink(lock.path) if lock && File.exist?(lock.path)
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def request_to(output, method, suffix, query: nil, headers: {}, body: nil, content_type:, limit:)
|
|
279
|
+
request_each(method, suffix, query: query, headers: headers, body: body, content_type: content_type, limit: limit) do |chunk|
|
|
280
|
+
output.write(chunk)
|
|
281
|
+
end
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
def safe_message(data) = data.force_encoding(Encoding::UTF_8).scrub.strip
|
|
285
|
+
end
|
|
286
|
+
end
|
|
287
|
+
end
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "stringio"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
module Thuban
|
|
8
|
+
module Remote
|
|
9
|
+
class Connection
|
|
10
|
+
MAX_ADVERTISEMENT_SIZE = 8 * 1024 * 1024
|
|
11
|
+
|
|
12
|
+
def initialize(url, credentials: nil, timeout: 30)
|
|
13
|
+
@uri = parse_url(url)
|
|
14
|
+
Credentials.validate(credentials)
|
|
15
|
+
@credentials = credentials
|
|
16
|
+
@timeout = Float(timeout)
|
|
17
|
+
raise ArgumentError, "timeout must be between 0 and 300 seconds" unless @timeout.positive? && @timeout <= 300
|
|
18
|
+
@authorization_lock = Mutex.new
|
|
19
|
+
@request_lock = Mutex.new
|
|
20
|
+
@active_http = nil
|
|
21
|
+
@closed = false
|
|
22
|
+
rescue ArgumentError, TypeError => error
|
|
23
|
+
raise error if error.message.start_with?("timeout")
|
|
24
|
+
|
|
25
|
+
raise TransportError, "invalid remote URL"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def refs
|
|
29
|
+
ensure_open
|
|
30
|
+
body = request(:get, "/info/refs", query: "service=git-upload-pack",
|
|
31
|
+
headers: {"Accept" => "application/x-git-upload-pack-advertisement", "Git-Protocol" => "version=2"},
|
|
32
|
+
content_type: "application/x-git-upload-pack-advertisement", limit: MAX_ADVERTISEMENT_SIZE)
|
|
33
|
+
reader = Protocol::Reader.new(StringIO.new(body), max_bytes: MAX_ADVERTISEMENT_SIZE)
|
|
34
|
+
first = reader.read
|
|
35
|
+
if first == "# service=git-upload-pack\n"
|
|
36
|
+
raise TransportError, "invalid upload-pack advertisement" unless reader.read == Protocol::FLUSH
|
|
37
|
+
first = reader.read
|
|
38
|
+
end
|
|
39
|
+
@refs = if first == "version 2\n"
|
|
40
|
+
@protocol_version = 2
|
|
41
|
+
read_v2_refs(reader)
|
|
42
|
+
else
|
|
43
|
+
@protocol_version = 0
|
|
44
|
+
read_v0_refs(reader, first)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def close
|
|
49
|
+
http = @request_lock.synchronize do
|
|
50
|
+
@closed = true
|
|
51
|
+
@active_http
|
|
52
|
+
end
|
|
53
|
+
socket = http&.instance_variable_get(:@socket)
|
|
54
|
+
socket.io.shutdown(Socket::SHUT_RDWR) if socket && !socket.closed?
|
|
55
|
+
socket.close if socket && !socket.closed?
|
|
56
|
+
http.finish if http&.started?
|
|
57
|
+
nil
|
|
58
|
+
rescue IOError, SystemCallError, OpenSSL::SSL::SSLError
|
|
59
|
+
nil
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
def parse_url(url)
|
|
65
|
+
raise TypeError unless url.is_a?(String)
|
|
66
|
+
|
|
67
|
+
uri = URI.parse(url)
|
|
68
|
+
raise AuthenticationError, "credentials in remote URLs are not supported" if uri.userinfo
|
|
69
|
+
|
|
70
|
+
valid = %w[http https].include?(uri.scheme) && uri.host && !uri.host.empty? &&
|
|
71
|
+
!uri.fragment && !uri.query
|
|
72
|
+
raise ArgumentError unless valid
|
|
73
|
+
|
|
74
|
+
uri
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def read_v2_refs(reader)
|
|
78
|
+
capabilities = []
|
|
79
|
+
loop do
|
|
80
|
+
packet = reader.read
|
|
81
|
+
break if packet == Protocol::FLUSH
|
|
82
|
+
raise TransportError, "truncated capability advertisement" unless packet.is_a?(String)
|
|
83
|
+
|
|
84
|
+
capabilities << packet.chomp
|
|
85
|
+
end
|
|
86
|
+
reject_non_sha1(capabilities)
|
|
87
|
+
raise TransportError, "server does not support ls-refs" unless capabilities.any? { |line| line.split("=", 2).first == "ls-refs" }
|
|
88
|
+
|
|
89
|
+
@capabilities = capabilities
|
|
90
|
+
body = Protocol.packet("command=ls-refs\n") + Protocol.delimiter +
|
|
91
|
+
%w[peel symrefs ref-prefix\ HEAD ref-prefix\ refs/].map { |line| Protocol.packet("#{line}\n") }.join + Protocol.flush
|
|
92
|
+
response = request(:post, "/git-upload-pack", body: body,
|
|
93
|
+
headers: {"Content-Type" => "application/x-git-upload-pack-request", "Accept" => "application/x-git-upload-pack-result",
|
|
94
|
+
"Git-Protocol" => "version=2"}, content_type: "application/x-git-upload-pack-result", limit: MAX_ADVERTISEMENT_SIZE)
|
|
95
|
+
parse_v2_ref_lines(Protocol::Reader.new(StringIO.new(response), max_bytes: MAX_ADVERTISEMENT_SIZE))
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def parse_v2_ref_lines(reader)
|
|
99
|
+
result = []
|
|
100
|
+
loop do
|
|
101
|
+
packet = reader.read
|
|
102
|
+
break if [Protocol::FLUSH, Protocol::RESPONSE_END].include?(packet)
|
|
103
|
+
raise TransportError, "truncated ls-refs response" unless packet.is_a?(String)
|
|
104
|
+
raise TransportError, safe_message(packet.delete_prefix("ERR ")) if packet.start_with?("ERR ")
|
|
105
|
+
fields = packet.chomp.split(" ")
|
|
106
|
+
oid = fields.shift
|
|
107
|
+
name = Protocol.validate_ref(fields.shift)
|
|
108
|
+
attributes = fields.to_h { |field| field.split(":", 2) }
|
|
109
|
+
result << Ref.new(name: name, oid: oid == "unborn" ? nil : Protocol.validate_oid(oid),
|
|
110
|
+
symref_target: attributes["symref-target"] && Protocol.validate_ref(attributes["symref-target"]),
|
|
111
|
+
peeled: attributes["peeled"] && Protocol.validate_oid(attributes["peeled"]))
|
|
112
|
+
end
|
|
113
|
+
result
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def read_v0_refs(reader, first)
|
|
117
|
+
raise TransportError, "empty upload-pack advertisement" unless first.is_a?(String)
|
|
118
|
+
|
|
119
|
+
lines = [first]
|
|
120
|
+
loop do
|
|
121
|
+
packet = reader.read
|
|
122
|
+
break if packet == Protocol::FLUSH
|
|
123
|
+
raise TransportError, "truncated ref advertisement" unless packet.is_a?(String)
|
|
124
|
+
|
|
125
|
+
lines << packet
|
|
126
|
+
end
|
|
127
|
+
payload, capabilities = lines.first.split("\0", 2)
|
|
128
|
+
lines[0] = payload
|
|
129
|
+
@capabilities = capabilities.to_s.chomp.split(" ")
|
|
130
|
+
reject_non_sha1(@capabilities)
|
|
131
|
+
symrefs = @capabilities.grep(/\Asymref=/).to_h do |capability|
|
|
132
|
+
name, target = capability.delete_prefix("symref=").split(":", 2)
|
|
133
|
+
[Protocol.validate_ref(name), Protocol.validate_ref(target)]
|
|
134
|
+
end
|
|
135
|
+
result = []
|
|
136
|
+
peeled = {}
|
|
137
|
+
lines.each do |line|
|
|
138
|
+
raise TransportError, safe_message(line.delete_prefix("ERR ")) if line.start_with?("ERR ")
|
|
139
|
+
|
|
140
|
+
oid, name = line.chomp.split(" ", 2)
|
|
141
|
+
next if oid == "0" * 40 && name == "capabilities^{}"
|
|
142
|
+
next unless name == "HEAD" || name&.start_with?("refs/")
|
|
143
|
+
|
|
144
|
+
if name.end_with?("^{}")
|
|
145
|
+
peeled[name.delete_suffix("^{}")] = Protocol.validate_oid(oid)
|
|
146
|
+
else
|
|
147
|
+
name = Protocol.validate_ref(name)
|
|
148
|
+
result << Ref.new(name: name, oid: Protocol.validate_oid(oid), symref_target: symrefs[name])
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
result.each { |ref| ref.peeled = peeled[ref.name] }
|
|
152
|
+
result
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def reject_non_sha1(capabilities)
|
|
156
|
+
format = capabilities.find { |line| line.start_with?("object-format=") }
|
|
157
|
+
raise TransportError, "remote object format is not SHA-1" if format && format != "object-format=sha1"
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def request(method, suffix, query: nil, headers: {}, body: nil, content_type:, limit:)
|
|
161
|
+
result = +"".b
|
|
162
|
+
request_each(method, suffix, query: query, headers: headers, body: body, content_type: content_type, limit: limit) do |chunk|
|
|
163
|
+
result << chunk
|
|
164
|
+
end
|
|
165
|
+
result
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def request_each(method, suffix, query: nil, headers: {}, body: nil, content_type:, limit:)
|
|
169
|
+
endpoint = @uri.dup
|
|
170
|
+
endpoint.path = @uri.path.sub(%r{/\z}, "") + suffix
|
|
171
|
+
endpoint.query = query
|
|
172
|
+
http = Net::HTTP.new(endpoint.host, endpoint.port, nil)
|
|
173
|
+
http.max_retries = 0
|
|
174
|
+
http.use_ssl = endpoint.scheme == "https"
|
|
175
|
+
http.open_timeout = http.read_timeout = @timeout
|
|
176
|
+
http.write_timeout = @timeout if http.respond_to?(:write_timeout=)
|
|
177
|
+
@request_lock.synchronize do
|
|
178
|
+
raise TransportError, "connection is closed" if @closed
|
|
179
|
+
@active_http = http
|
|
180
|
+
end
|
|
181
|
+
request_headers = headers.merge("Accept-Encoding" => "identity")
|
|
182
|
+
auth = authorization
|
|
183
|
+
request_headers["Authorization"] = auth if auth
|
|
184
|
+
request = (method == :get ? Net::HTTP::Get : Net::HTTP::Post).new(endpoint.request_uri, request_headers)
|
|
185
|
+
request.body = body if body
|
|
186
|
+
received = 0
|
|
187
|
+
@request_lock.synchronize { raise TransportError, "connection is closed" if @closed }
|
|
188
|
+
http.start do
|
|
189
|
+
http.request(request) do |response|
|
|
190
|
+
validate_response(response, content_type)
|
|
191
|
+
declared = response["content-length"]
|
|
192
|
+
raise TransportError, "HTTP response exceeds size limit" if declared&.match?(/\A\d+\z/) && declared.to_i > limit
|
|
193
|
+
response.read_body do |chunk|
|
|
194
|
+
received += chunk.bytesize
|
|
195
|
+
raise TransportError, "HTTP response exceeds size limit" if received > limit
|
|
196
|
+
|
|
197
|
+
yield chunk
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
received
|
|
202
|
+
rescue TransportError, AuthenticationError
|
|
203
|
+
raise
|
|
204
|
+
rescue Timeout::Error, IOError, SocketError, SystemCallError => error
|
|
205
|
+
raise TransportError, "HTTP transport failed: #{error.class}"
|
|
206
|
+
ensure
|
|
207
|
+
@request_lock&.synchronize { @active_http = nil if @active_http.equal?(http) }
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def validate_response(response, content_type)
|
|
211
|
+
code = response.code.to_i
|
|
212
|
+
raise AuthenticationError, "remote authentication required" if [401, 403].include?(code)
|
|
213
|
+
raise TransportError, "HTTP redirects are not supported" if (300...400).cover?(code)
|
|
214
|
+
raise TransportError, "HTTP request failed (#{code})" unless code == 200
|
|
215
|
+
actual = response["content-type"].to_s.split(";", 2).first.downcase
|
|
216
|
+
raise TransportError, "unexpected HTTP content type" unless actual == content_type
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def authorization
|
|
220
|
+
return @authorization if defined?(@authorization)
|
|
221
|
+
|
|
222
|
+
@authorization_lock.synchronize do
|
|
223
|
+
@authorization = @credentials&.send(:authorization, @uri, timeout: @timeout) unless defined?(@authorization)
|
|
224
|
+
end
|
|
225
|
+
@authorization
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def ensure_open
|
|
229
|
+
raise TransportError, "connection is closed" if @closed
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
module Thuban
|
|
6
|
+
module Remote
|
|
7
|
+
class LocalConnection < SSHConnection
|
|
8
|
+
def initialize(url, credentials: nil, timeout: 30)
|
|
9
|
+
raise AuthenticationError, "credentials cannot be used with local remotes" unless credentials.nil?
|
|
10
|
+
|
|
11
|
+
@path = parse_local_url(url)
|
|
12
|
+
@timeout = Float(timeout)
|
|
13
|
+
raise ArgumentError, "timeout must be between 0 and 300 seconds" unless @timeout.positive? && @timeout <= 300
|
|
14
|
+
|
|
15
|
+
@process_lock = Mutex.new
|
|
16
|
+
@process = nil
|
|
17
|
+
@closed = false
|
|
18
|
+
rescue AuthenticationError
|
|
19
|
+
raise
|
|
20
|
+
rescue ArgumentError, TypeError => error
|
|
21
|
+
raise error if error.message.start_with?("timeout")
|
|
22
|
+
|
|
23
|
+
raise TransportError, "invalid local remote"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def command(service) = [service, @path]
|
|
29
|
+
|
|
30
|
+
def parse_local_url(url)
|
|
31
|
+
raise TypeError unless url.is_a?(String) && !url.empty? && url.bytesize <= 8192 && !url.match?(/[\0\r\n]/)
|
|
32
|
+
|
|
33
|
+
if url.match?(/\Afile:\/\//i)
|
|
34
|
+
uri = URI.parse(url)
|
|
35
|
+
raise ArgumentError unless uri.scheme.casecmp?("file") && [nil, "", "localhost"].include?(uri.host) && !uri.query && !uri.fragment
|
|
36
|
+
|
|
37
|
+
path = URI::RFC2396_PARSER.unescape(uri.path)
|
|
38
|
+
path = path.delete_prefix("/") if Gem.win_platform? && path.match?(/\A\/[A-Za-z]:\//)
|
|
39
|
+
else
|
|
40
|
+
raise ArgumentError if url.include?("://")
|
|
41
|
+
|
|
42
|
+
path = url
|
|
43
|
+
end
|
|
44
|
+
raise ArgumentError if path.empty? || path.match?(/[\0\r\n]/)
|
|
45
|
+
File.expand_path(path)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def process_environment
|
|
49
|
+
super.merge("GIT_DIR" => nil, "GIT_WORK_TREE" => nil, "GIT_OBJECT_DIRECTORY" => nil,
|
|
50
|
+
"GIT_ALTERNATE_OBJECT_DIRECTORIES" => nil, "GIT_CONFIG_COUNT" => nil,
|
|
51
|
+
"GIT_CONFIG_GLOBAL" => nil, "GIT_CONFIG_SYSTEM" => nil)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Thuban
|
|
4
|
+
module Remote
|
|
5
|
+
module Protocol
|
|
6
|
+
MAX_PACKET_SIZE = 65_520
|
|
7
|
+
FLUSH = :flush
|
|
8
|
+
DELIMITER = :delimiter
|
|
9
|
+
RESPONSE_END = :response_end
|
|
10
|
+
|
|
11
|
+
def self.packet(data)
|
|
12
|
+
raise TypeError, "packet data must be a String" unless data.is_a?(String)
|
|
13
|
+
raise TransportError, "packet exceeds Git protocol limit" if data.bytesize > MAX_PACKET_SIZE - 4
|
|
14
|
+
|
|
15
|
+
format("%04x", data.bytesize + 4) + data.b
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def self.flush = "0000".b
|
|
19
|
+
def self.delimiter = "0001".b
|
|
20
|
+
|
|
21
|
+
def self.validate_oid(oid)
|
|
22
|
+
raise TransportError, "server advertised a non-SHA-1 object ID" unless /\A[0-9a-fA-F]{40}\z/.match?(oid.to_s)
|
|
23
|
+
|
|
24
|
+
oid.downcase
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def self.validate_depth(depth)
|
|
28
|
+
return if depth.nil?
|
|
29
|
+
raise ArgumentError, "depth must be an Integer between 1 and 2147483647" unless depth.is_a?(Integer) && (1..2_147_483_647).cover?(depth)
|
|
30
|
+
|
|
31
|
+
depth
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def self.validate_filter(filter)
|
|
35
|
+
return if filter.nil?
|
|
36
|
+
raise ArgumentError, "filter must be blob:none" unless filter == "blob:none"
|
|
37
|
+
|
|
38
|
+
filter
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def self.validate_ref(name)
|
|
42
|
+
valid = name == "HEAD" || (name.is_a?(String) && name.start_with?("refs/") &&
|
|
43
|
+
!name.end_with?("/", ".") && !name.include?("..") && !name.include?("@{") &&
|
|
44
|
+
!name.match?(/[\x00-\x20\x7f~^:?*\[\\]/) &&
|
|
45
|
+
name.split("/").none? { |part| part.empty? || part.start_with?(".") || part.downcase.end_with?(".lock") })
|
|
46
|
+
raise TransportError, "server advertised an invalid reference" unless valid
|
|
47
|
+
|
|
48
|
+
name
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
class Reader
|
|
52
|
+
def initialize(io, max_bytes:)
|
|
53
|
+
@io = io
|
|
54
|
+
@max_bytes = max_bytes
|
|
55
|
+
@bytes = 0
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def read
|
|
59
|
+
header = read_exact(4, eof: true)
|
|
60
|
+
return if header.nil?
|
|
61
|
+
raise TransportError, "invalid packet length" unless /\A[0-9a-fA-F]{4}\z/.match?(header)
|
|
62
|
+
|
|
63
|
+
length = header.to_i(16)
|
|
64
|
+
return FLUSH if length.zero?
|
|
65
|
+
return DELIMITER if length == 1
|
|
66
|
+
return RESPONSE_END if length == 2
|
|
67
|
+
raise TransportError, "invalid packet length" unless (4..MAX_PACKET_SIZE).cover?(length)
|
|
68
|
+
|
|
69
|
+
@bytes += length
|
|
70
|
+
raise TransportError, "protocol response exceeds size limit" if @bytes > @max_bytes
|
|
71
|
+
|
|
72
|
+
read_exact(length - 4)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
def read_exact(length, eof: false)
|
|
78
|
+
result = +"".b
|
|
79
|
+
while result.bytesize < length
|
|
80
|
+
chunk = @io.read(length - result.bytesize)
|
|
81
|
+
return if eof && result.empty? && chunk.nil?
|
|
82
|
+
raise TransportError, "truncated packet" if chunk.nil? || chunk.empty?
|
|
83
|
+
|
|
84
|
+
result << chunk
|
|
85
|
+
end
|
|
86
|
+
result
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|