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.
@@ -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