diff --git a/docs/internals.md b/docs/internals.md index 3adbc9b4..e73de956 100644 --- a/docs/internals.md +++ b/docs/internals.md @@ -1677,16 +1677,32 @@ 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`. +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 @@ -1704,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/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 3f0e240d..2a992219 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; @@ -1244,28 +1254,55 @@ 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 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(). + */ +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, 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 /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, + bool dev, + char *out, + size_t outsz, + const char **rest) { size_t len = 0; size_t marks[64]; @@ -1282,8 +1319,23 @@ 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; + } + 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; @@ -1494,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. * @@ -1519,8 +1561,8 @@ 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)); + int frc = usb_suffix_normalize(skip_slashes(path + 8), true, folded, + sizeof(folded), NULL); if (frc < 0) { *err_out = ENAMETOOLONG; return USB_PATH_NONE; @@ -1541,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); + int rc = usb_suffix_normalize(*sfx_out, false, norm, normsz, NULL); if (rc < 0) { *err_out = ENAMETOOLONG; return USB_PATH_NONE; @@ -1551,6 +1593,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 +1626,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 +1827,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); @@ -2272,6 +2327,61 @@ 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_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) +{ + 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), true, + 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 55aae172..0827b1b5 100644 --- a/src/runtime/usb-sysfs.h +++ b/src/runtime/usb-sysfs.h @@ -75,6 +75,35 @@ 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); + +/* 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 + * 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 @@ -87,18 +116,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-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/fs.c b/src/syscall/fs.c index 5255fdcd..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) @@ -963,12 +972,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/src/syscall/path.c b/src/syscall/path.c index 7ad64762..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 = @@ -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 @@ -1352,6 +1367,55 @@ 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. 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. + */ +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; + char comp[LINUX_PATH_MAX]; + const char *p = path; + + if (proc_seed_absolute_path(base, out, outsz, marks, ARRAY_SIZE(marks), + &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, const char *path, char *out, @@ -1387,16 +1451,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 +1470,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 +1498,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 +1542,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) 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 cb068e82..3db37c3a 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,17 @@ 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_NODE_DOTDOT, /* the usb node followed by '..' */ 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", "node-dotdot", }; /* COL_SUBSYS is the one spelling that cannot be shared: the recording host's @@ -171,6 +175,12 @@ 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"; + case COL_NODE_DOTDOT: + return "/dev/bus/usb/001/001/.."; default: return "/dev/bus"; } @@ -207,13 +217,21 @@ 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 + * /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. + */ + +/* 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 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 d5317f80..e0d00a34 100644 --- a/tests/test-usb-sysfs.c +++ b/tests/test-usb-sysfs.c @@ -4,8 +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. + * 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 @@ -22,6 +23,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 +1151,280 @@ 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); + + /* 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. */ +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 /"); +} + +/* 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, 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) +{ + 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); + + /* 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/...\")"); + 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"); @@ -1154,6 +1432,9 @@ int main(void) check_tree_contract(); check_slurp_reads_whole_files(); 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 d2028b93..252e0707 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,12 @@ * 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 + * node-dotdot /dev/bus/usb/001/001/.. + * usb-node's node followed by '..' * * Two markers appear in the table. * @@ -63,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 */ -/* 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 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 */