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.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +78 -3
  3. data/README.md +454 -27
  4. data/SECURITY.md +47 -0
  5. data/docs/architecture.md +30 -0
  6. data/docs/cli.md +27 -0
  7. data/docs/custom-exporters.md +36 -0
  8. data/docs/exporters.md +17 -0
  9. data/docs/input-schema.md +26 -0
  10. data/docs/migration-1.1.md +19 -0
  11. data/docs/performance.md +19 -0
  12. data/docs/releasing.md +19 -0
  13. data/exe/graphomaton +9 -0
  14. data/lib/graphomaton/atomic_file.rb +26 -0
  15. data/lib/graphomaton/cli/config.rb +102 -0
  16. data/lib/graphomaton/cli.rb +841 -0
  17. data/lib/graphomaton/errors.rb +11 -0
  18. data/lib/graphomaton/exporter_registry.rb +127 -0
  19. data/lib/graphomaton/exporters/dot.rb +284 -0
  20. data/lib/graphomaton/exporters/mermaid.rb +774 -0
  21. data/lib/graphomaton/exporters/pdf.rb +131 -0
  22. data/lib/graphomaton/exporters/plantuml.rb +284 -0
  23. data/lib/graphomaton/exporters/png.rb +172 -0
  24. data/lib/graphomaton/exporters/svg.rb +2872 -0
  25. data/lib/graphomaton/exporters/webp.rb +185 -0
  26. data/lib/graphomaton/exporters.rb +13 -0
  27. data/lib/graphomaton/identifier_allocator.rb +33 -0
  28. data/lib/graphomaton/input_policy.rb +82 -0
  29. data/lib/graphomaton/layout/force_tree.rb +127 -0
  30. data/lib/graphomaton/model.rb +218 -0
  31. data/lib/graphomaton/process_runner.rb +154 -0
  32. data/lib/graphomaton/url_policy.rb +40 -0
  33. data/lib/graphomaton/version.rb +1 -1
  34. data/lib/graphomaton.rb +2865 -240
  35. data/sig/graphomaton.rbs +127 -0
  36. metadata +39 -17
  37. data/.codespellignore +0 -0
  38. data/.rspec +0 -1
  39. data/CODE_OF_CONDUCT.md +0 -132
  40. data/Rakefile +0 -8
  41. data/sample/basic.rb +0 -19
  42. data/sample/complex.rb +0 -24
  43. data/sample/nfa.rb +0 -19
  44. data/spec/graphomaton_spec.rb +0 -371
  45. 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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class Graphomaton
4
- VERSION = '0.1.1'
4
+ VERSION = '1.1.0'
5
5
  end