inspec-core 4.21.3 → 4.23.4

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 (48) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile +1 -1
  3. data/inspec-core.gemspec +3 -5
  4. data/lib/bundles/inspec-supermarket/cli.rb +1 -1
  5. data/lib/inspec/base_cli.rb +5 -1
  6. data/lib/inspec/config.rb +19 -1
  7. data/lib/inspec/exceptions.rb +1 -0
  8. data/lib/inspec/input.rb +4 -3
  9. data/lib/inspec/input_registry.rb +9 -2
  10. data/lib/inspec/metadata.rb +6 -1
  11. data/lib/inspec/plugin/v2/plugin_types/reporter.rb +4 -25
  12. data/lib/inspec/profile.rb +30 -9
  13. data/lib/inspec/reporters.rb +0 -3
  14. data/lib/inspec/reporters/automate.rb +3 -3
  15. data/lib/inspec/reporters/base.rb +7 -23
  16. data/lib/inspec/reporters/cli.rb +1 -0
  17. data/lib/inspec/reporters/json.rb +9 -4
  18. data/lib/inspec/resources/apt.rb +2 -0
  19. data/lib/inspec/resources/bridge.rb +1 -1
  20. data/lib/inspec/resources/host.rb +1 -1
  21. data/lib/inspec/resources/mount.rb +1 -1
  22. data/lib/inspec/resources/mysql_session.rb +31 -8
  23. data/lib/inspec/resources/postgres.rb +1 -1
  24. data/lib/inspec/resources/postgres_session.rb +6 -4
  25. data/lib/inspec/resources/processes.rb +1 -1
  26. data/lib/inspec/resources/service.rb +2 -2
  27. data/lib/inspec/resources/users.rb +1 -1
  28. data/lib/inspec/resources/windows_firewall.rb +110 -0
  29. data/lib/inspec/resources/windows_firewall_rule.rb +137 -0
  30. data/lib/inspec/run_data.rb +1 -1
  31. data/lib/inspec/run_data/profile.rb +7 -6
  32. data/lib/inspec/runner.rb +8 -2
  33. data/lib/inspec/runner_rspec.rb +4 -1
  34. data/lib/inspec/schema.rb +2 -0
  35. data/lib/inspec/schema/exec_json.rb +4 -3
  36. data/lib/inspec/schema/primitives.rb +1 -1
  37. data/lib/inspec/utils/parser.rb +1 -1
  38. data/lib/inspec/utils/run_data_filters.rb +104 -0
  39. data/lib/inspec/version.rb +1 -1
  40. data/lib/plugins/inspec-compliance/lib/inspec-compliance/api.rb +4 -4
  41. data/lib/plugins/inspec-compliance/lib/inspec-compliance/cli.rb +1 -1
  42. data/lib/plugins/inspec-reporter-html2/templates/profile.html.erb +5 -2
  43. data/lib/plugins/inspec-reporter-junit/README.md +15 -0
  44. data/lib/plugins/inspec-reporter-junit/lib/inspec-reporter-junit.rb +12 -0
  45. data/lib/{inspec/reporters/junit.rb → plugins/inspec-reporter-junit/lib/inspec-reporter-junit/reporter.rb} +22 -26
  46. data/lib/plugins/inspec-reporter-junit/lib/inspec-reporter-junit/version.rb +5 -0
  47. metadata +19 -36
  48. data/README.md +0 -474
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: cefaf29ca67cbd5cdc90ac2bc3a41e990f38f966761d324489d46f8c9a32fdc5
4
- data.tar.gz: 933e72e1021a81bffac47c3de56378ea0945682c6ed8f51b0e9751bd42859398
3
+ metadata.gz: a2786d0d36c3218a4d79f4578c4a814469115787d4470fa81d422b05b2025462
4
+ data.tar.gz: a0d30c7b81dbb4e58e6b9e8c0447f0855ce1f94f223c1f4f5861a03dfce2ce6c
5
5
  SHA512:
6
- metadata.gz: 0b4919f052f1f9f7bccdbf4116c2d33fded92f508f6c3057bed3a56a8214c7fa20754e281e3f50f04a5807be4c8693740e2952a0441f96447c44d2bc43059c67
7
- data.tar.gz: 1c6b5778d3fe67831b7bd680dd1475f1ca7ddbb1becc24fbbeb1d245e8f6698f2c36d94d5f565564572039a87a94a6f82e9d7ddd50b70475a45c280f43766eca
6
+ metadata.gz: 915bbf9f4302c5fe2e8a41bc131b935bc42861cd4f76d0163ef1aeddc231cdbaf1f653ffd621bb79e5a531baa941e59852b34550d114bf30d95d4eb2ff5aa876
7
+ data.tar.gz: d7b5f80f550bf092aaa0faaf660225f99e3e4d6b600d9a89a45c0648b752a00cac5e30aaaf74fdb940bb135b2ae2ae02112f9617adbe6c25abc46c10d9492c5d
data/Gemfile CHANGED
@@ -19,7 +19,7 @@ group :omnibus do
19
19
  end
20
20
 
21
21
  group :test do
22
- gem "chefstyle", "~> 0.13.0"
22
+ gem "chefstyle", "~> 1.2.1"
23
23
  gem "minitest", "~> 5.5"
24
24
  gem "minitest-sprint", "~> 1.0"
25
25
  gem "rake", ">= 10"
@@ -17,16 +17,16 @@ Gem::Specification.new do |spec|
17
17
 
18
18
  # the gemfile and gemspec are necessary for appbundler so don't remove it
19
19
  spec.files =
20
- Dir.glob("{{lib,etc}/**/*,README.md,LICENSE,Gemfile,inspec-core.gemspec}")
20
+ Dir.glob("{{lib,etc}/**/*,LICENSE,Gemfile,inspec-core.gemspec}")
21
21
  .grep_v(%r{(?<!inspec-init/templates/profiles/)(aws|azure|gcp)})
22
22
  .grep_v(%r{lib/plugins/.*/test/})
23
23
  .reject { |f| File.directory?(f) }
24
24
 
25
25
  # Implementation dependencies
26
26
  spec.add_dependency "chef-telemetry", "~> 1.0"
27
- spec.add_dependency "license-acceptance", ">= 0.2.13", "< 2.0"
27
+ spec.add_dependency "license-acceptance", ">= 0.2.13", "< 3.0"
28
28
  spec.add_dependency "thor", ">= 0.20", "< 2.0"
29
- spec.add_dependency "json_schemer", "~> 0.2.1"
29
+ spec.add_dependency "json_schemer", ">= 0.2.1", "< 0.2.12"
30
30
  spec.add_dependency "method_source", ">= 0.8", "< 2.0"
31
31
  spec.add_dependency "rubyzip", "~> 1.2", ">= 1.2.2"
32
32
  spec.add_dependency "rspec", "~> 3.9"
@@ -43,9 +43,7 @@ Gem::Specification.new do |spec|
43
43
  spec.add_dependency "addressable", "~> 2.4"
44
44
  spec.add_dependency "parslet", "~> 1.5"
45
45
  spec.add_dependency "semverse", "~> 3.0"
46
- spec.add_dependency "htmlentities", "~> 4.3" # TODO: remove when #4853 fixed
47
46
  spec.add_dependency "multipart-post", "~> 2.0"
48
- spec.add_dependency "term-ansicolor", "~> 1.7"
49
47
 
50
48
  spec.add_dependency "train-core", "~> 3.0"
51
49
  end
@@ -5,7 +5,7 @@ module Supermarket
5
5
  class SupermarketCLI < Inspec::BaseCLI
6
6
  namespace "supermarket"
7
7
 
8
- # TODO: find another solution, once https://github.com/erikhuda/thor/issues/261 is fixed
8
+ # TODO: find another solution, once https://github.com/erikhuda/thor/issues/261 is fixed.
9
9
  def self.banner(command, _namespace = nil, _subcommand = false)
10
10
  "#{basename} #{subcommand_prefix} #{command.usage}"
11
11
  end
@@ -60,7 +60,7 @@ module Inspec
60
60
  true
61
61
  end
62
62
 
63
- def self.target_options # rubocop:disable MethodLength
63
+ def self.target_options # rubocop:disable Metrics/MethodLength
64
64
  option :target, aliases: :t, type: :string,
65
65
  desc: "Simple targeting option using URIs, e.g. ssh://user:pass@host:port"
66
66
  option :backend, aliases: :b, type: :string,
@@ -158,6 +158,10 @@ module Inspec
158
158
  option :silence_deprecations, type: :array,
159
159
  banner: "[all]|[GROUP GROUP...]",
160
160
  desc: "Suppress deprecation warnings. See install_dir/etc/deprecations.json for list of GROUPs or use 'all'."
161
+ option :diff, type: :boolean, default: true,
162
+ desc: "Use --no-diff to suppress 'diff' output of failed textual test results."
163
+ option :sort_results_by, type: :string, default: "file", banner: "--sort-results-by=none|control|file|random",
164
+ desc: "After normal execution order, results are sorted by control ID, or by file (default), or randomly. None uses legacy unsorted mode."
161
165
  end
162
166
 
163
167
  def self.format_platform_info(params: {}, indent: 0, color: 39)
@@ -344,7 +344,6 @@ module Inspec
344
344
  cli
345
345
  json
346
346
  json-automate
347
- junit
348
347
  yaml
349
348
  }
350
349
 
@@ -406,6 +405,18 @@ module Inspec
406
405
  @plugin_cfg = data
407
406
  end
408
407
 
408
+ def validate_sort_results_by!(option_value)
409
+ expected = %w{
410
+ none
411
+ control
412
+ file
413
+ random
414
+ }
415
+ return if expected.include? option_value
416
+
417
+ raise Inspec::ConfigError::Invalid, "--sort-results-by must be one of #{expected.join(", ")}"
418
+ end
419
+
409
420
  #-----------------------------------------------------------------------#
410
421
  # Merging Options
411
422
  #-----------------------------------------------------------------------#
@@ -436,6 +447,7 @@ module Inspec
436
447
  finalize_parse_reporters(options)
437
448
  finalize_handle_sudo(options)
438
449
  finalize_compliance_login(options)
450
+ finalize_sort_results(options)
439
451
 
440
452
  Thor::CoreExt::HashWithIndifferentAccess.new(options)
441
453
  end
@@ -510,6 +522,12 @@ module Inspec
510
522
  end
511
523
  end
512
524
 
525
+ def finalize_sort_results(options)
526
+ if options.key?("sort_results_by")
527
+ validate_sort_results_by!(options["sort_results_by"])
528
+ end
529
+ end
530
+
513
531
  class Defaults
