6
\$\begingroup\$

In a bash 3 script for OSX machines, I needed the functional equivalent of the realpath command, complete with support for the --relative-to and --relative-base options.

I would normally just install the homebrew coreutils formula, but I need this script to work when bootstrapping a new machine with no network or XCode available yet. So the code below includes a replacement implementation that's only used when the command is not already installed. Normal usage would be

for resolved_path in $(realpath [--relative-to=...] [--relative-base=...] [one or more paths]); do
    # ...
done

It's meant to be used as a sourced library (. realpathlib.bash), but if run directly with bash realpathlib.bash it runs a simple test suite.

I've been picking up bash best practices as I went along, but would love feedback on all aspects, such as naming conventions and other coding style aspects, techniques, problems I've overlooked, etc.

# shellcheck shell=bash
set -euo pipefail

_contains() {
    # return true if first argument is present in the other arguments
    local elem value

    value="$1"
    shift

    for elem in "$@"; do 
        if [[ $elem == "$value" ]]; then
            return 0
        fi
    done
    return 1
}

_canonicalize_filename_mode() {
    # resolve any symlink targets, GNU readlink -f style
    # where every path component except the last should exist and is
    # resolved if it is a symlink. This is essentially a re-implementation
    # of canonicalize_filename_mode(path, CAN_ALL_BUT_LAST).
    # takes the path to canonicalize as first argument

    local path result component seen
    seen=()
    path="$1"
    result="/"
    if [[ $path != /* ]]; then  # add in current working dir if relative
        result="$PWD"
    fi
    while [[ -n $path ]]; do
        component="${path%%/*}"
        case "$component" in
            '') # empty because it started with /
                path="${path:1}" ;;
            .)  # ./ current directory, do nothing
                path="${path:1}" ;;
            ..) # ../ parent directory
                if [[ $result != "/" ]]; then  # not at the root?
                    result="${result%/*}"      # then remove one element from the path
                fi
                path="${path:2}" ;;
            *)
                # add this component to the result, remove from path
                if [[ $result != */ ]]; then
                    result="$result/"
                fi
                result="$result$component"
                path="${path:${#component}}"
                # element must exist, unless this is the final component
                if [[ $path =~ [^/] && ! -e $result ]]; then
                    echo "$1: No such file or directory" >&2
                    return 1
                fi
                # if the result is a link, prefix it to the path, to continue resolving
                if [[ -L $result ]]; then
                    if _contains "$result" "${seen[@]+"${seen[@]}"}"; then
                        # we've seen this link before, abort
                        echo "$1: Too many levels of symbolic links" >&2
                        return 1
                    fi
                    seen+=("$result")
                    path="$(readlink "$result")$path"
                    if [[ $path = /* ]]; then
                        # if the link is absolute, restart the result from /
                        result="/"
                    elif [[ $result != "/" ]]; then
                        # otherwise remove the basename of the link from the result
                        result="${result%/*}"
                    fi
                elif [[ $path =~ [^/] && ! -d $result ]]; then
                    # otherwise all but the last element must be a dir
                    echo "$1: Not a directory" >&2
                    return 1
                fi
                ;;
        esac
    done
    echo "$result"
}

_realpath() {
    local relative_to relative_base seenerr path

    relative_to=
    relative_base=
    seenerr=

    while [[ $# -gt 0 ]]; do
        case $1 in
            "--relative-to="*)
                relative_to=$(_canonicalize_filename_mode "${1#*=}")
                shift 1;;
            "--relative-base="*)
                relative_base=$(_canonicalize_filename_mode "${1#*=}")
                shift 1;;
            *)
                break;;
        esac
    done

    if [[
        -n $relative_to
        && -n $relative_base
        && ${relative_to#${relative_base}/} == "$relative_to"
    ]]; then
        # relative_to is not a subdir of relative_base -> ignore both
        relative_to=
        relative_base=
    elif [[ -z $relative_to && -n $relative_base ]]; then
        # if relative_to has not been set but relative_base has, then
        # set relative_to from relative_base, simplifies logic later on
        relative_to="$relative_base"
    fi

    for path in "$@"; do
        if ! real=$(_canonicalize_filename_mode "$path"); then
            seenerr=1
            continue
        fi

        # make path relative if so required
        if [[
            -n $relative_to
            && ( # path must not be outside relative_base to be made relative
                -z $relative_base || ${real#${relative_base}/} != "$real"
            )
        ]]; then
            local common_part parentrefs

            common_part="$relative_to"
            parentrefs=
            while [[ ${real#${common_part}/} == "$real" ]]; do
                common_part="$(dirname "$common_part")"
                parentrefs="..${parentrefs:+/$parentrefs}"
            done

            if [[ $common_part != "/" ]]; then
                real="${parentrefs:+${parentrefs}/}${real#${common_part}/}"
            fi
        fi

        echo "$real"
    done
    if [[ $seenerr ]]; then
        return 1
    fi
}

if ! command -v realpath > /dev/null 2>&1; then
    # realpath is not available on OSX unless you install the `coreutils` brew
    realpath() { _realpath "$@"; }
fi

if [[ $0 == "${BASH_SOURCE[0]}" ]]; then
    assert_equal() {
        local result
        while read -r result; do
            if [[ $result != "$1" ]]; then
                echo -e "\033[0;31mFAIL\033[0m"
                echo -e "expected\n\t$1\ngot\n\t$result"
                exit 2
            fi
            shift 1
        done
        # any expected results left over?
        if [[ $# -gt 0 ]]; then
            echo -e "\033[0;31mFAIL\033[0m"
            echo "expected more results"
            printf '\t- %s\n' "$@"
            exit 2
        fi
        echo -e "\033[0;32mOK\033[0m"
    }

    testdir=$(mktemp -d -t "${0##*/}_tests")
    # canonicalize testdir with pwd -P (no .. components, so sufficient)
    pushd "$testdir"
    testdir=$(pwd -P)
    popd >/dev/null

    cleanup() {
        rm -rf "$testdir"
    }
    trap cleanup EXIT

    mkdir -p "$testdir/foo/bar/baz"
    mkdir -p "$testdir/foobar"
    touch "$testdir/target"
    touch "$testdir/foo/target"
    touch "$testdir/foo/bar/target"
    touch "$testdir/foobar/target"
    ln -s "../link" "$testdir/foo/bar/baz/link"
    ln -s "../link" "$testdir/foo/bar/link"
    ln -s "../target" "$testdir/foo/link"
    ln -s "circular2" "$testdir/foo/circular1"
    ln -s "circular1" "$testdir/foo/circular2"
    ln -s "../foo/bar" "$testdir/foobar/dirlink"

    echo -en "chained symlinks:\t"
    _realpath "$testdir/foo/bar/baz/link" \
        | assert_equal "$testdir/target"

    echo -en "circular symlinks:\t"
    { _realpath "$testdir/foo/circular1" 2>&1 || echo "error exit"; } \
        | assert_equal \
            "$testdir/foo/circular1: Too many levels of symbolic links" \
            "error exit"

    echo -en "symlink and .. combo:\t"
    _realpath "$testdir/foobar/dirlink/../target" \
        | assert_equal \
            "$testdir/foo/target"

    echo -en "non-existing path:\t"
    { _realpath "$testdir/nonesuch/foo" 2>&1 || echo "error exit"; } \
        | assert_equal \
            "$testdir/nonesuch/foo: No such file or directory" \
            "error exit"

    echo -en "file as directory:\t"
    { _realpath "$testdir/target/foo" 2>&1 || echo "error exit"; } \
        | assert_equal \
            "$testdir/target/foo: Not a directory" \
            "error exit"

    echo -en "relative paths:\t\t"
    pushd "$testdir/foo" > /dev/null
    _realpath \
            "bar/target" \
            "../target" \
            "target" \
            "$testdir/./foo/../foobar/target" \
        | assert_equal \
            "$testdir/foo/bar/target" \
            "$testdir/target" \
            "$testdir/foo/target" \
            "$testdir/foobar/target"
    popd > /dev/null

    echo -en "relative-base inside:\t"
    _realpath --relative-base="$testdir/foo" "$testdir/foo/bar/target" \
        | assert_equal "bar/target"

    echo -en "relative-base outside:\t"
    _realpath --relative-base="$testdir/foo/bar" "$testdir/foo/target" \
        | assert_equal "$testdir/foo/target"

    echo -en "--r-base name prefix:\t"
    _realpath --relative-base="$testdir/foo" "$testdir/foobar/target" \
        | assert_equal "$testdir/foobar/target"

    echo -en "--r-base extra /-s:\t"
    _realpath --relative-base="$testdir//foo//" "$testdir/foo/target" \
        | assert_equal "target"

    echo -en "--r-base relative:\t"
    pushd "$testdir/foo/bar" > /dev/null
    _realpath --relative-base="../../foo" "$testdir/foo/bar/target" \
        | assert_equal "bar/target"
    popd > /dev/null

    echo -en "multiple --r-base:\t"
    _realpath --relative-base="$testdir/foo" \
            "$testdir/foo/target" \
            "$testdir/target" \
            "$testdir/foo/bar/target" \
        | assert_equal \
            "target" \
            "$testdir/target" \
            "bar/target"

    echo -en "--r-base divergent:\t"
    _realpath --relative-base="$testdir" "/dev/null" \
        | assert_equal "/dev/null"

    echo -en "relative-to inside:\t"
    _realpath --relative-to="$testdir/foo" "$testdir/foo/bar/target" \
        | assert_equal "bar/target"

    echo -en "relative-to outside:\t"
    _realpath --relative-to="$testdir/foo/bar" "$testdir/target" \
        | assert_equal "../../target"

    echo -en "--r-to name prefix:\t"
    _realpath --relative-to="$testdir/foo" "$testdir/foobar/target" \
        | assert_equal "../foobar/target"

    echo -en "--r-to extra /-s:\t"
    _realpath --relative-to="$testdir//foo//" "$testdir/foo/target" \
        | assert_equal "target"

    echo -en "--r-to relative:\t"
    pushd "$testdir/foo" > /dev/null
    _realpath --relative-to="../foobar" "$testdir/foo/target" \
        | assert_equal "../foo/target"
    popd > /dev/null

    echo -en "multiple --r-to:\t"
    _realpath --relative-to="$testdir/foo" \
            "$testdir/foo/target" \
            "$testdir/target" \
            "$testdir/foo/bar/target" \
        | assert_equal \
            "target" \
            "../target" \
            "bar/target"

    echo -en "combined inside both:\t"
    _realpath --relative-base="$testdir" --relative-to="$testdir/foo" "$testdir/foo/bar/target" \
        | assert_equal "bar/target"

    echo -en "combined outside one:\t"
    _realpath --relative-base="$testdir" --relative-to="$testdir/foo/bar" "$testdir/target" \
        | assert_equal "../../target"

    echo -en "combined outside both:\t"
    _realpath --relative-base="$testdir/foo" --relative-to="$testdir/foo/bar" "$testdir/target" \
        | assert_equal "$testdir/target"

    echo -en "multiple combined:\t"
    _realpath --relative-base="$testdir/foo" --relative-to="$testdir/foo/bar" \
            "$testdir/foo/target" "$testdir/target" "$testdir/foo/bar/target" \
        | assert_equal \
            "../target" \
            "$testdir/target" \
            "target"

    echo -en "combined errorcase:\t"
    # -base should be a parent path of -to. If not, the arguments are ignored
    _realpath --relative-base="$testdir/foo/bar" --relative-to="$testdir/foo" "$testdir/foo/bar/target" \
        | assert_equal "$testdir/foo/bar/target"
fi

(Note: I have updated the code since posting to handle symlinks before '..' components in the path)

\$\endgroup\$
5
  • \$\begingroup\$ To me this seems like an XY problem. Why can't you just use the "Multiple installations" section to get homebrew? Ship the install script with the tarball and you can bootstrap from there. \$\endgroup\$ – Peilonrayz May 11 '20 at 23:23
  • \$\begingroup\$ @Peilonrayz that’d still require a network connection or a lot more work to package up all dependent bottles: discourse.brew.sh/t/installing-homebrew-without-internet/3321/4. Bootstrapping can’t always rely on a network connection. \$\endgroup\$ – Martijn Pieters May 12 '20 at 7:28
  • \$\begingroup\$ I wouldn't call 'compiling with a --build-from-source flag once and reusing the same pre-installed package' a lot more work. Seems like a lot less work then writing your own bootstrap ecosystem. With some use of chroot you could probably remove any need for the flag either. \$\endgroup\$ – Peilonrayz May 12 '20 at 10:15
  • \$\begingroup\$ @Peilonrayz: packaging up and maintaining a minimal network-less homebrew and xcode setup would be more work. This is ~150 lines (not counting tests), and was the only missing tool. \$\endgroup\$ – Martijn Pieters May 12 '20 at 10:51
  • \$\begingroup\$ I fail to see how this <20 lines script (not counting tests) I have is more work than maintaining a ~150 line script (not counting tests). Takes about 10 minutes to build the package and once built the 382M package runs even without internet. I guess I just lack the ability to do real bootstrapping. \$\endgroup\$ – Peilonrayz May 12 '20 at 12:44

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Browse other questions tagged or ask your own question.