samagotchi 0.128.0 → 0.130.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 (59) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +126 -1
  3. data/docs/architecture.md +13 -6
  4. data/docs/cli.md +20 -10
  5. data/docs/configuration.md +7 -2
  6. data/docs/guardrails.md +202 -33
  7. data/docs/internals/background-tasks.md +11 -0
  8. data/docs/internals/engine.md +4 -3
  9. data/docs/internals/participants.md +29 -6
  10. data/docs/internals/remote-sessions.md +5 -4
  11. data/docs/internals/web-push.md +4 -1
  12. data/docs/sessions.md +2 -2
  13. data/lib/samagotchi/bridge_client.rb +8 -2
  14. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  15. data/lib/samagotchi/engine.rb +20 -4
  16. data/lib/samagotchi/engine_peers.rb +3 -1
  17. data/lib/samagotchi/guardrail_wiring.rb +6 -5
  18. data/lib/samagotchi/guardrails/chi_dirs.rb +64 -0
  19. data/lib/samagotchi/guardrails/child_boundary.rb +26 -16
  20. data/lib/samagotchi/guardrails/outside.rb +68 -17
  21. data/lib/samagotchi/guardrails/pair_boundary.rb +89 -26
  22. data/lib/samagotchi/guardrails/pair_command.rb +327 -72
  23. data/lib/samagotchi/guardrails/parent_approvals.rb +8 -10
  24. data/lib/samagotchi/guardrails/path_resolve.rb +231 -0
  25. data/lib/samagotchi/guardrails/protected_paths.rb +18 -24
  26. data/lib/samagotchi/guardrails/read_only_shell.rb +191 -8
  27. data/lib/samagotchi/guardrails/review_paths.rb +30 -14
  28. data/lib/samagotchi/guardrails/rm_targets.rb +7 -6
  29. data/lib/samagotchi/guardrails/rules.rb +23 -5
  30. data/lib/samagotchi/guardrails/scratch_writes.rb +4 -1
  31. data/lib/samagotchi/guardrails/shell_git_dirs.rb +75 -38
  32. data/lib/samagotchi/guardrails/shell_lex.rb +1 -1
  33. data/lib/samagotchi/guardrails/shell_paths.rb +85 -48
  34. data/lib/samagotchi/guardrails/targets.rb +131 -29
  35. data/lib/samagotchi/remote_command.rb +13 -3
  36. data/lib/samagotchi/remotes.rb +3 -2
  37. data/lib/samagotchi/review_start.rb +3 -2
  38. data/lib/samagotchi/session_inbox.rb +17 -0
  39. data/lib/samagotchi/session_manager.rb +9 -15
  40. data/lib/samagotchi/system_one/self_protect.rb +127 -20
  41. data/lib/samagotchi/terminal_ui/exit_lines.rb +2 -1
  42. data/lib/samagotchi/terminal_ui/local_link.rb +2 -2
  43. data/lib/samagotchi/terminal_ui.rb +2 -0
  44. data/lib/samagotchi/tool_declarations.rb +1 -1
  45. data/lib/samagotchi/tools/builtins.rb +10 -1
  46. data/lib/samagotchi/tools/peers.rb +4 -0
  47. data/lib/samagotchi/tools/task_wait.rb +26 -1
  48. data/lib/samagotchi/turn_state.rb +23 -4
  49. data/lib/samagotchi/version.rb +1 -1
  50. data/lib/samagotchi/web/app.rb +32 -25
  51. data/lib/samagotchi/web/proxy_gate.rb +8 -6
  52. data/lib/samagotchi/web/public/app.js +8 -1
  53. data/lib/samagotchi/web/public/participant.js +2 -0
  54. data/lib/samagotchi/web/public/verdict_fresh.js +12 -5
  55. data/lib/samagotchi/web/server.rb +29 -4
  56. data/lib/samagotchi/worker.rb +91 -33
  57. data/lib/samagotchi/worker_idle_exit.rb +17 -0
  58. data/lib/samagotchi/worker_link.rb +13 -5
  59. metadata +3 -1
