diff --git a/.github/workflows/golang-build-debug.yml b/.github/workflows/golang-build-debug.yml index 596a332..43253a5 100644 --- a/.github/workflows/golang-build-debug.yml +++ b/.github/workflows/golang-build-debug.yml @@ -18,8 +18,6 @@ jobs: cache: false - name: Checkout code uses: actions/checkout@v4 - - name: Run all Go tests - run: make test - name: Vendor the application for debug run: make vendor - name: Build the application for debug diff --git a/.github/workflows/golang-build.yml b/.github/workflows/golang-build.yml index c8bbb5a..4f50719 100644 --- a/.github/workflows/golang-build.yml +++ b/.github/workflows/golang-build.yml @@ -18,8 +18,6 @@ jobs: cache: false - name: Checkout code uses: actions/checkout@v4 - - name: Run all Go tests - run: make test - name: Vendor the application for production run: make vendor - name: Build the application for production diff --git a/.github/workflows/zipfuse-cli.yml b/.github/workflows/zipfuse-cli.yml new file mode 100644 index 0000000..538a0e8 --- /dev/null +++ b/.github/workflows/zipfuse-cli.yml @@ -0,0 +1,237 @@ +name: zipfuse-cli +on: + push: + branches: + - master + - main + pull_request: +permissions: + contents: read +jobs: + zipfuse: + name: cli + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.25.1' + + - name: Install FUSE + run: | + sudo apt-get update + sudo apt-get install -y fuse3 + sudo sed -i 's/#user_allow_other/user_allow_other/' /etc/fuse.conf + + - name: Vendor dependencies + run: make vendor + + - name: Build debug binaries + run: make debug + + - name: Install debug binaries + run: | + sudo cp zipfuse /bin/zipfuse + sudo chmod +x /bin/zipfuse + sudo cp mount.zipfuse /sbin/mount.zipfuse + sudo chmod +x /sbin/mount.zipfuse + + - name: Create test environment + run: | + mkdir -p source + mkdir -p mountpoint + + mkdir -p test1/subdir1/subdir2 + echo "Hello from test1.txt" > test1/test1.txt + echo "Second file in test1" > test1/file2.txt + echo "File in subdir1" > test1/subdir1/nested1.txt + echo "File in subdir2" > test1/subdir1/subdir2/nested2.txt + (cd test1 && zip -r ../source/archive1.zip .) + + mkdir -p test2/docs/reports + echo "Hello from test2.txt" > test2/test2.txt + echo "Another file in test2" > test2/another.txt + echo "Document file" > test2/docs/document.txt + echo "Report file" > test2/docs/reports/report.txt + (cd test2 && zip -r ../source/archive2.zip .) + + rm -rf test1 test2 + + - name: Mount filesystem + run: | + sudo sh -c 'nohup zipfuse source mountpoint --allow-other &>/var/log/zipfuse.log &' + + for i in {1..20}; do + if mountpoint -q mountpoint; then + echo "Filesystem mounted successfully" + break + fi + if [ $i -eq 30 ]; then + echo "Mount timeout" + exit 1 + fi + sleep 0.5 + done + + mount | grep zipfuse || true + ls -la mountpoint/ + + - name: Test filesystem + run: | + echo "Testing archive1..." + if [ ! -f "mountpoint/archive1/test1.txt" ]; then + echo "ERROR: test1.txt not found in archive1" + exit 1 + fi + + CONTENT1=$(cat mountpoint/archive1/test1.txt) + if [ "$CONTENT1" != "Hello from test1.txt" ]; then + echo "ERROR: Content mismatch in test1.txt" + echo "Expected: 'Hello from test1.txt'" + echo "Got: '$CONTENT1'" + exit 1 + fi + echo "archive1/test1.txt content verified" + + CONTENT2=$(cat mountpoint/archive1/file2.txt) + if [ "$CONTENT2" != "Second file in test1" ]; then + echo "ERROR: Content mismatch in file2.txt" + echo "Expected: 'Second file in test1'" + echo "Got: '$CONTENT2'" + exit 1 + fi + echo "archive1/file2.txt content verified" + + CONTENT3=$(cat mountpoint/archive1/subdir1/nested1.txt) + if [ "$CONTENT3" != "File in subdir1" ]; then + echo "ERROR: Content mismatch in subdir1/nested1.txt" + echo "Expected: 'File in subdir1'" + echo "Got: '$CONTENT3'" + exit 1 + fi + echo "archive1/subdir1/nested1.txt content verified" + + CONTENT4=$(cat mountpoint/archive1/subdir1/subdir2/nested2.txt) + if [ "$CONTENT4" != "File in subdir2" ]; then + echo "ERROR: Content mismatch in subdir1/subdir2/nested2.txt" + echo "Expected: 'File in subdir2'" + echo "Got: '$CONTENT4'" + exit 1 + fi + echo "archive1/subdir1/subdir2/nested2.txt content verified" + + echo "Testing archive2..." + if [ ! -f "mountpoint/archive2/test2.txt" ]; then + echo "ERROR: test2.txt not found in archive2" + exit 1 + fi + + CONTENT5=$(cat mountpoint/archive2/test2.txt) + if [ "$CONTENT5" != "Hello from test2.txt" ]; then + echo "ERROR: Content mismatch in test2.txt" + echo "Expected: 'Hello from test2.txt'" + echo "Got: '$CONTENT5'" + exit 1 + fi + echo "archive2/test2.txt content verified" + + CONTENT6=$(cat mountpoint/archive2/another.txt) + if [ "$CONTENT6" != "Another file in test2" ]; then + echo "ERROR: Content mismatch in another.txt" + echo "Expected: 'Another file in test2'" + echo "Got: '$CONTENT6'" + exit 1 + fi + echo "archive2/another.txt content verified" + + CONTENT7=$(cat mountpoint/archive2/docs/document.txt) + if [ "$CONTENT7" != "Document file" ]; then + echo "ERROR: Content mismatch in docs/document.txt" + echo "Expected: 'Document file'" + echo "Got: '$CONTENT7'" + exit 1 + fi + echo "archive2/docs/document.txt content verified" + + CONTENT8=$(cat mountpoint/archive2/docs/reports/report.txt) + if [ "$CONTENT8" != "Report file" ]; then + echo "ERROR: Content mismatch in docs/reports/report.txt" + echo "Expected: 'Report file'" + echo "Got: '$CONTENT8'" + exit 1 + fi + echo "archive2/docs/reports/report.txt content verified" + + echo "All file content tests passed!" + + - name: Unmount filesystem + if: always() + run: | + sudo killall zipfuse || true + + for i in {1..10}; do + if ! mountpoint -q mountpoint; then + echo "Filesystem unmounted successfully" + break + fi + sleep 0.5 + done + + for i in {1..10}; do + if ! pgrep -fa zipfuse &>/dev/null; then + echo "Filesystem binary exited successfully" + exit 0 + fi + sleep 0.5 + done + + echo "ERROR: Filesystem binary seems stuck after unmount" + exit 1 + + - name: Verify unmounted + if: always() + run: | + if mountpoint -q mountpoint; then + echo "ERROR: Filesystem still mounted after killall" + mount | grep zipfuse || true + exit 1 + fi + + if pgrep -fa zipfuse &>/dev/null; then + echo "ERROR: Filesystem binary still alive after unmount" + pgrep -fa zipfuse || true + exit 1 + fi + + if [ -f "mountpoint/archive1/test1.txt" ]; then + echo "ERROR: Files still accessible after unmount" + exit 1 + fi + + echo "Filesystem properly unmounted" + + - name: Show logs on failure + if: failure() + run: | + echo "=== Mount points ===" + mount | grep zipfuse || echo "No zipfuse mounts found" + echo "" + + echo "=== Mountpoint directory ===" + ls -la mountpoint/ || true + echo "" + + echo "=== Process list ===" + ps aux | grep zipfuse || echo "No zipfuse processes found" + echo "" + + echo "=== Dmesg (last 25 lines) ===" + sudo dmesg | tail -n 25 || true + echo "" + + echo "=== ZipFUSE Events (last 25 lines) ===" + sudo cat /var/log/zipfuse.log | tail -n 25 || true diff --git a/.github/workflows/zipfuse-fstab.yml b/.github/workflows/zipfuse-fstab.yml new file mode 100644 index 0000000..e1c2706 --- /dev/null +++ b/.github/workflows/zipfuse-fstab.yml @@ -0,0 +1,243 @@ +name: zipfuse-fstab +on: + push: + branches: + - master + - main + pull_request: +permissions: + contents: read +jobs: + zipfuse: + name: fstab + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.25.1' + + - name: Install FUSE + run: | + sudo apt-get update + sudo apt-get install -y fuse3 + sudo sed -i 's/#user_allow_other/user_allow_other/' /etc/fuse.conf + + - name: Vendor dependencies + run: make vendor + + - name: Build debug binaries + run: make debug + + - name: Install debug binaries + run: | + sudo cp zipfuse /bin/zipfuse + sudo chmod +x /bin/zipfuse + sudo cp mount.zipfuse /sbin/mount.zipfuse + sudo chmod +x /sbin/mount.zipfuse + + - name: Create test environment + run: | + mkdir -p source + mkdir -p mountpoint + + mkdir -p test1/subdir1/subdir2 + echo "Hello from test1.txt" > test1/test1.txt + echo "Second file in test1" > test1/file2.txt + echo "File in subdir1" > test1/subdir1/nested1.txt + echo "File in subdir2" > test1/subdir1/subdir2/nested2.txt + (cd test1 && zip -r ../source/archive1.zip .) + + mkdir -p test2/docs/reports + echo "Hello from test2.txt" > test2/test2.txt + echo "Another file in test2" > test2/another.txt + echo "Document file" > test2/docs/document.txt + echo "Report file" > test2/docs/reports/report.txt + (cd test2 && zip -r ../source/archive2.zip .) + + rm -rf test1 test2 + + - name: Mount filesystem + run: | + if ! sudo mount -t zipfuse source mountpoint -o allow_other; then + echo "ERROR: Filesystem mount has failed" + exit 1 + fi + + for i in {1..20}; do + if mountpoint -q mountpoint; then + echo "Filesystem mounted successfully" + break + fi + if [ $i -eq 30 ]; then + echo "Mount timeout" + exit 1 + fi + sleep 0.5 + done + + mount | grep zipfuse || true + ls -la mountpoint/ + + - name: Test filesystem + run: | + echo "Testing archive1..." + if [ ! -f "mountpoint/archive1/test1.txt" ]; then + echo "ERROR: test1.txt not found in archive1" + exit 1 + fi + + CONTENT1=$(cat mountpoint/archive1/test1.txt) + if [ "$CONTENT1" != "Hello from test1.txt" ]; then + echo "ERROR: Content mismatch in test1.txt" + echo "Expected: 'Hello from test1.txt'" + echo "Got: '$CONTENT1'" + exit 1 + fi + echo "archive1/test1.txt content verified" + + CONTENT2=$(cat mountpoint/archive1/file2.txt) + if [ "$CONTENT2" != "Second file in test1" ]; then + echo "ERROR: Content mismatch in file2.txt" + echo "Expected: 'Second file in test1'" + echo "Got: '$CONTENT2'" + exit 1 + fi + echo "archive1/file2.txt content verified" + + CONTENT3=$(cat mountpoint/archive1/subdir1/nested1.txt) + if [ "$CONTENT3" != "File in subdir1" ]; then + echo "ERROR: Content mismatch in subdir1/nested1.txt" + echo "Expected: 'File in subdir1'" + echo "Got: '$CONTENT3'" + exit 1 + fi + echo "archive1/subdir1/nested1.txt content verified" + + CONTENT4=$(cat mountpoint/archive1/subdir1/subdir2/nested2.txt) + if [ "$CONTENT4" != "File in subdir2" ]; then + echo "ERROR: Content mismatch in subdir1/subdir2/nested2.txt" + echo "Expected: 'File in subdir2'" + echo "Got: '$CONTENT4'" + exit 1 + fi + echo "archive1/subdir1/subdir2/nested2.txt content verified" + + echo "Testing archive2..." + if [ ! -f "mountpoint/archive2/test2.txt" ]; then + echo "ERROR: test2.txt not found in archive2" + exit 1 + fi + + CONTENT5=$(cat mountpoint/archive2/test2.txt) + if [ "$CONTENT5" != "Hello from test2.txt" ]; then + echo "ERROR: Content mismatch in test2.txt" + echo "Expected: 'Hello from test2.txt'" + echo "Got: '$CONTENT5'" + exit 1 + fi + echo "archive2/test2.txt content verified" + + CONTENT6=$(cat mountpoint/archive2/another.txt) + if [ "$CONTENT6" != "Another file in test2" ]; then + echo "ERROR: Content mismatch in another.txt" + echo "Expected: 'Another file in test2'" + echo "Got: '$CONTENT6'" + exit 1 + fi + echo "archive2/another.txt content verified" + + CONTENT7=$(cat mountpoint/archive2/docs/document.txt) + if [ "$CONTENT7" != "Document file" ]; then + echo "ERROR: Content mismatch in docs/document.txt" + echo "Expected: 'Document file'" + echo "Got: '$CONTENT7'" + exit 1 + fi + echo "archive2/docs/document.txt content verified" + + CONTENT8=$(cat mountpoint/archive2/docs/reports/report.txt) + if [ "$CONTENT8" != "Report file" ]; then + echo "ERROR: Content mismatch in docs/reports/report.txt" + echo "Expected: 'Report file'" + echo "Got: '$CONTENT8'" + exit 1 + fi + echo "archive2/docs/reports/report.txt content verified" + + echo "All file content tests passed!" + + - name: Unmount filesystem + if: always() + run: | + if ! sudo fusermount3 -u mountpoint; then + echo "ERROR: Filesystem unmount has failed" + exit 1 + fi + + for i in {1..10}; do + if ! mountpoint -q mountpoint; then + echo "Filesystem unmounted successfully" + break + fi + sleep 0.5 + done + + for i in {1..10}; do + if ! pgrep -fa zipfuse &>/dev/null; then + echo "Filesystem binary exited successfully" + exit 0 + fi + sleep 0.5 + done + + echo "ERROR: Filesystem binary seems stuck after unmount" + exit 1 + + - name: Verify unmounted + if: always() + run: | + if mountpoint -q mountpoint; then + echo "ERROR: Filesystem still mounted after fusermount3" + mount | grep zipfuse || true + exit 1 + fi + + if pgrep -fa zipfuse &>/dev/null; then + echo "ERROR: Filesystem binary still alive after unmount" + pgrep -fa zipfuse || true + exit 1 + fi + + if [ -f "mountpoint/archive1/test1.txt" ]; then + echo "ERROR: Files still accessible after unmount" + exit 1 + fi + + echo "Filesystem properly unmounted" + + - name: Show logs on failure + if: failure() + run: | + echo "=== Mount points ===" + mount | grep zipfuse || echo "No zipfuse mounts found" + echo "" + + echo "=== Mountpoint directory ===" + ls -la mountpoint/ || true + echo "" + + echo "=== Process list ===" + ps aux | grep zipfuse || echo "No zipfuse processes found" + echo "" + + echo "=== Dmesg (last 25 lines) ===" + sudo dmesg | tail -n 25 || true + echo "" + + echo "=== ZipFUSE Events (last 25 lines) ===" + sudo cat /var/log/zipfuse.log | tail -n 25 || true diff --git a/README.md b/README.md index afab0f4..c192060 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,17 @@
Logo

