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


Reply via email to