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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +126 -1
- data/docs/architecture.md +13 -6
- data/docs/cli.md +20 -10
- data/docs/configuration.md +7 -2
- data/docs/guardrails.md +202 -33
- data/docs/internals/background-tasks.md +11 -0
- data/docs/internals/engine.md +4 -3
- data/docs/internals/participants.md +29 -6
- data/docs/internals/remote-sessions.md +5 -4
- data/docs/internals/web-push.md +4 -1
- data/docs/sessions.md +2 -2
- data/lib/samagotchi/bridge_client.rb +8 -2
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/engine.rb +20 -4
- data/lib/samagotchi/engine_peers.rb +3 -1
- data/lib/samagotchi/guardrail_wiring.rb +6 -5
- data/lib/samagotchi/guardrails/chi_dirs.rb +64 -0
- data/lib/samagotchi/guardrails/child_boundary.rb +26 -16
- data/lib/samagotchi/guardrails/outside.rb +68 -17
- data/lib/samagotchi/guardrails/pair_boundary.rb +89 -26
- data/lib/samagotchi/guardrails/pair_command.rb +327 -72
- data/lib/samagotchi/guardrails/parent_approvals.rb +8 -10
- data/lib/samagotchi/guardrails/path_resolve.rb +231 -0
- data/lib/samagotchi/guardrails/protected_paths.rb +18 -24
- data/lib/samagotchi/guardrails/read_only_shell.rb +191 -8
- data/lib/samagotchi/guardrails/review_paths.rb +30 -14
- data/lib/samagotchi/guardrails/rm_targets.rb +7 -6
- data/lib/samagotchi/guardrails/rules.rb +23 -5
- data/lib/samagotchi/guardrails/scratch_writes.rb +4 -1
- data/lib/samagotchi/guardrails/shell_git_dirs.rb +75 -38
- data/lib/samagotchi/guardrails/shell_lex.rb +1 -1
- data/lib/samagotchi/guardrails/shell_paths.rb +85 -48
- data/lib/samagotchi/guardrails/targets.rb +131 -29
- data/lib/samagotchi/remote_command.rb +13 -3
- data/lib/samagotchi/remotes.rb +3 -2
- data/lib/samagotchi/review_start.rb +3 -2
- data/lib/samagotchi/session_inbox.rb +17 -0
- data/lib/samagotchi/session_manager.rb +9 -15
- data/lib/samagotchi/system_one/self_protect.rb +127 -20
- data/lib/samagotchi/terminal_ui/exit_lines.rb +2 -1
- data/lib/samagotchi/terminal_ui/local_link.rb +2 -2
- data/lib/samagotchi/terminal_ui.rb +2 -0
- data/lib/samagotchi/tool_declarations.rb +1 -1
- data/lib/samagotchi/tools/builtins.rb +10 -1
- data/lib/samagotchi/tools/peers.rb +4 -0
- data/lib/samagotchi/tools/task_wait.rb +26 -1
- data/lib/samagotchi/turn_state.rb +23 -4
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +32 -25
- data/lib/samagotchi/web/proxy_gate.rb +8 -6
- data/lib/samagotchi/web/public/app.js +8 -1
- data/lib/samagotchi/web/public/participant.js +2 -0
- data/lib/samagotchi/web/public/verdict_fresh.js +12 -5
- data/lib/samagotchi/web/server.rb +29 -4
- data/lib/samagotchi/worker.rb +91 -33
- data/lib/samagotchi/worker_idle_exit.rb +17 -0
- data/lib/samagotchi/worker_link.rb +13 -5
- metadata +3 -1
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative "shell_lex"
|
|
4
4
|
require_relative "shell_paths"
|
|
5
|
-
require_relative "
|
|
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 =
|
|
42
|
-
roots = tmp_roots
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
|
20
|
-
#
|
|
21
|
-
#
|
|
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
|
-
|
|
107
|
-
|
|
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.
|
|
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 -`).
|
|
10
|
-
# follows cd/pushd/popd, ( … ) subshells, git -C/--git-dir/--work-tree
|
|
11
|
-
# and GIT_DIR/GIT_WORK_TREE.
|
|
12
|
-
#
|
|
13
|
-
#
|
|
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
|
-
|
|
36
|
-
|
|
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 << @
|
|
47
|
-
when :close then @
|
|
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 << @
|
|
73
|
-
@
|
|
74
|
-
when "popd" then @
|
|
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
|
-
@
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
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
|
|
121
|
-
#
|
|
122
|
-
#
|
|
123
|
-
|
|
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 =
|
|
127
|
-
return nil
|
|
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
|
|
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(
|
|
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 "
|
|
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 =
|
|
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 =
|
|
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|
|
|
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
|
|
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
|
-
|
|
67
|
-
|
|
66
|
+
PathResolve.reals(path).any? do |real|
|
|
67
|
+
next true if roots.any? { |root| PathResolve.under?(real, root) }
|
|
68
68
|
|
|
69
|
-
|
|
69
|
+
real.match?(HOOKS_PART) && tmp_roots.none? { |tmp| PathResolve.under?(real, tmp) }
|
|
70
|
+
end
|
|
70
71
|
end
|
|
71
72
|
|
|
72
|
-
|
|
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
|
-
@
|
|
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 << @
|
|
107
|
+
when :open then @subshells << @dirs
|
|
92
108
|
[]
|
|
93
|
-
when :close then @
|
|
109
|
+
when :close then @dirs = @subshells.pop || @dirs
|
|
94
110
|
[]
|
|
95
111
|
else command(words)
|
|
96
112
|
end
|
|
97
113
|
end
|
|
98
114
|
|
|
99
|
-
#
|
|
100
|
-
def
|
|
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
|
-
|
|
107
|
-
|
|
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 @
|
|
146
|
+
when "cd" then @dirs = cd_args.empty? ? [@home] : dirs_after(cd_args.first)
|
|
110
147
|
when "pushd"
|
|
111
|
-
@pushed << @
|
|
112
|
-
@
|
|
113
|
-
when "popd" then @
|
|
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
|
-
#
|
|
119
|
-
#
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
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
|
|
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
|