Compare commits

...

43 Commits

Author SHA1 Message Date
h4sh 8bf7f92cd8 introduce dependency cooldown 2026-06-07 12:18:20 +10:00
h4sh 7a2d988775 Merge pull request #363 from libfuse/3.7.6-release
prep for 3.7.6
2026-05-30 09:35:39 +10:00
h4sh5 879f15a83b prep for 3.7.6 2026-05-30 09:22:08 +10:00
h4sh 6678accb85 Merge pull request #362 from abhinavagarwal07/advisory-fix-1
reject hostname option injection via bracketed mount source
2026-05-30 09:12:11 +10:00
Abhinav Agarwal 6893c3a51b Merge pull request #361 from abhinavagarwal07/contain-symlinks
add contain_symlinks option to prevent symlink escape attacks
2026-05-29 16:06:54 -07:00
Abhinav Agarwal 29bb565ea6 reject hostname option injection via bracketed mount source
A source like [-oProxyCommand=CMD]:/path passes the bracket-parsing
check in find_base_path() and ends up as -oProxyCommand=CMD in the
ssh argv.  When sftp_server is a path, ssh gets a destination argument
and executes the injected ProxyCommand before connecting.

Reject hostnames starting with - after bracket stripping, and add --
before the hostname in the ssh command line so positional args can't
be misread as options.
2026-05-29 16:00:27 -07:00
h4sh b6b23b7482 Merge pull request #358 from abhinavagarwal07/ci-platform-coverage
ci: add platform build coverage
2026-05-30 08:56:57 +10:00
Abhinav Agarwal bcd132f17c add contain_symlinks option to prevent symlink escape attacks
A malicious SFTP server can return symlink targets that the local
kernel VFS resolves outside the mount root, enabling local file reads
or writes through ordinary operations like cp following a symlink.

Add a contain_symlinks option (default on) that rejects absolute
symlink targets and any target containing a `..` component, returning
EPERM. Users who need legacy pass-through for trusted servers can opt
out with -o no_contain_symlinks.

The check is purely lexical and deliberately strict: in an adversarial
filesystem the server controls intermediate path components, so any
non-`..` component could be a symlink anywhere, making lexical depth
tracking unreliable. Rejecting absolute and any `..` is the simplest
rule that is provably complete against the threat model.

transform_symlinks composes poorly with containment because transformed
results often contain `..`; a warning is emitted when both are enabled.

Tests cover default-on containment (readlink + open/stat traversal),
opt-out behavior, transform_symlinks interaction (both arms), and
option precedence.
2026-05-29 15:54:21 -07:00
Abhinav Agarwal 8711fa13a2 ci: add platform build coverage 2026-05-22 10:21:17 -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
h4sh5 9e35c39ba8 version bump and new changelog 2025-11-12 05:46:43 +10:00
h4sh bf540dd68b Merge pull request #336 from libfuse/ipv6-connect
ipv6 support for directport connection
2025-11-11 23:09:38 +10:00
h4sh5 882d3a0185 add documentation on bypassing SSH 2025-11-11 17:53:52 +10:00
h4sh5 113882adda ipv6 support for directport connection 2025-11-11 16:30:19 +10:00
Benjamin Fleischer ccb6821019 Disable macOS specific FUSE API extensions
Some macOS specific features require FUSE API modifications and
extensions that break compatibility with the vanilla FUSE API. Setting
the compile-time flag FUSE_DARWIN_ENABLE_EXTENSIONS to 0, when building
a file system, disables those API extensions. By default, the macOS
specific API modifications and extensions are enabled.
2025-03-09 00:10:32 +01:00
Benjamin Fleischer ed0825440c Mountpoint does not have to exist on macOS
macFUSE will create the mounpoint (in case it does not exist) before
mounting the volume. This allows unprivileged users to mount volumes
under /Volumes.
2025-03-09 00:10:32 +01:00
Benjamin Fleischer a9eb71cb1c Remove conditional fuse_darwin.h include
The header file is no longer available in macFUSE 4.
2025-03-09 00:10:32 +01:00
Benjamin Fleischer b1715a5a62 Remove fuse_opt compatibility code
The fuse_opt compatibility code is no longer needed on macOS. Remove
it since it is not included on any other platform.
2025-03-09 00:10:32 +01:00
h4sh5 21fdcad6fb bump to upload-artifact@v4 2025-03-09 08:58:15 +10:00
Haoxi Tan ef94977c5a Merge pull request #314 from manu0401/windows_openssh
Windows native OpenSSH fix
2024-11-30 08:25:19 +10:00
Emmanuel Dreyfus d78a624756 Windows native OpenSSH fix
Windows native OpenSSH has alternative behavior for standard I/O
descriptors, which can be selected through the OPENSSH_STDIO_MODE
environement variable. Setting it to "nonsock" is required for
sshfs compatibility.

