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,248 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require "shellwords"
5
+ require "timeout"
6
+ require "uri"
7
+
8
+ module Thuban
9
+ module Remote
10
+ class SSHConnection < Connection
11
+ MAX_COMMAND_ARGUMENTS = 64
12
+ MAX_COMMAND_ARGUMENT_SIZE = 4096
13
+ USER_PATTERN = /\A[A-Za-z0-9][A-Za-z0-9._-]{0,63}\z/n
14
+ HOST_PATTERN = /\A(?:[A-Za-z0-9](?:[A-Za-z0-9.-]{0,251}[A-Za-z0-9])?|\[[0-9A-Fa-f:.]+\])\z/n
15
+ PATH_PATTERN = /\A[0-9A-Za-z._~+\/:@=-]{1,4096}\z/n
16
+
17
+ def initialize(url, credentials: nil, ssh: nil, timeout: 30)
18
+ raise AuthenticationError, "HTTP credentials cannot be used with SSH remotes" unless credentials.nil?
19
+
20
+ @user, @host, @port, @path = parse_ssh_url(url)
21
+ @ssh_command = parse_ssh_command(ssh)
22
+ @timeout = Float(timeout)
23
+ raise ArgumentError, "timeout must be between 0 and 300 seconds" unless @timeout.positive? && @timeout <= 300
24
+
25
+ @process_lock = Mutex.new
26
+ @process = nil
27
+ @closed = false
28
+ rescue AuthenticationError
29
+ raise
30
+ rescue ArgumentError, TypeError
31
+ raise ArgumentError, "timeout must be between 0 and 300 seconds" if defined?(@timeout)
32
+
33
+ raise TransportError, "invalid SSH remote"
34
+ end
35
+
36
+ def refs
37
+ ensure_open
38
+ @refs = with_process("git-upload-pack") do |stdin, stdout|
39
+ reader = Protocol::Reader.new(stdout, max_bytes: MAX_ADVERTISEMENT_SIZE)
40
+ first = reader.read
41
+ @protocol_version = 0
42
+ result = read_v0_refs(reader, first)
43
+ stdin.close
44
+ result
45
+ end
46
+ end
47
+
48
+ def close
49
+ process = @process_lock.synchronize do
50
+ @closed = true
51
+ @process
52
+ end
53
+ cleanup(process)
54
+ nil
55
+ end
56
+
57
+ private
58
+
59
+ def receive_refs
60
+ return @receive_refs if @receive_refs
61
+
62
+ fetch_capabilities = @capabilities
63
+ begin
64
+ ensure_open
65
+ with_process("git-receive-pack") do |stdin, stdout|
66
+ reader = Protocol::Reader.new(stdout, max_bytes: MAX_ADVERTISEMENT_SIZE)
67
+ first = reader.read
68
+ result = read_v0_refs(reader, first)
69
+ @receive_capabilities = @capabilities
70
+ stdin.close
71
+ @receive_refs = result
72
+ end
73
+ ensure
74
+ @capabilities = fetch_capabilities
75
+ end
76
+ end
77
+
78
+ def request_each(method, suffix, query: nil, headers: {}, body: nil, content_type:, limit:)
79
+ services = {"/git-upload-pack" => "git-upload-pack", "/git-receive-pack" => "git-receive-pack"}
80
+ service = services[suffix]
81
+ raise TransportError, "invalid SSH request" unless method == :post && service && query.nil? && body
82
+
83
+ received = 0
84
+ with_process(service, check_status: true) do |stdin, stdout|
85
+ discard_advertisement(stdout)
86
+ stdin.write(body)
87
+ stdin.close
88
+ while (chunk = stdout.read(65_536))
89
+ received += chunk.bytesize
90
+ raise TransportError, "SSH response exceeds size limit" if received > limit
91
+
92
+ yield chunk
93
+ end
94
+ end
95
+ received
96
+ end
97
+
98
+ def discard_advertisement(stdout)
99
+ reader = Protocol::Reader.new(stdout, max_bytes: MAX_ADVERTISEMENT_SIZE)
100
+ loop do
101
+ packet = reader.read
102
+ return if packet == Protocol::FLUSH
103
+ raise TransportError, "truncated SSH advertisement" unless packet.is_a?(String)
104
+ end
105
+ end
106
+
107
+ def with_process(service, check_status: false)
108
+ process = start_process(service)
109
+ result = Timeout.timeout(@timeout) do
110
+ value = yield(process[:stdin], process[:stdout])
111
+ if check_status
112
+ status = process[:waiter].value
113
+ raise TransportError, "SSH transport failed" unless status.success?
114
+ end
115
+ value
116
+ end
117
+ result
118
+ rescue Timeout::Error
119
+ raise TransportError, "SSH transport timed out", cause: nil
120
+ rescue TransportError
121
+ raise
122
+ rescue Errno::ENOENT
123
+ raise TransportError, "SSH executable not found", cause: nil
124
+ rescue IOError, SystemCallError
125
+ raise TransportError, "SSH transport failed", cause: nil
126
+ ensure
127
+ @process_lock&.synchronize { @process = nil if @process.equal?(process) }
128
+ cleanup(process)
129
+ end
130
+
131
+ def start_process(service)
132
+ ensure_open
133
+ grouped = !Gem.win_platform?
134
+ options = grouped ? {pgroup: true} : {}
135
+ stdin, stdout, stderr, waiter = Open3.popen3(process_environment, *command(service), **options)
136
+ [stdin, stdout, stderr].each(&:binmode)
137
+ process = {stdin: stdin, stdout: stdout, stderr: stderr, waiter: waiter, grouped: grouped}
138
+ process[:errors] = Thread.new { while stderr.read(4096); end rescue nil }
139
+ @process_lock.synchronize do
140
+ if @closed
141
+ cleanup(process)
142
+ raise TransportError, "connection is closed"
143
+ end
144
+ @process = process
145
+ end
146
+ process
147
+ end
148
+
149
+ def command(service)
150
+ destination = @user ? "#{@user}@#{@host}" : @host
151
+ port = @port ? ["-p", @port.to_s] : []
152
+ @ssh_command + ["-o", "BatchMode=yes", *port, "--", destination,
153
+ "#{service} #{Shellwords.escape(@path)}"]
154
+ end
155
+
156
+ def process_environment
157
+ {"GIT_PROTOCOL" => nil, "GIT_TERMINAL_PROMPT" => "0", "GIT_TRACE" => nil,
158
+ "GIT_TRACE2" => nil, "GIT_TRACE_PACKET" => nil}
159
+ end
160
+
161
+ def safe_message(_data) = "remote reported an error"
162
+
163
+ def cleanup(process)
164
+ return unless process
165
+
166
+ [process[:stdin], process[:stdout], process[:stderr]].each { |io| io.close unless io.closed? }
167
+ terminate(process[:waiter], process[:grouped]) if process[:waiter].alive? && !process[:waiter].join(0.2)
168
+ process[:errors]&.join
169
+ rescue IOError, Errno::ECHILD
170
+ nil
171
+ end
172
+
173
+ def terminate(waiter, grouped)
174
+ unless grouped
175
+ Process.kill("KILL", waiter.pid)
176
+ waiter.join
177
+ return
178
+ end
179
+
180
+ target = -waiter.pid
181
+ Process.kill("TERM", target)
182
+ return if waiter.join(0.2)
183
+
184
+ Process.kill("KILL", target)
185
+ waiter.join
186
+ rescue Errno::EPERM
187
+ begin
188
+ Process.kill("TERM", waiter.pid)
189
+ Process.kill("KILL", waiter.pid) unless waiter.join(0.2)
190
+ waiter.join
191
+ rescue SystemCallError
192
+ nil
193
+ end
194
+ rescue SystemCallError
195
+ nil
196
+ end
197
+
198
+ def parse_ssh_url(url)
199
+ raise TypeError unless url.is_a?(String) && url.bytesize <= 8192
200
+
201
+ target = url.match?(/\Assh:\/\//i) ? parse_uri(url) : parse_scp(url)
202
+ user, host, port, path = target
203
+ raise ArgumentError unless (!user || USER_PATTERN.match?(user)) && HOST_PATTERN.match?(host) &&
204
+ (!port || (1..65_535).cover?(port)) && PATH_PATTERN.match?(path) && !path.start_with?("-")
205
+
206
+ target
207
+ end
208
+
209
+ def parse_uri(url)
210
+ uri = URI.parse(url)
211
+ raise AuthenticationError, "passwords in SSH remote URLs are not supported" if uri.password
212
+ raise ArgumentError unless uri.scheme == "ssh" && uri.host && !uri.host.empty? &&
213
+ uri.path && !uri.path.empty? && !uri.query && !uri.fragment
214
+
215
+ [decode(uri.user), uri.host, uri.port, decode(uri.path)]
216
+ end
217
+
218
+ def parse_scp(url)
219
+ match = /\A(?:(?<user>[A-Za-z0-9][A-Za-z0-9._-]{0,63})@)?(?<host>\[[0-9A-Fa-f:.]+\]|[^:]+):(?<path>.+)\z/n.match(url)
220
+ raise ArgumentError unless match
221
+
222
+ [match[:user], match[:host], nil, match[:path]]
223
+ end
224
+
225
+ def decode(value)
226
+ value && URI::RFC2396_PARSER.unescape(value)
227
+ end
228
+
229
+ def parse_ssh_command(value)
230
+ value = ENV["GIT_SSH_COMMAND"] if value.nil? && !ENV["GIT_SSH_COMMAND"].to_s.empty?
231
+ arguments = case value
232
+ when nil then ["ssh"]
233
+ when String then Shellwords.split(value)
234
+ when Array then value.dup
235
+ else raise TypeError
236
+ end
237
+ valid = arguments.length.between?(1, MAX_COMMAND_ARGUMENTS) && arguments.all? do |argument|
238
+ argument.is_a?(String) && !argument.empty? && argument.bytesize <= MAX_COMMAND_ARGUMENT_SIZE &&
239
+ !argument.match?(/[\0\r\n]/)
240
+ end
241
+ raise ArgumentError unless valid
242
+
243
+ arguments.map! { |argument| argument.dup.freeze }
244
+ arguments.freeze
245
+ end
246
+ end
247
+ end
248
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Thuban
4
+ class Error < StandardError; end
5
+ class TransportError < Error; end
6
+ class AuthenticationError < TransportError; end
7
+
8
+ Ref = Struct.new(:name, :oid, :symref_target, :peeled, keyword_init: true)
9
+ Progress = Struct.new(:phase, :current, :total, :bytes, keyword_init: true)
10
+
11
+ module Remote
12
+ def self.open(url, credentials: nil, ssh: nil, timeout: 30)
13
+ local_drive = url.is_a?(String) && url.match?(/\A[A-Za-z]:[\\\/]/)
14
+ local_path = local_drive || (url.is_a?(String) && url.start_with?("/", "./", "../", "~"))
15
+ if url.is_a?(String) && (url.match?(/\Assh:\/\//i) || (!local_path && !url.include?("://") && url.include?(":")))
16
+ return SSHConnection.new(url, credentials: credentials, ssh: ssh, timeout: timeout)
17
+ end
18
+ raise ArgumentError, "ssh configuration requires an SSH remote" if ssh
19
+
20
+ if url.is_a?(String) && !url.match?(/\Ahttps?:\/\//i)
21
+ return LocalConnection.new(url, credentials: credentials, timeout: timeout)
22
+ end
23
+
24
+ Connection.new(url, credentials: credentials, timeout: timeout)
25
+ end
26
+ end
27
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Thuban
4
- VERSION = "0.3.0"
4
+ VERSION = "0.4.0"
5
5
  end
data/lib/thuban.rb CHANGED
@@ -25,3 +25,13 @@ require_relative "thuban/commit_writer"
25
25
  require_relative "thuban/history_writer"
26
26
  require_relative "thuban/stash"
27
27
  require_relative "thuban/pack_writer"
28
+ require_relative "thuban/pack_reader"
29
+ require_relative "thuban/remote"
30
+ require_relative "thuban/remote/credentials"
31
+ require_relative "thuban/remote/protocol"
32
+ require_relative "thuban/remote/http"
33
+ require_relative "thuban/remote/fetch"
34
+ require_relative "thuban/remote/push"
35
+ require_relative "thuban/remote/ssh"
36
+ require_relative "thuban/remote/local"
37
+ require_relative "thuban/remote/repository"
data/sig/thuban.rbs CHANGED
@@ -1,6 +1,7 @@
1
1
  module Thuban
2
2
  VERSION: String
3
3
  type object = [String, String]
4
+ type push_update = [String, String?, String]
4
5
 
5
6
  interface _Differ
6
7
  def edits: (Porrima::lines, Porrima::lines) -> Array[Porrima::Edit]
@@ -10,12 +11,72 @@ module Thuban
10
11
  def write: (String) -> Integer
11
12
  end
12
13
 
14
+ interface _PackInput
15
+ def read: (Integer) -> String?
16
+ end
17
+
13
18
  class CorruptObject < StandardError
14
19
  end
15
20
 
16
21
  class RefLockError < StandardError
17
22
  end
18
23
 
24
+ class Error < StandardError
25
+ end
26
+
27
+ class TransportError < Error
28
+ end
29
+
30
+ class AuthenticationError < TransportError
31
+ end
32
+
33
+ class Ref < Struct[untyped]
34
+ attr_accessor name: String
35
+ attr_accessor oid: String?
36
+ attr_accessor symref_target: String?
37
+ attr_accessor peeled: String?
38
+ def self.new: (name: String, oid: String?, ?symref_target: String?, ?peeled: String?) -> instance
39
+ end
40
+
41
+ class Progress < Struct[untyped]
42
+ attr_accessor phase: Symbol
43
+ attr_accessor current: Integer?
44
+ attr_accessor total: Integer?
45
+ attr_accessor bytes: Integer?
46
+ def self.new: (phase: Symbol, ?current: Integer?, ?total: Integer?, ?bytes: Integer?) -> instance
47
+ end
48
+
49
+ module Remote
50
+ type ssh_command = String | Array[String]
51
+ def self.open: (String url, ?credentials: untyped, ?ssh: ssh_command?, ?timeout: Numeric) -> Connection
52
+
53
+ module Credentials
54
+ MAX_HELPER_OUTPUT: Integer
55
+ def self.static: (username: String, password: String) -> untyped
56
+ def self.bearer: (token: String) -> untyped
57
+ def self.helper: (?String? name) -> untyped
58
+ def self.callback: () { (String) -> untyped } -> untyped
59
+ end
60
+
61
+ class Connection
62
+ def initialize: (String url, ?credentials: untyped, ?timeout: Numeric) -> void
63
+ def refs: () -> Array[Ref]
64
+ def fetch: (Repository repository, wants: Enumerable[String], ?haves: Enumerable[String], ?depth: Integer?, ?filter: String?) ?{ (Progress) -> void } -> Array[String]
65
+ def push: (Repository repository, Enumerable[push_update] updates, ?atomic: bool) ?{ (Progress) -> void } -> Array[Ref]
66
+ def close: () -> nil
67
+ end
68
+
69
+ class SSHConnection < Connection
70
+ MAX_COMMAND_ARGUMENTS: Integer
71
+ MAX_COMMAND_ARGUMENT_SIZE: Integer
72
+ def initialize: (String url, ?credentials: untyped, ?ssh: ssh_command?, ?timeout: Numeric) -> void
73
+ end
74
+
75
+ class LocalConnection < SSHConnection
76
+ def initialize: (String url, ?credentials: untyped, ?timeout: Numeric) -> void
77
+ end
78
+ end
79
+
19
80
  class Commit < Struct[untyped]
20
81
  attr_accessor oid: String
21
82
  attr_accessor tree: String?
@@ -96,6 +157,9 @@ module Thuban
96
157
  def stash_push: (?message: String?, ?include_untracked: bool) -> String?
97
158
  def stash_pop: (?Integer index) -> String
98
159
  def stash_list: () -> Array[Commit]
160
+ def remotes: () -> Hash[String, String]
161
+ def fetch: (?String remote, ?refspecs: String | Enumerable[String]?, ?depth: Integer?, ?filter: String?) ?{ (Progress) -> void } -> Array[Ref]
162
+ def push: (?String remote, refspecs: String | Enumerable[String], ?force: bool, ?lease: String | Hash[String, String]?, ?atomic: bool) ?{ (Progress) -> void } -> Array[Ref]
99
163
  end
100
164
 
101
165
  class ObjectDatabase
@@ -128,6 +192,7 @@ module Thuban
128
192
  def read: (String oid) ?{ (String) -> object } -> object
129
193
  def self.apply_delta: (String base, String delta) -> String
130
194
  def self.write: (_PackOutput io, Enumerable[object] objects) ?{ (Integer current, Integer total) -> void } -> String
195
+ def self.read_stream: (_PackInput io, ObjectDatabase odb) ?{ (Integer current, Integer total) -> void } -> Array[String]
131
196
  end
132
197
 
133
198
  class Index
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: thuban
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Yudai Takada
@@ -15,14 +15,14 @@ dependencies:
15
15
  requirements:
16
16
  - - "~>"
17
17
  - !ruby/object:Gem::Version
18
- version: 0.1.0
18
+ version: 0.2.0
19
19
  type: :runtime
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
23
  - - "~>"
24
24
  - !ruby/object:Gem::Version
25
- version: 0.1.0
25
+ version: 0.2.0
26
26
  email:
27
27
  - t.yudai92@gmail.com
28
28
  executables: []
@@ -44,9 +44,19 @@ files:
44
44
  - lib/thuban/object_database.rb
45
45
  - lib/thuban/object_writer.rb
46
46
  - lib/thuban/pack.rb
47
+ - lib/thuban/pack_reader.rb
47
48
  - lib/thuban/pack_writer.rb
48
49
  - lib/thuban/ref_lock_error.rb
49
50
  - lib/thuban/ref_store.rb
51
+ - lib/thuban/remote.rb
52
+ - lib/thuban/remote/credentials.rb
53
+ - lib/thuban/remote/fetch.rb
54
+ - lib/thuban/remote/http.rb
55
+ - lib/thuban/remote/local.rb
56
+ - lib/thuban/remote/protocol.rb
57
+ - lib/thuban/remote/push.rb
58
+ - lib/thuban/remote/repository.rb
59
+ - lib/thuban/remote/ssh.rb
50
60
  - lib/thuban/repository.rb
51
61
  - lib/thuban/signature.rb
52
62
  - lib/thuban/stash.rb
@@ -79,5 +89,5 @@ required_rubygems_version: !ruby/object:Gem::Requirement
79
89
  requirements: []
80
90
  rubygems_version: 3.6.9
81
91
  specification_version: 4
82
- summary: A pure Ruby implementation for local Git repositories
92
+ summary: A pure Ruby Git implementation for local repositories and remote transfers
83
93
  test_files: []