Skip to content

Commit e8c9952

Browse files
authored
Merge pull request #1778 from nicklewis/GH-1759-non-utf-8-task-output
(GH-1759) Handle non-UTF-8 results
2 parents ee2968b + 1caa90c commit e8c9952

File tree

14 files changed

+105
-56
lines changed

14 files changed

+105
-56
lines changed

lib/bolt/outputter/human.rb

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -107,13 +107,14 @@ def print_result(result)
107107

108108
# Use special handling if the result looks like a command or script result
109109
if result.generic_value.keys == %w[stdout stderr exit_code]
110-
unless result['stdout'].strip.empty?
110+
safe_value = result.safe_value
111+
unless safe_value['stdout'].strip.empty?
111112
@stream.puts(indent(2, "STDOUT:"))
112-
@stream.puts(indent(4, result['stdout']))
113+
@stream.puts(indent(4, safe_value['stdout']))
113114
end
114-
unless result['stderr'].strip.empty?
115+
unless safe_value['stderr'].strip.empty?
115116
@stream.puts(indent(2, "STDERR:"))
116-
@stream.puts(indent(4, result['stderr']))
117+
@stream.puts(indent(4, safe_value['stderr']))
117118
end
118119
elsif result.generic_value.any?
119120
@stream.puts(indent(2, ::JSON.pretty_generate(result.generic_value)))

lib/bolt/outputter/json.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ def handle_event(event)
2828

2929
def print_result(result)
3030
@stream.puts ',' if @preceding_item
31-
@stream.puts result.status_hash.to_json
31+
@stream.puts result.to_json
3232
@preceding_item = true
3333
end
3434

lib/bolt/rerun.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ def update(result_set)
4545
end
4646

4747
if result_set.is_a?(Bolt::ResultSet)
48-
data = result_set.map { |res| res.status_hash.select { |k, _| %i[target status].include? k } }
48+
data = result_set.map { |res| { target: res.target.name, status: res.status } }
4949
FileUtils.mkdir_p(File.dirname(@path))
5050
File.write(@path, data.to_json)
5151
elsif File.exist?(@path)

lib/bolt/result.rb

Lines changed: 46 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -40,18 +40,23 @@ def self.for_command(target, stdout, stderr, exit_code, action, command)
4040
end
4141

4242
def self.for_task(target, stdout, stderr, exit_code, task)
43-
begin
44-
value = JSON.parse(stdout)
45-
unless value.is_a? Hash
46-
value = nil
47-
end
48-
rescue JSON::ParserError
49-
value = nil
50-
end
51-
value ||= { '_output' => stdout }
43+
stdout.force_encoding('utf-8') unless stdout.encoding == Encoding::UTF_8
44+
value = if stdout.valid_encoding?
45+
parse_hash(stdout) || { '_output' => stdout }
46+
else
47+
{ '_error' => { 'kind' => 'puppetlabs.tasks/task-error',
48+
'issue_code' => 'TASK_ERROR',
49+
'msg' => 'The task result contained invalid UTF-8 on stdout',
50+
'details' => {} } }
51+
end
52+
5253
if exit_code != 0 && value['_error'].nil?
5354
msg = if stdout.empty?
54-
"The task failed with exit code #{exit_code}:\n#{stderr}"
55+
if stderr.empty?
56+
"The task failed with exit code #{exit_code} and no output"
57+
else
58+
"The task failed with exit code #{exit_code} and no stdout, but stderr contained:\n#{stderr}"
59+
end
5560
else
5661
"The task failed with exit code #{exit_code}"
5762
end
@@ -63,6 +68,13 @@ def self.for_task(target, stdout, stderr, exit_code, task)
6368
new(target, value: value, action: 'task', object: task)
6469
end
6570

