Skip to content

Stop folding '..' across a usbfs node - #439

Open
jotpalch wants to merge 4 commits into
sysprog21:mainfrom
jotpalch:pr-a0-node-dotdot
Open

jotpalch wants to merge 4 commits into
sysprog21:mainfrom
jotpalch:pr-a0-node-dotdot

Conversation

@jotpalch

@jotpalch jotpalch commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

/dev/bus/usb/001/001/.. answers as the bus directory where Linux answers ENOTDIR, because a .. after a usbfs node is folded away, and /dev/bus/../null answers ENOENT, because a .. that climbs out of /dev/bus reaches a backing that cannot apply it. The first commit gives the relative-name join one home without changing behavior, and the second makes a /dev/bus descriptor and cwd report the directory reached rather than a spelling with .. in it.

Reproduce with the fixture devices and a static aarch64 busybox:

$ ELFUSE_USB_FIXTURE=1 build/elfuse busybox stat -c %F /dev/bus/usb/001/001/..
$ ELFUSE_USB_FIXTURE=1 build/elfuse busybox stat -c %F /dev/bus/../null

On main these print directory and No such file or directory. With this series they print Not a directory and character special file, as Linux does.

Tested on macOS 15.6.1 (24G90) with the 15.5 SDK on an Apple M1 (MacBookAir10,1). make check passes 100 of 101 and make test-matrix-elfuse-aarch64 gives 298 passed, 1 failed and 10 skipped. Main gives the same on this machine: the one failure in both is test-thread-churn timing out, and it passes when run alone.


Summary by cubic

Stops folding .. after a usbfs node and rewrites .. climbs out of /dev/bus, so names like /dev/bus/usb/001/001/.. and /dev/bus/../null answer as Linux does (ENOTDIR and /dev/null, respectively) instead of resolving to the wrong directory or ENOENT.

  • A .. after a usbfs node now stops the fold, keeping the rest as written, so every entry point answers ENOTDIR as Linux does.
  • A .. that climbs out of /dev/bus over the layer's directories is rewritten in path_translate_at to /dev plus what follows, so /dev/bus/../null resolves; a climb that ends the name becomes /dev/., keeping rmdir and rename from acting on /dev.
  • Descriptors and the cwd on a synthetic /dev/bus directory now report the directory reached, not the guest's spelling with .. or a trailing slash.
  • Relative names against a /dev/bus cwd or dirfd fold components up to a usbfs node, which the earlier relative-name join refactor (path_rebuild_under) makes a single code path.
  • statfs asks the /dev/bus layer before the /sys fold so a .. through a node answers ENOTDIR.
  • Matrix coverage adds dev-climb, dev-climb-f, and node-dotdot columns, recorded against Linux 7.0; test-usb-sysfs-sysroot pins that rmdir and rename of a final .. leave the sysroot's /dev in place.

Written for commit b0887e3. Summary will update on new commits.

View guided diff

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.
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.
/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
'..' after any other name leaves the walk to the backing, which
answers ENOTDIR after a file as Linux does. A climb that ends the name
becomes /dev/., so rmdir and rename fail rather than act on /dev, 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.
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.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

3 issues found across 12 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="src/runtime/usb-sysfs.c">

<violation number="1" location="src/runtime/usb-sysfs.c:1285">
P2: An absent bus number makes this helper leave the climb to the backing, even though `/dev/bus/usb` is synthetic and hides backing entries. With a backing `/dev/bus/usb/099`, `/dev/bus/usb/099/../../../null` can resolve to `/dev/null` instead of failing `ENOENT` at `099`; preserve the missing-path result rather than falling through.</violation>
</file>

<file name="tests/test-usb-sysfs-matrix.c">

<violation number="1" location="tests/test-usb-sysfs-matrix.c:179">
P2: `COL_CLIMB` never verifies that the path reaches `/dev`: its rows assert only success or directory type, and `r_chdir` immediately resets cwd to `/`. Compare the resolved inode with `/dev` and check `getcwd()` before resetting cwd.</violation>
</file>

<file name="src/syscall/path.c">

<violation number="1" location="src/syscall/path.c:681">
P2: The loop stops after the first climb when the remaining `..` components spell the path as `/dev/../dev/bus`. That path resolves back to the synthetic `/dev/bus` directory, but the USB intercept misses it and host fallback can return `ENOENT`; normalize the rewritten remainder before deciding whether to repeat the climb.</violation>
</file>

Reply with feedback, questions, or to request a fix.

View guided diff | Re-trigger cubic

