Script 'mail_helper' called by obssrc
Hello community,

here is the log from the commit of package sshfs for openSUSE:Factory checked 
in at 2026-06-03 20:23:40
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Comparing /work/SRC/openSUSE:Factory/sshfs (Old)
 and      /work/SRC/openSUSE:Factory/.sshfs.new.1937 (New)
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

Package is "sshfs"

Wed Jun  3 20:23:40 2026 rev:43 rq:1356809 version:3.7.6

Changes:
--------
--- /work/SRC/openSUSE:Factory/sshfs/sshfs.changes      2025-12-25 
19:58:11.936591991 +0100
+++ /work/SRC/openSUSE:Factory/.sshfs.new.1937/sshfs.changes    2026-06-03 
20:26:47.072068042 +0200
@@ -1,0 +2,19 @@
+Tue Jun  2 19:08:43 UTC 2026 - Matej Cepl <[email protected]>
+
+- Update to 3.7.6:
+  - Added new maintainer: abhinavagarwal07 Abhinav Agarwal
+  - CVE-2026-47187: Fixed critical vulnerability - Symlink
+    Escape: Rogue SFTP Server to Local File Read/Write), credit
+    to abhinavagarwal07 (bsc#1267017)
+  - New -o contain_symlinks and -o no_contain_symlinks to control
+    symlink containment behavior
+  - CVE-2026-48711: Fixed high severity vulnerability - Improper
+    Neutralization of Argument Delimiters in a Command ('Argument
+    Injection'), credit to abhinavagarwal07 (bsc#1267016)
+  - 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
+
+-------------------------------------------------------------------

Old:
----
  sshfs-3.7.5.tar.xz
  sshfs-3.7.5.tar.xz.asc

New:
----
  sshfs-3.7.6.tar.xz
  sshfs-3.7.6.tar.xz.asc

++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

Other differences:
------------------
++++++ sshfs.spec ++++++
--- /var/tmp/diff_new_pack.zDxViN/_old  2026-06-03 20:26:47.980105710 +0200
+++ /var/tmp/diff_new_pack.zDxViN/_new  2026-06-03 20:26:47.984105876 +0200
@@ -1,7 +1,7 @@
 #
 # spec file for package sshfs
 #
-# Copyright (c) 2025 SUSE LLC
+# Copyright (c) 2026 SUSE LLC and contributors
 # Copyright (c) 2025 Andreas Stieger <[email protected]>
 #
 # All modifications and additions to the file contributed by third parties
@@ -18,7 +18,7 @@
 
 
 Name:           sshfs
-Version:        3.7.5
+Version:        3.7.6
 Release:        0
 Summary:        Filesystem client based on SSH file transfer protocol
 License:        GPL-2.0-or-later

++++++ sshfs-3.7.5.tar.xz -> sshfs-3.7.6.tar.xz ++++++
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/sshfs-3.7.5/.github/dependabot.yml 
new/sshfs-3.7.6/.github/dependabot.yml
--- old/sshfs-3.7.5/.github/dependabot.yml      1970-01-01 01:00:00.000000000 
+0100
+++ new/sshfs-3.7.6/.github/dependabot.yml      2026-05-30 01:35:39.000000000 
+0200
@@ -0,0 +1,10 @@
+version: 2
+updates:
+  - package-ecosystem: github-actions
+    directory: /
+    schedule:
+      interval: weekly
+    groups:
+      github-actions:
+        patterns:
+          - "*"
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/sshfs-3.7.5/.github/workflows/build-platforms.yml 
new/sshfs-3.7.6/.github/workflows/build-platforms.yml
--- old/sshfs-3.7.5/.github/workflows/build-platforms.yml       1970-01-01 
01:00:00.000000000 +0100
+++ new/sshfs-3.7.6/.github/workflows/build-platforms.yml       2026-05-30 
01:35:39.000000000 +0200
@@ -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/
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/sshfs-3.7.5/.github/workflows/build-ubuntu.yml 
new/sshfs-3.7.6/.github/workflows/build-ubuntu.yml
--- old/sshfs-3.7.5/.github/workflows/build-ubuntu.yml  2025-11-11 
20:46:43.000000000 +0100
+++ new/sshfs-3.7.6/.github/workflows/build-ubuntu.yml  2026-05-30 
01:35:39.000000000 +0200
@@ -7,45 +7,137 @@
   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: Run tests
+        timeout-minutes: 20
+        run: |
+          cd build
+          python3 -m pytest --maxfail=99 --timeout=300 
--junitxml=test-results.xml test/
 
-      - name: make ssh into localhost without prompt possible for tests
+      - 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: |
-          ssh-keygen -b 2048 -t rsa  -f ~/.ssh/id_rsa -q -N ""
-          cat ~/.ssh/id_rsa.pub > ~/.ssh/authorized_keys
+          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: run tests
+      - name: Print tool versions
         run: |
-          cd build
-          python3 -m pytest test/
+          ${{ 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
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/sshfs-3.7.5/AUTHORS new/sshfs-3.7.6/AUTHORS
--- old/sshfs-3.7.5/AUTHORS     2025-11-11 20:46:43.000000000 +0100
+++ new/sshfs-3.7.6/AUTHORS     2026-05-30 01:35:39.000000000 +0200
@@ -1,7 +1,9 @@
-Current Maintainer
+Current Maintainers
 ------------------
 
-None.
+Haoxi Tan <[email protected]>
+Abhinav Agarwal <[email protected]>
+
 
 
 Past Maintainers
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/sshfs-3.7.5/ChangeLog.rst 
new/sshfs-3.7.6/ChangeLog.rst
--- old/sshfs-3.7.5/ChangeLog.rst       2025-11-11 20:46:43.000000000 +0100
+++ new/sshfs-3.7.6/ChangeLog.rst       2026-05-30 01:35:39.000000000 +0200
@@ -1,3 +1,15 @@
+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)
 --------------------------
 
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/sshfs-3.7.5/meson.build new/sshfs-3.7.6/meson.build
--- old/sshfs-3.7.5/meson.build 2025-11-11 20:46:43.000000000 +0100
+++ new/sshfs-3.7.6/meson.build 2026-05-30 01:35:39.000000000 +0200
@@ -1,4 +1,4 @@
-project('sshfs', 'c', version: '3.7.5',
+project('sshfs', 'c', version: '3.7.6',
         meson_version: '>= 0.40',
         default_options: [ 'buildtype=debugoptimized' ])
 
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/sshfs-3.7.5/sshfs.c new/sshfs-3.7.6/sshfs.c
--- old/sshfs-3.7.5/sshfs.c     2025-11-11 20:46:43.000000000 +0100
+++ new/sshfs-3.7.6/sshfs.c     2026-05-30 01:35:39.000000000 +0200
@@ -280,6 +280,11 @@
        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 @@
        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;
@@ -312,6 +313,7 @@
        int fstat_workaround;
        int createmode_workaround;
        int transform_symlinks;
+       int contain_symlinks;
        int follow_symlinks;
        int no_check_root;
        int detect_uid;
@@ -337,7 +339,6 @@
        int sync_readdir;
        int direct_io;
        int debug;
-       int verbose;
        int foreground;
        int reconnect;
        int delay_connect;
@@ -491,9 +492,10 @@
        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),
@@ -513,8 +515,6 @@
        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 +1176,7 @@
                        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 +1445,24 @@
 
 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 +1535,14 @@
 
        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 *)
@@ -2173,6 +2178,36 @@
        } 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;
@@ -2237,6 +2272,13 @@
                   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);