@@ -2,7 +2,7 @@
2
2
 
3
3
  require_relative "shell_lex"
4
4
  require_relative "shell_paths"
5
- require_relative "protected_paths"
5
+ require_relative "path_resolve"
6
6
 
7
7
  module Samagotchi
8
8
  module Guardrails
@@ -38,8 +38,8 @@ module Samagotchi
38
38
  code = ShellLex.code_text(command.to_s)
39
39
  return true if tokens.any? { |_, value| value.include?(ShellLex::SUBST) } && code.match?(/\brm\b/)
40
40
 
41
- session = ProtectedPaths.real(session_root.to_s)
42
- roots = tmp_roots.reject { |tmp| ShellPaths.within?(session, tmp) }
41
+ session = PathResolve.real(session_root.to_s)
42
+ roots = PathResolve.tmp_roots(session: session, roots: tmp_roots)
43
43
  walk = ShellPaths::Walk.new(cwd, home, env)
44
44
  ShellLex.simple_commands(tokens).any? do |words|
45
45
  wide = words.is_a?(Array) && words.any?(&RM_WORD) && wide?(words, walk, roots)
@@ -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| PathResolve.reals(path) }.any? do |real|
66
+ roots.none? { |tmp| PathResolve.strictly_under?(real, tmp, fold: File.exist?(tmp)) }
67
+ end
67
68
  end
68
69
  end
69
70
 
@@ -16,9 +16,12 @@ module Samagotchi
16
16
  # command: a Ruby regex on a shell tool's command, the body of a
17
17
  # heredoc its command only reads as data left out
18
18
  # (Targets#rule_command, ShellLex.code_text)
19
- # path: "outside_repo", or a glob on the resolved paths (absolute
20
- # or "**/…" globs match the absolute path; others the path
21
- # relative to the repo root, the cwd outside one)
19
+ # path: "outside_repo", or a glob on the paths (absolute or "**/…"
20
+ # globs match the absolute path; others the path relative
21
+ # to the repo root, the cwd outside one): as typed
22
+ # (Targets#paths) and, for an ask or deny, as they resolve
23
+ # (PathResolve.reals, relative to the root as it resolves),
24
+ # so a symlink into .git/hooks doesn't slip past a hooks rule
22
25
  # git: "outside_repo": a shell call runs a mutating git
23
26
  # subcommand outside the session's repo (ShellGitDirs)
24
27
  # models: "small" (Guardrails::ModelSize, guardrails.small_models),
@@ -102,12 +105,27 @@ module Samagotchi
102
105
 
103
106
  base = targets.repo_root || targets.cwd
104
107
  glob = path.start_with?("~") ? File.expand_path(path) : path
108
+ absolute = glob.start_with?("/", "**")
109
+ real_base = nil
105
110
  targets.paths.any? do |p|
106
- candidate = glob.start_with?("/", "**") ? p : relative(p, base)
107
- candidate && File.fnmatch(glob, candidate, GLOB_FLAGS)
111
+ forms = [[p, base]]
112
+ # A resolved match only adds an ask or a deny; were a verdict
113
+ # ever to let a call through, a symlink into its glob must not.
114
+ if resolved_match?
115
+ real_base ||= PathResolve.real(base)
116
+ forms += PathResolve.reals(p).map { |real| [real, real_base] }
117
+ end
118
+ forms.any? do |form, from|
119
+ candidate = absolute ? form : relative(form, from)
120
+ candidate && File.fnmatch(glob, candidate, GLOB_FLAGS)
121
+ end
108
122
  end
109
123
  end
110
124
 
125
+ # Whether a path: glob also matches the paths as they resolve: for
126
+ # an ask or a deny only.
127
+ def resolved_match? = VERDICTS.include?(verdict.to_s)
128
+
111
129
  def relative(path, base)
112
130
  prefix = File.join(base, "")
113
131
  path.start_with?(prefix) ? path.delete_prefix(prefix) : nil
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "path_resolve"
3
4
  require_relative "protected_paths"
4
5
  require_relative "../memory_paths"
5
6
 
