Compare commits

..

18 Commits

Author SHA1 Message Date
Abhinav Agarwal 5393ddf795 ci: add CodeQL static analysis
- New codeql.yml with manual build mode for C/C++ analysis
- Uses security-extended query suite, triggers on push/PR to master plus weekly schedule
- Upgraded to CodeQL Action v4 with full SHA pins
- Permissions scoped to contents: read and security-events: write
- Pin runner to ubuntu-24.04, add setup-python for PEP 668 compatibility, add job timeout
2026-05-21 18:35:19 -07:00
Abhinav Agarwal 25b58aad4d Merge pull request #359 from libfuse/fix-null-deref-strict-warnings
fix null-deref warning in tokenize_on_space, promote strict-warnings to required
2026-05-21 17:28:01 -07:00
Abhinav Agarwal c71eea8e68 fix null-deref warning in tokenize_on_space and promote strict-warnings to required 2026-05-20 23:48:08 -07:00
Abhinav Agarwal afbdf92fa6 Merge pull request #353 from abhinavagarwal07/ci-werror-clang
ci: add gcc/clang matrix with -Werror
2026-05-20 23:41:00 -07:00
Abhinav Agarwal daac34f7c8 ci: add gcc/clang matrix with -Werror
- Expand build job into gcc/clang matrix with fail-fast: false
- Pass -Dwerror=true so compiler warnings fail the build
- Pin all actions to Node 24-capable full SHAs (checkout v6.0.2, setup-python v6.2.0, upload-artifact v7.0.1)
- Add least-privilege permissions, concurrency cancellation
- Pin python-version to 3.12, pin runner to ubuntu-24.04
- Add explicit SSH setup with sshd start, hard-fail FUSE preflight
- Add pytest --timeout=300, --maxfail=99, JUnit XML, test result artifacts per compiler
2026-05-20 23:28:17 -07:00
h4sh 88692b79eb Merge pull request #352 from abhinavagarwal07/ci-hygiene
ci: harden workflow with SHA pins, permissions, timeouts, and dependabot
2026-05-19 16:48:12 +10:00
Abhinav Agarwal 035dcde9ba ci: harden workflow with SHA pins, permissions, timeouts, and dependabot
- Pin all actions to Node 24-capable full SHAs (checkout v6.0.2, setup-python v6.2.0, upload-artifact v7.0.1)
- Add least-privilege permissions (contents: read) and concurrency cancellation
- Pin python-version to 3.12, pin runner to ubuntu-24.04
- Add pytest --timeout=300, --maxfail=99 (overrides pytest.ini -x), JUnit XML output
- Add explicit SSH setup with sshd start and connectivity preflight
- Hard-fail FUSE preflight (job stops if /dev/fuse or fusermount3 missing)
- Upload test results and meson logs as artifacts
- Add dependabot config for weekly action version updates
2026-05-18 21:04:29 -07:00
h4sh cc14ae1c79 Merge pull request #351 from abhinavagarwal07/fix-conntab-null-deref
fix conntab lookup after rename in sshfs_release
2026-05-18 12:39:06 +10:00
h4sh 78f807b3dd Merge pull request #350 from abhinavagarwal07/fix-statfs-div-by-zero
fix statfs divide-by-zero when blksize is 0
2026-05-18 12:38:39 +10:00
h4sh fd885f023c Merge pull request #349 from abhinavagarwal07/fix-sftp-read-buffer-safety
fix malformed SFTP reply handling
2026-05-18 12:37:56 +10:00
Abhinav Agarwal 033a1d48f5 fix conntab lookup after rename in sshfs_release
sshfs_release looked up the conntab entry by path, but sshfs_rename
moves that entry to the new path. Closing the original handle after a
rename can miss the entry and dereference NULL.

