cd is not a program
Or rather, it is not a standalone executable. It is a shell builtin. Let me explain what that means and why it makes a difference.
Note: From now on, when I write shell I mean either bash or fish. I assume that most things are also true for most other shells, but I haven’t actually checked for any of them.
To explain the difference between a shell builtin and a standalone executable, let’s look at two concrete examples and how they are executed from the viewpoint of the shell. Let’s compare
cd some-dir
and
cat some-dir/some-file
cd
is a shell builtin, which means that it is a function in the shell code that is exposed to the user. So if we execute cd some-dir
, the shell can just call this function with some-dir
as an argument. On the other hand, cat
is a standalone executable. If we execute cat some-dir/some-file
, the shell first tries to find the absolute path to cat
using the PATH
variable; usually, it is located at /bin/cat
. It then creates a new process using the clone
or fork
system calls, and within the new process calls the execve
system call, passing it the absolute path to the command (/bin/cat
), the list of arguments (cat
and some-dir/some-file
), and the environment (more on the environment later). So calling a shell builtin is not only much more lightweight than calling an external executable, the builtin also has access to internals of the shell since it is called within the same process.
The cd
builtin is used to change the working directory of the shell. The concept of a working directory exists on the kernel level. Every process has one. On a Linux system you can see it in the /proc
file system: /proc/<pid>/cwd
is a symbolic link to the working directory of the process with id <pid>
. You can also see the working directory of the shell by executing pwd
(another shell builtin). The working directory is used by the kernel whenever the process wants to access a file or a directory with a relative path. For example, let’s look at
cat some-dir/some-file
again. When this is executed, cat
passes some-dir/some-file
to the open
system call (see the cat source code). The kernel then takes the current working directory of cat
, appends some-dir/some-file
, and tries to open that file (the details can be a bit more complicated since there might be symbolic links involved; the man page on path_resolution
has the full details).
When a process is created, it inherits the working directory from its parent. In the example, since cat
is created as a child process of the shell, it inherits the shell’s working directory. The shell in turn inherits its working directory from its parent, and so forth, all the way to the init process which has /
as a working directory (you can verify this by looking at /proc/1/cwd
). If that was all, then every process would have working directory /
, which would not be very useful. But a process can change its working directory with the chdir
system call. And that’s exactly what cd
does! (See for yourself in the bash source code and fish source code.)
In the cat
example, if we execute
cd /var && cat log/syslog
then cd /var
changes the working directory of the shell to /var
. Afterwards, the shell creates cat
as a child process and passes log/syslog
as an argument to it; cat
takes this argument and passes it to open
. cat
inherited /var
as a working directory from the shell, so when the kernel sees the relative path log/syslog
passed to open
, it prepends the working directory and tries to open /var/log/syslog
. In other words, this inheritance of the working directory means that everything magically works just as expected.
Note that a process can only change its own working directory. It has no control over the working directory of any other process (except its child processes). That is why cd
cannot be a standalone executable. The chdir
call has to be executed within the shell process. And that’s why cd
must be a shell builtin. (Note that this isn’t the case for all shell builtins; some of them could also be standalone executables. For example pwd
is a shell builtin, but it wouldn’t have to be. It could also be a standalone executable that reads the working directory of its parent process. In fact, there is the pidx
executable which shows the working directory of any process.)
So why does it make a difference whether cd
is a builtin or a standalone executable? Let me give three examples.
Unnecessary cd
in shell scripts
I occasionally see shell scripts which have a structure like this:
#!/bin/bash
cur_loc=$(pwd)
# ... main part of the script which includes some calls to cd ...
# return to original subdirectory
cd "$cur_loc"
The intention is that when the script is executed from the shell, make sure that we don’t end up in some random directory when the script returns. But this is completely unnecessary. When the script is executed, a new bash
process is created which has its own working directory. Any cd
in the script will only affect the working directory of the new bash
process; the parent process (our interactive shell from which we executed the script) is unaffected. So the last cd
is essentially a no-op: it changes the working directory of the bash
process, and immediately afterwards the process ends. It is not harmful either; but usually less code is better, especially if the code in question has no purpose, so we should just remove a trailing cd
.
Shortcuts to change a directory
Sometimes there might be a directory that you have to go to frequently. Especially if it is deeply nested, typing this out every time might be tedious. A tedious task in the shell is often something that can be simplified with a shell script. But we just saw that changing directories with a shell script is not possible. We have to execute the cd
within the current process of the shell. This is a good candidate for an alias. For example, if we realize that we often go to the directory /usr/share/fish/completions
, we can add an alias to the shell init files, like alias completions="cd /usr/share/fish/completions"
.
The CDPATH
variable
CDPATH
is a shell feature that changes the behavior of cd
. By default, if we execute
cd some-dir
then the shell will look for the directory some-dir
in its current working directory. If the directory exists, the shell changes its working directory to this subdirectory, otherwise it returns an error. This behavior can be changed with the CDPATH
variable. If it is nonempty, its contents is interpreted as a list of directories and those are used for the search path instead of the current working directory.
For example, assume that CDPATH
is set to /usr/local:/var/local
. If we now execute cd some-dir
, then the shell will first check whether /usr/local
has a subdirectory some-dir
, and if so, change its current working directory to /usr/local/some-dir
. Otherwise, it checks whether /var/local/
has a subdirectory some-dir
if so, it changes the current working directory to /var/local/some-dir
. If none of this was successful, both bash and fish then check whether some-dir
is a subdirectory of the current directory, and change the working directory to this subdirectory if it does (in other words, bash and fish implicitly add .
to the end of the CDPATH
list). Only if none of those directories exists cd
will fail.
I find CDPATH
incredibly useful. I keep all my projects in ~/projects
and set my CDPATH
to .:~/projects
. This way, I can get to any of my project directories from anywhere in the file system. Furthermore, fish supports tab completion across CDPATH
. So if my current working directory is /var/lib
and I type cd mb<tab>
, then fish will auto-complete this to cd my-blog
and take me to ~/projects/my-blog
.
But there is one potential problem with this feature, and that comes from the confusion of shell variables and environment variables (at least I was confused by this).
Environment variables
Just like the working directory, the environment is a concept that is defined on the kernel level. Every process has an environment; on Linux systems you can see it at /proc/<pid>/environ
. It is an array of pointers to strings. By convention the strings have the form key=value
, but this is not a requirement; these strings are interpreted as environment variables, in this case key
is the name of the variable and value
is its value. And just as the working directory, a process inherits the environment from its parent.
If a process executes another program with the execve
system call, it can change the environment for the process with the last parameter of the system call (the last e
stands for environment
, the v
stands for argument vector
). This is for example how env
works. env
allows you to execute a program with the environment of your choosing. It will compile the list of environment variables into an array and pass it (together with the program to execute and the arguments) to the execve
call.
Now to access the environment, a program will usually use the C standard library (either directly, or through a function in a higher level language that internally uses the C standard library). It provides access to the environment through the environ
variable and through the third argument of the main function int main(int argc, char *argv[], char *envp[])
(they initially point at the same array). It also provides the functions getenv
, setenv
, and putenv
, which are used to modify environ
. These functions do not however modify the environment that the kernel sees (and which we can see through /proc/<pid>/environ
). For example, if you use setenv
to add a new variable and call fork()
to create a child process, the child process will inherit the original environment which does not include the effects of the setenv
. In practice this is not really relevant, since the child will also inherit the environ
variable which does include the effects of the setenv
; so on a C standard library level, inheriting environments between parent and child works as expected. But it shows that there are two slightly different views on the environment.
The purpose of environment variables is to control the behavior of programs or libraries. For example, git
uses the EDITOR
environment variable to determine which editor to use to create commit messages. A common way to define environment variables is to create shell variables and to tell the shell that these variables should be added to the environment of any program that is executed.
Shell variables
Shell variables are like variables in any other programming language. They have a name and a value, and whenever we specify the name somewhere, the shell will replace it with the corresponding value. For example, if we have a shell variable named var
with value contents
and then execute
echo $var
the shell will replace $var
with contents
and execute echo contents
instead.
Some shell variables are already created when the shell is initialized; for example, bash creates BASH_VERSION
, and fish creates FISH_VERSION
. We can also create new variables, using NAME=VALUE
in bash or set NAME VALUE
in fish. And then there is another source for shell variables: the environment. When the shell is initialized, it reads environ
and makes its contents available as shell variables. This means that every environment variable is also a shell variable. That’s why we can access the HOME
environment variable as $HOME
.
Not only will the shell make environment variables available as shell variables during startup, we can also tell the shell to turn shell variables into environment variables for new processes. For a shell variable to become an environment variable for a child process, it needs to be marked as exported (for example, using export VARIABLE
in bash or set -x VARIABLE
in fish; the variables that come from environ
are automatically marked as exported). When we instruct the shell to execute another program (not a shell builtin), it compiles a list of all exported shell variables, and those define the environment for the executed program (it does this by executing the program with the execve
system call and passing it the exported shell variables as the environment parameter). So in the example above, if we want to define an EDITOR
environment variable for git
, we can do so by creating a shell variable EDITOR
and then exporting it.
Environment variables control the behavior of programs and libraries. Similarly, shell variables can control the behavior of the shell and its builtins. For example, the fc
bash builtin uses the EDITOR
variable to determine which editor to use to edit commands from the history list. This is similar to the git
example above. The difference is that fc
is a builtin, so EDITOR
does not have to be exported. Since fc
is a function within bash it has direct access to the shell variables, it doesn’t need environment variables. In fact, after it read the environ
variable during startup, it will not read any variables from the environment again, it will always use the shell variables instead.
So if we define EDITOR
as a (non-exported) shell variable, then fc
will see it and git
won’t. This might create confusion. To understand whether a variable needs to be exported or not, you need to know whether the command you want to be affected is a shell builtin or a program. It might be tempting to just export all variables so that any command can see it. But this is problematic, which brings us back to CDPATH
.
The problem with CDPATH
as an environment variable
On first thought it might make sense to export the CDPATH
variable. There exist similar PATH
and MANPATH
variables which need to be exported, and even the man page on environ
lists CDPATH
as an example of an environment variable. But cd
is not a program; it is a shell builtin. So as we just saw, it does not read CDPATH
from the environment, it uses it as a shell variable. This means that it is not only unnecessary to export CDPATH
, doing so can lead to undesired behavior and weird bugs as described in this blog post.
One problem is that when CDPATH
is set and cd
is called with a relative path that is not .
or ..
, then bash will output the absolute path of the new working directory. This leads to bugs in some bash scripts that try to get the parent directory of the script like this:
#!/bin/bash
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
This is intended to work like this: dirname "$0"
prints the (relative) path of the directory containing the script ($0
contains the name of the script that is called); the relative path is passed to cd
which changes the working directory of the subshell; then pwd
outputs the absolute path of the current directory, which is captured in SCRIPT_DIR
. This mostly works, unless the user has defined CDPATH
and exported it. Then it becomes an environment variable for any child of the shell. In particular, it is visible to the bash process that executes this script, which means that cd "$(dirname $0")"
might print the absolute path of the script. Since pwd
also prints the absolute path, $SCRIPT_DIR
now contains two lines, both containing the path, which will most likely lead to problems further down in the script.
Now this might be seen as a bug in the script which should guard against an exported CDPATH
. In fact, this Stack Overflow answer gives a more robust snippet to compute the parent directory of a bash script that guards against an exported CDPATH
and a range of other problems (although I think in a lot of cases it would be enough to get a relative path, so the snippet above could be replaced with SCRIPT_DIR=$(dirname "$0")
). But the truth is that not all shell scripts are written in a robust way, which makes a CDPATH
environment variable problematic.
Another problem comes with non-existing directories. Shellcheck warns against a line in a shell script that’s a single cd <somedir>
and advises to change it to cd <somedir> || exit
. Otherwise, there might be dangerous consequences if <somedir>
does not exist; for example some later line might be something like rm *
. The || exit
ensures that the shell script does not continue if the directory does not exist. But a CDPATH
environment variable can defeat the ||exit guard
: if <somedir>
does not exist in the working directory of the script but it does exist somewhere in the CDPATH
, then the cd
will still succeed and potentially remove files or do other destructive or at least unintended things.
So CDPATH
should not be exported. It is enough to define it as a shell variable, and since cd
is a shell builtin, it will be able to pick it up. Additionally, it should be prevented to be available to shell scripts. In bash, this can be accomplished by defining it in .bashrc
which is only read by interactive shells. In fish, the config files are also read by fish scripts, so here it is necessary to guard the CDPATH
definition as follows.
if status --is-interactive
set CDPATH <list of paths>
end
Other shell variables
CDPATH
is an especially problematic instance of a shell variable that shouldn’t be an environment variable, but there are other ones as well. For example, bash uses PS1
to control the prompt. This variable only makes sense in interactive shells. In fact, a common test to check whether bash is run interactively is to check whether $PS1
is defined. So this variable should not be exported either; but a quick search on GitHub shows that it isn’t uncommon to be exported in bashrc
s. Other variables like HISTSIZE
are not actively harmful when they are exported, but they pollute the environment of child processes (this is more of an aesthetic issue, like the trailing cd
in shell scripts).
So I think a good rule of thumb is to not export any variables, unless you are sure that they are used by another program or library.
Useful resources
I found it very helpful to look at the shell source code. The bash source code can be quite intimidating, the fish source code is much more accessible. But both of them were surprisingly easy to experiment with. For example, it took me about 10 minutes to hack in a new builtin to bash that prints out the contents of the environ
variable. (I wanted to find out whether bash updates environ
when an exported shell variable is changed; I couldn’t say for sure by just looking at the code. It turns out that bash updates environ
, whereas fish does not.)
I also found this Stack Exchange answer very helpful to understand the different views of the kernel and the C standard library on the environment.
—Written by Sebastian Jambor. Follow me on Mastodon @crepels@mastodon.social for updates on new blog posts.