@@ -2767,6 +2809,12 @@
        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 +2875,13 @@
                        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 +2921,12 @@
                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 +2997,6 @@
 {
        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 +3004,13 @@
        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 +3437,7 @@
                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 +3723,6 @@
 "    -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"
@@ -3710,6 +3760,9 @@
 "    -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"
@@ -3944,7 +3997,7 @@
 
        start = pos;
 
-       while (pos && *pos != '\0') {
+       while (*pos != '\0') {
                // break on space, but not on '\ '
                if (*pos == ' ' && *(pos - 1) != '\\') {
                        break;
@@ -4009,6 +4062,11 @@
        *d++ = '\0';
        s++;
 
+       if (sshfs.host[0] == '-') {
+               fprintf(stderr, "invalid hostname '%s'\n", sshfs.host);
+               exit(1);
+       }
+
        return s;
 }
 
@@ -4267,6 +4325,7 @@
        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;
@@ -4317,6 +4376,12 @@
                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) {
@@ -4400,7 +4465,6 @@
        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)
@@ -4411,6 +4475,8 @@
        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);
 
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/sshfs-3.7.5/sshfs.rst new/sshfs-3.7.6/sshfs.rst
--- old/sshfs-3.7.5/sshfs.rst   2025-11-11 20:46:43.000000000 +0100
+++ new/sshfs-3.7.6/sshfs.rst   2026-05-30 01:35:39.000000000 +0200
@@ -175,6 +175,21 @@
    ``/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 @@
 Authors
 =======
 
-SSHFS is currently maintained by Nikolaus Rath <[email protected]>,
+SSHFS was maintained by Nikolaus Rath <[email protected]>,
 and was created by Miklos Szeredi <[email protected]>.
 
+See https://github.com/libfuse/sshfs for new maintainer information.
+
 This man page was originally written by Bartosz Fenski
 <[email protected]> for the Debian GNU/Linux distribution (but it may
 be used by others).
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/sshfs-3.7.5/test/meson.build 
new/sshfs-3.7.6/test/meson.build
--- old/sshfs-3.7.5/test/meson.build    2025-11-11 20:46:43.000000000 +0100
+++ new/sshfs-3.7.6/test/meson.build    2026-05-30 01:35:39.000000000 +0200
@@ -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',
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/sshfs-3.7.5/test/test_hostname_validation.py 
new/sshfs-3.7.6/test/test_hostname_validation.py
--- old/sshfs-3.7.5/test/test_hostname_validation.py    1970-01-01 
01:00:00.000000000 +0100
+++ new/sshfs-3.7.6/test/test_hostname_validation.py    2026-05-30 
01:35:39.000000000 +0200
@@ -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
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/sshfs-3.7.5/test/test_sshfs.py 
new/sshfs-3.7.6/test/test_sshfs.py
--- old/sshfs-3.7.5/test/test_sshfs.py  2025-11-11 20:46:43.000000000 +0100
+++ new/sshfs-3.7.6/test/test_sshfs.py  2026-05-30 01:35:39.000000000 +0200
@@ -15,6 +15,7 @@
 import filecmp
 import errno
 from tempfile import NamedTemporaryFile
+from contextlib import contextmanager
 from util import (
     wait_for_mount,
     umount,
@@ -123,6 +124,9 @@
     # 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 @@
     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 @@
         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 @@
         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 @@
     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 @@
     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 @@
         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 [email protected], 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 @@
     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 @@
     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 @@
         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_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 @@
     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 @@
     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

Reply via email to