Skip to content

PS4 with subshells causes erroneous extra hits to be registered #16

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 44 commits into from
Feb 9, 2016
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
ee5e5d0
Added Xtrace#realpath helper method
Jan 11, 2016
d810cca
Refactor realpath into a class method that does the lookup and an ins…
Jan 12, 2016
bd85f6c
Fixed rubocop offenses
Jan 12, 2016
63476f2
Implemented newline-separated PS4 output parsing
Jan 21, 2016
4989134
Implemented UUID-separated PS4 fields
Jan 21, 2016
67e0807
Updated .rubocop.yml to fix Style/TrailingComma deprecation error
Jan 21, 2016
b082e58
Merge branch 'master' into safe-ps4
Jan 21, 2016
460147c
Updated README with notes on Xtrace-parsing caveats and esoterica
Jan 23, 2016
d53db13
Reconfigured module's eigenclass to delegate to regular old instance
Jan 23, 2016
d5f13b3
Added error class for indicating failure to parse Xtrace output
Jan 23, 2016
e224937
Added exception handling clause to #run
Jan 23, 2016
c071c0e
Fixed YARD carping
Jan 23, 2016
bbf0e67
Xtrace#run now raises an exception when parsing errors are detected
Jan 23, 2016
e56d0d5
DRYed up temporary script testing with shared_context
Jan 23, 2016
073ddeb
Forgot to run rake before committing :(
Jan 23, 2016
a529aa2
Removed vestigial @todo [ci skip]
Jan 23, 2016
f893e0c
Reset $SHELLOPTS after test to avoid side effect flaky test
infertux Jan 12, 2016
1c7e70c
Merge branch 'master' into safe-ps4
Jan 23, 2016
27b3fe0
Applying rubocop fix patch [ci skip]
Jan 23, 2016
47911cc
inject_xtrace_flag! now executes a provided block/proc
Jan 24, 2016
2c27260
Refactored bashcov.rb to reinstantiate new delegate on #set_default_o…
Jan 24, 2016
2539e7a
Expanded comment in xtrace.rb
Jan 24, 2016
0ff7d0c
Added tests for new features
Jan 24, 2016
9060ed2
Merge branch 'safe-ps4' of github.com:BaxterStockman/bashcov into saf…
Jan 24, 2016
e5a2f94
commit to check what Travis is seeing when bashcov runs
Jan 24, 2016
0705146
darnit, rubocop
Jan 24, 2016
5856369
Changing line ending format in Xtrace#read
Jan 24, 2016
1bfe652
Removed redundant instance of Xtrace::DELIM from beginning of Xtrace:…
Jan 24, 2016
5ad4dfc
More output testing
Jan 24, 2016
2037468
Added DEBUG trap-based tracking -- WIP on FieldStream class [ci skip]
Jan 25, 2016
9cb87ca
FieldStream classes working on 2.2; fixing for 2.1
Jan 26, 2016
c45e674
All tests passing -- now for 100% coverage...
Jan 29, 2016
949dcb8
Implemented 4.2 fix. Still seeing intermittent test errors; probably…
Jan 30, 2016
bf8af04
Fixed up intermittent test failures caused by incautious messing with…
Jan 30, 2016
00df6f0
Added note about Bash 4.2 PS4 truncation bug
Jan 30, 2016
e060601
Added default @options.bash_path and made .truncated_ps4? more intell…
Jan 30, 2016
9eef89d
Added tests for FieldStream
Jan 30, 2016
4612a79
Merge branch 'safe-ps4-travis-failures' into safe-ps4
Jan 30, 2016
33d32e4
Fleshed out documentation of public methods to 100%
Jan 30, 2016
4811632
Moved 'delimited stream' context to support/shared_context.rb
Jan 31, 2016
0d5864b
Added .program_name method for use in printing warnings
Jan 31, 2016
ff27e24
Added .program_name method for use in printing warnings
Jan 31, 2016
8b9fc66
Fix for flaky tests when running with BASHCOV_BASH_PATH
Jan 31, 2016
66e02ad
Testing fixes -- one liners++
Feb 1, 2016
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ doc/
lib/bundler/man
pkg
rdoc
.rubocop-*
spec/reports
test/tmp
test/version_tmp
Expand Down
25 changes: 2 additions & 23 deletions .rubocop.yml
Original file line number Diff line number Diff line change
@@ -1,27 +1,6 @@
Metrics/AbcSize:
Enabled: false

Metrics/MethodLength:
Enabled: false

Metrics/LineLength:
Max: 120
Exclude: [spec/**/*]

Style/AccessModifierIndentation:
EnforcedStyle: outdent

Style/AndOr:
EnforcedStyle: conditionals

Style/SignalException:
EnforcedStyle: only_raise
inherit_from:
- https://gist.githubusercontent.com/infertux/cdd2ccc6e0a0cd94f458/raw

Style/SpecialGlobalVars:
Enabled: false

Style/StringLiterals:
EnforcedStyle: double_quotes

Style/TrailingComma:
Enabled: false
49 changes: 48 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,11 @@
[Dependencies]: https://gemnasium.com/infertux/bashcov "Bashcov dependencies on Gemnasium"
[Bashcov]: https://github.com/infertux/bashcov
[SimpleCov]: https://github.com/colszowka/simplecov "Bashcov is backed by SimpleCov to generate awesome coverage report"
[Test app demo]: http://infertux.github.com/bashcov/test_app/ "Coverage for the bundled test application"

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

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

## Installation
Expand Down Expand Up @@ -51,6 +52,52 @@ Open `./coverage/index.html` to browse the coverage report.
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)).
See [SimpleCov README](https://github.com/colszowka/simplecov#readme) for more information.

### Some gory details

Figuring out where an executing Bash script lives in the file system can be
surprisingly difficult. Bash offers a fair amount of [introspection into its
internals](https://www.gnu.org/software/bash/manual/html_node/Bash-Variables.html),
but the location of the current script has to be inferred from the limited
information available through `BASH_SOURCE`, `PWD`, and `OLDPWD` (and
potentially `DIRSTACK` if you are using `pushd`/`popd`). For this purpose,
Bashcov puts Bash in debug mode and sets up a `PS4` that expands the values of
these variables, reading them on each command that Bash executes. But, given
that:

* `BASH_SOURCE` is only an absolute path if the script was invoked using an
absolute path,
* The builtins `cd`, `pushd`, and `popd` alter `PWD` and `OLDPWD`, and
* None of these variables are read-only and can therefore be `unset` or
otherwise altered,

it can be easy to lose track of where we are.

"Wait a minute, what about `pwd`, `readlink`, and so on?" That would be great,
except that subshells executed as part of expanding the `PS4` can cause Bash to
report [extra executions](https://github.com/infertux/bashcov/commit/4130874e30a05b7ab6ea66fb96a19acaa973c178)
for [certain lines](https://github.com/infertux/bashcov/pull/16). Also,
subshells are slow, and the `PS4` is expanded on each and every command when
Bash is in debug mode.

To deal with these limitations, Bashcov uses the expedient of maintaining two
stacks that track changes to `PWD` and `OLDPWD`. To determine the full path to
the executing script, Bashcov iterates in reverse over the `PWD` stack, testing
for the first `$PWD/$BASH_SOURCE` combination that refers to an existing file.
This heuristic is susceptible to false positives -- under certain combinations
of directory stucture, script invocation paths, and working directory changes,
it may yield a path that doesn't refer to the currently-running script.
However, it performs well under the various working directory changes performed
in the [test app demo] and avoids the spurious extra hits caused by using
subshells in the `PS4`.

One final note on innards: Bash 4.3 fixed a bug in which `PS4` expansion is
truncated to a maximum of 128 characters. On platforms whose Bash version
suffers from this bug, Bashcov uses the ASCII record separator character to
delimit the `PS4` fields, whereas it uses a long random string on Bash 4.3 and
above. When the field delimiter appears in the path of a script under test or
in a command the script executes, Bashcov won't correctly parse the `PS4` and
will abort early with incomplete coverage results.

## Contributing

Bug reports and patches are most welcome.
Expand Down
84 changes: 56 additions & 28 deletions lib/bashcov.rb
Original file line number Diff line number Diff line change
@@ -1,48 +1,71 @@
require "optparse"
require "ostruct"
require "bashcov/version"
require "bashcov/lexer"
require "bashcov/line"
require "pathname"

require "bashcov/bash_info"
require "bashcov/runner"
require "bashcov/xtrace"
require "bashcov/version"

# Bashcov default module
# @note Keep it short!
module Bashcov
class << self
# @return [OpenStruct] Bashcov settings
attr_reader :options

# @return [String] The project's root directory
def root_directory
@root_directory ||= Dir.pwd
end
extend Bashcov::BashInfo

# Sets default options overriding any existing ones.
# @return [void]
def set_default_options!
@options ||= OpenStruct.new
@options.skip_uncovered = false
@options.mute = false
class << self
# @return [OpenStruct] The +OpenStruct+ object representing Bashcov's
# execution environment
def options
set_default_options! unless defined?(@options)
@options
end

# Parses the given CLI arguments and sets +options+.
# @param [Array] args list of arguments
# @raise [SystemExit] if invalid arguments are given
# @return [void]
def parse_options!(args)
option_parser.parse!(args)
begin
option_parser.parse!(args)
rescue OptionParser::ParseError, Errno::ENOENT => e
abort "#{option_parser.program_name}: #{e.message}"
end

if args.empty?
abort("You must give exactly one command to execute.")
else
@options.command = args.join(" ")
options.command = args.unshift(bash_path)
end
end

# @return [String] Program name
def program_name
"bashcov"
end

# @return [String] Program name including version for easy consistent output
def name
"bashcov v#{VERSION}"
# @note +fullname+ instead of name to avoid clashing with +Module.name+
def fullname
"#{program_name} v#{VERSION}"
end

# Wipe the current options and reset default values
def set_default_options!
@options = OpenStruct.new

@options.root_directory = Dir.getwd
@options.skip_uncovered = false
@options.bash_path = "/bin/bash"
@options.mute = false
end

# Passes off +respond_to?+ to {options} for missing methods
def respond_to_missing?(*args)
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pretty sure respond_to_missing? and method_missing should be private.

options.respond_to?(*args)
end

# Dispatches missing methods to {options}
def method_missing(method_name, *args, &block)
options.send(method_name, *args, &block)
end

private
Expand All @@ -60,17 +83,25 @@ def help(program_name)

def option_parser
OptionParser.new do |opts|
opts.program_name = "bashcov"
opts.program_name = program_name
opts.version = Bashcov::VERSION
opts.banner = help opts.program_name

opts.separator "\nSpecific options:"

opts.on("-s", "--skip-uncovered", "Do not report uncovered files") do |s|
@options.skip_uncovered = s
options.skip_uncovered = s
end
opts.on("-m", "--mute", "Do not print script output") do |m|
@options.mute = m
options.mute = m
end
opts.on("--bash-path PATH", "Path to Bash executable") do |p|
raise Errno::ENOENT, p unless File.file? p
options.bash_path = p
end
opts.on("--root PATH", "Project root directory") do |d|
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When would we need to set this by hand? Is there any usecase when we can't rely on Dir.getwd?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I found this a useful shortcut when making changes to Bashcov and testing them against shell scripts in other directories. But that's a pretty slender use case! TBH the reason I added this was because I needed to make the project root configurable in order to properly test the temporary scripts created by the test suite, and I figured that there'd be little harm in exposing the setting as a command line option.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds good, in fact I just remembered this feature was also suggested in #13 so let's keep it!

raise Errno::ENOENT, d unless File.directory? d
options.root_directory = d
end

opts.separator "\nCommon options:"
Expand All @@ -86,6 +117,3 @@ def option_parser
end
end
end

# Make sure default options are set
Bashcov.set_default_options!
30 changes: 30 additions & 0 deletions lib/bashcov/bash_info.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
module Bashcov
# Module exposing information concerning the installed Bash version
# @note methods do not cache results because +bash_path+ can change at
# runtime
# @note receiver is expected to implement +bash_path+
module BashInfo
# @return [Array<String>] An array representing the components of
# +BASH_VERSINFO+
def bash_versinfo
`#{bash_path} -c 'echo "${BASH_VERSINFO[@]}"'`.chomp.split
end

# @return [Boolean] Whether Bash supports +BASH_XTRACEFD+
def bash_xtracefd?
bash_versinfo[0..1].join.to_i >= 41
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm tempted to NOT support very old versions of Bash. 4.1 was shipped up to Debian Squeeze which was removed from security support this month.

In this case, it doesn't add many if branches nor much overhead complexity to maintain so I think it's fine to keep it for now. However I don't want Bashcov codebase to be twice as big just to support obsolete versions of Bash ;)

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Supporting older Bash versions is definitely a drag! My main motivation here is supporting CentOS 6.7, which ships with 4.1.2(1)-release.

end

# @param [Integer] length the number of bytes to test; default 128
# @return [Boolean] whether Bash supports a +PS4+ of at least a given
# number of bytes
# @see https://tiswww.case.edu/php/chet/bash/CHANGES
# @note Item +i.+ under the +bash-4.2-release+ to +bash-4.3-alpha+ change
# list notes that version 4.2 truncates +PS4+ if it is greater than 128
# bytes.
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Along the lines of my comment above, I don't think it's worth adding a lot of complexity to the codebase to work around one-off Bash bugs. Since you've already worked on it and Bash 4.2 is still widely used, it's okay to keep it of course. I'm just talking in general :)

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agree in full -- so vehemently so that I considered seeing whether it would be possible to modify the Travis build to install Bash 4.3 :).

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah I was thinking we should be able to do something like that with Travis build matrix by maybe listing Bash versions we wanna test against through environment variables. No need to do that now though I reckon.

def truncated_ps4?(length = 128)
ps4 = SecureRandom.base64(length)
!`PS4=#{ps4} #{bash_path} 2>&1 1>&- -xc 'echo hello'`.start_with?(ps4)
end
end
end
14 changes: 14 additions & 0 deletions lib/bashcov/errors.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
module Bashcov
# Signals an error parsing Bash's debugging output.
class XtraceError < ::RuntimeError
# Will contain the coverages parsed prior to the error
attr_reader :files

# @param [#to_s] message An error message
# @param [Hash] files A hash containing coverage information
def initialize(message, files)
@files = files
super(message)
end
end
end
73 changes: 73 additions & 0 deletions lib/bashcov/field_stream.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
module Bashcov
# Classes for streaming token-delimited fields
class FieldStream
attr_accessor :read

# @param [IO] read an IO object opened for reading
def initialize(read = nil)
@read = read
end

# A convenience wrapper around +each_line(sep)+ that also does
# +chomp(sep)+ on the yielded line.
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nitpick: should be each_line(delim) and chomp(delim)

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice catch; thanks!

# @param [String, nil] delim the field separator for the stream
# @return [void]
# @yieldparam [String] field each +chomp+ed line
def each_field(delim)
return enum_for(__method__, delim) unless block_given?

read.each_line(delim) do |line|
yield line.chomp(delim)
end
end

# Yields fields extracted from a input stream
# @param [String, nil] delim the field separator
# @param [Integer] field_count the number of fields to extract
# @param [Regexp] start_match a +Regexp+ that, when matched against the
# input stream, signifies the beginning of the next series of fields to
# yield
# @yieldparam [String] field each field extracted from the stream. If
# +start_match+ is matched with fewer than +field_count+ fields yielded
# since the last match, yields empty strings until +field_count+ is
# reached.
def each(delim, field_count, start_match)
return enum_for(__method__, delim, field_count, start_match) unless block_given?

# Whether the current field is the start-of-fields match
matched_start = nil

# The number of fields processed since passing the last start-of-fields
# match
seen_fields = 0

fields = each_field(delim)

# Close over +field_count+ and +seen_fields+ to yield empty strings to
# the caller when we've already hit the next start-of-fields match
yield_remaining = -> { (field_count - seen_fields).times { yield "" } }

# Advance until the first start-of-fields match
loop { break if fields.next =~ start_match }

fields.each do |field|
# If the current field is the start-of-fields match...
if field =~ start_match
# Fill out any remaining (unparseable) fields with empty strings
yield_remaining.call

matched_start = nil
seen_fields = 0
elsif seen_fields < field_count
yield field
seen_fields += 1
end
end

# One last filling-out of empty fields if we're at the end of the stream
yield_remaining.call

read.close unless read.closed?
end
end
end
10 changes: 6 additions & 4 deletions lib/bashcov/lexer.rb
Original file line number Diff line number Diff line change
@@ -1,21 +1,23 @@
require "bashcov/line"

module Bashcov
# Simple lexer which analyzes Bash files in order to get information for
# coverage
class Lexer
# Lines starting with one of these tokens are irrelevant for coverage
IGNORE_START_WITH = %w(# function)
IGNORE_START_WITH = %w(# function).freeze

# Lines ending with one of these tokens are irrelevant for coverage
IGNORE_END_WITH = %w|(|
IGNORE_END_WITH = %w|(|.freeze

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

# @param [String] filename File to analyze
# @param [Hash] coverage Coverage with executed lines marked
# @raise [ArgumentError] if the given +filename+ is invalid.
def initialize(filename, coverage)
@filename = File.expand_path(filename)
@filename = filename
@coverage = coverage

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