See https://github.com/PowerShell/openssh-portable/pull/759
for details.
2024-11-20 16:00:40 +01:00
Haoxi Tan ddf1e42ce7 Merge pull request #306 from jpalus/fill-stat-info-from-cache
Fill stat info when returning cached data for readdir
2024-11-05 08:07:34 +10:00
Haoxi Tan 1a52814a4e Merge branch 'master' into fill-stat-info-from-cache 2024-11-05 08:05:23 +10:00
Haoxi Tan c9bf8ad451 Merge pull request #305 from jpalus/fix-link-leak
Fix memleak in cache after readlink
2024-11-05 08:01:26 +10:00
Jan Palus 5f767dec5b Fill stat info when returning cached data for readdir
Uncached and cached results for readdir were inconsistent -- the former
returned correct stat info for directory entries while the latter
didn't. That's because only names of entries were saved in cache without
stat info. In turn this leads to issues like
https://github.com/junegunn/fzf/issues/3832 since directory traversal
library (https://github.com/charlievieth/fastwalk in this case) relies
on proper stat info returned by readdir. Hence when unchached result was
returned it gave proper outcome, while with cached result it was wrong.

Cache stat info next to entry name to fix the issue. While file
attributes are saved in cache already, they use full path as key. To
avoid potentially plenty of allocations, string copying and cache
lookups to get each attr, let's keep a copy of stat struct independently
to be on the fast path.
2024-06-17 11:48:44 +02:00
Jan Palus 8bb9d33d16 Fix memleak in cache after readlink 2024-06-17 00:10:02 +02:00
Haoxi Tan eadf7f104a Merge pull request #295 from libfuse/rm-rst
remove README.rst file since README.md file exists
2024-02-27 14:09:05 +10:00
15 changed files with 1157 additions and 711 deletions
+12
View File
@@ -0,0 +1,12 @@
version: 2
updates:
- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly
cooldown:
default-days: 14
groups:
github-actions:
patterns:
- "*"
+149
View File
@@ -0,0 +1,149 @@
name: platform builds
on:
push:
pull_request:
workflow_dispatch:
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
linux-compat:
name: ${{ matrix.name }}
runs-on: ${{ matrix.runner }}
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
include:
- name: ubuntu-latest
runner: ubuntu-latest
run_tests: true
- name: ubuntu-22.04
runner: ubuntu-22.04
run_tests: true
- name: ubuntu-24.04-arm
runner: ubuntu-24.04-arm
run_tests: true
- name: ubuntu-24.04-release
runner: ubuntu-24.04
buildtype: release
- name: ubuntu-24.04-debug
runner: ubuntu-24.04
buildtype: debug
- name: ubuntu-24.04-hardened
runner: ubuntu-24.04
extra_cflags: "-D_FORTIFY_SOURCE=3 -fstack-protector-strong"
steps:
- 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 dependencies
run: |
sudo apt-get update
sudo apt-get install -y gcc ninja-build pkg-config libglib2.0-dev libfuse3-dev ${{ matrix.run_tests && 'openssh-server openssh-client fuse3' || '' }}
pip3 install meson ${{ matrix.run_tests && 'pytest pytest-timeout' || '' }}
- name: Build
env:
CFLAGS: ${{ matrix.extra_cflags || '' }}
run: |
meson setup build ${{ matrix.buildtype && format('--buildtype={0}', matrix.buildtype) || '' }}
ninja -C build
- name: Setup SSH
if: matrix.run_tests
run: |
chmod go-w ~
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 sed -i 's/^#*PubkeyAuthentication.*/PubkeyAuthentication yes/' /etc/ssh/sshd_config
sudo systemctl restart ssh || sudo service ssh restart
ssh -o StrictHostKeyChecking=no -o BatchMode=yes localhost true
- name: Check FUSE availability
if: matrix.run_tests
run: |
test -e /dev/fuse
command -v fusermount3
- name: Run tests
if: matrix.run_tests
timeout-minutes: 20
run: |
cd build
python3 -m pytest test/ --timeout=300 --maxfail=99 --junitxml=test-results.xml
- name: Upload test results
if: matrix.run_tests && always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: test-results-${{ matrix.name }}
path: |
build/test-results.xml
build/meson-logs/
- name: Upload build logs
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: failure()
with:
name: build-logs-${{ matrix.name }}
path: build/meson-logs/
alpine-musl:
name: alpine-musl
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Build in Alpine container
run: |
docker run --rm -v "$PWD:/work" -w /work alpine:3.21@sha256:48b0309ca019d89d40f670aa1bc06e426dc0931948452e8491e3d65087abc07d sh -euxc '
apk add --no-cache gcc musl-dev meson ninja pkgconf glib-dev fuse3-dev
meson setup build-alpine
ninja -C build-alpine
'
- name: Upload build logs
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: failure()
with:
name: build-logs-alpine-musl
path: build-alpine/meson-logs/
freebsd:
name: freebsd-14
runs-on: ubuntu-24.04
timeout-minutes: 20
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Build on FreeBSD
uses: vmactions/freebsd-vm@d1e65811565151536c0c894fff74f06351ed26e6 # v1.4.5
with:
usesh: true
prepare: |
pkg install -y fusefs-libs3 glib meson ninja pkgconf
run: |
meson setup build-freebsd
ninja -C build-freebsd
- name: Upload build logs
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: failure()
with:
name: build-logs-freebsd
path: build-freebsd/meson-logs/
+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@v3
- 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
+4 -2
View File
@@ -1,7 +1,9 @@
Current Maintainer
Current Maintainers
------------------
None.
Haoxi Tan <h4sh5@pm.me>
Abhinav Agarwal <abhinavagarwal1996@gmail.com>
Past Maintainers
+22
View File
@@ -1,3 +1,25 @@
Release 3.7.6 (2026-05-30)
--------------------------
* Added new maintainer: abhinavagarwal07 Abhinav Agarwal
* Fixed critical vulnerability CVE-2026-47187 - Symlink Escape: Rogue SFTP Server to Local File Read/Write), credit to abhinavagarwal07
* New -o contain_symlinks and -o no_contain_symlinks to control symlink containment behavior
* Fixed high severity vulnerability CVE-2026-48711 - Improper Neutralization of Argument Delimiters in a Command ('Argument Injection'), credit to abhinavagarwal07
* Fixed null-deref warning in tokenize_on_space, promote strict-warnings to required
* Added a number of tests in CI, including rename, chmod, fsync, statvfs values, error paths, option coverage
* Fixed malformed SFTP reply handling
* Hardened Github Actions workflow with SHA pins, permissions, timeouts, and dependabot
Release 3.7.5 (2025-11-11)
--------------------------
* New maintainers added
* Main documentation updates to using Markdown
* Updated Windows and macOS support
* Added IPv6 support in -o directport
* Added -o vsock option
* Minor bugfixes
Release 3.7.3 (2022-05-26)
--------------------------
+33
View File
@@ -55,6 +55,39 @@ On BSD and macOS, to unmount the filesystem:
umount mountpoint
```
### Bypassing SSH
#### Using directport
Using direct connections to sftp-server to bypass SSH for performance is also possible. To do this, start a network service using sftp-server (part of OpenSSH) on a server, then connect directly using `-o directport=PORT` option.
On server (listen on port 1234 using socat):
`socat tcp-listen:1234,reuseaddr,fork exec:/usr/lib/openssh/sftp-server`
On client:
`sshfs -o directport=1234 127.0.0.1:/tmp /tmp/mnt`
Note that this is insecure as connection will happen without encryption. Only use this on localhost or trusted networks. This option is sometimes used by other projects to mount folders inside VMs.
IPv6 is also possible:
`socat tcp6-listen:1234,reuseaddr,fork exec:/usr/lib/openssh/sftp-server`
`sshfs -o directport=1234 [::1]:/tmp /tmp/mnt`
#### Using vsock
Similarly to above, Linux [vsock](https://man7.org/linux/man-pages/man7/vsock.7.html) can be used to connect directly to sockets within VMs using `-o vsock=CID:PORT`.
```
# on the host
socat VSOCK-LISTEN:12345 EXEC:"/usr/lib/openssh/sftp-server",nofork
# on the clientside
sshfs -o vsock=2:12345 unused_host: ./tmp
```
## Installation
+34 -12
View File
@@ -14,6 +14,7 @@
#include <errno.h>
#include <glib.h>
#include <pthread.h>
#include <sys/stat.h>
#define DEFAULT_CACHE_TIMEOUT_SECS 20
#define DEFAULT_MAX_CACHE_SIZE 10000
@@ -40,7 +41,7 @@ static struct cache cache;
struct node {
struct stat stat;
time_t stat_valid;
char **dir;
GPtrArray *dir;
time_t dir_valid;
char *link;
time_t link_valid;
@@ -63,13 +64,28 @@ struct file_handle {
unsigned long fs_fh;
};
struct cache_dirent {
char *name;
struct stat stat;
};
static void free_node(gpointer node_)
{
struct node *node = (struct node *) node_;
g_strfreev(node->dir);
if (node->dir != NULL) {
g_ptr_array_free(node->dir, TRUE);
}
g_free(node);
}
static void free_cache_dirent(gpointer data) {
struct cache_dirent *cache_dirent = (struct cache_dirent *) data;
if (cache_dirent != NULL) {
g_free(cache_dirent->name);
g_free(cache_dirent);
}
}
static int cache_clean_entry(void *key_, struct node *node, time_t *now)
{
(void) key_;
@@ -186,13 +202,15 @@ void cache_add_attr(const char *path, const struct stat *stbuf, uint64_t wrctr)
pthread_mutex_unlock(&cache.lock);
}
static void cache_add_dir(const char *path, char **dir)
static void cache_add_dir(const char *path, GPtrArray *dir)
{
struct node *node;
pthread_mutex_lock(&cache.lock);
node = cache_get(path);
g_strfreev(node->dir);
if (node->dir != NULL) {
g_ptr_array_free(node->dir, TRUE);
}
node->dir = dir;
node->dir_valid = time(NULL) + cache.dir_timeout_secs;
if (node->dir_valid > node->valid)
@@ -341,7 +359,10 @@ static int cache_dirfill (void *buf, const char *name,
ch = (struct readdir_handle*) buf;
err = ch->filler(ch->buf, name, stbuf, off, flags);
if (!err) {
g_ptr_array_add(ch->dir, g_strdup(name));
struct cache_dirent *cdent = g_malloc(sizeof(struct cache_dirent));
cdent->name = g_strdup(name);
cdent->stat = *stbuf;
g_ptr_array_add(ch->dir, cdent);
if (stbuf->st_mode & S_IFMT) {
char *fullpath;
const char *basepath = !ch->path[1] ? "" : ch->path;
@@ -361,8 +382,9 @@ static int cache_readdir(const char *path, void *buf, fuse_fill_dir_t filler,
struct readdir_handle ch;
struct file_handle *cfi;
int err;
char **dir;
GPtrArray *dir;
struct node *node;
struct cache_dirent **cdent;
assert(offset == 0);
@@ -371,9 +393,9 @@ static int cache_readdir(const char *path, void *buf, fuse_fill_dir_t filler,
if (node != NULL && node->dir != NULL) {
time_t now = time(NULL);
if (node->dir_valid - now >= 0) {
for(dir = node->dir; *dir != NULL; dir++)
// FIXME: What about st_mode?
filler(buf, *dir, NULL, 0, 0);
for(cdent = (struct cache_dirent**)node->dir->pdata; *cdent != NULL; cdent++) {
filler(buf, (*cdent)->name, &(*cdent)->stat, 0, 0);
}
pthread_mutex_unlock(&cache.lock);
return 0;
}
@@ -397,16 +419,16 @@ static int cache_readdir(const char *path, void *buf, fuse_fill_dir_t filler,
ch.buf = buf;
ch.filler = filler;
ch.dir = g_ptr_array_new();
g_ptr_array_set_free_func(ch.dir, free_cache_dirent);
ch.wrctr = cache_get_write_ctr();
err = cache.next_oper->readdir(path, &ch, cache_dirfill, offset, fi, flags);
g_ptr_array_add(ch.dir, NULL);
dir = (char **) ch.dir->pdata;
dir = ch.dir;
if (!err) {
cache_add_dir(path, dir);
} else {
g_strfreev(dir);
g_ptr_array_free(dir, TRUE);
}
g_ptr_array_free(ch.dir, FALSE);
return err;
}
-364
View File
@@ -1,364 +0,0 @@
/*
FUSE: Filesystem in Userspace
Copyright (C) 2001-2006 Miklos Szeredi <miklos@szeredi.hu>
This program can be distributed under the terms of the GNU LGPL.
See the file COPYING.LIB
*/
#include "fuse_opt.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <assert.h>
struct fuse_opt_context {
void *data;
const struct fuse_opt *opt;
fuse_opt_proc_t proc;
int argctr;
int argc;
char **argv;
struct fuse_args outargs;
char *opts;
int nonopt;
};
void fuse_opt_free_args(struct fuse_args *args)
{
if (args && args->argv && args->allocated) {
int i;
for (i = 0; i < args->argc; i++)
free(args->argv[i]);
free(args->argv);
args->argv = NULL;
args->allocated = 0;
}
}
static int alloc_failed(void)
{
fprintf(stderr, "fuse: memory allocation failed\n");
return -1;
}
int fuse_opt_add_arg(struct fuse_args *args, const char *arg)
{
char **newargv;
char *newarg;
assert(!args->argv || args->allocated);
newargv = realloc(args->argv, (args->argc + 2) * sizeof(char *));
newarg = newargv ? strdup(arg) : NULL;
if (!newargv || !newarg)
return alloc_failed();
args->argv = newargv;
args->allocated = 1;
args->argv[args->argc++] = newarg;
args->argv[args->argc] = NULL;
return 0;
}
int fuse_opt_insert_arg(struct fuse_args *args, int pos, const char *arg)
{
assert(pos <= args->argc);
if (fuse_opt_add_arg(args, arg) == -1)
return -1;
if (pos != args->argc - 1) {
char *newarg = args->argv[args->argc - 1];
memmove(&args->argv[pos + 1], &args->argv[pos],
sizeof(char *) * (args->argc - pos - 1));
args->argv[pos] = newarg;
}
return 0;
}
static int next_arg(struct fuse_opt_context *ctx, const char *opt)
{
if (ctx->argctr + 1 >= ctx->argc) {
fprintf(stderr, "fuse: missing argument after `%s'\n", opt);
return -1;
}
ctx->argctr++;
return 0;
}
static int add_arg(struct fuse_opt_context *ctx, const char *arg)
{
return fuse_opt_add_arg(&ctx->outargs, arg);
}
int fuse_opt_add_opt(char **opts, const char *opt)
{
char *newopts;
if (!*opts)
newopts = strdup(opt);
else {
unsigned oldlen = strlen(*opts);
newopts = realloc(*opts, oldlen + 1 + strlen(opt) + 1);
if (newopts) {
newopts[oldlen] = ',';
strcpy(newopts + oldlen + 1, opt);
}
}
if (!newopts)
return alloc_failed();
*opts = newopts;
return 0;
}
static int add_opt(struct fuse_opt_context *ctx, const char *opt)
{
return fuse_opt_add_opt(&ctx->opts, opt);
}
static int call_proc(struct fuse_opt_context *ctx, const char *arg, int key,
int iso)
{
if (key == FUSE_OPT_KEY_DISCARD)
return 0;
if (key != FUSE_OPT_KEY_KEEP && ctx->proc) {
int res = ctx->proc(ctx->data, arg, key, &ctx->outargs);
if (res == -1 || !res)
return res;
}
if (iso)
return add_opt(ctx, arg);
else
return add_arg(ctx, arg);
}
static int match_template(const char *t, const char *arg, unsigned *sepp)
{
int arglen = strlen(arg);
const char *sep = strchr(t, '=');
sep = sep ? sep : strchr(t, ' ');
if (sep && (!sep[1] || sep[1] == '%')) {
int tlen = sep - t;
if (sep[0] == '=')
tlen ++;
if (arglen >= tlen && strncmp(arg, t, tlen) == 0) {
*sepp = sep - t;
return 1;
}
}
if (strcmp(t, arg) == 0) {
*sepp = 0;
return 1;
}
return 0;
}
static const struct fuse_opt *find_opt(const struct fuse_opt *opt,
const char *arg, unsigned *sepp)
{
for (; opt && opt->templ; opt++)
if (match_template(opt->templ, arg, sepp))
return opt;
return NULL;
}
int fuse_opt_match(const struct fuse_opt *opts, const char *opt)
{
unsigned dummy;
return find_opt(opts, opt, &dummy) ? 1 : 0;
}
static int process_opt_param(void *var, const char *format, const char *param,
const char *arg)
{
assert(format[0] == '%');
if (format[1] == 's') {
char *copy = strdup(param);
if (!copy)
return alloc_failed();
*(char **) var = copy;
} else {
if (sscanf(param, format, var) != 1) {
fprintf(stderr, "fuse: invalid parameter in option `%s'\n", arg);
return -1;
}
}
return 0;
}
static int process_opt(struct fuse_opt_context *ctx,
const struct fuse_opt *opt, unsigned sep,
const char *arg, int iso)
{
if (opt->offset == -1U) {
if (call_proc(ctx, arg, opt->value, iso) == -1)
return -1;
} else {
void *var = ctx->data + opt->offset;
if (sep && opt->templ[sep + 1]) {
const char *param = arg + sep;
if (opt->templ[sep] == '=')
param ++;
if (process_opt_param(var, opt->templ + sep + 1,
param, arg) == -1)
return -1;
} else
*(int *)var = opt->value;
}
return 0;
}
static int process_opt_sep_arg(struct fuse_opt_context *ctx,
const struct fuse_opt *opt, unsigned sep,
const char *arg, int iso)
{
int res;
char *newarg;
char *param;
if (next_arg(ctx, arg) == -1)
return -1;
param = ctx->argv[ctx->argctr];
newarg = malloc(sep + strlen(param) + 1);
if (!newarg)
return alloc_failed();
memcpy(newarg, arg, sep);
strcpy(newarg + sep, param);
res = process_opt(ctx, opt, sep, newarg, iso);
free(newarg);
return res;
}
static int process_gopt(struct fuse_opt_context *ctx, const char *arg, int iso)
{
unsigned sep;
const struct fuse_opt *opt = find_opt(ctx->opt, arg, &sep);
if (opt) {
for (; opt; opt = find_opt(opt + 1, arg, &sep)) {
int res;
if (sep && opt->templ[sep] == ' ' && !arg[sep])
res = process_opt_sep_arg(ctx, opt, sep, arg, iso);
else
res = process_opt(ctx, opt, sep, arg, iso);
if (res == -1)
return -1;
}
return 0;
} else
return call_proc(ctx, arg, FUSE_OPT_KEY_OPT, iso);
}
static int process_real_option_group(struct fuse_opt_context *ctx, char *opts)
{
char *sep;
do {
int res;
sep = strchr(opts, ',');
if (sep)
*sep = '\0';
res = process_gopt(ctx, opts, 1);
if (res == -1)
return -1;
opts = sep + 1;
} while (sep);
return 0;
}
static int process_option_group(struct fuse_opt_context *ctx, const char *opts)
{
int res;
char *copy;
const char *sep = strchr(opts, ',');
if (!sep)
return process_gopt(ctx, opts, 1);
copy = strdup(opts);
if (!copy) {
fprintf(stderr, "fuse: memory allocation failed\n");
return -1;
}
res = process_real_option_group(ctx, copy);
free(copy);
return res;
}
static int process_one(struct fuse_opt_context *ctx, const char *arg)
{
if (ctx->nonopt || arg[0] != '-')
return call_proc(ctx, arg, FUSE_OPT_KEY_NONOPT, 0);
else if (arg[1] == 'o') {
if (arg[2])
return process_option_group(ctx, arg + 2);
else {
if (next_arg(ctx, arg) == -1)
return -1;
return process_option_group(ctx, ctx->argv[ctx->argctr]);
}
} else if (arg[1] == '-' && !arg[2]) {
if (add_arg(ctx, arg) == -1)
return -1;
ctx->nonopt = ctx->outargs.argc;
return 0;
} else
return process_gopt(ctx, arg, 0);
}
static int opt_parse(struct fuse_opt_context *ctx)
{
if (ctx->argc) {
if (add_arg(ctx, ctx->argv[0]) == -1)
return -1;
}
for (ctx->argctr = 1; ctx->argctr < ctx->argc; ctx->argctr++)
if (process_one(ctx, ctx->argv[ctx->argctr]) == -1)
return -1;
if (ctx->opts) {
if (fuse_opt_insert_arg(&ctx->outargs, 1, "-o") == -1 ||
fuse_opt_insert_arg(&ctx->outargs, 2, ctx->opts) == -1)
return -1;
}
if (ctx->nonopt && ctx->nonopt == ctx->outargs.argc) {
free(ctx->outargs.argv[ctx->outargs.argc - 1]);
ctx->outargs.argv[--ctx->outargs.argc] = NULL;
}
return 0;
}
int fuse_opt_parse(struct fuse_args *args, void *data,
const struct fuse_opt opts[], fuse_opt_proc_t proc)
{
int res;
struct fuse_opt_context ctx = {
.data = data,
.opt = opts,
.proc = proc,
};
if (!args || !args->argv || !args->argc)
return 0;
ctx.argc = args->argc;
ctx.argv = args->argv;
res = opt_parse(&ctx);
if (res != -1) {
struct fuse_args tmp = *args;
*args = ctx.outargs;
ctx.outargs = tmp;
}
free(ctx.opts);
fuse_opt_free_args(&ctx.outargs);
return res;
}
-258
View File
@@ -1,258 +0,0 @@
/*
FUSE: Filesystem in Userspace
Copyright (C) 2001-2006 Miklos Szeredi <miklos@szeredi.hu>
This program can be distributed under the terms of the GNU GPL.
See the file COPYING.
*/
#ifndef _FUSE_OPT_H_
#define _FUSE_OPT_H_
/* This file defines the option parsing interface of FUSE */
#ifdef __cplusplus
extern "C" {
#endif
/**
* Option description
*
* This structure describes a single option, and an action associated
* with it, in case it matches.
*
* More than one such match may occur, in which case the action for
* each match is executed.
*
* There are three possible actions in case of a match:
*
* i) An integer (int or unsigned) variable determined by 'offset' is
* set to 'value'
*
* ii) The processing function is called, with 'value' as the key
*
* iii) An integer (any) or string (char *) variable determined by
* 'offset' is set to the value of an option parameter
*
* 'offset' should normally be either set to
*
* - 'offsetof(struct foo, member)' actions i) and iii)
*
* - -1 action ii)
*
* The 'offsetof()' macro is defined in the <stddef.h> header.
*
* The template determines which options match, and also have an
* effect on the action. Normally the action is either i) or ii), but
* if a format is present in the template, then action iii) is
* performed.
*
* The types of templates are:
*
* 1) "-x", "-foo", "--foo", "--foo-bar", etc. These match only
* themselves. Invalid values are "--" and anything beginning
* with "-o"
*
* 2) "foo", "foo-bar", etc. These match "-ofoo", "-ofoo-bar" or
* the relevant option in a comma separated option list
*
* 3) "bar=", "--foo=", etc. These are variations of 1) and 2)
* which have a parameter
*
* 4) "bar=%s", "--foo=%lu", etc. Same matching as above but perform
* action iii).
*
* 5) "-x ", etc. Matches either "-xparam" or "-x param" as
* two separate arguments
*
* 6) "-x %s", etc. Combination of 4) and 5)
*
* If the format is "%s", memory is allocated for the string unlike
* with scanf().
*/
struct fuse_opt {
/** Matching template and optional parameter formatting */
const char *templ;
/**
* Offset of variable within 'data' parameter of fuse_opt_parse()
* or -1
*/
unsigned long offset;
/**
* Value to set the variable to, or to be passed as 'key' to the
* processing function. Ignored if template has a format
*/
int value;
};
/**
* Key option. In case of a match, the processing function will be
* called with the specified key.
*/
#define FUSE_OPT_KEY(templ, key) { templ, -1U, key }
/**
* Last option. An array of 'struct fuse_opt' must end with a NULL
* template value
*/
#define FUSE_OPT_END { .templ = NULL }
/**
* Argument list
*/
struct fuse_args {
/** Argument count */
int argc;
/** Argument vector. NULL terminated */
char **argv;
/** Is 'argv' allocated? */
int allocated;
};
/**
* Initializer for 'struct fuse_args'
*/
#define FUSE_ARGS_INIT(argc, argv) { argc, argv, 0 }
/**
* Key value passed to the processing function if an option did not
* match any template
*/
#define FUSE_OPT_KEY_OPT -1
/**
* Key value passed to the processing function for all non-options
*
* Non-options are the arguments beginning with a charater other than
* '-' or all arguments after the special '--' option
*/
#define FUSE_OPT_KEY_NONOPT -2
/**
* Special key value for options to keep
*
* Argument is not passed to processing function, but behave as if the
* processing function returned 1
*/
#define FUSE_OPT_KEY_KEEP -3
/**
* Special key value for options to discard
*
* Argument is not passed to processing function, but behave as if the
* processing function returned zero
*/
#define FUSE_OPT_KEY_DISCARD -4
/**
* Processing function
*
* This function is called if
* - option did not match any 'struct fuse_opt'
* - argument is a non-option
* - option did match and offset was set to -1
*
* The 'arg' parameter will always contain the whole argument or
* option including the parameter if exists. A two-argument option
* ("-x foo") is always converted to single arguemnt option of the
* form "-xfoo" before this function is called.
*
* Options of the form '-ofoo' are passed to this function without the
* '-o' prefix.
*
* The return value of this function determines whether this argument
* is to be inserted into the output argument vector, or discarded.
*
* @param data is the user data passed to the fuse_opt_parse() function
* @param arg is the whole argument or option
* @param key determines why the processing function was called
* @param outargs the current output argument list
* @return -1 on error, 0 if arg is to be discarded, 1 if arg should be kept
*/
typedef int (*fuse_opt_proc_t)(void *data, const char *arg, int key,
struct fuse_args *outargs);
/**
* Option parsing function
*
* If 'args' was returned from a previous call to fuse_opt_parse() or
* it was constructed from
*
* A NULL 'args' is equivalent to an empty argument vector
*
* A NULL 'opts' is equivalent to an 'opts' array containing a single
* end marker
*
* A NULL 'proc' is equivalent to a processing function always
* returning '1'
*
* @param args is the input and output argument list
* @param data is the user data
* @param opts is the option description array
* @param proc is the processing function
* @return -1 on error, 0 on success
*/
int fuse_opt_parse(struct fuse_args *args, void *data,
const struct fuse_opt opts[], fuse_opt_proc_t proc);
/**
* Add an option to a comma separated option list
*
* @param opts is a pointer to an option list, may point to a NULL value
* @param opt is the option to add
* @return -1 on allocation error, 0 on success
*/
int fuse_opt_add_opt(char **opts, const char *opt);
/**
* Add an argument to a NULL terminated argument vector
*
* @param args is the structure containing the current argument list
* @param arg is the new argument to add
* @return -1 on allocation error, 0 on success
*/
int fuse_opt_add_arg(struct fuse_args *args, const char *arg);
/**
* Add an argument at the specified position in a NULL terminated
* argument vector
*
* Adds the argument to the N-th position. This is useful for adding
* options at the beggining of the array which must not come after the
* special '--' option.
*
* @param args is the structure containing the current argument list
* @param pos is the position at which to add the argument
* @param arg is the new argument to add
* @return -1 on allocation error, 0 on success
*/
int fuse_opt_insert_arg(struct fuse_args *args, int pos, const char *arg);
/**
* Free the contents of argument list
*
* The structure itself is not freed
*
* @param args is the structure containing the argument list
*/
void fuse_opt_free_args(struct fuse_args *args);
/**
* Check if an option matches
*
* @param opts is the option description array
* @param opt is the option to match
* @return 1 if a match is found, 0 if not
*/
int fuse_opt_match(const struct fuse_opt opts[], const char *opt);
#ifdef __cplusplus
}
#endif
#endif /* _FUSE_OPT_H_ */
+3 -2
View File
@@ -1,4 +1,4 @@
project('sshfs', 'c', version: '3.7.3',
project('sshfs', 'c', version: '3.7.6',
meson_version: '>= 0.40',
default_options: [ 'buildtype=debugoptimized' ])
@@ -34,8 +34,9 @@ cfg.set_quoted('PACKAGE_VERSION', meson.project_version())
include_dirs = [ include_directories('.') ]
sshfs_sources = ['sshfs.c', 'cache.c']
if target_machine.system() == 'darwin'
add_global_arguments('-DFUSE_DARWIN_ENABLE_EXTENSIONS=0', language: 'c')
cfg.set_quoted('IDMAP_DEFAULT', 'user')
sshfs_sources += [ 'compat/fuse_opt.c', 'compat/darwin_compat.c' ]
sshfs_sources += [ 'compat/darwin_compat.c' ]
include_dirs += [ include_directories('compat') ]
else
cfg.set_quoted('IDMAP_DEFAULT', 'none')
+125 -41
View File
@@ -14,9 +14,6 @@
#if !defined(__CYGWIN__)
# include <fuse_lowlevel.h>
#endif
#ifdef __APPLE__
# include <fuse_darwin.h>
#endif
#include <assert.h>
#include <stdio.h>
#include <stdlib.h>
@@ -283,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;
@@ -292,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;
@@ -315,6 +313,7 @@ struct sshfs {
int fstat_workaround;
int createmode_workaround;
int transform_symlinks;
int contain_symlinks;
int follow_symlinks;
int no_check_root;
int detect_uid;
@@ -340,7 +339,6 @@ struct sshfs {
int sync_readdir;
int direct_io;
int debug;
int verbose;
int foreground;
int reconnect;
int delay_connect;
@@ -494,9 +492,10 @@ 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("contain_symlinks", contain_symlinks, 1),
SSHFS_OPT("no_contain_symlinks", contain_symlinks, 0),
SSHFS_OPT("follow_symlinks", follow_symlinks, 1),
SSHFS_OPT("no_check_root", no_check_root, 1),
SSHFS_OPT("password_stdin", password_stdin, 1),
@@ -516,8 +515,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),
@@ -1179,7 +1176,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);
@@ -1227,6 +1224,13 @@ static int start_ssh(struct conn *conn)
fprintf(stderr, "\n");
}
#if defined(__CYGWIN__)
/*
* Windows native OpenSSH stdio behavior. For details check
* https://github.com/PowerShell/openssh-portable/pull/759
*/
putenv("OPENSSH_STDIO_MODE=nonsock");
#endif
execvp(sshfs.ssh_args.argv[0], sshfs.ssh_args.argv);
fprintf(stderr, "failed to execute '%s': %s\n",
sshfs.ssh_args.argv[0], strerror(errno));
@@ -1254,6 +1258,10 @@ static int connect_to(struct conn *conn, char *host, char *port)
memset(&hint, 0, sizeof(hint));
hint.ai_family = PF_INET;
if (strstr(host, ":") != NULL) { // only ipv6 should have : in it, normal IP and domains do not.
hint.ai_family = PF_INET6;
DEBUG("using ipv6 to connect to host %s\n", host);
}
hint.ai_socktype = SOCK_STREAM;
err = getaddrinfo(host, port, &hint, &ai);
if (err) {
@@ -1437,23 +1445,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;
}
@@ -1526,10 +1535,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 *)
@@ -2165,6 +2178,36 @@ static void strip_common(const char **sp, const char **tp)
} while ((*s == *t && *s) || (!*s && *t == '/') || (*s == '/' && !*t));
}
/*
* Reject symlink targets that could escape the mount root: absolute
* paths and any target containing a ".." component. Returns 1 if
* the target is safe to expose to the kernel, 0 otherwise.
*/
static int symlink_target_is_contained(const char *target)
{
const char *p = target;
if (*p == '/')
return 0;
while (*p) {
const char *comp = p;
while (*p && *p != '/')
p++;
/*
* Reject any ".." rather than try to normalize: in an
* adversarial filesystem the server controls intermediate
* components, so lexical normalization cannot be trusted.
*/
if (p - comp == 2 && comp[0] == '.' && comp[1] == '.')
return 0;
while (*p == '/')
p++;
}
return 1;
}
static void transform_symlink(const char *path, char **linkp)
{
const char *l = *linkp;
@@ -2229,6 +2272,13 @@ static int sshfs_readlink(const char *path, char *linkbuf, size_t size)
buf_get_string(&name, &link) != -1) {
if (sshfs.transform_symlinks)
transform_symlink(path, &link);
if (sshfs.contain_symlinks &&
!symlink_target_is_contained(link)) {
free(link);
buf_free(&name);
buf_free(&buf);
return -EPERM;
}
strncpy(linkbuf, link, size - 1);
linkbuf[size - 1] = '\0';
free(link);
@@ -2759,6 +2809,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)
{
@@ -2819,12 +2875,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);
@@ -2864,10 +2921,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);
}
@@ -2938,7 +2997,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);
@@ -2946,12 +3004,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);
@@ -3378,7 +3437,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
@@ -3664,7 +3723,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"
@@ -3702,6 +3760,9 @@ static void usage(const char *progname)
" -o passive communicate over stdin and stdout bypassing network\n"
" -o disable_hardlink link(2) will return with errno set to ENOSYS\n"
" -o transform_symlinks transform absolute symlinks to relative\n"
" -o contain_symlinks reject absolute symlinks and symlinks containing ..\n"
" (enabled by default; disable with no_contain_symlinks)\n"
" -o no_contain_symlinks allow all symlink targets including absolute and ..\n"
" -o follow_symlinks follow symlinks on the server\n"
" -o no_check_root don't check for existence of 'dir' on server\n"
" -o password_stdin read password from stdin (only for pam_mount!)\n"
@@ -3794,6 +3855,16 @@ static int sshfs_opt_proc(void *data, const char *arg, int key,
sshfs.mountpoint = strdup(arg);
} else {
sshfs.mountpoint = realpath(arg, NULL);
#ifdef __APPLE__
if (!sshfs.mountpoint) {
/*
* The mountpoint does not exist, yet.
* macFUSE will try to create it before
* mounting the volume.
*/
sshfs.mountpoint = strdup(arg);
}
#endif
}
#endif
if (!sshfs.mountpoint) {
@@ -3926,7 +3997,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;
@@ -3991,6 +4062,11 @@ static char *find_base_path(void)
*d++ = '\0';
s++;
if (sshfs.host[0] == '-') {
fprintf(stderr, "invalid hostname '%s'\n", sshfs.host);
exit(1);
}
return s;
}
@@ -4249,6 +4325,7 @@ int main(int argc, char *argv[])
sshfs.max_conns = 1;
sshfs.ptyfd = -1;
sshfs.dir_cache = 1;
sshfs.contain_symlinks = 1;
sshfs.show_help = 0;
sshfs.show_version = 0;
sshfs.singlethread = 0;
@@ -4299,6 +4376,12 @@ int main(int argc, char *argv[])
exit(1);
}
if (sshfs.transform_symlinks && sshfs.contain_symlinks)
fprintf(stderr, "warning: transform_symlinks with "
"contain_symlinks may reject transformed links "
"containing '..' - consider adding "
"-o no_contain_symlinks\n");
if (sshfs.idmap == IDMAP_USER)
sshfs.detect_uid = 1;
else if (sshfs.idmap == IDMAP_FILE) {
@@ -4382,7 +4465,6 @@ int main(int argc, char *argv[])
tmp = g_strdup_printf("-%i", sshfs.ssh_ver);
ssh_add_arg(tmp);
g_free(tmp);
ssh_add_arg(sshfs.host);
if (sshfs.sftp_server)
sftp_server = sshfs.sftp_server;
else if (sshfs.ssh_ver == 1)
@@ -4393,6 +4475,8 @@ int main(int argc, char *argv[])
if (sshfs.ssh_ver != 1 && strchr(sftp_server, '/') == NULL)
ssh_add_arg("-s");
ssh_add_arg("--");
ssh_add_arg(sshfs.host);
ssh_add_arg(sftp_server);
free(sshfs.sftp_server);
+18 -1
View File
@@ -175,6 +175,21 @@ Options
``/foo/bar/com`` is a symlink to ``/foo/blub``, SSHFS will
transform the link target to ``../blub`` on the client side.
-o contain_symlinks
reject symlink targets that are absolute or contain ``..``
components. When a blocked symlink is encountered, readlink
returns EPERM. This is enabled by default to prevent a
malicious server from inducing local file reads or writes
through crafted symlink targets. Note that this is stricter
than ``transform_symlinks``: the two options should not normally
be combined, since transformed results often contain ``..``
and would be rejected by containment.
-o no_contain_symlinks
disable symlink containment and allow all symlink targets
through unchanged, including absolute paths and paths
containing ``..``. Only use this with fully trusted servers.
-o follow_symlinks
follow symlinks on the server, i.e. present them as regular
files on the client. If a symlink is dangling (i.e, the target does
@@ -338,9 +353,11 @@ https://github.com/libfuse/libfuse/issues.
Authors
=======
SSHFS is currently maintained by Nikolaus Rath <Nikolaus@rath.org>,
SSHFS was maintained by Nikolaus Rath <Nikolaus@rath.org>,
and was created by Miklos Szeredi <miklos@szeredi.hu>.
See https://github.com/libfuse/sshfs for new maintainer information.
This man page was originally written by Bartosz Fenski
<fenio@debian.org> for the Debian GNU/Linux distribution (but it may
be used by others).
+1 -1
View File
@@ -1,5 +1,5 @@
test_scripts = [ 'conftest.py', 'pytest.ini', 'test_sshfs.py',
'util.py' ]
'test_hostname_validation.py', 'util.py' ]
custom_target('test_scripts', input: test_scripts,
output: test_scripts, build_by_default: true,
command: ['cp', '-fPp',
+60
View File
@@ -0,0 +1,60 @@
#!/usr/bin/env python3
"""Tests for hostname validation — no FUSE mount required."""
if __name__ == "__main__":
import pytest
import sys
sys.exit(pytest.main([__file__] + sys.argv[1:]))
import subprocess
from util import base_cmdline, basename
from os.path import join as pjoin
def test_reject_option_injection_in_hostname(tmpdir):
"""Bracketed source that resolves to a dash-prefixed host must be rejected."""
mnt_dir = str(tmpdir.mkdir("mnt"))
malicious = "[-oProxyCommand=echo pwned]:/path"
cmdline = base_cmdline + [
pjoin(basename, "sshfs"),
"-f",
malicious,
mnt_dir,
]
res = subprocess.run(
cmdline,
stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
timeout=10,
text=True,
)
assert res.returncode != 0
assert "invalid hostname" in res.stderr
def test_reject_dash_host_after_doubledash(tmpdir):
"""Non-bracketed dash-prefixed source after -- must also be rejected."""
mnt_dir = str(tmpdir.mkdir("mnt"))
cmdline = base_cmdline + [
pjoin(basename, "sshfs"),
"-f",
"--",
"-oProxyCommand=echo pwned:/path",
mnt_dir,
]
res = subprocess.run(
cmdline,
stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
timeout=10,
text=True,
)
assert res.returncode != 0
assert "invalid hostname" in res.stderr
+582 -8
View File
@@ -15,6 +15,7 @@ import shutil
import filecmp
import errno
from tempfile import NamedTemporaryFile
from contextlib import contextmanager
from util import (
wait_for_mount,
umount,
@@ -123,6 +124,9 @@ def test_sshfs(
# FUSE Cache
cmdline += ["-o", "entry_timeout=0", "-o", "attr_timeout=0"]
# Disable containment so tst_symlink can test absolute targets
cmdline += ["-o", "no_contain_symlinks"]
if multiconn:
cmdline += ["-o", "max_conns=3"]
@@ -135,7 +139,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 +149,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 +167,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 +222,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 +309,15 @@ def tst_symlink(mnt_dir):
assert fstat.st_nlink == 1
assert linkname in os.listdir(mnt_dir)
# Relative symlink without .. should also work
linkname2 = name_generator()
fullname2 = mnt_dir + "/" + linkname2
os.symlink("subdir/file", fullname2)
assert os.readlink(fullname2) == "subdir/file"
os.unlink(fullname)
assert linkname not in os.listdir(mnt_dir)
def tst_create(mnt_dir):
name = name_generator()
@@ -314,15 +414,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 +571,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 +628,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 +657,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 +701,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 +710,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 +728,355 @@ 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
@contextmanager
def _sshfs_mount(src_dir, mnt_dir, extra_opts=None):
"""Mount src_dir via sshfs, yield, then unmount."""
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)
yield mnt_dir
except Exception:
cleanup(mount_process, mnt_dir)
raise
else:
umount(mount_process, mnt_dir)
def test_contain_symlinks(tmpdir, capfd) -> None:
"""Default containment: safe symlinks resolve, dangerous ones get EPERM."""
capfd.register_output(r"^Warning: Permanently added 'localhost' .+", count=0)
_check_ssh_localhost()
mnt_dir = str(tmpdir.mkdir("mnt"))
src_dir = str(tmpdir.mkdir("src"))
os.makedirs(pjoin(src_dir, "sub"))
with open(pjoin(src_dir, "sub", "target"), "w") as f:
f.write("hello")
os.symlink("sub/target", pjoin(src_dir, "safe"))
os.symlink("./sub/target", pjoin(src_dir, "safe_dot"))
os.symlink("/etc/passwd", pjoin(src_dir, "abs"))
os.symlink("../../../etc/passwd", pjoin(src_dir, "dotdot"))
os.symlink("sub/../../etc/passwd", pjoin(src_dir, "interleaved"))
os.symlink("..", pjoin(src_dir, "bare_dotdot"))
with _sshfs_mount(src_dir, mnt_dir):
# Safe symlinks pass through and resolve
assert os.readlink(pjoin(mnt_dir, "safe")) == "sub/target"
assert os.readlink(pjoin(mnt_dir, "safe_dot")) == "./sub/target"
with open(pjoin(mnt_dir, "safe")) as f:
assert f.read() == "hello"
# Dangerous: readlink returns EPERM
for name in ("abs", "dotdot", "interleaved", "bare_dotdot"):
with pytest.raises(OSError) as exc_info:
os.readlink(pjoin(mnt_dir, name))
assert exc_info.value.errno == errno.EPERM
# Dangerous: traversal (open/stat) also EPERM
with pytest.raises(OSError) as exc_info:
open(pjoin(mnt_dir, "abs"))
assert exc_info.value.errno == errno.EPERM
with pytest.raises(OSError) as exc_info:
os.stat(pjoin(mnt_dir, "dotdot"))
assert exc_info.value.errno == errno.EPERM
def test_no_contain_symlinks(tmpdir, capfd) -> None:
"""Opt-out: symlinks pass through and actually resolve."""
capfd.register_output(r"^Warning: Permanently added 'localhost' .+", count=0)
_check_ssh_localhost()
mnt_dir = str(tmpdir.mkdir("mnt"))
src_dir = str(tmpdir.mkdir("src"))
os.symlink("/etc/passwd", pjoin(src_dir, "abs_link"))
os.symlink("../../../etc/passwd", pjoin(src_dir, "rel_escape"))
with _sshfs_mount(src_dir, mnt_dir, ["no_contain_symlinks"]):
assert os.readlink(pjoin(mnt_dir, "abs_link")) == "/etc/passwd"
assert os.readlink(pjoin(mnt_dir, "rel_escape")) == "../../../etc/passwd"
# Absolute symlink actually resolves (reads local /etc/passwd)
with open(pjoin(mnt_dir, "abs_link")) as f:
assert "root" in f.read()
# Relative escape: kernel must traverse the link (not EPERM).
# Target won't exist on the test host, so we just assert that
# sshfs didn't block it - any errno other than EPERM proves
# containment is genuinely disabled.
with pytest.raises(OSError) as exc_info:
os.stat(pjoin(mnt_dir, "rel_escape"))
assert exc_info.value.errno != errno.EPERM
def test_transform_with_contain(tmpdir, capfd) -> None:
"""transform_symlinks + default containment: transformed ../x is rejected."""
capfd.register_output(r"^Warning: Permanently added 'localhost' .+", count=0)
capfd.register_output(r"^warning: transform_symlinks.+", count=0)
_check_ssh_localhost()
mnt_dir = str(tmpdir.mkdir("mnt"))
src_dir = str(tmpdir.mkdir("src"))
os.makedirs(pjoin(src_dir, "other"))
with open(pjoin(src_dir, "other", "file"), "w") as f:
f.write("data")
# Absolute in-base: transform rewrites to "other/file" (no ..)
os.symlink(pjoin(src_dir, "other", "file"), pjoin(src_dir, "inbase"))
# Absolute in-base but sibling: transform rewrites to "../other/file"
os.makedirs(pjoin(src_dir, "sub"))
os.symlink(pjoin(src_dir, "other", "file"), pjoin(src_dir, "sub", "sibling"))
with _sshfs_mount(src_dir, mnt_dir, ["transform_symlinks"]):
# Direct child: transform produces "other/file" - no .., passes
link = os.readlink(pjoin(mnt_dir, "inbase"))
assert ".." not in link.split("/")
with open(pjoin(mnt_dir, "inbase")) as f:
assert f.read() == "data"
# Sibling: transform produces "../other/file" - has .., EPERM
with pytest.raises(OSError) as exc_info:
os.readlink(pjoin(mnt_dir, "sub", "sibling"))
assert exc_info.value.errno == errno.EPERM
# Same setup with no_contain_symlinks: sibling works
with _sshfs_mount(src_dir, mnt_dir,
["transform_symlinks", "no_contain_symlinks"]):
link = os.readlink(pjoin(mnt_dir, "sub", "sibling"))
assert ".." in link
with open(pjoin(mnt_dir, "sub", "sibling")) as f:
assert f.read() == "data"
def test_contain_symlinks_option_precedence(tmpdir, capfd) -> None:
"""Last option wins when contain_symlinks and no_contain_symlinks both set."""
capfd.register_output(r"^Warning: Permanently added 'localhost' .+", count=0)
_check_ssh_localhost()
mnt_dir = str(tmpdir.mkdir("mnt"))
src_dir = str(tmpdir.mkdir("src"))
os.symlink("/etc/passwd", pjoin(src_dir, "abs"))
# no_contain_symlinks last: containment disabled, readlink succeeds
with _sshfs_mount(src_dir, mnt_dir,
["contain_symlinks", "no_contain_symlinks"]):
assert os.readlink(pjoin(mnt_dir, "abs")) == "/etc/passwd"
# contain_symlinks last: containment enabled, EPERM
with _sshfs_mount(src_dir, mnt_dir,
["no_contain_symlinks", "contain_symlinks"]):
with pytest.raises(OSError) as exc_info:
os.readlink(pjoin(mnt_dir, "abs"))
assert exc_info.value.errno == errno.EPERM