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.
- checksums.yaml +4 -4
- data/README.md +59 -1
- data/RELEASE.md +12 -0
- data/lib/commands/flow_commands.rb +104 -0
- data/lib/generators/templates/flows/flow_helper.tt +42 -0
- data/lib/generators/templates/flows/run.tt +332 -0
- data/lib/ruby_raider.rb +16 -0
- data/lib/utilities/flows/next_run.rb +61 -0
- data/lib/utilities/flows/notifier.rb +76 -0
- data/lib/utilities/flows/os/command_runner.rb +73 -0
- data/lib/utilities/flows/os/cron.rb +137 -0
- data/lib/utilities/flows/os/launchd.rb +164 -0
- data/lib/utilities/flows/os/linux.rb +63 -0
- data/lib/utilities/flows/os/systemd.rb +178 -0
- data/lib/utilities/flows/os/task_scheduler.rb +179 -0
- data/lib/utilities/flows/project.rb +170 -0
- data/lib/utilities/flows/run_log.rb +61 -0
- data/lib/utilities/flows/runner.rb +206 -0
- data/lib/utilities/flows/schedule.rb +221 -0
- data/lib/utilities/flows/schedule_store.rb +135 -0
- data/lib/utilities/flows/scheduler.rb +187 -0
- data/lib/version +1 -1
- data/sig/commands/flow_commands.rbs +13 -0
- data/sig/ruby_raider.rbs +2 -0
- data/sig/utilities/flows/next_run.rbs +15 -0
- data/sig/utilities/flows/notifier.rbs +29 -0
- data/sig/utilities/flows/os/command_runner.rbs +27 -0
- data/sig/utilities/flows/os/cron.rbs +34 -0
- data/sig/utilities/flows/os/launchd.rbs +45 -0
- data/sig/utilities/flows/os/linux.rbs +27 -0
- data/sig/utilities/flows/os/systemd.rbs +43 -0
- data/sig/utilities/flows/os/task_scheduler.rbs +53 -0
- data/sig/utilities/flows/project.rbs +46 -0
- data/sig/utilities/flows/run_log.rbs +22 -0
- data/sig/utilities/flows/runner.rbs +35 -0
- data/sig/utilities/flows/schedule.rbs +61 -0
- data/sig/utilities/flows/schedule_store.rbs +34 -0
- data/sig/utilities/flows/scheduler.rbs +42 -0
- data/spec/commands/flow_commands_spec.rb +128 -0
- data/spec/generators/flow_helper_spec.rb +40 -0
- data/spec/integration/content/gemfile_content_spec.rb +2 -2
- data/spec/integration/end_to_end_spec.rb +63 -0
- data/spec/integration/flows/flow_project.rb +62 -0
- data/spec/integration/flows/run_command_spec.rb +91 -0
- data/spec/integration/flows/run_lifecycle_spec.rb +383 -0
- data/spec/utilities/flows/next_run_spec.rb +119 -0
- data/spec/utilities/flows/notifier_spec.rb +121 -0
- data/spec/utilities/flows/os/command_runner_spec.rb +56 -0
- data/spec/utilities/flows/os/cron_spec.rb +287 -0
- data/spec/utilities/flows/os/launchd_spec.rb +293 -0
- data/spec/utilities/flows/os/linux_spec.rb +149 -0
- data/spec/utilities/flows/os/systemd_spec.rb +329 -0
- data/spec/utilities/flows/os/task_scheduler_spec.rb +505 -0
- data/spec/utilities/flows/project_spec.rb +178 -0
- data/spec/utilities/flows/run_log_spec.rb +47 -0
- data/spec/utilities/flows/runner_spec.rb +313 -0
- data/spec/utilities/flows/schedule_spec.rb +233 -0
- data/spec/utilities/flows/schedule_store_spec.rb +137 -0
- data/spec/utilities/flows/scheduler_spec.rb +329 -0
- metadata +53 -2
|
@@ -0,0 +1,505 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'tmpdir'
|
|
4
|
+
require 'rexml/document'
|
|
5
|
+
require_relative '../../../../lib/utilities/flows/os/task_scheduler'
|
|
6
|
+
|
|
7
|
+
# Flow specs live under spec/utilities/flows, next to the code in lib/utilities/flows
|
|
8
|
+
# The fake runner, fixed clock and project folders sit next to the adapter under test
|
|
9
|
+
# rubocop:disable RSpec/MultipleMemoizedHelpers
|
|
10
|
+
RSpec.describe RubyRaider::Flows::Os::TaskScheduler do # rubocop:disable RSpec/FilePath
|
|
11
|
+
# Records every command and answers from a table keyed by the schtasks verb; never runs anything
|
|
12
|
+
let(:fake_runner_class) do
|
|
13
|
+
Class.new do
|
|
14
|
+
attr_reader :calls, :answers
|
|
15
|
+
|
|
16
|
+
def initialize
|
|
17
|
+
@calls = []
|
|
18
|
+
@answers = {}
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def call(*argv, stdin_data: nil)
|
|
22
|
+
@calls << argv
|
|
23
|
+
@seen&.call(argv, stdin_data)
|
|
24
|
+
stderr, status, stdout = answers.fetch(argv[1], ['', 0])
|
|
25
|
+
RubyRaider::Flows::Os::CommandRunner::Result.new(stdout: stdout.to_s, stderr: stderr, exitstatus: status)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def on_call(&block) = @seen = block
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
let(:hostile) { %q('; rm -rf ~; ' </string> %h $(id) " \\ &) }
|
|
33
|
+
let(:tmp) { File.realpath(Dir.mktmpdir('flows-schtasks')) }
|
|
34
|
+
let(:project_dir) { File.join(tmp, 'plain') }
|
|
35
|
+
let(:runner) { fake_runner_class.new }
|
|
36
|
+
let(:clock) { Struct.new(:now).new(Time.new(2026, 11, 9, 10, 30, 0)) }
|
|
37
|
+
let(:schtasks) { 'C:\\Windows\\System32\\schtasks.exe' }
|
|
38
|
+
let(:scheduler) { described_class.new(runner: runner, clock: clock, schtasks: schtasks) }
|
|
39
|
+
let(:now) { Time.utc(2026, 11, 9, 10, 0, 0) }
|
|
40
|
+
|
|
41
|
+
def schedule(dir: project_dir, **options)
|
|
42
|
+
FileUtils.mkdir_p(dir)
|
|
43
|
+
project = RubyRaider::Flows::Project.new(File.realpath(dir))
|
|
44
|
+
RubyRaider::Flows::Schedule.build(project: project, name: 'report', every: 'day', at: '08:00', now: now, **options)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def task_name(item) = "\\RubyRaider\\#{item.os_id}"
|
|
48
|
+
|
|
49
|
+
# schtasks /Query /FO CSV /NH: one row per task, full name first
|
|
50
|
+
def csv_rows(*names) = names.map { |name| %("#{name}","11/10/2026 8:00:00 AM","Ready"\r\n) }.join
|
|
51
|
+
|
|
52
|
+
def list_call = [schtasks, '/Query', '/FO', 'CSV', '/NH']
|
|
53
|
+
|
|
54
|
+
# CommandLineToArgvW / MSVCRT rules for the arguments after the program name: 2n backslashes and a
|
|
55
|
+
# quote give n backslashes and toggle quoting, 2n+1 give n and a literal quote, other backslashes are
|
|
56
|
+
# literal, unquoted space or tab splits. The quoter never writes "" inside quotes, so the post-2008
|
|
57
|
+
# MSVCRT "" rule never applies.
|
|
58
|
+
def split_arguments(line)
|
|
59
|
+
args = []
|
|
60
|
+
current = nil
|
|
61
|
+
quoted = false
|
|
62
|
+
index = 0
|
|
63
|
+
while index < line.length
|
|
64
|
+
char = line[index]
|
|
65
|
+
if char == '\\'
|
|
66
|
+
run = line[index..][/\A\\+/].length
|
|
67
|
+
index += run
|
|
68
|
+
if line[index] == '"'
|
|
69
|
+
current = "#{current}#{'\\' * (run / 2)}"
|
|
70
|
+
if run.odd?
|
|
71
|
+
current += '"'
|
|
72
|
+
index += 1
|
|
73
|
+
end
|
|
74
|
+
else
|
|
75
|
+
current = "#{current}#{'\\' * run}"
|
|
76
|
+
end
|
|
77
|
+
next
|
|
78
|
+
elsif char == '"'
|
|
79
|
+
quoted = !quoted
|
|
80
|
+
current ||= +''
|
|
81
|
+
elsif [' ', "\t"].include?(char) && !quoted
|
|
82
|
+
args << current if current
|
|
83
|
+
current = nil
|
|
84
|
+
else
|
|
85
|
+
current = "#{current}#{char}"
|
|
86
|
+
end
|
|
87
|
+
index += 1
|
|
88
|
+
end
|
|
89
|
+
args << current if current
|
|
90
|
+
args
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# ruby.exe's own reading: a port of w32_cmdvector in ruby/win32/win32.c. Pass 1 splits on unquoted
|
|
94
|
+
# space, tab or newline (' and " both quote; a backslash counts toward escaping a quote except inside
|
|
95
|
+
# '...'), and marks a * ? [ { outside '...' (even inside "...") for globbing. Pass 2 strips the quotes:
|
|
96
|
+
# an even run of backslashes before a quote of the other kind is left as it is, every other quote eats
|
|
97
|
+
# (run + 1) / 2 backslashes. A globbed element is refused here: the quoter must never produce one.
|
|
98
|
+
def ruby_split_arguments(line)
|
|
99
|
+
elements = []
|
|
100
|
+
index = 0
|
|
101
|
+
loop do
|
|
102
|
+
index += 1 while line[index]&.match?(/[ \t\n\v\f\r]/)
|
|
103
|
+
break if index >= line.length
|
|
104
|
+
|
|
105
|
+
element, index, glob = ruby_element(line, index)
|
|
106
|
+
raise "ruby.exe would glob #{element.inspect} in #{line.inspect}" if glob
|
|
107
|
+
|
|
108
|
+
elements << element
|
|
109
|
+
end
|
|
110
|
+
elements
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def ruby_element(line, index)
|
|
114
|
+
base = index
|
|
115
|
+
quote = nil
|
|
116
|
+
slashes = 0
|
|
117
|
+
glob = false
|
|
118
|
+
done = false
|
|
119
|
+
while !done && index < line.length
|
|
120
|
+
char = line[index]
|
|
121
|
+
case char
|
|
122
|
+
when '\\' then slashes += 1 unless quote == "'"
|
|
123
|
+
when ' ', "\t", "\n"
|
|
124
|
+
done = true unless quote
|
|
125
|
+
slashes = 0
|
|
126
|
+
when '*', '?', '[', '{'
|
|
127
|
+
glob = true unless quote == "'"
|
|
128
|
+
slashes = 0
|
|
129
|
+
when "'", '"'
|
|
130
|
+
if slashes.even?
|
|
131
|
+
if quote.nil? then quote = char
|
|
132
|
+
elsif quote == char
|
|
133
|
+
index += 1 if char == '"' && line[index + 1] == '"'
|
|
134
|
+
quote = nil
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
slashes = 0
|
|
138
|
+
else slashes = 0
|
|
139
|
+
end
|
|
140
|
+
index += 1
|
|
141
|
+
end
|
|
142
|
+
length = index - base - (done ? 1 : 0)
|
|
143
|
+
[ruby_unquote(line[base, length]), index, glob]
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def ruby_unquote(text)
|
|
147
|
+
chars = text.chars
|
|
148
|
+
at = 0
|
|
149
|
+
slashes = 0
|
|
150
|
+
quote = nil
|
|
151
|
+
while at < chars.length
|
|
152
|
+
char = chars[at]
|
|
153
|
+
if char == '\\'
|
|
154
|
+
at += 1
|
|
155
|
+
slashes += 1 unless quote == "'"
|
|
156
|
+
next
|
|
157
|
+
end
|
|
158
|
+
unless ["'", '"'].include?(char)
|
|
159
|
+
at += 1
|
|
160
|
+
slashes = 0
|
|
161
|
+
next
|
|
162
|
+
end
|
|
163
|
+
if slashes.even? && quote && quote != char
|
|
164
|
+
at += 1
|
|
165
|
+
slashes = 0
|
|
166
|
+
next
|
|
167
|
+
end
|
|
168
|
+
eaten = (slashes + 1) / 2
|
|
169
|
+
chars.slice!(at - eaten, eaten + (slashes.even? ? 1 : 0))
|
|
170
|
+
at -= eaten
|
|
171
|
+
if slashes.odd?
|
|
172
|
+
at += 1
|
|
173
|
+
elsif quote
|
|
174
|
+
at += 1 if quote == '"' && chars[at] == '"'
|
|
175
|
+
quote = nil
|
|
176
|
+
else
|
|
177
|
+
quote = char
|
|
178
|
+
end
|
|
179
|
+
slashes = 0
|
|
180
|
+
end
|
|
181
|
+
chars.join
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def parsed(text) = REXML::Document.new(text)
|
|
185
|
+
|
|
186
|
+
def field(text, path) = parsed(text).elements[path]&.text.to_s
|
|
187
|
+
|
|
188
|
+
# What a daily 08:00 schedule must produce, byte for byte
|
|
189
|
+
def daily_xml(item)
|
|
190
|
+
args = described_class.join(item.argv.drop(1))
|
|
191
|
+
|
|
192
|
+
<<~XML
|
|
193
|
+
<?xml version="1.0" encoding="UTF-16"?>
|
|
194
|
+
<Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
|
|
195
|
+
<RegistrationInfo>
|
|
196
|
+
<Description>Ruby Raider flow report (every day at 08:00)</Description>
|
|
197
|
+
<URI>\\RubyRaider\\#{item.os_id}</URI>
|
|
198
|
+
</RegistrationInfo>
|
|
199
|
+
<Triggers>
|
|
200
|
+
<CalendarTrigger>
|
|
201
|
+
<StartBoundary>2026-11-10T08:00:00</StartBoundary>
|
|
202
|
+
<Enabled>true</Enabled>
|
|
203
|
+
<ScheduleByDay>
|
|
204
|
+
<DaysInterval>1</DaysInterval>
|
|
205
|
+
</ScheduleByDay>
|
|
206
|
+
</CalendarTrigger>
|
|
207
|
+
</Triggers>
|
|
208
|
+
<Principals>
|
|
209
|
+
<Principal id="Author">
|
|
210
|
+
<LogonType>InteractiveToken</LogonType>
|
|
211
|
+
<RunLevel>LeastPrivilege</RunLevel>
|
|
212
|
+
</Principal>
|
|
213
|
+
</Principals>
|
|
214
|
+
<Settings>
|
|
215
|
+
<MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
|
|
216
|
+
<DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
|
|
217
|
+
<StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
|
|
218
|
+
<StartWhenAvailable>true</StartWhenAvailable>
|
|
219
|
+
<Enabled>true</Enabled>
|
|
220
|
+
</Settings>
|
|
221
|
+
<Actions Context="Author">
|
|
222
|
+
<Exec>
|
|
223
|
+
<Command>#{item.ruby}</Command>
|
|
224
|
+
<Arguments>#{args}</Arguments>
|
|
225
|
+
<WorkingDirectory>#{item.working_directory}</WorkingDirectory>
|
|
226
|
+
</Exec>
|
|
227
|
+
</Actions>
|
|
228
|
+
</Task>
|
|
229
|
+
XML
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
after { FileUtils.rm_rf(tmp) }
|
|
233
|
+
|
|
234
|
+
it 'is called Task Scheduler in messages' do
|
|
235
|
+
expect(scheduler.label).to eq('Task Scheduler')
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
it 'names the task under the RubyRaider folder' do
|
|
239
|
+
item = schedule
|
|
240
|
+
expect(scheduler.task_name(item.os_id)).to eq(task_name(item))
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
describe '.quote' do
|
|
244
|
+
{
|
|
245
|
+
'plain' => 'plain',
|
|
246
|
+
'' => '""',
|
|
247
|
+
'with space' => '"with space"',
|
|
248
|
+
"tab\there" => "\"tab\there\"",
|
|
249
|
+
'C:\\path with space\\' => '"C:\\path with space\\\\"',
|
|
250
|
+
'C:\\no\\space\\' => 'C:\\no\\space\\',
|
|
251
|
+
'a\\"b' => '"a\\\\\\"b"',
|
|
252
|
+
'say "hi"' => '"say \\"hi\\""',
|
|
253
|
+
'a\\\\b c' => '"a\\\\b c"',
|
|
254
|
+
'%h&$(id)' => '%h&$(id)',
|
|
255
|
+
"O'Brien" => %("O'Brien"),
|
|
256
|
+
'a*b' => "'a*b'",
|
|
257
|
+
'p[1]' => "'p[1]'",
|
|
258
|
+
'q?' => "'q?'",
|
|
259
|
+
'{x}' => "'{x}'",
|
|
260
|
+
'x{1,2}' => "'x{1,2}'",
|
|
261
|
+
'C:\\a b\\*.rb' => "'C:\\a b\\*.rb'",
|
|
262
|
+
'a*"b' => %('a*"b'),
|
|
263
|
+
"a\\'b c" => %("a\\\\\\'b c"),
|
|
264
|
+
"a\\\\'b c" => %("a\\\\'b c")
|
|
265
|
+
}.each do |arg, quoted|
|
|
266
|
+
it "quotes #{arg.inspect} as #{quoted.inspect}" do
|
|
267
|
+
expect(described_class.quote(arg)).to eq(quoted)
|
|
268
|
+
end
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
# ruby.exe is the real reader; CommandLineToArgvW agrees except on '...' and on backslashes before '
|
|
272
|
+
it 'gives back every argument when the joined line is read by ruby.exe, and by CommandLineToArgvW where they agree' do
|
|
273
|
+
plain = ['', 'C:\\path with space\\', 'a\\"b', '\\\\', '"', 'x\\', "'; rm -rf ~; '", '</string>', '%h', '$(id)', '\\', '&', hostile,
|
|
274
|
+
"C:\\Users\\O'Brien\\proj", "a=x'", "b=y'", "it's \\\"x\\\" \\"]
|
|
275
|
+
ruby_only = ['p[1]', 'a*b', 'q?', '{x}', 'x{1,2}', 'C:\\a b\\*.rb', 'a*"b\\', "a\\'b c", "a\\\\'b c", "a\\\\\\'b c", "C:\\x\\'q' y"]
|
|
276
|
+
|
|
277
|
+
expect(ruby_split_arguments(described_class.join(plain + ruby_only))).to eq(plain + ruby_only)
|
|
278
|
+
expect(split_arguments(described_class.join(plain))).to eq(plain)
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
it "refuses an argument with both ' and a glob character" do
|
|
282
|
+
expect { described_class.quote("a'*") }.to raise_error(RubyRaider::Flows::Error, /can not pass "a'\*" to ruby\.exe/)
|
|
283
|
+
end
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
describe '#task_xml' do
|
|
287
|
+
it 'writes the exact task for a daily schedule' do
|
|
288
|
+
item = schedule
|
|
289
|
+
expect(scheduler.task_xml(item)).to eq(daily_xml(item))
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
it 'carries no environment and no log file' do
|
|
293
|
+
text = scheduler.task_xml(schedule)
|
|
294
|
+
|
|
295
|
+
expect(text).not_to include('scheduler.log', 'Environment', 'GEM_HOME', 'cmd', 'powershell')
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
it 'runs ruby with the bundle path and the flow run arguments' do
|
|
299
|
+
item = schedule(retries: 2, inputs: %w[env=staging])
|
|
300
|
+
text = scheduler.task_xml(item)
|
|
301
|
+
|
|
302
|
+
expect(field(text, 'Task/Actions/Exec/Command')).to eq(item.ruby)
|
|
303
|
+
expect(split_arguments(field(text, 'Task/Actions/Exec/Arguments'))).to eq(item.argv.drop(1))
|
|
304
|
+
expect(field(text, 'Task/Actions/Exec/WorkingDirectory')).to eq(item.working_directory)
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
it 'starts at the next run: an 11:00 rule at 10:30 starts today at 11:00' do
|
|
308
|
+
trigger = parsed(scheduler.task_xml(schedule(every: 'day', at: '11:00'))).elements['Task/Triggers/CalendarTrigger']
|
|
309
|
+
|
|
310
|
+
expect(trigger.elements['StartBoundary'].text).to eq('2026-11-09T11:00:00')
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
it 'runs every hour from its next run, repeating hourly for a day' do
|
|
314
|
+
trigger = parsed(scheduler.task_xml(schedule(every: 'hour', at: ':15'))).elements['Task/Triggers/CalendarTrigger']
|
|
315
|
+
|
|
316
|
+
expect(trigger.elements.map(&:name)).to eq(%w[Repetition StartBoundary Enabled ScheduleByDay])
|
|
317
|
+
expect(%w[Repetition/Interval Repetition/Duration StartBoundary ScheduleByDay/DaysInterval].map { |path| trigger.elements[path].text })
|
|
318
|
+
.to eq(%w[PT1H P1D 2026-11-09T11:15:00 1])
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
it 'runs every weekday Monday to Friday, weekly' do
|
|
322
|
+
trigger = parsed(scheduler.task_xml(schedule(every: 'weekday', at: '18:05'))).elements['Task/Triggers/CalendarTrigger']
|
|
323
|
+
|
|
324
|
+
expect(trigger.elements['StartBoundary'].text).to eq('2026-11-09T18:05:00')
|
|
325
|
+
expect(trigger.elements['ScheduleByWeek'].elements.map(&:name)).to eq(%w[DaysOfWeek WeeksInterval])
|
|
326
|
+
expect(trigger.elements['ScheduleByWeek/DaysOfWeek'].elements.map(&:name)).to eq(%w[Monday Tuesday Wednesday Thursday Friday])
|
|
327
|
+
expect(trigger.elements['ScheduleByWeek/WeeksInterval'].text).to eq('1')
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
it 'runs every week on its one day' do
|
|
331
|
+
days = %w[mon sat sun].map do |on|
|
|
332
|
+
trigger = parsed(scheduler.task_xml(schedule(every: 'week', on: on))).elements['Task/Triggers/CalendarTrigger']
|
|
333
|
+
[trigger.elements['ScheduleByWeek/DaysOfWeek'].elements.map(&:name), trigger.elements['StartBoundary'].text]
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
expect(days).to eq([[%w[Monday], '2026-11-16T08:00:00'], [%w[Saturday], '2026-11-14T08:00:00'], [%w[Sunday], '2026-11-15T08:00:00']])
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
# One at a time: a lone % in both would read as %...% across the joined Arguments and be refused
|
|
340
|
+
{
|
|
341
|
+
'an input value' => ->(spec) { spec.schedule(inputs: ["note=#{spec.hostile}"]) },
|
|
342
|
+
'the project path' => ->(spec) { spec.schedule(dir: File.join(spec.tmp, 'p<', "string> #{spec.hostile}")) }
|
|
343
|
+
}.each do |where, build|
|
|
344
|
+
describe "with hostile values in #{where}" do
|
|
345
|
+
let(:item) { build.call(self) }
|
|
346
|
+
let(:text) { scheduler.task_xml(item) }
|
|
347
|
+
|
|
348
|
+
it 'round-trips them through CRT quoting and XML escaping' do
|
|
349
|
+
line = field(text, 'Task/Actions/Exec/Arguments')
|
|
350
|
+
|
|
351
|
+
expect([split_arguments(line), ruby_split_arguments(line)]).to eq([item.argv.drop(1), item.argv.drop(1)])
|
|
352
|
+
expect(field(text, 'Task/Actions/Exec/WorkingDirectory')).to eq(item.working_directory)
|
|
353
|
+
expect(item.argv.join).to include('</string>', "'; rm -rf ~; '", '$(id)', '%h', '"', '\\', '&')
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
it 'writes them only in escaped form' do
|
|
357
|
+
expect(text).not_to include('</string>', "'; rm", '" \\')
|
|
358
|
+
expect(text.scan(/&(?!amp;|lt;|gt;|quot;|apos;)/)).to be_empty
|
|
359
|
+
expect(text).to include('</string>', ''; rm -rf ~; '', '\\" \\ &', '$(id)', '%h')
|
|
360
|
+
end
|
|
361
|
+
end
|
|
362
|
+
end
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
describe 'with quotes and glob characters but no spaces' do
|
|
366
|
+
let(:item) { schedule(dir: File.join(tmp, 'Users', "O'Brien", 'proj'), inputs: ["a=x'", "b=y'", 'c=p[1]', 'd=a*b']) }
|
|
367
|
+
|
|
368
|
+
it "quotes them so ruby.exe reads the exact argv back and globs nothing ('...' around a glob)" do
|
|
369
|
+
line = field(scheduler.task_xml(item), 'Task/Actions/Exec/Arguments')
|
|
370
|
+
|
|
371
|
+
expect(ruby_split_arguments(line)).to eq(item.argv.drop(1))
|
|
372
|
+
expect(line).to include(%("#{item.project_root}"), %("a=x'"), %("b=y'"), "'c=p[1]'", "'d=a*b'")
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
it "refuses an input with both ' and a glob character before any schtasks call" do
|
|
376
|
+
expect { scheduler.install(schedule(inputs: ["e=a'*"])) }.to raise_error(RubyRaider::Flows::Error, /ruby\.exe/)
|
|
377
|
+
expect(runner.calls).to be_empty
|
|
378
|
+
end
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
describe 'with %NAME% that Task Scheduler would expand' do
|
|
382
|
+
it 'refuses it in an input value before any schtasks call' do
|
|
383
|
+
item = schedule(inputs: ['note=%PATH%'])
|
|
384
|
+
|
|
385
|
+
expect { scheduler.install(item) }.to raise_error(RubyRaider::Flows::Error, /Task Scheduler would expand %PATH% as an environment variable/)
|
|
386
|
+
expect(runner.calls).to be_empty
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
it 'refuses it in the project path before any schtasks call' do
|
|
390
|
+
item = schedule(dir: File.join(tmp, '%PATH%', 'proj'))
|
|
391
|
+
|
|
392
|
+
expect { scheduler.install(item) }.to raise_error(RubyRaider::Flows::Error, /would expand %PATH%/)
|
|
393
|
+
expect { scheduler.task_xml(item) }.to raise_error(RubyRaider::Flows::Error, /would expand %PATH%/)
|
|
394
|
+
expect(runner.calls).to be_empty
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
it 'refuses a %...% formed across two arguments in the joined Arguments' do
|
|
398
|
+
expect { scheduler.install(schedule(inputs: ['a=%x', 'b=y%'])) }.to raise_error(RubyRaider::Flows::Error, /would expand %x b=y%/)
|
|
399
|
+
expect(runner.calls).to be_empty
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
it 'keeps a lone %' do
|
|
403
|
+
item = schedule(inputs: ['note=100% sure'])
|
|
404
|
+
|
|
405
|
+
expect(split_arguments(field(scheduler.task_xml(item), 'Task/Actions/Exec/Arguments')).last).to eq('note=100% sure')
|
|
406
|
+
end
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
describe '#install' do
|
|
410
|
+
it 'writes the task as UTF-16LE with a BOM, creates it with schtasks and deletes the file' do
|
|
411
|
+
item = schedule
|
|
412
|
+
written = nil
|
|
413
|
+
path = nil
|
|
414
|
+
runner.on_call { |argv, _| written = File.binread(path = argv[5]) if argv[1] == '/Create' }
|
|
415
|
+
|
|
416
|
+
expect(scheduler.install(item)).to be(true)
|
|
417
|
+
expect(runner.calls).to eq([[schtasks, '/Create', '/TN', task_name(item), '/XML', path, '/F']])
|
|
418
|
+
expect(written.byteslice(0, 2).bytes).to eq([0xFF, 0xFE])
|
|
419
|
+
expect(written.byteslice(2..).force_encoding(Encoding::UTF_16LE).encode(Encoding::UTF_8)).to eq(scheduler.task_xml(item))
|
|
420
|
+
expect(File.exist?(path)).to be(false)
|
|
421
|
+
end
|
|
422
|
+
|
|
423
|
+
it 'raises with the scheduler error and still deletes the file when schtasks fails' do
|
|
424
|
+
item = schedule
|
|
425
|
+
path = nil
|
|
426
|
+
runner.on_call { |argv, _| path = argv[5] }
|
|
427
|
+
runner.answers['/Create'] = ["ERROR: Access is denied.\e[2J\n", 1]
|
|
428
|
+
|
|
429
|
+
expect { scheduler.install(item) }.to raise_error(RubyRaider::Flows::Error, /Task Scheduler.*#{Regexp.escape(item.os_id)}.*Access is denied\.\?\[2J/)
|
|
430
|
+
expect(File.exist?(path)).to be(false)
|
|
431
|
+
end
|
|
432
|
+
end
|
|
433
|
+
|
|
434
|
+
describe '#remove' do
|
|
435
|
+
it 'deletes the task' do
|
|
436
|
+
item = schedule
|
|
437
|
+
|
|
438
|
+
expect(scheduler.remove(item.os_id)).to be(true)
|
|
439
|
+
expect(runner.calls).to eq([[schtasks, '/Delete', '/TN', task_name(item), '/F']])
|
|
440
|
+
end
|
|
441
|
+
|
|
442
|
+
it 'is false when the delete fails and the task list does not show it' do
|
|
443
|
+
item = schedule
|
|
444
|
+
runner.answers['/Delete'] = ['ERROR: The system cannot find the file specified.', 1]
|
|
445
|
+
runner.answers['/Query'] = ['', 0, csv_rows('\\Other', "#{task_name(item)}_old")]
|
|
446
|
+
|
|
447
|
+
expect(scheduler.remove(item.os_id)).to be(false)
|
|
448
|
+
expect(runner.calls).to eq([[schtasks, '/Delete', '/TN', task_name(item), '/F'], list_call])
|
|
449
|
+
end
|
|
450
|
+
|
|
451
|
+
it 'raises with the delete error when the task list still shows it' do
|
|
452
|
+
item = schedule
|
|
453
|
+
runner.answers['/Delete'] = ["ERROR: Access\e is denied.\n", 1]
|
|
454
|
+
runner.answers['/Query'] = ['', 0, csv_rows('\\Other', task_name(item))]
|
|
455
|
+
|
|
456
|
+
expect { scheduler.remove(item.os_id) }.to raise_error(RubyRaider::Flows::Error, /Task Scheduler could not delete .*Access\? is denied\.\z/)
|
|
457
|
+
end
|
|
458
|
+
|
|
459
|
+
it 'raises with the delete error when the task list can not be read' do
|
|
460
|
+
runner.answers['/Delete'] = ['ERROR: Access is denied.', 1]
|
|
461
|
+
runner.answers['/Query'] = ['ERROR: list failed', 1]
|
|
462
|
+
|
|
463
|
+
expect { scheduler.remove(schedule.os_id) }.to raise_error(RubyRaider::Flows::Error, /could not delete .*Access is denied/)
|
|
464
|
+
end
|
|
465
|
+
|
|
466
|
+
it 'refuses an os_id that is not a flow schedule id' do
|
|
467
|
+
expect { scheduler.remove('..\\..\\evil') }.to raise_error(RubyRaider::Flows::Error, /Invalid os_id/)
|
|
468
|
+
expect(runner.calls).to be_empty
|
|
469
|
+
end
|
|
470
|
+
end
|
|
471
|
+
|
|
472
|
+
describe '#present?' do
|
|
473
|
+
it 'is true when the task list shows the exact task name' do
|
|
474
|
+
item = schedule
|
|
475
|
+
runner.answers['/Query'] = ['', 0, csv_rows('\\Other', task_name(item))]
|
|
476
|
+
|
|
477
|
+
expect(scheduler.present?(item.os_id)).to be(true)
|
|
478
|
+
expect(runner.calls).to eq([list_call])
|
|
479
|
+
end
|
|
480
|
+
|
|
481
|
+
it 'is false when it shows only other tasks' do
|
|
482
|
+
item = schedule
|
|
483
|
+
runner.answers['/Query'] = ['', 0, csv_rows("#{task_name(item)}_old", '\\Other')]
|
|
484
|
+
|
|
485
|
+
expect(scheduler.present?(item.os_id)).to be(false)
|
|
486
|
+
end
|
|
487
|
+
|
|
488
|
+
it 'raises when the task list can not be read' do
|
|
489
|
+
runner.answers['/Query'] = ["ERROR: no\e list", 1]
|
|
490
|
+
|
|
491
|
+
expect { scheduler.present?(schedule.os_id) }.to raise_error(RubyRaider::Flows::Error, /could not list tasks: ERROR: no\? list/)
|
|
492
|
+
end
|
|
493
|
+
end
|
|
494
|
+
|
|
495
|
+
it 'runs schtasks.exe from SystemRoot by default' do
|
|
496
|
+
allow(ENV).to receive(:fetch).and_call_original
|
|
497
|
+
allow(ENV).to receive(:fetch).with('SystemRoot', anything).and_return('D:\\Win')
|
|
498
|
+
item = schedule
|
|
499
|
+
|
|
500
|
+
described_class.new(runner: runner, clock: clock).remove(item.os_id)
|
|
501
|
+
|
|
502
|
+
expect(runner.calls.first.first).to eq(File.join('D:\\Win', 'System32', 'schtasks.exe'))
|
|
503
|
+
end
|
|
504
|
+
end
|
|
505
|
+
# rubocop:enable RSpec/MultipleMemoizedHelpers
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'stringio'
|
|
4
|
+
require 'tmpdir'
|
|
5
|
+
require_relative '../../../lib/utilities/flows/project'
|
|
6
|
+
|
|
7
|
+
# Flow specs live under spec/utilities/flows, next to the code in lib/utilities/flows
|
|
8
|
+
RSpec.describe RubyRaider::Flows::Project do # rubocop:disable RSpec/FilePath
|
|
9
|
+
let(:dir) { Dir.mktmpdir('flows-project') }
|
|
10
|
+
let(:out) { StringIO.new }
|
|
11
|
+
let(:err) { StringIO.new }
|
|
12
|
+
let(:project) { described_class.new(dir) }
|
|
13
|
+
|
|
14
|
+
def write(relative, content)
|
|
15
|
+
file = File.join(dir, relative)
|
|
16
|
+
FileUtils.mkdir_p(File.dirname(file))
|
|
17
|
+
File.write(file, content)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def read(relative) = File.read(File.join(dir, relative))
|
|
21
|
+
|
|
22
|
+
def selenium_project(pin: "'~> 4.0'")
|
|
23
|
+
write('Gemfile', "gem 'raider_reporter', '~> 1.0'\ngem 'ruby_raider', #{pin}\ngem 'selenium-webdriver'\n")
|
|
24
|
+
write('helpers/driver_helper.rb', "require 'selenium-webdriver'\nrequire 'ruby_raider/runtime'\n")
|
|
25
|
+
write('.gitignore', "raider-results\nspec/examples.txt\n.raider/auth/\n")
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
after { FileUtils.rm_rf(dir) }
|
|
29
|
+
|
|
30
|
+
describe '#init' do
|
|
31
|
+
it 'creates the flow helper, the run recorder and the gitignore lines' do
|
|
32
|
+
selenium_project
|
|
33
|
+
|
|
34
|
+
expect(project.init(out:, err:)).to eq(0)
|
|
35
|
+
expect(File).to exist(File.join(dir, 'flows/flow_helper.rb'))
|
|
36
|
+
expect(File).to exist(File.join(dir, 'flows/support/run.rb'))
|
|
37
|
+
expect(read('.gitignore').lines.map(&:strip)).to include('flow-results/', '.raider/flows/', '.raider/auth/')
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
it 'raises a ~> 4.0 ruby_raider pin to ~> 4.1 and says to bundle' do
|
|
41
|
+
selenium_project
|
|
42
|
+
|
|
43
|
+
project.init(out:, err:)
|
|
44
|
+
|
|
45
|
+
expect(read('Gemfile')).to include("gem 'ruby_raider', '~> 4.1'")
|
|
46
|
+
expect(out.string).to include('Run bundle install')
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
it 'keeps a pin that already allows flows' do
|
|
50
|
+
selenium_project(pin: "'~> 4.2'")
|
|
51
|
+
|
|
52
|
+
project.init(out:, err:)
|
|
53
|
+
|
|
54
|
+
expect(read('Gemfile')).to include("gem 'ruby_raider', '~> 4.2'")
|
|
55
|
+
expect(out.string).not_to include('Run bundle install')
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
it 'asks to check a ruby_raider line it cannot raise' do
|
|
59
|
+
selenium_project(pin: "'4.0.1'")
|
|
60
|
+
|
|
61
|
+
expect(project.init(out:, err:)).to eq(0)
|
|
62
|
+
expect(err.string).to include('Check that the ruby_raider line in your Gemfile allows 4.1 or later')
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
it 'is idempotent' do
|
|
66
|
+
selenium_project
|
|
67
|
+
project.init(out:, err:)
|
|
68
|
+
helper = read('flows/flow_helper.rb')
|
|
69
|
+
second = StringIO.new
|
|
70
|
+
|
|
71
|
+
expect(project.init(out: second, err:)).to eq(0)
|
|
72
|
+
expect(second.string).to eq("Flows already set up\n")
|
|
73
|
+
expect(read('flows/flow_helper.rb')).to eq(helper)
|
|
74
|
+
expect(read('.gitignore').scan('flow-results/').size).to eq(1)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
it 'never overwrites a flow helper the user changed' do
|
|
78
|
+
selenium_project
|
|
79
|
+
write('flows/flow_helper.rb', "# mine\n")
|
|
80
|
+
|
|
81
|
+
project.init(out:, err:)
|
|
82
|
+
|
|
83
|
+
expect(read('flows/flow_helper.rb')).to eq("# mine\n")
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
it 'adds a newline before the gitignore lines when the file does not end with one' do
|
|
87
|
+
selenium_project
|
|
88
|
+
write('.gitignore', 'raider-results')
|
|
89
|
+
|
|
90
|
+
project.init(out:, err:)
|
|
91
|
+
|
|
92
|
+
expect(read('.gitignore')).to eq("raider-results\nflow-results/\n.raider/flows/\n")
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
shared_examples 'a refused project' do
|
|
96
|
+
it 'refuses with exit 2 and writes nothing' do
|
|
97
|
+
expect(project.init(out:, err:)).to eq(2)
|
|
98
|
+
expect(err.string).to eq("Flows need a Selenium web project made with Ruby Raider 4.0 or later\n")
|
|
99
|
+
expect(File).not_to exist(File.join(dir, 'flows'))
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
context 'with a mobile project' do
|
|
104
|
+
before do
|
|
105
|
+
selenium_project
|
|
106
|
+
write('Gemfile', "gem 'appium_lib'\ngem 'ruby_raider', '~> 4.0'\n")
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
it_behaves_like 'a refused project'
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
context 'with a 3.x project' do
|
|
113
|
+
before do
|
|
114
|
+
selenium_project(pin: "'~> 3.15'")
|
|
115
|
+
write('helpers/driver_helper.rb', "require 'selenium-webdriver'\n")
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
it_behaves_like 'a refused project'
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
context 'with a 3.x pin on 4.0 helpers' do
|
|
122
|
+
before { selenium_project(pin: "'~> 3.15'") }
|
|
123
|
+
|
|
124
|
+
it_behaves_like 'a refused project'
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
context 'without a Gemfile' do
|
|
128
|
+
it_behaves_like 'a refused project'
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
describe '#flow_file' do
|
|
133
|
+
before { selenium_project }
|
|
134
|
+
|
|
135
|
+
it 'returns the real path of flows/<name>_flow.rb' do
|
|
136
|
+
write('flows/report_flow.rb', '')
|
|
137
|
+
|
|
138
|
+
expect(project.flow_file('report')).to eq(File.realpath(File.join(dir, 'flows/report_flow.rb')))
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
it 'rejects a name outside the allowed characters' do
|
|
142
|
+
expect { project.flow_file('../x') }.to raise_error(RubyRaider::Flows::Error, /Invalid flow name/)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
it 'rejects a name longer than 60 characters' do
|
|
146
|
+
expect { project.flow_file('a' * 61) }.to raise_error(RubyRaider::Flows::Error, /Invalid flow name/)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
it 'reports a missing flow' do
|
|
150
|
+
expect { project.flow_file('nope') }.to raise_error(RubyRaider::Flows::Error, 'No flow nope: flows/nope_flow.rb not found')
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
it 'rejects a flow file that links outside the project' do
|
|
154
|
+
outside = Dir.mktmpdir('outside')
|
|
155
|
+
File.write(File.join(outside, 'evil_flow.rb'), '')
|
|
156
|
+
FileUtils.mkdir_p(File.join(dir, 'flows'))
|
|
157
|
+
File.symlink(File.join(outside, 'evil_flow.rb'), File.join(dir, 'flows/evil_flow.rb'))
|
|
158
|
+
|
|
159
|
+
expect { project.flow_file('evil') }.to raise_error(RubyRaider::Flows::Error, /outside the project/)
|
|
160
|
+
ensure
|
|
161
|
+
FileUtils.rm_rf(outside)
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
describe '#flow_names' do
|
|
166
|
+
it 'lists flow files by name, sorted' do
|
|
167
|
+
write('flows/b_flow.rb', '')
|
|
168
|
+
write('flows/a_flow.rb', '')
|
|
169
|
+
write('flows/flow_helper.rb', '')
|
|
170
|
+
|
|
171
|
+
expect(project.flow_names).to eq(%w[a b])
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
it 'raises a Flows::Error for a missing project folder' do
|
|
176
|
+
expect { described_class.new(File.join(dir, 'missing')) }.to raise_error(RubyRaider::Flows::Error, /Project folder not found/)
|
|
177
|
+
end
|
|
178
|
+
end
|