graphomaton 0.1.1 → 1.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +78 -3
- data/README.md +454 -27
- data/SECURITY.md +47 -0
- data/docs/architecture.md +30 -0
- data/docs/cli.md +27 -0
- data/docs/custom-exporters.md +36 -0
- data/docs/exporters.md +17 -0
- data/docs/input-schema.md +26 -0
- data/docs/migration-1.1.md +19 -0
- data/docs/performance.md +19 -0
- data/docs/releasing.md +19 -0
- data/exe/graphomaton +9 -0
- data/lib/graphomaton/atomic_file.rb +26 -0
- data/lib/graphomaton/cli/config.rb +102 -0
- data/lib/graphomaton/cli.rb +841 -0
- data/lib/graphomaton/errors.rb +11 -0
- data/lib/graphomaton/exporter_registry.rb +127 -0
- data/lib/graphomaton/exporters/dot.rb +284 -0
- data/lib/graphomaton/exporters/mermaid.rb +774 -0
- data/lib/graphomaton/exporters/pdf.rb +131 -0
- data/lib/graphomaton/exporters/plantuml.rb +284 -0
- data/lib/graphomaton/exporters/png.rb +172 -0
- data/lib/graphomaton/exporters/svg.rb +2872 -0
- data/lib/graphomaton/exporters/webp.rb +185 -0
- data/lib/graphomaton/exporters.rb +13 -0
- data/lib/graphomaton/identifier_allocator.rb +33 -0
- data/lib/graphomaton/input_policy.rb +82 -0
- data/lib/graphomaton/layout/force_tree.rb +127 -0
- data/lib/graphomaton/model.rb +218 -0
- data/lib/graphomaton/process_runner.rb +154 -0
- data/lib/graphomaton/url_policy.rb +40 -0
- data/lib/graphomaton/version.rb +1 -1
- data/lib/graphomaton.rb +2865 -240
- data/sig/graphomaton.rbs +127 -0
- metadata +39 -17
- data/.codespellignore +0 -0
- data/.rspec +0 -1
- data/CODE_OF_CONDUCT.md +0 -132
- data/Rakefile +0 -8
- data/sample/basic.rb +0 -19
- data/sample/complex.rb +0 -24
- data/sample/nfa.rb +0 -19
- data/spec/graphomaton_spec.rb +0 -371
- data/spec/spec_helper.rb +0 -13
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'open3'
|
|
4
|
+
require 'timeout'
|
|
5
|
+
|
|
6
|
+
class Graphomaton
|
|
7
|
+
class ProcessRunner
|
|
8
|
+
class Error < Graphomaton::Error; end
|
|
9
|
+
class TimeoutError < Error; end
|
|
10
|
+
class OutputLimitError < Error; end
|
|
11
|
+
|
|
12
|
+
DEFAULT_TIMEOUT = 30
|
|
13
|
+
DEFAULT_MAX_STDOUT_BYTES = 64 * 1024 * 1024
|
|
14
|
+
DEFAULT_MAX_STDERR_BYTES = 1024 * 1024
|
|
15
|
+
READ_SIZE = 16 * 1024
|
|
16
|
+
|
|
17
|
+
def self.which(command, path: ENV.fetch('PATH', ''), pathext: ENV.fetch('PATHEXT', ''))
|
|
18
|
+
name = command.to_s
|
|
19
|
+
return nil if name.empty? || name.include?("\0")
|
|
20
|
+
|
|
21
|
+
explicit = name.include?(File::SEPARATOR) || (File::ALT_SEPARATOR && name.include?(File::ALT_SEPARATOR))
|
|
22
|
+
directories = explicit ? [''] : path.split(File::PATH_SEPARATOR)
|
|
23
|
+
extensions = executable_extensions(name, pathext)
|
|
24
|
+
directories.each do |directory|
|
|
25
|
+
base = explicit ? name : File.join(directory, name)
|
|
26
|
+
extensions.each do |extension|
|
|
27
|
+
candidate = "#{base}#{extension}"
|
|
28
|
+
return File.expand_path(candidate) if File.file?(candidate) && File.executable?(candidate)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
nil
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def self.capture3(*command, stdin_data: '', binmode: false, timeout: DEFAULT_TIMEOUT,
|
|
35
|
+
max_stdout_bytes: DEFAULT_MAX_STDOUT_BYTES,
|
|
36
|
+
max_stderr_bytes: DEFAULT_MAX_STDERR_BYTES)
|
|
37
|
+
validate_limit(timeout, 'timeout')
|
|
38
|
+
validate_limit(max_stdout_bytes, 'max_stdout_bytes')
|
|
39
|
+
validate_limit(max_stderr_bytes, 'max_stderr_bytes')
|
|
40
|
+
|
|
41
|
+
spawn_options = Gem.win_platform? ? { new_pgroup: true } : { pgroup: true }
|
|
42
|
+
stdout_data = nil
|
|
43
|
+
stderr_data = nil
|
|
44
|
+
status = nil
|
|
45
|
+
|
|
46
|
+
Open3.popen3(*command, **spawn_options) do |stdin, stdout, stderr, wait_thread|
|
|
47
|
+
streams = [stdin, stdout, stderr]
|
|
48
|
+
streams.each(&:binmode) if binmode
|
|
49
|
+
writer = input_writer(stdin, stdin_data)
|
|
50
|
+
stdout_reader = output_reader(stdout, max_stdout_bytes, 'stdout')
|
|
51
|
+
stderr_reader = output_reader(stderr, max_stderr_bytes, 'stderr')
|
|
52
|
+
threads = [writer, stdout_reader, stderr_reader]
|
|
53
|
+
|
|
54
|
+
begin
|
|
55
|
+
Timeout.timeout(timeout, TimeoutError, "Process timed out after #{timeout} seconds") do
|
|
56
|
+
writer.value
|
|
57
|
+
stdout_data = stdout_reader.value
|
|
58
|
+
stderr_data = stderr_reader.value
|
|
59
|
+
status = wait_thread.value
|
|
60
|
+
end
|
|
61
|
+
rescue TimeoutError, OutputLimitError
|
|
62
|
+
terminate(wait_thread)
|
|
63
|
+
raise
|
|
64
|
+
ensure
|
|
65
|
+
streams.each { |stream| stream.close unless stream.closed? }
|
|
66
|
+
threads.each do |thread|
|
|
67
|
+
thread.kill if thread.alive?
|
|
68
|
+
thread.join
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
[stdout_data, stderr_data, status]
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def self.input_writer(stdin, data)
|
|
77
|
+
Thread.new do
|
|
78
|
+
Thread.current.report_on_exception = false
|
|
79
|
+
begin
|
|
80
|
+
stdin.write(data)
|
|
81
|
+
rescue Errno::EPIPE, IOError
|
|
82
|
+
nil
|
|
83
|
+
ensure
|
|
84
|
+
stdin.close unless stdin.closed?
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
private_class_method :input_writer
|
|
89
|
+
|
|
90
|
+
def self.output_reader(stream, limit, name)
|
|
91
|
+
Thread.new do
|
|
92
|
+
Thread.current.report_on_exception = false
|
|
93
|
+
output = String.new(encoding: Encoding::BINARY)
|
|
94
|
+
|
|
95
|
+
begin
|
|
96
|
+
loop do
|
|
97
|
+
chunk = stream.readpartial(READ_SIZE)
|
|
98
|
+
if output.bytesize + chunk.bytesize > limit
|
|
99
|
+
raise OutputLimitError, "Process #{name} exceeded #{limit} bytes"
|
|
100
|
+
end
|
|
101
|
+
output << chunk
|
|
102
|
+
end
|
|
103
|
+
rescue EOFError, IOError
|
|
104
|
+
output
|
|
105
|
+
ensure
|
|
106
|
+
stream.close unless stream.closed?
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
private_class_method :output_reader
|
|
111
|
+
|
|
112
|
+
def self.terminate(wait_thread)
|
|
113
|
+
return unless wait_thread.alive?
|
|
114
|
+
|
|
115
|
+
signal_process(wait_thread.pid, 'TERM')
|
|
116
|
+
return if wait_thread.join(0.25)
|
|
117
|
+
|
|
118
|
+
signal_process(wait_thread.pid, 'KILL')
|
|
119
|
+
wait_thread.join
|
|
120
|
+
end
|
|
121
|
+
private_class_method :terminate
|
|
122
|
+
|
|
123
|
+
def self.signal_process(pid, signal)
|
|
124
|
+
attempts = Gem.win_platform? ? [[9, pid]] : [[signal, -pid], [signal, pid]]
|
|
125
|
+
attempts.each do |candidate_signal, target|
|
|
126
|
+
begin
|
|
127
|
+
Process.kill(candidate_signal, target)
|
|
128
|
+
return
|
|
129
|
+
rescue Errno::ESRCH, Errno::ECHILD
|
|
130
|
+
return
|
|
131
|
+
rescue Errno::EINVAL, Errno::EPERM
|
|
132
|
+
next
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
nil
|
|
136
|
+
end
|
|
137
|
+
private_class_method :signal_process
|
|
138
|
+
|
|
139
|
+
def self.validate_limit(value, name)
|
|
140
|
+
return if value.is_a?(Numeric) && value.finite? && value.positive?
|
|
141
|
+
|
|
142
|
+
raise ArgumentError, "#{name} must be a positive finite number"
|
|
143
|
+
end
|
|
144
|
+
private_class_method :validate_limit
|
|
145
|
+
|
|
146
|
+
def self.executable_extensions(command, pathext)
|
|
147
|
+
return [''] unless Gem.win_platform? && File.extname(command).empty?
|
|
148
|
+
|
|
149
|
+
extensions = pathext.empty? ? %w[.COM .EXE .BAT .CMD] : pathext.split(';').reject(&:empty?)
|
|
150
|
+
extensions.flat_map { |extension| [extension, extension.downcase] }.uniq
|
|
151
|
+
end
|
|
152
|
+
private_class_method :executable_extensions
|
|
153
|
+
end
|
|
154
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'uri'
|
|
4
|
+
|
|
5
|
+
class Graphomaton
|
|
6
|
+
class UrlPolicy
|
|
7
|
+
LINK_SCHEMES = %w[http https mailto].freeze
|
|
8
|
+
ASSET_SCHEMES = %w[https].freeze
|
|
9
|
+
CONTROL_CHARACTERS = /[\u0000-\u001f\u007f]/
|
|
10
|
+
|
|
11
|
+
def self.validate(value, context: 'URL', schemes: LINK_SCHEMES, allow_windows_path: false)
|
|
12
|
+
url = value.to_s
|
|
13
|
+
unless valid?(url, schemes: schemes, allow_windows_path: allow_windows_path)
|
|
14
|
+
raise SecurityError, "Unsafe #{context}: #{value.inspect}"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
url
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def self.validate_asset(value, context: 'asset URL')
|
|
21
|
+
validate(value, context: context, schemes: ASSET_SCHEMES, allow_windows_path: true)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def self.valid?(url, schemes: LINK_SCHEMES, allow_windows_path: false)
|
|
25
|
+
return false if url.empty? || url != url.strip
|
|
26
|
+
return false if url.match?(CONTROL_CHARACTERS) || url.start_with?('//')
|
|
27
|
+
return true if allow_windows_path && url.match?(/\A[A-Za-z]:[\\\/]/)
|
|
28
|
+
return false if url.include?('\\')
|
|
29
|
+
|
|
30
|
+
uri = URI.parse(url)
|
|
31
|
+
return true unless uri.scheme
|
|
32
|
+
|
|
33
|
+
schemes.include?(uri.scheme.downcase)
|
|
34
|
+
rescue URI::InvalidURIError
|
|
35
|
+
false
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private_class_method :valid?
|
|
39
|
+
end
|
|
40
|
+
end
|
data/lib/graphomaton/version.rb
CHANGED