srsh 0.7.1 → 1.0.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 (48) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE +18 -14
  3. data/README.md +351 -134
  4. data/bin/srsh +53 -1473
  5. data/docs/assets/slut.txt +4 -0
  6. data/docs/assets/srsh-mark.svg +12 -0
  7. data/docs/css/style.css +696 -0
  8. data/docs/index.html +703 -0
  9. data/docs/js/app.js +203 -0
  10. data/examples/bridge.rsh +8 -0
  11. data/examples/calculator.rsh +253 -0
  12. data/examples/defer.rsh +14 -0
  13. data/examples/hot.rsh +14 -0
  14. data/examples/meta.rsh +20 -0
  15. data/examples/modules/text.rsh +6 -0
  16. data/examples/modules.rsh +6 -0
  17. data/examples/paste.rsh +15 -0
  18. data/examples/plugin.rb +8 -0
  19. data/examples/power.rsh +65 -0
  20. data/examples/tour.rsh +38 -0
  21. data/ext/srsh_native/extconf.rb +3 -0
  22. data/ext/srsh_native/srsh_native.c +48 -0
  23. data/language-docs/LANGUAGE.md +670 -0
  24. data/language-docs/MIGRATION.md +44 -0
  25. data/language-docs/SECURITY.md +44 -0
  26. data/lib/srsh/app.rb +261 -0
  27. data/lib/srsh/builtins.rb +492 -0
  28. data/lib/srsh/editor.rb +530 -0
  29. data/lib/srsh/errors.rb +23 -0
  30. data/lib/srsh/history.rb +74 -0
  31. data/lib/srsh/language/evaluator.rb +1175 -0
  32. data/lib/srsh/language/lexer.rb +316 -0
  33. data/lib/srsh/language/parser.rb +997 -0
  34. data/lib/srsh/language/token.rb +5 -0
  35. data/lib/srsh/language/values.rb +392 -0
  36. data/lib/srsh/paths.rb +29 -0
  37. data/lib/srsh/plugins.rb +59 -0
  38. data/lib/srsh/process_identity.rb +38 -0
  39. data/lib/srsh/security.rb +38 -0
  40. data/lib/srsh/shell/executor.rb +1182 -0
  41. data/lib/srsh/shell/job.rb +101 -0
  42. data/lib/srsh/shell/lexer.rb +114 -0
  43. data/lib/srsh/shell/terminal.rb +26 -0
  44. data/lib/srsh/state.rb +136 -0
  45. data/lib/srsh/theme.rb +108 -0
  46. data/lib/srsh/version.rb +1 -2
  47. data/lib/srsh.rb +15 -0
  48. metadata +59 -11