@@ -33,7 +34,9 @@ module Samagotchi
33
34
  targets = verdict.targets
34
35
  return false unless targets && %w[write edit].include?(targets.tool)
35
36
 
36
- targets.paths.any? { |path| ProtectedPaths.within?(ProtectedPaths.real(path), @memories_dir.call) }
37
+ targets.paths.flat_map { |path| PathResolve.reals(path) }.any? do |real|
38
+ PathResolve.within?(real, @memories_dir.call)
39
+ end
37
40
  end
38
41
  end
39
42
  end
@@ -1,16 +1,27 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "shell_lex"
4
+ require_relative "path_resolve"
5
+ require_relative "shell_paths"
4
6
 
5
7
  module Samagotchi
6
8
  module Guardrails
7
9
  # Where a shell command runs a git subcommand that changes a checkout
8
10
  # (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.
11
+ # can't be read from the text ($VAR, $(…), `cd -`). Read from the text:
12
+ # it follows cd/pushd/popd, ( … ) subshells, git -C/--git-dir/--work-tree
13
+ # and GIT_DIR/GIT_WORK_TREE. Where a `..` climbs out of a symlinked
14
+ # dir's target, its lexical path and its physical one
15
+ # (PathResolve.expand, the only look at the disk) both count: a cd
16
+ # may land at either (the shell's logical cd chdirs to the physical
17
+ # path when the logical one doesn't exist, and always with -P), so the
18
+ # walk keeps every dir the command may be in, and git's -C and dir
19
+ # options are opened the kernel's way; such a git counts from each. A
20
+ # leading ~, $HOME, XDG dir or $TMPDIR is expanded as ShellPaths::Walk
21
+ # expands it (PathResolve.expand_word). It
22
+ # catches honest mistakes (cd into another checkout and commit
23
+ # there); sh -c, scripts, aliases and $(…) bodies get through. Never
24
+ # raises on odd input.
14
25
  module ShellGitDirs
15
26
  MUTATING = %w[commit add reset checkout switch rebase merge push stash rm mv
16
27
  cherry-pick revert pull restore am].freeze
@@ -22,9 +33,13 @@ module Samagotchi
22
33
  # @param command [String] the shell command
23
34
  # @param cwd [String] where it starts (the call's cwd:, else the session's)
24
35
  # @param home [String] for ~, $HOME, ${HOME} and a bare cd
36
+ # @param env [Hash{String=>String}] the call's env: (task_create's):
37
+ # its GIT_DIR / GIT_WORK_TREE hold for each git unless it sets its own
38
+ # @param vars [Hash{String=>String}] the shell's variables a word's
39
+ # $XDG_* / $TMPDIR expand from (ENV, with the call's env: over it)
25
40
  # @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)))
41
+ def self.for(command, cwd:, home: Dir.home, env: {}, vars: ENV)
42
+ Walk.new(cwd, home, env, vars).run(ShellLex.simple_commands(ShellLex.lex(command.to_s)))
28
43
  rescue ArgumentError, EncodingError
29
44
  [] # a NUL byte, invalid UTF-8: the shell would refuse it too
30
45
  end
@@ -32,9 +47,14 @@ module Samagotchi
32
47
  # Follows the current dir through the simple commands and records
33
48
  # where each mutating git runs.
34
49
  class Walk
35
- def initialize(cwd, home)
36
- @dir = cwd
50
+ # More dirs than this after a cd: the dir is unknown.
51
+ MAX_DIRS = 8
52
+
53
+ def initialize(cwd, home, env = {}, vars = ENV)
54
+ @dirs = [cwd]
37
55
  @home = home
56
+ @vars = vars
57
+ @env = env.slice("GIT_DIR", "GIT_WORK_TREE")
38
58
  @subshells = []
39
59
  @pushed = []
40
60
  @found = []
@@ -43,8 +63,8 @@ module Samagotchi
43
63
  def run(commands)
44
64
  commands.each do |words|
45
65
  case words
46
- when :open then @subshells << @dir
47
- when :close then @dir = @subshells.pop || @dir
66
+ when :open then @subshells << @dirs
67
+ when :close then @dirs = @subshells.pop || @dirs
48
68
  else simple(words.dup)
