ruby_raider 4.0.1 → 4.1.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 (60) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +59 -1
  3. data/RELEASE.md +12 -0
  4. data/lib/commands/flow_commands.rb +104 -0
  5. data/lib/generators/templates/flows/flow_helper.tt +42 -0
  6. data/lib/generators/templates/flows/run.tt +332 -0
  7. data/lib/ruby_raider.rb +16 -0
  8. data/lib/utilities/flows/next_run.rb +61 -0
  9. data/lib/utilities/flows/notifier.rb +76 -0
  10. data/lib/utilities/flows/os/command_runner.rb +73 -0
  11. data/lib/utilities/flows/os/cron.rb +137 -0
  12. data/lib/utilities/flows/os/launchd.rb +164 -0
  13. data/lib/utilities/flows/os/linux.rb +63 -0
  14. data/lib/utilities/flows/os/systemd.rb +178 -0
  15. data/lib/utilities/flows/os/task_scheduler.rb +179 -0
  16. data/lib/utilities/flows/project.rb +170 -0
  17. data/lib/utilities/flows/run_log.rb +61 -0
  18. data/lib/utilities/flows/runner.rb +206 -0
  19. data/lib/utilities/flows/schedule.rb +221 -0
  20. data/lib/utilities/flows/schedule_store.rb +135 -0
  21. data/lib/utilities/flows/scheduler.rb +187 -0
  22. data/lib/version +1 -1
  23. data/sig/commands/flow_commands.rbs +13 -0
  24. data/sig/ruby_raider.rbs +2 -0
  25. data/sig/utilities/flows/next_run.rbs +15 -0
  26. data/sig/utilities/flows/notifier.rbs +29 -0
  27. data/sig/utilities/flows/os/command_runner.rbs +27 -0
  28. data/sig/utilities/flows/os/cron.rbs +34 -0
  29. data/sig/utilities/flows/os/launchd.rbs +45 -0
  30. data/sig/utilities/flows/os/linux.rbs +27 -0
  31. data/sig/utilities/flows/os/systemd.rbs +43 -0
  32. data/sig/utilities/flows/os/task_scheduler.rbs +53 -0
  33. data/sig/utilities/flows/project.rbs +46 -0
  34. data/sig/utilities/flows/run_log.rbs +22 -0
  35. data/sig/utilities/flows/runner.rbs +35 -0
  36. data/sig/utilities/flows/schedule.rbs +61 -0
  37. data/sig/utilities/flows/schedule_store.rbs +34 -0
  38. data/sig/utilities/flows/scheduler.rbs +42 -0
  39. data/spec/commands/flow_commands_spec.rb +128 -0
  40. data/spec/generators/flow_helper_spec.rb +40 -0
  41. data/spec/integration/content/gemfile_content_spec.rb +2 -2
  42. data/spec/integration/end_to_end_spec.rb +63 -0
  43. data/spec/integration/flows/flow_project.rb +62 -0
  44. data/spec/integration/flows/run_command_spec.rb +91 -0
  45. data/spec/integration/flows/run_lifecycle_spec.rb +383 -0
  46. data/spec/utilities/flows/next_run_spec.rb +119 -0
  47. data/spec/utilities/flows/notifier_spec.rb +121 -0
  48. data/spec/utilities/flows/os/command_runner_spec.rb +56 -0
  49. data/spec/utilities/flows/os/cron_spec.rb +287 -0
  50. data/spec/utilities/flows/os/launchd_spec.rb +293 -0
  51. data/spec/utilities/flows/os/linux_spec.rb +149 -0
  52. data/spec/utilities/flows/os/systemd_spec.rb +329 -0
  53. data/spec/utilities/flows/os/task_scheduler_spec.rb +505 -0
  54. data/spec/utilities/flows/project_spec.rb +178 -0
  55. data/spec/utilities/flows/run_log_spec.rb +47 -0
  56. data/spec/utilities/flows/runner_spec.rb +313 -0
  57. data/spec/utilities/flows/schedule_spec.rb +233 -0
  58. data/spec/utilities/flows/schedule_store_spec.rb +137 -0
  59. data/spec/utilities/flows/scheduler_spec.rb +329 -0
  60. metadata +53 -2
