Skip to content

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

Merged
jserv merged 5 commits into
sysprog21:mainfrom
jotpalch:pr-a0-node-dotdot
Oct 16, 2026
Merged

jserv merged 5 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; /dev/bus/../null answers ENOENT, because a .. that climbs out of /dev/bus reaches a backing that cannot apply it; and opening /dev/bus/usb/001/../001/001 for writing fails with EACCES while stat reports a character device, because the usbfs open takes only the canonical spelling. 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
$ ELFUSE_USB_FIXTURE=1 build/elfuse busybox sh -c 'exec 3<>/dev/bus/usb/001/../001/001 && echo opened'

On main these print directory, No such file or directory and Permission denied. With this series the first two print Not a directory and character special file, as Linux does, and the third prints opened.

Tested on macOS 15.6.1 (24G90) with the 15.5 SDK on an Apple M1 (MacBookAir10,1). make check passes all 101 tests, test-thread-churn on the runner's re-run after a timeout, and make test-matrix-elfuse-aarch64 gives 298 passed, 1 failed and 10 skipped. The one failure is test-thread-churn timing out, which main shows on this machine too, in make check and in the matrix, and it passes when run alone.

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.
cubic-dev-ai[bot]

This comment was marked as resolved.

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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

In this PR as well, in c9ab320: usb_sysfs_node_path folds the name with classify_and_normalize, and sys_openat_path carries on with the canonical spelling, which usbdev_open_path accepts and which names the descriptor, O_PATH included. open("/dev/bus/usb/001/../001/001", O_RDWR) returns a usbfs descriptor that /proc/self/fd names /dev/bus/usb/001/001; the absolute twin sits next to the relative case, with the O_PATH name and ENOTDIR for /dev/bus/usb/001/../001/001/. beside it. /dev/bus/other/../usb/001/001 takes the same fold. The EACCES arm in usb_sysfs_intercept_open is then unreachable from sys_openat_path, as its comment says.

/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.
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.
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.
@jserv
jserv merged commit c05b805 into sysprog21:main Oct 16, 2026
15 checks passed
@jserv

jserv commented Oct 16, 2026

Copy link
Copy Markdown
Contributor

Thank @jotpalch for contributing!

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