How is this command used? Let’s check the comments at the top of the file.
“Usage” comments
# Usage: rbenv version-file-read <file>
We invoke it with a single argument, corresponding to a version file such as .ruby-version
or ${RBENV_ROOT}/version
:
$ rbenv version-file-read ~/.rbenv/version
2.7.5
$ rbenv version-file-read ./.ruby-version
3.0.0
Fun fact- you can also pass any arbitrary file that doesn’t contain a Ruby version, though this is not considered happy-path usage. You won’t get an error, but you also won’t get a Ruby version:
$ rbenv version-file-read README.md
#
$ rbenv version-file-read Gemfile
source
$ echo $? # prints the exit code of the last command you ran
0
Let’s move on to the test file.
Tests
After the bats
shebang and the loading of test_helper
, the first block of code is:
setup() {
mkdir -p "${RBENV_TEST_DIR}/myproject"
cd "${RBENV_TEST_DIR}/myproject"
}
This function makes a Ruby project directory and navigates into it. We’ve seen this setup
function before in other test files. This is a special bats
function, which gets called before each test case. You can also define a teardown
hook method, which gets called after each test case, though that isn’t done in this specific test file.
Passing no argument
Next block of code:
@test "fails without arguments" {
run rbenv-version-file-read
assert_failure ""
}
Our first test covers the sad-path case where no arguments are provided to the command. In this case, we expect the command to fail with no error output.
We can replicate this in the bash terminal:
bash-3.2$ rbenv version-file-read
bash-3.2$ echo "$?"
1
When the argument is not a real file
Next test:
@test "fails for invalid file" {
run rbenv-version-file-read "non-existent"
assert_failure ""
}
This test passes the name of a non-existent file to the version-file-read
command, and asserts that the command fails with no printed output.
When the argument is an empty file
Next test:
@test "fails for blank file" {
echo > my-version
run rbenv-version-file-read my-version
assert_failure ""
}
We create an empty version file via the echo > <new_filename>
command, and then we pass that filename to the version-file-read
command. The file is empty, so the command fails.
Reading a Ruby version
Next test:
@test "reads simple version file" {
cat > my-version <<<"1.9.3"
run rbenv-version-file-read my-version
assert_success "1.9.3"
}
This happy-path test begins by creating a valid version file named “my-version”, containing the string “1.9.3”. We then pass that filename to version-file-read
, and assert that the command passes and “1.9.3” is the printed output.
When the version file has leading spaces
Next test:
@test "ignores leading spaces" {
cat > my-version <<<" 1.9.3"
run rbenv-version-file-read my-version
assert_success "1.9.3"
}
This test is similar to the previous test, except the Ruby version string contained in the version file is prefixed with several space characters. The test asserts that the command is successful and that these extra spaces are trimmed off before the version is printed to STDOUT.
When the version file has more than one “word”
Next test:
@test "reads only the first word from file" {
cat > my-version <<<"1.9.3-p194@tag 1.8.7 hi"
run rbenv-version-file-read my-version
assert_success "1.9.3-p194@tag"
}
This test creates a version file with a valid Ruby version (1.9.3-p194@tag
), plus a 2nd version (1.8.7
) and some random text (hi
). We run the command with the name of the version file, and assert that only the first Ruby version is printed to the string. The 2nd valid version number and the random string are ignored.
When the version file has more than one line
Next test:
@test "loads only the first line in file" {
cat > my-version <<IN
1.8.7 one
1.9.3 two
IN
run rbenv-version-file-read my-version
assert_success "1.8.7"
}
This test is similar to the last one, except this Ruby version file contains a multi-line string and random text after each valid Ruby version. Here we assert that the command is successful, and that all text after the Ruby version (including the subsequent lines) are trimmed off.
When the first line of the file is blank
Next test:
@test "ignores leading blank lines" {
cat > my-version <<IN
1.9.3
IN
run rbenv-version-file-read my-version
assert_success "1.9.3"
}
This test again uses a multi-line heredoc, but this time the first line contains only a newline character. The test asserts that the command is successful and that the parser ignores this newline, and returns the correct version number anyway.
When the version file is missing a newline at the end
Next test:
@test "handles the file with no trailing newline" {
echo -n "1.8.7" > my-version
run rbenv-version-file-read my-version
assert_success "1.8.7"
}
With this test, we pass the -n
flag when echo
ing the expected version number to the new version file. This flag tells the shell to not append a trailing newline character to the file, which it usually would do. We then run the version-file-read
command and assert that it was successful, and that the expected version number was sent to STDOUT.
When the version number ends with a carriage return
Next test:
@test "ignores carriage returns" {
cat > my-version <<< $'1.9.3\r'
run rbenv-version-file-read my-version
assert_success "1.9.3"
}
This test asserts that the version-file-read
command trims off trailing \r
characters (aka carriage returns) before outputting the version number to STDOUT.
When the specified filepath includes directory traversal
Next test:
@test "prevents directory traversal" {
cat > my-version <<<".."
run rbenv-version-file-read my-version
assert_failure "rbenv: invalid version in \`my-version'"
cat > my-version <<<"../foo"
run rbenv-version-file-read my-version
assert_failure "rbenv: invalid version in \`my-version'"
}
Here we assert that strings which would normally cause directory traversal to happen (i.e. ..
and ../foo
) will trigger a failure in the version-file-read
command.
This test is related to this issue in the Github history, which describes a security vulnerability reported by another contributor. It looks like an earlier version of RBENV included the possibility of using a version of Ruby other than that intended by the user, if a malicious person was somehow able to modify the victim’s RBENV version file.
When a version file includes path segments
Next and last spec:
@test "disallows path segments in version string" {
cat > my-version <<<"foo/bar"
run rbenv-version-file-read my-version
assert_failure "rbenv: invalid version in \`my-version'"
}
This test was introduced in the same PR that introduced the previous test. It asserts that version-file-read
takes steps to prevent strings which resemble directories (such as foo/bar
) from being included in the version file that it reads. We create a version file with a string resembling a directory path as its contents, then pass that file to the command and assert that it fails with a helpful error message.
Onto the file itself.
Code
The usual first block of code:
set -e
[ -n "$RBENV_DEBUG" ] && set -x
- Set “exit-on-error” mode
- Set “verbose” mode when the user passes the
RBENV_DEBUG
variable
Storing the target file name
Next block of code:
VERSION_FILE="$1"
We store the first argument in a variable named VERSION_FILE
.
Testing if we have a non-empty file
Next block of code:
if [ -s "$VERSION_FILE" ]; then
...
fi
First we test if the value stored in VERSION_FILE
actually represents an existing file, and that file has some sort of content (this is what the -s
flag does). If so, we execute the code inside the if
block.
Reading the version number from the file
# Read the first word from the specified version file. Avoid reading it whole.
IFS="${IFS}"$'\r'
We modify the value of the internal field separator to be its original value, plus the carriage return. The "${IFS}"$'\r'
syntax can be thought of as "${IFS}"
plus $'\r'
. The 2nd half of this syntax is the Bash way of expanding escape sequences, as illustrated by this StackOverflow answer. For example:
$ echo $'Name\tAge\nBob\t24\nMary\t36'
Name Age
Bob 24
Mary 36
Next line of code:
read -n 1024 -d "" -r version _ <"$VERSION_FILE" || :
Let’s break this down into its pieces:
Reading the first 1024 characters
read -n 1024
Here we read up to the first 1024 characters of some input (source TBD).
According to help read
:
If -n is supplied with a non-zero NCHARS argument, read returns after NCHARS characters have been read.
Setting the delimiter for the read
operation
Next:
-d ""
According to this link:
With
-d ''
it sets the delimiter to'\0'
and makesread
read the whole input in one instance, and not just a single line.IFS=$'\n'
sets newline (\n
) as the separator for each value.__
is optional and gathers any extra input besides the first 3 lines.
We read the whole contents of the input source, not just the first line, and that the input which is read is then delimited according to the value of IFS
that we set on the previous line.
Disabling backslash escaping
-r
Again according to help read
:
If the -r option is given, this signifies `raw’ input, and backslash escaping is disabled.
So we ensure that the backslash character \
is treated literally and not as an escape character.
Storing the version number in a variable
version _
We store the first “word” we read (i.e. the version number) in the variable version
, and any remaining words we read in a throwaway variable named _
. The use of the underscore character is a convention (at least in Ruby) to indicate that the variable will not be used subsequently.
Specifying our input source
<"$VERSION_FILE"
We use the <
character to redirect the contents of $VERSION_FILE
to the input of the read
command.
Gracefully handling any errors
|| :
Lastly, this StackOverflow article mentions that the intention of || :
is to prevent the read
command from erroring out, or returning a non-successful exit code.
So to summarize the entire line of code:
- We take the first non-whitespace line from the input file.
- We trim any whitespace from that line.
- We trim anything after the
IFS
value (which now includes the carriage return character in addition to whatever its previous value was). - We store the remaining value as the version number, for use in subsequent lines of code.
Preventing directory traversal
Next block of code:
if [ "$version" = ".." ] || [[ $version == */* ]]; then
echo "rbenv: invalid version in \`$VERSION_FILE'" >&2
We check whether there is an attempt to traverse directories by using either ..
or a string which resembles a path, i.e. a string including a forward-slash. If either of these conditions returns true, we echo an error message to STDERR.
Note that there is no exit inside this conditional branch, the way there is in the subsequent elif
branch. We simply exit the if
condition and continue to the next line of code, which happens to be exit 1
(see below).
Printing the version number
Next block of code:
elif [ -n "$version" ]; then
echo "$version"
exit
fi
fi
If there’s a non-empty string stored in $version
, we simply echo that string and exit with a successful exit code.
Exiting if no version file was found
Last line of code in this file:
exit 1
We’d reach this line if one of the following things were true:
- there was no value set for
$version
. - there was a value but it was set to something resembling directory traversal.
In either case, we exit with a non-success return code.
On to the next file.