@@ -0,0 +1,44 @@
1
+ # Moving an old srsh 0.8 setup to 1.0
2
+
3
+ The old shell was one large Ruby file. 1.0 is not, but it keeps the bits users actually interacted with: `~/.srshrc`, `~/.srsh_history`, themes, Ruby plugins, the predictive prompt, normal Unix commands, and the original block spellings.
4
+
5
+ Old RSH still parses:
6
+
7
+ ```rsh
8
+ if $X == 2
9
+ emit "yes"
10
+ else
11
+ emit "no"
12
+ end
13
+
14
+ while $RUNNING
15
+ work
16
+ end
17
+
18
+ times 3
19
+ emit $it
20
+ end
21
+
22
+ fn twice x
23
+ return int($x) * 2
24
+ end
25
+ ```
26
+
27
+ New code can use the more capable expression layer and either readable or hot forms:
28
+
29
+ ```rsh
30
+ fn twice(x) => int(x) * 2
31
+ ? X == 2 => emit "yes"
32
+ @ 3 -> i => = twice(i)
33
+ ```
34
+
35
+ A few intentional changes:
36
+
37
+ - `:=` is a local RSH binding; `$NAME :=` writes the process environment.
38
+ - `..` is an inclusive range, `..<` is exclusive.
39
+ - `++` is explicit string concatenation.
40
+ - `|` is a Unix process pipe; `|>` is an RSH value pipeline.
41
+ - missing variables can be made errors with `option nounset yes` or `option strict yes`.
42
+ - normal shell wildcard expansion now works; use quoted wildcards when you want literal `*`/`?` characters.
43
+
44
+ The brief development-only 1.0.x/1.1/1.2 directories were never release lines. The code they introduced is folded into the unreleased 1.0.0 tree.
@@ -0,0 +1,44 @@
1
+ # Security notes
2
+
3
+ SRSH is a shell. If you run a command, source a script, load a plugin, call native C, or execute generated code, that code has your user account's privileges. There is no pretend sandbox hiding underneath it.
4
+
5
+ Things the runtime does try to get right:
6
+
7
+ - `~/.srsh` state directories are private where the OS permits it.
8
+ - history/state writes use temporary files and atomic replacement.
9
+ - automatically loaded rc/plugin files must be regular files owned by the current user and not group/world writable.
10
+ - symlinked auto-loaded plugins are refused.
11
+ - script, token, nesting, recursion and command-substitution sizes are bounded.
12
+ - `readfile()` and structured process capture have size limits.
13
+ - `cmd(...)` keeps argv data out of shell reparsing.
14
+ - ordinary list/map/string data is copied across RSH task boundaries; deliberate shared mutation uses objects, atoms or channels.
15
+ - worker tasks cannot silently rewrite the process environment or a shared `space`.
16
+ - structured command children are reaped on error paths.
17
+
18
+ ## Dynamic code
19
+
20
+ `code name ... end` is preferable when you know the code at parse time: the body is parsed before it can run.
21
+
22
+ `code(string)`, `eval(string)`, `run(string)`, `sh(string)` and `capture(string)` are intentionally dynamic. Do not build those strings from hostile input and then call them. Use values and `cmd(...)` when data is not trusted.
23
+
24
+ Ruby plugins are trusted code. That is a feature of a Ruby shell, not a security boundary.
25
+
26
+ ## Native C bridges
27
+
28
+ `bridge` uses the platform C ABI directly. Signature checking only checks the RSH declaration; SRSH cannot prove that the C function on the other side actually has that signature.
29
+
30
+ A bad pointer, wrong return type, incorrect calling convention, use-after-free in a library, or just a buggy C function can segfault the entire shell. This is normal FFI territory.
31
+
32
+ Prefer `cstr` for input strings and `cbuf()` for bounded writable memory. Keep raw `ptr` use small and boring. `@self` exposes symbols from the current process and loaded runtime libraries, so only bind symbols you understand.
33
+
34
+ ## Concurrency
35
+
36
+ Task cancellation stops the Ruby thread as best it can; it does not undo file writes or network activity that already happened.
37
+
38
+ `parallel()` uses Ruby threads. On CRuby, the GVL still limits CPU-bound Ruby bytecode. `pmap()` uses `fork` on Unix and therefore has process-copy semantics instead.
39
+
40
+ Objects expose synchronized slot updates, but synchronization does not magically make a multi-step algorithm race-free. Use atoms/channels when the ownership story is clearer that way.
41
+
42
+ ## Audit status
43
+
44
+ SRSH has automated parser, shell, TTY and concurrency tests, but it has not had an independent security audit. Treat that sentence more seriously than a big “production ready” badge.
data/lib/srsh/app.rb ADDED
@@ -0,0 +1,261 @@
1
+ require 'socket'
2
+ require_relative 'version'
3
+ require_relative 'paths'
4
+ require_relative 'state'
5
+ require_relative 'theme'
6
+ require_relative 'history'
7
+ require_relative 'security'
8
+ require_relative 'plugins'
9
+ require_relative 'builtins'
10
+ require_relative 'editor'
11
+ require_relative 'language/parser'
12
+ require_relative 'shell/executor'
13
+ require_relative 'shell/lexer'
14
+
15
+ module Srsh
16
+ class App
17
+ attr_reader :paths, :state, :theme, :history, :plugins, :builtins, :executor
18
+ attr_accessor :out, :err
19
+
20
+ def initialize(out: STDOUT, err: STDERR, home: Dir.home)
21
+ @out = out
22
+ @err = err
23
+ @paths = Paths.new(home).ensure!
24
+ @state = State.new
25
+ @theme = Theme.new(paths, state)
26
+ @history = History.new(paths.history, import_paths: [paths.history_v1])
27
+ @builtins = Builtins.new(self)
28
+ @executor = Shell::Executor.new(self)
29
+ @plugins = Plugins.new(self)
30
+ @editor = Editor.new(self)
31
+ @loaded_startup = false
32
+ @skip_startup = false
33
+ install_signals
34
+ end
35
+
36
+ def disable_startup!
37
+ @skip_startup = true
38
+ self
39
+ end
40
+
41
+ def startup!
42
+ return if @loaded_startup
43
+ if @skip_startup
44
+ @loaded_startup = true
45
+ return
46
+ end
47
+ create_default_rc
48
+ load_rc
49
+ plugins.load_all
50
+ @loaded_startup = true
51
+ end
52
+
53
+ def reload!
54
+ @state.functions.clear
55
+ @state.prototypes.clear
56
+ @state.traits.clear
57
+ @state.aliases.clear
58
+ @state.clear_hooks!
59
+ @builtins.reset_dynamic!
60
+ @theme.reset_dynamic!
61
+ load_rc
62
+ plugins.load_all
63
+ end
64
+
65
+ def run_script(path, argv = [])
66
+ real = File.expand_path(path)
67
+ raise Error, "script not found: #{path}" unless File.file?(real)
68
+ raise Error, 'script too large' if File.size(real) > 4 * 1024 * 1024
69
+ text = read_script_limited(real)
70
+ nodes = Language::ProgramParser.new(text).parse
71
+ scope = { '$0' => real }
72
+ argv.each_with_index { |value, i| scope["$#{i + 1}"] = value }
73
+ @state.push_scope(scope)
74
+ executor.run_program(nodes)
75
+ ensure
76
+ @state.pop_scope if defined?(scope) && scope
77
+ end
78
+
79
+ def check_script(path)
80
+ real = File.expand_path(path)
81
+ raise Error, "script not found: #{path}" unless File.file?(real)
82
+ Language::ProgramParser.new(read_script_limited(real)).parse
83
+ out.puts "#{path}: syntax ok"
84
+ 0
85
+ rescue StandardError => e
86
+ err.puts "#{path}: #{e.message}"
87
+ 2
88
+ end
89
+
90
+ def run_command(command)
91
+ startup!
92
+ executor.execute_line(command)
93
+ end
94
+
95
+ def run_expression(source)
96
+ startup!
97
+ raise ParseError, 'expression cannot contain a newline' if source.to_s.include?("\n")
98
+ nodes = Language::ProgramParser.new("= #{source}\n").parse
99
+ executor.run_program(nodes)
100
+ end
101
+
102
+ def run_input(input)
103
+ text = input.to_s
104
+ if rsh_candidate?(text)
105
+ executor.run_program(Language::ProgramParser.new(text.end_with?("\n") ? text : text + "\n").parse)
106
+ else
107
+ executor.execute_line(text)
108
+ end
109
+ end
110
+
111
+ def interactive
112
+ startup!
113
+ welcome
114
+ hostname = Socket.gethostname.split('.').first
115
+ loop do
116
+ state.prune_jobs!
117
+ title
118
+ input = @editor.read(prompt(hostname))
119
+ break if input == :eof
120
+ next if input == :interrupt
121
+ next if input.nil? || input.strip.empty?
122
+ record_history(input)
123
+ begin
124
+ input = collect_incomplete_input(input)
125
+ break if input == :eof
126
+ next if input == :interrupt
127
+ run_input(input)
128
+ rescue BreakSignal, NextSignal, ReturnSignal
129
+ err.puts theme.paint('control-flow marker used outside a script/function', :error, io: err)
130
+ state.last_status = 2
131
+ rescue StandardError => e
132
+ err.puts theme.paint("srsh: #{e.class}: #{e.message}", :error, io: err)
133
+ state.last_status = 1
134
+ end
135
+ end
136
+ 0
137
+ ensure
138
+ history.flush
139
+ end
140
+
141
+ def prompt(hostname)
142
+ "#{theme.paint(short_pwd, :path, io: out)} #{theme.paint(hostname, :host, io: out)}#{theme.paint(' > ', :mark, io: out)}"
143
+ end
144
+
145
+ private
146
+
147
+ def rsh_candidate?(text)
148
+ line = text.to_s.lstrip
149
+ return true if line.match?(/\A(?:=\s+|emit\s+|return(?:\s|$)|break$|continue$|\^|\?|@|::)/)
150
+ return true if line.match?(/\A(?:try|if|each|while|fn|task|match|code|use|defer|bridge|space|proto|trait|slot)\b/)
151
+ return true if line.include?('|>')
152
+ return true if line.match?(/\A(?:\*?[A-Za-z_][A-Za-z0-9_]*(?:\s*,\s*\*?[A-Za-z_][A-Za-z0-9_]*)+|\$?[A-Za-z_][A-Za-z0-9_]*|[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)+)\s*(?::=|\+=|-=|\*=|\/=|%=|\+\+=)/)
153
+ return true if line.match?(/\A[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*\s*\(/)
154
+ false
155
+ end
156
+
157
+ def collect_incomplete_input(first)
158
+ text = first.to_s
159
+ loop do
160
+ return text unless incomplete_input?(text)
161
+ more = @editor.read(theme.paint('... ', :dim, io: out))
162
+ return more if more == :eof || more == :interrupt
163
+ more = more.to_s
164
+ record_history(more)
165
+ text << "\n" << more
166
+ end
167
+ end
168
+
169
+ # History is line-oriented on disk and ghost completion is line-oriented in
170
+ # the editor. A bracketed multi-line paste therefore records its physical
171
+ # lines just like manually entering a continued block instead of stuffing a
172
+ # newline-bearing pseudo-entry into history.
173
+ def record_history(text)
174
+ text.to_s.each_line do |line|
175
+ line = line.chomp
176
+ history.add(line) unless line.strip.empty?
177
+ end
178
+ end
179
+
180
+ def incomplete_input?(text)
181
+ if rsh_candidate?(text)
182
+ begin
183
+ Language::ProgramParser.new(text.end_with?("\n") ? text : text + "\n").parse
184
+ false
185
+ rescue IncompleteInput
186
+ true
187
+ rescue ParseError
188
+ false
189
+ end
190
+ else
191
+ begin
192
+ tokens = Shell::Lexer.scan(text)
193
+ last = tokens.last
194
+ last && last.type == :op && %w[| && || < > >> 2> 2>>].include?(last.text)
195
+ rescue IncompleteInput
196
+ true
197
+ rescue ParseError
198
+ false
199
+ end
200
+ end
201
+ end
202
+
203
+ def short_pwd
204
+ home = paths.home
205
+ pwd = Dir.pwd
206
+ pwd.start_with?(home) ? pwd.sub(home, '~') : pwd
207
+ end
208
+
209
+ def title
210
+ return unless out.respond_to?(:tty?) && out.tty?
211
+ out.print "\e]0;srsh #{VERSION}: #{Dir.pwd}\a"
212
+ end
213
+
214
+ def welcome
215
+ out.puts theme.paint("Simple Ruby Shell #{VERSION}", :title, io: out)
216
+ out.puts theme.paint("Ruby #{RUBY_VERSION} · #{RUBY_PLATFORM}", :dim, io: out)
217
+ out.puts "type #{theme.paint('help', :key, io: out)} for commands; RSH hot forms work here too\n\n"
218
+ end
219
+
220
+ def load_rc
221
+ return unless File.file?(paths.rc)
222
+ unless Security.private_regular_file?(paths.rc)
223
+ err.puts theme.paint("srsh: refusing unsafe rc file #{paths.rc}", :warn, io: err)
224
+ return
225
+ end
226
+ run_script(paths.rc, [])
227
+ rescue StandardError => e
228
+ err.puts theme.paint("srshrc: #{e.class}: #{e.message}", :error, io: err)
229
+ end
230
+
231
+ def create_default_rc
232
+ return if File.exist?(paths.rc)
233
+ Security.atomic_write(paths.rc, <<~RSH)
234
+ # ~/.srshrc: Simple Ruby Shell startup script
235
+ #
236
+ # alias ll=ls -lah
237
+ # $EDITOR := "nano"
238
+ # scheme ocean
239
+ RSH
240
+ rescue SystemCallError
241
+ end
242
+
243
+ def read_script_limited(path)
244
+ limit = 4 * 1024 * 1024
245
+ File.open(path, 'rb') do |io|
246
+ data = io.read(limit + 1) || ''.b
247
+ raise Error, 'script too large' if data.bytesize > limit
248
+ data.force_encoding(Encoding::UTF_8)
249
+ raise Error, 'script is not valid UTF-8' unless data.valid_encoding?
250
+ data
251
+ end
252
+ end
253
+
254
+ def install_signals
255
+ Signal.trap('INT', 'IGNORE')
256
+ Signal.trap('TTOU', 'IGNORE') if Signal.list.key?('TTOU')
257
+ Signal.trap('TTIN', 'IGNORE') if Signal.list.key?('TTIN')
258
+ Signal.trap('TSTP', 'IGNORE') if Signal.list.key?('TSTP')
259
+ end
260
+ end
261
+ end