samagotchi 0.127.0 → 0.129.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 (61) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +106 -1
  3. data/docs/architecture.md +15 -5
  4. data/docs/cli.md +2 -1
  5. data/docs/guardrails.md +155 -25
  6. data/docs/internals/participants.md +23 -5
  7. data/docs/internals/web-push.md +4 -1
  8. data/docs/sessions.md +1 -1
  9. data/docs/testing.md +3 -2
  10. data/lib/samagotchi/bridge.rb +2 -0
  11. data/lib/samagotchi/bridge_client.rb +8 -2
  12. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  13. data/lib/samagotchi/engine.rb +56 -0
  14. data/lib/samagotchi/guardrail_wiring.rb +6 -5
  15. data/lib/samagotchi/guardrails/child_boundary.rb +23 -13
  16. data/lib/samagotchi/guardrails/outside.rb +54 -5
  17. data/lib/samagotchi/guardrails/pair_boundary.rb +78 -26
  18. data/lib/samagotchi/guardrails/pair_command.rb +261 -66
  19. data/lib/samagotchi/guardrails/parent_approvals.rb +2 -1
  20. data/lib/samagotchi/guardrails/protected_paths.rb +98 -5
  21. data/lib/samagotchi/guardrails/read_only_shell.rb +239 -9
  22. data/lib/samagotchi/guardrails/review_paths.rb +22 -8
  23. data/lib/samagotchi/guardrails/rm_targets.rb +4 -3
  24. data/lib/samagotchi/guardrails/scratch_writes.rb +3 -1
  25. data/lib/samagotchi/guardrails/shell_git_dirs.rb +67 -35
  26. data/lib/samagotchi/guardrails/shell_lex.rb +1 -1
  27. data/lib/samagotchi/guardrails/shell_paths.rb +73 -25
  28. data/lib/samagotchi/guardrails/targets.rb +128 -24
  29. data/lib/samagotchi/session_manager.rb +13 -14
  30. data/lib/samagotchi/system_one/self_protect.rb +108 -16
  31. data/lib/samagotchi/terminal_ui/attach_launcher.rb +9 -5
  32. data/lib/samagotchi/terminal_ui/attached_loop/queueing.rb +6 -12
  33. data/lib/samagotchi/terminal_ui/attached_loop/sending.rb +23 -35
  34. data/lib/samagotchi/terminal_ui/attached_loop.rb +10 -18
  35. data/lib/samagotchi/terminal_ui/exit_lines.rb +2 -1
  36. data/lib/samagotchi/terminal_ui/local_link.rb +2 -2
  37. data/lib/samagotchi/terminal_ui/own_turns.rb +81 -0
  38. data/lib/samagotchi/verify_anchor.rb +8 -0
  39. data/lib/samagotchi/version.rb +1 -1
  40. data/lib/samagotchi/web/app.rb +9 -7
  41. data/lib/samagotchi/web/participant_review.rb +4 -2
  42. data/lib/samagotchi/web/public/annotate_bar.js +14 -2
  43. data/lib/samagotchi/web/public/app.js +25 -5
  44. data/lib/samagotchi/web/public/composer.css +1 -0
  45. data/lib/samagotchi/web/public/notify.js +2 -1
  46. data/lib/samagotchi/web/public/participant.html +3 -2
  47. data/lib/samagotchi/web/public/participant.js +18 -7
  48. data/lib/samagotchi/web/public/participant_model.js +3 -2
  49. data/lib/samagotchi/web/public/participant_names.js +2 -2
  50. data/lib/samagotchi/web/public/question_card.js +4 -1
  51. data/lib/samagotchi/web/public/session_state.js +2 -0
  52. data/lib/samagotchi/web/public/stream_handlers.js +4 -2
  53. data/lib/samagotchi/web/public/verdict_card.js +18 -3
  54. data/lib/samagotchi/web/public/verdict_fresh.js +23 -5
  55. data/lib/samagotchi/web/public/verify_card.css +1 -0
  56. data/lib/samagotchi/web/public/verify_preset.js +6 -0
  57. data/lib/samagotchi/web/verify_fresh_routes.rb +12 -7
  58. data/lib/samagotchi/worker.rb +82 -32
  59. data/lib/samagotchi/worker_idle_exit.rb +17 -0
  60. data/lib/samagotchi/worker_link.rb +13 -5
  61. metadata +2 -1
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "strscan"
4
4
  require_relative "shell_lex"
5
+ require_relative "shell_paths"
5
6
 
