This adds support for only resolving the basename of the specified paths. This is useful where you want to maintain the parent namespace, but resolve the final component, E.g. if you had this symlink:
/opt/app/current/bin/tool -> ../libexec/tool You may want to resolve to: /opt/app/current/libexec/tool Rather than the more specific: /opt/app/releases/2026-06-20/libexec/tool I.e. leave the "current" symlink in place, as you don't want to resolve it in this context. This is a prelim patch to test, so only updates realpath.c. A final version would probably implement this in canonicalize_filename_mode() through a CAN_FINAL_LINKS option. Link: https://github.com/coreutils/coreutils/issues/114 --- NEWS | 5 ++ doc/coreutils.texi | 8 +++ src/realpath.c | 109 ++++++++++++++++++++++++++++++++++++++++- tests/misc/realpath.sh | 24 +++++++++ 4 files changed, 145 insertions(+), 1 deletion(-) diff --git a/NEWS b/NEWS index 4ffd690de..9b22c5def 100644 --- a/NEWS +++ b/NEWS @@ -51,6 +51,11 @@ GNU coreutils NEWS -*- outline -*- Also %Qn is a newly supported format combination to quote file names, leaving the existing %n format for when quoting is not desired. +** New Features + + 'realpath' now supports -H,--resolve-basename, to recursively resolve + only a final symbolic link while preserving symlinks in parent directories. + ** Improvements 'df', 'du', 'ls', 'od', 'pr', and 'sort' now escape invalid arguments in error diff --git a/doc/coreutils.texi b/doc/coreutils.texi index a0b4cd9cf..c9d854ca9 100644 --- a/doc/coreutils.texi +++ b/doc/coreutils.texi @@ -14492,6 +14492,14 @@ Symbolic links are resolved in the specified file names, and they are resolved before any subsequent @samp{..} components are processed. This is the default mode of operation. +@optItem{realpath,-H,} +@optItemx{realpath,--resolve-basename,} +Resolve symbolic links only if they are the final component of the +specified file names. If the final component is a symbolic link, +resolve it recursively. Do not resolve symbolic links in parent +directories, so the resulting file names may still contain symbolic +links in parent components. + @optItem{realpath,-q,} @optItemx{realpath,--quiet,} Suppress diagnostic messages for specified file names. diff --git a/src/realpath.c b/src/realpath.c index 8ad0ba68a..9f7cf99c2 100644 --- a/src/realpath.c +++ b/src/realpath.c @@ -22,7 +22,10 @@ #include <sys/types.h> #include "system.h" +#include "areadlink.h" #include "canonicalize.h" +#include "eloop-threshold.h" +#include "filenamecat.h" #include "relpath.h" /* The official name of this program (e.g., no 'g' prefix). */ @@ -38,6 +41,7 @@ enum static bool verbose = true; static bool logical; +static bool resolve_basename; static bool use_nuls; static char const *can_relative_to; static char const *can_relative_base; @@ -47,6 +51,7 @@ static struct option const longopts[] = {"canonicalize", no_argument, NULL, 'E'}, {"canonicalize-existing", no_argument, NULL, 'e'}, {"canonicalize-missing", no_argument, NULL, 'm'}, + {"resolve-basename", no_argument, NULL, 'H'}, {"relative-to", required_argument, NULL, RELATIVE_TO_OPTION}, {"relative-base", required_argument, NULL, RELATIVE_BASE_OPTION}, {"quiet", no_argument, NULL, 'q'}, @@ -81,6 +86,9 @@ Print the resolved absolute file name.\n\ oputs (_("\ -m, --canonicalize-missing no path components need exist or be a directory\ \n\ +")); + oputs (_("\ + -H, --resolve-basename resolve symlinks only in the final component\n\ ")); oputs (_("\ -L, --logical resolve '..' components before symlinks\n\ @@ -110,11 +118,102 @@ Print the resolved absolute file name.\n\ exit (status); } +static bool +parent_directory_exists (char const *fname, int can_mode) +{ + if ((can_mode & CAN_MODE_MASK) == CAN_MISSING) + return true; + + char *dir = dir_name (fname); + struct stat sb; + bool ok = stat (dir, &sb) == 0; + if (ok) + { + if (! S_ISDIR (sb.st_mode)) + { + errno = ENOTDIR; + ok = false; + } + } + else if (errno == EOVERFLOW) + ok = true; + + int saved_errno = errno; + free (dir); + errno = saved_errno; + return ok; +} + +/* Dereference the basename of FNAME, but preserve any symlink spelling in + parent directories. Relative link values are resolved against the + previous link's parent directory, then cleaned without dereferencing + symlinks in parent directories. */ +static char * +realpath_resolve_basename (char const *fname, int can_mode) +{ + can_mode |= CAN_NOLINKS; + char *can_fname = canonicalize_filename_mode (fname, can_mode); + + for (idx_t num_links = 0; can_fname; num_links++) + { + if (! parent_directory_exists (can_fname, can_mode)) + { + int saved_errno = errno; + free (can_fname); + errno = saved_errno; + return NULL; + } + + char *linkname = areadlink_with_size (can_fname, 63); + if (!linkname) + { + if (errno == EINVAL + || (can_mode & CAN_MODE_MASK) != CAN_EXISTING) + return can_fname; + + int saved_errno = errno; + free (can_fname); + errno = saved_errno; + return NULL; + } + + if (__eloop_threshold () <= num_links) + { + free (linkname); + free (can_fname); + errno = ELOOP; + return NULL; + } + + char *next_name; + if (IS_ABSOLUTE_FILE_NAME (linkname)) + next_name = xstrdup (linkname); + else + { + char *dir = dir_name (can_fname); + next_name = file_name_concat (dir, linkname, NULL); + free (dir); + } + + free (linkname); + free (can_fname); + can_fname = canonicalize_filename_mode (next_name, can_mode); + int saved_errno = errno; + free (next_name); + errno = saved_errno; + } + + return NULL; +} + /* A wrapper around canonicalize_filename_mode(), to call it twice when in LOGICAL mode. */ static char * realpath_canon (char const *fname, int can_mode) { + if (resolve_basename) + return realpath_resolve_basename (fname, can_mode); + char *can_fname = canonicalize_filename_mode (fname, can_mode); if (logical && can_fname) /* canonicalize again to resolve symlinks. */ { @@ -208,7 +307,7 @@ main (int argc, char **argv) while (true) { - int c = getopt_long (argc, argv, "EeLmPqsz", longopts, NULL); + int c = getopt_long (argc, argv, "EeHLmPqsz", longopts, NULL); if (c == -1) break; switch (c) @@ -228,14 +327,22 @@ main (int argc, char **argv) case 'L': can_mode |= CAN_NOLINKS; logical = true; + resolve_basename = false; break; case 's': can_mode |= CAN_NOLINKS; logical = false; + resolve_basename = false; break; case 'P': can_mode &= ~CAN_NOLINKS; logical = false; + resolve_basename = false; + break; + case 'H': + can_mode |= CAN_NOLINKS; + logical = false; + resolve_basename = true; break; case 'q': verbose = false; diff --git a/tests/misc/realpath.sh b/tests/misc/realpath.sh index 56bd8597a..9ff2fb56e 100755 --- a/tests/misc/realpath.sh +++ b/tests/misc/realpath.sh @@ -35,6 +35,9 @@ test -d /dev || framework_failure_ mkdir -p dir1/dir2 || framework_failure_ ln -s dir1/dir2 ldir2 || framework_failure_ touch dir1/f dir1/dir2/f || framework_failure_ +ln -s f dir1/dir2/flink || framework_failure_ +ln -s flink dir1/dir2/flink2 || framework_failure_ +ln -s missing dir1/dir2/broken || framework_failure_ ln -s / one || framework_failure_ ln -s // two || framework_failure_ ln -s /// three || framework_failure_ @@ -59,9 +62,30 @@ returns_ 1 realpath --relative-base= --relative-to=. . || fail=1 # symlink resolution this=$(realpath .) +ln -s "$this/dir1/f" dir1/dir2/abslink || framework_failure_ test "$(realpath ldir2/..)" = "$this/dir1" || fail=1 test "$(realpath -L ldir2/..)" = "$this" || fail=1 test "$(realpath -s ldir2)" = "$this/ldir2" || fail=1 +test "$(realpath --resolve-basename ldir2/f)" = "$this/ldir2/f" \ + || fail=1 +test "$(realpath -H ldir2/flink)" = "$this/ldir2/f" || fail=1 +test "$(realpath --resolve-basename ldir2/flink)" = "$this/ldir2/f" \ + || fail=1 +test "$(realpath --resolve-basename ldir2/flink2)" = "$this/ldir2/f" \ + || fail=1 +test "$(realpath --resolve-basename ldir2/abslink)" = "$this/dir1/f" \ + || fail=1 +test "$(realpath --resolve-basename ldir2/broken)" \ + = "$this/ldir2/missing" || fail=1 +returns_ 1 realpath -e --resolve-basename ldir2/broken || fail=1 +returns_ 1 realpath --resolve-basename missing-dir/f || fail=1 +test "$(realpath -m --resolve-basename missing-dir/f)" \ + = "$this/missing-dir/f" || fail=1 +test "$(realpath -s --resolve-basename ldir2/flink)" = "$this/ldir2/f" \ + || fail=1 +test "$(realpath --resolve-basename -s ldir2/flink)" \ + = "$this/ldir2/flink" || fail=1 +test "$(realpath -H -s ldir2/flink)" = "$this/ldir2/flink" || fail=1 # relative string handling test $(realpath -m --relative-to=prefix prefixed/1) = '../prefixed/1' || fail=1 -- 2.54.0
