Repository navigation
Conversation
9bc61ad to
a0daec0
Compare
| * the limit whatever the length, and a length cut at the limit otherwise. | ||
| */ | ||
| uint64_t room; | ||
| if (fsize_copy_room(out_ref.fd, off_out, &room)) { |
There was a problem hiding this comment.
Linux runs generic_file_rw_checks before generic_copy_file_checks: the input must be readable, the output writable and not O_APPEND, and both regular files. An input opened O_WRONLY (or a pipe input) with the output at the limit gets EBADF or EINVAL there. Here it gets EFBIG plus a SIGXFSZ that terminates the guest. Make those access-mode and file-type checks before this block. At minimum, test F_GETFL on in_ref.fd for read access before sending the signal.
There was a problem hiding this comment.
Fixed in eb20204, now the first commit of the series and independent of the limit. copy_file_rw_checks follows generic_file_rw_checks: EISDIR for a directory end, EINVAL for any other end that is not a regular file, then EBADF for an input not open for reading or an output not open for writing or open O_APPEND. sys_copy_file_range runs it before the RLIMIT_FSIZE block. test-io-opt gains a step for each case, and it passes on the qemu reference kernel. A pipe input, which the pread loop used to copy from, is now EINVAL as on Linux.
Linux runs generic_file_rw_checks before copy_file_range looks at offsets or lengths: EISDIR when either end is a directory, EINVAL when either is not a regular file, and EBADF when the input is not open for reading or the output is not open for writing or has O_APPEND. elfuse went straight to its pread/pwrite loop, so it appended through an O_APPEND output and copied out of a pipe, where Linux refuses both. Make the same checks on the host descriptors first. A size limit applied after the copy starts depends on this order: Linux refuses a bad end before it can raise SIGXFSZ for it.
a0daec0 to
af99d21
Compare
| return 1; | ||
| } | ||
|
|
||
| for (;;) { |
There was a problem hiding this comment.
The stream branch never checks that in_hfd is readable. splice(p[1], NULL, out, NULL, 1, 0), or a sendfile with a pipe's write end as input, against an output already at the limit: FIONREAD on the write end reports 0, POLLIN never fires and POLLHUP never comes while the read end is open, so a blocking descriptor waits in io_wait_fd_or_interrupted forever. On main the read failed at once with EBADF, as Linux does. Refuse a write-only input before probing:
| for (;;) { | |
| int in_fl = fcntl(in_hfd, F_GETFL); | |
| if (in_fl >= 0 && (in_fl & O_ACCMODE) == O_WRONLY) | |
| return -LINUX_EBADF; | |
| for (;;) { |
There was a problem hiding this comment.
Fixed in d45cb35, with your suggestion: copy_past_fsize refuses an input not open for reading with EBADF before the stream probe, as do_splice and do_sendfile do. test-rlimit-fsize step 10 runs splice and sendfile with a pipe's write end as input, the read end open and the output at the limit; both return EBADF with no SIGXFSZ, on elfuse and on the qemu reference kernel. Against af99d21 the same step hangs until it is killed.
af99d21 to
3550f63
Compare
| * permission a stat and faccessat report. | ||
| */ | ||
| if (length >= 0 && (uint64_t) length > sys_rlimit_cur(LINUX_RLIMIT_FSIZE)) { | ||
| int fd = open(tx.host_path, O_WRONLY | O_NONBLOCK | O_CLOEXEC); |
There was a problem hiding this comment.
This open(O_WRONLY) runs on whatever the path names, before anything checks that it is a regular file, and vfs_truncate never opens anything. Take a FIFO that has a reader: the open succeeds, then close drops the only writer, so a reader blocked in open(O_RDONLY) is released and its next read returns EOF. A tty gets opened without O_NOCTTY, and a device the caller cannot write answers EACCES where Linux answers EINVAL. stat first and open only an S_ISREG path, adding O_NOCTTY. truncate_fsize_check already re-checks the type through fstat, so a swap between the two calls is still caught.
There was a problem hiding this comment.
Fixed in bcca3e3. truncate now runs stat first and opens the path only when it is S_ISREG, with O_NONBLOCK | O_NOCTTY, so a FIFO or a device is never opened and falls through to the host truncate, which returns the Linux errno. truncate_fsize_check still re-checks the type through fstat after the open. Step 13 of test-rlimit-fsize truncates a FIFO and expects EINVAL. Linux under qemu agrees.
There was a problem hiding this comment.
Superseded in d45cb35: truncate by path no longer opens anything, even a regular file. See #463 (comment).
| /* An O_APPEND descriptor is judged at end of file here too: Linux moves the | ||
| * position there in generic_write_checks whatever offset pwrite gave. | ||
| */ | ||
| int64_t fsz = fsize_limit_write(host_ref.fd, offset, true, &count); |
There was a problem hiding this comment.
This runs before any check that the offset range fits. pwrite(fd, buf, 1, INT64_MAX) under a 4 KiB limit now returns EFBIG and queues SIGXFSZ, whose default action kills the guest. Linux's rw_verify_area answers EINVAL first, with no signal, because pos + count goes negative. Reject that wrap with -LINUX_EINVAL ahead of fsize_limit_write, here and in sys_pwritev.
There was a problem hiding this comment.
Fixed in 7d82c5e, a separate commit placed before the FSIZE commit. It applies with or without a limit, because main already returned EFBIG for pwrite(fd, buf, 1, INT64_MAX) with no limit set, where Linux returns EINVAL. pwrite64 and pwritev now return EINVAL when offset + count overflows off_t, after the EBADF check for a read-only fd. test-readv-writev covers both calls, and qemu gives the same EINVAL with no signal.
| * the limit whatever the length, and a length cut at the limit otherwise. | ||
| */ | ||
| uint64_t room; | ||
| if (fsize_copy_room(out_ref.fd, off_out, &room)) { |
There was a problem hiding this comment.
generic_copy_file_checks returns EOVERFLOW when pos_in + count or pos_out + count wraps, and does that before the input is shortened to EOF and before generic_write_check_limits. The handler has no wrap check at all. So *off_out = INT64_MAX - 10 with len = 100 gets EFBIG and SIGXFSZ, which kills the process, where Linux returns EOVERFLOW with no signal. Add the wrap check after copy_file_rw_checks. A negative *off_out read through the pointer is also still taken as "use the descriptor position" where Linux says EINVAL; that predates this PR and can be a follow-up.
There was a problem hiding this comment.
I measured this on Linux under qemu and did not change the code: with *off_out = INT64_MAX - 10 and len = 100, Linux returns EFBIG and queues SIGXFSZ, the same as this handler. The wrap test in generic_copy_file_checks is pos_out + count < pos_out with loff_t pos_out and size_t count, so the sum is computed as unsigned. INT64_MAX - 10 + 100 does not wrap as an unsigned 64-bit value, so the test passes and generic_write_check_limits answers EFBIG. Only a negative position wraps that sum. That is the case you raised at the end, and Linux gives EOVERFLOW for copy_file_range and EINVAL for splice there. It predates this PR and is tracked in #467 with the measurements.
| uint64_t room; | ||
| if (fsize_copy_room(st.out_hfd, st.off_out, &room)) { | ||
| if (room == 0) { | ||
| int64_t past = copy_past_fsize(st.in_hfd, st.off_in, &st.in_st, |
There was a problem hiding this comment.
copy_past_fsize decides whether to wait from io_eagain_should_wait(in_st) alone, but sys_splice discards flags, so SPLICE_F_NONBLOCK never reaches it. Take a blocking empty pipe and an output already at the limit: with SPLICE_F_NONBLOCK Linux returns EAGAIN from splice_from_pipe_next, while this parks until a signal comes. Step 8 of the test only covers pipe2(O_NONBLOCK). Pass a nonblock flag in. The ordinary splice read has the same gap and could share the fix.
There was a problem hiding this comment.
Fixed in b09447f, a separate commit before FSIZE. sys_splice sets guest_nonblock on both ends when SPLICE_F_NONBLOCK is set, so the ordinary splice read and copy_past_fsize both see it through io_eagain_should_wait. test-io-opt covers an empty blocking pipe without a limit (EAGAIN), and step 9 of test-rlimit-fsize covers the same pipe with the output at the limit (EAGAIN, no SIGXFSZ). The previous push (3550f63) hung on the first case, and qemu returns EAGAIN for both.
| memory_order_relaxed); | ||
| } | ||
|
|
||
| void sys_rlimit_send_xfsz(void) |
There was a problem hiding this comment.
The guest table starts as a copy of the host limits and never calls host setrlimit. So a guest started under ulimit -S -f N can raise its soft limit up to the hard one, and the first host write past N makes macOS send SIGXFSZ to elfuse itself, killing it with a core dump. The race tracked in #464 hits the same wall when the two limits are equal. Now that the limit is emulated here, setting host SIGXFSZ to SIG_IGN at startup turns that overshoot into a host EFBIG that io_write_result already maps.
There was a problem hiding this comment.
Fixed in a5180e2, a separate commit before FSIZE, which also fixes #437. main sets host SIGXFSZ to SIG_IGN next to SIGPIPE. test-rlimit-seed has two new cases: elfuse boots under ulimit -S -f 4, and a guest that raises its soft limit and then writes 8 KiB with dd gets EFBIG ("File too large", exit 1), where elfuse was killed before.
3550f63 to
bcca3e3
Compare
| if (length >= 0 && (uint64_t) length > sys_rlimit_cur(LINUX_RLIMIT_FSIZE) && | ||
| stat(tx.host_path, &st) == 0 && S_ISREG(st.st_mode)) { | ||
| int fd = | ||
| open(tx.host_path, O_WRONLY | O_NONBLOCK | O_NOCTTY | O_CLOEXEC); |
There was a problem hiding this comment.
Closing this descriptor drops every POSIX record lock the guest holds on the file: guest F_SETLK/F_SETLKW go to host per-process locks, and close on any descriptor for that file releases them all. A guest that holds a write lock and calls truncate(path, len) with len above a finite RLIMIT_FSIZE loses the lock, whether it gets EFBIG or a successful shrink. Refusing from stat plus faccessat(W_OK, AT_EACCESS), which is the EMFILE fallback already here, and leaving success to plain truncate() avoids opening the file at all.
There was a problem hiding this comment.
Fixed in d45cb35 as suggested. truncate by path no longer opens the file: over the limit, it refuses with EFBIG when stat reports a regular file shorter than the length and faccessat(W_OK, AT_EACCESS) passes, and every other case goes to plain truncate(). The comment and the commit message state the rename window this reopens. Step 14 of test-rlimit-fsize takes an F_SETLK write lock, gets EFBIG from truncate(path, ...), and has a child check the lock with F_GETLK. It passes on elfuse and on the qemu reference kernel, and fails on bcca3e3.
| if (offset < 0 || count <= (uint64_t) (INT64_MAX - offset)) | ||
| return false; | ||
| int fl = fcntl(host_fd, F_GETFL); | ||
| return fl >= 0 && (fl & O_ACCMODE) != O_RDONLY; |
There was a problem hiding this comment.
This checks only the access mode, so pwrite(pipefd[1], "x", 1, INT64_MAX) now returns EINVAL where main returned ESPIPE. ksys_pwrite64 and do_pwritev answer ESPIPE for a file without FMODE_PWRITE before rw_verify_area runs, and pwritev reaches the same helper. Return false when the descriptor cannot seek (lseek(host_fd, 0, SEEK_CUR) < 0), and add a pipe case to test_pwrite_range_wrap.
There was a problem hiding this comment.
Fixed in 7d82c5e. io_write_range_wraps now also requires lseek(host_fd, 0, SEEK_CUR) to succeed, so a pipe goes to the host call and gets ESPIPE. test_pwrite_range_wrap adds pwrite and pwritev on a pipe's write end at INT64_MAX and expects ESPIPE, which matches the qemu reference kernel.
| return false; | ||
|
|
||
| off_t pos = off_out >= 0 ? (off_t) off_out : lseek(out_hfd, 0, SEEK_CUR); | ||
| return fsize_room(out_hfd, pos, off_out < 0, room); |
There was a problem hiding this comment.
Treating a negative off_out as append-aware lets an O_APPEND output reach copy_past_fsize, so sendfile or a pipe-to-file splice into an O_APPEND file at the limit now raises SIGXFSZ (fatal by default). Linux refuses that output with EINVAL in do_splice and do_splice_direct before any write. elfuse never had that check, but this change turns the gap into a kill. The O_APPEND refusal copy_file_rw_checks now does for copy_file_range belongs in sys_sendfile and sys_splice too, in this PR or a follow-up.
There was a problem hiding this comment.
Added in this PR as f0ef35a, before the FSIZE commit. sendfile and splice now return EINVAL for an output opened O_APPEND before anything is read, unless the output is a pipe, as do_splice_direct and the pipe-to-file branch of do_splice do. The new test-io-opt step expects EINVAL from both calls and an output that did not grow. The qemu reference kernel agrees, and bcca3e3 fails the step.
| * O_NONBLOCK the descriptors carry (splice_from_pipe_next and | ||
| * splice_to_pipe, Linux fs/splice.c). | ||
| */ | ||
| if (flags & LINUX_SPLICE_F_NONBLOCK) { |
There was a problem hiding this comment.
Forcing out_st nonblocking loses data: the loop reads up to a chunk from the input before writing, so with a full output pipe splice_drain_chunk gets EAGAIN after the read, and io_rewind_unsent cannot rewind a pipe or socket input. For example, splice(a[0], NULL, b[1], NULL, 65536, SPLICE_F_NONBLOCK) with a holding 100 KiB and b nearly full drops whatever did not fit, where Linux consumes only what it can place. Apply the flag to the input only, and for a pipe output check POLLOUT with poll(..., 0) before reading, answering EAGAIN while nothing has moved, so a chunk already read is never thrown away.
Setting guest_nonblock also does nothing on a descriptor elfuse does not own: io_eagain_should_wait and io_xfer read the shadow only when nonblock_owned is set and otherwise follow the host flag. So sleep 10 | prog with splice(0, NULL, file, NULL, n, SPLICE_F_NONBLOCK) still blocks. The per-call request has to reach those helpers ahead of the ownership branch. The new tests cover only an empty input on pipes the guest created, so they miss both cases.
There was a problem hiding this comment.
Reworked in b09447f. SPLICE_F_NONBLOCK now reaches the input only, through a per-call call_nonblock field in fd_block_state_t. io_eagain_should_wait checks it first, and io_xfer checks it ahead of the ownership branch: on a descriptor elfuse does not own, it polls with a zero timeout and answers EAGAIN when the descriptor is not ready. For a pipe output, sys_splice polls for POLLOUT with a zero timeout before each read and caps the chunk at PIPE_BUF. A ready pipe has at least that much free (measured on macOS), so a chunk that has been read always lands, and a full output answers EAGAIN while nothing has moved.
Two tests cover the cases you named. test-io-opt fills the output pipe, expects EAGAIN with all 8 KiB still in the input, then frees 4 KiB and checks that the bytes moved plus the bytes left equal 8 KiB. test-matrix.sh gains test_held_stdin, which runs test-io-opt stdin with fd 0 an empty pipe whose writer stays open, and expects EAGAIN. Both pass on elfuse and on the qemu reference kernel. On bcca3e3 the first loses the input and the second hangs until its alarm.
Linux refuses an output opened with O_APPEND before anything moves: do_splice_direct for sendfile, and the pipe-to-file branch of do_splice, both return EINVAL. elfuse appended the data instead. A pipe output is still accepted, as on Linux.
sys_splice discarded its flags, so SPLICE_F_NONBLOCK had no effect: splice from an empty pipe whose writer is still open waited for data, where Linux returns EAGAIN (splice_from_pipe_next). The flag now reaches the input through a per-call field in the block state, which the transfer paths check before the ownership branch, so it also holds on a descriptor elfuse did not open, such as inherited stdin. The output is not made nonblocking: splice reads a chunk before it writes it, and a pipe or socket input cannot take back what a nonblocking write then failed to place. A pipe output is asked for room before each read instead, and the chunk is capped at PIPE_BUF, so a full output answers EAGAIN with the input untouched.
rw_verify_area refuses a positioned write whose offset plus count does not fit in loff_t with EINVAL, before the filesystem looks at the size. elfuse passed such a pwrite or pwritev to the host, which answered EFBIG, so pwrite(fd, buf, 1, INT64_MAX) reported a size error where Linux reports an invalid argument. Check the sum first in sys_pwrite64 and sys_pwritev. A descriptor not open for writing, or one that cannot seek, is left to the host call, since vfs_write answers EBADF and ksys_pwrite64 answers ESPIPE before rw_verify_area runs.
A host write past elfuse's own RLIMIT_FSIZE raises SIGXFSZ, and the default action kills elfuse. Two writes reach the host limit. The guest limit is emulated in the guest table, so a guest that raises its soft limit above the one elfuse started under writes past the host limit, and elfuse dies on the guest's behalf. guest_init also sizes the 1 TiB slab file with ftruncate, so any host limit below 1 TiB killed elfuse before the guest ran. Ignore SIGXFSZ next to SIGPIPE. The guest write then fails with EFBIG, which io_write_result already reports, and guest_init takes its existing fallback to an anonymous slab. Fixes sysprog21#437
The guest rlimit table stores RLIMIT_FSIZE, but nothing reads it, so a guest under a finite limit can grow a file without bound. Apply it where Linux does, to regular files only: write, pwrite64, writev, pwritev and pwritev2 cut a request at the soft limit, and one that starts at or past it fails with EFBIG (generic_write_check_limits). truncate, ftruncate and fallocate refuse to grow a file past it (inode_newsize_ok). sendfile and splice stop at the limit, and copy_file_range cuts or refuses the whole request up front (generic_copy_file_checks). Each refusal sends SIGXFSZ to the calling thread, as send_sig to current does, so a sibling with the signal unblocked does not take it. O_APPEND and RWF_APPEND writes are measured from end of file once a negative pwrite offset has been refused with EINVAL, a zero-length write is never refused, and a descriptor not open for writing is left to the host call, which refuses it first. The write fast path in syscall_dispatch defers to sys_write while the limit is finite. truncate by path refuses a grow past the limit from stat and the write permission faccessat reports, without opening the file: closing any descriptor on a file drops every record lock the process holds on it, and vfs_truncate opens nothing. A rename between the stat and truncate(2) can still let the truncate grow a file the check did not see. tests/test-rlimit-fsize covers each call under a 4 KiB limit, a record lock that survives a refused truncate, and a writer whose SIGXFSZ stays pending while a sibling thread has it unblocked.
bcca3e3 to
d45cb35
Compare
| /* An O_APPEND descriptor is judged at end of file here too: Linux moves the | ||
| * position there in generic_write_checks whatever offset pwrite gave. | ||
| */ | ||
| int64_t fsz = fsize_limit_write(host_ref.fd, offset, true, &count); |
There was a problem hiding this comment.
The limit is measured from end of file for an O_APPEND descriptor, but the pwrite below still writes at the guest's offset, and macOS honors that offset even with O_APPEND set (a 3-byte O_APPEND file takes pwrite(fd, "XY", 2, 10000) to 10002 bytes). With a 4096 limit and an empty O_APPEND file, pwrite(fd, buf, 100, 1 << 30) passes the check and grows the file to 1 GiB with no EFBIG or SIGXFSZ. Write at st.st_size when the descriptor has O_APPEND, as sys_pwritev_append already does, so the checked position and the written one are the same.
| } | ||
|
|
||
| int64_t fsz = | ||
| fsize_limit_writev(host_ref.fd, offset, true, host_iov.iov, &iovcnt); |
There was a problem hiding this comment.
Same hole as in sys_pwrite64: fsize_limit_writev measures an O_APPEND descriptor from end of file, then pwritev writes at the guest's offset, which macOS does not redirect to EOF. Route an O_APPEND descriptor through the end-of-file position here as well.
| err = -LINUX_EFBIG; | ||
| goto out_copy_file_range; | ||
| } | ||
| if (len > room) |
There was a problem hiding this comment.
generic_copy_file_checks rejects pos_in + count or pos_out + count that wrap with EOVERFLOW before it applies RLIMIT_FSIZE. Here a finite limit clips len first, so copy_file_range with len = UINT64_MAX and an offset of 1 copies data instead of failing, and at the limit it sends SIGXFSZ (default action kills the guest) where Linux returns EOVERFLOW. Check both positions for unsigned wrap before fsize_copy_room.
| * read instead. | ||
| */ | ||
| bool nonblock = (flags & LINUX_SPLICE_F_NONBLOCK) != 0; | ||
| st.in_st.call_nonblock = nonblock; |
There was a problem hiding this comment.
SPLICE_F_NONBLOCK only makes the pipe end nonblocking on Linux; a socket input follows its own O_NONBLOCK (tcp_splice_read and unix_stream_splice_read read f_flags), and a tty input blocks. Setting call_nonblock for every input makes splice(blocking_tcp_sock, NULL, pipe_w, NULL, n, SPLICE_F_NONBLOCK) return EAGAIN immediately where Linux waits, turning a blocking loop into a spin. Set it only when the input is a FIFO (fstat plus S_ISFIFO), and fix the "whatever O_NONBLOCK the descriptors carry" wording above to match.
Intent: a guest RLIMIT_FSIZE limits the guest's writes to regular files as it does on Linux. This is follow-up 1 of #448, which stores the limit in the guest table and leaves it unenforced.
Failure fixed: on main, a guest that sets RLIMIT_FSIZE to 4 KiB and writes 8 KiB gets 8192 back and no signal. On Linux the write returns 4096, the next write fails with EFBIG, and SIGXFSZ goes to the thread that wrote.
This PR: the first five commits fix gaps that already exist on main and that the limit relies on. Each stands alone, so it can be tracked and reverted on its own.
generic_file_rw_checksfirst: EISDIR for a directory, EINVAL for another non-regular file, EBADF for a wrong access mode or an O_APPEND output.do_splice_directanddo_splicedo. A pipe output is still accepted.offset + countoverflowsoff_t(rw_verify_area). A pipe still gets ESPIPE.SIGXFSZat startup under a hostRLIMIT_FSIZEbelow 1 TiB #437).The last commit enforces the limit, on regular files only:
generic_write_check_limits).inode_newsize_ok). truncate by path checks withstatandfaccessatand never opens the file, so the guest's record locks survive.generic_copy_file_checks).Review focus:
sys_rlimit_send_xfszqueues SIGXFSZ on the calling thread (send_sigtocurrent), falling back to the process only when the thread is not found.generic_write_checksmoves the position there.writefast path insyscall_dispatchsends every write tosys_writewhile the limit is finite.copy_past_fsizewaits for a byte on an empty pipe before refusing, assplice_from_pipe_nextdoes, and is listed as restartable inscripts/check-eintr-contract.py: it reads nothing and waits only while the copy has moved nothing.Left out:
fallocateon a read-only descriptor answers EINVAL or 0 where Linux answers EBADF; that is #462, and the new test only asserts no SIGXFSZ there.Known gap: two writers that both size themselves against one position or one file size can each fit the same remaining room, so the file ends past the limit by at most one request per extra writer. Linux closes this with the inode lock; #464 tracks it.
Verified on
e9fdbbb:make check-format,make lintandmake checkpass;make test-matrix-elfuse-aarch64gives 316 passed, 0 failed, 10 skipped;make test-matrix-qemu-aarch64gives 290 passed, 0 failed, 35 skipped. Each commit builds.test-rlimit-fsizeand the newtest-io-optandtest-readv-writevsteps pass on elfuse and on the qemu reference kernel, and fail on an elfuse built frome9fdbbb.Fixes #437
Summary by cubic
Enforces the guest RLIMIT_FSIZE on regular-file writes so a finite limit actually bounds file growth. The limit was previously stored but never read: a guest with a 4 KiB limit could write 8 KiB and get 8192 bytes back with no signal.
write,pwrite64,writev,pwritevandpwritev2cut a request at the soft limit and refuse one that starts at or past it;truncate,ftruncateandfallocaterefuse to grow a file past it;sendfileandsplicestop at the limit.copy_file_rangenow rejects bad ends before copying — EISDIR for a directory, EINVAL for a non-regular file, EBADF for an input not open for reading or an output not open for writing or open O_APPEND — then cuts or refuses its whole request at the limit.truncateby path now checks and truncates through a single descriptor opened for writing, so a rename cannot put one file under the check and another under the truncate.fallocateon a read-only descriptor still returns EINVAL or 0 instead of Linux's EBADF; tracked in fallocate on a read-only descriptor does not fail with EBADF #462.Written for commit af99d21. Summary will update on new commits.