6
7
  module Samagotchi
7
8
  module Guardrails
@@ -17,6 +18,17 @@ module Samagotchi
17
18
  # $HOME / the XDG dirs / $TMPDIR / $PWD at a word's start, a brace
18
19
  # expansion, a < or << redirection, a path to a verb (/bin/ls), and
19
20
  # less/more/vim (they run commands) are all not read-only.
21
+ #
22
+ # .read_only_but_writes? (the child boundary's) also lets through the
23
+ # writes it can name: a > or >> target, cp's destination and tee's
24
+ # files, each resolved against the dir the command is in by then
25
+ # (ShellPaths::Walk) and accepted by the caller's block. It trusts
26
+ # Walk's dir only where the shell's can't differ: a cd or pushd takes
27
+ # one existing dir, spelt /…, . or .. (no option, +N, -, $, ~ or
28
+ # CDPATH name), and joins beside it are && only (a failed or skipped
29
+ # cd can't be followed by a command that still runs); popd is never
30
+ # accepted, nor is a $ or ~ in a write's target (the call's env may
31
+ # set it).
20
32
  module ReadOnlyShell
21
33
  # Operators that join read-only commands (no &, no ;;).
22
34
  JOINS = ["&&", "||", ";", "|", "|&", "\n", "(", ")"].freeze
@@ -29,6 +41,49 @@ module Samagotchi
29
41
  # target follows).
30
42
  NULL_REDIRECT = %r{\A\d*>>?/dev/null\z}
31
43
  BARE_REDIRECT = /\A\d*>>?\z/
44
+ # A redirection with its target in the same word (>out, 2>>out); not
45
+ # >&file (it is &> spelt the other way).
46
+ REDIRECT_TO = /\A\d*>>?([^&>].*)\z/m
47
+ # Pipe operators: each side runs in a subshell.
48
+ PIPES = ["|", "|&"].freeze
49
+ # Verbs that change the dir ShellPaths::Walk follows.
50
+ DIR_VERBS = %w[cd pushd popd].freeze
51
+ # The joins a group (or the whole command) holding a cd or pushd may
52
+ # use in write mode: after any other a command can run in a dir the
53
+ # cd didn't reach (cd failed, or was skipped by ||).
54
+ DIR_JOINS = ["&&", "|", "|&"].freeze
55
+ # A dir verb's operand the shell resolves as Walk does: absolute, or
56
+ # ./ ../ (CDPATH is not searched for these).
57
+ DIR_OPERAND = %r{\A(?:/|\.\.?(?:/|\z))}
58
+
59
+ # The write side of .read_only_but_writes?: the Walk that follows the
60
+ # command's dir and the caller's test for an absolute path it writes.
61
+ Writes = Data.define(:walk, :writable) do
62
+ # +word+ as absolute paths against the current dir (ShellPaths::Walk:
63
+ # its lexical one, and its physical one when they differ, last); []
64
+ # when it can't be resolved, or has a $ or ~ (the shell may expand
65
+ # them from an env Walk doesn't see, as task_create's env: sets).
66
+ def paths(word) = word.include?("$") || word.start_with?("~") ? [] : walk.paths(word)
67
+
68
+ # Whether a cd or pushd with +args+ lands where Walk says: one
69
+ # operand (DIR_OPERAND) naming an existing dir every way it
70
+ # resolves. popd never does.
71
+ def dir_step?(verb, args)
72
+ return false unless verb != "popd" && args.size == 1 && args.first.match?(DIR_OPERAND)
73
+
74
+ dirs = paths(args.first)
75
+ !dirs.empty? && dirs.all? { |dir| File.directory?(dir) }
76
+ end
77
+
78
+ def ok?(path) = path ? writable.call(path) == true : false
79
+
80
+ # Whether +word+ names a file the command may write, every way it
81
+ # resolves.
82
+ def file?(word)
83
+ paths = paths(word)
84
+ !paths.empty? && paths.all? { |path| ok?(path) }
85
+ end
86
+ end
32
87
 
33
88
  # Verbs that only read whatever their options: → nil. The others
34
89
  # check their arguments.
@@ -49,29 +104,104 @@ module Samagotchi
49
104
  false
50
105
  end
51
106
 
