r/bash • • 21h ago

Replacing dirname with only builtins

I am writing a bunch of scripts for which I want to only use builtins and realised that dirname is not a builtin, I replaced it thusly:

SRC_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SRC_DIR2="$({ cd "${BASH_SOURCE[0]%/*}" 2>/dev/null || :; } && pwd)"

I think these two lines are functionally equivalent, considering that BASH_SOURCE[n] will never contain a trailing /.

Then again I am often stupid. Does anyone see any issues?

4 Upvotes

10 comments sorted by

4

u/snarkofagen 21h ago

dirname solution fails if path doesn't exist , as it should

1

u/da_Aresinger 21h ago

good point. not a problem for my usecase though

1

u/jjangg96 21h ago

they differ for a script that sits directly in /. BASH_SOURCE[0] is /x.sh there, the %/* strips it down to an empty string, and cd "" succeeds without moving, so pwd prints your current directory instead of /. dirname gives / for that case. rare, but it's the one place they aren't equivalent.

the other thing is the || : swallowing errors. if the cd fails for any reason you silently get the current directory back and keep going, where the dirname version gives you an empty SRC_DIR and a non-zero status. fine if you want that, just know it's there.

1

u/da_Aresinger 20h ago

I think I fixed the root dir issue by simply appending a / to the parameter expansion:

SRC_DIR2="$({ cd "${BASH_SOURCE[0]%/*}/" 2>/dev/null || :; } && pwd)"
                                      ^

since cd doesn't care whether or not a path terminates in /

As for the second point: the only situations in which I can think of this being an issue are either

  • The script deletes its own directory before this line is called, which... don't do that, I guess?

  • Or a "race condition" where an external process deletes the directory between script invocation and this line. That is a risk I am willing to take.

1

u/jjangg96 17h ago

yeah the trailing slash fixes it, /x.sh becomes / and cd / works.

one case left is a bare name with no slash, like running bash x.sh from its own dir. nothing gets stripped so you cd into "x.sh/", that fails, the || : eats it, and pwd happens to give the right dir. so it works there, just by accident.

→ More replies (1)

1

u/roxalu 6h ago edited 6h ago

Not any difference between both implementation - but a small miss in both:

In case you also want to support edge cases, then replace

cd

with

cd --

Otherwise directory names, starting with "-" could cause both implementation to fail.

Edited: Missed, that even

dirname --

were needed in that case as well.

But to be true, this is a super rare edge case cause it isn't easy to start a script within a folder name starting with '-' in a way, so that the

${BASH_SOURCE[0]}

has a "-" as first character. I doubt any standard user would run into that error by chance, because already bash would complain first in most cases.

1

u/michaelpaoli 4h ago

No, if you want to replace dirname, you need work on the string, no cd, no pwd, etc. as dirname works regardless as to whether or not the path exists or is accessible.

$ cd $(dirname $(mktemp -u $(mktemp -u)/tmp.XXXXXXXXXX))
-bash: cd: /tmp/tmp.bLAwbN85vl: No such file or directory
$ 

So, how 'bout something like this (not a full reimplementation of dirname(1)):

$ expand -t 2 < dirname
#!/usr/bin/env bash

dirname(){
  [ $# -ge 1 ] || {
    1>&2 printf '%s\n' 'dirname: missing operand'
    exit 1
  }
  (
    for d in "$@"
    do
      # strip traling / characters that aren't first character:
      while :
      do
        case "$d" in
          *?*/)
            d="${d/%\//}"
          ;;
          *)
            break
          ;;
        esac
      done
      # strip traling non-/ characters:
      while :
      do
        case "$d" in
          *[!/])
            d="${d/%[!\/]/}"
          ;;
          *)
            break
          ;;
        esac
      done
      # strip traling / characters that aren't first character:
      while :
      do
        case "$d" in
          *?*/)
            d="${d/%\//}"
          ;;
          *)
            break
          ;;
        esac
      done
      [ -n "$d" ] || d=.
      printf '%s\n' "$d"
    done
  )
}

dirname "$@"
$ t='/abs/x rel/x /abs///x/// rel///x/// /one//two///three rone//two///three file file/ file/// file///. ./File .///////File ./File/// .///////File/// . / // /// //// /////. /////.///'
$ dirname $t | cmp - <(./dirname $t)
$ 

-2

u/bac0on 21h ago

I use this function, but every time I put it to the test, it breaks, its a start though:

shopt -s extglob

## Sanitize dirname.
__dirname__(){
    local -n __d__=$1
    local -- __a__=${2:-.}; __a__=${__a__%%+(/)}
    [[ $__a__ = '' ]] && __a__=/ || { \
        __a__=${__a__//+(\/)/\/} ; [[ $__a__ = */* ]] && __d__=${__a__%/*} || __d__=. ; \
    }
}

__dirname__ dirname "directory"

5

u/zeekar 19h ago

yuckbo. Why on Earth are you using namerefs instead of simple assignment via command substitution? You wouldn't need those ugly underscored var names if you didn't have to worry about name conflicts...