diff --git a/src/runtime/procemu.c b/src/runtime/procemu.c index ed83fcc7..f0a5ccc2 100644 --- a/src/runtime/procemu.c +++ b/src/runtime/procemu.c @@ -1589,6 +1589,45 @@ static void proc_task_collect_cb(thread_entry_t *t, void *arg) */ #define PTY_KEEPALIVE_MAX 256 #define PTY_KEEPALIVE_FREE (-1) +/* Group that owns pty slaves. Linux distributions mount devpts with gid=5 + * ("tty") and glibc's grantpt(3) looks that group up before deciding whether + * the slave needs chowning. + */ +#define PTY_SLAVE_TTY_GID 5u + +/* Parse the N out of "/dev/pts/N". Returns false for the directory itself, a + * missing or non-numeric tail, or trailing garbage. + * + * Deliberately stricter than strtoul, which would take leading whitespace, a + * "+" sign and leading zeros. devpts dentries are decimal and canonical, so + * Linux answers ENOENT for "/dev/pts/016" even while slave 16 is open, and + * accepting the alias here would let one live slave answer under many names -- + * for its stat, its statfs identity, and for whether chmod and chown are + * intercepted at all. + */ +static bool pty_slave_num_from_path(const char *path, uint32_t *out) +{ + if (!path || strncmp(path, "/dev/pts/", 9) != 0) + return false; + const char *digits = path + 9; + if (!*digits) + return false; + /* "0" is the only name that may start with a zero. */ + if (digits[0] == '0' && digits[1] != '\0') + return false; + + unsigned long n = 0; + for (const char *d = digits; *d; d++) { + if (*d < '0' || *d > '9') + return false; + if (n > (UINT32_MAX - (unsigned long) (*d - '0')) / 10) + return false; + n = n * 10 + (unsigned long) (*d - '0'); + } + if (out) + *out = (uint32_t) n; + return true; +} /* PTY_SLAVE_PATH_MAX lives in procemu.h so this table and the fork-IPC payload * (proc_pty_ipc_entry_t) cannot drift apart. */ @@ -1954,6 +1993,16 @@ static int pty_lookup_slave_path(uint32_t linux_pts_num, return 0; } +bool proc_pty_slave_stat(const char *path, struct stat *out) +{ + if (!path || strncmp(path, "/dev/pts/", 9) != 0 || !path[9]) + return false; + struct stat st; + if (proc_intercept_stat(path, out ? out : &st) != 0) + return false; + return true; +} + static int pty_open_slave(uint32_t linux_pts_num, int linux_flags) { int oflags = translate_open_flags(linux_flags) & @@ -2816,14 +2865,8 @@ int proc_intercept_open(const guest_t *g, * Linux devpts behavior for an unallocated slave number. */ if (!strncmp(path, "/dev/pts/", 9)) { - const char *digits = path + 9; - if (!*digits) { - errno = ENOENT; - return -1; - } - char *endp; - unsigned long n = strtoul(digits, &endp, 10); - if (endp == digits || *endp != '\0' || n > UINT32_MAX) { + uint32_t n; + if (!pty_slave_num_from_path(path, &n)) { errno = ENOENT; return -1; } @@ -3539,14 +3582,8 @@ int proc_intercept_stat(const char *path, struct stat *st) return 0; } if (!strncmp(path, "/dev/pts/", 9)) { - const char *digits = path + 9; - if (!*digits) { - errno = ENOENT; - return -1; - } - char *endp; - unsigned long n = strtoul(digits, &endp, 10); - if (endp == digits || *endp != '\0' || n > UINT32_MAX) { + uint32_t n; + if (!pty_slave_num_from_path(path, &n)) { errno = ENOENT; return -1; } @@ -3567,8 +3604,15 @@ int proc_intercept_stat(const char *path, struct stat *st) memset(st, 0, sizeof(*st)); st->st_mode = S_IFCHR | 0620; st->st_nlink = 1; - st->st_uid = host_st.st_uid; - st->st_gid = host_st.st_gid; + /* devpts gives the slave to whoever opened the master, group tty -- + * that is what grantpt(3) expects to find. Reporting the host owner + * instead makes glibc see a foreign uid, try to chown the slave, fail, + * and fall back to exec'ing the pt_chown helper, which does not exist + * on a modern distro: grantpt then fails with ENOENT and no pty can be + * allocated even though the master opened fine. + */ + st->st_uid = (uid_t) proc_get_uid(); + st->st_gid = (gid_t) PTY_SLAVE_TTY_GID; /* macOS dev_t = (major << 24) | minor; the fs-stat translation layer * (mac_to_linux_dev) re-encodes that into Linux's split major/minor * layout, so storing 136 in the macOS-major slot makes glibc's diff --git a/src/runtime/procemu.h b/src/runtime/procemu.h index 32d8767a..cd8e75c4 100644 --- a/src/runtime/procemu.h +++ b/src/runtime/procemu.h @@ -11,6 +11,7 @@ #pragma once +#include #include #include #include @@ -45,6 +46,17 @@ int proc_intercept_readlink(const char *path, char *buf, size_t bufsiz); */ int proc_intercept_stat(const char *path, struct stat *mac_st); +/* True when PATH names a live Unix98 pty slave (/dev/pts/N whose master is + * open), filling out with the same synthesized stat proc_intercept_stat would + * return. Such a path has no host backing, so metadata operations on it must be + * answered here rather than passed through to the host filesystem. + * + * Answering and stat'ing together keeps callers that need both from walking the + * locked pty table twice, which would leave a window where the slave goes away + * between the test and the stat. out may be NULL for a pure test. + */ +bool proc_pty_slave_stat(const char *path, struct stat *out); + /* Intercept writes to synthetic proc files that need stateful behavior. * Returns 1 if handled (with *written_out set), 0 if not intercepted, or -1 on * error with errno set. diff --git a/src/syscall/fs-stat.c b/src/syscall/fs-stat.c index 7b14d174..cd737f6b 100644 --- a/src/syscall/fs-stat.c +++ b/src/syscall/fs-stat.c @@ -369,6 +369,67 @@ static bool statfs_path_is_proc(const char *path) return !strncmp(path, "/proc", 5) && (path[5] == '\0' || path[5] == '/'); } +/* /dev/pts itself and the pty slaves under it. /dev/ptmx is the multiplexer + * that hands out those slaves; Linux reports devpts for a master fd too. + */ +/* How statfs should answer for a path under the virtual devpts mount. */ +typedef enum { + DEVPTS_UNRELATED = 0, /* not under /dev/pts; carry on */ + DEVPTS_MOUNT, /* the mount point or a live slave: synthesize */ + DEVPTS_ABSENT, /* under /dev/pts but no such slave: ENOENT */ +} devpts_class_t; + +static devpts_class_t statfs_devpts_class(const char *path) +{ + if (!path || strncmp(path, "/dev/pts", 8) != 0) + return DEVPTS_UNRELATED; + if (path[8] != '\0' && path[8] != '/') + return DEVPTS_UNRELATED; /* "/dev/ptsfoo" is an ordinary name */ + + const char *tail = path + 8; + while (*tail == '/') + tail++; + if (!*tail) + return DEVPTS_MOUNT; /* "/dev/pts", "/dev/pts/", "/dev/pts//" */ + + /* The mount point exists for as long as the pty layer does. A particular + * slave does not: Linux answers ENOENT for an unallocated or malformed + * /dev/pts/N. Report that rather than falling through to the host, so the + * answer cannot depend on whether the sysroot happens to carry a file of + * the same name -- the devpts mount shadows whatever is underneath it, and + * the stat and open intercepts already treat the directory that way. + * + * Only the canonical spelling of a slave resolves, matching those + * intercepts: "/dev/pts/0" is the dentry, "/dev/pts/00" and "/dev/pts/./0" + * are not. Non-canonical spellings land here as ENOENT rather than + * resolving, which diverges from a real kernel for the "." form and is + * consistent across every /dev/pts intercept. + * + * /dev/ptmx is deliberately not claimed. Which filesystem backs it depends + * on whether it resolves to /dev/pts/ptmx or to the devtmpfs node, the same + * ambiguity that keeps sys_fstatfs from answering for a master fd, and + * nothing asks: glibc's getpt statfs's /dev/pts and /dev, never /dev/ptmx. + */ + return proc_pty_slave_stat(path, NULL) ? DEVPTS_MOUNT : DEVPTS_ABSENT; +} + +/* devpts is virtualized rather than host-backed, so answer synthetically. + * Matches what Linux reports for a devpts mount: no blocks, no inodes. + */ +static void fill_devpts_statfs(linux_statfs_t *lin) +{ + memset(lin, 0, sizeof(*lin)); + lin->f_type = 0x1cd1; /* DEVPTS_SUPER_MAGIC */ + lin->f_bsize = 4096; + lin->f_blocks = 0; + lin->f_bfree = 0; + lin->f_bavail = 0; + lin->f_files = 0; + lin->f_ffree = 0; + lin->f_namelen = 255; + lin->f_frsize = 4096; +} + static void fill_proc_statfs(linux_statfs_t *lin) { memset(lin, 0, sizeof(*lin)); @@ -431,6 +492,23 @@ static int64_t sys_statfs_impl(guest_t *g, } } + /* 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 + * glibc closes a perfectly good master -- breaking Unix98 pty allocation + * for every glibc program. Answer from the virtual filesystem instead. + */ + devpts_class_t devpts = statfs_devpts_class(tx.intercept_path); + if (devpts == DEVPTS_ABSENT) + return -LINUX_ENOENT; + if (devpts == DEVPTS_MOUNT) { + linux_statfs_t lin_st; + fill_devpts_statfs(&lin_st); + if (guest_write_small(g, buf_gva, &lin_st, sizeof(lin_st)) < 0) + return -LINUX_EFAULT; + return 0; + } + /* Report /dev/shm and its leaves as tmpfs, from the backing dir. statfs() * on the leaf would follow a symlink onto the host and leak the host fs * identity, so answer synthetically; lstat is the nofollow existence probe. @@ -480,6 +558,11 @@ int64_t sys_statfs(guest_t *g, uint64_t path_gva, uint64_t buf_gva) int64_t sys_fstatfs(guest_t *g, int fd, uint64_t buf_gva) { + /* Deliberately no devpts case for a pty master fd: Linux answers from + * whatever filesystem provides /dev/ptmx, which is devpts only when it is + * the bind-mounted /dev/pts/ptmx and tmpfs or devtmpfs otherwise. There is + * no single correct value to report, and nothing needs one. + */ fd_entry_t snap; memset(&snap, 0, sizeof(snap)); if (fd_snapshot(fd, &snap) && statfs_path_is_proc(snap.proc_path)) { diff --git a/src/syscall/fs.c b/src/syscall/fs.c index a7ce56af..ab64d5c4 100644 --- a/src/syscall/fs.c +++ b/src/syscall/fs.c @@ -2709,6 +2709,20 @@ int64_t sys_fchmodat(guest_t *g, if (rc != INT64_MIN) return rc; + /* A pty slave has no host file to chmod -- its mode comes from the pty + * layer. Passing this through would hit the host and fail with ENOENT, + * which is what grantpt(3) does when it decides the slave's mode needs + * adjusting: it would then fall back to the pt_chown helper and fail. + * + * The requested mode is accepted but not retained, so a later stat still + * reports the 0620 the pty layer synthesizes. grantpt only ever asks for + * the owner-access bits it is about to hand out, so nothing observes the + * difference; keeping it would need per-slave state that also has to cross + * the fork-IPC boundary. + */ + if (proc_pty_slave_stat(tx.intercept_path, NULL)) + return 0; + host_fd_ref_t dir_ref; if (host_dirfd_ref_open(dirfd, &dir_ref) < 0) return -LINUX_EBADF; @@ -2860,6 +2874,28 @@ int64_t sys_fchownat(guest_t *g, if (rc != INT64_MIN) return rc; + /* A pty slave has no host file to chown -- its owner comes from the pty + * layer. Accept only a request that leaves the reported owner alone; + * anything else would have to be remembered to be observable, so refuse + * rather than report a success the next stat() contradicts. + * + * Not for grantpt(3): the synthesized stat already reports proc_get_uid(), + * so glibc's "chown only when st_uid != getuid()" test is false by + * construction and musl's grantpt is a no-op. This exists so that a request + * naming some other owner is refused instead of silently lost. The known + * divergence is a privileged guest -- login(1) or sshd handing a tty to a + * user -- which Linux would allow and which is refused here, because + * reporting success without retaining the owner would be the worse lie. + */ + struct stat pty_st; + if (proc_pty_slave_stat(tx.intercept_path, &pty_st)) { + bool keeps_owner = + owner == (uint32_t) -1 || owner == (uint32_t) pty_st.st_uid; + bool keeps_group = + group == (uint32_t) -1 || group == (uint32_t) pty_st.st_gid; + return (keeps_owner && keeps_group) ? 0 : -LINUX_EPERM; + } + host_fd_ref_t dir_ref; if (host_dirfd_ref_open(dirfd, &dir_ref) < 0) return -LINUX_EBADF; diff --git a/tests/test-devpts.c b/tests/test-devpts.c new file mode 100644 index 00000000..5694adeb --- /dev/null +++ b/tests/test-devpts.c @@ -0,0 +1,314 @@ +/* devpts identification and Unix98 pty allocation. + * + * glibc's posix_openpt() opens /dev/ptmx and then confirms devpts is mounted + * before handing the master back (sysdeps/unix/sysv/linux/getpt.c). If statfs + * does not identify /dev/pts as devpts, glibc closes the master it just opened + * and every glibc pty consumer fails -- terminal emulators, script(1), expect, + * tmux, screen, sshd, anything using openpty()/forkpty(). + * + * Copyright 2026 elfuse contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#define DEVPTS_SUPER_MAGIC 0x1cd1 + +/* Fail loudly instead of hanging if the pty never delivers the payload. */ +static void on_alarm(int sig) +{ + (void) sig; + static const char msg[] = "\ntest-devpts: TIMEOUT waiting on pty read\n"; + ssize_t ignored = write(2, msg, sizeof(msg) - 1); + (void) ignored; + _exit(1); +} + +int main(void) +{ + int failures = 0; + + /* 1. statfs(/dev/pts) must report devpts -- the check glibc performs. */ + printf("test-devpts: 1. statfs(/dev/pts) reports devpts... "); + struct statfs pts_fs; + if (statfs("/dev/pts", &pts_fs) != 0) { + printf("FAIL (statfs: %m)\n"); + failures++; + } else if ((unsigned long) pts_fs.f_type != DEVPTS_SUPER_MAGIC) { + printf("FAIL (f_type=0x%lx, want 0x%x)\n", + (unsigned long) pts_fs.f_type, DEVPTS_SUPER_MAGIC); + failures++; + } else { + printf("PASS (f_type=0x%lx)\n", (unsigned long) pts_fs.f_type); + } + + /* 2. posix_openpt() must hand back a usable master. */ + printf("test-devpts: 2. posix_openpt()... "); + int master = posix_openpt(O_RDWR | O_NOCTTY); + if (master < 0) { + printf("FAIL (%m)\n"); + failures++; + /* Everything below needs the master; report what we have. */ + printf("test-devpts: %s\n", failures ? "FAILED" : "all tests passed"); + return failures != 0; + } + printf("PASS (fd=%d)\n", master); + + /* No assertion on fstatfs(master): Linux answers from whatever filesystem + * provides /dev/ptmx, which is devpts only when it is the bind-mounted + * /dev/pts/ptmx and tmpfs or devtmpfs otherwise. There is no portable + * value to expect. + * + * The slave fd is not ambiguous that way -- on Linux it really does live on + * devpts -- and elfuse still does not answer devpts for it. That is left + * alone knowingly: sys_fstatfs passes the macOS f_type through untranslated + * for every filesystem, so a guest sniffing fs types sees macOS mount-type + * indices whatever it opens, and fixing the slave alone would be one more + * spot patch on the mapping gap that is the other half of the issue this + * change addresses. grantpt(3) is unaffected: the fd it checks is the + * master. + */ + + /* 3. The rest of the Unix98 sequence must work on that master. */ + printf("test-devpts: 3. grantpt/unlockpt/ptsname... "); + char slave_name[256]; + int seq_ok = 1; + if (grantpt(master) != 0) { + printf("FAIL (grantpt: %m)\n"); + seq_ok = 0; + } else if (unlockpt(master) != 0) { + printf("FAIL (unlockpt: %m)\n"); + seq_ok = 0; + } else if (ptsname_r(master, slave_name, sizeof(slave_name)) != 0) { + printf("FAIL (ptsname_r: %m)\n"); + seq_ok = 0; + } else { + printf("PASS (%s)\n", slave_name); + } + if (!seq_ok) + failures++; + + /* 4. Data must survive a round trip through the pair. */ + printf("test-devpts: 4. master/slave round trip... "); + if (!seq_ok) { + printf("SKIP (no slave name)\n"); + } else { + int slave = open(slave_name, O_RDWR | O_NOCTTY); + if (slave < 0) { + printf("FAIL (open slave: %m)\n"); + failures++; + } else { + /* Raw mode: in canonical mode the slave read would block until a + * newline arrives, and ECHO would race the payload back at the + * master. Both would hang the run rather than fail it, so a + * termios failure has to stop here -- carrying on would spend the + * whole alarm window below only to report a round-trip failure + * that is really a setup failure. + */ + struct termios tio; + if (tcgetattr(slave, &tio) != 0) { + printf("FAIL (tcgetattr on slave: %m)\n"); + failures++; + close(slave); + goto round_trip_done; + } + cfmakeraw(&tio); + if (tcsetattr(slave, TCSANOW, &tio) != 0) { + printf("FAIL (tcsetattr raw mode: %m)\n"); + failures++; + close(slave); + goto round_trip_done; + } + /* Belt and braces: a stuck read must fail the test, not wedge CI. + */ + signal(SIGALRM, on_alarm); + alarm(10); + + static const char msg[] = "devpts"; + const size_t want = sizeof(msg) - 1; + char buf[sizeof(msg)]; + memset(buf, 0, sizeof(buf)); + + /* A pty may transfer fewer bytes per call than asked for, in + * either direction, so drive both to completion instead of + * assuming one call moves the whole payload -- a legal short read + * would otherwise fail a working pair. The alarm above bounds the + * exchange, so a stall still fails rather than looping forever. + */ + size_t sent = 0; + while (sent < want) { + ssize_t n = write(master, msg + sent, want - sent); + if (n <= 0) + break; + sent += (size_t) n; + } + size_t got = 0; + while (sent == want && got < want) { + ssize_t n = read(slave, buf + got, want - got); + if (n <= 0) + break; + got += (size_t) n; + } + alarm(0); + if (sent != want) { + printf("FAIL (wrote %zu of %zu: %m)\n", sent, want); + failures++; + } else if (got != want || memcmp(buf, msg, want) != 0) { + printf("FAIL (read %zu of %zu, buf='%s')\n", got, want, buf); + failures++; + } else { + printf("PASS\n"); + } + close(slave); + } + round_trip_done:; + } + + /* 5. An unallocated or malformed slave must not claim devpts: a prefix + * match on /dev/pts would make these succeed, where Linux reports ENOENT. + */ + printf("test-devpts: 5. bogus slave paths are ENOENT... "); + { + /* The last four are aliases of a live slave that strtoul would take: + * leading zeros, a sign, leading whitespace. Linux answers ENOENT for + * every one of them, and accepting any would let one slave answer + * under several names -- for stat, for devpts identity, and for + * whether chmod and chown are intercepted at all. + */ + char alias_zero[64], alias_plus[64], alias_space[64]; + snprintf(alias_zero, sizeof(alias_zero), "/dev/pts/0%s", + seq_ok ? slave_name + 9 : "0"); + snprintf(alias_plus, sizeof(alias_plus), "/dev/pts/+%s", + seq_ok ? slave_name + 9 : "0"); + snprintf(alias_space, sizeof(alias_space), "/dev/pts/ %s", + seq_ok ? slave_name + 9 : "0"); + const char *const bogus[] = {"/dev/pts/99999", "/dev/pts/bogus", + "/dev/pts/1x", alias_zero, + alias_plus, alias_space}; + int bad = 0; + for (size_t i = 0; i < sizeof(bogus) / sizeof(bogus[0]); i++) { + struct statfs b; + errno = 0; + if (statfs(bogus[i], &b) == 0) { + printf("FAIL (%s resolved, f_type=0x%lx) ", bogus[i], + (unsigned long) b.f_type); + bad++; + } else if (errno != ENOENT) { + /* Failing is not enough: EACCES or ENOTDIR would mean the + * lookup went wrong somewhere else rather than the slave + * simply not existing. + */ + printf("FAIL (%s errno=%d (%s), want ENOENT) ", bogus[i], errno, + strerror(errno)); + bad++; + } + } + if (bad) + failures++; + else + printf("PASS\n"); + } + + /* 6. chown must not claim to have given the slave away. Keeping the + * reported owner is what grantpt(3) asks for and must succeed; handing it + * to another uid is refused rather than silently discarded. + */ + printf("test-devpts: 6. chown semantics on the slave... "); + if (!seq_ok) { + printf("SKIP (no slave name)\n"); + } else { + struct stat st; + if (stat(slave_name, &st) != 0) { + printf("FAIL (stat slave: %m)\n"); + failures++; + } else if (chown(slave_name, st.st_uid, st.st_gid) != 0) { + printf("FAIL (no-op chown rejected: %m)\n"); + failures++; + } else if (chown(slave_name, (uid_t) -1, (gid_t) -1) != 0) { + printf("FAIL (unchanged chown rejected: %m)\n"); + failures++; + } else { + /* Giving the slave to another uid must not silently "succeed". */ + uid_t other = st.st_uid == 0 ? 12345 : 0; + int rc = chown(slave_name, other, (gid_t) -1); + struct stat after; + if (rc == 0 && stat(slave_name, &after) == 0 && + after.st_uid != other) { + printf("FAIL (chown reported success but owner unchanged)\n"); + failures++; + } else { + printf("PASS\n"); + } + } + } + + /* 7. chmod on the slave must succeed. This is the branch grantpt(3) needs + * on glibc: it derives the mode it wants from getgrnam("tty"), and on a + * macOS host that group is gid 4 while the synthesized slave reports gid 5 + * (PTY_SLAVE_TTY_GID, what Linux devpts uses), so the group never matches, + * the wanted mode comes out 0600 against the synthesized 0620, and grantpt + * chmods every time rather than accepting the mode as-is. Passing that + * through to the host would fail ENOENT and send grantpt to pt_chown. + * + * Succeeding is the whole portable contract, so the follow-up stat accepts + * two answers. A real kernel keeps the new mode and reports 0600. elfuse + * accepts the request without retaining it -- keeping it would need + * per-slave state that also has to cross the fork-IPC boundary -- and goes + * on reporting the synthesized 0620. Anything else means the chmod landed + * somewhere it should not have. + */ + printf("test-devpts: 7. chmod on the slave is accepted... "); + if (!seq_ok) { + printf("SKIP (no slave name)\n"); + } else if (chmod(slave_name, 0600) != 0) { + printf("FAIL (chmod rejected: %m)\n"); + failures++; + } else { + struct stat after; + unsigned mode = 0; + if (stat(slave_name, &after) != 0) { + printf("FAIL (stat after chmod: %m)\n"); + failures++; + } else if ((mode = after.st_mode & 07777) == 0600) { + printf("PASS (mode retained 0600)\n"); + } else if (mode == 0620) { + printf("PASS (accepted, stat still reports 0620)\n"); + } else { + printf("FAIL (mode=%04o, want 0600 or the synthesized 0620)\n", + mode); + failures++; + } + } + + /* 8. Negative control: an ordinary directory must not claim devpts, so a + * blanket f_type would not pass this file. + */ + printf("test-devpts: 8. ordinary path is not devpts... "); + struct statfs root_fs; + if (statfs("/", &root_fs) != 0) { + printf("FAIL (statfs /: %m)\n"); + failures++; + } else if ((unsigned long) root_fs.f_type == DEVPTS_SUPER_MAGIC) { + printf("FAIL (/ reports devpts)\n"); + failures++; + } else { + printf("PASS (f_type=0x%lx)\n", (unsigned long) root_fs.f_type); + } + + close(master); + + if (failures == 0) + printf("test-devpts: all tests passed -- PASS\n"); + else + printf("test-devpts: %d failed\n", failures); + return failures != 0; +} diff --git a/tests/test-matrix.sh b/tests/test-matrix.sh index 8565766f..529158f5 100755 --- a/tests/test-matrix.sh +++ b/tests/test-matrix.sh @@ -629,6 +629,8 @@ run_unit_tests() printf "\nSyscall coverage\n" test_check "$runner" "test-file-ops" "0 failed" "$bindir/test-file-ops" + test_check "$runner" "test-devpts" "all tests passed -- PASS" \ + "$bindir/test-devpts" test_check "$runner" "test-sysinfo" "0 failed" "$bindir/test-sysinfo" test_check "$runner" "test-io-opt" "0 failed" "$bindir/test-io-opt" test_check "$runner" "test-poll" "0 failed" "$bindir/test-poll"