107
+ # Whether the command only reads but for writes whose resolved paths
108
+ # the block accepts: > and >> targets, cp's destination (and the
109
+ # files it makes in it) and tee's files. Paths resolve against +cwd+
110
+ # and the dir an earlier cd, pushd or popd moved to; a cd beside a pipe
111
+ # (a subshell's) isn't one Walk can follow, so it is not.
112
+ # @param command [String, nil]
113
+ # @param cwd [String, nil] where the command starts
114
+ # @yieldparam path [String] an absolute path, symlinks not resolved
115
+ # @return [Boolean]
116
+ def read_only_but_writes?(command, cwd:, home: Dir.home, env: ENV, &writable)
117
+ text = command.to_s
118
+ return false if text.strip.empty?
119
+
120
+ tokens = ShellLex.lex(text)
121
+ commands = split(tokens, marks: true) or return false
122
+ return false if commands.none?(Array) || !dir_joins_ok?(tokens)
123
+
124
+ writes = Writes.new(walk: ShellPaths::Walk.new(cwd, home, env), writable: writable)
125
+ commands.all? { |words| words.is_a?(Symbol) ? writes.walk.step(words) : simple_read_only?(words, writes) }
126
+ rescue ArgumentError, EncodingError
127
+ false
128
+ end
129
+
52
130
  # The simple commands as [text, specials] word lists; nil when an
53
- # operator isn't one read-only commands may be joined with.
54
- def split(tokens)
131
+ # operator isn't one read-only commands may be joined with. With
132
+ # +marks+, ( and ) stay as :open / :close, and a cd, pushd or popd
133
+ # beside a pipe is nil.
134
+ def split(tokens, marks: false)
55
135
  commands = [[]]
136
+ piped = false
56
137
  tokens.each do |kind, value, specials|
57
138
  if kind == :op
58
139
  return nil unless JOINS.include?(value)
59
140
 
141
+ pipe = PIPES.include?(value)
142
+ return nil if marks && (piped || pipe) && dir_verb?(commands.last)
143
+
144
+ piped = pipe
145
+ commands << (value == "(" ? :open : :close) if marks && ["(", ")"].include?(value)
60
146
  commands << []
61
147
  else
62
148
  commands.last << [value, specials]
63
149
  end
64
150
  end
65
- commands.reject(&:empty?)
151
+ return nil if marks && piped && dir_verb?(commands.last)
152
+
153
+ commands.reject { |words| words.is_a?(Array) && words.empty? }
154
+ end
155
+
156
+ def dir_verb?(words) = words.is_a?(Array) && DIR_VERBS.include?(verb_of(words))
157
+
158
+ # Whether each group holding a dir verb itself (the whole command,
159
+ # or a ( … ): nested ones are their own) joins with DIR_JOINS only.
160
+ def dir_joins_ok?(tokens)
161
+ scopes = [[false, false]] # [holds a dir verb, has another join]
162
+ words = []
163
+ tokens.each do |kind, value, specials|
164
+ next words << [value, specials] unless kind == :op
165
+
166
+ scopes.last[0] ||= dir_verb?(words)
167
+ words = []
168
+ case value
169
+ when "(" then scopes << [false, false]
170
+ when ")"
171
+ dir, other = scopes.pop
172
+ return false if scopes.empty? || (dir && other)
173
+ else scopes.last[1] ||= !DIR_JOINS.include?(value)
174
+ end
175
+ end
176
+ scopes.last[0] ||= dir_verb?(words)
177
+ scopes.none? { |dir, other| dir && other }
66
178
  end
67
179
 
68
- def simple_read_only?(words)
180
+ # The first word that is no redirection or its target.
181
+ def verb_of(words)
182
+ target = false
183
+ words.each do |text, specials|
184
+ if target then target = false
185
+ elsif specials.include?(">") then target = text.match?(BARE_REDIRECT)
186
+ else return text
187
+ end
188
+ end
189
+ nil
190
+ end
191
+
192
+ # With +writes+ (.read_only_but_writes?) its redirections may write
193
+ # what it accepts, cp and tee count, and the walk takes the command.
194
+ def simple_read_only?(words, writes = nil)
69
195
  return false if words.any? { |text, specials| text.include?(ShellLex::SUBST) || !expansion_ok?(text, specials) }
70
196
 
71
- args = strip_redirects(words) or return false
197
+ args = strip_redirects(words, writes) or return false
198
+ return false if writes && DIR_VERBS.include?(args.first) && !writes.dir_step?(args.first, args.drop(1))
199
+
200
+ writes&.walk&.step(args)
72
201
  verb = args.shift
73
202
  return false unless verb&.match?(/\A[a-z][\w.-]*\z/)