Comment thread src/runtime/usb-sysfs.c
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));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: An absent bus number makes this helper leave the climb to the backing, even though /dev/bus/usb is synthetic and hides backing entries. With a backing /dev/bus/usb/099, /dev/bus/usb/099/../../../null can resolve to /dev/null instead of failing ENOENT at 099; preserve the missing-path result rather than falling through.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At src/runtime/usb-sysfs.c, line 1285:

<comment>An absent bus number makes this helper leave the climb to the backing, even though `/dev/bus/usb` is synthetic and hides backing entries. With a backing `/dev/bus/usb/099`, `/dev/bus/usb/099/../../../null` can resolve to `/dev/null` instead of failing `ENOENT` at `099`; preserve the missing-path result rather than falling through.</comment>

<file context>
@@ -1244,28 +1254,55 @@ static bool bus_exists(int busnum)
+    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));
+}
+
</file context>

case COL_SYS_FOLD_IN:
return "/sys/class/../bus/usb/devices";
case COL_CLIMB:
return "/dev/bus/usb/001/../../..";

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: COL_CLIMB never verifies that the path reaches /dev: its rows assert only success or directory type, and r_chdir immediately resets cwd to /. Compare the resolved inode with /dev and check getcwd() before resetting cwd.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At tests/test-usb-sysfs-matrix.c, line 179:

<comment>`COL_CLIMB` never verifies that the path reaches `/dev`: its rows assert only success or directory type, and `r_chdir` immediately resets cwd to `/`. Compare the resolved inode with `/dev` and check `getcwd()` before resetting cwd.</comment>

<file context>
@@ -171,6 +175,12 @@ static const char *col_path(int c)
     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";
</file context>

Comment thread src/syscall/path.c
* out of it. What follows can climb out again, as in
* /dev/bus/../bus/../null, and each pass shortens the name.
*/
while (path_prefix_match(tx->guest_path, "/dev/bus", 8)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: The loop stops after the first climb when the remaining .. components spell the path as /dev/../dev/bus. That path resolves back to the synthetic /dev/bus directory, but the USB intercept misses it and host fallback can return ENOENT; normalize the rewritten remainder before deciding whether to repeat the climb.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At src/syscall/path.c, line 681:

<comment>The loop stops after the first climb when the remaining `..` components spell the path as `/dev/../dev/bus`. That path resolves back to the synthetic `/dev/bus` directory, but the USB intercept misses it and host fallback can return `ENOENT`; normalize the rewritten remainder before deciding whether to repeat the climb.</comment>

<file context>
@@ -674,6 +674,20 @@ int path_translate_at(guest_fd_t dirfd,
+     * out of it. What follows can climb out again, as in
+     * /dev/bus/../bus/../null, and each pass shortens the name.
+     */
+    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,
</file context>

Comment thread src/runtime/usb-sysfs.c
* rather than act on /dev itself, with EINVAL on a sysroot where Linux
* gives ENOTEMPTY and EBUSY for a final '..'.
*/
int n = snprintf(out, outsz, "/dev/%s", *rest ? rest : ".");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

When the name climbs more than once, the rest still starts with .., and the rewrite hands the host /dev/../... without folding it. /dev/bus/usb/001/../../../../sys/bus/usb/devices becomes /dev/../sys/bus/usb/devices, and /dev/bus/../../proc/self/status becomes /dev/../proc/self/status. No /sys or /proc intercept matches those spellings, and the /sys/bus/usb/devices/ subsystem rewrite in path_translate_at runs before this loop, so both return ENOENT where Linux resolves them. The relative form of the same walk (openat(dfd, "../../../../sys/bus/usb/devices"), which the new test covers) works, because path_rebuild_under folds it all. A follow-up could have this function also consume each leading ../ of rest against /dev (clamping at /), and move the climb loop above the subsystem rewrite so a climb that lands in /sys still gets resolved.

Comment thread tests/test-usb-sysfs.c
* 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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This open works only because the name is relative and path_rebuild_under folds it. The absolute spelling of the same node, open("/dev/bus/usb/001/../001/001", O_RDWR), is not folded by path_translate_at (the fold returns 1, so the climb rewrite does not fire). usbdev_parse_node rejects anything but the exact canonical spelling, so the open falls through to proc_intercept_open, whose USB_PATH_DEV_NODE arm returns EACCES from the path its comment calls unreachable. /dev/bus/other/../usb/001/001 (the dev-fold-in column) fails the same way. Linux opens the node in both cases. A follow-up could pass usbdev_open_path the dev-mode folded spelling from usb_suffix_normalize, and add the absolute twin of this case next to it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants