Skip to content

Commit 6f5e4fd

Browse files
committed
Merge pull request #16 from BaxterStockman/safe-ps4
PS4 with subshells causes erroneous extra hits to be registered
2 parents 9fdf1a3 + 66e02ad commit 6f5e4fd

20 files changed

+884
-146
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ doc/
1313
lib/bundler/man
1414
pkg
1515
rdoc
16+
.rubocop-*
1617
spec/reports
1718
test/tmp
1819
test/version_tmp

.rubocop.yml

Lines changed: 2 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,6 @@
1-
Metrics/AbcSize:
2-
Enabled: false
3-
4-
Metrics/MethodLength:
5-
Enabled: false
6-
7-
Metrics/LineLength:
8-
Max: 120
9-
Exclude: [spec/**/*]
10-
11-
Style/AccessModifierIndentation:
12-
EnforcedStyle: outdent
13-
14-
Style/AndOr:
15-
EnforcedStyle: conditionals
16-
17-
Style/SignalException:
18-
EnforcedStyle: only_raise
1+
inherit_from:
2+
- https://gist.githubusercontent.com/infertux/cdd2ccc6e0a0cd94f458/raw
193

204
Style/SpecialGlobalVars:
215
Enabled: false
226

23-
Style/StringLiterals:
24-
EnforcedStyle: double_quotes
25-
26-
Style/TrailingComma:
27-
Enabled: false

README.md

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,11 @@
2020
[Dependencies]: https://gemnasium.com/infertux/bashcov "Bashcov dependencies on Gemnasium"
2121
[Bashcov]: https://github.com/infertux/bashcov
2222
[SimpleCov]: https://github.com/colszowka/simplecov "Bashcov is backed by SimpleCov to generate awesome coverage report"
23+
[Test app demo]: http://infertux.github.com/bashcov/test_app/ "Coverage for the bundled test application"
2324

2425
You should check out these coverage examples - it's worth a thousand words:
2526

26-
- [Test app demo](http://infertux.github.com/bashcov/test_app/ "Coverage for the bundled test application")
27+
- [Test app demo]
2728
- [RVM demo](http://infertux.github.com/bashcov/rvm/ "Coverage for RVM")
2829

2930
## Installation
@@ -51,6 +52,52 @@ Open `./coverage/index.html` to browse the coverage report.
5152
You can take great advantage of [SimpleCov] by adding a `.simplecov` file in your project's root (like [this](https://github.com/infertux/bashcov/blob/master/spec/test_app/.simplecov)).
5253
See [SimpleCov README](https://github.com/colszowka/simplecov#readme) for more information.
5354

55+
### Some gory details
56+
57+
Figuring out where an executing Bash script lives in the file system can be
58+
surprisingly difficult. Bash offers a fair amount of [introspection into its
59+
internals](https://www.gnu.org/software/bash/manual/html_node/Bash-Variables.html),
60+
but the location of the current script has to be inferred from the limited
61+
information available through `BASH_SOURCE`, `PWD`, and `OLDPWD` (and
62+
potentially `DIRSTACK` if you are using `pushd`/`popd`). For this purpose,
63+
Bashcov puts Bash in debug mode and sets up a `PS4` that expands the values of
64+
these variables, reading them on each command that Bash executes. But, given
65+
that:
66+
67+
* `BASH_SOURCE` is only an absolute path if the script was invoked using an
68+
absolute path,
69+
* The builtins `cd`, `pushd`, and `popd` alter `PWD` and `OLDPWD`, and
70+
* None of these variables are read-only and can therefore be `unset` or
71+
otherwise altered,
72+
73+
it can be easy to lose track of where we are.
74+
75+
"Wait a minute, what about `pwd`, `readlink`, and so on?" That would be great,
76+
except that subshells executed as part of expanding the `PS4` can cause Bash to
77+
report [extra executions](https://github.com/infertux/bashcov/commit/4130874e30a05b7ab6ea66fb96a19acaa973c178)
78+
for [certain lines](https://github.com/infertux/bashcov/pull/16). Also,
79+
subshells are slow, and the `PS4` is expanded on each and every command when
80+
Bash is in debug mode.
81+
82+
To deal with these limitations, Bashcov uses the expedient of maintaining two
83+
stacks that track changes to `PWD` and `OLDPWD`. To determine the full path to
84+
the executing script, Bashcov iterates in reverse over the `PWD` stack, testing
85+
for the first `$PWD/$BASH_SOURCE` combination that refers to an existing file.
86+
This heuristic is susceptible to false positives -- under certain combinations
87+
of directory stucture, script invocation paths, and working directory changes,
88+
it may yield a path that doesn't refer to the currently-running script.
89+
However, it performs well under the various working directory changes performed
90+
in the [test app demo] and avoids the spurious extra hits caused by using
91+
subshells in the `PS4`.
92+
93+
One final note on innards: Bash 4.3 fixed a bug in which `PS4` expansion is
94+
truncated to a maximum of 128 characters. On platforms whose Bash version
95+
suffers from this bug, Bashcov uses the ASCII record separator character to
96+
delimit the `PS4` fields, whereas it uses a long random string on Bash 4.3 and
97+
above. When the field delimiter appears in the path of a script under test or
98+
in a command the script executes, Bashcov won't correctly parse the `PS4` and
99+
will abort early with incomplete coverage results.
100+
54101
## Contributing
55102

56103
Bug reports and patches are most welcome.

lib/bashcov.rb

Lines changed: 56 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,48 +1,71 @@
11
require "optparse"
22
require "ostruct"
3-
require "bashcov/version"
4-
require "bashcov/lexer"
5-
require "bashcov/line"
3+
require "pathname"
4+
5+
require "bashcov/bash_info"
66
require "bashcov/runner"
7-
require "bashcov/xtrace"
7+
require "bashcov/version"
88

99
# Bashcov default module
1010
# @note Keep it short!
1111
module Bashcov
12-
class << self
13-
# @return [OpenStruct] Bashcov settings
14-
attr_reader :options
15-
16-
# @return [String] The project's root directory
17-
def root_directory
18-
@root_directory ||= Dir.pwd
19-
end
12+
extend Bashcov::BashInfo
2013

21-
# Sets default options overriding any existing ones.
22-
# @return [void]
23-
def set_default_options!
24-
@options ||= OpenStruct.new
25-
@options.skip_uncovered = false
26-
@options.mute = false
14+
class << self
15+
# @return [OpenStruct] The +OpenStruct+ object representing Bashcov's
16+
# execution environment
17+
def options
18+
set_default_options! unless defined?(@options)
19+
@options
2720
end
2821

2922
# Parses the given CLI arguments and sets +options+.
3023
# @param [Array] args list of arguments
3124
# @raise [SystemExit] if invalid arguments are given
3225
# @return [void]
3326
def parse_options!(args)
34-
option_parser.parse!(args)
27+
begin
28+
option_parser.parse!(args)
29+
rescue OptionParser::ParseError, Errno::ENOENT => e
30+
abort "#{option_parser.program_name}: #{e.message}"
31+
end
3532

3633
if args.empty?
3734
abort("You must give exactly one command to execute.")
3835
else
39-
@options.command = args.join(" ")
36+
options.command = args.unshift(bash_path)
4037
end
4138
end
4239

40+
# @return [String] Program name
41+
def program_name
42+
"bashcov"
43+
end
44+
4345
# @return [String] Program name including version for easy consistent output
44-
def name
45-
"bashcov v#{VERSION}"
46+
# @note +fullname+ instead of name to avoid clashing with +Module.name+
47+
def fullname
48+
"#{program_name} v#{VERSION}"
49+
end
50+
51+
# Wipe the current options and reset default values
52+
def set_default_options!
53+
@options = OpenStruct.new
54+
55+
@options.root_directory = Dir.getwd
56+
@options.skip_uncovered = false
57+
@options.bash_path = "/bin/bash"
58+
@options.mute = false
59+
end
60+
61+
# Passes off +respond_to?+ to {options} for missing methods
62+
def respond_to_missing?(*args)
63+
options.respond_to?(*args)
64+
end
65+
66+
# Dispatches missing methods to {options}
67+
def method_missing(method_name, *args, &block)
68+
options.send(method_name, *args, &block)
4669
end
4770

4871
private
@@ -60,17 +83,25 @@ def help(program_name)
6083

6184
def option_parser
6285
OptionParser.new do |opts|
63-
opts.program_name = "bashcov"
86+
opts.program_name = program_name
6487
opts.version = Bashcov::VERSION
6588
opts.banner = help opts.program_name
6689

6790
opts.separator "\nSpecific options:"
6891

6992
opts.on("-s", "--skip-uncovered", "Do not report uncovered files") do |s|
70-
@options.skip_uncovered = s
93+
options.skip_uncovered = s
7194
end
7295
opts.on("-m", "--mute", "Do not print script output") do |m|
73-
@options.mute = m
96+
options.mute = m
97+
end
98+
opts.on("--bash-path PATH", "Path to Bash executable") do |p|
99+
raise Errno::ENOENT, p unless File.file? p
100+
options.bash_path = p
101+
end
102+
opts.on("--root PATH", "Project root directory") do |d|
103+
raise Errno::ENOENT, d unless File.directory? d
104+
options.root_directory = d
74105
end
75106

76107
opts.separator "\nCommon options:"
@@ -86,6 +117,3 @@ def option_parser
86117
end
87118
end
88119
end
89-
90-
# Make sure default options are set
91-
Bashcov.set_default_options!

lib/bashcov/bash_info.rb

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
module Bashcov
2+
# Module exposing information concerning the installed Bash version
3+
# @note methods do not cache results because +bash_path+ can change at
4+
# runtime
5+
# @note receiver is expected to implement +bash_path+
6+
module BashInfo
7+
# @return [Array<String>] An array representing the components of
8+
# +BASH_VERSINFO+
9+
def bash_versinfo
10+
`#{bash_path} -c 'echo "${BASH_VERSINFO[@]}"'`.chomp.split
11+
end
12+
13+
# @return [Boolean] Whether Bash supports +BASH_XTRACEFD+
14+
def bash_xtracefd?
15+
bash_versinfo[0..1].join.to_i >= 41
16+
end
17+
18+
# @param [Integer] length the number of bytes to test; default 128
19+
# @return [Boolean] whether Bash supports a +PS4+ of at least a given
20+
# number of bytes
21+
# @see https://tiswww.case.edu/php/chet/bash/CHANGES
22+
# @note Item +i.+ under the +bash-4.2-release+ to +bash-4.3-alpha+ change
23+
# list notes that version 4.2 truncates +PS4+ if it is greater than 128
24+
# bytes.
25+
def truncated_ps4?(length = 128)
26+
ps4 = SecureRandom.base64(length)
27+
!`PS4=#{ps4} #{bash_path} 2>&1 1>&- -xc 'echo hello'`.start_with?(ps4)
28+
end
29+
end
30+
end

lib/bashcov/errors.rb

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
module Bashcov
2+
# Signals an error parsing Bash's debugging output.
3+
class XtraceError < ::RuntimeError
4+
# Will contain the coverages parsed prior to the error
5+
attr_reader :files
6+
7+
# @param [#to_s] message An error message
8+
# @param [Hash] files A hash containing coverage information
9+
def initialize(message, files)
10+
@files = files
11+
super(message)
12+
end
13+
end
14+
end

lib/bashcov/field_stream.rb

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
module Bashcov
2+
# Classes for streaming token-delimited fields
3+
class FieldStream
4+
attr_accessor :read
5+
6+
# @param [IO] read an IO object opened for reading
7+
def initialize(read = nil)
8+
@read = read
9+
end
10+
11+
# A convenience wrapper around +each_line(sep)+ that also does
12+
# +chomp(sep)+ on the yielded line.
13+
# @param [String, nil] delim the field separator for the stream
14+
# @return [void]
15+
# @yieldparam [String] field each +chomp+ed line
16+
def each_field(delim)
17+
return enum_for(__method__, delim) unless block_given?
18+
19+
read.each_line(delim) do |line|
20+
yield line.chomp(delim)
21+
end
22+
end
23+
24+
# Yields fields extracted from a input stream
25+
# @param [String, nil] delim the field separator
26+
# @param [Integer] field_count the number of fields to extract
27+
# @param [Regexp] start_match a +Regexp+ that, when matched against the
28+
# input stream, signifies the beginning of the next series of fields to
29+
# yield
30+
# @yieldparam [String] field each field extracted from the stream. If
31+
# +start_match+ is matched with fewer than +field_count+ fields yielded
32+
# since the last match, yields empty strings until +field_count+ is
33+
# reached.
34+
def each(delim, field_count, start_match)
35+
return enum_for(__method__, delim, field_count, start_match) unless block_given?
36+
37+
# Whether the current field is the start-of-fields match
38+
matched_start = nil
39+
40+
# The number of fields processed since passing the last start-of-fields
41+
# match
42+
seen_fields = 0
43+
44+
fields = each_field(delim)
45+
46+
# Close over +field_count+ and +seen_fields+ to yield empty strings to
47+
# the caller when we've already hit the next start-of-fields match
48+
yield_remaining = -> { (field_count - seen_fields).times { yield "" } }
49+
50+
# Advance until the first start-of-fields match
51+
loop { break if fields.next =~ start_match }
52+
53+
fields.each do |field|
54+
# If the current field is the start-of-fields match...
55+
if field =~ start_match
56+
# Fill out any remaining (unparseable) fields with empty strings
57+
yield_remaining.call
58+
59+
matched_start = nil
60+
seen_fields = 0
61+
elsif seen_fields < field_count
62+
yield field
63+
seen_fields += 1
64+
end
65+
end
66+
67+
# One last filling-out of empty fields if we're at the end of the stream
68+
yield_remaining.call
69+
70+
read.close unless read.closed?
71+
end
72+
end
73+
end

lib/bashcov/lexer.rb

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,23 @@
1+
require "bashcov/line"
2+
13
module Bashcov
24
# Simple lexer which analyzes Bash files in order to get information for
35
# coverage
46
class Lexer
57
# Lines starting with one of these tokens are irrelevant for coverage
6-
IGNORE_START_WITH = %w(# function)
8+
IGNORE_START_WITH = %w(# function).freeze
79

810
# Lines ending with one of these tokens are irrelevant for coverage
9-
IGNORE_END_WITH = %w|(|
11+
IGNORE_END_WITH = %w|(|.freeze
1012

1113
# Lines containing only one of these keywords are irrelevant for coverage
12-
IGNORE_IS = %w(esac if then else elif fi while do done { } ;;)
14+
IGNORE_IS = %w(esac if then else elif fi while do done { } ;;).freeze
1315

1416
# @param [String] filename File to analyze
1517
# @param [Hash] coverage Coverage with executed lines marked
1618
# @raise [ArgumentError] if the given +filename+ is invalid.
1719
def initialize(filename, coverage)
18-
@filename = File.expand_path(filename)
20+
@filename = filename
1921
@coverage = coverage
2022

2123
raise ArgumentError, "#{@filename} is not a file" unless File.file?(@filename)

0 commit comments

Comments
 (0)