71+
def self.parse_hash(string)
72+
value = JSON.parse(string)
73+
value if value.is_a? Hash
74+
rescue JSON::ParserError
75+
nil
76+
end
77+
6678
def self.for_upload(target, source, destination)
6779
new(target, message: "Uploaded '#{source}' to '#{target.host}:#{destination}'", action: 'upload', object: source)
6880
end
@@ -110,18 +122,8 @@ def message?
110122
message && !message.strip.empty?
111123
end
112124

113-
def status_hash
114-
{
115-
target: @target.name,
116-
action: action,
117-
object: object,
118-
status: status,
119-
value: @value
120-
}
121-
end
122-
123125
def generic_value
124-
value.reject { |k, _| %w[_error _output].include? k }
126+
safe_value.reject { |k, _| %w[_error _output].include? k }
125127
end
126128

127129
def eql?(other)
@@ -139,15 +141,36 @@ def ==(other)
139141
end
140142

141143
def to_json(opts = nil)
142-
status_hash.to_json(opts)
144+
to_data.to_json(opts)
143145
end
144146

145147
def to_s
146148
to_json
147149
end
148150

151+
# This is the value with all non-UTF-8 characters removed, suitable for
152+
# printing or converting to JSON. It *should* only be possible to have
153+
# non-UTF-8 characters in stdout/stderr keys as they are not allowed from
154+
# tasks but we scrub the whole thing just in case.
155+
def safe_value
156+
Bolt::Util.walk_vals(value) do |val|
157+
if val.is_a?(String)
158+
# Replace invalid bytes with hex codes, ie. \xDE\xAD\xBE\xEF
159+
val.scrub { |c| c.bytes.map { |b| "\\x" + b.to_s(16).upcase }.join }
160+
else
161+
val
162+
end
163+
end
164+
end
165+
149166
def to_data
150-
Bolt::Util.walk_keys(status_hash, &:to_s)
167+
{
168+
"target" => @target.name,
169+
"action" => action,
170+
"object" => object,
171+
"status" => status,
172+
"value" => safe_value
173+
}
151174
end
152175

153176
def status

lib/bolt/result_set.rb

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -99,17 +99,14 @@ def eql?(other)
9999
self.class == other.class && @results == other.results
100100
end
101101

102-
def to_a
103-
@results.map(&:status_hash)
104-
end
105-
106102
def to_json(opts = nil)
107-
@results.map(&:status_hash).to_json(opts)
103+
to_data.to_json(opts)
108104
end
109105

110106
def to_data
111107
@results.map(&:to_data)
112108
end
109+
alias to_a to_data
113110

114111
def to_s
115112
to_json

lib/bolt/shell/powershell.rb

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -267,10 +267,18 @@ def execute(command)
267267

268268
result = Bolt::Node::Output.new
269269
inp.close
270-
out.binmode
271-
err.binmode
272-
stdout = Thread.new { result.stdout << out.read }
273-
stderr = Thread.new { result.stderr << err.read }
270+
stdout = Thread.new do
271+
# Set to binmode to preserve \r\n line endings, but save and restore
272+
# the proper encoding so the string isn't later misinterpreted
273+
encoding = out.external_encoding
274+
out.binmode
275+
result.stdout << out.read.force_encoding(encoding)
276+
end
277+
stderr = Thread.new do
278+
encoding = err.external_encoding
279+
err.binmode
280+
result.stderr << err.read.force_encoding(encoding)
281+
end
274282

275283
stdout.join
276284
stderr.join

lib/bolt/transport/winrm/connection.rb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -108,8 +108,8 @@ def execute(command)
108108
# it will fail if the shell attempts to provide stdin
109109
inp.close
110110

111-
out_rd, out_wr = IO.pipe
112-
err_rd, err_wr = IO.pipe
111+
out_rd, out_wr = IO.pipe('UTF-8')
112+
err_rd, err_wr = IO.pipe('UTF-8')
113113
th = Thread.new do
114114
result = @session.run(command)
115115
out_wr << result.stdout

lib/bolt_server/transport_app.rb

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -57,8 +57,8 @@ def initialize(config)
5757
end
5858

