Skip to content

Enforce RLIMIT_FSIZE on regular-file writes - #463

Open
xalestar wants to merge 6 commits into
sysprog21:mainfrom
xalestar:rlimit-fsize
Open

xalestar wants to merge 6 commits into
sysprog21:mainfrom
xalestar:rlimit-fsize

Conversation

@xalestar

@xalestar xalestar commented Oct 23, 2026 •

Copy link
Copy Markdown
Collaborator

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.

  1. copy_file_range runs generic_file_rw_checks first: EISDIR for a directory, EINVAL for another non-regular file, EBADF for a wrong access mode or an O_APPEND output.
  2. sendfile and splice refuse an O_APPEND output with EINVAL, as do_splice_direct and do_splice do. A pipe output is still accepted.
  3. splice honors SPLICE_F_NONBLOCK on the input, inherited stdin included. A full pipe output answers EAGAIN before anything is read, so no input is lost.
  4. pwrite64 and pwritev return EINVAL when offset + count overflows off_t (rw_verify_area). A pipe still gets ESPIPE.
  5. elfuse ignores host SIGXFSZ, so a guest that raises its soft limit past the host one gets EFBIG instead of killing elfuse (elfuse is killed by SIGXFSZ at startup under a host RLIMIT_FSIZE below 1 TiB #437).

The last commit enforces the limit, on regular files only:

  • write, pwrite64, writev, pwritev and pwritev2 cut a request at the soft limit, and refuse one that starts at or past it (generic_write_check_limits).
  • truncate, ftruncate and fallocate refuse to grow a file past it (inode_newsize_ok). truncate by path checks with stat and faccessat and never opens the file, so the guest's record locks survive.
  • sendfile and splice stop at the limit, and raise SIGXFSZ only when the input still has a byte to give.
  • copy_file_range cuts or refuses the whole request up front (generic_copy_file_checks).
  • Pipes, sockets and descriptors not open for writing are not limited.

Review focus:

  • sys_rlimit_send_xfsz queues SIGXFSZ on the calling thread (send_sig to current), falling back to the process only when the thread is not found.
  • O_APPEND and RWF_APPEND writes, pwrite on an O_APPEND descriptor included, are measured from end of file, since generic_write_checks moves the position there.
  • The write fast path in syscall_dispatch sends every write to sys_write while the limit is finite.
  • copy_past_fsize waits for a byte on an empty pipe before refusing, as splice_from_pipe_next does, and is listed as restartable in scripts/check-eintr-contract.py: it reads nothing and waits only while the copy has moved nothing.

Left out: fallocate on 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 lint and make check pass; make test-matrix-elfuse-aarch64 gives 316 passed, 0 failed, 10 skipped; make test-matrix-qemu-aarch64 gives 290 passed, 0 failed, 35 skipped. Each commit builds. test-rlimit-fsize and the new test-io-opt and test-readv-writev steps pass on elfuse and on the qemu reference kernel, and fail on an elfuse built from e9fdbbb.

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, pwritev and pwritev2 cut a request at the soft limit and refuse one that starts at or past it; truncate, ftruncate and fallocate refuse to grow a file past it; sendfile and splice stop at the limit.
  • copy_file_range now 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.
  • truncate by 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.
  • Each refusal sends SIGXFSZ to the calling thread, not to a sibling that has it unblocked. A copy that hits the limit before moving anything returns the input's read error rather than 0.
  • The limit applies only to regular files; pipes, sockets and descriptors not open for writing are unaffected. O_APPEND and RWF_APPEND writes are measured from end of file, and zero-length writes are never refused.
  • fallocate on 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.

View guided diff

cubic-dev-ai[bot]

This comment was marked as resolved.

Comment thread src/syscall/io.c Outdated
Comment thread src/syscall/io.c Outdated
Comment thread src/syscall/io.c
* 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)) {

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.

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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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.
@xalestar
xalestar marked this pull request as ready for review October 24, 2026 10:50
@xalestar
xalestar requested a review from jserv October 24, 2026 11:02
Comment thread src/syscall/io.c
return 1;
}

for (;;) {

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.

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:

Suggested change
for (;;) {
int in_fl = fcntl(in_hfd, F_GETFL);
if (in_fl >= 0 && (in_fl & O_ACCMODE) == O_WRONLY)
return -LINUX_EBADF;
for (;;) {

@xalestar xalestar Oct 24, 2026 •

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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.

Comment thread src/syscall/fs.c Outdated
* 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);

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(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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Superseded in d45cb35: truncate by path no longer opens anything, even a regular file. See #463 (comment).

Comment thread src/syscall/io.c
/* 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);

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 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.

@xalestar xalestar Oct 24, 2026 •

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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.

Comment thread src/syscall/io.c
* 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)) {

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.

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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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.

Comment thread src/syscall/io.c
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,

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.

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.

@xalestar xalestar Oct 24, 2026 •

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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.

Comment thread src/syscall/sys.c
memory_order_relaxed);
}

void sys_rlimit_send_xfsz(void)

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.

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.

@xalestar xalestar Oct 24, 2026 •

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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.

Comment thread src/syscall/fs.c Outdated
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);

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.

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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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.

Comment thread src/syscall/io.c Outdated
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;

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 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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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.

Comment thread src/syscall/io.c
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);

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.

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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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.

Comment thread src/syscall/io.c Outdated
* O_NONBLOCK the descriptors carry (splice_from_pipe_next and
* splice_to_pipe, Linux fs/splice.c).
*/
if (flags & LINUX_SPLICE_F_NONBLOCK) {

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.

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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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.
Comment thread src/syscall/io.c
/* 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);

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.

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.

Comment thread src/syscall/io.c
}

int64_t fsz =
fsize_limit_writev(host_ref.fd, offset, true, host_iov.iov, &iovcnt);

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.

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.

Comment thread src/syscall/io.c
err = -LINUX_EFBIG;
goto out_copy_file_range;
}
if (len > room)

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.

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.

Comment thread src/syscall/io.c
* read instead.
*/
bool nonblock = (flags & LINUX_SPLICE_F_NONBLOCK) != 0;
st.in_st.call_nonblock = nonblock;

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.

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.

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.

elfuse is killed by SIGXFSZ at startup under a host RLIMIT_FSIZE below 1 TiB

2 participants