From 665b31897220380451b307b9e7bb879cfe6ac218 Mon Sep 17 00:00:00 2001 From: jotpalch Date: Thu, 15 Oct 2026 00:22:01 +0800 Subject: [PATCH 1/5] Rebuild a relative synthetic name in one place resolve_proc_dirfd_path, resolve_proc_cwd_path and path_rebase_hostdirfd each seed an absolute guest path from their base directory and apply the relative name's components to it. path_rebuild_under now does it for all three, so a change to how the join treats /dev/bus is made once. What any of them returns is unchanged. --- src/syscall/path.c | 63 +++++++++++++++++++++------------------------- 1 file changed, 29 insertions(+), 34 deletions(-) diff --git a/src/syscall/path.c b/src/syscall/path.c index 7ad64762..e3850ea1 100644 --- a/src/syscall/path.c +++ b/src/syscall/path.c @@ -1352,6 +1352,28 @@ static bool proc_path_fd_is_dir(const fd_entry_t *snap) return true; } +/* Rebuild @path against the guest directory @base as an absolute guest path, + * folding its components. + * + * Returns 0, or -1 with errno set to ENAMETOOLONG. + */ +static int path_rebuild_under(const char *base, + const char *path, + char *out, + size_t outsz) +{ + size_t marks[PROC_PATH_COMPONENTS_MAX]; + size_t depth; + if (proc_seed_absolute_path(base, out, outsz, marks, ARRAY_SIZE(marks), + &depth) < 0 || + proc_apply_components(path, out, outsz, marks, ARRAY_SIZE(marks), + &depth) < 0) { + errno = ENAMETOOLONG; + return -1; + } + return 0; +} + int resolve_proc_dirfd_path(guest_fd_t dirfd, const char *path, char *out, @@ -1387,16 +1409,8 @@ int resolve_proc_dirfd_path(guest_fd_t dirfd, return -1; } - size_t marks[PROC_PATH_COMPONENTS_MAX]; - size_t depth; - if (proc_seed_absolute_path(snap.proc_path, out, outsz, marks, - ARRAY_SIZE(marks), &depth) < 0 || - proc_apply_components(path, out, outsz, marks, ARRAY_SIZE(marks), - &depth) < 0) { - errno = ENAMETOOLONG; + if (path_rebuild_under(snap.proc_path, path, out, outsz) < 0) return -1; - } - return 1; } @@ -1414,24 +1428,12 @@ static int resolve_proc_cwd_path(const char *path, char *out, size_t outsz) * relative path measured against one has to be rebuilt as a guest path and * re-offered to the intercepts. Without the /sys and /dev/bus arms a cwd * set by fchdir() onto a synthetic USB directory would resolve relative - * names straight against the scratch tree. The component walk below is - * base-agnostic. + * names straight against the scratch tree. */ int rc = 0; if (!strncmp(view.path, "/proc", 5) || !strncmp(view.path, "/dev/pts", 8) || - !strncmp(view.path, "/sys", 4) || !strncmp(view.path, "/dev/bus", 8)) { - size_t marks[PROC_PATH_COMPONENTS_MAX]; - size_t depth; - if (proc_seed_absolute_path(view.path, out, outsz, marks, - ARRAY_SIZE(marks), &depth) < 0 || - proc_apply_components(path, out, outsz, marks, ARRAY_SIZE(marks), - &depth) < 0) { - errno = ENAMETOOLONG; - rc = -1; - } else { - rc = 1; - } - } + !strncmp(view.path, "/sys", 4) || !strncmp(view.path, "/dev/bus", 8)) + rc = path_rebuild_under(view.path, path, out, outsz) < 0 ? -1 : 1; proc_release_cwd_view(&view); return rc; @@ -1454,8 +1456,8 @@ int resolve_proc_at_path(guest_fd_t dirfd, /* Rebase a relative path against the host directory a descriptor points at, * producing the guest-visible absolute spelling: F_GETPATH names where the - * directory lives on the host, path_host_to_guest strips the sysroot, and the - * component walk folds "." and "..". + * directory lives on the host, path_host_to_guest strips the sysroot, and + * path_rebuild_under joins @rel to the result. * * This exists for relative walkers (systemd's chase() opens "/", then "sys", * then "bus", one openat per component) stepping from a host-backed directory @@ -1498,14 +1500,7 @@ int path_rebase_hostdirfd(int host_dirfd, char guest_dir[LINUX_PATH_MAX]; if (path_host_to_guest(host_dir, guest_dir, sizeof(guest_dir)) < 0) return 0; - size_t marks[PROC_PATH_COMPONENTS_MAX]; - size_t depth; - if (proc_seed_absolute_path(guest_dir, out, outsz, marks, ARRAY_SIZE(marks), - &depth) < 0 || - proc_apply_components(rel, out, outsz, marks, ARRAY_SIZE(marks), - &depth) < 0) - return 0; - return 1; + return path_rebuild_under(guest_dir, rel, out, outsz) < 0 ? 0 : 1; } bool path_openat2_stays_beneath(const char *path, bool clamp_at_root) From 0b7a89f2d868fe0cf96aea605d5262c387c50215 Mon Sep 17 00:00:00 2001 From: jotpalch Date: Thu, 15 Oct 2026 00:23:15 +0800 Subject: [PATCH 2/5] Name a /dev/bus descriptor by its directory A descriptor or cwd on a synthetic /dev/bus directory takes the guest's spelling as its name, so chdir("/dev/bus/usb/001/..") leaves getcwd reporting /dev/bus/usb/001/.., and /proc/self/fd/N repeats any '..' or trailing slash the open was spelled with. Linux names the directory the walk reached. usb_sysfs_guest_path_for_fd already asks a /sys descriptor what it holds, for the open stamp and for chdir; it now answers for the /dev/bus scratch tree the same way. --- src/runtime/usb-sysfs.c | 55 +++++++++++++++++++++++++++++------------ src/runtime/usb-sysfs.h | 16 ++++++------ src/syscall/fs.c | 12 +++++---- tests/test-usb-sysfs.c | 38 ++++++++++++++++++++++++++++ 4 files changed, 93 insertions(+), 28 deletions(-) diff --git a/src/runtime/usb-sysfs.c b/src/runtime/usb-sysfs.c index 3f0e240d..202c1c68 100644 --- a/src/runtime/usb-sysfs.c +++ b/src/runtime/usb-sysfs.c @@ -93,8 +93,9 @@ static char usb_sys_dir[64]; /* scratch root == /sys */ * spelling, or /tmp -> /private/tmp alone would fail it. */ static char usb_sys_real[PATH_MAX]; -static char usb_dev_dir[64]; /* scratch root == /dev/bus */ -static usb_dev_t *usb_devs; /* grown on demand; see USB_DEVS_INIT_CAP */ +static char usb_dev_dir[64]; /* scratch root == /dev/bus */ +static char usb_dev_real[PATH_MAX]; /* usb_dev_dir resolved, likewise */ +static usb_dev_t *usb_devs; /* grown on demand; see USB_DEVS_INIT_CAP */ static int usb_ndevs; static int usb_devs_cap; static pid_t usb_owner_pid; @@ -1057,6 +1058,15 @@ static int ensure_usb_tree(void) usb_sys_dir[0] = usb_dev_dir[0] = usb_sys_real[0] = '\0'; return -1; } + if (!realpath(usb_dev_dir, usb_dev_real)) { + int saved = errno; + usb_remove_tree(usb_sys_dir, 0); + usb_remove_tree(usb_dev_dir, 0); + usb_sys_dir[0] = usb_dev_dir[0] = usb_sys_real[0] = usb_dev_real[0] = + '\0'; + errno = saved; + return -1; + } model_build(); @@ -1108,7 +1118,7 @@ fail:; int saved = errno; usb_remove_tree(usb_sys_dir, 0); usb_remove_tree(usb_dev_dir, 0); - usb_sys_dir[0] = usb_dev_dir[0] = usb_sys_real[0] = '\0'; + usb_sys_dir[0] = usb_dev_dir[0] = usb_sys_real[0] = usb_dev_real[0] = '\0'; model_release(); errno = saved; return -1; @@ -1551,6 +1561,18 @@ static usb_path_kind_t classify_and_normalize(const char *path, return USB_PATH_SYS; } +static bool usb_root_contains(const char *root, const char *canonical) +{ + size_t rootlen = strlen(root); + return rootlen != 0 && !strncmp(canonical, root, rootlen) && + (canonical[rootlen] == '\0' || canonical[rootlen] == '/'); +} + +static bool usb_sys_contained(const char *canonical) +{ + return usb_root_contains(usb_sys_real, canonical); +} + /* Resolve a host path inside the sysfs scratch tree and prove the result is * still inside it, so the caller can open it with symlinks followed. * @@ -1572,13 +1594,6 @@ static usb_path_kind_t classify_and_normalize(const char *path, * Returns 0 with `out` filled, or -1 with errno set (realpath's errno, or * ENOENT for a path that resolved outside the tree). */ -static bool usb_sys_contained(const char *canonical) -{ - size_t rootlen = strlen(usb_sys_real); - return rootlen != 0 && !strncmp(canonical, usb_sys_real, rootlen) && - (canonical[rootlen] == '\0' || canonical[rootlen] == '/'); -} - static int usb_sys_resolve(const char *host_path, char *out, size_t outsz) { char resolved[PATH_MAX]; @@ -1780,12 +1795,20 @@ int usb_sysfs_guest_path_for_fd(int host_fd, char *out, size_t outsz) pthread_mutex_lock(&usb_lock); int rc = 0; - if (usb_sys_real[0] && usb_sys_contained(host_path)) { - /* usb_sys_contained proved the remainder after the root is either empty - * or starts with '/', so "/sys" plus it is the guest spelling. - */ - const char *rel = host_path + strlen(usb_sys_real); - int n = snprintf(out, outsz, "/sys%s", rel); + + /* Containment proved the remainder after the root is either empty or starts + * with '/', so the guest root plus it is the guest spelling. + */ + const char *root = NULL, *guest = NULL; + if (usb_root_contains(usb_sys_real, host_path)) { + root = usb_sys_real; + guest = "/sys"; + } else if (usb_root_contains(usb_dev_real, host_path)) { + root = usb_dev_real; + guest = "/dev/bus"; + } + if (root) { + int n = snprintf(out, outsz, "%s%s", guest, host_path + strlen(root)); rc = (n > 0 && (size_t) n < outsz) ? 1 : 0; } pthread_mutex_unlock(&usb_lock); diff --git a/src/runtime/usb-sysfs.h b/src/runtime/usb-sysfs.h index 55aae172..319fb53e 100644 --- a/src/runtime/usb-sysfs.h +++ b/src/runtime/usb-sysfs.h @@ -87,18 +87,20 @@ int usb_sysfs_resolve_guest_path(const char *guest_path, */ uint8_t *usb_sysfs_descriptors_dup(int busnum, int devnum, size_t *len_out); -/* The guest /sys spelling of the object an open descriptor holds, for stamping - * a synthetic identity on it. +/* The guest /sys or /dev/bus spelling of the object an open descriptor holds, + * for stamping a synthetic identity on it. * - * The name the guest opened is not always the name it got: a `subsystem` link + * The name the guest opened is not always the name it got: a subsystem link * opened for following names the directory it resolves to, and every relative * openat off that descriptor must walk from there. Asking the descriptor - * instead of the request is what makes the two agree -- F_GETPATH reports the + * instead of the request is what makes the two agree: F_GETPATH reports the * link's own path for the O_PATH|O_NOFOLLOW open that deliberately named the - * link, and the target's path for every open that followed it. + * link, and the target's path for every open that followed it. Under /dev/bus + * the request may spell a '..' the layer applied, and the descriptor names the + * directory it reached. * - * Returns 1 with `out` filled, or 0 when the descriptor is not in the sysfs - * tree (including when the tree does not exist). + * Returns 1 with @out filled, or 0 when the descriptor is in neither synthetic + * tree (including when the trees do not exist). */ int usb_sysfs_guest_path_for_fd(int host_fd, char *out, size_t outsz); diff --git a/src/syscall/fs.c b/src/syscall/fs.c index 5255fdcd..bc0cbf9f 100644 --- a/src/syscall/fs.c +++ b/src/syscall/fs.c @@ -963,12 +963,14 @@ int64_t sys_openat_path(guest_t *g, * walk off such a descriptor restart from the link's own directory * instead. Ask the descriptor what it holds; it names the link * itself for the O_PATH|O_NOFOLLOW open that asked for the link, - * and the target for every open that followed one. + * and the target for every open that followed one. Under /dev/bus + * the request may spell a '..' the layer applied, and the + * descriptor names the directory it reached. */ - char sys_stamp[LINUX_PATH_MAX]; - if (usb_sysfs_guest_path_for_fd(intercepted, sys_stamp, - sizeof(sys_stamp)) > 0) - stamp = sys_stamp; + char usb_stamp[LINUX_PATH_MAX]; + if (usb_sysfs_guest_path_for_fd(intercepted, usb_stamp, + sizeof(usb_stamp)) > 0) + stamp = usb_stamp; if (alias_fd >= 0) stamp = (aliased && alias_src.proc_path[0] != '\0') diff --git a/tests/test-usb-sysfs.c b/tests/test-usb-sysfs.c index d5317f80..88977be7 100644 --- a/tests/test-usb-sysfs.c +++ b/tests/test-usb-sysfs.c @@ -22,6 +22,9 @@ * reachable: followed on a plain open, ELOOP only when the caller asked for * O_NOFOLLOW, and readable as a target either way. * + * The /dev/bus name checks at the end use the fixture's devices, which the lane + * always provides. + * * A run with no devices is not a pass by default: the device count is printed, * so a lane that quietly stopped covering the second half is visible rather * than merely green. @@ -1147,6 +1150,40 @@ static int check_devices(void) return ndev; } +/* Whether /proc/self/fd/@fd reads back as @want. */ +static bool fd_names(int fd, const char *want) +{ + char link[32], buf[128]; + snprintf(link, sizeof(link), "/proc/self/fd/%d", fd); + ssize_t n = readlink(link, buf, sizeof(buf) - 1); + if (n < 0) + return false; + buf[n] = '\0'; + return !strcmp(buf, want); +} + +/* A /dev/bus directory reached through '..' is named by the directory in getcwd + * and in /proc/self/fd, as Linux names it, not by the spelling. + */ +static void check_dev_bus_names(void) +{ + char cwd[64]; + + TEST("getcwd after chdir /dev/bus/usb/001/.. is /dev/bus/usb"); + EXPECT_TRUE(chdir("/dev/bus/usb/001/..") == 0 && getcwd(cwd, sizeof(cwd)) && + !strcmp(cwd, "/dev/bus/usb"), + "chdir(\"/dev/bus/usb/001/..\"), then getcwd"); + if (chdir("/") != 0) + FAIL("chdir back to /"); + + TEST("/dev/bus/usb/001/../001/ opens as /dev/bus/usb/001"); + int fd = open("/dev/bus/usb/001/../001/", O_RDONLY | O_DIRECTORY); + EXPECT_TRUE(fd >= 0 && fd_names(fd, "/dev/bus/usb/001"), + "readlink of the descriptor"); + if (fd >= 0) + close(fd); +} + int main(void) { printf("test-usb-sysfs: synthetic USB tree contract\n"); @@ -1154,6 +1191,7 @@ int main(void) check_tree_contract(); check_slurp_reads_whole_files(); int ndev = check_devices(); + check_dev_bus_names(); /* Stated, not implied: the second half is only as strong as the bus it ran * against, and a zero here means those assertions did not execute. From 101d615c3f8c03a049c5db28ce125962ae54a54a Mon Sep 17 00:00:00 2001 From: jotpalch Date: Thu, 15 Oct 2026 00:27:55 +0800 Subject: [PATCH 3/5] Rewrite a '..' that climbs out of /dev/bus /dev/bus/../null answers ENOENT where Linux resolves /dev/null: the name leaves the layer, so it goes to the backing as written, and the backing cannot apply the '..'. The directories it climbs are the layer's, and the macOS host has no /dev/bus at all. path_translate_at now rewrites a /dev/bus name that climbs out over directories the layer serves, /dev/bus/usb and the bus directories that exist, to /dev followed by what comes after the climbing '..'. A further '..' is applied to /dev and stops at /, so /dev/bus/../../proc/self/status reaches the /proc intercept. A '..' after any other name leaves the walk to the backing, which answers ENOTDIR after a file as Linux does. The rewrite runs before the subsystem link rewrite, which a climb that lands in /sys still needs. When the climb ends the name, the rewrite ends in '.', as in /dev/., so rmdir and rename fail rather than act on the directory reached, with EINVAL on a sysroot where Linux gives ENOTEMPTY and EBUSY for a final '..'. The dev-climb and dev-climb-f matrix columns are recorded on Linux 7.0, where every existing cell records unchanged. Without the rewrite dev-climb fails on the 16 rows that spell the name in full, and without the usb_dev_names_dir check dev-climb-f fails on 16. test-usb-sysfs-sysroot gives its sysroot an empty /dev and checks that rmdir and rename of /dev/bus/.. leave it there, and that a climb through a subsystem link reads a file under the sysroot's /sys. --- docs/internals.md | 10 +++ mk/tests.mk | 2 +- src/runtime/usb-sysfs.c | 108 ++++++++++++++++++++++++------- src/runtime/usb-sysfs.h | 13 ++++ src/syscall/path.c | 15 +++++ tests/test-usb-sysfs-matrix.c | 27 ++++++-- tests/test-usb-sysfs-sysroot.c | 45 +++++++++++-- tests/test-usb-sysfs.c | 98 +++++++++++++++++++++++++++- tests/usb-sysfs-matrix-vectors.h | 48 +++++++------- 9 files changed, 307 insertions(+), 59 deletions(-) diff --git a/docs/internals.md b/docs/internals.md index 3adbc9b4..6f39c900 100644 --- a/docs/internals.md +++ b/docs/internals.md @@ -1687,6 +1687,16 @@ spelling instead splits the two halves apart in both directions: and `/dev/bus/other/../usb/001/002` reads as a foreign bus and is disowned. A suffix that folds away above its own root leaves as `USB_PATH_NONE`. +A backing cannot apply such a `..` on `/dev/bus`: the directories are the +layer's, and the macOS host has no `/dev/bus` at all. So a `/dev/bus` name that +climbs out over the layer's own directories is first rewritten in the path +layer to `/dev` followed by what comes after the `..` that climbed. A further +`..` is applied to `/dev` and stops at `/`, so `/dev/bus/../../proc/self/status` +reaches the `/proc` intercept. When the climb ends the name, the rewrite ends +in `.`, as in `/dev/.`, so `rmdir` and `rename` fail rather than act on the +directory reached, with `EINVAL` on a sysroot where Linux gives `ENOTEMPTY` and +`EBUSY` for a final `..`. + ### Filesystem Identity Of A Descriptor Two questions have one answer here: what `statfs` reports for a name, and what diff --git a/mk/tests.mk b/mk/tests.mk index 069972eb..49c2674d 100644 --- a/mk/tests.mk +++ b/mk/tests.mk @@ -1724,7 +1724,7 @@ test-usb-sysfs-sysroot: $(ELFUSE_BIN) $(TEST_DIR)/test-usb-sysfs-sysroot trap 'rm -rf "$$tmpdir"' EXIT; \ mkdir -p "$$sysroot/sys/class/net/eth0" \ "$$sysroot/sys/kernel/mm/transparent_hugepage" \ - "$$sysroot/sys/devices/system/node"; \ + "$$sysroot/sys/devices/system/node" "$$sysroot/dev"; \ printf '02:42:ac:11:00:02\n' > "$$sysroot/sys/class/net/eth0/address"; \ printf 'always [madvise] never\n' \ > "$$sysroot/sys/kernel/mm/transparent_hugepage/enabled"; \ diff --git a/src/runtime/usb-sysfs.c b/src/runtime/usb-sysfs.c index 202c1c68..941c8981 100644 --- a/src/runtime/usb-sysfs.c +++ b/src/runtime/usb-sysfs.c @@ -1254,28 +1254,44 @@ static bool bus_exists(int busnum) return false; } -/* Fold '.' and '..' in a /sys-relative suffix, lexically. - * - * This is the ours/not-ours gate and nothing more: a suffix that folds away - * above /sys is not a name this layer serves, and the caller reports - * PROC_NOT_INTERCEPTED for it. Rejecting '..' outright (the syscpu_suffix_safe - * contract) answered EACCES for /sys/bus/usb/devices/../devices/2-1, which - * Linux resolves without complaint, so this folds instead. - * - * The fold is NOT how the served path is built. An earlier version claimed the - * lexical fold was exact because `subsystem` links are always the last - * component -- that is false, `/subsystem/..` puts one in the middle, and - * Linux applies '..' to what the link resolved to (/sys/bus) rather than to the - * directory the link sits in. usb_sys_resolve_suffix does the real resolution; - * see it for how '..' and symlinks are ordered. - * - * A trailing slash survives the fold: it is what makes an attribute file used - * as a directory report ENOTDIR. +/* classify_path's answer for the /dev/bus-relative name @folded. */ +static usb_path_kind_t usb_dev_kind(const char *folded, int *bus) +{ + char path[LINUX_PATH_MAX]; + int dev = 0; + const char *sfx = ""; + int n = snprintf(path, sizeof(path), "/dev/bus/%s", folded); + if (n < 0 || (size_t) n >= sizeof(path)) + return USB_PATH_NONE; + return classify_path(path, bus, &dev, &sfx); +} + +/* Whether @folded names a directory the layer serves. Caller holds usb_lock + * after ensure_usb_tree(). + */ +static bool usb_dev_names_dir(const char *folded) +{ + int bus = 0; + usb_path_kind_t kind = usb_dev_kind(folded, &bus); + return kind == USB_PATH_DEV_USB || + (kind == USB_PATH_DEV_BUSNUM && bus_exists(bus)); +} + +/* Fold '.' and '..' in a /sys- or /dev/bus-relative suffix, lexically; a name + * that folds above its root is not this layer's. On /sys the fold decides only + * that, and usb_sys_resolve_suffix builds the served path. A trailing slash + * survives, so an attribute used as a directory reports ENOTDIR. * - * Returns 1 with out filled, 0 when the walk climbs above /sys (no longer ours; - * the caller reports PROC_NOT_INTERCEPTED), -1 when the result does not fit. + * Returns 1 with @out filled, 0 when the walk climbs above the root, -1 when + * the result does not fit. With @rest, under usb_lock after ensure_usb_tree(), + * a '..' pops only a directory usb_dev_names_dir accepts, and *@rest is left on + * what follows a climbing '..', or NULL when a '..' meets any other name. + * Without @rest no lock is held, and any other name is popped unchecked. */ -static int usb_suffix_normalize(const char *suffix, char *out, size_t outsz) +static int usb_suffix_normalize(const char *suffix, + char *out, + size_t outsz, + const char **rest) { size_t len = 0; size_t marks[64]; @@ -1292,8 +1308,15 @@ static int usb_suffix_normalize(const char *suffix, char *out, size_t outsz) if (seglen == 0 || (seglen == 1 && seg[0] == '.')) continue; if (seglen == 2 && seg[0] == '.' && seg[1] == '.') { - if (depth == 0) - return 0; /* above /sys */ + if (depth == 0) { + if (rest) + *rest = p; + return 0; /* above the root */ + } + if (rest && !usb_dev_names_dir(out)) { + *rest = NULL; + return 0; + } len = marks[--depth]; out[len] = '\0'; continue; @@ -1530,7 +1553,7 @@ static usb_path_kind_t classify_and_normalize(const char *path, if (path_prefix_match(path, "/dev/bus", 8)) { char folded[LINUX_PATH_MAX]; int frc = usb_suffix_normalize(skip_slashes(path + 8), folded, - sizeof(folded)); + sizeof(folded), NULL); if (frc < 0) { *err_out = ENAMETOOLONG; return USB_PATH_NONE; @@ -1551,7 +1574,7 @@ static usb_path_kind_t classify_and_normalize(const char *path, return USB_PATH_NONE; /* another bus's /dev/bus subtree; not ours */ if (kind != USB_PATH_SYS) return kind; - int rc = usb_suffix_normalize(*sfx_out, norm, normsz); + int rc = usb_suffix_normalize(*sfx_out, norm, normsz, NULL); if (rc < 0) { *err_out = ENAMETOOLONG; return USB_PATH_NONE; @@ -2295,6 +2318,43 @@ int usb_sysfs_resolve_guest_path(const char *guest_path, return rc; } +bool usb_sysfs_resolve_dev_climb(const char *guest_path, + char *out, + size_t outsz) +{ + if (!path_prefix_match(guest_path, "/dev/bus", 8) || + !strstr(guest_path, "..")) + return false; + char folded[LINUX_PATH_MAX]; + const char *rest = NULL; + pthread_mutex_lock(&usb_lock); + bool climbs = ensure_usb_tree() == 0 && + usb_suffix_normalize(skip_slashes(guest_path + 8), folded, + sizeof(folded), &rest) == 0 && + rest; + pthread_mutex_unlock(&usb_lock); + if (!climbs) + return false; + + /* A further '..' climbs /dev, and one past it stays at the root as on + * Linux; left in the name, it reaches the backing as /dev/.., where no + * intercept matches what it lands on. + */ + const char *dir = "/dev"; + while (rest[0] == '.' && rest[1] == '.' && + (rest[2] == '/' || rest[2] == '\0')) { + dir = ""; + rest = skip_slashes(rest + 2); + } + + /* When the climb ends the name, the rewrite ends in '.', so rmdir and + * rename fail rather than act on the directory reached, with EINVAL on a + * sysroot where Linux gives ENOTEMPTY and EBUSY for a final '..'. + */ + int n = snprintf(out, outsz, "%s/%s", dir, *rest ? rest : "."); + return n > 0 && (size_t) n < outsz; +} + bool usb_sysfs_dir_unions_backing(const char *guest_path) { int bus = 0, dev = 0, cerr = 0; diff --git a/src/runtime/usb-sysfs.h b/src/runtime/usb-sysfs.h index 319fb53e..c001d61c 100644 --- a/src/runtime/usb-sysfs.h +++ b/src/runtime/usb-sysfs.h @@ -75,6 +75,19 @@ int usb_sysfs_resolve_guest_path(const char *guest_path, char *out, size_t outsz); +/* Rewrite a /dev/bus name that climbs out of /dev/bus with '..', popping only + * directories the layer serves, to /dev followed by what comes after the '..' + * that climbed, or to /dev/. when nothing does. Those directories are the + * layer's, and the macOS host has no /dev/bus at all, so the '..' cannot be + * left to the backing; a walk through any other name is. A further '..' is + * applied to /dev and stops at the root. + * + * Returns true with @out filled, or false when nothing is rewritten. + */ +bool usb_sysfs_resolve_dev_climb(const char *guest_path, + char *out, + size_t outsz); + /* Malloc'd copy of the usbfs descriptors blob (18-byte little-endian device * descriptor followed by every raw configuration descriptor in index order) for * the device at busnum/devnum. This is the exact byte sequence read() returns diff --git a/src/syscall/path.c b/src/syscall/path.c index e3850ea1..e9c8fbf1 100644 --- a/src/syscall/path.c +++ b/src/syscall/path.c @@ -646,6 +646,21 @@ int path_translate_at(guest_fd_t dirfd, } } + /* usb_sysfs_resolve_dev_climb rewrites a /dev/bus name whose '..' climbs + * out of it. What follows can climb out again, as in + * /dev/bus/../bus/../null, and each pass shortens the name. A climb can + * land in /sys, so this runs before the /sys rewrite below. + */ + while (path_prefix_match(tx->guest_path, "/dev/bus", 8)) { + char resolved[LINUX_PATH_MAX]; + if (!usb_sysfs_resolve_dev_climb(tx->guest_path, resolved, + sizeof(resolved))) + break; + str_copy_trunc(tx->guest_buf, resolved, sizeof(tx->guest_buf)); + tx->guest_path = tx->guest_buf; + tx->intercept_path = tx->guest_buf; + } + /* A /sys walk that passes through one of the synthetic USB `subsystem` * symlinks is rewritten to the canonical guest spelling of where it lands, * before anything decides whose name it is. The links exist only in the diff --git a/tests/test-usb-sysfs-matrix.c b/tests/test-usb-sysfs-matrix.c index cb068e82..91cf54e8 100644 --- a/tests/test-usb-sysfs-matrix.c +++ b/tests/test-usb-sysfs-matrix.c @@ -6,8 +6,9 @@ * * Code under test: the ours/not-ours decision in src/runtime/usb-sysfs.c * (classify_and_normalize + usb_resolve_or_disown), the getdents64 union in - * src/syscall/fs.c, the access arm of sys_faccessat, and the fd-side sysfs - * identity in sys_fstatfs (src/syscall/fs-stat.c). + * src/syscall/fs.c, the access arm of sys_faccessat, the fd-side sysfs identity + * in sys_fstatfs (src/syscall/fs-stat.c), and the /dev/bus climb rewrite in + * path_translate_at (src/syscall/path.c). * * The layer synthesizes exactly one subtree on each side, /sys/bus/usb and * /dev/bus/usb, on top of a backing /sys and /dev/bus that a sysroot supplies. @@ -24,7 +25,7 @@ * * EXPECTED VALUES ARE MEASURED, NOT ASSUMED. Every cell below was recorded by * running this same binary natively on Linux (docker gcc:14, aarch64, kernel - * 6.x) with MATRIX_RECORD=1, over a /sys that is a real sysfs and a /dev/bus + * 7.0) with MATRIX_RECORD=1, over a /sys that is a real sysfs and a /dev/bus * carrying a mknod'd usb node next to a foreign bus directory. Re-record with: * * docker run --rm -v "$PWD:/w" -w /w gcc:14 sh -c \ @@ -96,14 +97,16 @@ enum { COL_FOLD_OUT, /* a '..' out of /dev/bus/usb onto a name the backing owns */ COL_FOLD_IN, /* a '..' out of a foreign bus and back into /dev/bus/usb */ COL_SYS_FOLD_IN, /* a '..' out of a backing /sys name and back into ours */ + COL_CLIMB, /* a '..' out of /dev/bus over its own directories */ + COL_CLIMB_FILE, /* a '..' out of /dev/bus after the backing's file */ COL_COUNT, }; static const char *col_name[COL_COUNT] = { - "synth-dir", "back-sys", "back-dev", "subsys", - "escape", "escape-syn", "usb-node", "absent", - "long-sys", "sys-root", "dev-bus", "shadow", - "subsys-out", "dev-fold-out", "dev-fold-in", "sys-fold-in", + "synth-dir", "back-sys", "back-dev", "subsys", "escape", + "escape-syn", "usb-node", "absent", "long-sys", "sys-root", + "dev-bus", "shadow", "subsys-out", "dev-fold-out", "dev-fold-in", + "sys-fold-in", "dev-climb", "dev-climb-f", }; /* COL_SUBSYS is the one spelling that cannot be shared: the recording host's @@ -171,6 +174,10 @@ static const char *col_path(int c) return "/dev/bus/other/../usb/001/001"; case COL_SYS_FOLD_IN: return "/sys/class/../bus/usb/devices"; + case COL_CLIMB: + return "/dev/bus/usb/001/../../.."; + case COL_CLIMB_FILE: + return "/dev/bus/other/f/../../../null"; default: return "/dev/bus"; } @@ -216,6 +223,12 @@ static const char *col_path(int c) * fixed the /dev one. */ +/* COL_CLIMB leaves /dev/bus through directories the layer serves and lands on + * /dev; its dirfd rows take the last '..' from a descriptor on /dev/bus. + * COL_CLIMB_FILE leaves after COL_BACK_DEV's regular file, which Linux answers + * ENOTDIR for, so a rewrite that trusts the lexical fold alone fails it. + */ + /* Names that must be listed by the union directories, one comma-free name per * entry, NULL-terminated. Empty for the columns where enumeration is not the * property under test. diff --git a/tests/test-usb-sysfs-sysroot.c b/tests/test-usb-sysfs-sysroot.c index c748375d..b9bb2798 100644 --- a/tests/test-usb-sysfs-sysroot.c +++ b/tests/test-usb-sysfs-sysroot.c @@ -4,16 +4,17 @@ * Copyright 2026 elfuse contributors * SPDX-License-Identifier: Apache-2.0 * - * Code under test: the /sys ours/not-ours split in src/runtime/usb-sysfs.c and - * the /sys + /dev/bus arms of sys_fchdir (src/syscall/fs.c) and - * resolve_proc_cwd_path (src/syscall/path.c). + * Code under test: the /sys ours/not-ours split in src/runtime/usb-sysfs.c, the + * /sys + /dev/bus arms of sys_fchdir (src/syscall/fs.c) and + * resolve_proc_cwd_path (src/syscall/path.c), and the /dev/bus climb rewrite in + * usb_sysfs_resolve_dev_climb and path_translate_at. * * Run under --sysroot over a tree that carries a real /sys skeleton * (/sys/class/net/eth0/address, /sys/kernel/mm/transparent_hugepage/enabled, * /sys/devices/system/node/online) and with ELFUSE_USB_FIXTURE set so the USB * tree carries two deterministic devices with no hardware attached. * - * Two regressions are pinned here. + * Three regressions are pinned here. * * F1: the USB layer synthesizes only /sys/bus/usb. A name it does not model * (everything under /sys/class, /sys/kernel, /sys/devices) must fall through to @@ -29,6 +30,10 @@ * lands in the read-only tree, and fstatfs reports the /tmp filesystem instead * of sysfs. The cubic behavior that must survive: a relative walk resolved * against that cwd still reaches the synthetic attributes. + * + * F3: a '..' out of /dev/bus that ends the name must not let rmdir or rename + * act on the sysroot's /dev, which the lane leaves empty, and one that lands in + * /sys must reach the sysroot's files there. */ #include @@ -228,6 +233,38 @@ int main(void) FAIL("relative 1-1/idVendor against the /sys cwd"); } + /* Linux refuses to remove or rename a final '..' (do_rmdir and do_renameat2 + * in fs/namei.c), whatever it resolves to. + */ + TEST("rmdir and rename of /dev/bus/.. leave /dev in place"); + { + struct stat st; + if (rmdir("/dev/bus/..") == 0 || + rename("/dev/bus/..", "/dev-moved") == 0) + FAIL("a final '..' out of /dev/bus was acted on"); + else if (stat("/dev", &st) != 0 || !S_ISDIR(st.st_mode)) + FAIL("stat(\"/dev\")"); + else + PASS(); + } + + /* path_translate_at runs usb_sysfs_resolve_dev_climb before + * usb_sysfs_resolve_guest_path, which applies a '..' after a subsystem link + * to the link's target. + */ + TEST( + "a climb out of /dev/bus through a subsystem link reaches the sysroot"); + { + ssize_t n = read_file( + "/dev/bus/../../sys/bus/usb/devices/1-1/" + "subsystem/../../class/net/eth0/address", + buf, sizeof(buf)); + if (n < 0 || strcmp(buf, "02:42:ac:11:00:02\n") != 0) + FAIL("read /sys/class/net/eth0/address through the climb"); + else + PASS(); + } + SUMMARY("test-usb-sysfs-sysroot"); return fails ? 1 : 0; } diff --git a/tests/test-usb-sysfs.c b/tests/test-usb-sysfs.c index 88977be7..86fbd80f 100644 --- a/tests/test-usb-sysfs.c +++ b/tests/test-usb-sysfs.c @@ -5,7 +5,8 @@ * SPDX-License-Identifier: Apache-2.0 * * Code under test: src/runtime/usb-sysfs.c, plus the sysfs statfs arms in - * src/syscall/fs-stat.c. + * src/syscall/fs-stat.c and the /dev/bus climb rewrite in path_translate_at + * (src/syscall/path.c). * * Two halves. The first asserts what holds with no USB device attached at all: * SYSFS_MAGIC from statfs and fstatfs alike, the read-only open contract, and @@ -1184,6 +1185,100 @@ static void check_dev_bus_names(void) close(fd); } +/* Whether @a and @b describe the same file. */ +static bool same_inode(const struct stat *a, const struct stat *b) +{ + return a->st_dev == b->st_dev && a->st_ino == b->st_ino; +} + +/* Whether @path names the same file as @want. */ +static bool same_file(const char *path, const struct stat *want) +{ + struct stat st; + return stat(path, &st) == 0 && same_inode(&st, want); +} + +/* A '..' out of /dev/bus lands in /dev however the name is reached, and a + * further '..' climbs on from there. + */ +static void check_dotdot_leaves_bus(void) +{ + static const char *const nulls[] = { + "/dev/bus/../null", "/dev/bus/../bus/../null", + "/dev/bus/.././bus/../null", "/dev/bus/../../dev/null", + "/dev/bus/../../../dev/null", "/dev/bus/../../dev/bus/../null", + }; + static const char *const climbs[][2] = { + {"/dev/bus/usb/001/../../..", "/dev"}, + {"/dev/bus/../..", "/"}, + {"/dev/bus/../../dev/bus/usb", "/dev/bus/usb"}, + {"/dev/bus/usb/001/../../../../sys/bus/usb/devices", + "/sys/bus/usb/devices"}, + {"/dev/bus/../../proc/self/status", "/proc/self/status"}, + {"/dev/bus/../../../proc/self/status", "/proc/self/status"}, + }; + struct stat dev_st, null_st, st; + char what[96], cwd[64]; + + TEST("/dev and /dev/null are stat-able"); + if (stat("/dev", &dev_st) != 0 || stat("/dev/null", &null_st) != 0) { + FAIL("stat"); + return; + } + PASS(); + + for (size_t i = 0; i < sizeof(nulls) / sizeof(nulls[0]); i++) { + snprintf(what, sizeof(what), "%s is /dev/null", nulls[i]); + TEST(what); + EXPECT_TRUE(same_file(nulls[i], &null_st), nulls[i]); + } + + TEST("/dev/bus/.. is /dev"); + EXPECT_TRUE(same_file("/dev/bus/..", &dev_st), "stat(\"/dev/bus/..\")"); + + for (size_t i = 0; i < sizeof(climbs) / sizeof(climbs[0]); i++) { + snprintf(what, sizeof(what), "%s is %s", climbs[i][0], climbs[i][1]); + TEST(what); + EXPECT_TRUE( + stat(climbs[i][1], &st) == 0 && same_file(climbs[i][0], &st), + climbs[i][0]); + } + + TEST("a '..' out through a bus no device has is ENOENT"); + EXPECT_ERRNO(stat("/dev/bus/usb/099/../../../null", &st), ENOENT, + "stat(\"/dev/bus/usb/099/../../../null\")"); + + TEST("../null against a /dev/bus dirfd is /dev/null"); + int dfd = open("/dev/bus", O_RDONLY | O_DIRECTORY); + EXPECT_TRUE(dfd >= 0 && fstatat(dfd, "../null", &st, 0) == 0 && + same_inode(&st, &null_st), + "fstatat(/dev/bus, \"../null\")"); + if (dfd >= 0) + close(dfd); + + TEST("chdir .. from /dev/bus reaches /dev"); + if (chdir("/dev/bus") != 0 || chdir("..") != 0) + FAIL("chdir /dev/bus, then .."); + else if (!getcwd(cwd, sizeof(cwd)) || strcmp(cwd, "/dev")) + FAIL("getcwd after chdir .."); + else if (!same_file("null", &null_st)) + FAIL("stat(\"null\") in /dev"); + else + PASS(); + if (chdir("/") != 0) + FAIL("chdir back to /"); + + TEST("chdir /dev/bus/usb/001/../../.. reaches /dev"); + if (chdir("/dev/bus/usb/001/../../..") != 0) + FAIL("chdir /dev/bus/usb/001/../../.."); + else if (!getcwd(cwd, sizeof(cwd)) || strcmp(cwd, "/dev")) + FAIL("getcwd after chdir /dev/bus/usb/001/../../.."); + else + PASS(); + if (chdir("/") != 0) + FAIL("chdir back to /"); +} + int main(void) { printf("test-usb-sysfs: synthetic USB tree contract\n"); @@ -1192,6 +1287,7 @@ int main(void) check_slurp_reads_whole_files(); int ndev = check_devices(); check_dev_bus_names(); + check_dotdot_leaves_bus(); /* Stated, not implied: the second half is only as strong as the bus it ran * against, and a zero here means those assertions did not execute. diff --git a/tests/usb-sysfs-matrix-vectors.h b/tests/usb-sysfs-matrix-vectors.h index d2028b93..f38f3643 100644 --- a/tests/usb-sysfs-matrix-vectors.h +++ b/tests/usb-sysfs-matrix-vectors.h @@ -4,7 +4,7 @@ * Copyright 2026 elfuse contributors * SPDX-License-Identifier: Apache-2.0 * - * Recorded on Linux, not reasoned about: docker gcc:14 (aarch64, kernel 6.x) + * Recorded on Linux, not reasoned about: docker gcc:14 (aarch64, kernel 7.0) * over a real sysfs, with /dev/bus/other/f created and /dev/bus/usb/001/001 * mknod'd as char 189:0. See the header comment in the test for the exact * command; MATRIX_RECORD=1 prints this block. @@ -46,6 +46,10 @@ * sys-fold-in /sys/class/../bus/usb/devices * synth-dir's directory, spelled through a /sys name only the * backing has + * dev-climb /dev/bus/usb/001/../../.. + * out of /dev/bus over the layer's directories + * dev-climb-f /dev/bus/other/f/../../../null + * the same, after back-dev's file * * Two markers appear in the table. * @@ -99,25 +103,25 @@ */ /* clang-format off */ -/* synth-dir back-sys back-dev subsys escape escape-syn usb-node absent long-sys sys-root dev-bus shadow subsys-out dev-fold-out dev-fold-in sys-fold-in */ -/* open */ {"ok", "ok", "ok", "ok", "ok", "?ok", "-", "E2", "ok", "ok", "ok", "E2", "ok", "ok", "-", "?ok"}, -/* open_nofollow */ {"ok", "ok", "ok", "E40", "ok", "?ok", "-", "E2", "ok", "ok", "ok", "E2", "ok", "ok", "-", "?ok"}, -/* openat_dirfd */ {"ok", "ok", "ok", "ok", "ok", "?ok", "-", "E2", "ok", "skip", "ok", "skip", "ok", "ok", "-", "?ok"}, -/* stat */ {"ok:d", "ok:d", "ok:f", "ok:d", "ok:f", "?ok:f", "ok:c", "E2", "ok:f", "ok:d", "ok:d", "E2", "ok:d", "ok:f", "ok:c", "?ok:d"}, -/* lstat */ {"ok:d", "ok:d", "ok:f", "ok:l", "ok:f", "?ok:f", "ok:c", "E2", "ok:f", "ok:d", "ok:d", "E2", "ok:d", "ok:f", "ok:c", "?ok:d"}, -/* fstatat_nofollow */ {"ok:d", "ok:d", "ok:f", "ok:l", "ok:f", "?ok:f", "ok:c", "E2", "ok:f", "ok:d", "ok:d", "E2", "ok:d", "ok:f", "ok:c", "?ok:d"}, -/* fstatat_dirfd */ {"ok:d", "ok:d", "ok:f", "ok:d", "ok:f", "?ok:f", "ok:c", "E2", "ok:f", "skip", "ok:d", "skip", "ok:d", "ok:f", "ok:c", "?ok:d"}, -/* statx */ {"ok:d", "ok:d", "ok:f", "ok:d", "ok:f", "?ok:f", "ok:c", "E2", "ok:f", "ok:d", "ok:d", "E2", "ok:d", "ok:f", "ok:c", "?ok:d"}, -/* access */ {"ok", "ok", "ok", "ok", "ok", "?ok", "ok", "E2", "ok", "ok", "ok", "E2", "ok", "ok", "ok", "?ok"}, -/* faccessat_nofollow */ {"ok", "ok", "ok", "ok", "ok", "?ok", "ok", "E2", "ok", "ok", "ok", "E2", "ok", "ok", "ok", "?ok"}, -/* readlink */ {"E22", "E22", "E22", "ok", "E22", "?E22", "E22", "E2", "E22", "E22", "E22", "E2", "E22", "E22", "E22", "?E22"}, -/* readlinkat_dirfd */ {"E22", "E22", "E22", "ok", "E22", "?E22", "E22", "E2", "E22", "skip", "E22", "skip", "E22", "E22", "E22", "?E22"}, -/* getdents64 */ {"ok", "ok", "E20", "ok", "E20", "?E20", "E20", "E2", "E20", "ok", "ok", "E2", "ok", "E20", "E20", "?ok"}, -/* statfs */ {"sysfs", "sysfs", "other", "sysfs","other","?other", "other", "E2", "sysfs", "sysfs", "other", "E2", "sysfs", "other", "other", "sysfs"}, -/* fstatfs */ {"sysfs", "sysfs", "other", "sysfs","other","?other", "other", "E2", "sysfs", "sysfs", "other", "E2", "sysfs", "other", "other", "?sysfs"}, -/* fstat_type */ {"ok:d", "ok:d", "ok:f", "ok:l", "ok:f", "?ok:f", "ok:c", "E2", "ok:f", "ok:d", "ok:d", "E2", "ok:d", "ok:f", "ok:c", "?ok:d"}, -/* chdir */ {"ok", "ok", "E20", "ok", "E20", "?E20", "E20", "E2", "E20", "ok", "ok", "E2", "ok", "E20", "E20", "?ok"}, -/* fchdir */ {"ok", "ok", "E20", "ok", "E20", "?E20", "E20", "E2", "E20", "ok", "ok", "E2", "ok", "E20", "E20", "?ok"}, -/* epoll_ctl */ {"E1", "E1", "E1", "E1", "E1", "?E1", "-", "E2", "ok", "E1", "E1", "E2", "E1", "E1", "-", "?E1"}, -/* union_listing */ {"n/a", "n/a", "n/a", "n/a", "n/a", "n/a", "n/a", "n/a", "n/a", "all", "all", "n/a", "n/a", "n/a", "n/a", "n/a"}, +/* synth-dir back-sys back-dev subsys escape escape-syn usb-node absent long-sys sys-root dev-bus shadow subsys-out dev-fold-out dev-fold-in sys-fold-in dev-climb dev-climb-f */ +/* open */ {"ok", "ok", "ok", "ok", "ok", "?ok", "-", "E2", "ok", "ok", "ok", "E2", "ok", "ok", "-", "?ok", "ok", "E20"}, +/* open_nofollow */ {"ok", "ok", "ok", "E40", "ok", "?ok", "-", "E2", "ok", "ok", "ok", "E2", "ok", "ok", "-", "?ok", "ok", "E20"}, +/* openat_dirfd */ {"ok", "ok", "ok", "ok", "ok", "?ok", "-", "E2", "ok", "skip", "ok", "skip", "ok", "ok", "-", "?ok", "ok", "skip"}, +/* stat */ {"ok:d", "ok:d", "ok:f", "ok:d", "ok:f", "?ok:f", "ok:c", "E2", "ok:f", "ok:d", "ok:d", "E2", "ok:d", "ok:f", "ok:c", "?ok:d", "ok:d", "E20"}, +/* lstat */ {"ok:d", "ok:d", "ok:f", "ok:l", "ok:f", "?ok:f", "ok:c", "E2", "ok:f", "ok:d", "ok:d", "E2", "ok:d", "ok:f", "ok:c", "?ok:d", "ok:d", "E20"}, +/* fstatat_nofollow */ {"ok:d", "ok:d", "ok:f", "ok:l", "ok:f", "?ok:f", "ok:c", "E2", "ok:f", "ok:d", "ok:d", "E2", "ok:d", "ok:f", "ok:c", "?ok:d", "ok:d", "E20"}, +/* fstatat_dirfd */ {"ok:d", "ok:d", "ok:f", "ok:d", "ok:f", "?ok:f", "ok:c", "E2", "ok:f", "skip", "ok:d", "skip", "ok:d", "ok:f", "ok:c", "?ok:d", "ok:d", "skip"}, +/* statx */ {"ok:d", "ok:d", "ok:f", "ok:d", "ok:f", "?ok:f", "ok:c", "E2", "ok:f", "ok:d", "ok:d", "E2", "ok:d", "ok:f", "ok:c", "?ok:d", "ok:d", "E20"}, +/* access */ {"ok", "ok", "ok", "ok", "ok", "?ok", "ok", "E2", "ok", "ok", "ok", "E2", "ok", "ok", "ok", "?ok", "ok", "E20"}, +/* faccessat_nofollow */ {"ok", "ok", "ok", "ok", "ok", "?ok", "ok", "E2", "ok", "ok", "ok", "E2", "ok", "ok", "ok", "?ok", "ok", "E20"}, +/* readlink */ {"E22", "E22", "E22", "ok", "E22", "?E22", "E22", "E2", "E22", "E22", "E22", "E2", "E22", "E22", "E22", "?E22", "E22", "E20"}, +/* readlinkat_dirfd */ {"E22", "E22", "E22", "ok", "E22", "?E22", "E22", "E2", "E22", "skip", "E22", "skip", "E22", "E22", "E22", "?E22", "E22", "skip"}, +/* getdents64 */ {"ok", "ok", "E20", "ok", "E20", "?E20", "E20", "E2", "E20", "ok", "ok", "E2", "ok", "E20", "E20", "?ok", "ok", "E20"}, +/* statfs */ {"sysfs", "sysfs", "other", "sysfs","other","?other", "other", "E2", "sysfs", "sysfs", "other", "E2", "sysfs", "other", "other", "sysfs", "other", "E20"}, +/* fstatfs */ {"sysfs", "sysfs", "other", "sysfs","other","?other", "other", "E2", "sysfs", "sysfs", "other", "E2", "sysfs", "other", "other", "?sysfs", "other", "E20"}, +/* fstat_type */ {"ok:d", "ok:d", "ok:f", "ok:l", "ok:f", "?ok:f", "ok:c", "E2", "ok:f", "ok:d", "ok:d", "E2", "ok:d", "ok:f", "ok:c", "?ok:d", "ok:d", "E20"}, +/* chdir */ {"ok", "ok", "E20", "ok", "E20", "?E20", "E20", "E2", "E20", "ok", "ok", "E2", "ok", "E20", "E20", "?ok", "ok", "E20"}, +/* fchdir */ {"ok", "ok", "E20", "ok", "E20", "?E20", "E20", "E2", "E20", "ok", "ok", "E2", "ok", "E20", "E20", "?ok", "ok", "E20"}, +/* epoll_ctl */ {"E1", "E1", "E1", "E1", "E1", "?E1", "-", "E2", "ok", "E1", "E1", "E2", "E1", "E1", "-", "?E1", "E1", "E20"}, +/* union_listing */ {"n/a", "n/a", "n/a", "n/a", "n/a", "n/a", "n/a", "n/a", "n/a", "all", "all", "n/a", "n/a", "n/a", "n/a", "n/a", "n/a", "n/a"}, /* clang-format on */ From 392e823861c804f53cb5eb0ce990612cae564505 Mon Sep 17 00:00:00 2001 From: jotpalch Date: Thu, 15 Oct 2026 11:34:39 +0800 Subject: [PATCH 4/5] Stop folding '..' across a usbfs node classify_and_normalize folds a /dev/bus name lexically before deciding whose it is, so /dev/bus/usb/001/001/.. becomes /dev/bus/usb/001 and every entry point answers with the bus directory. Linux applies '..' only to a directory, and the node is a character device, so the walk fails there with ENOTDIR. The fold now stops at a '..' after a usbfs node and keeps the rest as written, which classify_path answers as USB_PATH_DEV_NODE_SUB. path_rebuild_under stops the same way for a name relative to a synthetic cwd or descriptor, or retried by openat from a host directory, and folds every other '..' as before, so a node opened as ../001/001 still gets a usbfs descriptor. statfs asks the /dev/bus layer before its lexical /sys fold, which would otherwise carry /dev/bus/usb/001/001/../../../../../sys to sysfs. The node-dotdot matrix column is recorded on Linux 7.0; without the stop it fails on all 16 cells that can name the path. The classify_and_normalize comment and the vectors header justified the lexical fold by every /dev/bus component being a plain directory; a node is not one, and the claim is gone from both. The vectors header paragraphs around it drop a measurement against an old build as well. --- docs/internals.md | 22 ++++-- src/runtime/usb-sysfs.c | 61 ++++++++------ src/runtime/usb-sysfs.h | 5 ++ src/syscall/fs-stat.c | 25 +++--- src/syscall/path.c | 45 ++++++++--- src/syscall/path.h | 3 +- tests/test-usb-sysfs-matrix.c | 19 +++-- tests/test-usb-sysfs.c | 131 ++++++++++++++++++++++++++++++- tests/usb-sysfs-matrix-vectors.h | 86 ++++++++++---------- 9 files changed, 291 insertions(+), 106 deletions(-) diff --git a/docs/internals.md b/docs/internals.md index 6f39c900..e73de956 100644 --- a/docs/internals.md +++ b/docs/internals.md @@ -1677,12 +1677,18 @@ and then fails to serve is an answer, not a fall-through: taking the failure for one let `access(2)`, and then `statfs(2)`, answer from the backing while `open` and `stat` reported `ENOENT` for the same path. -`.` and `..` are folded lexically before ownership is decided, on both halves. -The fold is the ours/not-ours gate and nothing else -- the served path is built -by `usb_sys_resolve_suffix`, which resolves symlinks and applies each `..` to -what the previous component resolved to, the way the kernel does, so -`/subsystem/..` names `/sys/bus`. Deciding ownership on the guest's -spelling instead splits the two halves apart in both directions: +`.` and `..` are folded before ownership is decided, on both halves. On `/sys` +the fold is lexical and is the ours/not-ours gate and nothing else: the served +path is built by `usb_sys_resolve_suffix`, which resolves symlinks and applies +each `..` to what the previous component resolved to, as Linux does, so +`/subsystem/..` names `/sys/bus`. On `/dev/bus` a `..` after a usbfs node +is not applied, since Linux applies `..` only to a directory, and the name +answers `ENOTDIR` once the node exists. A `..` after any other name is applied +without checking that the name exists, so `/dev/bus/usb/099/../001/001` is +served where Linux answers `ENOENT`. A relative name is folded the same way up +to a usbfs node, so `099/../../../null` from a `/dev/bus/usb` cwd skips the +check the climb rewrite makes for the absolute spelling. Deciding ownership on +the unfolded spelling splits the two halves apart in both directions: `/dev/bus/usb/../other/f` reads as a malformed device number and is claimed, and `/dev/bus/other/../usb/001/002` reads as a foreign bus and is disowned. A suffix that folds away above its own root leaves as `USB_PATH_NONE`. @@ -1714,7 +1720,9 @@ The rules: - `/dev/bus` reports devtmpfs, on both entry points. - `..` is folded and a relative name is resolved against the cwd before either entry point decides, so the two cannot be handed different spellings of one - object. + object. A `..` after a usbfs node is the exception, and `statfs` asks + `/dev/bus` before its `/sys` fold so that it answers `ENOTDIR` as `stat` + does. A descriptor's identity comes from the virtual path stamped on its slot when this layer served the open, and from the descriptor's own host path mapped back diff --git a/src/runtime/usb-sysfs.c b/src/runtime/usb-sysfs.c index 941c8981..b0971042 100644 --- a/src/runtime/usb-sysfs.c +++ b/src/runtime/usb-sysfs.c @@ -1266,6 +1266,14 @@ static usb_path_kind_t usb_dev_kind(const char *folded, int *bus) return classify_path(path, bus, &dev, &sfx); } +/* Whether @folded starts with a usbfs node. */ +static bool usb_dev_names_node(const char *folded) +{ + int bus = 0; + usb_path_kind_t kind = usb_dev_kind(folded, &bus); + return kind == USB_PATH_DEV_NODE || kind == USB_PATH_DEV_NODE_SUB; +} + /* Whether @folded names a directory the layer serves. Caller holds usb_lock * after ensure_usb_tree(). */ @@ -1277,9 +1285,11 @@ static bool usb_dev_names_dir(const char *folded) (kind == USB_PATH_DEV_BUSNUM && bus_exists(bus)); } -/* Fold '.' and '..' in a /sys- or /dev/bus-relative suffix, lexically; a name - * that folds above its root is not this layer's. On /sys the fold decides only - * that, and usb_sys_resolve_suffix builds the served path. A trailing slash +/* Fold '.' and '..' in a /sys- or, with @dev, a /dev/bus-relative suffix, + * lexically; a name that folds above its root is not this layer's. On /sys the + * fold decides only that, and usb_sys_resolve_suffix builds the served path. + * With @dev a '..' after a usbfs node stops the fold and the rest is kept as + * written, since Linux applies '..' only to a directory. A trailing slash * survives, so an attribute used as a directory reports ENOTDIR. * * Returns 1 with @out filled, 0 when the walk climbs above the root, -1 when @@ -1289,6 +1299,7 @@ static bool usb_dev_names_dir(const char *folded) * Without @rest no lock is held, and any other name is popped unchecked. */ static int usb_suffix_normalize(const char *suffix, + bool dev, char *out, size_t outsz, const char **rest) @@ -1317,6 +1328,14 @@ static int usb_suffix_normalize(const char *suffix, *rest = NULL; return 0; } + if (dev && usb_dev_names_node(out)) { + size_t rlen = strlen(seg); /* this '..' and all after it */ + if (len + 1 + rlen + 1 > outsz) + return -1; + out[len++] = '/'; + memcpy(out + len, seg, rlen + 1); + return 1; + } len = marks[--depth]; out[len] = '\0'; continue; @@ -1527,22 +1546,12 @@ static usb_path_kind_t classify_and_normalize(const char *path, *err_out = 0; /* Fold the /dev/bus suffix before classify_path reads it, so both halves of - * the layer reach ownership the same way. The /sys half has always folded - * first and decided after; the /dev half used to classify the guest's - * spelling as written, and a '..' crossing the boundary then went wrong in - * both directions. /dev/bus/usb/../other/f reached parse_ddd's failure arm - * and came back USB_PATH_DEV_ABSENT, so the layer claimed the name and - * answered ENOENT for a file the sysroot really has; - * /dev/bus/other/../usb/001/002 classified as USB_PATH_DEV_FOREIGN, fell - * through, and missed the synthetic node because no sysroot carries a - * /dev/bus/usb. Both spellings are matrix columns now (dev-fold-out and - * dev-fold-in). + * the layer reach ownership the same way: /dev/bus/usb/../other/f is the + * backing's file and /dev/bus/other/../usb/001/001 is the synthetic node + * (matrix columns dev-fold-out and dev-fold-in). * - * The fold is lexical, and that is the right walk here: every component of - * /dev/bus this layer serves is a plain directory it materialized itself, - * so there is no symlink for a kernel-order walk to resolve differently. - * The result is written into `norm`, which the /sys arm below would use for - * its own folded suffix -- the two never both run -- and classify_path's + * The result is written into norm, which the /sys arm below would use for + * its own folded suffix; the two never both run, and classify_path's * sys_suffix_out is only set on the /sys arm, so it keeps pointing into * live storage either way. * @@ -1552,7 +1561,7 @@ static usb_path_kind_t classify_and_normalize(const char *path, */ if (path_prefix_match(path, "/dev/bus", 8)) { char folded[LINUX_PATH_MAX]; - int frc = usb_suffix_normalize(skip_slashes(path + 8), folded, + int frc = usb_suffix_normalize(skip_slashes(path + 8), true, folded, sizeof(folded), NULL); if (frc < 0) { *err_out = ENAMETOOLONG; @@ -1574,7 +1583,7 @@ static usb_path_kind_t classify_and_normalize(const char *path, return USB_PATH_NONE; /* another bus's /dev/bus subtree; not ours */ if (kind != USB_PATH_SYS) return kind; - int rc = usb_suffix_normalize(*sfx_out, norm, normsz, NULL); + int rc = usb_suffix_normalize(*sfx_out, false, norm, normsz, NULL); if (rc < 0) { *err_out = ENAMETOOLONG; return USB_PATH_NONE; @@ -2318,6 +2327,14 @@ int usb_sysfs_resolve_guest_path(const char *guest_path, return rc; } +bool usb_sysfs_names_node(const char *guest_path) +{ + int bus = 0, dev = 0; + const char *sfx = ""; + return path_prefix_match(guest_path, "/dev/bus/usb", 12) && + classify_path(guest_path, &bus, &dev, &sfx) == USB_PATH_DEV_NODE; +} + bool usb_sysfs_resolve_dev_climb(const char *guest_path, char *out, size_t outsz) @@ -2329,8 +2346,8 @@ bool usb_sysfs_resolve_dev_climb(const char *guest_path, const char *rest = NULL; pthread_mutex_lock(&usb_lock); bool climbs = ensure_usb_tree() == 0 && - usb_suffix_normalize(skip_slashes(guest_path + 8), folded, - sizeof(folded), &rest) == 0 && + usb_suffix_normalize(skip_slashes(guest_path + 8), true, + folded, sizeof(folded), &rest) == 0 && rest; pthread_mutex_unlock(&usb_lock); if (!climbs) diff --git a/src/runtime/usb-sysfs.h b/src/runtime/usb-sysfs.h index c001d61c..51390b22 100644 --- a/src/runtime/usb-sysfs.h +++ b/src/runtime/usb-sysfs.h @@ -75,6 +75,11 @@ int usb_sysfs_resolve_guest_path(const char *guest_path, char *out, size_t outsz); +/* Whether the folded guest name @guest_path is a usbfs node, by its shape + * alone; it takes no lock. + */ +bool usb_sysfs_names_node(const char *guest_path); + /* Rewrite a /dev/bus name that climbs out of /dev/bus with '..', popping only * directories the layer serves, to /dev followed by what comes after the '..' * that climbed, or to /dev/. when nothing does. Those directories are the diff --git a/src/syscall/fs-stat.c b/src/syscall/fs-stat.c index e57fca81..c2b052de 100644 --- a/src/syscall/fs-stat.c +++ b/src/syscall/fs-stat.c @@ -669,6 +669,20 @@ static int64_t sys_statfs_impl(guest_t *g, } } + /* /dev/bus is asked first: a '..' after a usbfs node is ENOTDIR, before the + * lexical fold below can carry the name to /sys. + */ + int dev_bus = statfs_dev_bus_class(name); + if (dev_bus == -1) + return linux_errno(); + if (dev_bus == 0) { + linux_statfs_t lin_st; + fill_dev_statfs(&lin_st); + if (guest_write_small(g, buf_gva, &lin_st, sizeof(lin_st)) < 0) + return -LINUX_EFAULT; + return 0; + } + /* /sys is sysfs. libusb refuses to enumerate through the synthetic * /sys/bus/usb tree unless statfs("/sys") reports SYSFS_MAGIC * (linux_usbfs.c:398-408), and passing through to the host either fails (no @@ -710,17 +724,6 @@ static int64_t sys_statfs_impl(guest_t *g, return 0; } - int dev_bus = statfs_dev_bus_class(name); - if (dev_bus == -1) - return linux_errno(); - if (dev_bus == 0) { - linux_statfs_t lin_st; - fill_dev_statfs(&lin_st); - if (guest_write_small(g, buf_gva, &lin_st, sizeof(lin_st)) < 0) - return -LINUX_EFAULT; - return 0; - } - /* glibc's posix_openpt() opens /dev/ptmx and then confirms devpts is * mounted before handing the master back (sysdeps/unix/sysv/linux/getpt.c). * /dev/pts has no host backing here, so a pass-through statfs fails and diff --git a/src/syscall/path.c b/src/syscall/path.c index e9c8fbf1..e619555a 100644 --- a/src/syscall/path.c +++ b/src/syscall/path.c @@ -631,9 +631,9 @@ int path_translate_at(guest_fd_t dirfd, /* Linux folds "//" runs and "." components in the one walk every path * syscall shares, so no filesystem is handed them. The intercepts match * literal prefixes, here and behind this function, so the name is folded - * here once rather than by each of them. A relative name needs nothing: the - * two resolvers above fold what they join, and what they decline goes to - * the host as written. + * here once rather than by each of them. A relative name needs nothing: + * what the two resolvers above join comes back absolute and is folded here, + * and what they decline goes to the host as written. */ if (tx->guest_path[0] == '/' && path_has_foldable(tx->guest_path)) { char *folded = @@ -1368,7 +1368,10 @@ static bool proc_path_fd_is_dir(const fd_entry_t *snap) } /* Rebuild @path against the guest directory @base as an absolute guest path, - * folding its components. + * folding its components. Once the walk reaches a usbfs node the rest is joined + * as written from the slash after it: Linux applies '..' only to a directory, + * and the USB layer answers ENOTDIR for a '..', a name or a trailing slash + * after a node. * * Returns 0, or -1 with errno set to ENAMETOOLONG. */ @@ -1379,14 +1382,38 @@ static int path_rebuild_under(const char *base, { size_t marks[PROC_PATH_COMPONENTS_MAX]; size_t depth; + char comp[LINUX_PATH_MAX]; + const char *p = path; + if (proc_seed_absolute_path(base, out, outsz, marks, ARRAY_SIZE(marks), - &depth) < 0 || - proc_apply_components(path, out, outsz, marks, ARRAY_SIZE(marks), - &depth) < 0) { - errno = ENAMETOOLONG; - return -1; + &depth) < 0) + goto toolong; + while (*p && !usb_sysfs_names_node(out)) { + p += strspn(p, "/"); + size_t n = strcspn(p, "/"); + if (n == 0) + break; + if (n >= sizeof(comp)) + goto toolong; + memcpy(comp, p, n); + comp[n] = '\0'; + if (proc_apply_components(comp, out, outsz, marks, ARRAY_SIZE(marks), + &depth) < 0) + goto toolong; + p += n; + } + if (*p) { + size_t len = strlen(out); + int n = + snprintf(out + len, outsz - len, "%s%s", *p == '/' ? "" : "/", p); + if (n < 0 || (size_t) n >= outsz - len) + goto toolong; } return 0; + +toolong: + errno = ENAMETOOLONG; + return -1; } int resolve_proc_dirfd_path(guest_fd_t dirfd, diff --git a/src/syscall/path.h b/src/syscall/path.h index 34c20b52..1a95f1fd 100644 --- a/src/syscall/path.h +++ b/src/syscall/path.h @@ -275,7 +275,8 @@ int path_translate_dirent_name(bool dir_holds_escapes, size_t guest_name_sz); /* Rebase a relative path against a host directory fd into the guest-visible - * absolute spelling (F_GETPATH + sysroot strip + dot-folding). + * absolute spelling (F_GETPATH and sysroot strip, with the components folded up + * to a usbfs node). * * Returns 1 with out filled, 0 when no mapping exists. See path.c for the * chase() rationale. diff --git a/tests/test-usb-sysfs-matrix.c b/tests/test-usb-sysfs-matrix.c index 91cf54e8..3db37c3a 100644 --- a/tests/test-usb-sysfs-matrix.c +++ b/tests/test-usb-sysfs-matrix.c @@ -99,6 +99,7 @@ enum { COL_SYS_FOLD_IN, /* a '..' out of a backing /sys name and back into ours */ COL_CLIMB, /* a '..' out of /dev/bus over its own directories */ COL_CLIMB_FILE, /* a '..' out of /dev/bus after the backing's file */ + COL_NODE_DOTDOT, /* the usb node followed by '..' */ COL_COUNT, }; @@ -106,7 +107,7 @@ static const char *col_name[COL_COUNT] = { "synth-dir", "back-sys", "back-dev", "subsys", "escape", "escape-syn", "usb-node", "absent", "long-sys", "sys-root", "dev-bus", "shadow", "subsys-out", "dev-fold-out", "dev-fold-in", - "sys-fold-in", "dev-climb", "dev-climb-f", + "sys-fold-in", "dev-climb", "dev-climb-f", "node-dotdot", }; /* COL_SUBSYS is the one spelling that cannot be shared: the recording host's @@ -178,6 +179,8 @@ static const char *col_path(int c) return "/dev/bus/usb/001/../../.."; case COL_CLIMB_FILE: return "/dev/bus/other/f/../../../null"; + case COL_NODE_DOTDOT: + return "/dev/bus/usb/001/001/.."; default: return "/dev/bus"; } @@ -214,13 +217,11 @@ static const char *col_path(int c) /* COL_SYS_FOLD_IN is the /sys mirror of COL_FOLD_IN, and the one direction that * stays unmet. /sys/class/../bus/usb/devices folds to a name this layer owns - * and serves, and ownership is decided on that folded name -- but the resolve + * and serves, and ownership is decided on that folded name, but the resolve * behind it joins the unfolded suffix onto the scratch tree, which carries no - * `class`, so the lookup fails and the layer answers its own authoritative - * ENOENT for a directory it does serve. Recorded as XFAIL rather than repaired: - * it is not this series\' doing, and the vectors header carries the measurement - * against the merge base and the reason a fold cannot fix this half the way it - * fixed the /dev one. + * class, so the lookup fails and the layer answers its own authoritative ENOENT + * for a directory it does serve. It is an XFAIL; the vectors header says why a + * fold cannot fix this half the way it fixes the /dev one. */ /* COL_CLIMB leaves /dev/bus through directories the layer serves and lands on @@ -229,6 +230,10 @@ static const char *col_path(int c) * ENOTDIR for, so a rewrite that trusts the lexical fold alone fails it. */ +/* COL_NODE_DOTDOT puts a '..' after COL_NODE's character device, which Linux + * answers ENOTDIR for and a lexical fold would turn into the bus directory. + */ + /* Names that must be listed by the union directories, one comma-free name per * entry, NULL-terminated. Empty for the columns where enumeration is not the * property under test. diff --git a/tests/test-usb-sysfs.c b/tests/test-usb-sysfs.c index 86fbd80f..5263ddba 100644 --- a/tests/test-usb-sysfs.c +++ b/tests/test-usb-sysfs.c @@ -4,9 +4,9 @@ * Copyright 2026 elfuse contributors * SPDX-License-Identifier: Apache-2.0 * - * Code under test: src/runtime/usb-sysfs.c, plus the sysfs statfs arms in - * src/syscall/fs-stat.c and the /dev/bus climb rewrite in path_translate_at - * (src/syscall/path.c). + * Code under test: src/runtime/usb-sysfs.c, plus the sysfs and /dev/bus statfs + * arms in src/syscall/fs-stat.c, and path_rebuild_under and the /dev/bus climb + * rewrite in path_translate_at (src/syscall/path.c). * * Two halves. The first asserts what holds with no USB device attached at all: * SYSFS_MAGIC from statfs and fstatfs alike, the read-only open contract, and @@ -1183,6 +1183,38 @@ static void check_dev_bus_names(void) "readlink of the descriptor"); if (fd >= 0) close(fd); + + /* path_rebuild_under folds a relative name against a /dev/bus cwd or dirfd + * up to a usbfs node, so every other '..' is applied before any entry point + * or stamp sees the name. + */ + TEST("getcwd after chdir(\"..\") from /dev/bus/usb/001 is /dev/bus/usb"); + EXPECT_TRUE(chdir("/dev/bus/usb/001") == 0 && chdir("..") == 0 && + getcwd(cwd, sizeof(cwd)) && !strcmp(cwd, "/dev/bus/usb"), + "chdir(\"..\"), then getcwd"); + + TEST("open(\".\") in a /dev/bus/usb cwd names /dev/bus/usb"); + fd = open(".", O_RDONLY | O_DIRECTORY); + EXPECT_TRUE(fd >= 0 && fd_names(fd, "/dev/bus/usb"), + "readlink of the descriptor"); + if (fd >= 0) + close(fd); + + if (chdir("/") != 0) + FAIL("chdir back to /"); + + TEST("a '..' chain from a /dev/bus/usb/001 dirfd names it"); + int dfd = open("/dev/bus/usb/001", O_RDONLY | O_DIRECTORY); + fd = dfd < 0 + ? -1 + : openat(dfd, "../001/../001/../001/../001/../001/../001/../001", + O_RDONLY | O_DIRECTORY); + EXPECT_TRUE(fd >= 0 && fd_names(fd, "/dev/bus/usb/001"), + "readlink of the descriptor"); + if (fd >= 0) + close(fd); + if (dfd >= 0) + close(dfd); } /* Whether @a and @b describe the same file. */ @@ -1279,6 +1311,98 @@ static void check_dotdot_leaves_bus(void) FAIL("chdir back to /"); } +/* openat of @rel from the host directory @dir, which the host refuses and + * openat retries through the guest spelling: ENOTDIR when @enotdir, else a + * descriptor. + */ +static void check_hostdir_openat(const char *dir, const char *rel, bool enotdir) +{ + char what[96]; + snprintf(what, sizeof(what), "openat(%s, \"%s\") %s", dir, rel, + enotdir ? "is ENOTDIR" : "opens"); + TEST(what); + int hfd = open(dir, O_RDONLY | O_DIRECTORY); + if (hfd < 0) { + FAIL(dir); + return; + } + errno = 0; + int fd = openat(hfd, rel, O_RDONLY | O_DIRECTORY); + EXPECT_TRUE(enotdir ? fd < 0 && errno == ENOTDIR : fd >= 0, what); + if (fd >= 0) + close(fd); + close(hfd); +} + +/* A '..' after a usbfs node is ENOTDIR however the name is reached; chdir and + * fchdir publish the cwd through different code. + */ +static void check_node_dotdot_relative(void) +{ + struct stat st; + struct statfs sfs; + + TEST("a '..' after a node used as a directory is ENOTDIR"); + EXPECT_ERRNO(stat("/dev/bus/usb/001/001/x/..", &st), ENOTDIR, + "stat(\"/dev/bus/usb/001/001/x/..\")"); + + TEST("statfs through a usbfs node to /sys is ENOTDIR"); + EXPECT_ERRNO(statfs("/dev/bus/usb/001/001/../../../../../sys", &sfs), + ENOTDIR, + "statfs(\"/dev/bus/usb/001/001/../../../../../sys\")"); + + int dfd = open("/dev/bus/usb/001", O_RDONLY | O_DIRECTORY); + TEST("\"001/..\" against a /dev/bus/usb/001 dirfd is ENOTDIR"); + if (dfd < 0) { + FAIL("open /dev/bus/usb/001"); + return; + } + EXPECT_ERRNO(fstatat(dfd, "001/..", &st, 0), ENOTDIR, + "fstatat(/dev/bus/usb/001, \"001/..\")"); + + TEST("\"001/..\" in a /dev/bus/usb/001 cwd from chdir is ENOTDIR"); + if (chdir("/dev/bus/usb/001") != 0) + FAIL("chdir /dev/bus/usb/001"); + else + EXPECT_ERRNO(stat("001/..", &st), ENOTDIR, "stat(\"001/..\")"); + + TEST("\"001/..\" in a /dev/bus/usb/001 cwd from fchdir is ENOTDIR"); + if (chdir("/") != 0 || fchdir(dfd) != 0) + FAIL("fchdir /dev/bus/usb/001"); + else + EXPECT_ERRNO(stat("001/..", &st), ENOTDIR, "stat(\"001/..\")"); + if (chdir("/") != 0) + FAIL("chdir back to /"); + + TEST("\"001/\" against a /dev/bus/usb/001 dirfd is ENOTDIR"); + EXPECT_ERRNO(openat(dfd, "001/", O_RDWR), ENOTDIR, + "openat(/dev/bus/usb/001, \"001/\", O_RDWR)"); + + /* Every other '..' is still applied before the layer sees the name, so + * usbdev_open_path, which takes only the canonical spelling, gets it. + */ + TEST("\"../001/001\" against a /dev/bus/usb/001 dirfd opens read-write"); + int fd = openat(dfd, "../001/001", O_RDWR); + EXPECT_TRUE(fd >= 0, "openat(/dev/bus/usb/001, \"../001/001\", O_RDWR)"); + if (fd >= 0) + close(fd); + + TEST("a climb from a /dev/bus/usb/001 dirfd reaches /sys"); + fd = openat(dfd, "../../../../sys/bus/usb/devices", O_RDONLY | O_DIRECTORY); + EXPECT_TRUE(fd >= 0, "openat(/dev/bus/usb/001, \"../../../../sys/...\")"); + if (fd >= 0) + close(fd); + close(dfd); + + /* The retry folds the name up to the node, so the node is not folded away + * and a walk from / or out of /dev still reaches the layer. + */ + check_hostdir_openat("/dev", "bus/usb/001/001/..", true); + check_hostdir_openat("/", "dev/bus/usb/001/001/..", true); + check_hostdir_openat("/dev", "../dev/bus/usb/001/001/..", true); + check_hostdir_openat("/", "dev/bus/usb/001", false); +} + int main(void) { printf("test-usb-sysfs: synthetic USB tree contract\n"); @@ -1288,6 +1412,7 @@ int main(void) int ndev = check_devices(); check_dev_bus_names(); check_dotdot_leaves_bus(); + check_node_dotdot_relative(); /* Stated, not implied: the second half is only as strong as the bus it ran * against, and a zero here means those assertions did not execute. diff --git a/tests/usb-sysfs-matrix-vectors.h b/tests/usb-sysfs-matrix-vectors.h index f38f3643..252e0707 100644 --- a/tests/usb-sysfs-matrix-vectors.h +++ b/tests/usb-sysfs-matrix-vectors.h @@ -50,6 +50,8 @@ * out of /dev/bus over the layer's directories * dev-climb-f /dev/bus/other/f/../../../null * the same, after back-dev's file + * node-dotdot /dev/bus/usb/001/001/.. + * usb-node's node followed by '..' * * Two markers appear in the table. * @@ -67,61 +69,53 @@ * ends: a folded /sys spelling that crosses this layer's boundary cannot * re-enter path translation, whichever way it crosses. * - * escape-syn is a '..' chain that walks *through* the synthetic subtree and - * back out cannot be resolved here. The lexical fold recognizes that the name + * escape-syn is a '..' chain that walks through the synthetic subtree and back + * out, which cannot be resolved here. The lexical fold recognizes that the name * leaves /sys and hands it to the host walk, but the host walk has to traverse - * /sys/bus/usb, which exists only inside this layer -- the lane's sysroot /sys - * carries no `bus/usb`. The name therefore answers ENOENT where Linux resolves - * it. This predates the synthetic USB tree in kind and reproduces identically - * on the pre-merge build (377c134) with a sysroot whose /sys has no `bus`; - * fixing it means having the folded spelling re-enter path translation, which - * is a path-layer change rather than an ownership one. The neighbouring - * `escape` column, whose chain transits only backing directories, is asserted + * /sys/bus/usb, which exists only inside this layer: the lane's sysroot /sys + * carries no bus/usb. The name therefore answers ENOENT where Linux resolves + * it. Fixing it means having the folded spelling re-enter path translation, + * which is a path-layer change rather than an ownership one. The neighboring + * escape column, whose chain transits only backing directories, is asserted * normally. * * sys-fold-in is the same fact walked the other way, and it is the /sys mirror * of dev-fold-in. /sys/class/../bus/usb/devices folds to a name this layer owns - * and does serve, so ownership is decided correctly -- but the resolve that - * follows joins the *unfolded* suffix onto the scratch tree, which has no - * `class`, so the lookup fails and the layer reports its own authoritative - * ENOENT. Eighteen entry points answer E2 where Linux resolves the name. statfs - * is the one cell that matches, and it matches because its test is the lexical - * /sys prefix rather than a lookup. The listing side says the same thing from - * the other end: /sys lists `bus` and /sys/class/.. does not. + * and does serve, so ownership is decided correctly, but the resolve that + * follows joins the unfolded suffix onto the scratch tree, which has no class, + * so the lookup fails and the layer reports its own authoritative ENOENT. + * Eighteen entry points answer E2 where Linux resolves the name. statfs is the + * one cell that matches, and it matches because its test is the lexical /sys + * prefix rather than a lookup. The listing side says the same thing from the + * other end: /sys lists bus and /sys/class/.. does not. * - * It is recorded rather than repaired because it is not this series' doing. - * Measured against the merge base this branch sits on (f70ee7a) with the lane's - * own sysroot, the answers are identical on both builds: stat of - * /sys/class/../bus/usb/devices is ENOENT, and /sys/class/.. lists `class - * kernel` without `bus`. The /dev half could be repaired by folding before - * classify because every component of /dev/bus this layer serves is a plain - * directory it materialized itself; the /sys half cannot, because - * usb_sys_resolve_suffix has to see the '..' in their original positions to - * order them against the `subsystem` symlinks. Making these cells green means + * The /sys half cannot be repaired by folding before classify, as the /dev half + * is: usb_sys_resolve_suffix has to see the '..' in their original positions to + * order them against the subsystem symlinks. Making these cells green means * having the folded spelling re-enter path translation, the same path-layer * change escape-syn needs. */ /* clang-format off */ -/* synth-dir back-sys back-dev subsys escape escape-syn usb-node absent long-sys sys-root dev-bus shadow subsys-out dev-fold-out dev-fold-in sys-fold-in dev-climb dev-climb-f */ -/* open */ {"ok", "ok", "ok", "ok", "ok", "?ok", "-", "E2", "ok", "ok", "ok", "E2", "ok", "ok", "-", "?ok", "ok", "E20"}, -/* open_nofollow */ {"ok", "ok", "ok", "E40", "ok", "?ok", "-", "E2", "ok", "ok", "ok", "E2", "ok", "ok", "-", "?ok", "ok", "E20"}, -/* openat_dirfd */ {"ok", "ok", "ok", "ok", "ok", "?ok", "-", "E2", "ok", "skip", "ok", "skip", "ok", "ok", "-", "?ok", "ok", "skip"}, -/* stat */ {"ok:d", "ok:d", "ok:f", "ok:d", "ok:f", "?ok:f", "ok:c", "E2", "ok:f", "ok:d", "ok:d", "E2", "ok:d", "ok:f", "ok:c", "?ok:d", "ok:d", "E20"}, -/* lstat */ {"ok:d", "ok:d", "ok:f", "ok:l", "ok:f", "?ok:f", "ok:c", "E2", "ok:f", "ok:d", "ok:d", "E2", "ok:d", "ok:f", "ok:c", "?ok:d", "ok:d", "E20"}, -/* fstatat_nofollow */ {"ok:d", "ok:d", "ok:f", "ok:l", "ok:f", "?ok:f", "ok:c", "E2", "ok:f", "ok:d", "ok:d", "E2", "ok:d", "ok:f", "ok:c", "?ok:d", "ok:d", "E20"}, -/* fstatat_dirfd */ {"ok:d", "ok:d", "ok:f", "ok:d", "ok:f", "?ok:f", "ok:c", "E2", "ok:f", "skip", "ok:d", "skip", "ok:d", "ok:f", "ok:c", "?ok:d", "ok:d", "skip"}, -/* statx */ {"ok:d", "ok:d", "ok:f", "ok:d", "ok:f", "?ok:f", "ok:c", "E2", "ok:f", "ok:d", "ok:d", "E2", "ok:d", "ok:f", "ok:c", "?ok:d", "ok:d", "E20"}, -/* access */ {"ok", "ok", "ok", "ok", "ok", "?ok", "ok", "E2", "ok", "ok", "ok", "E2", "ok", "ok", "ok", "?ok", "ok", "E20"}, -/* faccessat_nofollow */ {"ok", "ok", "ok", "ok", "ok", "?ok", "ok", "E2", "ok", "ok", "ok", "E2", "ok", "ok", "ok", "?ok", "ok", "E20"}, -/* readlink */ {"E22", "E22", "E22", "ok", "E22", "?E22", "E22", "E2", "E22", "E22", "E22", "E2", "E22", "E22", "E22", "?E22", "E22", "E20"}, -/* readlinkat_dirfd */ {"E22", "E22", "E22", "ok", "E22", "?E22", "E22", "E2", "E22", "skip", "E22", "skip", "E22", "E22", "E22", "?E22", "E22", "skip"}, -/* getdents64 */ {"ok", "ok", "E20", "ok", "E20", "?E20", "E20", "E2", "E20", "ok", "ok", "E2", "ok", "E20", "E20", "?ok", "ok", "E20"}, -/* statfs */ {"sysfs", "sysfs", "other", "sysfs","other","?other", "other", "E2", "sysfs", "sysfs", "other", "E2", "sysfs", "other", "other", "sysfs", "other", "E20"}, -/* fstatfs */ {"sysfs", "sysfs", "other", "sysfs","other","?other", "other", "E2", "sysfs", "sysfs", "other", "E2", "sysfs", "other", "other", "?sysfs", "other", "E20"}, -/* fstat_type */ {"ok:d", "ok:d", "ok:f", "ok:l", "ok:f", "?ok:f", "ok:c", "E2", "ok:f", "ok:d", "ok:d", "E2", "ok:d", "ok:f", "ok:c", "?ok:d", "ok:d", "E20"}, -/* chdir */ {"ok", "ok", "E20", "ok", "E20", "?E20", "E20", "E2", "E20", "ok", "ok", "E2", "ok", "E20", "E20", "?ok", "ok", "E20"}, -/* fchdir */ {"ok", "ok", "E20", "ok", "E20", "?E20", "E20", "E2", "E20", "ok", "ok", "E2", "ok", "E20", "E20", "?ok", "ok", "E20"}, -/* epoll_ctl */ {"E1", "E1", "E1", "E1", "E1", "?E1", "-", "E2", "ok", "E1", "E1", "E2", "E1", "E1", "-", "?E1", "E1", "E20"}, -/* union_listing */ {"n/a", "n/a", "n/a", "n/a", "n/a", "n/a", "n/a", "n/a", "n/a", "all", "all", "n/a", "n/a", "n/a", "n/a", "n/a", "n/a", "n/a"}, +/* synth-dir back-sys back-dev subsys escape escape-syn usb-node absent long-sys sys-root dev-bus shadow subsys-out dev-fold-out dev-fold-in sys-fold-in dev-climb dev-climb-f node-dotdot */ +/* open */ {"ok", "ok", "ok", "ok", "ok", "?ok", "-", "E2", "ok", "ok", "ok", "E2", "ok", "ok", "-", "?ok", "ok", "E20", "E20"}, +/* open_nofollow */ {"ok", "ok", "ok", "E40", "ok", "?ok", "-", "E2", "ok", "ok", "ok", "E2", "ok", "ok", "-", "?ok", "ok", "E20", "E20"}, +/* openat_dirfd */ {"ok", "ok", "ok", "ok", "ok", "?ok", "-", "E2", "ok", "skip", "ok", "skip", "ok", "ok", "-", "?ok", "ok", "skip", "skip"}, +/* stat */ {"ok:d", "ok:d", "ok:f", "ok:d", "ok:f", "?ok:f", "ok:c", "E2", "ok:f", "ok:d", "ok:d", "E2", "ok:d", "ok:f", "ok:c", "?ok:d", "ok:d", "E20", "E20"}, +/* lstat */ {"ok:d", "ok:d", "ok:f", "ok:l", "ok:f", "?ok:f", "ok:c", "E2", "ok:f", "ok:d", "ok:d", "E2", "ok:d", "ok:f", "ok:c", "?ok:d", "ok:d", "E20", "E20"}, +/* fstatat_nofollow */ {"ok:d", "ok:d", "ok:f", "ok:l", "ok:f", "?ok:f", "ok:c", "E2", "ok:f", "ok:d", "ok:d", "E2", "ok:d", "ok:f", "ok:c", "?ok:d", "ok:d", "E20", "E20"}, +/* fstatat_dirfd */ {"ok:d", "ok:d", "ok:f", "ok:d", "ok:f", "?ok:f", "ok:c", "E2", "ok:f", "skip", "ok:d", "skip", "ok:d", "ok:f", "ok:c", "?ok:d", "ok:d", "skip", "skip"}, +/* statx */ {"ok:d", "ok:d", "ok:f", "ok:d", "ok:f", "?ok:f", "ok:c", "E2", "ok:f", "ok:d", "ok:d", "E2", "ok:d", "ok:f", "ok:c", "?ok:d", "ok:d", "E20", "E20"}, +/* access */ {"ok", "ok", "ok", "ok", "ok", "?ok", "ok", "E2", "ok", "ok", "ok", "E2", "ok", "ok", "ok", "?ok", "ok", "E20", "E20"}, +/* faccessat_nofollow */ {"ok", "ok", "ok", "ok", "ok", "?ok", "ok", "E2", "ok", "ok", "ok", "E2", "ok", "ok", "ok", "?ok", "ok", "E20", "E20"}, +/* readlink */ {"E22", "E22", "E22", "ok", "E22", "?E22", "E22", "E2", "E22", "E22", "E22", "E2", "E22", "E22", "E22", "?E22", "E22", "E20", "E20"}, +/* readlinkat_dirfd */ {"E22", "E22", "E22", "ok", "E22", "?E22", "E22", "E2", "E22", "skip", "E22", "skip", "E22", "E22", "E22", "?E22", "E22", "skip", "skip"}, +/* getdents64 */ {"ok", "ok", "E20", "ok", "E20", "?E20", "E20", "E2", "E20", "ok", "ok", "E2", "ok", "E20", "E20", "?ok", "ok", "E20", "E20"}, +/* statfs */ {"sysfs", "sysfs", "other", "sysfs","other","?other", "other", "E2", "sysfs", "sysfs", "other", "E2", "sysfs", "other", "other", "sysfs", "other", "E20", "E20"}, +/* fstatfs */ {"sysfs", "sysfs", "other", "sysfs","other","?other", "other", "E2", "sysfs", "sysfs", "other", "E2", "sysfs", "other", "other", "?sysfs", "other", "E20", "E20"}, +/* fstat_type */ {"ok:d", "ok:d", "ok:f", "ok:l", "ok:f", "?ok:f", "ok:c", "E2", "ok:f", "ok:d", "ok:d", "E2", "ok:d", "ok:f", "ok:c", "?ok:d", "ok:d", "E20", "E20"}, +/* chdir */ {"ok", "ok", "E20", "ok", "E20", "?E20", "E20", "E2", "E20", "ok", "ok", "E2", "ok", "E20", "E20", "?ok", "ok", "E20", "E20"}, +/* fchdir */ {"ok", "ok", "E20", "ok", "E20", "?E20", "E20", "E2", "E20", "ok", "ok", "E2", "ok", "E20", "E20", "?ok", "ok", "E20", "E20"}, +/* epoll_ctl */ {"E1", "E1", "E1", "E1", "E1", "?E1", "-", "E2", "ok", "E1", "E1", "E2", "E1", "E1", "-", "?E1", "E1", "E20", "E20"}, +/* union_listing */ {"n/a", "n/a", "n/a", "n/a", "n/a", "n/a", "n/a", "n/a", "n/a", "all", "all", "n/a", "n/a", "n/a", "n/a", "n/a", "n/a", "n/a", "n/a"}, /* clang-format on */ From c9ab320e2ed8215a43778f5f95cf938c2ae0436a Mon Sep 17 00:00:00 2001 From: jotpalch Date: Fri, 16 Oct 2026 02:10:17 +0800 Subject: [PATCH 5/5] Open a usbfs node named through '..' usbdev_open_path takes only the canonical /dev/bus/usb/BBB/DDD spelling, and an absolute name keeps its '..' through path translation. open("/dev/bus/usb/001/../001/001", O_RDWR) therefore fell through to the intercepts, where usb_sysfs_intercept_open refuses a writable open of a node with EACCES, and /dev/bus/other/../usb/001/001 did the same on a sysroot with a /dev/bus/other. Linux opens the node in both cases, and stat already reports it as a character device. usb_sysfs_node_path folds the name with classify_and_normalize, as the stat intercept does, and sys_openat_path carries on with the canonical spelling: usbdev_open_path accepts it, and it names the descriptor, an O_PATH one included. A read-only open of such a name gets the FD_USBDEV descriptor a canonical open gets, where it got the descriptor blob usb_blob_fd serves, named by the spelling with '..' in it. classify_and_normalize drops a final '.', so usb_sysfs_node_path tests path_dir_required on the name as given, and one that ends in '.' or a slash is left to the intercepts, which answer ENOTDIR for a node. --- src/runtime/usb-sysfs.c | 10 ++++++++++ src/runtime/usb-sysfs.h | 11 +++++++++++ src/syscall/fs.c | 17 +++++++++++++---- tests/test-usb-sysfs.c | 26 ++++++++++++++++++++++++-- 4 files changed, 58 insertions(+), 6 deletions(-) diff --git a/src/runtime/usb-sysfs.c b/src/runtime/usb-sysfs.c index b0971042..2a992219 100644 --- a/src/runtime/usb-sysfs.c +++ b/src/runtime/usb-sysfs.c @@ -2335,6 +2335,16 @@ bool usb_sysfs_names_node(const char *guest_path) classify_path(guest_path, &bus, &dev, &sfx) == USB_PATH_DEV_NODE; } +bool usb_sysfs_node_path(const char *guest_path, char *out, size_t outsz) +{ + int bus = 0, dev = 0, cerr = 0; + const char *sfx = NULL; + return path_prefix_match(guest_path, "/dev/bus", 8) && + !path_dir_required(guest_path) && + classify_and_normalize(guest_path, &bus, &dev, &sfx, out, outsz, + &cerr) == USB_PATH_DEV_NODE; +} + bool usb_sysfs_resolve_dev_climb(const char *guest_path, char *out, size_t outsz) diff --git a/src/runtime/usb-sysfs.h b/src/runtime/usb-sysfs.h index 51390b22..0827b1b5 100644 --- a/src/runtime/usb-sysfs.h +++ b/src/runtime/usb-sysfs.h @@ -80,6 +80,17 @@ int usb_sysfs_resolve_guest_path(const char *guest_path, */ bool usb_sysfs_names_node(const char *guest_path); +/* The canonical /dev/bus/usb/BBB/DDD spelling of @guest_path when it names a + * usbfs node once its '.' and '..' are folded as the stat intercept folds them. + * usbdev_open_path takes only that spelling. A name that ends in the directory + * requirement path_dir_required reports is left to the intercepts, which answer + * ENOTDIR for a node. + * + * Returns true with @out filled, or false when @guest_path names no node or the + * folded name does not fit @outsz. + */ +bool usb_sysfs_node_path(const char *guest_path, char *out, size_t outsz); + /* Rewrite a /dev/bus name that climbs out of /dev/bus with '..', popping only * directories the layer serves, to /dev followed by what comes after the '..' * that climbed, or to /dev/. when nothing does. Those directories are the diff --git a/src/syscall/fs.c b/src/syscall/fs.c index bc0cbf9f..e1319620 100644 --- a/src/syscall/fs.c +++ b/src/syscall/fs.c @@ -881,17 +881,26 @@ int64_t sys_openat_path(guest_t *g, return guest_fd; } + char usb_node[sizeof("/dev/bus/usb/BBB/DDD")]; + /* Intercept /proc and /dev paths before touching the host filesystem */ if (path_might_use_open_intercept(tx.intercept_path)) { if (!strcmp(tx.intercept_path, "/dev/fuse")) return fuse_proc_open(linux_flags); /* /dev/bus/usb/BBB/DDD: typed FD_USBDEV constructor (any access mode - * except O_PATH, which the stage-1 placeholder serves). + * except O_PATH, which the stage-1 placeholder serves). A node named + * through '..' is passed on as /dev/bus/usb/BBB/DDD, the only spelling + * usbdev_open_path accepts and the one the placeholder's descriptor is + * stamped with. */ - int64_t usb_fd = usbdev_open_path(tx.intercept_path, linux_flags); - if (usb_fd != INT64_MIN) - return usb_fd; + if (usb_sysfs_node_path(tx.intercept_path, usb_node, + sizeof(usb_node))) { + tx.intercept_path = usb_node; + int64_t usb_fd = usbdev_open_path(usb_node, linux_flags); + if (usb_fd != INT64_MIN) + return usb_fd; + } int64_t fuse_fd = fuse_open_path(g, tx.intercept_path, linux_flags, mode); if (fuse_fd != INT64_MIN) diff --git a/tests/test-usb-sysfs.c b/tests/test-usb-sysfs.c index 5263ddba..e0d00a34 100644 --- a/tests/test-usb-sysfs.c +++ b/tests/test-usb-sysfs.c @@ -1334,8 +1334,9 @@ static void check_hostdir_openat(const char *dir, const char *rel, bool enotdir) close(hfd); } -/* A '..' after a usbfs node is ENOTDIR however the name is reached; chdir and - * fchdir publish the cwd through different code. +/* A '..' after a usbfs node is ENOTDIR however the name is reached, and a node + * reached through any other '..' opens as itself; chdir and fchdir publish the + * cwd through different code. */ static void check_node_dotdot_relative(void) { @@ -1387,6 +1388,27 @@ static void check_node_dotdot_relative(void) if (fd >= 0) close(fd); + /* An absolute name keeps its '..' through path translation, so + * sys_openat_path folds it for usbdev_open_path. + */ + TEST("/dev/bus/usb/001/../001/001 opens read-write as the node"); + fd = open("/dev/bus/usb/001/../001/001", O_RDWR); + EXPECT_TRUE(fd >= 0 && fd_names(fd, "/dev/bus/usb/001/001"), + "open(\"/dev/bus/usb/001/../001/001\", O_RDWR)"); + if (fd >= 0) + close(fd); + + TEST("an O_PATH open through '..' is named as the node"); + fd = open("/dev/bus/usb/001/../001/001", O_PATH); + EXPECT_TRUE(fd >= 0 && fd_names(fd, "/dev/bus/usb/001/001"), + "open(\"/dev/bus/usb/001/../001/001\", O_PATH)"); + if (fd >= 0) + close(fd); + + TEST("/dev/bus/usb/001/../001/001/. is ENOTDIR"); + EXPECT_ERRNO(open("/dev/bus/usb/001/../001/001/.", O_RDWR), ENOTDIR, + "open(\"/dev/bus/usb/001/../001/001/.\", O_RDWR)"); + TEST("a climb from a /dev/bus/usb/001 dirfd reaches /sys"); fd = openat(dfd, "../../../../sys/bus/usb/devices", O_RDONLY | O_DIRECTORY); EXPECT_TRUE(fd >= 0, "openat(/dev/bus/usb/001, \"../../../../sys/...\")");