commit: cd06343865e358791e5b40c883224a5495f0143a
parent 85835133a4c2cd5a224bb91f2ba938e19d6a1317
Author: Haelwenn (lanodan) Monnier <contact@hacktivis.me>
Date: Fri, 1 Aug 2025 07:17:59 +0200
cmd/ln: correctly handle broken symlinks
O_PATH simplifies things a lot, for example it opens the symbolic
link when O_NOFOLLOW is passed.
Diffstat:
M | cmd/ln.c | 178 | ++++++++++++++++++++++++++++++++++++++++++++++++++----------------------------- |
M | test-cmd/ln.sh | 9 | ++++++++- |
2 files changed, 120 insertions(+), 67 deletions(-)
diff --git a/cmd/ln.c b/cmd/ln.c
@@ -2,34 +2,31 @@
// SPDX-FileCopyrightText: 2017 Haelwenn (lanodan) Monnier <contact+utils@hacktivis.me>
// SPDX-License-Identifier: MPL-2.0
-#define _POSIX_C_SOURCE 200809L
+#define _DEFAULT_SOURCE // due to O_PATH
+// Don't define _POSIX_C_SOURCE otherwise FreeBSD hides O_PATH
#include "../lib/bitmasks.h"
#include "../libutils/getopt_nolong.h"
-// NetBSD (9.3 and 10) hides symlink behind _XOPEN_SOURCE / _NETBSD_SOURCE
-#ifdef __NetBSD__
-#define _XOPEN_SOURCE 700
-#endif
-
#include <errno.h>
#include <fcntl.h> // linkat, AT_SYMLINK_FOLLOW
#include <libgen.h> // basename
#include <limits.h> // PATH_MAX
#include <stdbool.h>
-#include <stdio.h> // fprintf
-#include <string.h> // strerror
-#include <sys/stat.h>
-#include <unistd.h> // getopt, symlink, link
+#include <stdio.h> // fprintf
+#include <string.h> // strerror
+#include <sys/stat.h> // fstat
+#include <unistd.h> // getopt, symlink, link
const char *argv0 = "ln";
static bool opt_s = false, force = false;
static int link_flags = 0;
-static int open_dir_flags = O_RDONLY | O_DIRECTORY;
+static int open_dest_flags = O_RDONLY | O_PATH;
+static struct stat dest_stat;
static int
-do_link(char *src, char *dest)
+do_link(char *src, char *dest, int destfd)
{
if(opt_s)
{
@@ -56,52 +53,78 @@ do_link(char *src, char *dest)
}
}
- errno = 0;
- int dirfd = open(dest, open_dir_flags);
- if(dirfd < 0)
+ if(strcmp(src, dest) == 0)
{
- switch(errno)
- {
- case ENOENT:
- break;
- // ENOTDIR: Found but not a directory
- case ENOTDIR:
- // ELOOP: POSIX return code when O_NOFOLLOW encounters a symbolic link
- case ELOOP:
- // EMLINK: Same as ELOOP but FreeBSD *sigh*
- case EMLINK:
-#ifdef EFTYPE
- // EFTYPE: Same as ELOOP but NetBSD *grunt*
- case EFTYPE:
-#endif
- if(!force)
- {
- fprintf(stderr, "ln: error: Destination '%s' already exists\n", dest);
- return -1;
- }
+ fprintf(stderr, "ln: error: Path '%s' passed to both source and destination\n", src);
+ return 1;
+ }
- errno = 0;
+ errno = 0;
- dirfd = AT_FDCWD;
+ if(!force)
+ {
+ fprintf(stderr, "ln: error: Destination '%s' already exists\n", dest);
+ return -1;
+ }
- if(unlink(dest) < 0)
+ if(destfd < 0)
+ {
+ destfd = open(dest, open_dest_flags);
+ if(destfd < 0)
+ {
+ if(errno != ENOENT)
{
fprintf(stderr,
- "ln: error: Failed removing already existing destination '%s': %s\n",
+ "ln: error: Failed opening destination as path '%s': %s\n",
dest,
strerror(errno));
return -1;
}
- break;
- default:
+ }
+ else if(fstat(destfd, &dest_stat) < 0)
+ {
fprintf(stderr,
- "ln: error: Failed opening destination as directory '%s': %s\n",
+ "ln: error: Failed getting status for destination '%s': %s\n",
dest,
strerror(errno));
return -1;
}
}
+ int dirfd = AT_FDCWD;
+ if(S_ISDIR(dest_stat.st_mode))
+ {
+ dirfd = destfd;
+ }
+ else
+ {
+ /* check if symbolic/hard link refers to same file */
+ struct stat src_stat;
+ if(stat(src, &src_stat) < 0)
+ {
+ if(errno != ENOENT)
+ {
+ fprintf(
+ stderr, "ln: error: Failed getting status for source '%s': %s\n", src, strerror(errno));
+ return -1;
+ }
+ }
+ else if(src_stat.st_dev == dest_stat.st_dev && src_stat.st_ino == dest_stat.st_ino)
+ {
+ fprintf(stderr,
+ "ln: error: Source '%s' and destination '%s' refer to the same file\n",
+ src,
+ dest);
+ return -1;
+ }
+
+ if(unlink(dest) < 0)
+ {
+ fprintf(stderr, "ln: error: Failed unlinking destination '%s': %s\n", dest, strerror(errno));
+ return -1;
+ }
+ }
+
if(opt_s)
{
if(symlinkat(src, dirfd, dest) == 0) goto cleanup;
@@ -122,11 +145,14 @@ do_link(char *src, char *dest)
}
cleanup:
- if(dirfd == AT_FDCWD) return 0;
+ if(destfd < 0) return 0;
- if(close(dirfd) != 0)
+ if(close(destfd) != 0)
{
- fprintf(stderr, "ln: error: Failed closing directory '%s': %s\n", dest, strerror(errno));
+ fprintf(stderr,
+ "ln: error: Failed closing destination directory '%s': %s\n",
+ dest,
+ strerror(errno));
return -1;
}
@@ -137,8 +163,8 @@ static void
usage(void)
{
fprintf(stderr, "\
-Usage: ln [-fv] [-L|-P] source... target\n\
- ln -s [-fv] reference... target\n\
+Usage: ln [-fnv] [-L|-P] source... target\n\
+ ln -s [-fnv] reference... target\n\
");
}
@@ -155,7 +181,7 @@ main(int argc, char *argv[])
force = true;
break;
case 'n':
- FIELD_SET(open_dir_flags, O_NOFOLLOW);
+ FIELD_SET(open_dest_flags, O_NOFOLLOW);
break;
case 's':
opt_s = true;
@@ -179,8 +205,8 @@ main(int argc, char *argv[])
argc -= optind;
argv += optind;
- char *dest = argv[argc - 1];
- char target[PATH_MAX] = "";
+ char *target = argv[argc - 1];
+ char dest[PATH_MAX] = "";
if(argc <= 0)
{
@@ -189,25 +215,42 @@ main(int argc, char *argv[])
}
else if(argc == 1)
{
- dest = (char *)".";
+ target = (char *)".";
argc++;
}
else if(argc == 2)
{
- errno = 0;
- struct stat dest_status;
- int ret_stat = fstatat(AT_FDCWD, argv[1], &dest_status, AT_SYMLINK_NOFOLLOW);
- if(
- // clang-format off
- argc == 2 && (
- (ret_stat != 0 && errno == ENOENT) ||
- (ret_stat == 0 && !S_ISDIR(dest_status.st_mode))
- )
- // clang-format on
- )
+ int targetfd = open(target, open_dest_flags);
+ if(targetfd >= 0)
+ {
+ if(fstat(targetfd, &dest_stat) < 0)
+ {
+ fprintf(stderr,
+ "ln: error: Failed getting status for target '%s': %s\n",
+ dest,
+ strerror(errno));
+ return -1;
+ }
+
+ if(!S_ISDIR(dest_stat.st_mode))
+ {
+ int ret = do_link(argv[0], argv[1], targetfd);
+
+ return ret < 0 ? 1 : 0;
+ }
+
+ if(close(targetfd) < 0)
+ {
+ fprintf(stderr,
+ "ln: error: Failed closing target directory '%s': %s\n",
+ target,
+ strerror(errno));
+ return -1;
+ }
+ }
+ else
{
- errno = 0;
- int ret = do_link(argv[0], argv[1]);
+ int ret = do_link(argv[0], argv[1], -1);
return ret < 0 ? 1 : 0;
}
@@ -218,13 +261,16 @@ main(int argc, char *argv[])
char *src = argv[i];
char *src_basename = basename(src);
- if(snprintf(target, PATH_MAX, "%s/%s", dest, src_basename) < 0)
+ if(snprintf(dest, PATH_MAX, "%s/%s", target, src_basename) < 0)
{
- fprintf(stderr, "ln: error: Failed joining destination '%s' and target '%s'\n", dest, src);
+ fprintf(stderr,
+ "ln: error: Failed joining target '%s' and source basename '%s'\n",
+ target,
+ src_basename);
return 1;
}
- if(do_link(src, target) < 0) return 1;
+ if(do_link(src, dest, -1) < 0) return 1;
if(verbose) printf("'%s' -> '%s'\n", src, dest);
}
diff --git a/test-cmd/ln.sh b/test-cmd/ln.sh
@@ -2,7 +2,7 @@
# SPDX-FileCopyrightText: 2017 Haelwenn (lanodan) Monnier <contact+utils@hacktivis.me>
# SPDX-License-Identifier: MPL-2.0
-plans=55
+plans=59
WD=$(dirname "$0")
target="${WD}/../cmd/ln"
. "${WD}/tap.sh"
@@ -93,3 +93,10 @@ t_cmd '' '' rm -r e_target_dir e_src_dir
t_args 'implicit_dest' '' -sn //example.org
t_readlink ./example.org //example.org
t_cmd '' '' rm ./example.org
+
+t_args 'dest_broken_symlink:create' '' -s /var/empty/e/no/ent dest_broken_symlink
+t_args --exit=1 'dest_broken_symlink' "\
+ln: error: Destination 'dest_broken_symlink' already exists
+" -s //example.org dest_broken_symlink
+t_args 'dest_broken_symlink:force' '' -sf //example.org dest_broken_symlink
+t_cmd 'dest_broken_symlink:cleanup' '' rm dest_broken_symlink