514
532
  DEFAULTS = {
515
533
  exec: {
@@ -4,6 +4,7 @@ module Inspec
4
4
  module Exceptions
5
5
  class InputsFileDoesNotExist < ArgumentError; end
6
6
  class InputsFileNotReadable < ArgumentError; end
7
+ class ProfileLoadFailed < StandardError; end
7
8
  class ResourceFailed < StandardError; end
8
9
  class ResourceSkipped < StandardError; end
9
10
  class SecretsBackendNotFound < ArgumentError; end
@@ -171,7 +171,7 @@ module Inspec
171
171
  # are free to go higher.
172
172
  DEFAULT_PRIORITY_FOR_VALUE_SET = 60
173
173
 
174
- attr_reader :description, :events, :identifier, :name, :required, :title, :type
174
+ attr_reader :description, :events, :identifier, :name, :required, :sensitive, :title, :type
175
175
 
176
176
  def initialize(name, options = {})
177
177
  @name = name
@@ -264,6 +264,7 @@ module Inspec
264
264
  @required = options[:required] if options.key?(:required)
265
265
  @identifier = options[:identifier] if options.key?(:identifier) # TODO: determine if this is ever used
266
266
  @type = options[:type] if options.key?(:type)
267
+ @sensitive = options[:sensitive] if options.key?(:sensitive)
267
268
  end
268
269
 
269
270
  def make_creation_event(options)
@@ -320,7 +321,7 @@ module Inspec
320
321
 
321
322
  def to_hash
322
323
  as_hash = { name: name, options: {} }
323
- %i{description title identifier type required value}.each do |field|
324
+ %i{description title identifier type required value sensitive}.each do |field|
324
325
  val = send(field)
325
326
  next if val.nil?
326
327
 
@@ -334,7 +335,7 @@ module Inspec
334
335
  #--------------------------------------------------------------------------#
335
336
 
336
337
  def to_s
337
- "Input #{name} with #{current_value}"
338
+ "Input #{name} with value " + (sensitive ? "*** (senstive)" : "#{current_value}")
338
339
  end
339
340
 
340
341
  #--------------------------------------------------------------------------#
@@ -29,6 +29,8 @@ module Inspec
29
29
  def_delegator :inputs_by_profile, :select
30
30
  def_delegator :profile_aliases, :key?, :profile_alias?
31
31
 
32
+ attr_accessor :cache_inputs
33
+
32
34
  def initialize
33
35
  # Keyed on String profile_name => Hash of String input_name => Input object
34
36
  @inputs_by_profile = {}
@@ -43,6 +45,9 @@ module Inspec
43
45
  activator.activate!
44
46
  activator.implementation_class.new
45
47
  end
48
+
49
+ # Activate caching for inputs by default
50
+ @cache_inputs = true
46
51
  end
47
52
 
48
53
  #-------------------------------------------------------------#
@@ -84,7 +89,7 @@ module Inspec
84
89
 
85
90
  # Find or create the input
86
91
  inputs_by_profile[profile_name] ||= {}
87
- if inputs_by_profile[profile_name].key?(input_name)
92
+ if inputs_by_profile[profile_name].key?(input_name) && cache_inputs
88
93
  inputs_by_profile[profile_name][input_name].update(options)
89
94
  else
90
95
  inputs_by_profile[profile_name][input_name] = Inspec::Input.new(input_name, options)
@@ -165,7 +170,8 @@ module Inspec
165
170
  raise ArgumentError, "ERROR: An '=' is required when using --input. Usage: --input input_name1=input_value1 input2=value2"
166
171
  end
167
172
  end
168
- input_name, input_value = pair.split("=")
173
+ pair = pair.match(/(.*?)=(.*)/)
174
+ input_name, input_value = pair[1], pair[2]
169
175
  input_value = parse_cli_input_value(input_name, input_value)
170
176
  evt = Inspec::Input::Event.new(
171
177
  value: input_value,
@@ -315,6 +321,7 @@ module Inspec
315
321
  profile_name,
316
322
  type: input_options[:type],
317
323
  required: input_options[:required],
324
+ sensitive: input_options[:sensitive],
318
325
  event: evt
319
326
  )
320
327
  end
@@ -9,7 +9,12 @@ require "inspec/version"
9
9
  require "inspec/utils/spdx"
10
10
 
11
11
  module Inspec
12
- # Extract metadata.rb information
12
+ # The Metadata class represents a profile's metadata.
13
+ # This includes the metadata stored in the profile's metadata.rb file, as well as inferred
14
+ # metadata like if this profile supports the current runtime and the intended target.
15
+ # This class does NOT represent the runtime state of a profile during execution.
16
+ # See lib/inspec/profile.rb for the runtime representation of a profile.
17
+ #
13
18
  # A Metadata object may be created and finalized with invalid data.
14
19
  # This allows the check CLI command to analyse the issues.
15
20
  # Use valid? to determine if the metadata is coherent.
@@ -1,18 +1,20 @@
1
1
  require_relative "../../../run_data"
2
+ require_relative "../../../utils/run_data_filters"
2
3
 
3
4
  module Inspec::Plugin::V2::PluginType
4
5
  class Reporter < Inspec::Plugin::V2::PluginBase
5
6
  register_plugin_type(:reporter)
7
+ include Inspec::Utils::RunDataFilters
6
8
 
7
9
  attr_reader :run_data
8
10
 
9
11
  def initialize(config)
10
12
  @config = config
11
13
 
12
- # Trim the run_data while still a Hash; if it is huge, this
14
+ # Filter the run_data while still a Hash; if it is huge, this
13
15
  # saves on conversion time
14
16
  @run_data = config[:run_data] || {}
15
- apply_report_resize_options
17
+ apply_run_data_filters_to_hash
16
18
 
17
19
  unless Inspec::RunData.compatible_schema?(self.class.run_data_schema_constraints)
18
20
  # Best we can do is warn here, the InSpec run has finished
@@ -24,29 +26,6 @@ module Inspec::Plugin::V2::PluginType
24
26
  @output = ""
25
27
  end
26
28
 
27
- # This is a temporary duplication of code from lib/inspec/reporters/base.rb
28
- # To be DRY'd up once the core reporters become plugins...
29
- # Apply options such as message truncation and removal of backtraces
30
- def apply_report_resize_options
31
- runtime_config = Inspec::Config.cached.respond_to?(:final_options) ? Inspec::Config.cached.final_options : {}
32
-
33
- message_truncation = runtime_config[:reporter_message_truncation] || "ALL"
34
- trunc = message_truncation == "ALL" ? -1 : message_truncation.to_i
35
- include_backtrace = runtime_config[:reporter_backtrace_inclusion].nil? ? true : runtime_config[:reporter_backtrace_inclusion]
36
-
37
- @run_data[:profiles]&.each do |p|
38
- p[:controls].each do |c|
39
- c[:results]&.map! do |r|
40
- r.delete(:backtrace) unless include_backtrace
41
- if r.key?(:message) && r[:message] != "" && trunc > -1
42
- r[:message] = r[:message][0...trunc] + "[Truncated to #{trunc} characters]"
43
- end
44
- r
45
- end
46
- end
47
- end
48
- end
49
-
50
29
  def output(str, newline = true)
51
30
  @output << str
52
31
  @output << "\n" if newline
@@ -94,6 +94,7 @@ module Inspec
94
94
  @input_values = options[:inputs]
95
95
  @tests_collected = false
96
96
  @libraries_loaded = false
97
+ @state = :loaded
97
98
  @check_mode = options[:check_mode] || false
98
99
  @parent_profile = options[:parent_profile]
99
100
  @legacy_profile_path = options[:profiles_path] || false
@@ -146,7 +147,12 @@ module Inspec
146
147
  options[:profile_context] ||
147
148
  Inspec::ProfileContext.for_profile(self, @backend)
148
149
 
149
- @supports_platform = metadata.supports_platform?(@backend)
150
+ if metadata.supports_platform?(@backend)
151
+ @supports_platform = true
152
+ else
153
+ @supports_platform = false
154
+ @state = :skipped
155
+ end
150
156
  @supports_runtime = metadata.supports_runtime?
151
157
  end
152
158
 
@@ -162,6 +168,10 @@ module Inspec
162
168
  @writable
163
169
  end
164
170
 
171
+ def failed?
172
+ @state == :failed
173
+ end
174
+
165
175
  #
166
176
  # Is this profile is supported on the current platform of the
167
177
  # backend machine and the current inspec version.
@@ -197,7 +207,7 @@ module Inspec
197
207
  end
198
208
 
199
209
  def collect_tests(include_list = @controls)
200
- unless @tests_collected
210
+ unless @tests_collected || failed?
201
211
  return unless supports_platform?
202
212
 
203
213
  locked_dependencies.each(&:collect_tests)
@@ -206,7 +216,12 @@ module Inspec
206
216
  next if content.nil? || content.empty?
207
217
 
208
218
  abs_path = source_reader.target.abs_path(path)
209
- @runner_context.load_control_file(content, abs_path, nil)
219
+ begin
220
+ @runner_context.load_control_file(content, abs_path, nil)
221
+ rescue => e
222
+ @state = :failed
223
+ raise Inspec::Exceptions::ProfileLoadFailed, "Failed to load source for #{path}: #{e}"
224
+ end
210
225
  end
211
226
  @tests_collected = true
212
227
  end
@@ -249,12 +264,13 @@ module Inspec
249
264
  d = dep.profile
250
265
  # this will force a dependent profile load so we are only going to add
251
266
  # this metadata if the parent profile is supported.
252
- if supports_platform? && !d.supports_platform?
267
+ if @supports_platform && !d.supports_platform?
253
268
  # since ruby 1.9 hashes are ordered so we can just use index values here
254
269
  # TODO: NO! this is a violation of encapsulation to an extreme
255
270
  metadata.dependencies[i][:status] = "skipped"
256
271
  msg = "Skipping profile: '#{d.name}' on unsupported platform: '#{d.backend.platform.name}/#{d.backend.platform.release}'."
257
- metadata.dependencies[i][:skip_message] = msg
272
+ metadata.dependencies[i][:status_message] = msg
273
+ metadata.dependencies[i][:skip_message] = msg # Repeat as skip_message for backward compatibility
258
274
  next
259
275
  elsif metadata.dependencies[i]
260
276
  # Currently wrapper profiles will load all dependencies, and then we
@@ -324,12 +340,13 @@ module Inspec
324
340
  res[:sha256] = sha256
325
341
  res[:parent_profile] = parent_profile unless parent_profile.nil?
326
342
 
327
- if !supports_platform?
343
+ if @supports_platform
344
+ res[:status_message] = @status_message || ""
345
+ res[:status] = failed? ? "failed" : "loaded"
346
+ else
328
347
  res[:status] = "skipped"
329
348
  msg = "Skipping profile: '#{name}' on unsupported platform: '#{backend.platform.name}/#{backend.platform.release}'."
330
- res[:skip_message] = msg
331
- else
332
- res[:status] = "loaded"
349
+ res[:status_message] = msg
333
350
  end
334
351
 
335
352
  # convert legacy os-* supports to their platform counterpart
@@ -455,6 +472,10 @@ module Inspec
455
472
  params[:controls].values.length
456
473
  end
457
474
 
475
+ def set_status_message(msg)
476
+ @status_message = msg.to_s
477
+ end
478
+
458
479
  # generates a archive of a folder profile
459
480
  # assumes that the profile was checked before
460
481
  def archive(opts)
@@ -2,7 +2,6 @@ require "inspec/reporters/base"
2
2
  require "inspec/reporters/cli"
3
3
  require "inspec/reporters/json"
4
4
  require "inspec/reporters/json_automate"
5
- require "inspec/reporters/junit"
6
5
  require "inspec/reporters/automate"
7
6
  require "inspec/reporters/yaml"
8
7
 
@@ -20,8 +19,6 @@ module Inspec::Reporters
20
19
  # right to introduce breaking changes to this reporter at any time.
21
20
  when "json-automate"
22
21
  reporter = Inspec::Reporters::JsonAutomate.new(config)
23
- when "junit"
24
- reporter = Inspec::Reporters::Junit.new(config)
25
22
  when "automate"
26
23
  reporter = Inspec::Reporters::Automate.new(config)
27
24
  when "yaml"
@@ -49,14 +49,14 @@ module Inspec::Reporters
49
49
 
50
50
  res = http.request(req)
51
51
  if res.is_a?(Net::HTTPSuccess)
52
- return true
52
+ true
53
53
  else
54
54
  Inspec::Log.error "send_report: POST to #{uri.path} returned: #{res.body}"
55
- return false
55
+ false
56
56
  end
57
57
  rescue => e
58
58
  Inspec::Log.error "send_report: POST to #{uri.path} returned: #{e.message}"
59
- return false
59
+ false
60
60
  end
61
61
  end
62
62
 
@@ -1,33 +1,17 @@
1
+ require_relative "../utils/run_data_filters"
2
+
1
3
  module Inspec::Reporters
2
4
  class Base
5
+ include Inspec::Utils::RunDataFilters
6
+
3
7
  attr_reader :run_data
4
8
 
5
9
  def initialize(config)
6
10
  @config = config
7
- @run_data = config[:run_data]
8
- apply_report_resize_options unless @run_data.nil?
9
- @output = ""
10
- end
11
+ @run_data = config[:run_data] || {}
12
+ apply_run_data_filters_to_hash
11
13
 
12
- # Apply options such as message truncation and removal of backtraces
13
- def apply_report_resize_options
14
- runtime_config = Inspec::Config.cached.respond_to?(:final_options) ? Inspec::Config.cached.final_options : {}
15
-
16
- message_truncation = runtime_config[:reporter_message_truncation] || "ALL"
17
- trunc = message_truncation == "ALL" ? -1 : message_truncation.to_i
18
- include_backtrace = runtime_config[:reporter_backtrace_inclusion].nil? ? true : runtime_config[:reporter_backtrace_inclusion]
19
-
20
- @run_data[:profiles]&.each do |p|
21
- p[:controls].each do |c|
22
- c[:results]&.map! do |r|
23
- r.delete(:backtrace) unless include_backtrace
24
- if r.key?(:message) && r[:message] != "" && trunc > -1
25
- r[:message] = r[:message][0...trunc] + "[Truncated to #{trunc} characters]"
26
- end
27
- r
28
- end
29
- end
30
- end
14
+ @output = ""
31
15
  end
32
16
 
33
17
  def output(str, newline = true)