fuse: route STATFS through the access gate (P1 #33c)

ober

0b341b7fbe230f9c3e55260b393ece5ca78018d2

diff --git a/lib/jerboa-fuse.sls b/lib/jerboa-fuse.sls
index 44c2a78..a2a5edb 100644
--- a/lib/jerboa-fuse.sls
+++ b/lib/jerboa-fuse.sls
@@ -36,7 +36,7 @@
    FATTR-MTIME-NOW FATTR-CTIME FOPEN-DIRECT-IO FOPEN-KEEP-CACHE
    FOPEN-NONSEEKABLE make-access-controller access-check
    access-controller-lock! access-controller-unlock!
-   access-controller-locked?)
+   access-controller-locked? always-allowed-opcode?)
   (import
     (except (chezscheme) make-hash-table hash-table? sort sort!
      printf fprintf format path-extension path-absolute?
@@ -154,8 +154,7 @@
   (def (always-allowed-opcode? op)
        (or (= op FUSE-INIT)
            (= op FUSE-DESTROY)
-           (= op FUSE-INTERRUPT)
-           (= op FUSE-STATFS)))
+           (= op FUSE-INTERRUPT)))
   (def (stealth-deny-response opcode unique nodeid)
        (cond
          [(and (= opcode FUSE-GETATTR) (= nodeid FUSE-ROOT-ID))
@@ -169,6 +168,8 @@
           (encode-out-header unique 0 0)]
          [(and (= opcode FUSE-OPENDIR) (= nodeid FUSE-ROOT-ID))
           (encode-open-out unique 0 0)]
+         [(= opcode FUSE-STATFS)
+          (encode-statfs-out unique (stealth-deny-statfs))]
          [(= opcode FUSE-RELEASEDIR) (encode-out-header unique 0 0)]
          [(or (= opcode FUSE-FORGET) (= opcode FUSE-BATCH-FORGET))
           #f]
diff --git a/lib/jerboa-fuse/access.sls b/lib/jerboa-fuse/access.sls
index 3e50ff0..7cddb05 100644
--- a/lib/jerboa-fuse/access.sls
+++ b/lib/jerboa-fuse/access.sls
@@ -5,8 +5,8 @@
 (library (jerboa-fuse access)
   (export make-access-controller access-check access-controller-lock!
     access-controller-unlock! access-controller-locked?
-    stealth-deny-attr stealth-deny-readdir access-current-pid
-    pid-is-descendant?)
+    stealth-deny-attr stealth-deny-readdir stealth-deny-statfs
+    access-current-pid pid-is-descendant?)
   (import
     (except (chezscheme) make-hash-table hash-table? sort sort!
      printf fprintf format path-extension path-absolute?
@@ -101,4 +101,6 @@
   (def (stealth-deny-readdir ino)
        (list
          (make-fuse-dirent ino 1 DT-DIR ".")
-         (make-fuse-dirent ino 2 DT-DIR ".."))))
+         (make-fuse-dirent ino 2 DT-DIR "..")))
+  (def (stealth-deny-statfs)
+       (make-fuse-statfs 0 0 0 0 0 4096 255 4096)))
diff --git a/src/jerboa-fuse.ss b/src/jerboa-fuse.ss
index f22a92f..424c89b 100644
--- a/src/jerboa-fuse.ss
+++ b/src/jerboa-fuse.ss
@@ -48,7 +48,10 @@
 
     ;; Re-exports: Access control
     make-access-controller access-check
-    access-controller-lock! access-controller-unlock! access-controller-locked?)
+    access-controller-lock! access-controller-unlock! access-controller-locked?
+
+    ;; Access gate predicate (which opcodes bypass the access controller)
+    always-allowed-opcode?)
 
   (import
     (jerboa prelude)
@@ -225,10 +228,13 @@
   ;; Request handler — dispatches a single request
   ;; ======================================================================
 
-  ;; Opcodes that are always allowed (protocol handshake, no data exposure)
+  ;; Opcodes that are always allowed (protocol handshake, no data exposure).
+  ;; FUSE-STATFS is intentionally NOT here: a denied caller would otherwise
+  ;; learn vault block counts, so it is routed through the access gate and
+  ;; answered with a generic empty statfs (see stealth-deny-response).
   (def (always-allowed-opcode? op)
     (or (= op FUSE-INIT) (= op FUSE-DESTROY)
-        (= op FUSE-INTERRUPT) (= op FUSE-STATFS)))
+        (= op FUSE-INTERRUPT)))
 
   ;; Stealth deny: denied processes see an empty directory, not errors.
   ;; GETATTR on root → valid empty dir attr. Everything else → ENOENT.