74
203
  return true if PLAIN.include?(verb)
204
+ return WRITE_CHECKS[verb].call(args, writes) if writes && WRITE_CHECKS[verb]
75
205
 
76
206
  check = CHECKS[verb] or return false
77
207
  check.call(args)
@@ -85,19 +215,20 @@ module Samagotchi
85
215
  end
86
216
 
87
217
  # The words with their redirections taken out; nil when one isn't an
88
- # fd dup or to /dev/null.
89
- def strip_redirects(words)
218
+ # fd dup, to /dev/null, or (with +writes+) to a file it accepts.
219
+ def strip_redirects(words, writes = nil)
90
220
  out = []
91
221
  pending = false
92
222
  words.each do |text, specials|
93
223
  if pending
94
- return nil unless text == "/dev/null"
224
+ return nil unless text == "/dev/null" || (writes && specials.delete("$").empty? && writes.file?(text))
95
225
 
96
226
  pending = false
97
227
  elsif specials.include?("<")
98
228
  return nil
99
229
  elsif specials.include?(">")
100
230
  next if text.match?(FD_DUP) || text.match?(NULL_REDIRECT)
231
+ next if writes && (m = text.match(REDIRECT_TO)) && !m[1].match?(/[<>]/) && writes.file?(m[1])
101
232
  return nil unless text.match?(BARE_REDIRECT)
102
233
 
103
234
  pending = true
@@ -130,6 +261,53 @@ module Samagotchi
130
261
  # uniq IN OUT writes OUT.
131
262
  def uniq(args) = args.reject { |a| a.start_with?("-") }.size <= 1
132
263
 
264
+ SQLITE_OPTIONS = %w[-readonly -safe -json -csv -header -noheader -line -column -box -list -separator -bail
265
+ -batch -noinit].freeze
266
+
267
+ # sqlite3 can still write or run programs unless both safety flags are
268
+ # present. Its options may follow the database name, so check every word.
269
+ def sqlite3(args)
270
+ options = []
271
+ positional = []
272
+ index = 0
273
+ while index < args.length
274
+ arg = args[index]
275
+ if arg == "-separator"
276
+ return false if index + 1 == args.length
277
+
278
+ index += 2
279
+ elsif SQLITE_OPTIONS.include?(arg)
280
+ options << arg
281
+ index += 1
282
+ elsif arg.start_with?("-", ".")
283
+ return false
284
+ else
285
+ positional << arg
286
+ index += 1
287
+ end
288
+ end
289
+
290
+ options.include?("-readonly") && options.include?("-safe") && positional.size >= 2
291
+ end
292
+
293
+ GH_READS = {
294
+ "run" => %w[list view],
295
+ "pr" => %w[list view diff checks],
296
+ "issue" => %w[list view],
297
+ "repo" => %w[view]
298
+ }.freeze
299
+
300
+ def gh(args)
301
+ # -w alone or in a bundle of short flags (-cw) opens a browser.
302
+ return false if args.any? { |arg| arg.match?(/\A-[a-zA-Z]*w/) || arg.start_with?("--web") }
303
+
304
+ group, action = args
305
+ return false unless GH_READS.fetch(group, []).include?(action)
306
+ return false if group == "pr" && action == "checks" && args.any? { |arg| arg.start_with?("--watch") }
307
+
308
+ true
309
+ end
310
+
133
311
  SED_FLAGS = /\A-[nErsuz]+\z/
134
312
  SED_LONG = %w[--quiet --silent --regexp-extended --separate --unbuffered --null-data --posix --debug
135
313
  --sandbox].freeze
@@ -225,10 +403,62 @@ module Samagotchi
225
403
  names.zero? || options.any? { |a| GIT_BRANCH_LISTING.any? { |listing| a == listing || a.start_with?("#{listing}=") } }
226
404
  end
227
405
 
