Update:
As of at least macOS 13 (Ventura), macOS now also supports readlink -f
for following a symlink to its ultimate target (FreeBSD has had support for much longer), so the need for a POSIX-compliant solution has lessened considerably.
However, a POSIX-compliant solution - as implemented below - may still be of interest:
Below is a fully POSIX-compliant script / function that is therefore cross-platform (works on macOS too, whose readlink
still doesn't support -f
as of 10.12 (Sierra)) - it uses only POSIX shell language features and only POSIX-compliant utility calls.
It is a portable implementation of GNU's readlink -e
(the stricter version of readlink -f
).
You can run the script with sh
or source the function in bash
, ksh
, and zsh
:
For instance, inside a script you can use it as follows to get the running's script true directory of origin, with symlinks resolved:
trueScriptDir=$(dirname -- "$(rreadlink "$0")")
rreadlink
script / function definition:
The code was adapted with gratitude from this answer.
I've also created a bash
-based stand-alone utility version here, which you can install with
npm install rreadlink -g
, if you have Node.js installed.
#!/bin/sh
# SYNOPSIS
# rreadlink <fileOrDirPath>
# DESCRIPTION
# Resolves <fileOrDirPath> to its ultimate target, if it is a symlink, and
# prints its canonical path. If it is not a symlink, its own canonical path
# is printed.
# A broken symlink causes an error that reports the non-existent target.
# LIMITATIONS
# - Won't work with filenames with embedded newlines or filenames containing
# the string ' -> '.
# COMPATIBILITY
# This is a fully POSIX-compliant implementation of what GNU readlink's
# -e option does.
# EXAMPLE
# In a shell script, use the following to get that script's true directory of origin:
# trueScriptDir=$(dirname -- "$(rreadlink "$0")")
rreadlink() ( # Execute the function in a *subshell* to localize variables and the effect of `cd`.
target=$1 fname= targetDir= CDPATH=
# Try to make the execution environment as predictable as possible:
# All commands below are invoked via `command`, so we must make sure that
# `command` itself is not redefined as an alias or shell function.
# (Note that command is too inconsistent across shells, so we don't use it.)
# `command` is a *builtin* in bash, dash, ksh, zsh, and some platforms do not
# even have an external utility version of it (e.g, Ubuntu).
# `command` bypasses aliases and shell functions and also finds builtins
# in bash, dash, and ksh. In zsh, option POSIX_BUILTINS must be turned on for
# that to happen.
{ \unalias command; \unset -f command; } >/dev/null 2>&1
[ -n "$ZSH_VERSION" ] && options[POSIX_BUILTINS]=on # make zsh find *builtins* with `command` too.
while :; do # Resolve potential symlinks until the ultimate target is found.
[ -L "$target" ] || [ -e "$target" ] || { command printf '%s\n' "ERROR: '$target' does not exist." >&2; return 1; }
command cd "$(command dirname -- "$target")" # Change to target dir; necessary for correct resolution of target path.
fname=$(command basename -- "$target") # Extract filename.
[ "$fname" = '/' ] && fname='' # !! curiously, `basename /` returns '/'
if [ -L "$fname" ]; then
# Extract [next] target path, which may be defined
# *relative* to the symlink's own directory.
# Note: We parse `ls -l` output to find the symlink target
# which is the only POSIX-compliant, albeit somewhat fragile, way.
target=$(command ls -l "$fname")
target=${target#* -> }
continue # Resolve [next] symlink target.
fi
break # Ultimate target reached.
done
targetDir=$(command pwd -P) # Get canonical dir. path
# Output the ultimate target's canonical path.
# Note that we manually resolve paths ending in /. and /.. to make sure we have a normalized path.
if [ "$fname" = '.' ]; then
command printf '%s\n' "${targetDir%/}"
elif [ "$fname" = '..' ]; then
# Caveat: something like /var/.. will resolve to /private (assuming /var@ -> /private/var), i.e. the '..' is applied
# AFTER canonicalization.
command printf '%s\n' "$(command dirname -- "${targetDir}")"
else
command printf '%s\n' "${targetDir%/}/$fname"
fi
)
rreadlink "$@"
A tangent on security:
jarno, in reference to the function ensuring that builtin command
is not shadowed by an alias or shell function of the same name, asks in a comment:
What if unalias
or unset
and [
are set as aliases or shell functions?
The motivation behind rreadlink
ensuring that command
has its original meaning is to use it to bypass (benign) convenience aliases and functions often used to shadow standard commands in interactive shells, such as redefining ls
to include favorite options.
I think it's safe to say that unless you're dealing with an untrusted, malicious environment, worrying about unalias
or unset
- or, for that matter, while
, do
, ... - being redefined is not a concern.
There is something that the function must rely on to have its original meaning and behavior - there is no way around that.
That POSIX-like shells allow redefinition of builtins and even language keywords is inherently a security risk (and writing paranoid code is hard in general).
To address your concerns specifically:
The function relies on unalias
and unset
having their original meaning. Having them redefined as shell functions in a manner that alters their behavior would be a problem; redefinition as an alias is
not necessarily a concern, because quoting (part of) the command name (e.g., \unalias
) bypasses aliases.
However, quoting is not an option for shell keywords (while
, for
, if
, do
, ...) and while shell keywords do take precedence over shell functions, in bash
and zsh
aliases have the highest precedence, so to guard against shell-keyword redefinitions you must run unalias
with their names (although in non-interactive bash
shells (such as scripts) aliases are not expanded by default - only if shopt -s expand_aliases
is explicitly called first).
To ensure that unalias
- as a builtin - has its original meaning, you must use \unset
on it first, which requires that unset
have its original meaning:
unset
is a shell builtin, so to ensure that it is invoked as such, you'd have to make sure that it itself is not redefined as a function. While you can bypass an alias form with quoting, you cannot bypass a shell-function form - catch 22.
Thus, unless you can rely on unset
to have its original meaning, from what I can tell, there is no guaranteed way to defend against all malicious redefinitions.