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.
- checksums.yaml +4 -4
- data/LICENSE +18 -14
- data/README.md +351 -134
- data/bin/srsh +53 -1473
- data/docs/assets/slut.txt +4 -0
- data/docs/assets/srsh-mark.svg +12 -0
- data/docs/css/style.css +696 -0
- data/docs/index.html +703 -0
- data/docs/js/app.js +203 -0
- data/examples/bridge.rsh +8 -0
- data/examples/calculator.rsh +253 -0
- data/examples/defer.rsh +14 -0
- data/examples/hot.rsh +14 -0
- data/examples/meta.rsh +20 -0
- data/examples/modules/text.rsh +6 -0
- data/examples/modules.rsh +6 -0
- data/examples/paste.rsh +15 -0
- data/examples/plugin.rb +8 -0
- data/examples/power.rsh +65 -0
- data/examples/tour.rsh +38 -0
- data/ext/srsh_native/extconf.rb +3 -0
- data/ext/srsh_native/srsh_native.c +48 -0
- data/language-docs/LANGUAGE.md +670 -0
- data/language-docs/MIGRATION.md +44 -0
- data/language-docs/SECURITY.md +44 -0
- data/lib/srsh/app.rb +261 -0
- data/lib/srsh/builtins.rb +492 -0
- data/lib/srsh/editor.rb +530 -0
- data/lib/srsh/errors.rb +23 -0
- data/lib/srsh/history.rb +74 -0
- data/lib/srsh/language/evaluator.rb +1175 -0
- data/lib/srsh/language/lexer.rb +316 -0
- data/lib/srsh/language/parser.rb +997 -0
- data/lib/srsh/language/token.rb +5 -0
- data/lib/srsh/language/values.rb +392 -0
- data/lib/srsh/paths.rb +29 -0
- data/lib/srsh/plugins.rb +59 -0
- data/lib/srsh/process_identity.rb +38 -0
- data/lib/srsh/security.rb +38 -0
- data/lib/srsh/shell/executor.rb +1182 -0
- data/lib/srsh/shell/job.rb +101 -0
- data/lib/srsh/shell/lexer.rb +114 -0
- data/lib/srsh/shell/terminal.rb +26 -0
- data/lib/srsh/state.rb +136 -0
- data/lib/srsh/theme.rb +108 -0
- data/lib/srsh/version.rb +1 -2
- data/lib/srsh.rb +15 -0
- 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
|