5959
def scrub_stack_trace(result)
60-
if result.dig(:value, '_error', 'details', 'stack_trace')
61-
result[:value]['_error']['details'].reject! { |k| k == 'stack_trace' }
60+
if result.dig('value', '_error', 'details', 'stack_trace')
61+
result['value']['_error']['details'].reject! { |k| k == 'stack_trace' }
6262
end
6363
result
6464
end
@@ -87,14 +87,14 @@ def validate_schema(schema, body)
8787
# If the `result_set` contains only one item, it will be returned
8888
# as a single result object. Set `aggregate` to treat it as a set
8989
# of results with length 1 instead.
90-
def result_set_to_status_hash(result_set, aggregate: false)
90+
def result_set_to_data(result_set, aggregate: false)
9191
scrubbed_results = result_set.map do |result|
92-
scrub_stack_trace(result.status_hash)
92+
scrub_stack_trace(result.to_data)
9393
end
9494

9595
if aggregate || scrubbed_results.length > 1
9696
# For actions that act on multiple targets, construct a status hash for the aggregate result
97-
all_succeeded = scrubbed_results.all? { |r| r[:status] == 'success' }
97+
all_succeeded = scrubbed_results.all? { |r| r['status'] == 'success' }
9898
{
9999
status: all_succeeded ? 'success' : 'failure',
100100
result: scrubbed_results
@@ -297,7 +297,7 @@ def make_ssh_target(target_hash)
297297
return [400, error.to_json] unless error.nil?
298298

299299
aggregate = body['target'].nil?
300-
[200, result_set_to_status_hash(result_set, aggregate: aggregate).to_json]
300+
[200, result_set_to_data(result_set, aggregate: aggregate).to_json]
301301
end
302302

303303
def make_winrm_target(target_hash)
@@ -337,7 +337,7 @@ def make_winrm_target(target_hash)
337337
return [400, error.to_json] if error
338338

339339
aggregate = body['target'].nil?
340-
[200, result_set_to_status_hash(result_set, aggregate: aggregate).to_json]
340+
[200, result_set_to_data(result_set, aggregate: aggregate).to_json]
341341
end
342342

343343
# Fetches the metadata for a single plan

spec/bolt/rerun_spec.rb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,8 @@
3232

3333
let(:failure_array) do
3434
result_set.map do |r|
35-
r = r.status_hash
36-
{ 'target' => r[:target], 'status' => r[:status] }
35+
r = r.to_data
36+
{ 'target' => r['target'], 'status' => r['status'] }
3737
end
3838
end
3939

spec/bolt/result_spec.rb

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,5 +109,25 @@
109109
expect(result.value).to eq(obj)
110110
expect(result.error_hash).to eq(obj['_error'])
111111
end
112+
113+
it 'uses the unparsed value of stdout if it is not valid JSON' do
114+
stdout = 'just some string'
115+
result = Bolt::Result.for_task(target, stdout, '', 0, 'atask')
116+
expect(result.value).to eq('_output' => 'just some string')
117+
end
118+
119+
it 'generates an error for binary data' do
120+
stdout = "\xFC].\xF9\xA8\x85f\xDF{\x11d\xD5\x8E\xC6\xA6"
121+
result = Bolt::Result.for_task(target, stdout, '', 0, 'atask')
122+
expect(result.value.keys).to eq(['_error'])
123+
expect(result.error_hash['msg']).to match(/The task result contained invalid UTF-8/)
124+
end
125+
126+
it 'generates an error for non-UTF-8 output' do
127+
stdout = "☃".encode('utf-32')
128+
result = Bolt::Result.for_task(target, stdout, '', 0, 'atask')
129+
expect(result.value.keys).to eq(['_error'])
130+
expect(result.error_hash['msg']).to match(/The task result contained invalid UTF-8/)
131+
end
112132
end
113133
end

0 commit comments

Comments
 (0)