gienah 0.1.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,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gienah
4
+ Manifest = Value.define(:id, :name, :version, :api_version, :entry, :activation,
5
+ :capabilities, :contributes, :limits, :root)
6
+
7
+ class Manifest
8
+ REQUIRED = %w[id name version api_version entry].freeze
9
+ EXACT_CAPABILITIES = %w[
10
+ buffer.read buffer.edit workspace.read ui.panel ui.command ui.statusbar
11
+ ui.decoration completion.provide language.define process.exec exec
12
+ ].freeze
13
+ LIMITS = %w[memory_mb request_timeout_ms max_message_bytes max_concurrent_requests startup_timeout_ms].freeze
14
+
15
+ class << self
16
+ def load(path)
17
+ path = File.expand_path(path.to_s)
18
+ raise ArgumentError, "manifest does not exist" unless File.file?(path)
19
+
20
+ from_hash(JSON.parse(strip_jsonc(File.binread(path))), root: File.dirname(path))
21
+ rescue JSON::ParserError => error
22
+ raise ProtocolError, "invalid manifest JSON: #{error.message}"
23
+ end
24
+
25
+ def from_hash(value, root: Dir.pwd)
26
+ raise ProtocolError, "manifest must be an object" unless value.is_a?(Hash)
27
+ value = value.transform_keys(&:to_s)
28
+ REQUIRED.each { |key| raise ProtocolError, "manifest missing #{key}" unless value.key?(key) }
29
+ id = text(value["id"], "id")
30
+ name = text(value["name"], "name")
31
+ version = text(value["version"], "version")
32
+ api_version = positive_integer(value["api_version"], "api_version")
33
+ entry = text(value["entry"], "entry")
34
+ raise ProtocolError, "entry must be relative" if Pathname.new(entry).absolute? || entry.split(File::SEPARATOR).include?("..")
35
+
36
+ activation = array(value.fetch("activation", []), "activation").map { |item| text(item, "activation") }
37
+ capabilities = array(value.fetch("capabilities", []), "capabilities").map do |item|
38
+ capability = text(item, "capability")
39
+ validate_capability(capability)
40
+ capability
41
+ end.uniq.freeze
42
+ contributes = value.fetch("contributes", {})
43
+ raise ProtocolError, "contributes must be an object" unless contributes.is_a?(Hash)
44
+ limits = validate_limits(value.fetch("limits", {}))
45
+
46
+ new(id, name, version, api_version, entry, activation.freeze, capabilities,
47
+ deep_freeze(contributes), limits, File.expand_path(root.to_s)).freeze
48
+ end
49
+
50
+ private
51
+
52
+ def text(value, field)
53
+ raise ProtocolError, "#{field} must be a nonempty String" unless value.is_a?(String) && !value.empty? && !value.include?("\0")
54
+
55
+ value
56
+ end
57
+
58
+ def array(value, field)
59
+ raise ProtocolError, "#{field} must be an Array" unless value.is_a?(Array)
60
+
61
+ value
62
+ end
63
+
64
+ def positive_integer(value, field)
65
+ raise ProtocolError, "#{field} must be a positive Integer" unless value.is_a?(Integer) && value.positive?
66
+
67
+ value
68
+ end
69
+
70
+ def validate_capability(value)
71
+ return if EXACT_CAPABILITIES.include?(value)
72
+ return if value.match?(/\A(?:fs\.read|fs\.write|net):.+\z/)
73
+
74
+ raise ProtocolError, "unknown capability: #{value}"
75
+ end
76
+
77
+ def validate_limits(value)
78
+ raise ProtocolError, "limits must be an object" unless value.is_a?(Hash)
79
+
80
+ normalized = value.transform_keys(&:to_s)
81
+ normalized.each do |key, number|
82
+ raise ProtocolError, "unknown limit: #{key}" unless LIMITS.include?(key)
83
+ raise ProtocolError, "#{key} must be positive" unless number.is_a?(Numeric) && number.finite? && number.positive?
84
+ end
85
+ deep_freeze(normalized)
86
+ end
87
+
88
+ def deep_freeze(value)
89
+ case value
90
+ when Hash
91
+ value.each { |key, item| deep_freeze(key); deep_freeze(item) }
92
+ when Array
93
+ value.each { |item| deep_freeze(item) }
94
+ end
95
+ value.freeze
96
+ end
97
+
98
+ def strip_jsonc(source)
99
+ output = +""
100
+ quote = false
101
+ escaped = false
102
+ index = 0
103
+ while index < source.length
104
+ char = source[index]
105
+ if quote
106
+ output << char
107
+ if escaped
108
+ escaped = false
109
+ elsif char == "\\"
110
+ escaped = true
111
+ elsif char == '"'
112
+ quote = false
113
+ end
114
+ elsif char == '"'
115
+ quote = true
116
+ output << char
117
+ elsif char == "/" && source[index + 1] == "/"
118
+ index += 2
119
+ index += 1 while index < source.length && source[index] != "\n"
120
+ output << "\n"
121
+ elsif char == "/" && source[index + 1] == "*"
122
+ index += 2
123
+ index += 1 while index + 1 < source.length && source[index, 2] != "*/"
124
+ index += 1
125
+ else
126
+ output << char
127
+ end
128
+ index += 1
129
+ end
130
+ output.gsub(/,\s*([}\]])/, '\1')
131
+ end
132
+ end
133
+ end
134
+ end
@@ -0,0 +1,140 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gienah
4
+ module Plugin
5
+ module_function
6
+
7
+ def export(name, &handler)
8
+ raise ArgumentError, "name must be a nonempty String" unless name.is_a?(String) && !name.empty?
9
+ raise ArgumentError, "handler required" unless handler
10
+
11
+ exports[name] = handler
12
+ nil
13
+ end
14
+
15
+ def on(event, &handler)
16
+ raise ArgumentError, "event must be a nonempty String" unless event.is_a?(String) && !event.empty?
17
+ raise ArgumentError, "handler required" unless handler
18
+
19
+ listeners[event] << handler
20
+ nil
21
+ end
22
+
23
+ def call(method, params = {})
24
+ raise LifecycleError, "plugin is not running" unless running?
25
+
26
+ id = next_id
27
+ write(Protocol.request(id, method, params))
28
+ loop do
29
+ message = Protocol.read(@input)
30
+ raise Error, "host closed the connection" unless message
31
+ return response_value(message) if message["id"] == id && !message.key?("method")
32
+
33
+ dispatch(message)
34
+ end
35
+ end
36
+
37
+ def notify(method, params = {})
38
+ raise LifecycleError, "plugin is not running" unless running?
39
+
40
+ write(Protocol.notification(method, params))
41
+ nil
42
+ end
43
+
44
+ def capability?(name)
45
+ @capabilities.include?(name.to_s)
46
+ end
47
+
48
+ def run(input: $stdin, output: $stdout)
49
+ @input = input
50
+ @output = output
51
+ @input.binmode if @input.respond_to?(:binmode)
52
+ @output.binmode if @output.respond_to?(:binmode)
53
+ @running = true
54
+ loop do
55
+ message = Protocol.read(@input)
56
+ break unless message
57
+
58
+ dispatch(message)
59
+ break if @stopped
60
+ end
61
+ nil
62
+ ensure
63
+ @running = false
64
+ end
65
+
66
+ def reset!
67
+ @exports = {}
68
+ @listeners = Hash.new { |hash, key| hash[key] = [] }
69
+ @capabilities = []
70
+ @sequence = 0
71
+ @running = false
72
+ nil
73
+ end
74
+
75
+ def exports
76
+ @exports ||= {}
77
+ end
78
+
79
+ def listeners
80
+ @listeners ||= Hash.new { |hash, key| hash[key] = [] }
81
+ end
82
+
83
+ def running?
84
+ !!@running
85
+ end
86
+
87
+ def next_id
88
+ @sequence = (@sequence || 0) + 1
89
+ end
90
+
91
+ def write(message)
92
+ @output.write(Protocol.frame(message))
93
+ @output.flush
94
+ end
95
+
96
+ def dispatch(message)
97
+ if message.key?("method")
98
+ if message.key?("id")
99
+ write(handle_request(message))
100
+ else
101
+ handle_notification(message)
102
+ end
103
+ end
104
+ end
105
+
106
+ def handle_request(message)
107
+ method = message["method"]
108
+ params = message.fetch("params", {})
109
+ case method
110
+ when "initialize"
111
+ @capabilities = Array(params["capabilities"]).map(&:to_s).freeze
112
+ Protocol.response(message["id"], result: {"api_version" => params["api_version"]})
113
+ when "shutdown"
114
+ @stopped = true
115
+ Protocol.response(message["id"], result: nil)
116
+ else
117
+ handler = exports[method]
118
+ unless handler
119
+ return Protocol.response(message["id"], error: {"code" => -32601, "message" => "method not found: #{method}"})
120
+ end
121
+ result = handler.arity == 1 ? handler.call(params) : handler.call(self, params)
122
+ Protocol.response(message["id"], result: result)
123
+ end
124
+ rescue StandardError => error
125
+ Protocol.response(message["id"], error: {"code" => -32000, "message" => "#{error.class}: #{error.message}"})
126
+ end
127
+
128
+ def handle_notification(message)
129
+ listeners.fetch(message["method"], []).each { |listener| listener.call(message.fetch("params", {})) }
130
+ end
131
+
132
+ def response_value(message)
133
+ raise Error, message.fetch("error").fetch("message") if message.key?("error")
134
+
135
+ message["result"]
136
+ end
137
+
138
+ reset!
139
+ end
140
+ end
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gienah
4
+ module Protocol
5
+ MAX_MESSAGE = 4 * 1024 * 1024
6
+ MAX_HEADER = 16 * 1024
7
+ module_function
8
+
9
+ def frame(message, max_size: MAX_MESSAGE)
10
+ validate_message(message)
11
+ body = JSON.generate(message).b
12
+ raise ProtocolError, "message exceeds #{max_size} bytes" unless body.bytesize.between?(1, max_size)
13
+
14
+ "Content-Length: #{body.bytesize}\r\n\r\n".b + body
15
+ rescue JSON::GeneratorError => error
16
+ raise ProtocolError, "invalid JSON: #{error.message}"
17
+ end
18
+
19
+ def read(io, max_size: MAX_MESSAGE)
20
+ headers = {}
21
+ bytes = 0
22
+ loop do
23
+ line = io.gets("\r\n", MAX_HEADER + 1)
24
+ return nil if line.nil? && headers.empty?
25
+ raise ProtocolError, "truncated header" unless line&.end_with?("\r\n")
26
+ raise ProtocolError, "oversized header" if line.bytesize > MAX_HEADER
27
+ break if line == "\r\n"
28
+
29
+ bytes += line.bytesize
30
+ raise ProtocolError, "oversized headers" if bytes > MAX_HEADER
31
+ raise ProtocolError, "non-ASCII header" unless line.ascii_only?
32
+
33
+ key, value = line.delete_suffix("\r\n").split(":", 2)
34
+ raise ProtocolError, "invalid header" unless key&.match?(/\A[A-Za-z][A-Za-z0-9-]*\z/) && value
35
+ key = key.downcase
36
+ raise ProtocolError, "duplicate header" if headers.key?(key)
37
+ headers[key] = value.strip
38
+ end
39
+
40
+ raw_length = headers["content-length"]
41
+ raise ProtocolError, "missing or invalid Content-Length" unless raw_length&.match?(/\A\d+\z/)
42
+ length = Integer(raw_length, 10)
43
+ raise ProtocolError, "message exceeds #{max_size} bytes" unless length.between?(1, max_size)
44
+ body = read_exact(io, length)
45
+ raise ProtocolError, "truncated body" unless body&.bytesize == length
46
+ body.force_encoding(Encoding::UTF_8)
47
+ raise ProtocolError, "invalid UTF-8 body" unless body.valid_encoding?
48
+ validate_message(JSON.parse(body))
49
+ rescue JSON::ParserError => error
50
+ raise ProtocolError, "invalid JSON: #{error.message}"
51
+ end
52
+
53
+ def validate_message(message)
54
+ raise ProtocolError, "message must be an object" unless message.is_a?(Hash)
55
+ version = message["jsonrpc"] || message[:jsonrpc]
56
+ raise ProtocolError, "jsonrpc must be \"2.0\"" unless version == "2.0"
57
+ method = message["method"] || message[:method]
58
+ id_present = message.key?("id") || message.key?(:id)
59
+ has_result = message.key?("result") || message.key?(:result)
60
+ has_error = message.key?("error") || message.key?(:error)
61
+ raise ProtocolError, "method must be a nonempty String" if method && (!method.is_a?(String) || method.empty?)
62
+ if method
63
+ raise ProtocolError, "request cannot contain result or error" if has_result || has_error
64
+ params = message["params"] || message[:params]
65
+ raise ProtocolError, "params must be an object or array" if params && !params.is_a?(Hash) && !params.is_a?(Array)
66
+ elsif id_present
67
+ id = message["id"] || message[:id]
68
+ raise ProtocolError, "invalid response id" unless id.is_a?(Integer) || id.is_a?(String)
69
+ raise ProtocolError, "response must contain exactly one result or error" unless has_result ^ has_error
70
+ error = message["error"] || message[:error]
71
+ raise ProtocolError, "invalid response error" if has_error && (!error.is_a?(Hash) || !error["message"].is_a?(String))
72
+ else
73
+ raise ProtocolError, "message must contain method or id"
74
+ end
75
+ message
76
+ end
77
+
78
+ def request(id, method, params = {})
79
+ {"jsonrpc" => "2.0", "id" => id, "method" => method, "params" => params}
80
+ end
81
+
82
+ def notification(method, params = {})
83
+ {"jsonrpc" => "2.0", "method" => method, "params" => params}
84
+ end
85
+
86
+ def response(id, result: nil, error: nil)
87
+ raise ArgumentError, "result and error are exclusive" if !error.nil? && !result.nil?
88
+ value = {"jsonrpc" => "2.0", "id" => id}
89
+ error.nil? ? value["result"] = result : value["error"] = error
90
+ value
91
+ end
92
+
93
+ def read_exact(io, size)
94
+ data = +"".b
95
+ while data.bytesize < size
96
+ chunk = io.read(size - data.bytesize)
97
+ return nil if chunk.nil? || chunk.empty?
98
+ data << chunk
99
+ end
100
+ data
101
+ end
102
+ private_class_method :read_exact
103
+ end
104
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gienah
4
+ module Sandbox
5
+ module_function
6
+
7
+ DANGEROUS = /\A(?:fs\.write:|net:|exec\z|process\.exec\z)/
8
+
9
+ def dangerous?(capabilities)
10
+ Array(capabilities).any? { |capability| capability.match?(DANGEROUS) }
11
+ end
12
+
13
+ def policy_for(capabilities, root:)
14
+ require "saiph"
15
+ root = File.expand_path(root.to_s)
16
+ reads = []
17
+ writes = []
18
+ network = false
19
+ exec = false
20
+ Array(capabilities).each do |capability|
21
+ case capability
22
+ when /\Afs\.read:(.+)/
23
+ reads << path_for(Regexp.last_match(1), root)
24
+ when /\Afs\.write:(.+)/
25
+ writes << path_for(Regexp.last_match(1), root)
26
+ when /\Anet:/
27
+ network = true
28
+ when "exec", "process.exec"
29
+ exec = true
30
+ end
31
+ end
32
+ Saiph::Policy.new((reads + [root]).uniq, writes.uniq, network, exec, ENV.keys)
33
+ end
34
+
35
+ def available?
36
+ require "saiph"
37
+ Saiph.available?
38
+ rescue LoadError, StandardError
39
+ false
40
+ end
41
+
42
+ def ensure_safe!(capabilities)
43
+ raise Error, "OS sandbox is unavailable for dangerous capabilities" if dangerous?(capabilities) && !available?
44
+ end
45
+
46
+ def path_for(pattern, root)
47
+ pattern = pattern.sub("${workspaceFolder}", root)
48
+ return root if pattern.include?("*") && !pattern.start_with?("/")
49
+
50
+ path = pattern.start_with?("/") ? pattern : File.join(root, pattern)
51
+ wildcard = path.index(/[*?\[]/)
52
+ File.expand_path(wildcard ? path[0...wildcard].sub(%r{[/\\]\z}, "") : path)
53
+ end
54
+ private_class_method :path_for
55
+ end
56
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gienah
4
+ module Testing
5
+ class FakePlugin
6
+ attr_reader :capabilities
7
+
8
+ def initialize(capabilities: [])
9
+ @exports = {}
10
+ @notifications = []
11
+ @capabilities = capabilities.map(&:to_s)
12
+ end
13
+
14
+ def export(method, &handler)
15
+ @exports[method] = handler
16
+ self
17
+ end
18
+
19
+ def on_notification(&handler)
20
+ @notifications << handler
21
+ self
22
+ end
23
+
24
+ def transport(manifest, &receive)
25
+ FakeTransport.new(self, manifest, receive)
26
+ end
27
+
28
+ def dispatch(message)
29
+ method = message["method"]
30
+ params = message.fetch("params", {})
31
+ case method
32
+ when "initialize"
33
+ @capabilities = Array(params["capabilities"]).map(&:to_s)
34
+ Protocol.response(message["id"], result: {"api_version" => params["api_version"]})
35
+ when "shutdown"
36
+ Protocol.response(message["id"], result: nil)
37
+ else
38
+ handler = @exports[method]
39
+ return Protocol.response(message["id"], error: {"code" => -32601, "message" => "method not found: #{method}"}) unless handler
40
+ result = handler.arity == 1 ? handler.call(params) : handler.call(self, params)
41
+ Protocol.response(message["id"], result: result)
42
+ end
43
+ rescue StandardError => error
44
+ Protocol.response(message["id"], error: {"code" => -32000, "message" => "#{error.class}: #{error.message}"})
45
+ end
46
+
47
+ def notify(method, params = {})
48
+ @notifications.each { |handler| handler.call(method, params) }
49
+ end
50
+ end
51
+
52
+ class FakeTransport
53
+ attr_reader :pid
54
+
55
+ def initialize(plugin, manifest, receive)
56
+ @plugin = plugin
57
+ @manifest = manifest
58
+ @receive = receive
59
+ @pid = Process.pid
60
+ @closed = false
61
+ end
62
+
63
+ def write(message, **)
64
+ raise Error, "transport is closed" if @closed
65
+ response = @plugin.dispatch(message)
66
+ @receive.call(response, nil) if response
67
+ nil
68
+ end
69
+
70
+ def alive?
71
+ !@closed
72
+ end
73
+
74
+ def close(**)
75
+ @closed = true
76
+ true
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gienah
4
+ class Transport
5
+ attr_reader :pid
6
+
7
+ def self.windows?
8
+ /mswin|mingw/.match?(RbConfig::CONFIG.fetch("host_os"))
9
+ end
10
+
11
+ def self.open(command, cwd: nil, env: {}, policy: nil, &receive)
12
+ raise ArgumentError, "receiver required" unless receive
13
+ raise ArgumentError, "command must be a nonempty Array" unless command.is_a?(Array) && !command.empty?
14
+
15
+ input_read, input_write = IO.pipe
16
+ output_read, output_write = IO.pipe
17
+ error_read, error_write = IO.pipe
18
+ [input_read, input_write, output_read, output_write, error_read, error_write].each(&:binmode)
19
+ options = {in: input_read, out: output_write, err: error_write, close_others: true}
20
+ options[:chdir] = cwd if cwd
21
+ options[:new_pgroup] = true if windows?
22
+ pid = if policy
23
+ require "saiph"
24
+ Saiph.spawn(command, policy: policy, **options, env: env)
25
+ else
26
+ Process.spawn(env, *command, **options)
27
+ end
28
+ [input_read, output_write, error_write].each(&:close)
29
+ new(input_write, output_read, error_read, pid, receive)
30
+ rescue Exception
31
+ [input_read, input_write, output_read, output_write, error_read, error_write].compact.each do |io|
32
+ io.close unless io.closed?
33
+ end
34
+ Process.kill("KILL", pid) if pid
35
+ Process.wait(pid) if pid
36
+ raise
37
+ end
38
+
39
+ def initialize(input, output, error, pid, receive)
40
+ @input = input
41
+ @output = output
42
+ @error = error
43
+ @pid = pid
44
+ @receive = receive
45
+ @write_lock = Mutex.new
46
+ @closed = false
47
+ @reader = Thread.new { read_loop }
48
+ @reader.report_on_exception = false
49
+ @stderr_reader = Thread.new { drain_stderr }
50
+ @stderr_reader.report_on_exception = false
51
+ end
52
+
53
+ def write(message, max_size: Protocol::MAX_MESSAGE)
54
+ frame = Protocol.frame(message, max_size: max_size)
55
+ @write_lock.synchronize do
56
+ raise Error, "transport is closed" if closed?
57
+
58
+ @input.write(frame)
59
+ @input.flush
60
+ end
61
+ nil
62
+ rescue IOError, Errno::EPIPE => error
63
+ raise Error, "transport write failed: #{error.message}"
64
+ end
65
+
66
+ def alive?
67
+ !closed? && (@pid.nil? || process_alive?)
68
+ end
69
+
70
+ def close(grace: 0.5)
71
+ @closed = true
72
+ @input.close unless @input.closed?
73
+ joined = @reader == Thread.current || @reader.join(grace)
74
+ unless joined
75
+ terminate("TERM")
76
+ joined = @reader.join(grace)
77
+ end
78
+ unless joined
79
+ terminate("KILL")
80
+ @reader.join
81
+ end
82
+ @stderr_reader.join(grace) unless @stderr_reader == Thread.current
83
+ [@output, @error].each { |io| io.close unless io.closed? }
84
+ if @pid && process_alive?
85
+ terminate("TERM")
86
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + grace
87
+ until Process.waitpid(@pid, Process::WNOHANG)
88
+ break if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
89
+
90
+ sleep(0.01)
91
+ end
92
+ if process_alive?
93
+ terminate("KILL")
94
+ Process.wait(@pid)
95
+ end
96
+ end
97
+ true
98
+ rescue IOError, Errno::ECHILD
99
+ true
100
+ end
101
+
102
+ private
103
+
104
+ def read_loop
105
+ loop do
106
+ message = Protocol.read(@output)
107
+ break unless message
108
+
109
+ @receive.call(message, nil)
110
+ end
111
+ @receive.call(nil, Error.new("transport closed")) unless @closed
112
+ rescue StandardError => error
113
+ @receive.call(nil, error) unless @closed
114
+ ensure
115
+ @closed = true
116
+ end
117
+
118
+ def drain_stderr
119
+ while @error.read(8_192)
120
+ break if @closed
121
+ end
122
+ rescue IOError
123
+ nil
124
+ end
125
+
126
+ def closed?
127
+ @closed
128
+ end
129
+
130
+ def process_alive?
131
+ Process.kill(0, @pid)
132
+ true
133
+ rescue Errno::ESRCH, Errno::ECHILD
134
+ false
135
+ rescue Errno::EPERM
136
+ true
137
+ end
138
+
139
+ def terminate(signal)
140
+ Process.kill(windows? ? "KILL" : signal, @pid)
141
+ rescue Errno::ESRCH, Errno::ECHILD
142
+ nil
143
+ end
144
+
145
+ def windows?
146
+ self.class.windows?
147
+ end
148
+ end
149
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gienah
4
+ VERSION = "0.1.0"
5
+ end