Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 3 additions & 5 deletions src/runtime/procemu.c
Original file line number Diff line number Diff line change
Expand Up @@ -814,14 +814,12 @@ static int proc_parse_fd_index(const char *path,
size_t prefix_len,
int errno_on_invalid)
{
char *endp;
long n = strtol(path + prefix_len, &endp, 10);
if (endp == path + prefix_len || *endp != '\0' || n < 0 ||
n >= FD_TABLE_SIZE) {
int n = path_parse_proc_name(path + prefix_len);
if (n < 0 || n >= FD_TABLE_SIZE) {
errno = errno_on_invalid;
return -1;
}
return (int) n;
return n;
}

/* Map a guest /dev/shm/<name> path to its host backing path, and gate the name.
Expand Down
144 changes: 144 additions & 0 deletions src/syscall/path.c
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
#include "syscall/fuse.h"
#include "proved/pathdepth.h"

#include "syscall/internal.h" /* fd_to_host_dup */
#include "syscall/path.h"
#include "syscall/proc.h"

Expand Down Expand Up @@ -200,6 +201,116 @@ static int path_check_relative_sysroot_containment(guest_fd_t dirfd,
char *host_out,
size_t host_outsz);

int path_parse_proc_name(const char *name)
{
if (!name || !*name)
return -1;
/* Linux rejects a leading zero on any name longer than one character, so
* "0" names descriptor 0 but "00" and "03" name nothing.
*/
if (name[0] == '0' && name[1] != '\0')
return -1;

long n = 0;
for (const char *p = name; *p; p++) {
if (*p < '0' || *p > '9')
return -1;
n = n * 10 + (*p - '0');
if (n > INT_MAX)
return -1;
}
return (int) n;
}

/* Resolve an absolute fd magic link to the host path guest fd <n> is open on.
* This accepts "/proc/self/fd/<n>", the equivalent spelling with this process's
* own pid, and the /dev aliases Linux exposes as symlinks to procfs.
*
* Linux makes that a magic symlink, so a path-based syscall against it acts on
* the file the descriptor holds. It is the standard way to reach a file through
* an fd when no f*() variant applies -- systemd's fchmod_opath() chmods
* /proc/self/fd/<n> precisely because fchmod() rejects O_PATH descriptors, and
* reads ENOENT there as "this fd is not valid" (reporting EBADF) rather than as
* a missing file.
*
* Returns 1 and fills out on success, 0 when the path is not that shape or the
* descriptor has no host path (a pipe, socket, or anonymous fd, where F_GETPATH
* fails and the caller's own /proc intercepts remain the right answer).
*/
static int resolve_fd_magiclink_host_path(const char *path,
char *out,
size_t outsz)
{
const char *rest = NULL;

if (strncmp(path, "/proc/", 6) == 0) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The /proc magic-link shape is matched with exact strncmp against fixed prefixes, so non-canonical spellings that Linux normalizes — /proc/self//fd/3, /proc//self/fd/3, /proc/self/fd//3 — are not recognized and fall through to generic resolution, which fails on the host with ENOENT. This only affects redundant-separator spellings (glibc usually normalizes before syscalls, but raw syscalls can pass them), so it is low impact, but the fix leaves those spellings broken while the canonical form works.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/syscall/path.c, line 246:

<comment>The /proc magic-link shape is matched with exact strncmp against fixed prefixes, so non-canonical spellings that Linux normalizes — /proc/self//fd/3, /proc//self/fd/3, /proc/self/fd//3 — are not recognized and fall through to generic resolution, which fails on the host with ENOENT. This only affects redundant-separator spellings (glibc usually normalizes before syscalls, but raw syscalls can pass them), so it is low impact, but the fix leaves those spellings broken while the canonical form works.</comment>

<file context>
@@ -200,6 +201,106 @@ static int path_check_relative_sysroot_containment(guest_fd_t dirfd,
+{
+    const char *rest = NULL;
+
+    if (strncmp(path, "/proc/", 6) == 0) {
+        rest = path + 6;
+        if (!strncmp(rest, "self/", 5)) {
</file context>

rest = path + 6;
if (!strncmp(rest, "self/", 5)) {
rest += 5;
} else {
/* The pid component gets the same strict rules as the fd leaf:
* Linux resolves /proc/<pid> through name_to_int as well, so
* "/proc/+1234/fd/3" names nothing there even when 1234 is this
* process. A component too long for the buffer is not a pid either.
*/
const char *slash = strchr(rest, '/');
if (!slash)
return 0;
char pid_name[16];
if (path_component_copy(pid_name, sizeof(pid_name), rest,
(size_t) (slash - rest)) < 0)
return 0;
if (path_parse_proc_name(pid_name) != (int) proc_get_pid())
return 0;
rest = slash + 1;
}

if (strncmp(rest, "fd/", 3) != 0)
return 0;
rest += 3;
} else if (strncmp(path, "/dev/fd/", 8) == 0) {
rest = path + 8;
} else if (!strcmp(path, "/dev/stdin")) {
rest = "0";
} else if (!strcmp(path, "/dev/stdout")) {
rest = "1";
} else if (!strcmp(path, "/dev/stderr")) {
rest = "2";
} else {
return 0;
}

/* Only a bare descriptor number names the file itself. Anything trailing
* ("/proc/self/fd/3/x" or "/dev/fd/3/x") walks through it, which the host
* path cannot express here, and a leaf Linux would not accept as a procfs
* fd name is not this shape at all.
*/
int fd = path_parse_proc_name(rest);
if (fd < 0)
return 0;

/* Take a dup under fd_lock rather than the bare host fd: a sibling vCPU
* closing this slot between the lookup and F_GETPATH would leave the number
* free for the next open to claim, and the caller would then chmod or chown
* whatever file landed there.
*/
int host_fd = fd_to_host_dup(fd);
if (host_fd < 0)
return 0;

char resolved[MAXPATHLEN];
int rc = fcntl(host_fd, F_GETPATH, resolved);
close(host_fd);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: When the fd's file is renamed, unlinked, or replaced after F_GETPATH returns, the later path-based syscall uses a stale pathname and can fail with ENOENT or modify a different inode instead of the file held by the fd. Keep the duplicated descriptor alive through the operation or use an identity-preserving fd-backed operation.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/syscall/path.c, line 258:

<comment>When the fd's file is renamed, unlinked, or replaced after `F_GETPATH` returns, the later path-based syscall uses a stale pathname and can fail with `ENOENT` or modify a different inode instead of the file held by the fd. Keep the duplicated descriptor alive through the operation or use an identity-preserving fd-backed operation.</comment>

<file context>
@@ -200,6 +201,71 @@ static int path_check_relative_sysroot_containment(guest_fd_t dirfd,
+
+    char resolved[MAXPATHLEN];
+    int rc = fcntl(host_fd, F_GETPATH, resolved);
+    close(host_fd);
+    if (rc < 0)
+        return 0;
</file context>

@maxliu04002 maxliu04002 Aug 14, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please address this issue.

if (rc < 0)
return 0;

size_t len = strlen(resolved);
if (len >= outsz)
return 0;
memcpy(out, resolved, len + 1);
return 1;
}

int path_translate_at(guest_fd_t dirfd,
const char *path,
unsigned int flags,
Expand Down Expand Up @@ -265,6 +376,39 @@ int path_translate_at(guest_fd_t dirfd,
return 0;
}

/* Only host_path moves; guest_path and intercept_path keep the /proc
* spelling. open, stat and readlink never reach host_path for these paths:
* proc_intercept_open dups the descriptor, proc_intercept_stat fstats it,
* and proc_intercept_readlink reports its path, and none of the three fall
* through to the host on a fd magic link that names an open slot (a
* closed one fails as EBADF rather than falling through). What this changes
* is every other follow-style operation -- chmod, chown, utimensat,
* truncate, access -- which now acts on the file the descriptor holds, the
* way Linux does when it resolves the magic link.
*
* Returning before sysroot resolution is not a containment claim about the
* path: F_GETPATH reports where the descriptor's file actually lives, which
* is regularly outside the sysroot -- an emulated character device, a
* /dev/shm backing file, inherited stdio. Re-resolving one of those as a
* guest path would be wrong, since it is already a host path. Nothing is
* widened by it either: the guest holds the descriptor, so this reaches
* only what it could already reach through it.
*
* Follow-style only. Linux resolves the link for an operation that follows
* the final component and acts on the link itself otherwise, so a no-follow
* or create-style caller -- unlinkat, renameat, chmod with
* AT_SYMLINK_NOFOLLOW -- must not be handed the descriptor's file, or
* unlinkat("/proc/self/fd/<n>") would delete it instead of failing on the
* /proc entry.
*/
if (tx->guest_path[0] == '/' &&

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: This early return rewrites host_path for every follow-style absolute /proc/self/fd/ translation, not just the chmod/chown/utimensat family the PR targets. When the /proc open intercept does not serve the path (sys_openat_path falls through to open(tx.host_path) at fs.c:573), open/stat behavior changes from ENOENT to acting on the resolved file, contradicting the stated invariant that open/stat/readlink stay unchanged. Verify the /proc intercept always shadows these paths before relying on host_path-only scoping.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/syscall/path.c, line 348:

<comment>This early return rewrites host_path for every follow-style absolute /proc/self/fd/<n> translation, not just the chmod/chown/utimensat family the PR targets. When the /proc open intercept does not serve the path (sys_openat_path falls through to open(tx.host_path) at fs.c:573), open/stat behavior changes from ENOENT to acting on the resolved file, contradicting the stated invariant that open/stat/readlink stay unchanged. Verify the /proc intercept always shadows these paths before relying on host_path-only scoping.</comment>

<file context>
@@ -265,6 +331,28 @@ int path_translate_at(guest_fd_t dirfd,
+     * file, or unlinkat("/proc/self/fd/<n>") would delete it instead of failing
+     * on the /proc entry.
+     */
+    if (tx->guest_path[0] == '/' &&
+        !(flags & (PATH_TR_NOFOLLOW | PATH_TR_CREATE)) &&
+        resolve_proc_fd_host_path(tx->guest_path, tx->host_buf,
</file context>

!(flags & (PATH_TR_NOFOLLOW | PATH_TR_CREATE)) &&
resolve_fd_magiclink_host_path(tx->guest_path, tx->host_buf,
sizeof(tx->host_buf))) {
tx->host_path = tx->host_buf;
return 0;
}

unsigned int lookup_flags = flags;
if (path_has_trailing_slash(tx->guest_path))
lookup_flags &= ~PATH_TR_NOFOLLOW;
Expand Down
13 changes: 13 additions & 0 deletions src/syscall/path.h
Original file line number Diff line number Diff line change
Expand Up @@ -268,3 +268,16 @@ int path_openat2_crosses_mount(guest_fd_t dirfd,
* symlink-driven crossings that the string-only precheck misses by design.
*/
int path_openat2_check_fd_xdev(int guest_fd, int start_class);

/* Parse a numeric procfs component the way Linux's name_to_int() does: decimal
* digits only, so no sign, no leading whitespace, and no leading zero unless
* the name is "0" itself. The kernel runs both the pid and the fd component
* through it, so both get the same rules here. strtol() accepts all three
* spellings, which made "/proc/self/fd/+3", "/proc/self/fd/03", "/proc/self/fd/
* 3" and the matching pid forms resolve here while Linux reports ENOENT for
* each.
*
* Returns the value, or -1 when the name is not that shape. The caller applies
* its own upper bound and errno.
*/
int path_parse_proc_name(const char *name);
Loading