Bash – When does globstar descend into symlinked directories

bashwildcards

In this Ask Ubuntu post, I used globstar to locate a file when PATH is unset:

$ shopt -s globstar; for v in /**/vim; do [[ -x $v && -f $v ]] && echo "$v"; done
/etc/alternatives/vim
/usr/bin/vim
/usr/bin/X11/vim

Now that I think about it, that output seems a bit odd. /usr/bin/X11 is a symlink to /usr/bin:

$ readlink /usr/bin/X11
.

So, there's an infinite recursion of X11s there, but only the first of them turned up in the output. Weirdly, just a /usr/** doesn't descend into X11 at all:

$ printf "%s\n" /usr/bin/** | grep X11
/usr/bin/X11

How can the first and the last outputs be reconciled?


From comments:

I'm using Bash version 4.4.18(1) on Ubuntu 16.04.

Best Answer

tl;dr - Bash expansion is complicated to prevent infinite symlink loops (in bash >= 4.3), and you and I both misinterpreted what it was doing in the commands you posted

I assume you have bash >= 4.3 as I cannot reproduce what you describe in bash 4.2.46, it loops until it hits a recursion limit (as expected).

Stared at this for a while and set up a test directory to play in that imitaed your situation. The crux of this is how the bash expansion happens in each of your examples. The expansion behaves differently based on whether or not it is followed by a /, and there's just some cognitive dissonance on that point for us primates when looking at examples like this.
From the documentation for bash shopt:

globstar
If set, the pattern ‘**’ used in a filename expansion context will match all files and zero or more directories and subdirectories. If the pattern is followed by a ‘/’, only directories and subdirectories match.

To illustrate here's my test setup:

$ mkdir -p test/nested
$ cd test
$ touch sneaky
$ touch nested/sneaky
$ cd nested
$ ln -s . looper
$ cd ..

resulting in this directory structure:

test/
  - sneaky
  - nested/
    - sneaky
    - looper -> ./

This duplicates your findings in my test directory:

$ for apath in ../**/sneaky; do echo "$apath"; done   
../test/nested/looper/sneaky                                                                                                                                                                 
../test/nested/sneaky
../test/sneaky

$ printf "%s\n" ../** | grep sneaky
../test/nested/sneaky
../test/sneaky

In the first example, the glob expands to (test/nested/looper, test/nested, test), stopping at looper without following the link because the glob was followed by a /

We then append /sneaky to that, resulting in the set (test/nested/looper/sneaky, test/nested/sneaky, test/sneaky).

In the second example, the glob expands to (test/nested/looper, test/nested/sneaky, test/nested, test/sneaky, test) (which you can verify by removing the | grep sneaky)

Again, this expansion does not follow the looper link, but in this case we do not append /sneaky to it, thus dropping ../test/nested/looper/sneaky from our results.

On the other hand, we continue to get ../test/nested/sneaky and ../test/sneaky because the glob grabs files as well when it is not followed by a /