@@ -0,0 +1,287 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'shellwords'
4
+ require 'tmpdir'
5
+ require_relative '../../../../lib/utilities/flows/os/cron'
6
+
7
+ # Flow specs live under spec/utilities/flows, next to the code in lib/utilities/flows
8
+ # The fake runner and project folders sit next to the adapter under test
9
+ # rubocop:disable RSpec/MultipleMemoizedHelpers
10
+ RSpec.describe RubyRaider::Flows::Os::Cron do # rubocop:disable RSpec/FilePath
11
+ # Plays crontab: -l prints the current table (or fails), - stores stdin; never runs anything
12
+ let(:fake_runner_class) do
13
+ Class.new do
14
+ attr_reader :calls, :answers
15
+ attr_accessor :table
16
+
17
+ def initialize
18
+ @calls = []
19
+ @answers = {}
20
+ @table = nil
21
+ end
22
+
23
+ def call(*argv, stdin_data: nil)
24
+ @calls << [*argv, stdin_data].compact
25
+ verb = argv[1]
26
+ missing = verb == '-l' && table.nil?
27
+ stderr, status = answers.fetch(verb) { missing ? ['no crontab for raider', 1] : ['', 0] }
28
+ stdout = verb == '-l' ? table.to_s : ''
29
+ self.table = stdin_data if verb == '-' && status.zero?
30
+ RubyRaider::Flows::Os::CommandRunner::Result.new(stdout: stdout, stderr: stderr, exitstatus: status)
31
+ end
32
+ end
33
+ end
34
+
35
+ let(:hostile) { %q('; rm -rf ~; ' </string> %h $(id) " \\ &) }
36
+ let(:tmp) { File.realpath(Dir.mktmpdir('flows-cron')) }
37
+ let(:project_dir) { File.join(tmp, 'plain') }
38
+ let(:runner) { fake_runner_class.new }
39
+ let(:cron) { described_class.new(runner: runner) }
40
+ let(:now) { Time.utc(2026, 11, 9, 10, 0, 0) }
41
+
42
+ def schedule(dir: project_dir, name: 'report', **options)
43
+ FileUtils.mkdir_p(dir)
44
+ project = RubyRaider::Flows::Project.new(File.realpath(dir))
45
+ RubyRaider::Flows::Schedule.build(project: project, name: name, every: 'day', at: '08:00', now: now, **options)
46
+ end
47
+
48
+ # What cron (cronie/vixie do_command.c) hands to /bin/sh: a backslash escapes the next character, an
49
+ # escaped % loses its backslash, any other escaped character keeps it, and an unescaped % would end the
50
+ # command (the rest becomes stdin). Strict: an unescaped % fails the spec.
51
+ def cron_command(text)
52
+ command = +''
53
+ escaped = false
54
+ text.each_char do |char|
55
+ if escaped
56
+ command.chop! if char == '%'
57
+ command << char
58
+ escaped = false
59
+ next
60
+ end
61
+ raise ArgumentError, "unescaped % in #{text.inspect}" if char == '%'
62
+
63
+ escaped = char == '\\'
64
+ command << char
65
+ end
66
+ command
67
+ end
68
+
69
+ def fields_and_words(line)
70
+ fields = line.split(' ', 6)
71
+ [fields.first(5), Shellwords.split(cron_command(fields.last))]
72
+ end
73
+
74
+ def expected_words(item)
75
+ ['cd', item.working_directory, '&&', '/usr/bin/env', *item.env.map { |key, value| "#{key}=#{value}" }, *item.argv,
76
+ '>>', item.log_path, '2>&1']
77
+ end
78
+
79
+ def block(item) = "# raider-flow #{item.os_id}\n#{cron.line(item)}\n"
80
+
81
+ after { FileUtils.rm_rf(tmp) }
82
+
83
+ it 'is called cron in messages and says missed runs are skipped' do
84
+ expect([cron.label, cron.missed_runs_note]).to eq(['cron', '(cron: missed runs are skipped)'])
85
+ end
86
+
87
+ describe '#line' do
88
+ it 'writes the exact line for a daily schedule' do
89
+ item = schedule
90
+ env = item.env.map { |key, value| Shellwords.escape("#{key}=#{value}") }.join(' ')
91
+ args = item.argv.map { |arg| Shellwords.escape(arg) }.join(' ')
92
+
93
+ expect(cron.line(item)).to eq("0 8 * * * cd #{Shellwords.escape(item.working_directory)} && /usr/bin/env #{env} #{args} " \
94
+ ">> #{Shellwords.escape(item.log_path)} 2>&1")
95
+ end
96
+
97
+ it 'turns each rule into cron fields, Sunday 0' do
98
+ rules = [schedule(every: 'hour', at: ':15'), schedule(every: 'weekday', at: '18:05'),
99
+ schedule(every: 'week', on: 'sun', at: '07:30'), schedule(every: 'week', on: 'sat', at: '23:59')]
100
+
101
+ expect(rules.map { |item| fields_and_words(cron.line(item)).first.join(' ') })
102
+ .to eq(['15 * * * *', '5 18 * * 1-5', '30 7 * * 0', '59 23 * * 6'])
103
+ end
104
+
105
+ it 'keeps GEM_HOME and GEM_PATH in the environment when they were captured' do
106
+ allow(RubyRaider::Flows::Schedule).to receive(:captured_gem_env).and_return('GEM_HOME' => '/g/%home', 'GEM_PATH' => '/g/path')
107
+
108
+ expect(fields_and_words(cron.line(schedule)).last).to include('GEM_HOME=/g/%home', 'GEM_PATH=/g/path')
109
+ end
110
+
111
+ it 'refuses a ruby path env would read as a variable' do
112
+ plain = schedule
113
+ item = RubyRaider::Flows::Schedule.from_h(plain.to_h.merge('ruby' => '/opt/a=b/ruby'), plain.project_root)
114
+
115
+ expect { cron.line(item) }.to raise_error(RubyRaider::Flows::Error, /cron can not run ruby from .*a=b/)
116
+ end
117
+
118
+ describe 'with hostile values in an input and in the project path' do
119
+ let(:item) { schedule(dir: File.join(tmp, "p#{hostile}<", "string> #{hostile} 100%"), inputs: ["note=#{hostile} 50%"]) }
120
+ let(:text) { cron.line(item) }
121
+
122
+ it 'gives back the exact command words after cron and the shell read it' do
123
+ fields, words = fields_and_words(text)
124
+
125
+ expect([fields, words]).to eq([%w[0 8 * * *], expected_words(item)])
126
+ expect(words).to include("note=#{hostile} 50%")
127
+ expect(item.working_directory).to include('</string>', "'; rm -rf ~; '", '$(id)', '%h', '"', '\\', '&', ' ')
128
+ end
129
+
130
+ it 'writes them only in escaped form' do
131
+ expect(text).to include('\\%h', '\\$\\(id\\)', "\\'\\;\\ rm")
132
+ expect(text).not_to include('$(id)', "'; rm", "\n")
133
+ end
134
+ end
135
+ end
136
+
137
+ describe '#install' do
138
+ it 'starts a crontab when there is none' do
139
+ item = schedule
140
+
141
+ cron.install(item)
142
+
143
+ expect(runner.calls).to eq([%w[crontab -l], ['crontab', '-', block(item)]])
144
+ end
145
+
146
+ it 'puts the block first and keeps every other byte, CRLF and a missing last newline included' do
147
+ item = schedule
148
+ other = "SHELL=/bin/fish\r\nMAILTO=\"\"\r\n# raider-flow dev.rubyraider.flow.0123456789.other\n1 2 * * * echo % \\%\n5 5 * * * last"
149
+ runner.table = other
150
+
151
+ cron.install(item)
152
+
153
+ expect(runner.table).to eq(block(item) + other)
154
+ end
155
+
156
+ it 'replaces its own block and nothing else' do
157
+ item = schedule
158
+ daily = block(item)
159
+ runner.table = "1 * * * * a\n#{daily}2 * * * * b\n"
160
+
161
+ cron.install(schedule(every: 'hour', at: ':10'))
162
+
163
+ expect(runner.table).to eq("#{block(schedule(every: 'hour', at: ':10'))}1 * * * * a\n2 * * * * b\n")
164
+ expect(runner.table).not_to include(daily)
165
+ end
166
+
167
+ it 'keeps another flow block' do
168
+ other = schedule(name: 'nightly')
169
+ runner.table = block(other)
170
+
171
+ cron.install(schedule)
172
+
173
+ expect(runner.table).to eq(block(schedule) + block(other))
174
+ end
175
+
176
+ it 'refuses a line vixie cron would cut short and writes nothing' do
177
+ item = schedule(inputs: ["note=#{'x' * 900}"])
178
+
179
+ expect { cron.install(item) }.to raise_error(RubyRaider::Flows::Error, /crontab line is \d{4} bytes, over 990/)
180
+ expect(runner.calls).to be_empty
181
+ end
182
+
183
+ it 'raises with the scheduler error when crontab -l fails for another reason' do
184
+ runner.answers['-l'] = ["crontab: permission denied\e[2J", 1]
185
+
186
+ expect { cron.install(schedule) }.to raise_error(RubyRaider::Flows::Error, /cron could not read .*permission denied\?\[2J/)
187
+ expect(runner.calls).to eq([%w[crontab -l]])
188
+ end
189
+
190
+ it 'raises with the scheduler error when crontab - fails' do
191
+ runner.table = "1 * * * * a\n"
192
+ runner.answers['-'] = ['crontab: errors in crontab file', 1]
193
+
194
+ expect { cron.install(schedule) }.to raise_error(RubyRaider::Flows::Error, /cron could not save .*errors in crontab file/)
195
+ expect(runner.table).to eq("1 * * * * a\n")
196
+ end
197
+ end
198
+
199
+ describe '#remove' do
200
+ it 'removes its block and gives back the crontab byte for byte' do
201
+ other = "A=1\r\n1 * * * * a\n2 * * * * b"
202
+ runner.table = other
203
+ cron.install(schedule)
204
+
205
+ expect(cron.remove(schedule.os_id)).to be(true)
206
+ expect(runner.table).to eq(other)
207
+ end
208
+
209
+ it 'removes every copy of its block' do
210
+ item = schedule
211
+ runner.table = "#{block(item)}1 * * * * a\n#{block(item)}"
212
+
213
+ expect(cron.remove(item.os_id)).to be(true)
214
+ expect(runner.table).to eq("1 * * * * a\n")
215
+ end
216
+
217
+ it 'keeps an unrelated line that follows an orphaned tag' do
218
+ item = schedule
219
+ other = schedule(name: 'nightly')
220
+ runner.table = "# raider-flow #{item.os_id}\n#{cron.line(other)}\n1 * * * * a\n"
221
+
222
+ expect(cron.remove(item.os_id)).to be(true)
223
+ expect(runner.table).to eq("#{cron.line(other)}\n1 * * * * a\n")
224
+ end
225
+
226
+ it 'keeps a line under its tag that is not a schedule run' do
227
+ item = schedule
228
+ manual = cron.line(item).sub(' --trigger schedule', ' --trigger manual')
229
+ runner.table = "# raider-flow #{item.os_id}\n#{manual}\n"
230
+
231
+ expect(cron.remove(item.os_id)).to be(true)
232
+ expect(runner.table).to eq("#{manual}\n")
233
+ end
234
+
235
+ it 'removes a tag line left without its line' do
236
+ item = schedule
237
+ runner.table = "1 * * * * a\n# raider-flow #{item.os_id}"
238
+
239
+ expect(cron.remove(item.os_id)).to be(true)
240
+ expect(runner.table).to eq("1 * * * * a\n")
241
+ end
242
+
243
+ it 'is false and writes nothing when its block is not there' do
244
+ runner.table = "1 * * * * a\n# raider-flow #{schedule.os_id}x\n"
245
+
246
+ expect(cron.remove(schedule.os_id)).to be(false)
247
+ expect(runner.calls).to eq([%w[crontab -l]])
248
+ end
249
+
250
+ it 'is false when there is no crontab' do
251
+ expect(cron.remove(schedule.os_id)).to be(false)
252
+ end
253
+
254
+ it 'refuses an os_id that is not a flow schedule id' do
255
+ expect { cron.remove("x\n* * * * * evil") }.to raise_error(RubyRaider::Flows::Error, /Invalid os_id/)
256
+ expect(runner.calls).to be_empty
257
+ end
258
+ end
259
+
260
+ describe '#usable?' do
261
+ it 'is true when crontab -l answers, even with no crontab' do
262
+ expect(cron.usable?).to be(true)
263
+ end
264
+
265
+ it 'is false when crontab -l fails for another reason' do
266
+ runner.answers['-l'] = ['crontab: not found', 127]
267
+
268
+ expect(cron.usable?).to be(false)
269
+ end
270
+ end
271
+
272
+ describe '#present?' do
273
+ it 'is true when the tag line is there' do
274
+ item = schedule
275
+ runner.table = block(item)
276
+
277
+ expect(cron.present?(item.os_id)).to be(true)
278
+ end
279
+
280
+ it 'is false when there is no crontab or no tag line' do
281
+ expect(cron.present?(schedule.os_id)).to be(false)
282
+ runner.table = "1 * * * * a\n"
283
+ expect(cron.present?(schedule.os_id)).to be(false)
284
+ end
285
+ end
286
+ end
287
+ # rubocop:enable RSpec/MultipleMemoizedHelpers
@@ -0,0 +1,293 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'tmpdir'
4
+ require 'rexml/document'
5
+ require_relative '../../../../lib/utilities/flows/os/launchd'
6
+
7
+ # Flow specs live under spec/utilities/flows, next to the code in lib/utilities/flows
8
+ # The fake runner, temp home and project folders sit next to the adapter under test
9
+ # rubocop:disable RSpec/MultipleMemoizedHelpers
10
+ RSpec.describe RubyRaider::Flows::Os::Launchd do # rubocop:disable RSpec/FilePath
11
+ # Records every command and answers from a table (success unless told otherwise); never runs anything
12
+ let(:fake_runner_class) do
13
+ Class.new do
14
+ attr_reader :calls, :answers, :stdin
15
+
16
+ def initialize
17
+ @calls = []
18
+ @answers = {}
19
+ @stdin = []
20
+ end
21
+
22
+ def call(*argv, stdin_data: nil)
23
+ @calls << argv
24
+ @stdin << stdin_data
25
+ @seen&.call(argv)
26
+ stderr, status = answers.fetch(argv[1], ['', 0])
27
+ RubyRaider::Flows::Os::CommandRunner::Result.new(stdout: '', stderr: stderr, exitstatus: status)
28
+ end
29
+
30
+ def on_call(&block) = @seen = block
31
+ end
32
+ end
33
+
34
+ let(:hostile) { %q('; rm -rf ~; ' </string> %h $(id) " \\ &) }
35
+ let(:tmp) { File.realpath(Dir.mktmpdir('flows-launchd')) }
36
+ let(:home) { File.join(tmp, 'home') }
37
+ let(:project_dir) { File.join(tmp, 'plain') }
38
+ let(:runner) { fake_runner_class.new }
39
+ let(:launchd) { described_class.new(runner: runner, home: home, uid: 501) }
40
+ let(:now) { Time.utc(2026, 11, 9, 10, 0, 0) }
41
+ let(:agents) { File.join(home, 'Library', 'LaunchAgents') }
42
+
43
+ def schedule(dir: project_dir, **options)
44
+ FileUtils.mkdir_p(dir)
45
+ project = RubyRaider::Flows::Project.new(File.realpath(dir))
46
+ RubyRaider::Flows::Schedule.build(project: project, name: 'report', every: 'day', at: '08:00', now: now, **options)
47
+ end
48
+
49
+ # plist XML back to Ruby: dict -> Hash, array -> Array, string -> String, integer -> Integer
50
+ def plist_value(element)
51
+ case element.name
52
+ when 'dict'
53
+ element.elements.to_a.each_slice(2).to_h { |key, value| [key.text, plist_value(value)] }
54
+ when 'array' then element.elements.map { |child| plist_value(child) }
55
+ when 'integer' then Integer(element.text)
56
+ else element.text.to_s
57
+ end
58
+ end
59
+
60
+ def parsed(text)
61
+ document = REXML::Document.new(text)
62
+ expect(document.root.name).to eq('plist')
63
+ plist_value(document.root.elements.first)
64
+ end
65
+
66
+ # What a daily 08:00 schedule must produce, byte for byte
67
+ def daily_plist(item)
68
+ env = item.env.map { |key, value| "\t\t<key>#{key}</key>\n\t\t<string>#{value}</string>\n" }.join
69
+ args = item.argv.map { |arg| "\t\t<string>#{arg}</string>\n" }.join
70
+
71
+ <<~PLIST
72
+ <?xml version="1.0" encoding="UTF-8"?>
73
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
74
+ <plist version="1.0">
75
+ <dict>
76
+ \t<key>Label</key>
77
+ \t<string>#{item.os_id}</string>
78
+ \t<key>ProgramArguments</key>
79
+ \t<array>
80
+ #{args.chomp}
81
+ \t</array>
82
+ \t<key>WorkingDirectory</key>
83
+ \t<string>#{item.working_directory}</string>
84
+ \t<key>StandardOutPath</key>
85
+ \t<string>#{item.log_path}</string>
86
+ \t<key>StandardErrorPath</key>
87
+ \t<string>#{item.log_path}</string>
88
+ \t<key>EnvironmentVariables</key>
89
+ \t<dict>
90
+ #{env.chomp}
91
+ \t</dict>
92
+ \t<key>StartCalendarInterval</key>
93
+ \t<dict>
94
+ \t\t<key>Hour</key>
95
+ \t\t<integer>8</integer>
96
+ \t\t<key>Minute</key>
97
+ \t\t<integer>0</integer>
98
+ \t</dict>
99
+ </dict>
100
+ </plist>
101
+ PLIST
102
+ end
103
+
104
+ def plist_path(item) = File.join(agents, "#{item.os_id}.plist")
105
+
106
+ after { FileUtils.rm_rf(tmp) }
107
+
108
+ it 'is called launchd in messages' do
109
+ expect(launchd.label).to eq('launchd')
110
+ end
111
+
112
+ describe '#plist' do
113
+ it 'writes the exact plist for a daily schedule' do
114
+ item = schedule
115
+ expect(launchd.plist(item)).to eq(daily_plist(item))
116
+ end
117
+
118
+ it 'round-trips every field and never sets RunAtLoad' do
119
+ item = schedule(retries: 2, inputs: %w[env=staging])
120
+
121
+ expect(parsed(launchd.plist(item))).to eq(
122
+ 'Label' => item.os_id, 'ProgramArguments' => item.argv, 'WorkingDirectory' => item.working_directory,
123
+ 'StandardOutPath' => item.log_path, 'StandardErrorPath' => item.log_path, 'EnvironmentVariables' => item.env,
124
+ 'StartCalendarInterval' => { 'Hour' => 8, 'Minute' => 0 }
125
+ )
126
+ end
127
+
128
+ it 'keeps GEM_HOME and GEM_PATH in the environment when they were captured' do
129
+ allow(RubyRaider::Flows::Schedule).to receive(:captured_gem_env).and_return('GEM_HOME' => '/g/home', 'GEM_PATH' => '/g/path')
130
+
131
+ env = parsed(launchd.plist(schedule))['EnvironmentVariables']
132
+
133
+ expect(env.slice('LANG', 'GEM_HOME', 'GEM_PATH')).to eq('LANG' => 'en_US.UTF-8', 'GEM_HOME' => '/g/home', 'GEM_PATH' => '/g/path')
134
+ end
135
+
136
+ it 'runs every hour at its minute' do
137
+ expect(parsed(launchd.plist(schedule(every: 'hour', at: ':15')))['StartCalendarInterval']).to eq('Minute' => 15)
138
+ end
139
+
140
+ it 'runs every weekday as five dicts, Monday 1 to Friday 5' do
141
+ interval = parsed(launchd.plist(schedule(every: 'weekday', at: '18:05')))['StartCalendarInterval']
142
+
143
+ expect(interval).to eq((1..5).map { |day| { 'Weekday' => day, 'Hour' => 18, 'Minute' => 5 } })
144
+ end
145
+
146
+ it 'runs every week on its day, Sunday 0 and Saturday 6' do
147
+ days = %w[sun mon sat].map { |on| parsed(launchd.plist(schedule(every: 'week', on: on)))['StartCalendarInterval'] }
148
+
149
+ expect(days).to eq([0, 1, 6].map { |day| { 'Weekday' => day, 'Hour' => 8, 'Minute' => 0 } })
150
+ end
151
+
152
+ describe 'with hostile values in an input and in the project path' do
153
+ let(:item) { schedule(dir: File.join(tmp, "p#{hostile}<", "string> #{hostile}"), inputs: ["note=#{hostile}"]) }
154
+ let(:text) { launchd.plist(item) }
155
+
156
+ it 'round-trips them exactly' do
157
+ data = parsed(text)
158
+
159
+ expect([data['ProgramArguments'], data['WorkingDirectory'], data['StandardOutPath']]).to eq([item.argv, item.working_directory, item.log_path])
160
+ expect(data['ProgramArguments'].last).to eq("note=#{hostile}")
161
+ expect(item.working_directory).to include('</string>', "'; rm -rf ~; '", '$(id)', '%h', '"', '\\', '&')
162
+ end
163
+
164
+ it 'writes them only in escaped form' do
165
+ expect(text).not_to include('</string> ', "'; rm", '" \\')
166
+ expect(text.scan(/&(?!amp;|lt;|gt;|quot;|apos;)/)).to be_empty
167
+ expect(text).to include('&lt;/string&gt;', '&apos;; rm -rf ~; &apos;', '&quot; \\ &amp;', '$(id)', '%h')
168
+ end
169
+ end
170
+ end
171
+
172
+ describe '#install' do
173
+ it 'writes the plist 0644, lints it, then boots out and bootstraps it' do
174
+ item = schedule
175
+ path = plist_path(item)
176
+ linted = nil
177
+ runner.on_call { |argv| linted = File.read(argv[2]) if argv[1] == '-lint' }
178
+
179
+ launchd.install(item)
180
+
181
+ expect(runner.calls).to match([
182
+ ['/usr/bin/plutil', '-lint', %r{\A#{Regexp.escape(agents)}/\..+\.tmp\z}],
183
+ ['/bin/launchctl', 'bootout', "gui/501/#{item.os_id}"],
184
+ ['/bin/launchctl', 'bootstrap', 'gui/501', path]
185
+ ])
186
+ expect([File.read(path), linted, format('%o', File.stat(path).mode & 0o777)]).to eq([launchd.plist(item), launchd.plist(item), '644'])
187
+ expect(Dir.children(agents)).to eq([File.basename(path)])
188
+ end
189
+
190
+ it 'replaces an existing entry and ignores a bootout that finds nothing' do
191
+ item = schedule
192
+ FileUtils.mkdir_p(agents)
193
+ File.write(plist_path(item), 'old')
194
+ runner.answers['bootout'] = ['Boot-out failed: 3: No such process', 3]
195
+
196
+ launchd.install(item)
197
+
198
+ expect(runner.calls.map { |argv| argv[1] }).to eq(%w[-lint bootout bootstrap])
199
+ expect(File.read(plist_path(item))).to eq(launchd.plist(item))
200
+ end
201
+
202
+ it 'removes the plist and raises with the scheduler error when bootstrap fails' do
203
+ item = schedule
204
+ runner.answers['bootstrap'] = ["Bootstrap failed: 5: Input/output error\e[2J\n", 5]
205
+
206
+ expect { launchd.install(item) }.to raise_error(RubyRaider::Flows::Error, %r{launchd.*Bootstrap failed: 5: Input/output error\?\[2J})
207
+ expect(Dir.children(agents)).to be_empty
208
+ end
209
+
210
+ it 'says so when the plist a failed bootstrap left can not be deleted' do
211
+ item = schedule
212
+ runner.answers['bootstrap'] = ['Bootstrap failed: 5', 5]
213
+ allow(File).to receive(:unlink).and_call_original
214
+ allow(File).to receive(:unlink).with(plist_path(item)).and_raise(Errno::EACCES)
215
+
216
+ expect { launchd.install(item) }.to raise_error(RubyRaider::Flows::Error, /Bootstrap failed: 5; also: Could not remove .*Permission denied/)
217
+ end
218
+
219
+ it 'raises a flow error and runs nothing when it can not write the plist' do
220
+ FileUtils.mkdir_p(File.dirname(agents))
221
+ File.write(agents, 'not a folder')
222
+
223
+ expect { launchd.install(schedule) }.to raise_error(RubyRaider::Flows::Error, /Could not write .*LaunchAgents/)
224
+ expect([runner.calls, File.read(agents)]).to eq([[], 'not a folder'])
225
+ end
226
+
227
+ it 'leaves the entry alone and raises when the plist does not lint' do
228
+ item = schedule
229
+ FileUtils.mkdir_p(agents)
230
+ File.write(plist_path(item), 'old')
231
+ runner.answers['-lint'] = ['bad plist', 1]
232
+
233
+ expect { launchd.install(item) }.to raise_error(RubyRaider::Flows::Error, /bad plist/)
234
+ expect([runner.calls.map { |argv| argv[1] }, Dir.children(agents), File.read(plist_path(item))])
235
+ .to eq([['-lint'], [File.basename(plist_path(item))], 'old'])
236
+ end
237
+ end
238
+
239
+ describe '#remove' do
240
+ it 'boots out and deletes the plist' do
241
+ item = schedule
242
+ FileUtils.mkdir_p(agents)
243
+ File.write(plist_path(item), 'x')
244
+
245
+ expect(launchd.remove(item.os_id)).to be(true)
246
+ expect([runner.calls, File.exist?(plist_path(item))]).to eq([[['/bin/launchctl', 'bootout', "gui/501/#{item.os_id}"]], false])
247
+ end
248
+
249
+ it 'is true when only the service was loaded' do
250
+ expect(launchd.remove(schedule.os_id)).to be(true)
251
+ end
252
+
253
+ it 'is false when it was already gone' do
254
+ runner.answers['bootout'] = ['Boot-out failed: 3: No such process', 3]
255
+
256
+ expect(launchd.remove(schedule.os_id)).to be(false)
257
+ end
258
+
259
+ it 'raises when the plist can not be deleted' do
260
+ item = schedule
261
+ allow(File).to receive(:unlink).and_raise(Errno::EACCES)
262
+
263
+ expect { launchd.remove(item.os_id) }.to raise_error(RubyRaider::Flows::Error, /Could not remove .*Permission denied/)
264
+ expect(runner.calls).to eq([['/bin/launchctl', 'bootout', "gui/501/#{item.os_id}"]])
265
+ end
266
+
267
+ it 'refuses an os_id that is not a flow schedule id' do
268
+ expect { launchd.remove('../../evil') }.to raise_error(RubyRaider::Flows::Error, /Invalid os_id/)
269
+ expect(runner.calls).to be_empty
270
+ end
271
+ end
272
+
273
+ describe '#present?' do
274
+ it 'asks launchctl print' do
275
+ item = schedule
276
+
277
+ expect(launchd.present?(item.os_id)).to be(true)
278
+ expect(runner.calls).to eq([['/bin/launchctl', 'print', "gui/501/#{item.os_id}"]])
279
+ end
280
+
281
+ it 'refuses an os_id that is not a flow schedule id and runs nothing' do
282
+ expect { launchd.present?('../../evil') }.to raise_error(RubyRaider::Flows::Error, /Invalid os_id/)
283
+ expect(runner.calls).to be_empty
284
+ end
285
+
286
+ it 'is false when launchctl print fails' do
287
+ runner.answers['print'] = ['Could not find service', 113]
288
+
289
+ expect(launchd.present?(schedule.os_id)).to be(false)
290
+ end
291
+ end
292
+ end
293
+ # rubocop:enable RSpec/MultipleMemoizedHelpers