@@ -245,7 +251,10 @@
        (encode-out-header unique 0 0)]
       ;; OPENDIR on root → allow (so readdir works)
       [(and (= opcode FUSE-OPENDIR) (= nodeid FUSE-ROOT-ID))
-       (encode-open-out unique 0 0)]
+        (encode-open-out unique 0 0)]
+      ;; STATFS → generic empty stats (deny block-count leak)
+      [(= opcode FUSE-STATFS)
+       (encode-statfs-out unique (stealth-deny-statfs))]
       ;; RELEASEDIR → always OK
       [(= opcode FUSE-RELEASEDIR)
        (encode-out-header unique 0 0)]
diff --git a/src/jerboa-fuse/access.ss b/src/jerboa-fuse/access.ss
index cb24039..b7bce99 100644
--- a/src/jerboa-fuse/access.ss
+++ b/src/jerboa-fuse/access.ss
@@ -9,6 +9,7 @@
     ;; Stealth deny helpers (for FUSE dispatch)
     stealth-deny-attr         ;; → fuse-attr (empty root-like)
     stealth-deny-readdir      ;; → list of dirents (just . and ..)
+    stealth-deny-statfs       ;; → fuse-statfs (generic/empty, no block counts)
 
     ;; Process tree inspection
     access-current-pid
@@ -150,3 +151,9 @@
       (make-fuse-dirent ino 1 DT-DIR ".")
       (make-fuse-dirent ino 2 DT-DIR "..")))
 
+  (def (stealth-deny-statfs)
+    ;; Generic empty filesystem stats: no block counts are leaked to a
+    ;; caller denied by the access gate. bsize/namelen/frsize stay sane so
+    ;; the mountpoint still looks like a filesystem to ordinary tools.
+    (make-fuse-statfs 0 0 0 0 0 4096 255 4096))
+
diff --git a/tests/test-access.ss b/tests/test-access.ss
index 814a963..5aecd50 100644
--- a/tests/test-access.ss
+++ b/tests/test-access.ss
@@ -2,6 +2,8 @@
 ;;; Run with: jerbuild exec --libdirs lib tests/test-access.ss
 
 (import (jerboa prelude))
+(import (jerboa-fuse))
+(import (jerboa-fuse constants))
 (import (jerboa-fuse access))
 
 (define pass 0)
@@ -60,6 +62,30 @@
 (define sdirents (stealth-deny-readdir 1))
 (test-assert "stealth readdir has 2 entries" (= (length sdirents) 2))
 
+;; === STATFS access gate (P1 #33c) ===
+(display "=== stealth statfs / STATFS gate ===") (newline)
+
+;; STATFS must NOT be always-allowed: a denied caller would otherwise learn
+;; vault block counts. It is routed through the access gate instead.
+(test-assert "FUSE-STATFS is not always-allowed (gated)"
+  (not (always-allowed-opcode? FUSE-STATFS)))
+(test-assert "FUSE-INIT is still always-allowed"
+  (always-allowed-opcode? FUSE-INIT))
+
+;; A denied caller's STATFS receives a generic empty statfs (no block counts).
+(define sstat (stealth-deny-statfs))
+(test-assert "stealth statfs is a fuse-statfs" (fuse-statfs? sstat))
+(test-assert "stealth statfs leaks no block counts"
+  (and (zero? (fuse-statfs-blocks sstat))
+       (zero? (fuse-statfs-bfree sstat))
+       (zero? (fuse-statfs-bavail sstat))
+       (zero? (fuse-statfs-files sstat))
+       (zero? (fuse-statfs-ffree sstat))))
+(test-assert "stealth statfs keeps sane bsize/namelen/frsize"
+  (and (= (fuse-statfs-bsize sstat) 4096)
+       (= (fuse-statfs-namelen sstat) 255)
+       (= (fuse-statfs-frsize sstat) 4096)))
+
 ;; Summary
 (newline)
 (display "PASS: ") (display pass) (newline)