49
69
  end
50
70
  end
@@ -54,7 +74,7 @@ module Samagotchi
54
74
  private
55
75
 
56
76
  def simple(words)
57
- env = {}
77
+ env = @env.dup
58
78
  while (word = words.first)
59
79
  if word.match?(/\A[A-Za-z_]\w*=/)
60
80
  key, value = word.split("=", 2)
@@ -69,46 +89,63 @@ module Samagotchi
69
89
  case verb
70
90
  when "cd" then cd(args)
71
91
  when "pushd"
72
- @pushed << @dir
73
- @dir = args.first ? expand(args.first, @dir) : nil
74
- when "popd" then @dir = @pushed.pop unless @pushed.empty?
92
+ @pushed << @dirs
93
+ @dirs = args.first ? dirs_after(args.first) : [nil]
94
+ when "popd" then @dirs = @pushed.pop unless @pushed.empty?
75
95
  else git(args, env) if verb == "git" || verb.end_with?("/git")
76
96
  end
77
97
  end
78
98
 
79
99
  def cd(args)
80
100
  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
101
+ @dirs = if target.nil? then [@home]
102
+ elsif target == "-" then [nil]
103
+ else dirs_after(target)
104
+ end
105
+ end
106
+
107
+ # Every dir a cd to +target+ may land at from each current one: its
108
+ # lexical path and its physical one (nil for one it can't tell);
109
+ # [nil] past MAX_DIRS.
110
+ def dirs_after(target)
111
+ dirs = @dirs.flat_map { |dir| [false, true].map { |physical| expand(target, dir, physical: physical) } }.uniq
112
+ dirs.size > MAX_DIRS ? [nil] : dirs
85
113
  end
86
114
 
87
115
  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)
116
+ @found.concat(@dirs.product([false, true]).filter_map do |dir, physical|
117
+ where_git(args.dup, env, dir, physical)
118
+ end)
119
+ end
120
+
121
+ # Where a mutating git run from +dir+ runs (a dir or :unknown), nil
122
+ # for another git; +physical+: its dir options' `..` taken the
123
+ # kernel's way.
124
+ def where_git(args, env, dir, physical)
125
+ expand = ->(arg, base) { expand(arg, base, physical: physical) }
126
+ work_tree = env["GIT_WORK_TREE"] && expand.call(env["GIT_WORK_TREE"], dir)
127
+ git_dir = env["GIT_DIR"] && expand.call(env["GIT_DIR"], dir)
91
128
  sub = nil
92
129
  until args.empty?
93
130
  arg = args.shift
94
131
  case arg
95
132
  when *VALUE_OPTIONS
96
133
  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)
134
+ dir = expand.call(value, dir) if arg == "-C"
135
+ git_dir = expand.call(value, dir) if arg == "--git-dir"
136
+ work_tree = expand.call(value, dir) if arg == "--work-tree"
137
+ when /\A--git-dir=(.*)\z/m then git_dir = expand.call(Regexp.last_match(1), dir)
138
+ when /\A--work-tree=(.*)\z/m then work_tree = expand.call(Regexp.last_match(1), dir)
102
139
  when /\A-/ then next
103
140
  else
104
141
  sub = arg
105
142
  break
106
143
  end
107
144
  end
108
- return unless MUTATING.include?(sub)
109
- return if sub == "stash" && STASH_READS.include?(args.first)
145
+ return nil unless MUTATING.include?(sub)
146
+ return nil if sub == "stash" && STASH_READS.include?(args.first)
110
147
 
111
- @found << (work_tree || repo_of(git_dir) || dir || :unknown)
148
+ work_tree || repo_of(git_dir) || dir || :unknown
112
149
  end
113
150
 
114
151
  def repo_of(git_dir)
@@ -117,17 +154,17 @@ module Samagotchi
117
154
  File.basename(git_dir) == ".git" ? File.dirname(git_dir) : git_dir
118
155
  end