406
+ CP_SHORT = "afinpvrRPLHxT"
407
+ CP_LONG = %w[--archive --force --interactive --no-clobber --recursive --verbose --dereference
408
+ --no-dereference --one-file-system --no-target-directory].freeze
409
+ CP_PRESERVE = /\A--(?:no-)?preserve(?:=[\w,]+)?\z/
410
+
411
+ # cp whose destination (the last argument, or -t / --target-directory)
412
+ # and the files it makes in it, when it is a dir, the caller accepts.
413
+ # Only options that copy: no -l/-s (links into the source), --parents
414
+ # or --backup; anything unknown is not.
415
+ def cp(args, writes)
416
+ target = nil
417
+ no_target = false
418
+ positional = []
419
+ rest = args.dup
420
+ until rest.empty?
421
+ arg = rest.shift
422
+ if !arg.start_with?("-") || arg == "-" then positional << arg
423
+ elsif arg == "--" then positional.concat(rest.shift(rest.size))
424
+ elsif (m = arg.match(/\A-([#{CP_SHORT}]*)t(.*)\z/m))
425
+ no_target ||= m[1].include?("T")
426
+ target = m[2].empty? ? rest.shift : m[2]
427
+ return false unless target
428
+ elsif arg == "--target-directory" then target = rest.shift or return false
429
+ elsif (m = arg.match(/\A--target-directory=(.+)\z/m)) then target = m[1]
430
+ elsif arg.match?(/\A-[#{CP_SHORT}]+\z/) then no_target ||= arg.include?("T")
431
+ elsif CP_LONG.include?(arg) || arg.match?(CP_PRESERVE) then no_target ||= arg == "--no-target-directory"
432
+ else return false
433
+ end
434
+ end
435
+ into = !target.nil?
436
+ target ||= positional.pop
437
+ return false if positional.empty? || (into && no_target)
438
+
439
+ dests = writes.paths(target)
440
+ return false if dests.empty?
441
+
442
+ into ||= !no_target && File.directory?(dests.last)
443
+ made = into ? dests.product(positional).map { |dest, src| File.join(dest, File.basename(src)) } : []
444
+ [*dests, *made].all? { |path| writes.ok?(path) }
445
+ end
446
+
447
+ TEE_OPTIONS = %w[-a --append -i --ignore-interrupts -p].freeze
448
+
449
+ # tee whose every file the caller accepts.
450
+ def tee(args, writes)
451
+ files = args.reject { |arg| TEE_OPTIONS.include?(arg) }
452
+ files.all? { |file| !file.start_with?("-") && writes.file?(file) }
453
+ end
454
+
455
+ # The verbs that write, for .read_only_but_writes? only: → (args, writes).
456
+ WRITE_CHECKS = { "cp" => method(:cp), "tee" => method(:tee) }.freeze
457
+
228
458
  CHECKS = {
229
459
  "rg" => method(:rg), "find" => method(:find), "tree" => method(:tree), "file" => method(:file),
230
460
  "sort" => method(:sort), "uniq" => method(:uniq), "sed" => method(:sed), "awk" => method(:awk),
231
- "git" => method(:git)
461
+ "git" => method(:git), "sqlite3" => method(:sqlite3), "gh" => method(:gh)
232
462
  }.freeze
233
463
 
234
464
  # A sed script parsed far enough to tell that it only reads: its
@@ -10,9 +10,11 @@ module Samagotchi
10
10
  # under the session's project root that isn't on the read denylist
11
11
  # (chi's state and config dirs, home secret dirs, project secret files),
12
12
  # checked on the path as given and as it resolves (a symlink in the
13
- # project to ~/.ssh is ~/.ssh). ReviewBoundary asks it for every read of
14
- # a participant's turn; a review session's subjects (chi review, the
15
- # participant page's Plan) go through the same check.
13
+ # project to ~/.ssh is ~/.ssh; a `..` after a symlinked dir climbs out
14
+ # of its target, as the kernel opens it: ProtectedPaths.reals).
15
+ # ReviewBoundary asks it for every read of a participant's turn; a
16
+ # review session's subjects (chi review, the participant page's Plan)
17
+ # go through the same check.
16
18
  class ReviewPaths
17
19
  # Home folders that hold keys and tokens, never read in a review turn
18
20
  # (nor read or written in a pair turn).
@@ -46,6 +48,17 @@ module Samagotchi
46
48
  # chi's state dir and config dir, as Paths has them now.
47
49
  def self.chi_dirs = [Paths.state_dir, File.join(Paths.config_home, Paths::APP_DIR)]
48
50
 
51
+ # Whether +root+ is the home folder or a folder above it (symlinks
52
+ # resolved on both): no project to keep a participant's turn in. A
53
+ # review turn reads nothing there (#refusal: HOME_ROOT); a pair turn
54
+ # asks the owner before every write (PairBoundary).
55
+ # @param root [String] @param home [String, nil]
56
+ def self.home_root?(root, home)
57
+ return false if home.to_s.empty? || root.to_s.empty?
58
+
59
+ ShellPaths.within?(ProtectedPaths.real(home.to_s), ProtectedPaths.real(root.to_s))
60
+ end
61
+
49
62
  # @param root [String, nil] the session's project root (the work tree
50
63
  # top of its folder); nil or blank refuses every path
51
64
  # @param denied_dirs [Array<String>] chi's own dirs (state, config:
@@ -66,14 +79,15 @@ module Samagotchi
66
79
  def refusal(paths)
67
80
  return OUTSIDE if paths.empty? || @root.empty?
68
81
 
82
+ return HOME_ROOT if ReviewPaths.home_root?(@root, @home)
83
+
69
84
  real_root = ProtectedPaths.real(@root)
70
- return HOME_ROOT if !@home.empty? && ShellPaths.within?(ProtectedPaths.real(@home), real_root)
71
85
 
72
86
  paths.each do |path|
73
87
  given = File.expand_path(path)
74
- real = ProtectedPaths.real(given)
75
- return OUTSIDE unless ShellPaths.within?(real, real_root)
76
- return SECRET if secret?(given) || secret?(real)
88
+ reals = ProtectedPaths.reals(path)
89
+ return OUTSIDE unless reals.all? { |real| ShellPaths.within?(real, real_root) }
90
+ return SECRET if secret?(given) || reals.any? { |real| secret?(real) }
77
91
  end
78
92
  nil
79
93
  end
@@ -86,7 +100,7 @@ module Samagotchi
86
100
  def secret_refusal(paths)
87
101
  paths.each do |path|
88
102
  given = File.expand_path(path)
89
- return SECRET if secret?(given) || secret?(ProtectedPaths.real(given))
103
+ return SECRET if secret?(given) || ProtectedPaths.reals(path).any? { |real| secret?(real) }
90
104
  end
91
105
  nil
92
106
  end
@@ -61,9 +61,10 @@ module Samagotchi
61
61
  return false unless options.any? { |o| o.match?(RECURSIVE) } && options.any? { |o| o.match?(FORCE) }
62
62
 
63
63
  targets.empty? || targets.any? do |target|
64
- path = walk.path(target)
65
- real = path && ProtectedPaths.real(path)
66
- real.nil? || roots.none? { |tmp| real.start_with?(File.join(tmp, "")) }
64
+ paths = walk.paths(target)
65
+ paths.empty? || paths.flat_map { |path| ProtectedPaths.reals(path) }.any? do |real|
66
+ roots.none? { |tmp| real.start_with?(File.join(tmp, "")) }
67
+ end
67
68
  end
68
69
  end
69
70
 
@@ -33,7 +33,9 @@ module Samagotchi
33
33
  targets = verdict.targets
34
34
  return false unless targets && %w[write edit].include?(targets.tool)
35
35
 
36
- targets.paths.any? { |path| ProtectedPaths.within?(ProtectedPaths.real(path), @memories_dir.call) }
36
+ targets.paths.flat_map { |path| ProtectedPaths.reals(path) }.any? do |real|
37
+ ProtectedPaths.within?(real, @memories_dir.call)
38
+ end
37
39
  end
38
40
  end
39
41
  end
@@ -1,16 +1,24 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "shell_lex"
4
+ require_relative "protected_paths"
4
5
 
5
6
  module Samagotchi
6
7
  module Guardrails
7
8
  # Where a shell command runs a git subcommand that changes a checkout
8
9
  # (commit, add, reset, …): the directories, or :unknown for one that
9
- # can't be read from the text ($VAR, $(…), `cd -`). Pure text: it
10
- # follows cd/pushd/popd, ( … ) subshells, git -C/--git-dir/--work-tree
11
- # and GIT_DIR/GIT_WORK_TREE. It catches honest mistakes (cd into
12
- # another checkout and commit there); sh -c, scripts, aliases and
13
- # $(…) bodies get through. Never raises on odd input.
10
+ # can't be read from the text ($VAR, $(…), `cd -`). Read from the text:
11
+ # it follows cd/pushd/popd, ( … ) subshells, git -C/--git-dir/--work-tree
12
+ # and GIT_DIR/GIT_WORK_TREE. Where a `..` climbs out of a symlinked
13
+ # dir's target, its lexical path and its physical one
14
+ # (ProtectedPaths.expand, the only look at the disk) both count: a cd
15
+ # may land at either (the shell's logical cd chdirs to the physical
16
+ # path when the logical one doesn't exist, and always with -P), so the
17
+ # walk keeps every dir the command may be in, and git's -C and dir
18
+ # options are opened the kernel's way; such a git counts from each. It
19
+ # catches honest mistakes (cd into another checkout and commit
20
+ # there); sh -c, scripts, aliases and $(…) bodies get through. Never
21
+ # raises on odd input.
14
22
  module ShellGitDirs
15
23
  MUTATING = %w[commit add reset checkout switch rebase merge push stash rm mv
16
24
  cherry-pick revert pull restore am].freeze
@@ -22,9 +30,11 @@ module Samagotchi
22
30
  # @param command [String] the shell command
23
31
  # @param cwd [String] where it starts (the call's cwd:, else the session's)
24
32
  # @param home [String] for ~, $HOME, ${HOME} and a bare cd
33
+ # @param env [Hash{String=>String}] the call's env: (task_create's):
34
+ # its GIT_DIR / GIT_WORK_TREE hold for each git unless it sets its own
25
35
  # @return [Array<String, Symbol>] absolute dirs and :unknown, unique
26
- def self.for(command, cwd:, home: Dir.home)
27
- Walk.new(cwd, home).run(ShellLex.simple_commands(ShellLex.lex(command.to_s)))
36
+ def self.for(command, cwd:, home: Dir.home, env: {})
37
+ Walk.new(cwd, home, env).run(ShellLex.simple_commands(ShellLex.lex(command.to_s)))
28
38
  rescue ArgumentError, EncodingError
29
39
  [] # a NUL byte, invalid UTF-8: the shell would refuse it too
30
40
  end
@@ -32,9 +42,13 @@ module Samagotchi
32
42
  # Follows the current dir through the simple commands and records
33
43
  # where each mutating git runs.
34
44
  class Walk
35
- def initialize(cwd, home)
36
- @dir = cwd
45
+ # More dirs than this after a cd: the dir is unknown.
46
+ MAX_DIRS = 8
47
+
48
+ def initialize(cwd, home, env = {})
49
+ @dirs = [cwd]
37
50
  @home = home
51
+ @env = env.slice("GIT_DIR", "GIT_WORK_TREE")
38
52
  @subshells = []
39
53
  @pushed = []
40
54
  @found = []
@@ -43,8 +57,8 @@ module Samagotchi
43
57
  def run(commands)
44
58
  commands.each do |words|
45
59
  case words
46
- when :open then @subshells << @dir
47
- when :close then @dir = @subshells.pop || @dir
60
+ when :open then @subshells << @dirs
61
+ when :close then @dirs = @subshells.pop || @dirs
48
62
  else simple(words.dup)
49
63
  end
50
64
  end
@@ -54,7 +68,7 @@ module Samagotchi
54
68
  private
55
69
 
56
70
  def simple(words)
57
- env = {}
71
+ env = @env.dup
58
72
  while (word = words.first)
59
73
  if word.match?(/\A[A-Za-z_]\w*=/)
60
74
  key, value = word.split("=", 2)
@@ -69,46 +83,63 @@ module Samagotchi
69
83
  case verb
70
84
  when "cd" then cd(args)
71
85
  when "pushd"
72
- @pushed << @dir
73
- @dir = args.first ? expand(args.first, @dir) : nil
74
- when "popd" then @dir = @pushed.pop unless @pushed.empty?
86
+ @pushed << @dirs
87
+ @dirs = args.first ? dirs_after(args.first) : [nil]
88
+ when "popd" then @dirs = @pushed.pop unless @pushed.empty?
75
89
  else git(args, env) if verb == "git" || verb.end_with?("/git")
76
90
  end
77
91
  end
78
92
 
79
93
  def cd(args)
80
94
  target = args.reject { |a| %w[-P -L -e -@].include?(a) }.first
81
- @dir = if target.nil? then @home
82
- elsif target == "-" then nil
83
- else expand(target, @dir)
84
- end
95
+ @dirs = if target.nil? then [@home]
96
+ elsif target == "-" then [nil]
97
+ else dirs_after(target)
98
+ end
99
+ end
100
+
101
+ # Every dir a cd to +target+ may land at from each current one: its
102
+ # lexical path and its physical one (nil for one it can't tell);
103
+ # [nil] past MAX_DIRS.
104
+ def dirs_after(target)
105
+ dirs = @dirs.flat_map { |dir| [false, true].map { |physical| expand(target, dir, physical: physical) } }.uniq
106
+ dirs.size > MAX_DIRS ? [nil] : dirs
85
107
  end
86
108
 
87
109
  def git(args, env)
88
- dir = @dir
89
- work_tree = env["GIT_WORK_TREE"] && expand(env["GIT_WORK_TREE"], dir)
90
- git_dir = env["GIT_DIR"] && expand(env["GIT_DIR"], dir)
110
+ @found.concat(@dirs.product([false, true]).filter_map do |dir, physical|
111
+ where_git(args.dup, env, dir, physical)
112
+ end)
113
+ end
114
+
115
+ # Where a mutating git run from +dir+ runs (a dir or :unknown), nil
116
+ # for another git; +physical+: its dir options' `..` taken the
117
+ # kernel's way.
118
+ def where_git(args, env, dir, physical)
119
+ expand = ->(arg, base) { expand(arg, base, physical: physical) }
120
+ work_tree = env["GIT_WORK_TREE"] && expand.call(env["GIT_WORK_TREE"], dir)
121
+ git_dir = env["GIT_DIR"] && expand.call(env["GIT_DIR"], dir)
91
122
  sub = nil
92
123
  until args.empty?
93
124
  arg = args.shift
94
125
  case arg
95
126
  when *VALUE_OPTIONS
96
127
  value = args.shift.to_s
97
- dir = expand(value, dir) if arg == "-C"
98
- git_dir = expand(value, dir) if arg == "--git-dir"
99
- work_tree = expand(value, dir) if arg == "--work-tree"
100
- when /\A--git-dir=(.*)\z/m then git_dir = expand(Regexp.last_match(1), dir)
101
- when /\A--work-tree=(.*)\z/m then work_tree = expand(Regexp.last_match(1), dir)
128
+ dir = expand.call(value, dir) if arg == "-C"
129
+ git_dir = expand.call(value, dir) if arg == "--git-dir"
130
+ work_tree = expand.call(value, dir) if arg == "--work-tree"
131
+ when /\A--git-dir=(.*)\z/m then git_dir = expand.call(Regexp.last_match(1), dir)
132
+ when /\A--work-tree=(.*)\z/m then work_tree = expand.call(Regexp.last_match(1), dir)
102
133
  when /\A-/ then next
103
134
  else
104
135
  sub = arg
105
136
  break
106
137
  end
107
138
  end
108
- return unless MUTATING.include?(sub)
109
- return if sub == "stash" && STASH_READS.include?(args.first)
139
+ return nil unless MUTATING.include?(sub)
140
+ return nil if sub == "stash" && STASH_READS.include?(args.first)
110
141
 
111
- @found << (work_tree || repo_of(git_dir) || dir || :unknown)
142
+ work_tree || repo_of(git_dir) || dir || :unknown
112
143
  end
113
144
 
114
145
  def repo_of(git_dir)
@@ -117,17 +148,18 @@ module Samagotchi
117
148
  File.basename(git_dir) == ".git" ? File.dirname(git_dir) : git_dir
118
149
  end
119
150
 
120
- # An absolute path for +arg+ against +dir+; nil when it can't be
121
- # told from the text (a variable, a substitution, a relative path
151
+ # An absolute path for +arg+ against +dir+ (`..` lexical, or
152
+ # +physical+: ProtectedPaths.expand); nil when it can't
153
+ # be told from the text (a variable, a substitution, a relative path
122
154
  # against an unknown dir).
123
- def expand(arg, dir)
155
+ def expand(arg, dir, physical: false)
124
156
  return nil if arg.include?(ShellLex::SUBST)
125
157
 
126
158
  arg = arg.sub(%r{\A(~|\$HOME|\$\{HOME\})(?=/|\z)}) { @home }
127
159
  return nil if arg.include?("$") || arg.start_with?("~")
128
- return File.expand_path(arg) if arg.start_with?("/")
160
+ return nil unless arg.start_with?("/") || dir
129
161
 
130
- dir && File.expand_path(arg, dir)
162
+ physical ? ProtectedPaths.expand(arg, dir) : File.expand_path(arg, dir)
131
163
  end
132
164
  end
133
165
  end
@@ -114,7 +114,7 @@ module Samagotchi
114
114
  end
115
115
  redirected ||= exec_redirect?(tokens, indices, words)
116
116
  end
117
- cuts.sort_by { |span, _| -span.begin }.each_with_object(+text) do |(span, replacement), out|
117
+ cuts.sort_by { |span, _| -span.begin }.each_with_object(text.dup) do |(span, replacement), out|
118
118
  out[span] = replacement
119
119
  end
120
120
  rescue ArgumentError, EncodingError