- + Release Go Version Go Reference Go Report License
- Lint Tests - Build + Integration CLI + Integration Fstab

>**Note: This software is under active development.** @@ -138,6 +138,14 @@ sudo mount -t zipfuse /home/alice/zips /home/alice/zipfuse -o setuid=alice,allow /home/alice/zips /home/alice/zipfuse zipfuse setuid=alice,allow_other,webserver=:8000 0 0 ``` +**Additional mount options to control mount helper behavior itself:** +``` +setuid=USER (as username or UID; overrides executing user) +xbin=/full/path/to/zipfuse/binary (overrides filesystem binary) +xlog=/full/path/to/writeable/logfile (overrides filesystem logfile) +xtim=SECS (numeric and in seconds; overrides filesystem mount timeout) +``` + **As you can see, program options (read more below) need format conversion:** `--allow-other --webserver :8000` is turning into `allow_other,webserver=:8000` diff --git a/cmd/mount.zipfuse/exec.go b/cmd/mount.zipfuse/exec.go index ac8d275..95d4466 100644 --- a/cmd/mount.zipfuse/exec.go +++ b/cmd/mount.zipfuse/exec.go @@ -1,8 +1,9 @@ -//nolint:mnd,noctx +//nolint:mnd,noctx,err113 package main import ( "bufio" + "encoding/json" "errors" "fmt" "io" @@ -16,7 +17,15 @@ import ( "al.essio.dev/pkg/shellescape" ) -var errMountTimeout = errors.New("mount timeout") +const ( + signalMountSuccess byte = 0 + signalMountFailed byte = 1 +) + +var ( + errMountTimeout = errors.New("mount timeout") + errMountFailed = errors.New("mount failed") +) func (mh *mountHelper) BuildCommand() []string { var parts []string @@ -75,13 +84,15 @@ func (mh *mountHelper) Execute() error { fdnull, err := os.OpenFile("/dev/null", os.O_RDWR, 0) if err != nil { - return fmt.Errorf("failed to open /dev/null: %w", err) + return fmt.Errorf("failed to open \"/dev/null\": %w", err) } defer fdnull.Close() - fdlog, err := os.OpenFile(mountLog, os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0o640) + fdlog, err := os.OpenFile(mh.Logfile, os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0o640) if err != nil { - fmt.Fprintf(os.Stderr, "warning: failed to open %q: %v (falling back to '/dev/null')\n", mountLog, err) + fmt.Fprintf(os.Stderr, `mount.zipfuse warning: failed to open %q: %v (falling back to "/dev/null"). +Do try to pass "xlog=/full/path/to/writeable/logfile" as a mount option. +`, mh.Logfile, err) cmd.Stdin, cmd.Stdout, cmd.Stderr = fdnull, fdnull, fdnull } else { defer fdlog.Close() @@ -136,7 +147,8 @@ func (mh *mountHelper) setUID(spa *syscall.SysProcAttr, cmd *exec.Cmd, cmdArgs [ Gid: gid, } } else { - fmt.Fprintf(os.Stderr, "warning: failed to resolve setuid %q: %v (falling back to 'su')\n", mh.Setuid, err) + fmt.Fprintf(os.Stderr, "mount.zipfuse warning: failed to resolve user %q: %v (falling back to \"su\")\n", + mh.Setuid, err) safeCmdArgs := make([]string, len(cmdArgs)) for i, arg := range cmdArgs { @@ -156,28 +168,21 @@ func (mh *mountHelper) setUID(spa *syscall.SysProcAttr, cmd *exec.Cmd, cmdArgs [ } func (mh *mountHelper) waitForMount(r io.Reader) error { - signalDone := make(chan error, 1) - go func() { - defer close(signalDone) - buf := make([]byte, 1) - _, err := r.Read(buf) - if err == nil { - signalDone <- nil - } else { - signalDone <- err - } - }() + signalDone := mh.waitForSignal(r) ticker := time.NewTicker(200 * time.Millisecond) defer ticker.Stop() - totalTimeout := time.After(mountTimeout) + totalTimeout := time.After(mh.Timeout) for { select { case signalErr := <-signalDone: if signalErr == nil { return nil + } else if errors.Is(signalErr, errMountFailed) { + return signalErr } + fmt.Fprintf(os.Stderr, "mount.zipfuse warning: %v\n", signalErr) signalDone = nil case <-ticker.C: @@ -195,10 +200,61 @@ func (mh *mountHelper) waitForMount(r io.Reader) error { } } +func (mh *mountHelper) waitForSignal(r io.Reader) <-chan error { + signalChan := make(chan error, 1) + + go func() { + defer func() { + rec := recover() + if rec != nil { + select { + case signalChan <- fmt.Errorf("panic recovered: %v", rec): + default: + } + } + close(signalChan) + }() + + status := make([]byte, 1) + if _, err := io.ReadFull(r, status); err != nil { + signalChan <- fmt.Errorf("failed to read from pipe: %w", err) + + return + } + + switch status[0] { + case signalMountSuccess: + signalChan <- nil + + case signalMountFailed: + scanner := bufio.NewScanner(r) + if scanner.Scan() { + var msg string + err := json.Unmarshal(scanner.Bytes(), &msg) + if err != nil { + signalChan <- fmt.Errorf("failed to unmarshal from pipe: %w", err) + + return + } + signalChan <- fmt.Errorf("%w: %s", errMountFailed, msg) + } else if err := scanner.Err(); err != nil { + signalChan <- fmt.Errorf("failed to parse from pipe: %w", err) + } else { + signalChan <- errors.New("malformed signal from pipe: unknown error") + } + + default: + signalChan <- errors.New("malformed signal from pipe: unknown status") + } + }() + + return signalChan +} + func (mh *mountHelper) checkMountTable() (bool, error) { f, err := os.Open("/proc/self/mountinfo") if err != nil { - return false, fmt.Errorf("cannot open /proc/self/mountinfo: %w", err) + return false, fmt.Errorf("cannot open \"/proc/self/mountinfo\": %w", err) } defer f.Close() @@ -211,7 +267,7 @@ func (mh *mountHelper) checkMountTable() (bool, error) { } if err := scanner.Err(); err != nil { - return false, fmt.Errorf("error reading /proc/self/mountinfo: %w", err) + return false, fmt.Errorf("error reading \"/proc/self/mountinfo\": %w", err) } return false, nil diff --git a/cmd/mount.zipfuse/help.go b/cmd/mount.zipfuse/help.go new file mode 100644 index 0000000..d7c52c9 --- /dev/null +++ b/cmd/mount.zipfuse/help.go @@ -0,0 +1,41 @@ +package main + +const ( + helpTextLong = `%s (%s) - FUSE mount helper + +This program is a helper for the mount/fstab mechanism. +It is normally located in /sbin or another directory +searched by mount(8) for filesystem helpers, and is +not intended to be invoked directly by the end users. + +Usage: + %s source mountpoint [-o key[=value],key[=value],...] + +For running the filesystem as another (e.g. unprivileged) user: + %s source mountpoint -o setuid=USER[,key[=value],...] + +Example (fstab entry): + /mnt/zips /mnt/zipfuse zipfuse allow_other,webserver=:8000 0 0 + +Additional mount options to control mount helper behavior itself: + setuid=USER (as username or UID; overrides executing user) + xbin=/full/path/to/zipfuse/binary (overrides filesystem binary) + xlog=/full/path/to/writeable/logfile (overrides filesystem logfile) + xtim=SECS (numeric and in seconds; overrides filesystem mount timeout) + +Filesystem-specific options need to be adapted into this format: + --webserver :8000 --strict-cache => webserver=:8000,strict_cache + +Note that FUSE mount helper events are printed to standard error (stderr). +Filesystem events are printed to %q (if it is writeable).` + + helpErrNotFound = `mount.zipfuse error: zipfuse not found within $PATH dirs. +Perhaps you installed it into some non-standard directory? +Some operating systems also mangle the environment variable. +Do try to pass "xbin=/full/path/to/binary" as a mount option.` + + helpErrMountTimeout = `mount.zipfuse error: mount did not appear within %d seconds. +You can raise this timeout by passing "xtim=SECS" as a mount option. +But beware default timeouts usually suffice and indicate error conditions. +So first do try checking %q for more (error) information.` +) diff --git a/cmd/mount.zipfuse/main.go b/cmd/mount.zipfuse/main.go index e94809e..28351d5 100644 --- a/cmd/mount.zipfuse/main.go +++ b/cmd/mount.zipfuse/main.go @@ -15,11 +15,17 @@ For running the filesystem as another (e.g. unprivileged) user: Example (fstab entry): /mnt/zips /mnt/zipfuse zipfuse allow_other,webserver=:8000 0 0 +Additional mount options to control mount helper behavior itself: + setuid=USER (as username or UID; overrides executing user) + xbin=/full/path/to/zipfuse/binary (overrides filesystem binary) + xlog=/full/path/to/writeable/logfile (overrides filesystem logfile) + xtim=SECS (numeric and in seconds; overrides filesystem mount timeout) + Filesystem-specific options need to be adapted into this format: --webserver :8000 --strict-cache => webserver=:8000,strict_cache -Mount helper events are printed to standard error (stderr). -FS events are printed to '/var/log/zipfuse.log' (if writeable). +Note that FUSE mount helper events are printed to standard error (stderr). +Filesystem events are printed to "/var/log/zipfuse.log" (if it is writeable). */ //nolint:mnd,err113 package main @@ -30,13 +36,15 @@ import ( "os" "os/exec" "path/filepath" + "strconv" "strings" "time" ) const ( - mountLog = "/var/log/zipfuse.log" - mountTimeout = 20 * time.Second + defaultType = "zipfuse" + defaultLogfile = "/var/log/zipfuse.log" + defaultTimeout = 20 * time.Second ) var ( @@ -70,15 +78,19 @@ type mountHelper struct { Mountpoint string Options map[string]string Setuid string + Logfile string + Timeout time.Duration } func newMountHelper(args []string) (*mountHelper, error) { mh := &mountHelper{ Program: args[0], Source: args[1], - Type: "zipfuse", + Type: defaultType, Mountpoint: args[2], Options: make(map[string]string), + Logfile: defaultLogfile, + Timeout: defaultTimeout, } if mh.Source == "" { @@ -139,11 +151,29 @@ func (mh *mountHelper) parseOptions(args []string) error { key := parts[0] val := parts[1] - if key == "bin" { + _, ok := allowedKeys[key] + + switch { + case key == "xbin": mh.Binary = val - } else if key == "setuid" { + + case key == "xlog": + mh.Logfile = val + + case key == "xtim": + secs, err := strconv.Atoi(val) + if err != nil { + return fmt.Errorf("failed to parse %q value %q: %w", key, val, err) + } + if secs <= 0 { + return fmt.Errorf("failed to use %q value %q: must be > 0", key, val) + } + mh.Timeout = time.Duration(secs) * time.Second + + case key == "setuid": mh.Setuid = val - } else if _, ok := allowedKeys[key]; ok { + + case ok: mh.Options[key] = val } } else { // key @@ -160,7 +190,7 @@ func (mh *mountHelper) parseOptions(args []string) error { func (mh *mountHelper) deriveTypeFromArg(i *int, args []string) error { *i++ if *i >= len(args) { - return errors.New("missing value to argument '-t'") + return errors.New("missing type value to argument \"-t\"") } t := args[*i] if after, ok := strings.CutPrefix(t, "fuse."); ok { @@ -169,7 +199,7 @@ func (mh *mountHelper) deriveTypeFromArg(i *int, args []string) error { t = after0 } if t == "" { - return errors.New("missing value to argument '-t'") + return errors.New("empty type value to argument \"-t\"") } mh.Type = t @@ -183,14 +213,14 @@ func (mh *mountHelper) deriveTypeFromSource() error { mh.Type = parts[0] mh.Source = parts[1] } else { - return errors.New("source argument is not in format 'type#source'") + return errors.New("source argument is not in format \"type#source\"") } if mh.Type == "" { - return errors.New("empty type before '#' in source argument") + return errors.New("empty type value before '#' in source argument") } if mh.Source == "" { - return errors.New("empty source after '#' in source argument") + return errors.New("empty source value after '#' in source argument") } return nil @@ -199,28 +229,8 @@ func (mh *mountHelper) deriveTypeFromSource() error { func main() { if len(os.Args) < 3 { progName := filepath.Base(os.Args[0]) - fmt.Fprintf(os.Stderr, `%s (%s) - FUSE mount helper - -This program is a helper for the mount/fstab mechanism. -It is normally located in /sbin or another directory -searched by mount(8) for filesystem helpers, and is -not intended to be invoked directly by the end users. - -Usage: - %s source mountpoint [-o key[=value],key[=value],...] - -For running the filesystem as another (e.g. unprivileged) user: - %s source mountpoint -o setuid=USER[,key[=value],...] - -Example (fstab entry): - /mnt/zips /mnt/zipfuse zipfuse allow_other,webserver=:8000 0 0 - -Filesystem-specific options need to be adapted into this format: - --webserver :8000 --strict-cache => webserver=:8000,strict_cache - -Mount helper events are printed to standard error (stderr). -FS events are printed to '%s' (if writeable). -`, progName, Version, progName, progName, mountLog) + fmt.Fprintf(os.Stderr, helpTextLong+"\n", + progName, Version, progName, progName, defaultLogfile) os.Exit(1) } @@ -234,15 +244,11 @@ FS events are printed to '%s' (if writeable). if err != nil { switch { case errors.Is(err, exec.ErrNotFound): - fmt.Fprintln(os.Stderr, `mount.zipfuse error: zipfuse not found within $PATH dirs. -Perhaps you installed it into some non-standard directory? -Some operating systems also mangle the environment variable. -Do try to pass 'bin=/full/path/to/binary' as a mount option.`) + fmt.Fprintln(os.Stderr, helpErrNotFound) case errors.Is(err, errMountTimeout): - fmt.Fprintf(os.Stderr, `mount.zipfuse error: mount did not appear within %d seconds. -Do try checking '/var/log/zipfuse.log' for more information. -`, int(mountTimeout.Seconds())) + fmt.Fprintf(os.Stderr, helpErrMountTimeout+"\n", + int(helper.Timeout.Seconds()), helper.Logfile) default: fmt.Fprintf(os.Stderr, "mount.zipfuse error: %v\n", err) diff --git a/cmd/mount.zipfuse/main_test.go b/cmd/mount.zipfuse/main_test.go index 2eb4ab2..cde2dc3 100644 --- a/cmd/mount.zipfuse/main_test.go +++ b/cmd/mount.zipfuse/main_test.go @@ -237,7 +237,7 @@ func Test_MountHelper_BuildCommand_Success(t *testing.T) { }, { name: "explicit binary path", - args: []string{"mount.zipfuse", "./source", "./dest", "-o", "bin=/bin/zipfuze"}, + args: []string{"mount.zipfuse", "./source", "./dest", "-o", "xbin=/bin/zipfuze"}, want: []string{"/bin/zipfuze", "./source", "./dest"}, }, { @@ -280,6 +280,11 @@ func Test_MountHelper_BuildCommand_Success(t *testing.T) { args: []string{"mount", "/mnt/a", "/mnt/b", "-t"}, wantErr: true, }, + { + name: "invalid xtim value", + args: []string{"mount", "/mnt/a", "/mnt/b", "-o", "xtim=0"}, + wantErr: true, + }, } for _, tt := range tests { diff --git a/cmd/zipfuse/help.go b/cmd/zipfuse/help.go index 829ae81..bf20a8c 100644 --- a/cmd/zipfuse/help.go +++ b/cmd/zipfuse/help.go @@ -23,4 +23,10 @@ When enabled, the diagnostics dashboard exposes the following routes: - "/set/must-crc32/" for adapting forced integrity checking - "/set/fd-cache-bypass/" for bypassing the file descriptor cache - "/set/stream-threshold/" for adapting of the streaming threshold` + + helpErrOptionsArg = `You have invoked this program with an "-o" flag, which is not supported. +Most likely you tried mounting as "fuse.zipfuse" using mount(8) or fstab? +If you wish to mount using mount(8) or fstab, use only "zipfuse" as type. +However that requires the helper "mount.zipfuse" be installed in "/sbin". +For more information, please read the INSTALL instructions or the README.` ) diff --git a/cmd/zipfuse/main.go b/cmd/zipfuse/main.go index 67cd9a6..0a8bc9c 100644 --- a/cmd/zipfuse/main.go +++ b/cmd/zipfuse/main.go @@ -23,6 +23,7 @@ When enabled, the diagnostics server exposes the following routes over HTTP: package main import ( + "encoding/json" "errors" "fmt" "net/http" @@ -42,13 +43,19 @@ import ( ) const ( - stackTraceBufferSize = 1 << 24 + stackTraceBufferSize int = 1 << 24 + signalMountSuccess byte = 0 + signalMountFailed byte = 1 + signalDelimiter byte = '\n' ) var ( // Version is the program version (filled in from the Makefile). Version string + // mountHelperNotified is for when the FUSE mount helper was notified. + mountHelperNotified bool + // errInvalidArgument is for an invalid CLI argument/value provided. errInvalidArgument = errors.New("invalid argument") @@ -162,6 +169,7 @@ func mountFilesystem(opts cliOptions, fsys *filesystem.FS) (*fuse.Conn, error) { mountOpts := []fuse.MountOption{ fuse.FSName("zipfuse"), fuse.ReadOnly(), + fuse.DefaultPermissions(), fuse.MaxReadahead(uint32(opts.streamPoolSize)), } if opts.allowOther { @@ -177,7 +185,14 @@ func mountFilesystem(opts cliOptions, fsys *filesystem.FS) (*fuse.Conn, error) { return conn, nil } -func notifyMountHelper() error { +// The FUSE mount helper passes the write end of a [os.Pipe] as file descriptor. +// We use the pipe to signal either success or failure to the FUSE mount helper. +// The argument is the error (or nil = success) that will be sent over the pipe. +func notifyMountHelper(e error) error { + if mountHelperNotified { + return nil + } + fdStr := os.Getenv("ZIPFUSE_HELPER_FD") if fdStr == "" { return nil @@ -185,17 +200,34 @@ func notifyMountHelper() error { fd, err := strconv.Atoi(fdStr) if err != nil { - return fmt.Errorf("fd conversion failed: %w", err) + return fmt.Errorf("fd conversion failed for %q: %w", fdStr, err) } f := os.NewFile(uintptr(fd), "helper-pipe") + if f == nil { + return fmt.Errorf("opening pipe failed for %q (fd=%d): (nil)", fdStr, fd) //nolint:err113 + } defer f.Close() - _, err = f.Write([]byte{1}) - if err != nil { - return fmt.Errorf("write to pipe failed: %w", err) + if e == nil { + if _, err := f.Write([]byte{signalMountSuccess}); err != nil { + return fmt.Errorf("write success status to pipe failed: %w", err) + } + } else { + if _, err := f.Write([]byte{signalMountFailed}); err != nil { + return fmt.Errorf("write error status to pipe failed: %w", err) + } + msg, err := json.Marshal(e.Error()) + if err != nil { + return fmt.Errorf("marshal error message for pipe failed: %w", err) + } + if _, err := f.Write(append(msg, signalDelimiter)); err != nil { + return fmt.Errorf("write error message to pipe failed: %w", err) + } } + mountHelperNotified = true + return nil } @@ -250,7 +282,21 @@ func cleanupMount(mountDir string, conn *fuse.Conn, fsys *filesystem.FS) { } func main() { + for _, arg := range os.Args { + if arg == "-o" { + fmt.Fprintln(os.Stderr, helpErrOptionsArg) + err := notifyMountHelper(fmt.Errorf("%w: \"-o\" is not supported", errInvalidArgument)) + if err != nil { + fmt.Fprintf(os.Stderr, "failed to notify mount helper: %v\n", err) + } + os.Exit(1) + } + } if err := rootCmd().Execute(); err != nil { + err := notifyMountHelper(err) + if err != nil { + fmt.Fprintf(os.Stderr, "failed to notify mount helper: %v\n", err) + } os.Exit(1) } } @@ -274,7 +320,7 @@ func run(opts cliOptions) error { } defer cleanupMount(opts.mountDir, conn, fsys) - err = notifyMountHelper() + err = notifyMountHelper(nil) if err != nil { rbuf.Printf("failed to notify mount helper: %v\n", err) } diff --git a/cmd/zipfuse/util.go b/cmd/zipfuse/util.go index 9b4d62f..d682db4 100644 --- a/cmd/zipfuse/util.go +++ b/cmd/zipfuse/util.go @@ -44,7 +44,7 @@ func fdLimits() (fsLimit int, cacheLimit int, err error) { cacheLimit = (fsLimit * 70) / 100 // 70% of FS limit if fsLimit < 1 || cacheLimit < 1 { - return 0, 0, fmt.Errorf("calculations too small (soft=%d)", osLimit) + return 0, 0, fmt.Errorf("calculations too small (os=%d)", osLimit) } return fsLimit, cacheLimit, nil