119
156
 
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
122
- # against an unknown dir).
123
- def expand(arg, dir)
157
+ # An absolute path for +arg+ against +dir+ (`..` lexical, or
158
+ # +physical+: PathResolve.expand); nil when it can't
159
+ # be told from the text (a variable but those PathResolve.expand_word
160
+ # expands, a substitution, a relative path against an unknown dir).
161
+ def expand(arg, dir, physical: false)
124
162
  return nil if arg.include?(ShellLex::SUBST)
125
163
 
126
- arg = arg.sub(%r{\A(~|\$HOME|\$\{HOME\})(?=/|\z)}) { @home }
127
- return nil if arg.include?("$") || arg.start_with?("~")
128
- return File.expand_path(arg) if arg.start_with?("/")
164
+ arg = PathResolve.expand_word(arg, home: @home, env: @vars) or return nil
165
+ return nil unless arg.start_with?("/") || dir
129
166
 
130
- dir && File.expand_path(arg, dir)
167
+ physical ? PathResolve.expand(arg, dir) : File.expand_path(arg, dir)
131
168
  end
132
169
  end
133
170
  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
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "shell_lex"
4
- require_relative "protected_paths"
4
+ require_relative "path_resolve"
5
5
  require_relative "../redacted_inspect"
6
6
 
7
7
  module Samagotchi
