From 87e00a3ea2bf58424be41c7731b9d9cccf085293 Mon Sep 17 00:00:00 2001 From: Abhinav Agarwal Date: Fri, 23 Oct 2026 17:50:55 -0700 Subject: [PATCH 1/5] test: add unit tests for cache.c Includes cache.c and runs it against a fake backend and clock, so no mount and no sleeping. Known gaps are strict expected failures. --- test/meson.build | 11 + test/test_cache.c | 1793 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 1804 insertions(+) create mode 100644 test/test_cache.c diff --git a/test/meson.build b/test/meson.build index 4b26321f..5d352a33 100644 --- a/test/meson.build +++ b/test/meson.build @@ -5,6 +5,17 @@ custom_target('test_scripts', input: test_scripts, command: ['cp', '-fPp', '@INPUT@', meson.current_build_dir() ]) +# Unit tests for cache.c. The test #includes cache.c itself, so it is built +# from its own source only. They need no mount and are in the 'unit' suite. +# Run them with 'meson test --suite unit': the wrong_cmd test below always +# fails, so a plain 'meson test' does too. +test_cache = executable('test_cache', 'test_cache.c', + include_directories: include_dirs, + dependencies: sshfs_deps, + c_args: ['-DFUSE_USE_VERSION=31'], + install: false) +test('cache', test_cache, suite: 'unit') + # Provide something helpful when running 'ninja test' wrong_cmd = executable('wrong_command', 'wrong_command.c', install: false) diff --git a/test/test_cache.c b/test/test_cache.c new file mode 100644 index 00000000..0022f056 --- /dev/null +++ b/test/test_cache.c @@ -0,0 +1,1793 @@ +/* + Unit tests for cache.c + + cache.c is #included so that its static helpers and the global cache state + are reachable. The backend is a small in-memory filesystem that counts the + getattr, readlink and directory calls it gets, and time() is replaced by a + clock the tests advance by hand, + so nothing sleeps and nothing depends on thread timing. A call that is in + flight while something else happens is simulated by a hook that the fake + backend runs after it has computed its reply and before it returns it. + + Output is one TAP-style line per case. A case listed with an xfail reason + documents a known gap in the cache: it passes the run while the gap is there + and fails the run (XPASS) once it is closed. Only the assertions written + with XCHECK can be expected to fail; any other failed check is a plain + failure. +*/ + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include "../cache.h" + +#define T0 1000000 + +static time_t test_now = T0; + +static time_t test_time(time_t *tp) +{ + if (tp) + *tp = test_now; + return test_now; +} + +/* + * Count the strings cache.c gets from g_strndup() that are still allocated, + * so a missing g_free() shows up in every build and not only under LSan. The + * addresses are stored complemented so that LSan does not treat this table as + * a reference to them. + */ +#define MAX_TRACKED 256 +static uintptr_t tracked[MAX_TRACKED]; +static int ntracked; + +static char *test_strndup(const char *s, gsize n) +{ + char *p = g_strndup(s, n); + + if (ntracked == MAX_TRACKED) + abort(); + tracked[ntracked++] = ~(uintptr_t) p; + return p; +} + +static void test_free(gpointer p) +{ + int i; + + for (i = 0; i < ntracked; i++) { + if (tracked[i] == ~(uintptr_t) p) { + tracked[i] = tracked[--ntracked]; + break; + } + } + g_free(p); +} + +#undef g_strndup +#undef g_free +#define g_strndup(s, n) test_strndup(s, n) +#define g_free(p) test_free(p) +#define time(tp) test_time(tp) +#include "../cache.c" +#undef time +#undef g_free +#undef g_strndup + +/* ---- harness ---- */ + +static int cur_fails; /* failed CHECKs in the running case */ +static int cur_xfails; /* failed XCHECKs in the running case */ +static const char *cur_sub; /* sub-case name, for loops over a table */ + +static void check_failed(int line, const char *expr, int known) +{ + if (cur_sub) + printf("# %s: line %d: %s\n", cur_sub, line, expr); + else + printf("# line %d: %s\n", line, expr); + if (known) + cur_xfails++; + else + cur_fails++; +} + +#define CHECK(cond) \ + do { if (!(cond)) check_failed(__LINE__, #cond, 0); } while (0) +/* the assertion that a known bug makes fail */ +#define XCHECK(cond) \ + do { if (!(cond)) check_failed(__LINE__, #cond, 1); } while (0) +/* abandon the case if the setup did not work */ +#define REQUIRE(cond) \ + do { if (!(cond)) { check_failed(__LINE__, #cond, 0); return; } } while (0) + +#define ARRAY_SIZE(a) (sizeof(a) / sizeof((a)[0])) + +/* ---- fake backend: a tiny in-memory filesystem that counts calls ---- */ + +#define MAX_ENT 32 +#define PATH_LEN 64 + +struct fent { + int used; + char path[PATH_LEN]; + ino_t ino; /* names with the same ino are hard links */ + mode_t mode; + off_t size; + uid_t uid; + gid_t gid; + time_t atime; + time_t mtime; + char link[PATH_LEN]; +}; + +static struct fent ents[MAX_ENT]; +static ino_t next_ino; + +static struct { + int init, getattr, readlink, opendir, readdir, releasedir; +} calls; + +static void (*hook_getattr)(void); /* runs once, inside the next getattr */ +static void (*hook_readdir)(void); /* runs once, inside the next readdir */ +static void (*hook_readlink)(void); /* runs once, inside the next readlink */ +static int readdir_fail_at; /* fail with EIO before this entry */ +static int readdir_no_attrs; +static off_t last_readdir_offset; +static uint64_t last_readdir_fh; +static uint64_t last_releasedir_fh; +static int init_token; + +static struct fuse_operations *ops; /* what cache_wrap() returned */ + +static struct fent *ent_find(const char *path) +{ + int i; + + for (i = 0; i < MAX_ENT; i++) + if (ents[i].used && strcmp(ents[i].path, path) == 0) + return &ents[i]; + return NULL; +} + +static struct fent *ent_get(const char *path) +{ + struct fent *e = ent_find(path); + + if (!e) { + fprintf(stderr, "test bug: no entry %s\n", path); + abort(); + } + return e; +} + +static struct fent *ent_add(const char *path, mode_t mode, off_t size) +{ + struct fent *e = ent_find(path); + int i; + + if (!e) { + for (i = 0; i < MAX_ENT && ents[i].used; i++) + ; + if (i == MAX_ENT) + abort(); + e = &ents[i]; + } + memset(e, 0, sizeof(*e)); + e->used = 1; + g_strlcpy(e->path, path, sizeof(e->path)); + e->ino = ++next_ino; + e->mode = mode; + e->size = size; + return e; +} + +static void mk_dir(const char *path) +{ + ent_add(path, S_IFDIR | 0755, 0); +} + +static void mk_file(const char *path, off_t size) +{ + ent_add(path, S_IFREG | 0644, size); +} + +static void mk_link(const char *path, const char *target) +{ + struct fent *e = ent_add(path, S_IFLNK | 0777, 0); + + g_strlcpy(e->link, target, sizeof(e->link)); +} + +static int ent_create(const char *path, mode_t mode) +{ + if (ent_find(path)) + return -EEXIST; + ent_add(path, mode, 0); + return 0; +} + +static int ent_remove(const char *path) +{ + struct fent *e = ent_find(path); + + if (!e) + return -ENOENT; + e->used = 0; + return 0; +} + +static const char *base_name(const char *path) +{ + const char *s = strrchr(path, '/'); + + return s ? s + 1 : path; +} + +/* is path a direct child of the directory dir? */ +static int is_child(const char *dir, const char *path) +{ + size_t n = strlen(dir); + const char *rest; + + if (n == 1) { + rest = path + 1; + return path[0] == '/' && *rest && !strchr(rest, '/'); + } + if (strncmp(path, dir, n) != 0 || path[n] != '/') + return 0; + rest = path + n + 1; + return *rest && !strchr(rest, '/'); +} + +static nlink_t count_names(ino_t ino) +{ + nlink_t n = 0; + int i; + + for (i = 0; i < MAX_ENT; i++) + if (ents[i].used && ents[i].ino == ino) + n++; + return n; +} + +/* a change made through one name of a file shows through its other names */ +static void sync_inode(const struct fent *src) +{ + int i; + + for (i = 0; i < MAX_ENT; i++) { + struct fent *o = &ents[i]; + + if (!o->used || o == src || o->ino != src->ino) + continue; + o->mode = src->mode; + o->size = src->size; + o->uid = src->uid; + o->gid = src->gid; + o->atime = src->atime; + o->mtime = src->mtime; + } +} + +static void fill_stat(struct stat *st, const struct fent *e) +{ + memset(st, 0, sizeof(*st)); + st->st_ino = e->ino; + st->st_mode = e->mode; + st->st_size = e->size; + st->st_uid = e->uid; + st->st_gid = e->gid; + st->st_atime = e->atime; + st->st_mtime = e->mtime; + st->st_nlink = count_names(e->ino); +} + +static void run_hook(void (**hook)(void)) +{ + void (*h)(void) = *hook; + + *hook = NULL; + if (h) + h(); +} + +static void *fake_init(struct fuse_conn_info *conn, struct fuse_config *cfg) +{ + (void) conn; + calls.init++; + cfg->nullpath_ok = 1; + return &init_token; +} + +static int fake_getattr(const char *path, struct stat *st, + struct fuse_file_info *fi) +{ + const struct fent *e; + + (void) fi; + calls.getattr++; + e = ent_find(path); + if (!e) + return -ENOENT; + fill_stat(st, e); + /* the reply is computed; anything that happens now comes too late */ + run_hook(&hook_getattr); + return 0; +} + +static int fake_readlink(const char *path, char *buf, size_t size) +{ + const struct fent *e; + size_t n; + + calls.readlink++; + e = ent_find(path); + if (!e || !S_ISLNK(e->mode)) + return -EINVAL; + n = strlen(e->link); + if (n > size - 1) + n = size - 1; + memcpy(buf, e->link, n); + buf[n] = '\0'; + /* the reply is computed; anything that happens now comes too late */ + run_hook(&hook_readlink); + return 0; +} + +static int fake_opendir(const char *path, struct fuse_file_info *fi) +{ + (void) path; + calls.opendir++; + fi->fh = 42; + return 0; +} + +static int fake_releasedir(const char *path, struct fuse_file_info *fi) +{ + (void) path; + calls.releasedir++; + last_releasedir_fh = fi->fh; + return 0; +} + +static int fake_readdir(const char *path, void *buf, fuse_fill_dir_t filler, + off_t offset, struct fuse_file_info *fi, + enum fuse_readdir_flags flags) +{ + struct fent snap[MAX_ENT]; + int i, n = 0; + + (void) flags; + calls.readdir++; + last_readdir_offset = offset; + last_readdir_fh = fi->fh; + if (!ent_find(path)) + return -ENOENT; + for (i = 0; i < MAX_ENT; i++) + if (ents[i].used && is_child(path, ents[i].path)) + snap[n++] = ents[i]; + /* the listing is taken; changes from here on are not in it */ + run_hook(&hook_readdir); + for (i = 0; i < n; i++) { + struct stat st; + + if (i == readdir_fail_at) + return -EIO; + memset(&st, 0, sizeof(st)); + if (!readdir_no_attrs) + fill_stat(&st, &snap[i]); + if (filler(buf, base_name(snap[i].path), &st, 0, 0)) + break; + } + return 0; +} + +static int fake_mknod(const char *path, mode_t mode, dev_t rdev) +{ + (void) rdev; + return ent_create(path, mode); +} + +static int fake_mkdir(const char *path, mode_t mode) +{ + return ent_create(path, S_IFDIR | mode); +} + +static int fake_unlink(const char *path) +{ + return ent_remove(path); +} + +static int fake_rmdir(const char *path) +{ + return ent_remove(path); +} + +static int fake_symlink(const char *from, const char *to) +{ + if (ent_find(to)) + return -EEXIST; + mk_link(to, from); + return 0; +} + +static int fake_rename(const char *from, const char *to, unsigned int flags) +{ + struct fent *src, *old; + size_t n = strlen(from); + int i; + + (void) flags; + src = ent_find(from); + if (!src) + return -ENOENT; + old = ent_find(to); + if (old && old->ino == src->ino) /* the same file: nothing to do */ + return 0; + if (old) + old->used = 0; + for (i = 0; i < MAX_ENT; i++) { + struct fent *e = &ents[i]; + char *np; + + if (!e->used || strncmp(e->path, from, n) != 0 || + (e->path[n] != '\0' && e->path[n] != '/')) + continue; + np = g_strdup_printf("%s%s", to, e->path + n); + g_strlcpy(e->path, np, sizeof(e->path)); + g_free(np); + } + return 0; +} + +static int fake_link(const char *from, const char *to) +{ + const struct fent *e; + struct fent *l; + + e = ent_find(from); + if (!e) + return -ENOENT; + if (ent_find(to)) + return -EEXIST; + l = ent_add(to, e->mode, e->size); + l->ino = e->ino; + g_strlcpy(l->link, e->link, sizeof(l->link)); + sync_inode(e); + return 0; +} + +static int fake_chmod(const char *path, mode_t mode, struct fuse_file_info *fi) +{ + struct fent *e; + + (void) fi; + e = ent_find(path); + if (!e) + return -ENOENT; + e->mode = (e->mode & S_IFMT) | (mode & 07777); + sync_inode(e); + return 0; +} + +static int fake_chown(const char *path, uid_t uid, gid_t gid, + struct fuse_file_info *fi) +{ + struct fent *e; + + (void) fi; + e = ent_find(path); + if (!e) + return -ENOENT; + if (uid != (uid_t) -1) + e->uid = uid; + if (gid != (gid_t) -1) + e->gid = gid; + sync_inode(e); + return 0; +} + +static int fake_utimens(const char *path, const struct timespec tv[2], + struct fuse_file_info *fi) +{ + struct fent *e; + + (void) fi; + e = ent_find(path); + if (!e) + return -ENOENT; + e->atime = tv[0].tv_sec; + e->mtime = tv[1].tv_sec; + sync_inode(e); + return 0; +} + +static int fake_truncate(const char *path, off_t size, + struct fuse_file_info *fi) +{ + struct fent *e; + + (void) fi; + e = ent_find(path); + if (!e) + return -ENOENT; + e->size = size; + sync_inode(e); + return 0; +} + +static int fake_write(const char *path, const char *buf, size_t size, + off_t offset, struct fuse_file_info *fi) +{ + struct fent *e; + + (void) buf; + (void) fi; + e = ent_find(path); + if (!e) + return -ENOENT; + if (offset + (off_t) size > e->size) + e->size = offset + (off_t) size; + sync_inode(e); + return (int) size; +} + +static int fake_create(const char *path, mode_t mode, + struct fuse_file_info *fi) +{ + (void) fi; + return ent_create(path, S_IFREG | mode); +} + +static int fake_access(const char *path, int mask) +{ + (void) path; + (void) mask; + return 0; +} + +static struct fuse_operations fake_ops = { + .init = fake_init, + .getattr = fake_getattr, + .readlink = fake_readlink, + .opendir = fake_opendir, + .releasedir = fake_releasedir, + .readdir = fake_readdir, + .mknod = fake_mknod, + .mkdir = fake_mkdir, + .unlink = fake_unlink, + .rmdir = fake_rmdir, + .symlink = fake_symlink, + .rename = fake_rename, + .link = fake_link, + .chmod = fake_chmod, + .chown = fake_chown, + .utimens = fake_utimens, + .truncate = fake_truncate, + .write = fake_write, + .create = fake_create, + .access = fake_access, +}; + +/* ---- helpers for the tests ---- */ + +static void advance(time_t secs) +{ + test_now += secs; +} + +static void reset_state(void) +{ + g_hash_table_remove_all(cache.table); + memset(ents, 0, sizeof(ents)); + memset(&calls, 0, sizeof(calls)); + hook_getattr = NULL; + hook_readdir = NULL; + hook_readlink = NULL; + readdir_fail_at = -1; + readdir_no_attrs = 0; + next_ino = 100; + last_readdir_offset = -1; + last_readdir_fh = 0; + last_releasedir_fh = 0; + test_now = T0; + cache.stat_timeout_secs = 20; + cache.dir_timeout_secs = 20; + cache.link_timeout_secs = 20; + cache.max_size = 10000; + cache.clean_interval_secs = 60; + cache.min_clean_interval_secs = 5; + cache.last_cleaned = T0; + cache.write_ctr = 0; + mk_dir("/"); +} + +struct listing { + int n; + char name[MAX_ENT][32]; + mode_t mode[MAX_ENT]; + off_t size[MAX_ENT]; + ino_t ino[MAX_ENT]; + off_t max_off; +}; + +static int collect(void *buf, const char *name, const struct stat *st, + off_t off, enum fuse_fill_dir_flags flags) +{ + struct listing *l = buf; + + (void) flags; + if (l->n < MAX_ENT) { + g_strlcpy(l->name[l->n], name, sizeof(l->name[0])); + l->mode[l->n] = st->st_mode; + l->size[l->n] = st->st_size; + l->ino[l->n] = st->st_ino; + } + l->n++; + if (off > l->max_off) + l->max_off = off; + return 0; +} + +static int listing_index(const struct listing *l, const char *name) +{ + int i; + + for (i = 0; i < l->n && i < MAX_ENT; i++) + if (strcmp(l->name[i], name) == 0) + return i; + return -1; +} + +static int listing_has(const struct listing *l, const char *name) +{ + return listing_index(l, name) >= 0; +} + +/* list a directory the way the kernel does: open, read, release */ +static int do_readdir(const char *path, off_t offset, struct listing *l) +{ + struct fuse_file_info fi; + uint64_t dh; + int err, rel; + + memset(l, 0, sizeof(*l)); + memset(&fi, 0, sizeof(fi)); + err = ops->opendir(path, &fi); + if (err) + return err; + dh = fi.fh; + err = ops->readdir(path, l, collect, offset, &fi, 0); + fi.fh = dh; + rel = ops->releasedir(path, &fi); + return err ? err : rel; +} + +static int do_getattr(const char *path, struct stat *st) +{ + memset(st, 0, sizeof(*st)); + return ops->getattr(path, st, NULL); +} + +/* did looking up path go to the backend? */ +static int probe(const char *path) +{ + struct stat st; + int before = calls.getattr; + + (void) do_getattr(path, &st); + return calls.getattr != before; +} + +/* did listing path go to the backend? */ +static int probe_dir(const char *path) +{ + struct listing l; + int before = calls.readdir; + + (void) do_readdir(path, 0, &l); + return calls.readdir != before; +} + +/* did reading the link path go to the backend? */ +static int probe_link(const char *path) +{ + char buf[64]; + int before = calls.readlink; + + (void) ops->readlink(path, buf, sizeof(buf)); + return calls.readlink != before; +} + +/* look path up, then again: the second answer, left in st, must be cached */ +static int get_cached(const char *path, struct stat *st) +{ + int before; + + if (do_getattr(path, st)) + return -1; + before = calls.getattr; + return (do_getattr(path, st) || calls.getattr != before) ? -1 : 0; +} + +/* size or permission bits of path as cached, or -1 on error */ +static off_t size_of(const char *path) +{ + struct stat st; + + return get_cached(path, &st) ? -1 : st.st_size; +} + +static int perm_of(const char *path) +{ + struct stat st; + + return get_cached(path, &st) ? -1 : (int) (st.st_mode & 07777); +} + +/* for each letter x, create the file /x (mk_files) or look it up (get_files) */ +static void mk_files(const char *letters) +{ + char path[3] = { '/', 0, 0 }; + + for (; *letters; letters++) { + path[1] = *letters; + mk_file(path, 1); + } +} + +static void get_files(const char *letters) +{ + char path[3] = { '/', 0, 0 }; + + for (; *letters; letters++) { + path[1] = *letters; + (void) probe(path); + } +} + +/* ---- getattr ---- */ + +static void test_getattr_timeout(void) +{ + cache.stat_timeout_secs = 10; + mk_file("/f", 100); + + CHECK(size_of("/f") == 100); + ent_get("/f")->size = 200; /* behind the cache's back */ + CHECK(size_of("/f") == 100); + advance(10); /* still valid in the last second */ + CHECK(size_of("/f") == 100); + CHECK(calls.getattr == 1); + + advance(1); + CHECK(size_of("/f") == 200); + CHECK(size_of("/f") == 200); /* and cached again */ + CHECK(calls.getattr == 2); +} + +static void test_getattr_errors(void) +{ + struct stat st; + + CHECK(do_getattr("/missing", &st) == -ENOENT); + CHECK(do_getattr("/missing", &st) == -ENOENT); + CHECK(calls.getattr == 2); + mk_file("/missing", 3); /* seen at once when it appears */ + CHECK(size_of("/missing") == 3); +} + +static void test_timeouts_per_kind(void) +{ + cache.stat_timeout_secs = 5; + cache.dir_timeout_secs = 30; + cache.link_timeout_secs = 15; + mk_dir("/d"); + mk_file("/d/a", 1); + mk_link("/d/l", "target"); + (void) probe_dir("/d"); + (void) probe_link("/d/l"); + + advance(10); /* only the attributes expired */ + CHECK(probe("/d/a")); + CHECK(!probe_link("/d/l")); + CHECK(!probe_dir("/d")); + advance(10); /* the link too */ + CHECK(probe_link("/d/l")); + CHECK(!probe_dir("/d")); + advance(11); /* and then the listing */ + CHECK(probe_dir("/d")); +} + +/* ---- invalidation by modifying operations ---- */ + +enum { + OP_MKNOD, OP_MKDIR, OP_CREATE, OP_SYMLINK, OP_LINK, OP_UNLINK, + OP_RMDIR, OP_RENAME, OP_CHMOD, OP_CHOWN, OP_UTIMENS, OP_TRUNCATE, + OP_WRITE +}; + +static int run_op(int op) +{ + struct fuse_file_info fi; + struct timespec tv[2]; + + memset(&fi, 0, sizeof(fi)); + memset(tv, 0, sizeof(tv)); + switch (op) { + case OP_MKNOD: return ops->mknod("/d/new", S_IFREG | 0644, 0); + case OP_MKDIR: return ops->mkdir("/d/new", 0755); + case OP_CREATE: return ops->create("/d/new", 0644, &fi); + case OP_SYMLINK: return ops->symlink("target", "/d/new"); + case OP_LINK: return ops->link("/d/f", "/d/new"); + case OP_UNLINK: return ops->unlink("/d/f"); + case OP_RMDIR: return ops->rmdir("/d/sub"); + case OP_RENAME: return ops->rename("/d/f", "/d/h", 0); + case OP_CHMOD: return ops->chmod("/d/f", 0600, NULL); + case OP_CHOWN: return ops->chown("/d/f", 1000, 1000, NULL); + case OP_UTIMENS: return ops->utimens("/d/f", tv, NULL); + case OP_TRUNCATE: return ops->truncate("/d/f", 0, NULL); + case OP_WRITE: return ops->write("/d/f", "x", 1, 0, NULL) == 1 ? 0 : -1; + } + abort(); +} + +static const struct inval_case { + const char *name; + int op; + const char *target; /* cached path that must be refetched, if any */ + int purges_parent; /* the listing of /d must be refetched */ +} inval_cases[] = { + { "mknod", OP_MKNOD, NULL, 1 }, + { "mkdir", OP_MKDIR, NULL, 1 }, + { "create", OP_CREATE, NULL, 1 }, + { "symlink", OP_SYMLINK, NULL, 1 }, + { "link", OP_LINK, "/d/f", 1 }, + { "unlink", OP_UNLINK, "/d/f", 1 }, + { "rmdir", OP_RMDIR, "/d/sub", 1 }, + { "rename", OP_RENAME, "/d/f", 1 }, + { "chmod", OP_CHMOD, "/d/f", 0 }, + { "chown", OP_CHOWN, "/d/f", 0 }, + { "utimens", OP_UTIMENS, "/d/f", 0 }, + { "truncate", OP_TRUNCATE, "/d/f", 0 }, + { "write", OP_WRITE, "/d/f", 0 }, +}; + +static void test_invalidation(void) +{ + struct listing l; + size_t i; + + for (i = 0; i < ARRAY_SIZE(inval_cases); i++) { + const struct inval_case *c = &inval_cases[i]; + + cur_sub = c->name; + reset_state(); + mk_dir("/d"); + mk_file("/d/f", 1); + mk_file("/d/g", 2); + mk_dir("/d/sub"); + mk_file("/other", 3); + + /* the listing of /d also caches the attributes of its entries */ + REQUIRE(do_readdir("/d", 0, &l) == 0); + (void) probe("/other"); + REQUIRE(!probe("/d/g")); + REQUIRE(!probe_dir("/d")); + if (c->target) + REQUIRE(!probe(c->target)); + + CHECK(run_op(c->op) == 0); + + if (c->target) + CHECK(probe(c->target)); + if (c->purges_parent) + CHECK(probe_dir("/d")); + /* other entries are left alone */ + CHECK(!probe("/d/g")); + CHECK(!probe("/other")); + } + cur_sub = NULL; +} + +static void test_new_values_after_ops(void) +{ + struct stat st; + struct timespec tv[2]; + + mk_file("/f", 100); + CHECK(size_of("/f") == 100); + + /* each modification shows in the next lookup, and in the cached copy */ + CHECK(ops->chmod("/f", 0600, NULL) == 0); + CHECK(perm_of("/f") == 0600); + CHECK(ops->truncate("/f", 7, NULL) == 0); + CHECK(size_of("/f") == 7); + CHECK(ops->write("/f", "abc", 3, 50, NULL) == 3); + CHECK(size_of("/f") == 53); + + CHECK(ops->chown("/f", 1000, 2000, NULL) == 0); + REQUIRE(get_cached("/f", &st) == 0); + CHECK(st.st_uid == 1000 && st.st_gid == 2000); + + memset(tv, 0, sizeof(tv)); + tv[0].tv_sec = 111; + tv[1].tv_sec = 222; + CHECK(ops->utimens("/f", tv, NULL) == 0); + REQUIRE(get_cached("/f", &st) == 0); + CHECK(st.st_atime == 111 && st.st_mtime == 222); + + /* a second name raises the link count of the cached first one */ + CHECK(ops->link("/f", "/l") == 0); + REQUIRE(get_cached("/f", &st) == 0); + CHECK(st.st_nlink == 2); + + /* /l was never cached: a write through /f shows in it */ + CHECK(ops->write("/f", "x", 1, 500, NULL) == 1); + CHECK(size_of("/l") == 501); +} + +static void test_cache_invalidate(void) +{ + mk_file("/f", 1); + mk_file("/g", 1); + (void) probe("/f"); + (void) probe("/g"); + CHECK(!probe("/f")); + + cache_invalidate("/f"); + CHECK(probe("/f")); + CHECK(!probe("/g")); +} + +static void test_rename_moves_children(void) +{ + struct listing l; + struct stat st; + + mk_dir("/a"); + mk_file("/a/f", 1); + mk_dir("/a/sub"); + mk_file("/a/sub/g", 2); + mk_file("/zz", 3); + (void) probe_dir("/"); + (void) probe_dir("/a"); + (void) probe("/a/sub/g"); + + REQUIRE(ops->rename("/a", "/b", 0) == 0); + + CHECK(!probe("/zz")); /* unrelated, still cached */ + /* nothing under the old name is served any more */ + CHECK(do_getattr("/a", &st) == -ENOENT); + CHECK(do_getattr("/a/f", &st) == -ENOENT); + CHECK(do_getattr("/a/sub/g", &st) == -ENOENT); + CHECK(do_readdir("/a", 0, &l) == -ENOENT); + /* the listing of the parent was refetched */ + CHECK(do_readdir("/", 0, &l) == 0); + CHECK(listing_has(&l, "b") && !listing_has(&l, "a")); +} + +static void test_rename_nested(void) +{ + struct listing l; + + mk_dir("/d"); + mk_dir("/d/sub"); + mk_file("/d/f", 4); + (void) probe_dir("/d"); + (void) probe_dir("/d/sub"); + + REQUIRE(ops->rename("/d/f", "/d/sub/f2", 0) == 0); + + /* both parents were purged */ + CHECK(do_readdir("/d", 0, &l) == 0 && !listing_has(&l, "f")); + CHECK(do_readdir("/d/sub", 0, &l) == 0 && listing_has(&l, "f2")); +} + +static void test_rename_over_existing(void) +{ + mk_file("/x", 3); + mk_file("/y", 7); + CHECK(size_of("/y") == 7); + REQUIRE(ops->rename("/x", "/y", 0) == 0); + CHECK(size_of("/y") == 3); + + /* onto itself: nothing changes */ + CHECK(ops->rename("/y", "/y", 0) == 0); + CHECK(size_of("/y") == 3); +} + +static void test_failed_ops_keep_cache(void) +{ + struct listing l; + uint64_t w; + + mk_dir("/d"); + mk_file("/d/f", 1); + REQUIRE(do_readdir("/d", 0, &l) == 0); + w = cache_get_write_ctr(); + + CHECK(ops->mkdir("/d/f", 0755) == -EEXIST); + CHECK(ops->unlink("/d/none") == -ENOENT); + CHECK(ops->rmdir("/d/none") == -ENOENT); + CHECK(ops->chmod("/d/none", 0600, NULL) == -ENOENT); + CHECK(ops->truncate("/d/none", 0, NULL) == -ENOENT); + CHECK(ops->write("/d/none", "x", 1, 0, NULL) == -ENOENT); + CHECK(ops->rename("/d/none", "/d/f", 0) == -ENOENT); + + /* a write that failed does not count as one */ + CHECK(cache_get_write_ctr() == w); + CHECK(!probe("/d/f")); + CHECK(!probe_dir("/d")); +} + +/* ---- readdir ---- */ + +static void test_readdir_cached(void) +{ + struct listing l; + + mk_dir("/d"); + mk_file("/d/a", 10); + mk_file("/d/b", 20); + mk_dir("/d/c"); + + REQUIRE(do_readdir("/d", 0, &l) == 0); + CHECK(l.n == 3); + CHECK(listing_has(&l, "a") && listing_has(&l, "b") && listing_has(&l, "c")); + CHECK(calls.readdir == 1 && calls.opendir == 1 && calls.releasedir == 1); + CHECK(last_readdir_offset == 0); + + /* the listing also cached the attributes of its entries */ + CHECK(size_of("/d/a") == 10 && calls.getattr == 0); + + /* a second listing needs neither the backend listing nor its handle */ + mk_file("/d/late", 1); + REQUIRE(do_readdir("/d", 0, &l) == 0); + CHECK(l.n == 3 && !listing_has(&l, "late")); + CHECK(calls.readdir == 1 && calls.opendir == 1 && calls.releasedir == 1); + + advance(20); /* valid through dir_timeout */ + CHECK(!probe_dir("/d")); + advance(1); + REQUIRE(do_readdir("/d", 0, &l) == 0); + CHECK(l.n == 4 && listing_has(&l, "late")); + CHECK(calls.readdir == 2 && calls.opendir == 2 && calls.releasedir == 2); +} + +/* + * sshfs hands the whole listing to the filler with offset 0 and leaves the + * slicing to libfuse, so an offset from the caller must neither cut the + * listing short nor be passed on (upstream issue 338, PR 379). + */ +static void test_readdir_ignores_offset(void) +{ + struct listing l; + + mk_dir("/d"); + mk_file("/d/a", 1); + mk_file("/d/b", 2); + mk_file("/d/c", 3); + + /* cold cache: the backend is asked from 0 and the filler gets offset 0 */ + REQUIRE(do_readdir("/d", 2, &l) == 0); + CHECK(l.n == 3 && l.max_off == 0 && last_readdir_offset == 0); + + /* warm cache, new handle, other offsets */ + REQUIRE(do_readdir("/d", 3, &l) == 0); + CHECK(l.n == 3 && l.max_off == 0); + REQUIRE(do_readdir("/d", 1000, &l) == 0); + CHECK(l.n == 3 && calls.readdir == 1); + + advance(21); /* expired: nonzero offset again */ + REQUIRE(do_readdir("/d", 5, &l) == 0); + CHECK(l.n == 3 && last_readdir_offset == 0 && calls.readdir == 2); +} + +static void test_readdir_root(void) +{ + struct listing l; + + mk_file("/x", 5); + mk_dir("/d"); + + REQUIRE(do_readdir("/", 0, &l) == 0); + CHECK(l.n == 2 && listing_has(&l, "x") && listing_has(&l, "d")); + /* entries of the root are cached as /x, not //x */ + CHECK(size_of("/x") == 5 && calls.getattr == 0); +} + +static void test_readdir_without_attrs(void) +{ + struct listing l; + + readdir_no_attrs = 1; + mk_dir("/d"); + mk_file("/d/a", 5); + + REQUIRE(do_readdir("/d", 0, &l) == 0); + CHECK(listing_has(&l, "a")); + /* an entry listed without attributes has to be looked up */ + CHECK(size_of("/d/a") == 5 && calls.getattr == 1); + CHECK(!probe_dir("/d")); +} + +static void test_readdir_error(void) +{ + struct listing l; + + mk_dir("/d"); + mk_file("/d/a", 1); + mk_file("/d/b", 2); + + readdir_fail_at = 0; /* the backend fails before any entry */ + CHECK(do_readdir("/d", 0, &l) == -EIO); + CHECK(l.n == 0 && cache_lookup("/d") == NULL); + + readdir_fail_at = 1; /* and after delivering "a" */ + CHECK(do_readdir("/d", 0, &l) == -EIO); + CHECK(l.n == 1 && cache_lookup("/d") == NULL); + /* + * "a" came with a valid reply and passed the write counter check, so + * its attributes stay cached, as they would after a getattr. + */ + CHECK(size_of("/d/a") == 1 && calls.getattr == 0); + CHECK(cache_lookup("/d/b") == NULL); + + readdir_fail_at = -1; + REQUIRE(do_readdir("/d", 0, &l) == 0); + CHECK(l.n == 2 && calls.readdir == 3 && !probe_dir("/d")); +} + +/* list /d through a directory handle that is already open */ +static int readdir_on(uint64_t dh) +{ + struct fuse_file_info fi; + struct listing l; + + memset(&l, 0, sizeof(l)); + memset(&fi, 0, sizeof(fi)); + fi.fh = dh; + return ops->readdir("/d", &l, collect, 0, &fi, 0); +} + +static void test_readdir_handle_reuse(void) +{ + struct fuse_file_info fi; + uint64_t dh; + + mk_dir("/d"); + mk_file("/d/a", 1); + memset(&fi, 0, sizeof(fi)); + REQUIRE(ops->opendir("/d", &fi) == 0); + dh = fi.fh; + CHECK(calls.opendir == 0); + + CHECK(readdir_on(dh) == 0); + CHECK(calls.opendir == 1 && last_readdir_fh == 42); + + /* after expiry the backend handle is reused */ + advance(cache.dir_timeout_secs + 1); + CHECK(readdir_on(dh) == 0); + CHECK(calls.readdir == 2 && calls.opendir == 1 && last_readdir_fh == 42); + + fi.fh = dh; + CHECK(ops->releasedir("/d", &fi) == 0); + CHECK(calls.releasedir == 1 && last_releasedir_fh == 42); +} + +static void test_readdir_attr_independent(void) +{ + mk_dir("/d"); + mk_file("/d/a", 1); + mk_dir("/e"); + + /* a listing says nothing about the attributes of the directory, and + caching them leaves the listing alone */ + (void) probe_dir("/d"); + CHECK(probe("/d")); + CHECK(!probe_dir("/d")); + /* attributes alone do not make a listing either */ + (void) probe("/e"); + CHECK(probe_dir("/e")); +} + +/* ---- readlink ---- */ + +static void test_readlink_cached(void) +{ + char buf[64]; + + cache.link_timeout_secs = 10; + mk_link("/l", "target-1"); + mk_link("/m", "other"); + + REQUIRE(ops->readlink("/l", buf, sizeof(buf)) == 0); + CHECK(strcmp(buf, "target-1") == 0); + g_strlcpy(ent_get("/l")->link, "target-2", PATH_LEN); + advance(10); + REQUIRE(ops->readlink("/l", buf, sizeof(buf)) == 0); + CHECK(strcmp(buf, "target-1") == 0 && calls.readlink == 1); + advance(1); + REQUIRE(ops->readlink("/l", buf, sizeof(buf)) == 0); + CHECK(strcmp(buf, "target-2") == 0 && calls.readlink == 2); + + /* cached attributes of a path do not stand in for its target */ + (void) probe("/m"); + CHECK(probe_link("/m")); + + /* a hard link to a symlink has the same target */ + CHECK(ops->link("/m", "/n") == 0); + REQUIRE(ops->readlink("/n", buf, sizeof(buf)) == 0); + CHECK(strcmp(buf, "other") == 0); +} + +static void test_readlink_truncation(void) +{ + char buf[8]; + char small[5]; + char one[1]; + + mk_link("/l", "0123456789"); + + /* the backend fills a buffer of 5 and the cache keeps what fit */ + memset(small, 'X', sizeof(small)); + REQUIRE(ops->readlink("/l", small, sizeof(small)) == 0); + CHECK(strcmp(small, "0123") == 0); + CHECK(calls.readlink == 1); + + memset(small, 'X', sizeof(small)); + REQUIRE(ops->readlink("/l", small, sizeof(small)) == 0); + CHECK(strcmp(small, "0123") == 0); + CHECK(calls.readlink == 1); + + /* a smaller buffer gets a shorter, terminated string from the cache */ + memset(buf, 'X', sizeof(buf)); + REQUIRE(ops->readlink("/l", buf, 3) == 0); + CHECK(strcmp(buf, "01") == 0); + CHECK(buf[3] == 'X'); + CHECK(calls.readlink == 1); + + /* a buffer of one byte holds just the terminator */ + cache_invalidate("/l"); + one[0] = 'X'; + REQUIRE(ops->readlink("/l", one, sizeof(one)) == 0); + CHECK(one[0] == '\0'); + one[0] = 'X'; + REQUIRE(ops->readlink("/l", one, sizeof(one)) == 0); + CHECK(one[0] == '\0'); + CHECK(calls.readlink == 2); +} + +static void test_readlink_freed(void) +{ + char buf[64]; + + mk_link("/l", "target"); + CHECK(ntracked == 0); + REQUIRE(ops->readlink("/l", buf, sizeof(buf)) == 0); + CHECK(ntracked == 1); + + /* refreshing an expired link frees the old string */ + advance(cache.link_timeout_secs + 1); + REQUIRE(ops->readlink("/l", buf, sizeof(buf)) == 0); + CHECK(calls.readlink == 2); + CHECK(ntracked == 1); + + /* dropping the node frees the string (free_node) */ + cache_invalidate("/l"); + CHECK(ntracked == 0); + + REQUIRE(ops->readlink("/l", buf, sizeof(buf)) == 0); + CHECK(ntracked == 1); + g_hash_table_remove_all(cache.table); + CHECK(ntracked == 0); +} + +/* ---- the write counter ---- */ + +static const char *hook_path; /* what the write and chmod hooks act on */ + +static void hook_write(void) +{ + (void) ops->write(hook_path, "hello", 5, 100, NULL); +} + +static void test_write_guard_getattr(void) +{ + struct stat st; + uint64_t w; + + mk_file("/f", 10); + hook_path = "/f"; + hook_getattr = hook_write; + w = cache_get_write_ctr(); + + REQUIRE(do_getattr("/f", &st) == 0); + CHECK(st.st_size == 10); /* the reply that was in flight */ + CHECK(cache_get_write_ctr() == w + 1); + CHECK(cache_lookup("/f") == NULL); + + REQUIRE(do_getattr("/f", &st) == 0); + CHECK(calls.getattr == 2); + CHECK(st.st_size == 105); +} + +static void test_write_guard_api(void) +{ + struct stat st, st2; + uint64_t w; + + mk_file("/f", 1); + memset(&st, 0, sizeof(st)); + st.st_mode = S_IFREG | 0644; + st.st_size = 1; + + w = cache_get_write_ctr(); + REQUIRE(ops->write("/f", "x", 1, 0, NULL) == 1); + cache_add_attr("/f", &st, w); + CHECK(cache_lookup("/f") == NULL); + + cache_add_attr("/f", &st, cache_get_write_ctr()); + CHECK(cache_lookup("/f") != NULL); + REQUIRE(do_getattr("/f", &st2) == 0); + CHECK(calls.getattr == 0); +} + +static void test_write_guard_readdir(void) +{ + struct listing l; + struct stat st; + ino_t ino_a, ino_b; + int a, b; + + mk_dir("/d"); + mk_file("/d/a", 1); + mk_file("/d/b", 2); + ino_a = ent_get("/d/a")->ino; + ino_b = ent_get("/d/b")->ino; + hook_path = "/d/a"; + hook_readdir = hook_write; + + REQUIRE(do_readdir("/d", 0, &l) == 0); + CHECK(l.n == 2); + + /* the attributes that came with the listing may predate the write */ + CHECK(cache_lookup("/d/a") == NULL); + CHECK(cache_lookup("/d/b") == NULL); + REQUIRE(do_getattr("/d/a", &st) == 0); + CHECK(st.st_size == 105); + + /* + * The cached listing keeps each stat as the backend read it. libfuse + * uses only the type and inode number, which must be right; the size of + * "a" is the old one today and may be refreshed. + */ + REQUIRE(do_readdir("/d", 0, &l) == 0); + CHECK(calls.readdir == 1); + CHECK(l.n == 2); + a = listing_index(&l, "a"); + b = listing_index(&l, "b"); + REQUIRE(a >= 0 && b >= 0); + CHECK(S_ISREG(l.mode[a]) && l.ino[a] == ino_a); + CHECK(l.size[a] == 1 || l.size[a] == 105); + CHECK(S_ISREG(l.mode[b]) && l.ino[b] == ino_b && l.size[b] == 2); +} + +/* ---- calls in flight across operations that do not bump write_ctr ---- */ + +static void hook_mkdir_new(void) +{ + (void) ops->mkdir("/d/new", 0755); +} + +static void hook_chmod(void) +{ + (void) ops->chmod(hook_path, 0600, NULL); +} + +static void hook_replace_link(void) +{ + (void) ops->unlink("/l"); + (void) ops->symlink("target-B", "/l"); +} + +/* + * A reply that is in flight when a modifying operation purges its path is + * installed after the purge, and then served until its timeout. Only + * cache_write() advances write_ctr, and cache_add_dir() and cache_add_link() + * do not check it at all. + * + * These are gaps in the cache layer, shown by running the two calls in an + * order the cache cannot rule out. Whether a mount can produce that order + * differs: on one Linux mount the VFS locks serialise readdir and mkdir in a + * directory, so that pair is probably out of reach there, while a getattr or a + * readdir racing a chmod of an entry is plausibly reachable, with a window of + * about one round trip. None of this has been reproduced on a mount. + */ +static void test_race_readdir_mkdir(void) +{ + struct listing l; + + mk_dir("/d"); + mk_file("/d/a", 1); + mk_file("/d/b", 2); + hook_readdir = hook_mkdir_new; + + REQUIRE(do_readdir("/d", 0, &l) == 0); + CHECK(l.n == 2); /* the listing in flight predates the mkdir */ + REQUIRE(ent_find("/d/new") != NULL); + + REQUIRE(do_readdir("/d", 0, &l) == 0); + CHECK(listing_has(&l, "a") && listing_has(&l, "b")); + XCHECK(listing_has(&l, "new")); +} + +static void test_race_getattr_chmod(void) +{ + struct stat st; + int perm; + + mk_file("/f", 1); + hook_path = "/f"; + hook_getattr = hook_chmod; + + REQUIRE(do_getattr("/f", &st) == 0); + CHECK((st.st_mode & 07777) == 0644); /* the reply in flight */ + REQUIRE((ent_get("/f")->mode & 07777) == 0600); + + perm = perm_of("/f"); + CHECK(perm == 0644 || perm == 0600); + XCHECK(perm == 0600); +} + +static void test_race_readdir_chmod(void) +{ + struct listing l; + int perm; + + mk_dir("/d"); + mk_file("/d/a", 1); + hook_path = "/d/a"; + hook_readdir = hook_chmod; + + REQUIRE(do_readdir("/d", 0, &l) == 0); + REQUIRE((ent_get("/d/a")->mode & 07777) == 0600); + + perm = perm_of("/d/a"); + CHECK(perm == 0644 || perm == 0600); + XCHECK(perm == 0600); +} + +static void test_race_readlink(void) +{ + char buf[64]; + + mk_link("/l", "target-A"); + hook_readlink = hook_replace_link; + + REQUIRE(ops->readlink("/l", buf, sizeof(buf)) == 0); + CHECK(strcmp(buf, "target-A") == 0); /* the reply in flight */ + REQUIRE(strcmp(ent_get("/l")->link, "target-B") == 0); + + REQUIRE(ops->readlink("/l", buf, sizeof(buf)) == 0); + CHECK(strcmp(buf, "target-A") == 0 || strcmp(buf, "target-B") == 0); + XCHECK(strcmp(buf, "target-B") == 0); +} + +/* + * The cache keeps attributes per path, and a change made through one name of + * a file purges only that name, so another cached name of the same file is + * served stale until it expires. + */ +static void test_stale_hard_link_alias(void) +{ + off_t size; + + mk_file("/a", 1); + REQUIRE(ops->link("/a", "/b") == 0); + REQUIRE(size_of("/b") == 1); /* cache the second name */ + REQUIRE(ops->truncate("/a", 100, NULL) == 0); + REQUIRE(ent_get("/b")->size == 100); + + size = size_of("/b"); + CHECK(size == 1 || size == 100); + XCHECK(size == 100); +} + +/* ---- cleaning ---- */ + +static void test_clean_interval(void) +{ + cache.stat_timeout_secs = 10; + mk_files("abcd"); + + get_files("ab"); /* valid until T0+10 */ + advance(55); + get_files("c"); /* valid until T0+65 */ + /* a and b are expired but linger: the interval has not passed */ + CHECK(g_hash_table_size(cache.table) == 3); + + advance(6); /* T0+61 */ + get_files("d"); + CHECK(g_hash_table_size(cache.table) == 2); + CHECK(!cache_lookup("/a") && !cache_lookup("/b")); + CHECK(cache_lookup("/c") && cache_lookup("/d")); + CHECK(cache.last_cleaned == T0 + 61); + CHECK(!probe("/c")); /* what stayed is still served */ +} + +static void test_clean_by_size(void) +{ + cache.stat_timeout_secs = 4; + cache.max_size = 3; + cache.clean_interval_secs = 1000; + mk_files("abcdefghi"); + + get_files("abcd"); + /* over the limit, but the minimum interval has not passed */ + CHECK(g_hash_table_size(cache.table) == 4); + advance(6); + get_files("e"); + CHECK(g_hash_table_size(cache.table) == 1); + CHECK(!cache_lookup("/a") && cache_lookup("/e")); + + /* the limit is soft: entries that are still valid stay */ + cache.stat_timeout_secs = 100; + get_files("fgh"); + advance(6); + get_files("i"); + CHECK(cache.last_cleaned == T0 + 12); + CHECK(g_hash_table_size(cache.table) == 4); + CHECK(!cache_lookup("/e") && !probe("/f")); +} + +static void test_clean_keeps_node_with_valid_part(void) +{ + cache.stat_timeout_secs = 5; + cache.dir_timeout_secs = 50; + cache.clean_interval_secs = 10; + cache.min_clean_interval_secs = 1; + mk_dir("/d"); + mk_file("/d/a", 1); + mk_files("x"); + + (void) probe_dir("/d"); + advance(20); + get_files("x"); /* runs a clean */ + + CHECK(cache.last_cleaned == T0 + 20); + CHECK(cache_lookup("/d/a") == NULL); /* attributes expired */ + CHECK(cache_lookup("/d") != NULL); /* listing still valid */ + CHECK(g_hash_table_size(cache.table) == 2); + CHECK(!probe_dir("/d")); +} + +/* ---- setup of the wrapped operations ---- */ + +/* run cache_parse_options on "sshfs -o opts"; returns the arguments left */ +static int parse(const char *opts) +{ + char a0[] = "sshfs"; + char a1[] = "-o"; + char *a2 = opts ? g_strdup(opts) : NULL; + char *argv[] = { a0, a1, a2, NULL }; + struct fuse_args args = FUSE_ARGS_INIT(opts ? 3 : 1, argv); + int left = -1; + + if (cache_parse_options(&args) == 0) + left = args.argc; + fuse_opt_free_args(&args); + g_free(a2); + return left; +} + +static void test_parse_options(void) +{ + /* the later, specific option wins over dcache_timeout */ + CHECK(parse("dcache_timeout=7,dcache_stat_timeout=3,dcache_max_size=50," + "dcache_clean_interval=30,dcache_min_clean_interval=2") == 1); + CHECK(cache.stat_timeout_secs == 3 && cache.dir_timeout_secs == 7 && + cache.link_timeout_secs == 7); + CHECK(cache.max_size == 50 && cache.clean_interval_secs == 30 && + cache.min_clean_interval_secs == 2); + + /* the older names */ + CHECK(parse("cache_timeout=9,cache_dir_timeout=4,cache_link_timeout=5," + "cache_max_size=8,cache_clean_interval=31," + "cache_min_clean_interval=3") == 1); + CHECK(cache.stat_timeout_secs == 9 && cache.dir_timeout_secs == 4 && + cache.link_timeout_secs == 5); + CHECK(cache.max_size == 8 && cache.clean_interval_secs == 31 && + cache.min_clean_interval_secs == 3); + + /* without options the defaults are restored */ + CHECK(parse(NULL) == 1); + CHECK(cache.stat_timeout_secs == 20 && cache.dir_timeout_secs == 20 && + cache.link_timeout_secs == 20); + CHECK(cache.max_size == 10000 && cache.clean_interval_secs == 60 && + cache.min_clean_interval_secs == 5); +} + +static void test_init(void) +{ + struct fuse_conn_info conn; + struct fuse_config cfg; + void *res; + + memset(&conn, 0, sizeof(conn)); + memset(&cfg, 0, sizeof(cfg)); + res = ops->init(&conn, &cfg); + CHECK(calls.init == 1); + CHECK(res == &init_token); + /* the cache needs a path for every request */ + CHECK(cfg.nullpath_ok == 0); +} + +static void test_fill(void) +{ + struct fuse_operations minimal, out; + + /* everything the backend has is wrapped, with the cache's own + directory handling */ + CHECK(ops->getattr == cache_getattr && ops->readlink == cache_readlink); + CHECK(ops->readdir == cache_readdir && ops->write == cache_write); + CHECK(ops->opendir == cache_opendir && ops->releasedir == cache_releasedir); + CHECK(ops->init == cache_init && ops->access == fake_access); + + /* what the backend lacks stays missing */ + memset(&minimal, 0, sizeof(minimal)); + memset(&out, 0, sizeof(out)); + minimal.getattr = fake_getattr; + cache_fill(&minimal, &out); + CHECK(out.getattr == cache_getattr); + CHECK(out.opendir == cache_opendir && out.releasedir == cache_releasedir); + CHECK(!out.readdir && !out.readlink && !out.mkdir && !out.unlink); + CHECK(!out.rename && !out.chmod && !out.write && !out.create); + CHECK(!out.access); +} + +/* ---- runner ---- */ + +struct tcase { + const char *name; + void (*fn)(void); + const char *xfail; /* why the case is expected to fail, if it is */ +}; + +static const struct tcase cases[] = { + { "getattr: cached until stat_timeout", test_getattr_timeout, NULL }, + { "getattr: errors are not cached", test_getattr_errors, NULL }, + { "timeouts: stat, dir and link expire separately", + test_timeouts_per_kind, NULL }, + { "invalidate: modifying ops purge the path and its parent", + test_invalidation, NULL }, + { "invalidate: next getattr returns the new value", + test_new_values_after_ops, NULL }, + { "invalidate: cache_invalidate drops one path", + test_cache_invalidate, NULL }, + { "rename: children of the old name are dropped", + test_rename_moves_children, NULL }, + { "rename: both parents are purged", test_rename_nested, NULL }, + { "rename: an existing target is not served stale", + test_rename_over_existing, NULL }, + { "invalidate: failed ops leave the cache alone", + test_failed_ops_keep_cache, NULL }, + { "readdir: cached until dir_timeout, fills child attrs", + test_readdir_cached, NULL }, + { "readdir: offset is ignored", test_readdir_ignores_offset, NULL }, + { "readdir: root entries are cached as /name", test_readdir_root, NULL }, + { "readdir: entries without attrs are not attr-cached", + test_readdir_without_attrs, NULL }, + { "readdir: an error caches no listing, nor entries not yet delivered", + test_readdir_error, NULL }, + { "readdir: backend handle is opened once per handle", + test_readdir_handle_reuse, NULL }, + { "readdir: listing and attrs of a dir are separate", + test_readdir_attr_independent, NULL }, + { "readlink: cached until link_timeout", test_readlink_cached, NULL }, + { "readlink: cached target is cut to the buffer size", + test_readlink_truncation, NULL }, + { "readlink: cached target is freed with its node", + test_readlink_freed, NULL }, + { "write counter: getattr in flight across a write is not cached", + test_write_guard_getattr, NULL }, + { "write counter: cache_add_attr drops a stale counter", + test_write_guard_api, NULL }, + { "write counter: readdir in flight across a write drops child attrs", + test_write_guard_readdir, NULL }, + { "stale install: readdir in flight across mkdir (cache layer only)", + test_race_readdir_mkdir, + "cache_add_dir has no generation check; one Linux mount serialises this pair" }, + { "stale install: getattr in flight across chmod", + test_race_getattr_chmod, + "only write bumps write_ctr; chmod purges before cache_add_attr" }, + { "stale install: readdir in flight across chmod of an entry", + test_race_readdir_chmod, + "only write bumps write_ctr; chmod purges before cache_dirfill" }, + { "stale install: readlink in flight across a symlink replacement", + test_race_readlink, + "cache_add_link has no generation check; unlink+symlink purge before it" }, + { "stale alias: hard link name cached across a change via another name", + test_stale_hard_link_alias, + "cache_truncate purges only the path it is given; the cache is keyed by path" }, + { "clean: expired entries go after clean_interval", + test_clean_interval, NULL }, + { "clean: expired entries go when over max_size, valid ones stay", + test_clean_by_size, NULL }, + { "clean: a node with one valid part is kept", + test_clean_keeps_node_with_valid_part, NULL }, + { "options: defaults, dcache_* and cache_* names", test_parse_options, + NULL }, + { "init: forwards to the backend and requires paths", test_init, NULL }, + { "wrap: only existing backend ops are wrapped", test_fill, NULL }, +}; + +int main(void) +{ + int failed = 0; + size_t i; + + setvbuf(stdout, NULL, _IOLBF, 0); + + ops = cache_wrap(&fake_ops); + if (!ops) { + printf("Bail out! cache_wrap failed\n"); + return 1; + } + + printf("1..%d\n", (int) ARRAY_SIZE(cases)); + for (i = 0; i < ARRAY_SIZE(cases); i++) { + const struct tcase *tc = &cases[i]; + int n = (int) i + 1; + + reset_state(); + cur_fails = 0; + cur_xfails = 0; + cur_sub = NULL; + ntracked = 0; + + tc->fn(); + + /* dropping every node must free everything it holds */ + g_hash_table_remove_all(cache.table); + if (ntracked) { + printf("# %d cached string(s) not freed\n", ntracked); + cur_fails++; + } + + if (cur_fails) { + printf("not ok %d - %s\n", n, tc->name); + failed++; + } else if (!tc->xfail) { + if (cur_xfails) { + printf("not ok %d - %s\n", n, tc->name); + failed++; + } else { + printf("ok %d - %s\n", n, tc->name); + } + } else if (cur_xfails) { + printf("ok %d - %s # XFAIL %s\n", n, tc->name, + tc->xfail); + } else { + printf("not ok %d - %s # XPASS %s: no longer fails, " + "remove the xfail\n", n, tc->name, tc->xfail); + failed++; + } + } + + g_hash_table_destroy(cache.table); + cache.table = NULL; + return failed ? 1 : 0; +} From d6f28f086e21e55883b90026440cbeda6e6ad78e Mon Sep 17 00:00:00 2001 From: Abhinav Agarwal Date: Fri, 23 Oct 2026 17:51:14 -0700 Subject: [PATCH 2/5] ci: run the unit tests Run the unit suite in the Linux build jobs and under ASan and UBSan. --- .github/workflows/build-platforms.yml | 8 ++++++++ .github/workflows/build-ubuntu.yml | 7 +++++++ .github/workflows/sanitizers.yml | 4 ++++ 3 files changed, 19 insertions(+) diff --git a/.github/workflows/build-platforms.yml b/.github/workflows/build-platforms.yml index b670d622..74aa9388 100644 --- a/.github/workflows/build-platforms.yml +++ b/.github/workflows/build-platforms.yml @@ -60,6 +60,14 @@ jobs: meson setup build ${{ matrix.buildtype && format('--buildtype={0}', matrix.buildtype) || '' }} ninja -C build + - name: Run unit tests + if: matrix.run_tests + run: | + set -o pipefail + meson test -C build --suite unit --print-errorlogs -v | tee "$RUNNER_TEMP/unit-tests.log" + # meson exits 0 when no test matches the suite, so insist on a pass + grep -Eiq '^ok: +[1-9]' "$RUNNER_TEMP/unit-tests.log" + - name: Setup SSH if: matrix.run_tests run: | diff --git a/.github/workflows/build-ubuntu.yml b/.github/workflows/build-ubuntu.yml index cc9ce770..34d3f038 100644 --- a/.github/workflows/build-ubuntu.yml +++ b/.github/workflows/build-ubuntu.yml @@ -83,6 +83,13 @@ jobs: path: build/sshfs if-no-files-found: ignore + - name: Run unit tests + run: | + set -o pipefail + meson test -C build --suite unit --print-errorlogs -v | tee "$RUNNER_TEMP/unit-tests.log" + # meson exits 0 when no test matches the suite, so insist on a pass + grep -Eiq '^ok: +[1-9]' "$RUNNER_TEMP/unit-tests.log" + - name: Run tests timeout-minutes: 20 run: | diff --git a/.github/workflows/sanitizers.yml b/.github/workflows/sanitizers.yml index 49fcfe1e..752baf21 100644 --- a/.github/workflows/sanitizers.yml +++ b/.github/workflows/sanitizers.yml @@ -79,6 +79,10 @@ jobs: ASAN_SYMBOLIZER_PATH: /usr/bin/llvm-symbolizer run: | mkdir -p "$RUNNER_TEMP/sanitizer" + set -o pipefail + meson test -C build --suite unit --print-errorlogs -v | tee "$RUNNER_TEMP/unit-tests.log" + # meson exits 0 when no test matches the suite, so insist on a pass + grep -Eiq '^ok: +[1-9]' "$RUNNER_TEMP/unit-tests.log" cd build python3 -m pytest test/ --timeout=180 --maxfail=99 --junitxml=test-results.xml timeout-minutes: 20 From 8a49ca70202935ae92bf4f39c97e15c64b2c1958 Mon Sep 17 00:00:00 2001 From: Abhinav Agarwal Date: Thu, 22 Oct 2026 04:06:32 -0700 Subject: [PATCH 3/5] ci: add a macOS build Builds sshfs against macFUSE 5.4.0, runs the cache unit tests and the hostname tests, and does a startup smoke test. Runners can't mount. --- .github/workflows/build-macos.yml | 120 ++++++++++++++++++++++++++++++ 1 file changed, 120 insertions(+) create mode 100644 .github/workflows/build-macos.yml diff --git a/.github/workflows/build-macos.yml b/.github/workflows/build-macos.yml new file mode 100644 index 00000000..7b1136bd --- /dev/null +++ b/.github/workflows/build-macos.yml @@ -0,0 +1,120 @@ +name: macos build + +on: + push: + branches: [master] + pull_request: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +env: + MACFUSE_VERSION: "5.4.0" + MACFUSE_SHA256: 861814f0ac7fa8f6547ea40cdd49a36ac84bcc7d34f38a1fa74e8cf68b0401c5 + HOMEBREW_NO_AUTO_UPDATE: "1" + HOMEBREW_NO_INSTALL_CLEANUP: "1" + HOMEBREW_NO_ENV_HINTS: "1" + HOMEBREW_NO_ANALYTICS: "1" + +jobs: + build: + name: ${{ matrix.runner }} + runs-on: ${{ matrix.runner }} + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + runner: [macos-15, macos-26] + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: '3.12' + + - name: Install macFUSE + timeout-minutes: 5 + run: | + set -euxo pipefail + dmg="$RUNNER_TEMP/macfuse.dmg" + curl -fsSL --retry 3 --retry-all-errors --connect-timeout 20 --max-time 300 -o "$dmg" "https://github.com/macfuse/macfuse/releases/download/macfuse-${MACFUSE_VERSION}/macfuse-${MACFUSE_VERSION}.dmg" + echo "${MACFUSE_SHA256} $dmg" | shasum -a 256 -c - + hdiutil attach "$dmg" -nobrowse -readonly -mountpoint "$RUNNER_TEMP/macfuse" + sudo installer -pkg "$RUNNER_TEMP/macfuse/Extras/macFUSE ${MACFUSE_VERSION}.pkg" -target / + hdiutil detach "$RUNNER_TEMP/macfuse" + ls -l /usr/local/lib/pkgconfig/fuse3.pc /usr/local/lib/libfuse3.dylib + cat /usr/local/lib/pkgconfig/fuse3.pc + + - name: Install build dependencies + timeout-minutes: 5 + run: | + brew install glib + pip3 install meson==1.12.1 docutils==0.23 pytest==9.1.1 pytest-timeout==2.4.0 + + - name: Print tool versions + run: | + sw_vers + uname -m + clang --version + meson --version + ninja --version + pkg-config --version + + - name: Build + env: + # macFUSE installs its pkg-config files here; Homebrew's pkg-config + # does not look in /usr/local on Apple Silicon. + PKG_CONFIG_PATH: /usr/local/lib/pkgconfig + run: | + meson setup build + ninja -C build + + - name: Run unit tests + run: | + set -o pipefail + meson test -C build --suite unit --print-errorlogs -v | tee "$RUNNER_TEMP/unit-tests.log" + # meson exits 0 when no test matches the suite, so insist on a pass + grep -Eiq '^ok: +[1-9]' "$RUNNER_TEMP/unit-tests.log" + + - name: Run mount-free tests + timeout-minutes: 2 + run: | + set -o pipefail + cd build + python3 -m pytest test/test_hostname_validation.py --timeout=30 | tee "$RUNNER_TEMP/pytest.log" + # exactly the expected tests, all passed: no skip, xfail or rename slips by + grep -Eq '^=+ 2 passed in ' "$RUNNER_TEMP/pytest.log" + + - name: Smoke test + timeout-minutes: 2 + run: | + set -o pipefail + ./build/sshfs --version | tee version.txt + grep -q '^SSHFS version ' version.txt + grep -q '^FUSE library version ' version.txt + test -s build/sshfs.1 + otool -L build/sshfs + + # no mount is possible on a hosted runner, so check that startup + # runs through to a clean ssh failure + mkdir "$RUNNER_TEMP/mnt" + rc=0 + perl -e 'alarm 30; exec @ARGV' ./build/sshfs -f -o ssh_command=/dev/null localhost:/ "$RUNNER_TEMP/mnt" 2> err.txt || rc=$? + cat err.txt + [ "$rc" -eq 1 ] || { echo "expected exit 1, got $rc"; exit 1; } + grep -q "failed to execute '/dev/null'" err.txt + + - name: Upload build logs + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + if: failure() + with: + name: build-logs-${{ matrix.runner }} + path: build/meson-logs/ From ff99b6aecd6ecce1f5a6e5e501008bb0551e2328 Mon Sep 17 00:00:00 2001 From: Abhinav Agarwal Date: Thu, 22 Oct 2026 05:45:54 -0700 Subject: [PATCH 4/5] test: check daemonize order at runtime on macOS Catches the bug class behind issue 373 without a mount. --- .github/workflows/build-macos.yml | 6 +- test/conftest.py | 22 ++++++ test/daemonize_shim.c | 102 ++++++++++++++++++++++++ test/meson.build | 3 + test/test_macos_daemonize.py | 127 ++++++++++++++++++++++++++++++ test/util.py | 68 ++++++++++++++++ 6 files changed, 326 insertions(+), 2 deletions(-) create mode 100644 test/daemonize_shim.c create mode 100644 test/test_macos_daemonize.py diff --git a/.github/workflows/build-macos.yml b/.github/workflows/build-macos.yml index 7b1136bd..bf519cbd 100644 --- a/.github/workflows/build-macos.yml +++ b/.github/workflows/build-macos.yml @@ -86,12 +86,14 @@ jobs: - name: Run mount-free tests timeout-minutes: 2 + env: + TEST_WITH_MACFUSE: "1" run: | set -o pipefail cd build - python3 -m pytest test/test_hostname_validation.py --timeout=30 | tee "$RUNNER_TEMP/pytest.log" + python3 -m pytest test/test_hostname_validation.py test/test_macos_daemonize.py --timeout=60 | tee "$RUNNER_TEMP/pytest.log" # exactly the expected tests, all passed: no skip, xfail or rename slips by - grep -Eq '^=+ 2 passed in ' "$RUNNER_TEMP/pytest.log" + grep -Eq '^=+ 4 passed in ' "$RUNNER_TEMP/pytest.log" - name: Smoke test timeout-minutes: 2 diff --git a/test/conftest.py b/test/conftest.py index 9416dde4..5c88adf6 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -1,8 +1,30 @@ +import os import sys import pytest import time import re +from util import require_macfuse_lazy_mount + +# These need macFUSE 5.3.3+, whose mount is delayed so that nothing is mounted +# on a runner. On macOS they are only collected with TEST_WITH_MACFUSE=1; +# test_startup.py also runs elsewhere, against a FUSE mount. +MACFUSE_TESTS = ("test_macos_daemonize.py", "test_startup.py") + +if sys.platform != "darwin": + collect_ignore = ["test_macos_daemonize.py"] +elif os.environ.get("TEST_WITH_MACFUSE") != "1": + collect_ignore = list(MACFUSE_TESTS) + + +@pytest.fixture(scope="module", autouse=True) +def macfuse_prerequisite(request): + # collect_ignore does not apply to files named on the command line, so + # they skip here without the opt-in + if sys.platform == "darwin" and os.path.basename(request.module.__file__) in MACFUSE_TESTS: + require_macfuse_lazy_mount() + + # If a test fails, wait a moment before retrieving the captured stdout/stderr. # When using a server process, this makes sure that we capture any potential # output of the server that comes *after* a test has failed. For example, if a diff --git a/test/daemonize_shim.c b/test/daemonize_shim.c new file mode 100644 index 00000000..fb21dda7 --- /dev/null +++ b/test/daemonize_shim.c @@ -0,0 +1,102 @@ +/* + * DYLD_INSERT_LIBRARIES shim for test_macos_daemonize.py. + * + * macFUSE 5.3.3+ delays the real mount until the session is first used and + * remembers that in a process-wide flag. Once it is set, fuse_daemonize() + * refuses to fork. Replace fuse_daemonize() with a check of that flag, so a + * test can run sshfs without -f and see whether anything started the mount + * too early. No working mount is needed: a hosted runner can't finish one, + * and the shim exits before the real fuse_daemonize() would fork. It also + * stops fuse_session_fd(), the call that used to start the mount too early, + * before it can try a real mount. + * + * Exit codes: 0 ok, 86 mount started before fuse_daemonize(), 87 fuse_daemonize() + * called with foreground set, 88 fuse_daemonize() never reached, 89 watchdog + * not installed. + */ + +#include +#include +#include +#include +#include + +/* not public API: the test fails to build the shim if libfuse3 lacks it */ +extern bool fuse_darwin_mount_started(void); +extern int fuse_daemonize(int foreground); +struct fuse_session; +extern int fuse_session_fd(struct fuse_session *se); + +#define WATCHDOG_SECS 25 + +/* dyld-interposing.h is not in the public SDK */ +#define DYLD_INTERPOSE(replacement, replacee) \ + __attribute__((used)) static struct { const void *new; const void *old; } \ + interpose_##replacee __attribute__((section("__DATA,__interpose"))) = \ + { (const void *) &replacement, (const void *) &replacee }; + +static void say(const char *msg) +{ + (void) write(2, msg, strlen(msg)); +} + +/* + * If something starts the mount before fuse_daemonize() the call blocks, + * since the runner can't finish a mount. Look at the same flag from a timer + * so that still ends as a clear failure and not as a hang. A timer and not a + * thread: macFUSE warns when a threaded process forks, and sshfs forks ssh. + */ +static void watchdog(int sig) +{ + (void) sig; + if (fuse_darwin_mount_started()) { + say("daemonize-shim: VIOLATION: mount started before fuse_daemonize (blocked)\n"); + _exit(86); + } + say("daemonize-shim: TIMEOUT: fuse_daemonize not reached, mount not started\n"); + _exit(88); +} + +static int shim_daemonize(int foreground) +{ + if (fuse_darwin_mount_started()) { + say("daemonize-shim: VIOLATION: mount started before fuse_daemonize\n"); + _exit(86); + } + if (foreground) { + say("daemonize-shim: fuse_daemonize(foreground=1), fork path not exercised\n"); + _exit(87); + } + say("daemonize-shim: ok: fuse_daemonize(foreground=0) before mount start\n"); + _exit(0); +} + +DYLD_INTERPOSE(shim_daemonize, fuse_daemonize) + +/* shim_daemonize() never returns, so any call here is too early */ +static int shim_session_fd(struct fuse_session *se) +{ + (void) se; + say("daemonize-shim: VIOLATION: fuse_session_fd called before fuse_daemonize\n"); + _exit(86); +} + +DYLD_INTERPOSE(shim_session_fd, fuse_session_fd) + +__attribute__((constructor)) +static void shim_init(void) +{ + struct sigaction sa; + + /* don't hand the shim on to ssh or the fake server */ + unsetenv("DYLD_INSERT_LIBRARIES"); + say("daemonize-shim: loaded\n"); + + memset(&sa, 0, sizeof(sa)); + sa.sa_handler = watchdog; + if (sigaction(SIGALRM, &sa, NULL) != 0) { + say("daemonize-shim: cannot install the watchdog\n"); + _exit(89); + } + alarm(WATCHDOG_SECS); +} diff --git a/test/meson.build b/test/meson.build index 5d352a33..1309f43f 100644 --- a/test/meson.build +++ b/test/meson.build @@ -1,5 +1,8 @@ test_scripts = [ 'conftest.py', 'pytest.ini', 'test_sshfs.py', 'test_hostname_validation.py', 'util.py' ] +if host_machine.system() == 'darwin' + test_scripts += [ 'test_macos_daemonize.py', 'daemonize_shim.c' ] +endif custom_target('test_scripts', input: test_scripts, output: test_scripts, build_by_default: true, command: ['cp', '-fPp', diff --git a/test/test_macos_daemonize.py b/test/test_macos_daemonize.py new file mode 100644 index 00000000..12c9d1d8 --- /dev/null +++ b/test/test_macos_daemonize.py @@ -0,0 +1,127 @@ +#!/usr/bin/env python3 +"""Check at run time that nothing starts the macFUSE mount before +fuse_daemonize(). No mount is needed: sshfs runs without -f under a small +DYLD shim (daemonize_shim.c) that looks at macFUSE's own "mount started" flag +when sshfs calls fuse_daemonize().""" + +if __name__ == "__main__": + import pytest + import sys + + sys.exit(pytest.main([__file__] + sys.argv[1:])) + +import os +import shlex +import subprocess + +import pytest +from os.path import join as pjoin +from util import basename, libfuse3_path, run_isolated + +SSHFS = pjoin(basename, "sshfs") +SHIM_SRC = pjoin(os.path.dirname(os.path.abspath(__file__)), "daemonize_shim.c") + +# Just enough of an SFTP server for sshfs to connect: version reply to INIT, +# a directory for the LSTAT of the remote root, "unsupported" for the rest. +FAKE_SSH = """#!/usr/bin/env python3 +import struct, sys + +def read(n): + data = b"" + while len(data) < n: + chunk = sys.stdin.buffer.read(n - len(data)) + if not chunk: + sys.exit(0) + data += chunk + return data + +def send(kind, payload): + sys.stdout.buffer.write(struct.pack(">IB", len(payload) + 1, kind) + payload) + sys.stdout.buffer.flush() + +while True: + (size,) = struct.unpack(">I", read(4)) + pkt = read(size) + if pkt[0] == 1: # INIT + send(2, struct.pack(">I", 3)) + elif pkt[0] == 7: # LSTAT + send(105, pkt[1:5] + struct.pack(">II", 4, 0o40755)) + else: + send(101, pkt[1:5] + struct.pack(">I", 8) + struct.pack(">II", 0, 0)) +""" + +EXIT_MEANING = { + 86: "the mount was started before fuse_daemonize()", + 87: "sshfs ran in the foreground, so the fork path was not exercised", + 88: "sshfs never reached fuse_daemonize()", + 89: "the shim could not install its watchdog", +} + + +def signature(): + res = subprocess.run( + ["codesign", "-dv", "--verbose=4", SSHFS], + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + ) + return res.stdout + + +@pytest.fixture(scope="module") +def shim(tmp_path_factory): + lib = libfuse3_path() + path = str(tmp_path_factory.mktemp("shim") / "daemonize_shim.dylib") + res = subprocess.run( + shlex.split(os.environ.get("CC", "cc")) + + ["-dynamiclib", "-Wall", "-Wextra", "-Werror", "-o", path, SHIM_SRC, lib], + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + ) + if res.returncode != 0: + pytest.fail("could not build the shim against %s:\n%s" % (lib, res.stdout)) + return path + + +def run_sshfs(shim, args, out_file, helper_dir=None, timeout=45): + env = dict(os.environ, DYLD_INSERT_LIBRARIES=shim) + if helper_dir: + # a bare command name: sshfs splits ssh_command at spaces + env["PATH"] = str(helper_dir) + os.pathsep + env["PATH"] + rc, output = run_isolated([SSHFS] + args, out_file, timeout, env=env) + if rc is None: + pytest.fail("sshfs did not exit in %ds\noutput:\n%s" % (timeout, output)) + return rc, output + + +def test_shim_loads(shim, tmp_path): + """DYLD_INSERT_LIBRARIES must work for the sshfs built here; if it does + not (hardened runtime, SIP), the check below would prove nothing.""" + rc, err = run_sshfs(shim, ["--version"], tmp_path / "version.txt") + assert "daemonize-shim: loaded" in err, ( + "shim was not loaded\nsignature:\n%s\noutput:\n%s" % (signature(), err) + ) + assert rc == 0, err + + +def test_no_mount_before_daemonize(shim, tmp_path): + fake_ssh = tmp_path / "fake-ssh" + fake_ssh.write_text(FAKE_SSH) + fake_ssh.chmod(0o755) + # a file, not a directory: nothing can be mounted there, whatever runs + mnt_dir = tmp_path / "not-a-directory" + mnt_dir.write_text("") + + rc, err = run_sshfs( + shim, + ["-o", "ssh_command=fake-ssh", "localhost:/", str(mnt_dir)], + tmp_path / "sshfs.txt", + helper_dir=tmp_path, + ) + assert rc == 0, "%s (exit %d)\noutput:\n%s" % ( + EXIT_MEANING.get(rc, "unexpected failure"), + rc, + err, + ) + assert "daemonize-shim: ok: fuse_daemonize(foreground=0)" in err, err diff --git a/test/util.py b/test/util.py index ce443899..f1c9df67 100644 --- a/test/util.py +++ b/test/util.py @@ -2,6 +2,7 @@ import subprocess import pytest import os +import signal import stat import time from os.path import join as pjoin @@ -120,6 +121,73 @@ def skip(reason: str): return pytest.mark.uses_fuse() +def libfuse3_path(): + """Path of the libfuse3 dylib the sshfs binary links against (macOS), or None""" + linked = subprocess.run( + ["otool", "-L", pjoin(basename, "sshfs")], + stdout=subprocess.PIPE, + universal_newlines=True, + check=True, + ).stdout + for line in linked.splitlines()[1:]: + path = line.strip().split(" (compatibility", 1)[0] + if "libfuse3" in os.path.basename(path): + return path + return None + + +def require_macfuse_lazy_mount(): + """Skip unless TEST_WITH_MACFUSE=1, which only CI sets; then fail unless + sshfs links against macFUSE 5.3.3 or later, which delays the mount until + the session is first used (it exports fuse_darwin_mount_started). Older + macFUSE and FUSE-T mount in fuse_mount(), before sshfs connects.""" + if os.environ.get("TEST_WITH_MACFUSE") != "1": + pytest.skip("set TEST_WITH_MACFUSE=1 to run the macFUSE tests") + lib = libfuse3_path() + if lib is None: + pytest.fail("sshfs is not linked against libfuse3") + symbols = subprocess.run( + ["nm", "-gjU", lib], + stdout=subprocess.PIPE, + universal_newlines=True, + check=True, + ).stdout.split() + if "_fuse_darwin_mount_started" not in symbols: + pytest.fail("%s has no fuse_darwin_mount_started (macFUSE 5.3.3+ is required)" % lib) + + +def run_isolated(cmdline, out_file, timeout, env=None): + """Run cmdline in its own process group with its output in out_file. + + Returns (exit status, output); the status is None on timeout. The output + goes to a file, not a pipe, because helper processes left behind by a failed + mount start can keep a pipe open after the process itself is gone. The + whole group is killed afterwards. + """ + with open(out_file, "w") as out: + proc = subprocess.Popen( + cmdline, + env=env, + stdin=subprocess.DEVNULL, + stdout=out, + stderr=subprocess.STDOUT, + start_new_session=True, + ) + try: + rc = proc.wait(timeout=timeout) + except subprocess.TimeoutExpired: + rc = None + finally: + try: + os.killpg(proc.pid, signal.SIGKILL) + except (ProcessLookupError, PermissionError): + # nothing left to kill; macOS reports EPERM if only zombies remain + pass + proc.wait() + with open(out_file) as f: + return rc, f.read() + + # Use valgrind if requested if os.environ.get("TEST_WITH_VALGRIND", "no").lower().strip() not in ( "no", From 89c771cb511953fc083f9ed0f60e26fd4d63eb67 Mon Sep 17 00:00:00 2001 From: Abhinav Agarwal Date: Thu, 22 Oct 2026 12:19:42 -0700 Subject: [PATCH 5/5] test: run mount-free startup checks on macOS Moves the bad-reply and backslash tests out of test_sshfs.py and adds an ssh exec failure test, so they run on macOS without a mount. --- .github/workflows/build-macos.yml | 13 +---- test/meson.build | 2 +- test/test_sshfs.py | 71 ---------------------- test/test_startup.py | 97 +++++++++++++++++++++++++++++++ 4 files changed, 100 insertions(+), 83 deletions(-) create mode 100644 test/test_startup.py diff --git a/.github/workflows/build-macos.yml b/.github/workflows/build-macos.yml index bf519cbd..67e1e4ad 100644 --- a/.github/workflows/build-macos.yml +++ b/.github/workflows/build-macos.yml @@ -91,9 +91,9 @@ jobs: run: | set -o pipefail cd build - python3 -m pytest test/test_hostname_validation.py test/test_macos_daemonize.py --timeout=60 | tee "$RUNNER_TEMP/pytest.log" + python3 -m pytest test/test_hostname_validation.py test/test_macos_daemonize.py test/test_startup.py --timeout=60 | tee "$RUNNER_TEMP/pytest.log" # exactly the expected tests, all passed: no skip, xfail or rename slips by - grep -Eq '^=+ 4 passed in ' "$RUNNER_TEMP/pytest.log" + grep -Eq '^=+ 7 passed in ' "$RUNNER_TEMP/pytest.log" - name: Smoke test timeout-minutes: 2 @@ -105,15 +105,6 @@ jobs: test -s build/sshfs.1 otool -L build/sshfs - # no mount is possible on a hosted runner, so check that startup - # runs through to a clean ssh failure - mkdir "$RUNNER_TEMP/mnt" - rc=0 - perl -e 'alarm 30; exec @ARGV' ./build/sshfs -f -o ssh_command=/dev/null localhost:/ "$RUNNER_TEMP/mnt" 2> err.txt || rc=$? - cat err.txt - [ "$rc" -eq 1 ] || { echo "expected exit 1, got $rc"; exit 1; } - grep -q "failed to execute '/dev/null'" err.txt - - name: Upload build logs uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 if: failure() diff --git a/test/meson.build b/test/meson.build index 1309f43f..18ac05ee 100644 --- a/test/meson.build +++ b/test/meson.build @@ -1,5 +1,5 @@ test_scripts = [ 'conftest.py', 'pytest.ini', 'test_sshfs.py', - 'test_hostname_validation.py', 'util.py' ] + 'test_hostname_validation.py', 'test_startup.py', 'util.py' ] if host_machine.system() == 'darwin' test_scripts += [ 'test_macos_daemonize.py', 'daemonize_shim.c' ] endif diff --git a/test/test_sshfs.py b/test/test_sshfs.py index 1425ced3..28857923 100755 --- a/test/test_sshfs.py +++ b/test/test_sshfs.py @@ -899,47 +899,6 @@ def test_direct_io(tmpdir, capfd): umount(mount_process, mnt_dir) -def test_bad_sftp_reply_len(tmpdir): - """sshfs must reject a zero-length SFTP reply instead of underflowing.""" - helper = tmpdir.join("bad_sftp.py") - helper.write( - '#!/usr/bin/env python3\n' - 'import os, struct, sys\n' - 'def read_pkt():\n' - ' hdr = os.read(0, 4)\n' - ' if len(hdr) < 4: sys.exit(0)\n' - ' n = struct.unpack(">I", hdr)[0]\n' - ' while n:\n' - ' c = os.read(0, n)\n' - ' if not c: sys.exit(0)\n' - ' n -= len(c)\n' - 'read_pkt()\n' - 'os.write(1, struct.pack(">IBI", 5, 2, 3))\n' # SSH_FXP_VERSION v3 - 'read_pkt()\n' - 'os.write(1, struct.pack(">IB", 0, 0))\n' # len=0 reply (5 bytes on wire) - ) - helper.chmod(0o755) - - mnt_dir = str(tmpdir.mkdir("mnt")) - cmdline = base_cmdline + [ - pjoin(basename, "sshfs"), - "-f", - "dummy:/", - mnt_dir, - "-o", f"ssh_command={helper}", - ] - res = subprocess.run( - cmdline, - stdin=subprocess.DEVNULL, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - timeout=10, - text=True, - ) - assert res.returncode != 0 - assert "bad reply len: 0" in res.stderr - - @contextmanager def _sshfs_mount(src_dir, mnt_dir, extra_opts=None): """Mount src_dir via sshfs, yield, then unmount.""" @@ -1098,33 +1057,3 @@ def test_contain_symlinks_option_precedence(tmpdir, capfd) -> None: with pytest.raises(OSError) as exc_info: os.readlink(pjoin(mnt_dir, "abs")) assert exc_info.value.errno == errno.EPERM - - -def test_backslash_escape(tmpdir, capfd): - """Regression test for parsing backslash escape sequences in options""" - - cases = [ - (r"one two three", " "), - (r"one\\ two three", " "), - # fuse_opt_parse interprets "\ " as " ", so these are still separate - (r"one two\ three", " "), - ] - - mnt_dir = str(tmpdir.mkdir("mnt")) - # we can skip cleanup since the mount always fails - - for line, args in cases: - cmdline = base_cmdline + [ - pjoin(basename, "sshfs"), - "-f", - "localhost:foo", - mnt_dir, - "-o", - "sshfs_debug", - "-o", - "ssh_command=/dev/null " + line, - ] - assert subprocess.run(cmdline).returncode != 0 - - captured = capfd.readouterr() - assert "executing " + args in captured.err diff --git a/test/test_startup.py b/test/test_startup.py new file mode 100644 index 00000000..94875d25 --- /dev/null +++ b/test/test_startup.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python3 +"""Startup tests: macFUSE 5.3.3+ needs no mount; other platforms require FUSE.""" + +import os +import subprocess +import sys +from os.path import join as pjoin + +from util import base_cmdline, basename, fuse_test_marker, run_isolated + +# Linux mounts before connecting to SSH, so it needs FUSE. macFUSE 5.3.3+ does +# not; conftest.py checks for it and runs these on macOS with TEST_WITH_MACFUSE=1. +if sys.platform != "darwin": + pytestmark = fuse_test_marker() + + +def test_bad_sftp_reply_len(tmpdir): + """sshfs must reject a zero-length SFTP reply instead of underflowing.""" + helper = tmpdir.join("bad_sftp.py") + helper.write( + '#!/usr/bin/env python3\n' + 'import os, struct, sys\n' + 'def read_pkt():\n' + ' hdr = os.read(0, 4)\n' + ' if len(hdr) < 4: sys.exit(0)\n' + ' n = struct.unpack(">I", hdr)[0]\n' + ' while n:\n' + ' c = os.read(0, n)\n' + ' if not c: sys.exit(0)\n' + ' n -= len(c)\n' + 'read_pkt()\n' + 'os.write(1, struct.pack(">IBI", 5, 2, 3))\n' # SSH_FXP_VERSION v3 + 'read_pkt()\n' + 'os.write(1, struct.pack(">IB", 0, 0))\n' # len=0 reply (5 bytes on wire) + ) + helper.chmod(0o755) + + mnt_dir = str(tmpdir.mkdir("mnt")) + cmdline = base_cmdline + [ + pjoin(basename, "sshfs"), + "-f", + "dummy:/", + mnt_dir, + # a bare command name: sshfs splits ssh_command at spaces + "-o", "ssh_command=bad_sftp.py", + ] + env = dict(os.environ, PATH=str(tmpdir) + os.pathsep + os.environ["PATH"]) + rc, output = run_isolated(cmdline, tmpdir.join("sshfs.txt"), timeout=10, env=env) + assert rc == 1, output + # Nothing may follow the rejection: a "remote host has disconnected" after + # it means the bad length was read past instead of failing. + assert output.rstrip().endswith("bad reply len: 0"), output + + +def test_backslash_escape(tmpdir, capfd): + """Regression test for parsing backslash escape sequences in options""" + + cases = [ + (r"one two three", " "), + (r"one\\ two three", " "), + # fuse_opt_parse interprets "\ " as " ", so these are still separate + (r"one two\ three", " "), + ] + + mnt_dir = str(tmpdir.mkdir("mnt")) + # we can skip cleanup since the mount always fails + + for line, args in cases: + cmdline = base_cmdline + [ + pjoin(basename, "sshfs"), + "-f", + "localhost:foo", + mnt_dir, + "-o", + "sshfs_debug", + "-o", + "ssh_command=/dev/null " + line, + ] + assert subprocess.run(cmdline, timeout=10).returncode != 0 + + captured = capfd.readouterr() + assert "executing " + args in captured.err + + +def test_ssh_exec_failure(tmp_path): + """An SSH exec error must report failure and exit promptly.""" + mnt_dir = tmp_path / "mnt" + mnt_dir.mkdir() + cmdline = base_cmdline + [ + pjoin(basename, "sshfs"), "-f", "-o", "ssh_command=/dev/null", + "localhost:/", str(mnt_dir), + ] + + rc, output = run_isolated(cmdline, tmp_path / "sshfs.txt", timeout=20) + assert rc is not None, "startup did not finish within 20s\n" + output + assert rc == 1, f"expected exit 1, got {rc}\n{output}" + assert "failed to execute '/dev/null'" in output, output