Store the conntab_entry pointer on sshfs_file and use it for release
and open-failure cleanup. Add a regression test for open -> rename -> close.
2026-05-17 18:58:06 -07:00
Abhinav Agarwal 82285f9c28 fix statfs divide-by-zero when blksize is 0
On macOS, sshfs.blksize is set to 0. The statfs fallback path
(used when the server lacks the statvfs extension) divides by
f_frsize, which inherits that zero. Use 4096 as the default.
2026-05-17 18:17:23 -07:00
Abhinav Agarwal 43b2708186 fix malformed SFTP reply handling 2026-05-17 18:12:57 -07:00
h4sh d749988c80 Merge pull request #348 from abhinavagarwal07/add-tests-round-2
add tests for error paths, option coverage, and fix existing test bugs
2026-05-18 07:28:44 +10:00
Abhinav Agarwal 7921230f83 add tests for error paths, option coverage, and fix existing test bugs 2026-05-16 22:48:04 -07:00
h4sh ea60e3466e Merge pull request #347 from abhinavagarwal07/add-tests-rename-chmod-fsync-statvfs
add tests for rename, chmod, fsync, and statvfs values
2026-05-16 06:18:44 +10:00
Abhinav Agarwal 033a88349c add tests for rename, chmod, fsync, and statvfs values 2026-05-15 11:27:49 -07:00
h4sh5 1cd6995713 remove basically unused -v and --verbose flag 2026-04-15 14:49:05 +10:00
5 changed files with 634 additions and 67 deletions
+10
View File
@@ -0,0 +1,10 @@
version: 2
updates:
- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly
groups:
github-actions:
patterns:
- "*"
+114 -22
View File
@@ -7,45 +7,137 @@ on:
workflow_dispatch: # this is a nice option that will enable a button w/ inputs
inputs:
git-ref:
description: Git Ref (Optional)
description: Git Ref (Optional)
required: false
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
build-and-test:
name: Build and test
runs-on: ubuntu-latest
name: ${{ matrix.compiler }} / ${{ matrix.buildtype }}
runs-on: ubuntu-24.04
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
compiler: [gcc, clang]
buildtype: [debugoptimized, release]
include:
- compiler: gcc
cc: gcc
- compiler: clang
cc: clang
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-python@v4
- name: Set up Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: '3.12'
- name: Install build dependencies
run: |
sudo apt-get update
sudo apt-get install valgrind gcc ninja-build meson libglib2.0-dev libfuse3-dev
sudo apt-get install -y gcc clang ninja-build libglib2.0-dev libfuse3-dev openssh-server openssh-client fuse3
- name: Install meson
run: pip3 install meson pytest
run: pip3 install meson pytest pytest-timeout
- name: build
- name: Print tool versions
run: |
mkdir build; cd build
meson ..
ninja
${{ matrix.cc }} --version
meson --version
# cd does not persist across steps
- name: upload build artifact
uses: actions/upload-artifact@v4
- name: Setup SSH
run: |
mkdir -p ~/.ssh
chmod 700 ~/.ssh
ssh-keygen -t ed25519 -f ~/.ssh/id_ed25519 -q -N ""
cat ~/.ssh/id_ed25519.pub > ~/.ssh/authorized_keys
chmod 600 ~/.ssh/authorized_keys
sudo systemctl start ssh || sudo service ssh start
ssh -o StrictHostKeyChecking=no -o BatchMode=yes localhost true
- name: Check FUSE availability
run: |
test -e /dev/fuse
command -v fusermount3
- name: Build
env:
CC: ${{ matrix.cc }}
run: |
meson setup build --buildtype=${{ matrix.buildtype }} -Dwerror=true
ninja -C build
- name: Upload build artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: sshfs
name: sshfs-${{ matrix.compiler }}-${{ matrix.buildtype }}
path: build/sshfs
if-no-files-found: ignore
- name: make ssh into localhost without prompt possible for tests
run: |
ssh-keygen -b 2048 -t rsa -f ~/.ssh/id_rsa -q -N ""
cat ~/.ssh/id_rsa.pub > ~/.ssh/authorized_keys
- name: run tests
- name: Run tests
timeout-minutes: 20
run: |
cd build
python3 -m pytest test/
python3 -m pytest --maxfail=99 --timeout=300 --junitxml=test-results.xml test/
- name: Upload test results
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: always()
with:
name: test-results-${{ matrix.compiler }}-${{ matrix.buildtype }}
path: |
build/test-results.xml
build/meson-logs/
strict-warnings:
name: ${{ matrix.compiler }} / strict warnings
runs-on: ubuntu-24.04
timeout-minutes: 20
strategy:
fail-fast: false
matrix:
include:
- compiler: gcc
cc: gcc
extra_cflags: "-Wformat=2 -Wformat-security -Wundef -Wstrict-prototypes -Wold-style-definition -Wmissing-prototypes -Wnull-dereference"
- compiler: clang
cc: clang
extra_cflags: "-Wformat=2 -Wformat-security -Wundef -Wstrict-prototypes -Wold-style-definition -Wmissing-prototypes -Wnull-dereference"
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: '3.12'
- name: Install build dependencies
run: |
sudo apt-get update
sudo apt-get install -y gcc clang ninja-build libglib2.0-dev libfuse3-dev
- name: Install meson
run: pip3 install meson
- name: Print tool versions
run: |
${{ matrix.cc }} --version
meson --version
- name: Build with strict warnings
env:
CC: ${{ matrix.cc }}
CFLAGS: ${{ matrix.extra_cflags }}
run: |
meson setup build -Dwerror=true
ninja -C build
+51
View File
@@ -0,0 +1,51 @@
name: CodeQL
on:
push:
branches: [master]
pull_request:
branches: [master]
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
permissions:
contents: read
security-events: write
jobs:
analyze:
name: Analyze C
runs-on: ubuntu-24.04
timeout-minutes: 30
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: '3.12'
- name: Initialize CodeQL
uses: github/codeql-action/init@bc0b696b4103f5fe60f15749af68a046868d511a # codeql-bundle-v2.25.4
with:
languages: c-cpp
build-mode: manual
queries: security-extended
- name: Install dependencies
run: |
sudo apt-get update
sudo apt-get install -y gcc ninja-build pkg-config libglib2.0-dev libfuse3-dev
pip3 install meson
- name: Build
run: |
meson setup build --buildtype=debug
ninja -C build sshfs
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@bc0b696b4103f5fe60f15749af68a046868d511a # codeql-bundle-v2.25.4
+47 -37
View File
@@ -280,6 +280,11 @@ struct read_chunk {
struct sshfs_io sio;
};
struct conntab_entry {
unsigned refcount;
struct conn *conn;
};
struct sshfs_file {
struct buffer handle;
struct list_head write_reqs;
@@ -289,15 +294,11 @@ struct sshfs_file {
off_t next_pos;
int is_seq;
struct conn *conn;
struct conntab_entry *ce;
int connver;
int modifver;
};
struct conntab_entry {
unsigned refcount;
struct conn *conn;
};
struct sshfs {
char *directport;
char *ssh_command;
@@ -337,7 +338,6 @@ struct sshfs {
int sync_readdir;
int direct_io;
int debug;
int verbose;
int foreground;
int reconnect;
int delay_connect;
@@ -491,7 +491,6 @@ static struct fuse_opt sshfs_opts[] = {
SSHFS_OPT("no_readahead", sync_read, 1),
SSHFS_OPT("sync_readdir", sync_readdir, 1),
SSHFS_OPT("sshfs_debug", debug, 1),
SSHFS_OPT("sshfs_verbose", verbose, 1),
SSHFS_OPT("reconnect", reconnect, 1),
SSHFS_OPT("transform_symlinks", transform_symlinks, 1),
SSHFS_OPT("follow_symlinks", follow_symlinks, 1),
@@ -513,8 +512,6 @@ static struct fuse_opt sshfs_opts[] = {
SSHFS_OPT("--version", show_version, 1),
SSHFS_OPT("-d", debug, 1),
SSHFS_OPT("debug", debug, 1),
SSHFS_OPT("-v", verbose, 1),
SSHFS_OPT("verbose", verbose, 1),
SSHFS_OPT("-f", foreground, 1),
SSHFS_OPT("-s", singlethread, 1),
@@ -1176,7 +1173,7 @@ static int start_ssh(struct conn *conn)
perror("failed to redirect input/output");
_exit(1);
}
if (!sshfs.verbose && !sshfs.foreground && devnull != -1)
if (!sshfs.foreground && devnull != -1)
dup2(devnull, 2);
close(devnull);
@@ -1445,23 +1442,24 @@ static int do_read(struct conn *conn, struct buffer *buf)
static int sftp_read(struct conn *conn, uint8_t *type, struct buffer *buf)
{
int res;
int res = -1;
struct buffer buf2;
uint32_t len;
buf_init(&buf2, 5);
res = do_read(conn, &buf2);
if (res != -1) {
if (buf_get_uint32(&buf2, &len) == -1)
return -1;
if (len > MAX_REPLY_LEN) {
fprintf(stderr, "reply len too large: %u\n", len);
return -1;
}
if (buf_get_uint8(&buf2, type) == -1)
return -1;
buf_init(buf, len - 1);
res = do_read(conn, buf);
if (do_read(conn, &buf2) == -1)
goto out;
if (buf_get_uint32(&buf2, &len) == -1)
goto out;
if (len < 1 || len > MAX_REPLY_LEN) {
fprintf(stderr, "bad reply len: %u\n", len);
goto out;
}
if (buf_get_uint8(&buf2, type) == -1)
goto out;
buf_init(buf, len - 1);
res = do_read(conn, buf);
out:
buf_free(&buf2);
return res;
}
@@ -1534,10 +1532,14 @@ static int process_one_request(struct conn *conn)
buf_init(&buf, 0);
res = sftp_read(conn, &type, &buf);
if (res == -1)
if (res == -1) {
buf_free(&buf);
return -1;
if (buf_get_uint32(&buf, &id) == -1)
}
if (buf_get_uint32(&buf, &id) == -1) {
buf_free(&buf);
return -1;
}
pthread_mutex_lock(&sshfs.lock);
req = (struct request *)
@@ -2767,6 +2769,12 @@ static int sshfs_utimens(const char *path, const struct timespec tv[2],
return err;
}
static gboolean conntab_entry_is(gpointer key, gpointer value, gpointer data)
{
(void) key;
return value == data;
}
static int sshfs_open_common(const char *path, mode_t mode,
struct fuse_file_info *fi)
{
@@ -2827,12 +2835,13 @@ static int sshfs_open_common(const char *path, mode_t mode,
g_hash_table_insert(sshfs.conntab, g_strdup(path), ce);
}
sf->conn = ce->conn;
sf->ce = ce;
ce->refcount++;
sf->conn->file_count++;
assert(sf->conn->file_count > 0);
} else {
sf->conn = &sshfs.conns[0];
ce = NULL; // only to silence compiler warning
sf->ce = NULL;
}
sf->connver = sf->conn->connver;
pthread_mutex_unlock(&sshfs.lock);
@@ -2872,10 +2881,12 @@ static int sshfs_open_common(const char *path, mode_t mode,
if (sshfs.max_conns > 1) {
pthread_mutex_lock(&sshfs.lock);
sf->conn->file_count--;
ce->refcount--;
if(ce->refcount == 0) {
g_hash_table_remove(sshfs.conntab, path);
g_free(ce);
sf->ce->refcount--;
if (sf->ce->refcount == 0) {
g_hash_table_foreach_remove(sshfs.conntab,
conntab_entry_is,
sf->ce);
g_free(sf->ce);
}
pthread_mutex_unlock(&sshfs.lock);
}
@@ -2946,7 +2957,6 @@ static int sshfs_release(const char *path, struct fuse_file_info *fi)
{
struct sshfs_file *sf = get_sshfs_file(fi);
struct buffer *handle = &sf->handle;
struct conntab_entry *ce;
if (sshfs_file_is_conn(sf)) {
sshfs_flush(path, fi);
sftp_request(sf->conn, SSH_FXP_CLOSE, handle, 0, NULL);
@@ -2954,12 +2964,13 @@ static int sshfs_release(const char *path, struct fuse_file_info *fi)
buf_free(handle);
chunk_put_locked(sf->readahead);
if (sshfs.max_conns > 1) {
struct conntab_entry *ce = sf->ce;
pthread_mutex_lock(&sshfs.lock);
sf->conn->file_count--;
ce = g_hash_table_lookup(sshfs.conntab, path);
ce->refcount--;
if(ce->refcount == 0) {
g_hash_table_remove(sshfs.conntab, path);
if (ce->refcount == 0) {
g_hash_table_foreach_remove(sshfs.conntab,
conntab_entry_is, ce);
g_free(ce);
}
pthread_mutex_unlock(&sshfs.lock);
@@ -3386,7 +3397,7 @@ static int sshfs_statfs(const char *path, struct statvfs *buf)
return sshfs_ext_statvfs(path, buf);
buf->f_namemax = 255;
buf->f_bsize = sshfs.blksize;
buf->f_bsize = sshfs.blksize ? sshfs.blksize : 4096;
/*
* df seems to use f_bsize instead of f_frsize, so make them
* the same
@@ -3672,7 +3683,6 @@ static void usage(const char *progname)
" -o no_readahead synchronous reads (no speculative readahead)\n"
" -o sync_readdir synchronous readdir\n"
" -d, --debug print some debugging information (implies -f)\n"
" -v, --verbose print ssh replies and messages\n"
" -o dir_cache=BOOL enable caching of directory contents (names,\n"
" attributes, symlink targets) {yes,no} (default: yes)\n"
" -o dcache_max_size=N sets the maximum size of the directory cache (default: 10000)\n"
@@ -3944,7 +3954,7 @@ static char *tokenize_on_space(char *str)
start = pos;
while (pos && *pos != '\0') {
while (*pos != '\0') {
// break on space, but not on '\ '
if (*pos == ' ' && *(pos - 1) != '\\') {
break;
+412 -8
View File
@@ -135,7 +135,7 @@ def test_sshfs(
try:
wait_for_mount(mount_process, mnt_dir)
tst_statvfs(mnt_dir)
tst_statvfs(src_dir, mnt_dir)
tst_readdir(src_dir, mnt_dir)
tst_open_read(src_dir, mnt_dir)
tst_open_write(src_dir, mnt_dir)
@@ -145,6 +145,10 @@ def test_sshfs(
tst_passthrough(src_dir, mnt_dir, cache_timeout)
tst_mkdir(mnt_dir)
tst_rmdir(src_dir, mnt_dir, cache_timeout)
tst_rename(mnt_dir)
tst_rename_over(mnt_dir)
tst_chmod(mnt_dir)
tst_fsync(src_dir, mnt_dir)
tst_unlink(src_dir, mnt_dir, cache_timeout)
tst_symlink(mnt_dir)
if os.getuid() == 0:
@@ -159,6 +163,12 @@ def test_sshfs(
tst_truncate_path(mnt_dir)
tst_truncate_fd(mnt_dir)
tst_open_unlink(mnt_dir)
tst_open_writeonly_read(mnt_dir)
tst_access(mnt_dir)
tst_mkdir_exist(mnt_dir)
tst_readdir_repeated(mnt_dir)
tst_rename_sibling(mnt_dir)
tst_rename_open_release(mnt_dir)
except Exception as exc:
cleanup(mount_process, mnt_dir)
raise exc
@@ -208,6 +218,83 @@ def tst_rmdir(src_dir, mnt_dir, cache_timeout):
assert name not in os.listdir(src_dir)
def tst_rename(mnt_dir):
src_name = pjoin(mnt_dir, name_generator())
dst_name = pjoin(mnt_dir, name_generator())
data = b"rename test data\n"
with open(src_name, "wb") as fh:
fh.write(data)
assert os.path.exists(src_name)
os.rename(src_name, dst_name)
assert not os.path.exists(src_name)
assert os.path.basename(src_name) not in os.listdir(mnt_dir)
assert os.path.basename(dst_name) in os.listdir(mnt_dir)
with open(dst_name, "rb") as fh:
assert fh.read() == data
os.unlink(dst_name)
def tst_rename_over(mnt_dir):
src_name = pjoin(mnt_dir, name_generator())
dst_name = pjoin(mnt_dir, name_generator())
src_data = b"source content\n"
dst_data = b"destination content\n"
with open(src_name, "wb") as fh:
fh.write(src_data)
with open(dst_name, "wb") as fh:
fh.write(dst_data)
os.rename(src_name, dst_name)
assert not os.path.exists(src_name)
assert os.path.basename(src_name) not in os.listdir(mnt_dir)
with open(dst_name, "rb") as fh:
assert fh.read() == src_data
os.unlink(dst_name)
def tst_chmod(mnt_dir):
filename = pjoin(mnt_dir, name_generator())
with open(filename, "wb") as fh:
fh.write(b"chmod test\n")
os.chmod(filename, 0o644)
fstat = os.stat(filename)
assert stat.S_IMODE(fstat.st_mode) == 0o644
os.chmod(filename, 0o755)
fstat = os.stat(filename)
assert stat.S_IMODE(fstat.st_mode) == 0o755
os.unlink(filename)
def tst_fsync(src_dir, mnt_dir):
name = name_generator()
mnt_name = pjoin(mnt_dir, name)
src_name = pjoin(src_dir, name)
data = b"fsync test data\n"
fd = os.open(mnt_name, os.O_CREAT | os.O_WRONLY)
try:
os.write(fd, data)
os.fsync(fd)
# Read from backing store while fd is still open, before
# close/release has a chance to flush
with open(src_name, "rb") as fh:
assert fh.read() == data
finally:
os.close(fd)
os.unlink(mnt_name)
def tst_symlink(mnt_dir):
linkname = name_generator()
fullname = mnt_dir + "/" + linkname
@@ -218,6 +305,9 @@ def tst_symlink(mnt_dir):
assert fstat.st_nlink == 1
assert linkname in os.listdir(mnt_dir)
os.unlink(fullname)
assert linkname not in os.listdir(mnt_dir)
def tst_create(mnt_dir):
name = name_generator()
@@ -314,15 +404,121 @@ def tst_open_unlink(mnt_dir):
os.unlink(fullname)
with pytest.raises(OSError) as exc_info:
os.stat(fullname)
assert exc_info.value.errno == errno.ENOENT
assert exc_info.value.errno == errno.ENOENT
assert name not in os.listdir(mnt_dir)
fh.write(data2)
fh.seek(0)
assert fh.read() == data1 + data2
def tst_statvfs(mnt_dir):
os.statvfs(mnt_dir)
def tst_statvfs(src_dir, mnt_dir):
vfs = os.statvfs(mnt_dir)
ref = os.statvfs(src_dir)
# When the server supports statvfs@openssh.com, values should
# match the backing store. Otherwise sshfs returns synthetic
# values that still pass the loose checks.
if vfs.f_bsize == ref.f_bsize:
assert vfs.f_frsize == ref.f_frsize
assert vfs.f_blocks == ref.f_blocks
assert vfs.f_namemax == ref.f_namemax
else:
assert vfs.f_bsize > 0
assert vfs.f_blocks > 0
assert vfs.f_namemax > 0
def tst_open_writeonly_read(mnt_dir):
name = pjoin(mnt_dir, name_generator())
fd = os.open(name, os.O_CREAT | os.O_WRONLY)
try:
os.write(fd, b"hello")
with pytest.raises(OSError) as exc_info:
os.read(fd, 10)
assert exc_info.value.errno == errno.EBADF
finally:
os.close(fd)
os.unlink(name)
def tst_access(mnt_dir):
filename = pjoin(mnt_dir, name_generator())
with open(filename, "wb") as fh:
fh.write(b"test")
os.chmod(filename, 0o644)
assert os.access(filename, os.R_OK)
if os.getuid() != 0:
assert not os.access(filename, os.X_OK)
os.unlink(filename)
def tst_mkdir_exist(mnt_dir):
name = name_generator()
fullname = pjoin(mnt_dir, name)
os.mkdir(fullname)
with pytest.raises(OSError) as exc_info:
os.mkdir(fullname)
assert exc_info.value.errno == errno.EEXIST
os.rmdir(fullname)
def tst_readdir_repeated(mnt_dir):
dirname = pjoin(mnt_dir, name_generator())
os.mkdir(dirname)
names = []
for i in range(5):
n = name_generator()
names.append(n)
with open(pjoin(dirname, n), "wb") as fh:
fh.write(b"x")
# Verify repeated directory listings return consistent results
listing1 = sorted(os.listdir(dirname))
listing2 = sorted(os.listdir(dirname))
assert listing1 == sorted(names)
assert listing1 == listing2
for n in names:
os.unlink(pjoin(dirname, n))
os.rmdir(dirname)
def tst_rename_sibling(mnt_dir):
# Verify renaming one file doesn't break access to a sibling
name_a = pjoin(mnt_dir, name_generator())
name_b = pjoin(mnt_dir, name_generator())
name_c = pjoin(mnt_dir, name_generator())
with open(name_a, "wb") as fh:
fh.write(b"aaa")
with open(name_b, "wb") as fh:
fh.write(b"bbb")
os.rename(name_a, name_c)
assert not os.path.exists(name_a)
assert os.path.exists(name_b)
with open(name_b, "rb") as fh:
assert fh.read() == b"bbb"
os.unlink(name_b)
os.unlink(name_c)
def tst_rename_open_release(mnt_dir):
src = pjoin(mnt_dir, name_generator())
dst = pjoin(mnt_dir, name_generator())
fd = os.open(src, os.O_CREAT | os.O_RDWR)
try:
os.write(fd, b"data")
os.rename(src, dst)
finally:
os.close(fd)
assert not os.path.exists(src)
with open(dst, "rb") as fh:
assert fh.read() == b"data"
os.unlink(dst)
def tst_link(mnt_dir, cache_timeout):
@@ -365,6 +561,10 @@ def tst_link(mnt_dir, cache_timeout):
assert os.path.basename(name2) not in os.listdir(mnt_dir)
with pytest.raises(FileNotFoundError):
os.lstat(name2)
if cache_timeout:
safe_sleep(cache_timeout + 1)
fstat1 = os.lstat(name1)
assert fstat1.st_nlink == 1
os.unlink(name1)
@@ -418,6 +618,10 @@ def tst_truncate_path(mnt_dir):
with open(filename, "rb") as fh:
assert fh.read(size) == TEST_DATA[: size - 1024]
# Truncate to zero
os.truncate(filename, 0)
assert os.stat(filename).st_size == 0
os.unlink(filename)
@@ -443,6 +647,10 @@ def tst_truncate_fd(mnt_dir):
fh.seek(0)
assert fh.read(size) == TEST_DATA[: size - 1024]
# Truncate to zero via fd
os.ftruncate(fd, 0)
assert os.fstat(fd).st_size == 0
def tst_utimens(mnt_dir, tol=0):
filename = pjoin(mnt_dir, name_generator())
@@ -483,7 +691,7 @@ def tst_utimens_now(mnt_dir):
def tst_passthrough(src_dir, mnt_dir, cache_timeout):
name = name_generator()
src_name = pjoin(src_dir, name)
mnt_name = pjoin(src_dir, name)
mnt_name = pjoin(mnt_dir, name)
assert name not in os.listdir(src_dir)
assert name not in os.listdir(mnt_dir)
with open(src_name, "w") as fh:
@@ -492,11 +700,16 @@ def tst_passthrough(src_dir, mnt_dir, cache_timeout):
if cache_timeout:
safe_sleep(cache_timeout + 1)
assert name in os.listdir(mnt_dir)
assert os.stat(src_name) == os.stat(mnt_name)
src_st = os.stat(src_name)
mnt_st = os.stat(mnt_name)
assert src_st.st_size == mnt_st.st_size
assert src_st.st_uid == mnt_st.st_uid
assert src_st.st_gid == mnt_st.st_gid
assert abs(src_st.st_mtime - mnt_st.st_mtime) <= 1
name = name_generator()
src_name = pjoin(src_dir, name)
mnt_name = pjoin(src_dir, name)
mnt_name = pjoin(mnt_dir, name)
assert name not in os.listdir(src_dir)
assert name not in os.listdir(mnt_dir)
with open(mnt_name, "w") as fh:
@@ -505,4 +718,195 @@ def tst_passthrough(src_dir, mnt_dir, cache_timeout):
if cache_timeout:
safe_sleep(cache_timeout + 1)
assert name in os.listdir(mnt_dir)
assert os.stat(src_name) == os.stat(mnt_name)
src_st = os.stat(src_name)
mnt_st = os.stat(mnt_name)
assert src_st.st_size == mnt_st.st_size
assert src_st.st_uid == mnt_st.st_uid
assert src_st.st_gid == mnt_st.st_gid
assert abs(src_st.st_mtime - mnt_st.st_mtime) <= 1
def _check_ssh_localhost():
try:
res = subprocess.call(
["ssh", "-o", "StrictHostKeyChecking=no",
"-o", "KbdInteractiveAuthentication=no",
"-o", "ChallengeResponseAuthentication=no",
"-o", "PasswordAuthentication=no",
"localhost", "--", "true"],
stdin=subprocess.DEVNULL, timeout=10,
)
except subprocess.TimeoutExpired:
res = 1
if res != 0:
pytest.fail("Unable to ssh into localhost without password prompt.")
_mount_ctr = [0]
def _mount_sshfs(tmpdir, extra_opts=None):
"""Helper to mount sshfs with custom options. Returns (mount_process, mnt_dir, src_dir)."""
_check_ssh_localhost()
_mount_ctr[0] += 1
mnt_dir = str(tmpdir.mkdir(f"mnt{_mount_ctr[0]}"))
src_dir = str(tmpdir.mkdir(f"src{_mount_ctr[0]}"))
cmdline = base_cmdline + [
pjoin(basename, "sshfs"),
"-f",
f"localhost:{src_dir}",
mnt_dir,
"-o", "entry_timeout=0",
"-o", "attr_timeout=0",
]
if extra_opts:
for opt in extra_opts:
cmdline += ["-o", opt]
new_env = dict(os.environ)
new_env["G_DEBUG"] = "fatal-warnings"
mount_process = subprocess.Popen(cmdline, env=new_env)
try:
wait_for_mount(mount_process, mnt_dir)
except:
cleanup(mount_process, mnt_dir)
raise
return mount_process, mnt_dir, src_dir
def test_disable_hardlink(tmpdir, capfd):
capfd.register_output(r"^Warning: Permanently added 'localhost' .+", count=0)
# Control: verify hardlinks work without disable_hardlink.
# If the server lacks the extension, skip this test entirely.
mount_process, mnt_dir, src_dir = _mount_sshfs(tmpdir, [])
try:
name1 = pjoin(mnt_dir, name_generator())
name2 = pjoin(mnt_dir, name_generator())
with open(name1, "wb") as fh:
fh.write(b"test")
try:
os.link(name1, name2)
except OSError:
os.unlink(name1)
pytest.skip("server does not support hardlink extension")
os.unlink(name2)
os.unlink(name1)
except Exception:
cleanup(mount_process, mnt_dir)
raise
else:
umount(mount_process, mnt_dir)
# Now test with disable_hardlink — links should fail
mount_process, mnt_dir, src_dir = _mount_sshfs(tmpdir, ["disable_hardlink"])
try:
name1 = pjoin(mnt_dir, name_generator())
name2 = pjoin(mnt_dir, name_generator())
with open(name1, "wb") as fh:
fh.write(b"test")
with pytest.raises(OSError) as exc_info:
os.link(name1, name2)
assert exc_info.value.errno in (errno.ENOSYS, errno.EPERM)
os.unlink(name1)
except Exception:
cleanup(mount_process, mnt_dir)
raise
else:
umount(mount_process, mnt_dir)
def test_follow_symlinks(tmpdir, capfd):
capfd.register_output(r"^Warning: Permanently added 'localhost' .+", count=0)
mount_process, mnt_dir, src_dir = _mount_sshfs(tmpdir, ["follow_symlinks"])
try:
target_name = name_generator()
target = pjoin(src_dir, target_name)
with open(target, "wb") as fh:
fh.write(b"symlink target data")
link = pjoin(src_dir, name_generator())
os.symlink(target_name, link)
mnt_link = pjoin(mnt_dir, os.path.basename(link))
# With follow_symlinks, stat should return the target's attributes
# and the entry should appear as a regular file, not a symlink
fstat = os.lstat(mnt_link)
assert stat.S_ISREG(fstat.st_mode)
with open(mnt_link, "rb") as fh:
assert fh.read() == b"symlink target data"
os.unlink(link)
os.unlink(target)
except Exception:
cleanup(mount_process, mnt_dir)
raise
else:
umount(mount_process, mnt_dir)
def test_direct_io(tmpdir, capfd):
capfd.register_output(r"^Warning: Permanently added 'localhost' .+", count=0)
mount_process, mnt_dir, src_dir = _mount_sshfs(tmpdir, ["direct_io"])
try:
name = name_generator()
mnt_name = pjoin(mnt_dir, name)
src_name = pjoin(src_dir, name)
data = b"direct io test data\n" * 100
with open(mnt_name, "wb") as fh:
fh.write(data)
with open(mnt_name, "rb") as fh:
assert fh.read() == data
with open(src_name, "rb") as fh:
assert fh.read() == data
os.unlink(mnt_name)
except Exception:
cleanup(mount_process, mnt_dir)
raise
else:
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