@@ -24,11 +24,10 @@ module Samagotchi
24
24
  SCRIPT = /[\s;&|<>()'"`]/
25
25
  GLOB = /[*?\[\]{}]/
26
26
  HOOKS_PART = %r{(?:\A|/)\.git/hooks(?:/|\z)}
27
- # $HOME, ${HOME} and the XDG dirs at a word's start.
28
- EXPANSION = /\A\$(?:\{(HOME|XDG_[A-Z_]+|TMPDIR)\}|(HOME|XDG_[A-Z_]+|TMPDIR)\b)/
27
+ # $HOME, ${HOME} and the XDG dirs at a word's start (PathResolve's).
28
+ EXPANSION = PathResolve::EXPANSION
29
29
  # Where XDG dirs default when unset.
30
- XDG_DEFAULTS = { "XDG_CONFIG_HOME" => ".config", "XDG_STATE_HOME" => ".local/state",
31
- "XDG_DATA_HOME" => ".local/share", "XDG_CACHE_HOME" => ".cache" }.freeze
30
+ XDG_DEFAULTS = PathResolve::XDG_DEFAULTS
32
31
  # A redirection's operator at a word's start (>>file, 2>file, <file).
33
32
  REDIRECT_PREFIX = /\A\d*(?:&>>?|>>?&?|<)/
34
33
 
@@ -40,7 +39,7 @@ module Samagotchi
40
39
  # @param cwd [String, nil] where the command starts
41
40
  # @param tmp_roots [Array<String>] where a .git/hooks part doesn't count
42
41
  def touches?(command, dirs:, text:, cwd:, home: Dir.home, env: ENV, tmp_roots: [])
43
- roots = dirs.compact.map { |dir| ProtectedPaths.real(dir.to_s.chomp("/")) }.reject(&:empty?).uniq
42
+ roots = dirs.compact.map { |dir| PathResolve.real(dir.to_s.chomp("/")) }.reject(&:empty?).uniq
44
43
  any_path?(command, text: text, cwd: cwd, home: home, env: env) { |path| names?(path, roots, tmp_roots) }
45
44
  end
46
45
 
@@ -48,7 +47,8 @@ module Samagotchi
48
47
  # word resolved as #touches? resolves it); a word that can't be
49
48
  # resolved, a command with a $(…) or backticks, or one that doesn't
50
49
  # lex, by the text match +text+ instead.
51
- # @yieldparam path [String] an absolute path (not resolved for symlinks)
50
+ # @yieldparam path [String] an absolute path (not resolved for
51
+ # symlinks but for a `..` climbing out of one: Walk)
52
52
  def any_path?(command, text:, cwd:, home: Dir.home, env: ENV)
53
53
  walk = Walk.new(cwd, home, env)
54
54
  tokens = ShellLex.lex(command.to_s)
@@ -63,21 +63,37 @@ module Samagotchi
63
63
  end
64
64
 
65
65
  def names?(path, roots, tmp_roots)
66
- real = ProtectedPaths.real(path)
67
- return true if roots.any? { |root| within?(real, root) }
66
+ PathResolve.reals(path).any? do |real|
67
+ next true if roots.any? { |root| PathResolve.under?(real, root) }
68
68
 
69
- real.match?(HOOKS_PART) && tmp_roots.none? { |tmp| within?(real, tmp) }
69
+ real.match?(HOOKS_PART) && tmp_roots.none? { |tmp| PathResolve.under?(real, tmp) }
70
+ end
70
71
  end
71
72
 
72
- def within?(path, root) = path == root || path.start_with?(File.join(root, ""))
73
+ # Whether +path+ is +root+ or in it, cased alike on a disk that folds
74
+ # case (PathResolve.under?).
75
+ def within?(path, root) = PathResolve.under?(path, root)
73
76
 
74
77
  # Follows the current dir and gives, per simple command, each word
75
- # with its path (nil when it can't be resolved).
78
+ # with its path (nil when it can't be resolved). A word is opened the
79
+ # kernel's way, so a `..` after a symlinked dir climbs out of its
80
+ # target: such a word comes twice, with its lexical path and its
81
+ # physical one (PathResolve.expand), and a check reads both. A cd
82
+ # or pushd to such a word may land at either (the shell's logical cd
83
+ # chdirs to the physical path when the logical one doesn't exist, and
84
+ # always with -P, set -P or zsh's chase_links/chase_dots), so the walk
85
+ # keeps every dir the command may be in by then and resolves later
86
+ # words against each (a word not resolvable from one of them isn't
87
+ # resolved). A symlink without a `..` lands at the same file either
88
+ # way.
76
89
  class Walk
77
90
  include RedactedInspect
78
91
 
92
+ # More dirs than this after a cd: the dir is unknown.
93
+ MAX_DIRS = 8
94
+
79
95
  def initialize(cwd, home, env)
80
- @dir = cwd
96
+ @dirs = [cwd]
81
97
  @home = home
82
98
  @env = env
83
99
  @subshells = []
@@ -88,45 +104,78 @@ module Samagotchi
88
104
  # @return [Array<[String, String|nil]>]
89
105
  def step(words)
90
106
  case words
91
- when :open then @subshells << @dir
107
+ when :open then @subshells << @dirs
92
108
  []
93
- when :close then @dir = @subshells.pop || @dir
109
+ when :close then @dirs = @subshells.pop || @dirs
94
110
  []
95
111
  else command(words)
96
112
  end
97
113
  end
98
114
 
99
- # An absolute path for +word+ against the current dir, or nil.
100
- def path(word) = resolve(word, @dir)
115
+ # Every dir the command may be in by now (nil for one it can't tell).
116
+ def dirs = @dirs.dup
117
+
118
+ # The part of +word+ that may be a path: an option's or an
119
+ # assignment's value, a redirection's target; nil for an option
120
+ # alone or an operator.
121
+ def candidate(word)
122
+ word = word.sub(REDIRECT_PREFIX, "")
123
+ word = word.split("=", 2).last if word.match?(/\A(--?[\w-]+|[A-Za-z_]\w*)=/)
124
+ return nil if word.empty? || word == "-" || word.match?(%r{\A-[^/]})
125
+
126
+ word
127
+ end
128
+
129
+ # The absolute paths for +word+ against every dir the command may be
130
+ # in (.paths_for), [] when it can't be resolved from one of them.
131
+ def paths(word)
132
+ found = @dirs.map { |dir| paths_for(word, dir) }
133
+ found.any?(&:empty?) ? [] : found.flatten.uniq
134
+ end
101
135
 
102
136
  private
103
137
 
104
138
  def command(words)
105
139
  verb = words.find { |w| !w.match?(/\A[A-Za-z_]\w*=/) }
106
- resolved = words.filter_map { |word| candidate(word) }.map { |word| [word, resolve(word, @dir)] }
107
- cd_args = words.drop_while { |w| w != verb }.drop(1).grep_v(/\A-/)
140
+ cd_args = words.drop_while { |w| w != verb }.drop(1).grep_v(/\A-./)
141
+ resolved = words.filter_map { |word| candidate(word) }.flat_map do |word|
142
+ paths = paths(word)
143
+ paths.empty? ? [[word, nil]] : paths.map { |path| [word, path] }
144
+ end
108
145
  case verb
109
- when "cd" then @dir = cd_args.empty? ? @home : resolve(cd_args.first, @dir)
146
+ when "cd" then @dirs = cd_args.empty? ? [@home] : dirs_after(cd_args.first)
110
147
  when "pushd"
111
- @pushed << @dir
112
- @dir = cd_args.first && resolve(cd_args.first, @dir)
113
- when "popd" then @dir = @pushed.pop unless @pushed.empty?
148
+ @pushed << @dirs
149
+ @dirs = cd_args.first ? dirs_after(cd_args.first) : [nil]
150
+ when "popd" then @dirs = @pushed.pop unless @pushed.empty?
114
151
  end
115
152
  resolved
116
153
  end
117
154
 
118
- # The part of +word+ that may be a path: an option's or an
119
- # assignment's value, a redirection's target; nil for an option
120
- # alone or an operator.
121
- def candidate(word)
122
- word = word.sub(REDIRECT_PREFIX, "")
123
- word = word.split("=", 2).last if word.match?(/\A(--?[\w-]+|[A-Za-z_]\w*)=/)
124
- return nil if word.empty? || word == "-" || word.match?(%r{\A-[^/]})
155
+ # Every dir a cd to +word+ may land at, from each current one (nil
156
+ # for one it can't tell: `cd -` among them); [nil] past MAX_DIRS.
157
+ def dirs_after(word)
158
+ return [nil] if word == "-"
125
159
 
126
- word
160
+ dirs = @dirs.flat_map do |dir|
161
+ found = paths_for(word, dir)
162
+ found.empty? ? [nil] : found
163
+ end.uniq
164
+ dirs.size > MAX_DIRS ? [nil] : dirs
165
+ end
166
+
167
+ # +word+ as a file argument against +dir+: its lexical path, and its
168
+ # physical one when a `..` in it climbs out of a symlinked dir's
169
+ # target (PathResolve.expand); [] when it can't be resolved.
170
+ def paths_for(word, dir)
171
+ lexical = resolve(word, dir) or return []
172
+ return [lexical] unless word.include?("..")
173
+
174
+ [lexical, PathResolve.expand(expand(word), dir)].uniq
127
175
  end
128
176
 
129
- # An absolute path for +word+ against +dir+, or nil.
177
+ # An absolute path for +word+ against +dir+ with `..` taken
178
+ # lexically, or nil.
130
179
  def resolve(word, dir)
131
180
  return nil if word.include?(ShellLex::SUBST) || word == ShellLex::HEREDOC || word.match?(SCRIPT)
132
181
 
@@ -137,23 +186,11 @@ module Samagotchi
137
186
  dir && File.expand_path(word, dir)
138
187
  end
139
188
 
140
- def expand(word)
141
- word = word.sub(%r{\A~(?=/|\z)}) { @home }
142
- word = word.sub(EXPANSION) { value(Regexp.last_match(1) || Regexp.last_match(2)) || "$" }
143
- return nil if word.include?("$") || word.start_with?("~")
144
-
145
- word
146
- end
147
-
148
- def value(name)
149
- return @home if name == "HOME"
150
-
151
- set = @env[name].to_s
152
- return set unless set.empty?
153
-
154
- XDG_DEFAULTS[name] && File.join(@home, XDG_DEFAULTS[name])
155
- end
189
+ def expand(word) = PathResolve.expand_word(word, home: @home, env: @env)
156
190
  end
191
+
192
+ # PathResolve.expand_word, as this module has long named it.
193
+ def expand_word(word, home:, env:) = PathResolve.expand_word(word, home: home, env: env)
157
194
  end
158
195
  end
159
196
  end